├── speedtools ├── blender │ ├── __init__.py │ ├── blender_manifest.toml │ └── io_nfs4_import.py ├── specs │ ├── heights_sim.ksy │ ├── qfs.ksy │ ├── cam.ksy │ ├── can.ksy │ ├── viv.ksy │ ├── README.md │ ├── fsh.ksy │ ├── fce.ksy │ └── frd.ksy ├── __init__.py ├── parsers │ └── __init__.py ├── can_data.py ├── cam_data.py ├── speedtool.py ├── refpack.py ├── tr_ini.py ├── fsh_data.py ├── utils.py ├── carp_data.py ├── types.py ├── viv_data.py ├── frd_data.py └── track_data.py ├── .github └── workflows │ ├── python-publish.yml │ ├── basic-checks.yml │ └── build-extension.yml ├── README.md ├── setup.py ├── pyproject.toml ├── .gitignore └── LICENSE /speedtools/blender/__init__.py: -------------------------------------------------------------------------------- 1 | from .io_nfs4_import import register, unregister 2 | 3 | __all__ = ["register", "unregister"] 4 | -------------------------------------------------------------------------------- /speedtools/specs/heights_sim.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: sim 3 | file-extension: sim 4 | endian: le 5 | seq: 6 | - id: heights 7 | type: f4 8 | repeat: eos 9 | -------------------------------------------------------------------------------- /speedtools/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from speedtools.refpack import Refpack 8 | from speedtools.track_data import TrackData 9 | from speedtools.types import CollisionType, ObjectType 10 | from speedtools.viv_data import VivData 11 | 12 | __all__ = [ 13 | "TrackData", 14 | "VivData", 15 | "Refpack", 16 | "CollisionType", 17 | "ObjectType", 18 | ] 19 | -------------------------------------------------------------------------------- /speedtools/specs/qfs.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: qfs 3 | file-extension: qfs 4 | license: CC0-1.0 5 | imports: 6 | - fsh 7 | endian: be 8 | seq: 9 | - id: magic 10 | contents: [0x10, 0xFB] 11 | doc: Pack code indicating LZ77-compressed file 12 | - id: expanded_length 13 | type: b24 14 | doc: Data length after decompression 15 | - id: data 16 | size-eos: true 17 | process: speedtools.refpack(expanded_length) 18 | type: fsh 19 | doc: Data compressed with LZ77 algorithm (RefPack) 20 | -------------------------------------------------------------------------------- /speedtools/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from .cam import Cam as CamParser 8 | from .can import Can as CanParser 9 | from .fce import Fce as FceParser 10 | from .frd import Frd as FrdParser 11 | from .fsh import Fsh as FshParser 12 | from .qfs import Qfs as QfsParser 13 | from .sim import Sim as HeightsParser 14 | from .viv import Viv as VivParser 15 | 16 | __all__ = [ 17 | "FrdParser", 18 | "FshParser", 19 | "QfsParser", 20 | "VivParser", 21 | "FceParser", 22 | "CanParser", 23 | "CamParser", 24 | "HeightsParser", 25 | ] 26 | -------------------------------------------------------------------------------- /speedtools/specs/cam.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: cam 3 | file-extension: cam 4 | license: CC0-1.0 5 | endian: le 6 | seq: 7 | - id: num_cameras 8 | type: u4 9 | - id: cameras 10 | type: camera 11 | repeat: expr 12 | repeat-expr: num_cameras 13 | types: 14 | camera: 15 | seq: 16 | - id: type 17 | type: u4 18 | - id: location 19 | type: float3 20 | - id: transform 21 | type: f4 22 | repeat: expr 23 | repeat-expr: 9 24 | - id: unknown1 25 | type: f4 26 | - id: start_road_block 27 | type: u4 28 | - id: unknown2 29 | size: 4 30 | - id: end_road_block 31 | type: u4 32 | float3: 33 | seq: 34 | - id: x 35 | type: f4 36 | - id: y 37 | type: f4 38 | - id: z 39 | type: f4 40 | -------------------------------------------------------------------------------- /speedtools/blender/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | id = "speedtools" 3 | version = "0.22.99" 4 | name = "NFS4 resource importer" 5 | tagline = "Import NFS4 cars and tracks into Blender" 6 | maintainer = "Rafał Kuźnia " 7 | type = "add-on" 8 | website = "https://github.com/e-rk/speedtools" 9 | tags = ["Import-Export"] 10 | blender_version_min = "4.4.0" 11 | license = ["SPDX:GPL-3.0-or-later"] 12 | copyright = ["2023-2025 Rafał Kuźnia"] 13 | wheels = [ 14 | "./wheels/kaitaistruct-0.10-py2.py3-none-any.whl", 15 | "./wheels/more_itertools-10.7.0-py3-none-any.whl", 16 | "./wheels/parse-1.20.2-py2.py3-none-any.whl", 17 | "./wheels/speedtools-0.24.0-py3-none-any.whl", 18 | "./wheels/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", 19 | "./wheels/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", 20 | "./wheels/pillow-11.3.0-cp311-cp311-win_amd64.whl", 21 | ] 22 | 23 | [permissions] 24 | files = "Import resources from disk" 25 | -------------------------------------------------------------------------------- /speedtools/specs/can.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: can 3 | file-extension: can 4 | license: CC0-1.0 5 | endian: le 6 | seq: 7 | - id: head 8 | type: u2 9 | - id: type 10 | type: u1 11 | - id: identifier 12 | type: u1 13 | - id: num_keyframes 14 | type: u2 15 | - id: delay 16 | type: u2 17 | - id: keyframes 18 | type: keyframe 19 | repeat: expr 20 | repeat-expr: num_keyframes 21 | types: 22 | keyframe: 23 | seq: 24 | - id: location 25 | type: int3 26 | - id: quaternion 27 | type: short4 28 | int3: 29 | seq: 30 | - id: ix 31 | type: s4 32 | - id: iy 33 | type: s4 34 | - id: iz 35 | type: s4 36 | instances: 37 | x: 38 | value: ix * 0.7692307692307693 / 65536 39 | y: 40 | value: iy * 0.7692307692307693 / 65536 41 | z: 42 | value: iz * 0.7692307692307693 / 65536 43 | short4: 44 | seq: 45 | - id: x 46 | type: s2 47 | - id: y 48 | type: s2 49 | - id: z 50 | type: s2 51 | - id: w 52 | type: s2 53 | -------------------------------------------------------------------------------- /speedtools/specs/viv.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: viv 3 | file-extension: viv 4 | license: CC0-1.0 5 | imports: 6 | - fce 7 | endian: be 8 | encoding: ASCII 9 | seq: 10 | - id: magic 11 | contents: [BIGF] 12 | - id: size 13 | type: u4 14 | doc: Size of the entire file 15 | - id: num_entries 16 | type: u4 17 | doc: Number of directory entries 18 | - id: unknown 19 | size: 4 20 | - id: entries 21 | type: directory_entry 22 | repeat: expr 23 | repeat-expr: num_entries 24 | doc: Directory entries 25 | types: 26 | directory_entry: 27 | seq: 28 | - id: offset 29 | type: u4 30 | doc: Absolute offset of associated data in file 31 | - id: length 32 | type: u4 33 | doc: Length of the associated data 34 | - id: name 35 | type: strz 36 | doc: Name of the directory entry 37 | instances: 38 | body: 39 | pos: offset 40 | type: 41 | switch-on: name 42 | cases: 43 | '"carp.txt"': strz 44 | '"car.fce"': fce 45 | '"dash.fce"': fce 46 | '"hel.fce"': fce 47 | size: length 48 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | deploy: 15 | name: Publish package 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: '3.x' 24 | - name: Install dependencies 25 | run: | 26 | curl -LO https://github.com/kaitai-io/kaitai_struct_compiler/releases/download/0.10/kaitai-struct-compiler_0.10_all.deb 27 | sudo apt-get install ./kaitai-struct-compiler_0.10_all.deb 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /speedtools/can_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from pathlib import Path 11 | 12 | from speedtools.parsers import CanParser 13 | from speedtools.types import Animation, Quaternion, Vector3d 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class CanData: 19 | def __init__(self, parser: CanParser) -> None: 20 | self.can = parser 21 | 22 | @classmethod 23 | def from_file(cls, path: Path) -> CanData: 24 | parser = CanParser.from_file(path) 25 | return cls(parser) 26 | 27 | @property 28 | def animation(self) -> Animation: 29 | can = self.can 30 | locations = [ 31 | Vector3d(x=keyframe.location.x, y=keyframe.location.y, z=keyframe.location.z) 32 | for keyframe in self.can.keyframes 33 | ] 34 | quaternions = [ 35 | Quaternion( 36 | x=keyframe.quaternion.x, 37 | y=keyframe.quaternion.y, 38 | z=keyframe.quaternion.z, 39 | w=keyframe.quaternion.w, 40 | ) 41 | for keyframe in self.can.keyframes 42 | ] 43 | return Animation( 44 | length=can.num_keyframes, delay=can.delay, locations=locations, quaternions=quaternions 45 | ) 46 | -------------------------------------------------------------------------------- /speedtools/cam_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from collections.abc import Iterable, Sequence 11 | from pathlib import Path 12 | 13 | from more_itertools import chunked, strictly_n, transpose 14 | 15 | from speedtools.parsers import CamParser 16 | from speedtools.types import Camera, Matrix3x3, Vector3d 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class CamData: 22 | def __init__(self, parser: CamParser) -> None: 23 | self.cam = parser 24 | 25 | @classmethod 26 | def from_file(cls, path: Path) -> CamData: 27 | parser = CamParser.from_file(path) 28 | return cls(parser) 29 | 30 | @classmethod 31 | def _make_matrix(cls, value: Sequence[float]) -> Matrix3x3: 32 | val = list(strictly_n(value, 9)) 33 | rows = [Vector3d(x=x, y=y, z=z) for x, y, z in transpose(chunked(val, 3, strict=True))] 34 | return Matrix3x3(x=rows[0], y=rows[1], z=rows[2]) 35 | 36 | @classmethod 37 | def _make_camera(cls, camera: CamParser.Camera) -> Camera: 38 | location = Vector3d(x=camera.location.x, y=camera.location.y, z=camera.location.z) 39 | transform = cls._make_matrix(camera.transform) 40 | return Camera(location=location, transform=transform) 41 | 42 | @property 43 | def cameras(self) -> Iterable[Camera]: 44 | return map(self._make_camera, self.cam.cameras) 45 | -------------------------------------------------------------------------------- /speedtools/specs/README.md: -------------------------------------------------------------------------------- 1 | # Format specs files 2 | This directory contains [Kaitai Struct](https://kaitai.io/) declarative descriptions of file formats used by NFSHS. See the project's [User Guide](https://doc.kaitai.io/user_guide.html) to learn how to use the specs files. 3 | 4 | The specs can be quite conveniently developed using the [Web IDE](https://ide.kaitai.io/). Here are some points to look out for: 5 | - It is not possible to unpack RefPack compressed data in the Web IDE. The decompressed FSH data should be written to disk and parsed as a separate file. 6 | 7 | # License 8 | The specs files are distributed under the terms of Creative Commons CC0-1.0. 9 | 10 | # File format references 11 | The project was not made in a vacuum. It is based on previous work of numerous people who took lots of time to reverse-engineer the file fromats and share their findings. 12 | 13 | The following resources were used: 14 | - [UNOFFICIAL NEED FOR SPEED III FILE FORMAT SPECIFICATIONS - Version 1.0](https://sites.google.com/site/2torcs/labs/need-for-speed-3---hot-pursuit/nfs3-the-unofficial-file-format-descriptions) 15 | - T3ED documentation by Denis Auroux and Vitaly Kootin 16 | - Source code of [LWO2FRD](https://github.com/OpenNFS/OpenNFS/files/6658908/FRD.2.LWO.zip) by KROM 17 | - Source code of [OpenNFS](https://github.com/OpenNFS/OpenNFS) by Amrik Sadhra 18 | - Source code of FHSTool by Denis Auroux 19 | - [NFS Modder's Corner](https://nfsmodderscorner.blogspot.com/p/need-for-speed-high-stakes.html) by AJ_Lethal 20 | - RefPack description on [Niotso Wiki](http://wiki.niotso.org/RefPack) 21 | - and probably many others 22 | -------------------------------------------------------------------------------- /speedtools/speedtool.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | import logging 8 | from collections.abc import Sequence 9 | from fnmatch import fnmatch 10 | from pathlib import Path 11 | from typing import Any 12 | 13 | import click 14 | 15 | from speedtools.fsh_data import FshData 16 | from speedtools.utils import ( 17 | export_resource, 18 | make_horizon_texture, 19 | unique_named_resources, 20 | ) 21 | 22 | logger = logging.getLogger() 23 | logger.setLevel(logging.INFO) 24 | sh = logging.StreamHandler() 25 | logger.addHandler(sh) 26 | 27 | 28 | @click.group() 29 | def main() -> None: 30 | pass 31 | 32 | 33 | @main.command() 34 | @click.option("--output", help="Output directory", type=click.Path(path_type=Path)) 35 | @click.argument("files", type=click.Path(path_type=Path), nargs=-1) 36 | def unpack(output: Path | None, files: Sequence[Path]) -> None: 37 | for file in files: 38 | logger.info(f"Unpacking: {file}") 39 | out = Path(file.stem) if output is None else output 40 | data = FshData.from_file(file) 41 | resources = unique_named_resources(data.resources) 42 | export_resource(resources, directory=out) 43 | 44 | 45 | @main.group() 46 | @click.argument("path", type=click.Path(path_type=Path)) 47 | @click.pass_context 48 | def track(ctx: Any, path: Path) -> None: 49 | ctx.ensure_object(dict) 50 | ctx.obj["path"] = path 51 | 52 | 53 | @track.group() 54 | @click.pass_context 55 | def sky(ctx: Any) -> None: 56 | pass 57 | 58 | 59 | @sky.command() 60 | @click.pass_context 61 | @click.option("--output", help="Output file", type=click.Path(path_type=Path)) 62 | def cubemap(ctx: Any, output: Path | None) -> None: 63 | sky_path = Path(ctx.obj["path"], "SKY.QFS") 64 | logger.info(f"Sky resource file: {sky_path}") 65 | data = FshData.from_file(sky_path) 66 | resources = list(filter(lambda x: fnmatch(x.name, "HDC?"), data.resources)) 67 | image = make_horizon_texture(resources) 68 | image.save("horizon.png", "png") 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This project provides data extraction utilities for the following Need for Speed 4 HS asset files: 4 | - tracks 5 | - cars 6 | 7 | Additionally, Blender addon is provided to directly import track and car data. 8 | 9 | # Recommended setup 10 | 11 | 1. Download the latest ZIP file from [Releases][5]. 12 | 2. Open Blender. 13 | 3. Go to `Edit -> Preferences -> Get Extensions`. 14 | 4. Click `v` in the top right corner of the window. 15 | 5. Click `Install from Disk...`. 16 | 6. Select the downloaded ZIP file. 17 | 7. Activate the addon in the `Add-ons` panel. 18 | 19 | # Old setup instructions 20 | 21 | > **Warning**: Use instructions from this section only if you have problems with the recommended setup. 22 | 23 | 1. Create new empty Blender project 24 | 2. Open the __Scripting__ tab 25 | 3. Copy-paste the following two commands into the Blender console: 26 | ``` 27 | import sys, subprocess 28 | subprocess.call([sys.executable, "-m", "pip", "install", "speedtools"]) 29 | ``` 30 | This command will install the [`speedtools`][1] package to your Blender Python installation. 31 | 32 | > **Note**: Python installation that comes with Blender is completely separate from the global Python installation on your system. For this reason, it is necessary to use the Blender scripting console to install the package correctly. 33 | 4. Copy and paste the content of [this][2] file to the Blender scripting window. 34 | 5. Click the __▶__ button. 35 | 6. You should see `Track resources` and `Car resources` under `File > Import`. 36 | 37 | # Setup (command line version) 38 | 39 | Install the package from PyPI: 40 | ``` 41 | pip install speedtools 42 | ``` 43 | 44 | Currently, the command line version does not provide any useful functionality. 45 | 46 | # Development dependencies 47 | 48 | This setup is needed only if you plan to modify the add-on. 49 | 50 | To develop the project the following dependencies must be installed on your system: 51 | * [Kaitai Struct compiler][3] 52 | 53 | Make sure the binary directories are in your `PATH`. 54 | 55 | # Re-make project 56 | 57 | This tool is a part of the re-make project. The project source code is available [here][4]. 58 | 59 | [1]: https://pypi.org/project/speedtools/ 60 | [2]: https://github.com/e-rk/speedtools/blob/master/speedtools/blender/io_nfs4_import.py 61 | [3]: https://kaitai.io/ 62 | [4]: https://github.com/e-rk/velocity 63 | [5]: https://github.com/e-rk/speedtools/releases 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from pathlib import Path 8 | 9 | import setuptools 10 | import setuptools.command.build 11 | from setuptools import Command 12 | from setuptools.errors import ExecError 13 | 14 | 15 | class build_ksy(Command): 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self.build_lib = None 19 | self.editable_mode = False 20 | 21 | def initialize_options(self): 22 | self.files = {} 23 | self.package = [] 24 | self.compiler = None 25 | 26 | def finalize_options(self): 27 | self.set_undefined_options("build_py", ("build_lib", "build_lib")) 28 | self.packages = self.distribution.packages 29 | for package in self.packages: 30 | package_path = Path(*(package.split("."))) 31 | for source_file in Path(package_path, "specs").glob("*.ksy"): 32 | build_file = Path(self.build_lib, package_path, source_file.name) 33 | self.files[build_file] = source_file 34 | 35 | def run(self): 36 | for build, source in self.files.items(): 37 | target_path = ( 38 | Path(build.parent, "parsers") 39 | if not self.editable_mode 40 | else Path(source.parent.parent, "parsers") 41 | ) 42 | args = [ 43 | "--outdir", 44 | str(target_path), 45 | "-t", 46 | "python", 47 | "--python-package", 48 | "speedtools.parsers", 49 | str(source), 50 | ] 51 | executables = ["ksc", "kaitai-struct-compiler", "kaitai-struct-compiler.bat"] 52 | for executable in executables: 53 | try: 54 | self.spawn([executable] + args) 55 | except ExecError: 56 | pass 57 | 58 | def get_output_mapping(self): 59 | mapping = {} 60 | for key, value in self.files.items(): 61 | mapping[str(key)] = str(value) 62 | return mapping 63 | 64 | def get_outputs(self): 65 | return list(map(str, self.files.keys())) 66 | 67 | def get_source_files(self): 68 | return list(map(str, self.files.values())) 69 | 70 | 71 | setuptools.command.build.build.sub_commands.append(("build_ksy", None)) 72 | 73 | setuptools.setup(cmdclass={"build_ksy": build_ksy}) 74 | -------------------------------------------------------------------------------- /.github/workflows/basic-checks.yml: -------------------------------------------------------------------------------- 1 | name: Basic checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: ["master"] 9 | 10 | jobs: 11 | lint: 12 | name: Pylint 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.12", "3.13"] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | curl -LO https://github.com/kaitai-io/kaitai_struct_compiler/releases/download/0.10/kaitai-struct-compiler_0.10_all.deb 26 | sudo apt-get install ./kaitai-struct-compiler_0.10_all.deb 27 | python -m pip install --upgrade pip 28 | python -m pip install fake-bpy-module-4.3 pylint 29 | pip install . 30 | - name: Run lint 31 | run: | 32 | pylint speedtools 33 | 34 | format: 35 | name: Format 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | python-version: ["3.12", "3.13"] 40 | steps: 41 | - uses: actions/checkout@v3 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v3 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | - name: Install dependencies 47 | run: | 48 | curl -LO https://github.com/kaitai-io/kaitai_struct_compiler/releases/download/0.10/kaitai-struct-compiler_0.10_all.deb 49 | sudo apt-get install ./kaitai-struct-compiler_0.10_all.deb 50 | python -m pip install --upgrade pip 51 | python -m pip install fake-bpy-module-4.3 52 | pip install . 53 | - uses: psf/black@stable 54 | with: 55 | options: "--check --diff --verbose" 56 | src: "." 57 | - name: python-isort 58 | uses: isort/isort-action@v1.1.0 59 | 60 | mypy: 61 | name: Mypy 62 | runs-on: ubuntu-latest 63 | strategy: 64 | matrix: 65 | python-version: ["3.12", "3.13"] 66 | steps: 67 | - uses: actions/checkout@v3 68 | - name: Set up Python ${{ matrix.python-version }} 69 | uses: actions/setup-python@v3 70 | with: 71 | python-version: ${{ matrix.python-version }} 72 | - name: Install dependencies 73 | run: | 74 | curl -LO https://github.com/kaitai-io/kaitai_struct_compiler/releases/download/0.10/kaitai-struct-compiler_0.10_all.deb 75 | sudo apt-get install ./kaitai-struct-compiler_0.10_all.deb 76 | python -m pip install --upgrade pip 77 | python -m pip install fake-bpy-module-4.3 mypy 78 | pip install . 79 | - name: Correctness check 80 | run: | 81 | mypy -p speedtools --install-types --non-interactive 82 | -------------------------------------------------------------------------------- /.github/workflows/build-extension.yml: -------------------------------------------------------------------------------- 1 | name: Extension 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | tags: 8 | - "*" 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | extension: 14 | name: Build extension 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Git describe 18 | id: ghd 19 | uses: proudust/gh-describe@v2 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | curl -LO https://github.com/kaitai-io/kaitai_struct_compiler/releases/download/0.10/kaitai-struct-compiler_0.10_all.deb 28 | sudo apt-get install ./kaitai-struct-compiler_0.10_all.deb 29 | sudo snap install blender --classic 30 | - name: Build 31 | run: | 32 | cd speedtools/blender 33 | pip wheel ../.. -w wheels 34 | pip download pillow --dest ./wheels --only-binary=:all: --python-version=3.11 --platform=manylinux_2_28_x86_64 35 | pip download pillow --dest ./wheels --only-binary=:all: --python-version=3.11 --platform=macosx_11_0_arm64 36 | pip download pillow --dest ./wheels --only-binary=:all: --python-version=3.11 --platform=win_amd64 37 | ls wheels 38 | VERSION=${{ steps.ghd.outputs.describe }} 39 | sed -i "s/0.22.99/${VERSION:1}/g" blender_manifest.toml 40 | blender --command extension build 41 | - name: Upload extension 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: speedtools 45 | path: speedtools/blender/speedtools-*.zip 46 | 47 | install: 48 | needs: extension 49 | name: Basic extension test 50 | strategy: 51 | matrix: 52 | os: [windows-latest, ubuntu-latest] 53 | runs-on: ${{ matrix.os }} 54 | steps: 55 | - uses: actions/download-artifact@v4 56 | with: 57 | name: speedtools 58 | - if: ${{ matrix.os == 'ubuntu-latest' }} 59 | run: sudo snap install blender --classic 60 | - if: ${{ matrix.os == 'windows-latest' }} 61 | run: | 62 | choco install blender 63 | echo 'C:\Program Files\Blender Foundation\Blender 4.5\' | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 64 | - run: blender --command extension install-file -e -r user_default speedtools-*.zip 65 | 66 | release: 67 | name: Publish Release 68 | needs: extension 69 | runs-on: ubuntu-latest 70 | permissions: 71 | contents: write 72 | steps: 73 | - name: Git describe 74 | id: ghd 75 | uses: proudust/gh-describe@v2 76 | - name: Download artifact 77 | uses: actions/download-artifact@v4 78 | with: 79 | name: speedtools 80 | - name: Create draft release 81 | env: 82 | GH_TOKEN: ${{ github.token }} 83 | GH_REPO: ${{ github.repository }} 84 | run: gh release create '${{ steps.ghd.outputs.describe }}' --draft=true --generate-notes speedtools-*.zip 85 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "speedtools" 7 | version = "0.24.0" 8 | description = "NFS4 HS (PC) resource utilities" 9 | authors = [{ name = "Rafał Kuźnia" }, { email = "rafal.kuznia@protonmail.com" }] 10 | readme = { file = 'README.md', content-type = 'text/markdown' } 11 | dependencies = ["kaitaistruct", "pillow", "click", "more-itertools", "parse"] 12 | license = { text = "GPL-3.0-or-later" } 13 | classifiers = [ 14 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python', 17 | 'Programming Language :: Python :: 3', 18 | 'Programming Language :: Python :: 3.10', 19 | 'Programming Language :: Python :: 3.11', 20 | 'Programming Language :: Python :: 3 :: Only', 21 | 'Topic :: File Formats', 22 | 'Typing :: Typed', 23 | ] 24 | 25 | [project.scripts] 26 | speedtool-unpack = "speedtools.speedtool:unpack" 27 | speedtool = "speedtools.speedtool:main" 28 | 29 | [project.urls] 30 | homepage = "https://github.com/e-rk/speedtools" 31 | repository = "https://github.com/e-rk/speedtools" 32 | 33 | [tool.isort] 34 | profile = "black" 35 | 36 | [tool.black] 37 | line-length = 99 38 | 39 | [tool.mypy] 40 | python_version = 3.13 41 | strict = true 42 | exclude = ['^setup\.py$'] 43 | 44 | [[tool.mypy.overrides]] 45 | module = "speedtools.parsers.*" 46 | ignore_errors = true 47 | 48 | [[tool.mypy.overrides]] 49 | module = "speedtools.blender.*" 50 | strict = true 51 | disallow_untyped_calls = false 52 | 53 | [tool.pylint.main] 54 | # Files or directories to be skipped. They should be base names, not paths. 55 | ignore = ["parsers"] 56 | 57 | [tool.pylint.basic] 58 | good-names = [ 59 | "a", 60 | "b", 61 | "c", 62 | "d", 63 | "x", 64 | "y", 65 | "z", 66 | "i", 67 | "j", 68 | "k", 69 | "f", 70 | "_", 71 | "uv", 72 | "wm", 73 | ] 74 | 75 | [tool.pylint.design] 76 | # Maximum number of arguments for function / method. 77 | max-args = 8 78 | # Maximum number of attributes for a class (see R0902). 79 | max-attributes = 15 80 | # Maximum number of locals for function / method body. 81 | max-locals = 20 82 | # Minimum number of public methods for a class (see R0903). 83 | min-public-methods = 1 84 | 85 | [tool.pylint."messages control"] 86 | # Disable the message, report, category or checker with the given id(s). You can 87 | # either give multiple identifiers separated by comma (,) or put this option 88 | # multiple times (only on the command line, not in the configuration file where 89 | # it should appear only once). You can also use "--disable=all" to disable 90 | # everything first and then re-enable specific checks. For example, if you want 91 | # to run only the similarities checker, you can use "--disable=all 92 | # --enable=similarities". If you want to run only the classes checker, but have 93 | # no Warning level messages displayed, use "--disable=all --enable=classes 94 | # --disable=W". 95 | disable = [ 96 | "raw-checker-failed", 97 | "bad-inline-option", 98 | "locally-disabled", 99 | "file-ignored", 100 | "suppressed-message", 101 | "useless-suppression", 102 | "deprecated-pragma", 103 | "use-symbolic-message-instead", 104 | "missing-module-docstring", 105 | "missing-class-docstring", 106 | "logging-fstring-interpolation", 107 | "missing-function-docstring", 108 | "unused-argument", 109 | "too-many-positional-arguments", 110 | ] 111 | -------------------------------------------------------------------------------- /speedtools/refpack.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | import logging 8 | from collections.abc import Iterator 9 | from dataclasses import dataclass 10 | from struct import unpack 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @dataclass 16 | class Opcode: 17 | proclen: int 18 | reflen: int 19 | refdist: int 20 | 21 | @property 22 | def is_stop(self) -> bool: 23 | return self.proclen < 4 and self.reflen == 0 and self.refdist == 0 24 | 25 | 26 | class Refpack: 27 | def __init__(self, expanded_length: int): 28 | self.expanded_length = expanded_length 29 | logger.debug(self.expanded_length) 30 | 31 | def decode(self, compressed_data: bytes) -> bytes: 32 | decompressed_data = bytearray() 33 | 34 | for cmd, data in self._decode_cmd(compressed_data): 35 | logger.debug(f"Decompressing: {cmd}") 36 | decompressed_data.extend(data) 37 | 38 | for _ in range(cmd.reflen): 39 | decompressed_data.append(decompressed_data[-cmd.refdist]) 40 | 41 | if len(decompressed_data) != self.expanded_length: 42 | raise ValueError( 43 | f"Bad decompressed length {len(decompressed_data)} != {self.expanded_length}" 44 | ) 45 | 46 | return bytes(decompressed_data) 47 | 48 | def _decode_cmd(self, compressed_data: bytes) -> Iterator[tuple[Opcode, bytearray]]: 49 | current = bytearray(compressed_data) 50 | while True: 51 | (opcode,) = unpack(" Opcode: 78 | a, b = unpack("BB", opdata) 79 | proclen = a & 0x03 80 | reflen = ((a & 0x1C) >> 2) + 3 81 | refdist = ((a & 0x60) << 3) + b + 1 82 | return Opcode(proclen=proclen, refdist=refdist, reflen=reflen) 83 | 84 | def _decode_3b_cmd(self, opdata: bytearray) -> Opcode: 85 | a, b, c = unpack("BBB", opdata) 86 | proclen = (b & 0xC0) >> 6 87 | reflen = (a & 0x3F) + 4 88 | refdist = ((b & 0x3F) << 8) + c + 1 89 | return Opcode(proclen=proclen, refdist=refdist, reflen=reflen) 90 | 91 | def _decode_4b_cmd(self, opdata: bytearray) -> Opcode: 92 | a, b, c, d = unpack("BBBB", opdata) 93 | proclen = a & 0x03 94 | reflen = ((a & 0x0C) << 6) + d + 5 95 | refdist = ((a & 0x10) << 12) + (b << 8) + c + 1 96 | return Opcode(proclen=proclen, refdist=refdist, reflen=reflen) 97 | 98 | def _decode_1b_cmd(self, opdata: bytearray) -> Opcode: 99 | (a,) = unpack("B", opdata) 100 | if a < 0xFC: 101 | proclen = ((a & 0x1F) + 1) << 2 102 | else: 103 | proclen = a & 0x03 104 | return Opcode(proclen=proclen, refdist=0, reflen=0) 105 | -------------------------------------------------------------------------------- /speedtools/tr_ini.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from collections.abc import Iterator 11 | from configparser import ConfigParser 12 | from itertools import starmap 13 | from pathlib import Path 14 | 15 | from parse import parse, search # type: ignore[import-untyped] 16 | 17 | from speedtools.types import Color, Horizon, LightAttributes, SunAttributes 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class TrackIni: 23 | def __init__(self, parser: ConfigParser) -> None: 24 | self.parser = parser 25 | 26 | @classmethod 27 | def from_file(cls, path: Path) -> TrackIni: 28 | parser = ConfigParser() 29 | with open(path, "r", encoding="utf-8") as file: 30 | parser.read_file(file) 31 | return cls(parser=parser) 32 | 33 | @classmethod 34 | def _make_glow(cls, name: str, string: str) -> LightAttributes: 35 | no_spaces = string.replace(" ", "") 36 | results = search("[{:d},{:d},{:d},{:d}],{:d},{:d},{:d},{:f}", no_spaces) 37 | (identifier,) = parse("glow{:d}", name) 38 | alpha, red, green, blue, is_blinking, interval, _, flare_size = results 39 | color = Color(alpha=alpha, red=red, green=green, blue=blue) 40 | blink_interval = interval if is_blinking == 1 else None 41 | return LightAttributes( 42 | identifier=identifier, 43 | color=color, 44 | blink_interval_ms=blink_interval, 45 | flare_size=flare_size, 46 | ) 47 | 48 | @classmethod 49 | def _parse_color(cls, value: str) -> Color: 50 | red, green, blue = parse("[{:d},{:d},{:d}]", value) 51 | return Color(alpha=255, red=red, green=green, blue=blue) 52 | 53 | @property 54 | def glows(self) -> Iterator[LightAttributes]: 55 | return starmap(self._make_glow, self.parser["track glows"].items()) 56 | 57 | @property 58 | def sun(self) -> SunAttributes | None: 59 | try: 60 | sun = self.parser["sun"] 61 | if sun["hasSun"] == "0": 62 | return None 63 | return SunAttributes( 64 | angle_theta=float(sun["angleTheta"]), 65 | angle_rho=float(sun["angleRho"]), 66 | radius=float(sun["radius"]), 67 | rotates=sun.get("rotates", "0") != "0", 68 | additive=sun.get("additive", "0") != "0", 69 | in_front=sun.get("inFront", "0") != "0", 70 | ) 71 | except KeyError: 72 | return None 73 | 74 | @property 75 | def ambient_color(self) -> Color: 76 | light = self.parser["light"] 77 | red = int(light["AmbientRed"]) 78 | green = int(light["AmbientGreen"]) 79 | blue = int(light["AmbientBlue"]) 80 | red = (red * 255) // 100 81 | green = (green * 255) // 100 82 | blue = (blue * 255) // 100 83 | return Color(alpha=255, red=red, green=green, blue=blue) 84 | 85 | @property 86 | def horizon(self) -> Horizon: 87 | strip = self.parser["strip"] 88 | sun_side = self._parse_color(strip["hrzSunColor"]) 89 | top_side = self._parse_color(strip["hrzSkyTopColor"]) 90 | opposite_side = self._parse_color(strip["hrzOppositeSunColor"]) 91 | earth_bottom = opposite_side = self._parse_color(strip["hrzEarthBotColor"]) 92 | earth_top = opposite_side = self._parse_color(strip["hrzEarthTopColor"]) 93 | return Horizon( 94 | sun_side=sun_side, 95 | sun_top_side=top_side, 96 | sun_opposite_side=opposite_side, 97 | earth_bottom=earth_bottom, 98 | earth_top=earth_top, 99 | ) 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Project-specific 163 | speedtools/parsers 164 | !speedtools/parsers/__init__.py -------------------------------------------------------------------------------- /speedtools/specs/fsh.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: fsh 3 | file-extension: fsh 4 | license: CC0-1.0 5 | endian: le 6 | encoding: ASCII 7 | seq: 8 | - id: magic 9 | contents: [SHPI] 10 | - id: length 11 | type: u4 12 | - id: num_resources 13 | type: u4 14 | doc: Number of resources 15 | - id: directory_id_string 16 | contents: [GIMX] 17 | - id: resources 18 | type: resource(_index, _index == (num_resources - 1)) 19 | repeat: expr 20 | repeat-expr: num_resources 21 | types: 22 | resource: 23 | params: 24 | - id: index 25 | type: s4 26 | - id: is_last 27 | type: bool 28 | seq: 29 | - id: name 30 | type: str 31 | size: 4 32 | doc: Resource name 33 | - id: offset 34 | type: u4 35 | doc: Offset of the resource data in file 36 | instances: 37 | body: 38 | pos: offset 39 | type: resource_body 40 | size: body_size 41 | body_size: 42 | value: 'not is_last ? _parent.resources[index + 1].offset - offset : _root._io.size - offset' 43 | resource_body: 44 | seq: 45 | - id: blocks 46 | type: data_block 47 | repeat: until 48 | repeat-until: _.extra_offset == 0 49 | data_block: 50 | seq: 51 | - id: code 52 | type: u1 53 | enum: data_type 54 | doc: Data block type 55 | - id: extra_offset 56 | type: b24le 57 | doc: Offset of the next data block since the start of this data block 58 | - id: width 59 | type: u2 60 | doc: Width of the bitmap or length of text data 61 | - id: height 62 | type: u2 63 | doc: Height of the bitmap 64 | - id: data 65 | type: 66 | switch-on: code 67 | cases: 68 | data_type::text: strz 69 | _: bitmap 70 | size: 'is_last ? (_parent._io.size - _parent._io.pos) : (extra_offset - 8)' 71 | instances: 72 | is_last: 73 | value: extra_offset == 0 74 | bitmap: 75 | seq: 76 | - id: unknown 77 | type: u4 78 | - id: x_pos 79 | type: u2 80 | - id: y_pos 81 | type: u2 82 | - id: data 83 | type: 84 | switch-on: _parent.code 85 | cases: 86 | data_type::bitmap16: pixel_16_element 87 | data_type::bitmap8: u1 88 | data_type::bitmap32: pixel_32_element 89 | data_type::bitmap16_alpha: pixel_16_alpha_element 90 | data_type::palette: pixel_16_alpha_element 91 | repeat: expr 92 | repeat-expr: _parent.width * _parent.height 93 | pixel_32_element: 94 | seq: 95 | - id: value 96 | type: u4 97 | doc: Raw 32-bit pixel value 98 | instances: 99 | red: 100 | value: value & 0xff 101 | green: 102 | value: (value >> 8) & 0xff 103 | blue: 104 | value: (value >> 16) & 0xff 105 | alpha: 106 | value: (value >> 24) & 0xff 107 | color: 108 | value: 'blue + green * 0x100 + red * 0x10000 + alpha * 0x1000000' 109 | doc: ARGB color value 110 | pixel_16_element: 111 | seq: 112 | - id: value 113 | type: u2 114 | doc: Raw 16-bit pixel value 115 | instances: 116 | red: 117 | value: (value & 0x1f) * 8 118 | green: 119 | value: ((value >> 5) & 0x3f) * 4 120 | blue: 121 | value: ((value >> 11) & 0x1f) * 8 122 | alpha: 123 | value: 0xff 124 | color: 125 | value: 'blue + green * 0x100 + red * 0x10000 + alpha * 0x1000000' 126 | doc: ARGB color value 127 | pixel_16_alpha_element: 128 | seq: 129 | - id: value 130 | type: u2 131 | doc: Raw 16-bit pixel value 132 | instances: 133 | red: 134 | value: (value & 0x1f) * 8 135 | green: 136 | value: ((value >> 5) & 0x1f) * 8 137 | blue: 138 | value: ((value >> 10) & 0x1f) * 8 139 | alpha: 140 | value: '(value & 0x8000) != 0 ? 0xff : 0' 141 | color: 142 | value: 'blue + green * 0x100 + red * 0x10000 + alpha * 0x1000000' 143 | doc: ARGB color value 144 | enums: 145 | data_type: 146 | 0x78: bitmap16 147 | 0x7b: bitmap8 148 | 0x7d: bitmap32 149 | 0x7e: bitmap16_alpha 150 | 0x2d: palette 151 | 0x6f: text 152 | -------------------------------------------------------------------------------- /speedtools/fsh_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from collections.abc import Iterator 11 | from pathlib import Path 12 | from struct import pack 13 | from typing import Container 14 | 15 | from more_itertools import one, only 16 | from parse import search # type: ignore[import-untyped] 17 | 18 | from speedtools.parsers import FshParser, QfsParser 19 | from speedtools.types import Bitmap, BlendMode, FshDataType, Resource, SunAttributes 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class FshData: 25 | def __init__(self, fsh_parser: FshParser) -> None: 26 | self.fsh = fsh_parser 27 | 28 | @classmethod 29 | def from_file(cls, filename: Path) -> FshData: 30 | suffix = filename.suffix.lower() 31 | if suffix == ".qfs": 32 | parser = QfsParser.from_file(filename=filename).data 33 | elif suffix == ".fsh": 34 | parser = FshParser.from_file(filename=filename) 35 | else: 36 | raise ValueError("Invalid fsh file extension") 37 | return cls(fsh_parser=parser) 38 | 39 | @classmethod 40 | def _get_data_by_code( 41 | cls, codes: Container[FshDataType], resource: FshParser.DataBlock 42 | ) -> Iterator[FshParser.DataBlock]: 43 | return filter(lambda x: x.code in codes, resource.body.blocks) 44 | 45 | @classmethod 46 | def _make_bitmap(cls, resource: FshParser.Resource) -> tuple[Bitmap, FshDataType]: 47 | bitmap = one( 48 | cls._get_data_by_code( 49 | codes=( 50 | FshDataType.bitmap8, 51 | FshDataType.bitmap32, 52 | FshDataType.bitmap16, 53 | FshDataType.bitmap16_alpha, 54 | ), 55 | resource=resource, 56 | ) 57 | ) 58 | bitmap_type = bitmap.code 59 | if bitmap_type is FshDataType.bitmap8: 60 | palette = one(cls._get_data_by_code(codes=[FshDataType.palette], resource=resource)) 61 | palette_colors = [element.color for element in palette.data.data] 62 | rgba_int = [palette_colors[element] for element in bitmap.data.data] 63 | rgba_bytes = pack(f"<{len(rgba_int)}I", *rgba_int) 64 | bitmap_object = Bitmap( 65 | width=bitmap.width, 66 | height=bitmap.height, 67 | data=rgba_bytes, 68 | ) 69 | elif bitmap_type in ( 70 | FshDataType.bitmap32, 71 | FshDataType.bitmap16, 72 | FshDataType.bitmap16_alpha, 73 | ): 74 | rgba_int = [elem.color for elem in bitmap.data.data] 75 | rgba_bytes = pack(f"<{len(rgba_int)}I", *rgba_int) 76 | bitmap_object = Bitmap( 77 | width=bitmap.width, 78 | height=bitmap.height, 79 | data=rgba_bytes, 80 | ) 81 | else: 82 | raise RuntimeError("Bitmap resource not recognized") 83 | return bitmap_object, bitmap_type 84 | 85 | @classmethod 86 | def _make_resource(cls, resource: FshParser.Resource) -> Resource: 87 | bitmap, bitmap_type = cls._make_bitmap(resource) 88 | is_32bit = bitmap_type is FshDataType.bitmap32 89 | text = only(cls._get_data_by_code(codes=[FshDataType.text], resource=resource)) 90 | text_data = text.data if text is not None else None 91 | mirrored = "" in text_data if text_data is not None else False 92 | nonmirrored = "" in text_data if text_data is not None else False 93 | additive = "" in text_data if text_data is not None else False 94 | blend_mode = None 95 | if is_32bit: 96 | blend_mode = BlendMode.ALPHA 97 | elif additive: 98 | blend_mode = BlendMode.ADDITIVE 99 | sun_attributes = None 100 | radius = search("R{:d}", text_data) if text_data else None 101 | angle_theta = search("A{:d}", text_data) if text_data else None 102 | angle_rho = search("B{:d}", text_data) if text_data else None 103 | if text_data and radius and angle_theta and angle_rho: 104 | sun_attributes = SunAttributes( 105 | angle_theta=angle_theta[0] / 360.0, 106 | angle_rho=angle_rho[0] / 360.0, 107 | radius=radius[0] * 10, 108 | rotates="ROTATE" in text_data, 109 | additive="NOADD" not in text_data, 110 | in_front="INFRONT" in text_data, 111 | ) 112 | return Resource( 113 | name=resource.name, 114 | image=bitmap, 115 | mirrored=mirrored, 116 | nonmirrored=nonmirrored, 117 | blend_mode=blend_mode, 118 | sun_attributes=sun_attributes, 119 | ) 120 | 121 | @property 122 | def resources(self) -> Iterator[Resource]: 123 | return map(self._make_resource, self.fsh.resources) 124 | -------------------------------------------------------------------------------- /speedtools/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | import logging 8 | import os 9 | from collections.abc import Callable, Hashable, Iterable, Iterator, Sequence 10 | from contextlib import suppress 11 | from dataclasses import replace 12 | from functools import partial, singledispatch 13 | from io import BytesIO 14 | from itertools import chain, compress, islice 15 | from operator import getitem 16 | from pathlib import Path 17 | from typing import Any, Dict, TypeVar 18 | 19 | from PIL import Image as pil_Image 20 | 21 | from speedtools.types import BaseMesh, BasePolygon, Bitmap, Image, Resource, Vertex 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | T = TypeVar("T") 26 | Ty = TypeVar("Ty") 27 | 28 | 29 | def islicen(iterable: Iterable[T], start: int, num: int) -> Iterable[T]: 30 | return islice(iterable, start, start + num) 31 | 32 | 33 | def slicen(iterable: Sequence[T], start: int, num: int) -> Sequence[T]: 34 | return iterable[start : start + num] 35 | 36 | 37 | def count_repeats_and_map( 38 | iterable: Iterable[T], func: Callable[[T, int], Ty], key: Callable[[T], Hashable] 39 | ) -> Iterable[Ty]: 40 | count: Dict[Hashable, int] = {} 41 | for item in iterable: 42 | k = key(item) 43 | count[k] = count.setdefault(k, -1) + 1 44 | yield func(item, count[k]) 45 | 46 | 47 | def unique_named_resources(iterable: Iterable[Resource]) -> Iterable[Resource]: 48 | def make_unique_name(resource: Resource, repeats: int) -> Resource: 49 | if repeats == 0: 50 | return resource 51 | return Resource( 52 | name=f"{resource.name}-{repeats}", 53 | image=resource.image, 54 | mirrored=resource.mirrored, 55 | nonmirrored=resource.nonmirrored, 56 | blend_mode=resource.blend_mode, 57 | ) 58 | 59 | return count_repeats_and_map(iterable=iterable, func=make_unique_name, key=lambda x: x.name) 60 | 61 | 62 | @singledispatch 63 | def create_pil_image(image: Image) -> Any: 64 | buffer = BytesIO(image.data) 65 | return pil_Image.open(fp=buffer) 66 | 67 | 68 | @create_pil_image.register 69 | def _(image: Bitmap) -> Any: 70 | return pil_Image.frombytes("RGBA", (image.width, image.height), data=image.data) 71 | 72 | 73 | def pil_image_to_png(image: Any) -> bytes: 74 | buffer = BytesIO() 75 | image.save(buffer, "png") 76 | return buffer.getvalue() 77 | 78 | 79 | def image_to_png(image: Image) -> bytes: 80 | pil_image = create_pil_image(image) 81 | return pil_image_to_png(pil_image) 82 | 83 | 84 | @singledispatch 85 | def export_resource(resource: Any, directory: Path) -> None: 86 | raise NotImplementedError("Unsupported resource type") 87 | 88 | 89 | @export_resource.register(Iterator) 90 | def _(resource: Iterator[Resource], directory: Path) -> None: 91 | for res in resource: 92 | export_resource(res, directory=directory) 93 | 94 | 95 | @export_resource.register(Resource) 96 | def _(resource: Resource, directory: Path) -> None: 97 | with suppress(FileExistsError): 98 | os.makedirs(directory) 99 | output_file = Path(directory, f"{resource.name}.png") 100 | image = create_pil_image(resource.image) 101 | logger.info(f"Writing image: {output_file}") 102 | image.save(output_file) 103 | 104 | 105 | def remove_unused_vertices(mesh: T) -> T: 106 | used_vertice_idx = chain.from_iterable( 107 | polygon.face for polygon in mesh.polygons # type: ignore[attr-defined] 108 | ) 109 | used_vertices = list( 110 | set(map(partial(getitem, mesh.vertices), used_vertice_idx)) # type: ignore[attr-defined] 111 | ) 112 | mapping = {v: i for i, v in enumerate(used_vertices)} 113 | 114 | def _make_polygon(polygon: BasePolygon) -> BasePolygon: 115 | vertices = tuple(mesh.vertices[i] for i in polygon.face) # type: ignore[attr-defined] 116 | face = tuple(mapping[v] for v in vertices) 117 | return replace(polygon, face=face) 118 | 119 | polygons = [_make_polygon(polygon) for polygon in mesh.polygons] # type: ignore[attr-defined] 120 | return replace(mesh, vertices=used_vertices, polygons=polygons) # type: ignore[type-var] 121 | 122 | 123 | def make_subset_mesh( 124 | mesh: BaseMesh, 125 | mesh_constructor: Callable[[Iterable[Vertex], Sequence[Ty]], T], 126 | polygon_constructors: Iterable[Callable[[tuple[int, ...]], Ty]], 127 | selectors: Iterable[bool], 128 | ) -> T: 129 | selected_polygons = list(compress(mesh.polygons, selectors)) 130 | minimal_mesh = remove_unused_vertices( 131 | BaseMesh(vertices=mesh.vertices, polygons=selected_polygons) 132 | ) 133 | constructed_polygons = list( 134 | map(lambda f, x: f(x.face), polygon_constructors, minimal_mesh.polygons) 135 | ) 136 | constructed_mesh = mesh_constructor(minimal_mesh.vertices, constructed_polygons) 137 | return constructed_mesh 138 | 139 | 140 | def merge_mesh(a: T, b: T) -> T: 141 | vertices = list(chain(a.vertices, b.vertices)) # type: ignore[attr-defined] 142 | 143 | def remap_idx(polygon: Ty) -> Ty: 144 | face = tuple(f + len(a.vertices) for f in polygon.face) # type: ignore[attr-defined] 145 | return replace(polygon, face=face) # type: ignore[type-var] 146 | 147 | b_polygons = map(remap_idx, b.polygons) # type: ignore[attr-defined] 148 | polygons = list(chain(a.polygons, b_polygons)) # type: ignore[attr-defined] 149 | return replace(a, vertices=vertices, polygons=polygons) # type: ignore[type-var] 150 | 151 | 152 | def make_horizon_texture(resources: list[Resource]) -> Any: 153 | images = [create_pil_image(x.image) for x in resources] 154 | width_hrz = sum(x.width for x in images) 155 | horizon_image = pil_Image.new("RGBA", (width_hrz, width_hrz)) 156 | for idx, image in enumerate(images): 157 | horizon_image.paste(image, (image.width * idx, width_hrz // 2 - image.width // 2)) 158 | return horizon_image 159 | -------------------------------------------------------------------------------- /speedtools/carp_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2024 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | import re 8 | from functools import reduce 9 | from typing import Any, Callable, TypeVar 10 | 11 | from more_itertools import grouper 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | def listify(constructor: Callable[[str], T]) -> Callable[[str], list[T]]: 17 | def body(value: str) -> list[T]: 18 | items = filter(lambda x: x, value.split(",")) 19 | return [constructor(item) for item in items] 20 | 21 | return body 22 | 23 | 24 | def bool_str(value: str) -> bool: 25 | return value != "0" 26 | 27 | 28 | def float_to_int(value: str) -> int: 29 | return int(float(value)) 30 | 31 | 32 | class CarpItem: 33 | def __init__(self, name: str, constructor: Callable[[str], Any]): 34 | self.name = name 35 | self.constructor = constructor 36 | 37 | def to_dict(self, value: str) -> dict[str, Any]: 38 | return {self.name: self.constructor(value)} 39 | 40 | 41 | class CarpData: 42 | CARP_ITEMS = { 43 | 0: CarpItem("serial_number", int), 44 | 1: CarpItem("car_classification", int), 45 | 2: CarpItem("mass", float), 46 | 3: CarpItem("manual_number_of_gears", int), 47 | 75: CarpItem("automatic_number_of_gears", int), 48 | 4: CarpItem("gear_shift_delay", int), 49 | 5: CarpItem("shift_blip_in_rpm", listify(int)), 50 | 6: CarpItem("brake_blip_in_rpm", listify(int)), 51 | 7: CarpItem("manual_velocity_to_rpm_ratio", listify(float)), 52 | 76: CarpItem("automatic_velocity_to_rpm_ratio", listify(float)), 53 | 8: CarpItem("manual_gear_ratios", listify(float)), 54 | 77: CarpItem("automatic_gear_ratios", listify(float)), 55 | 9: CarpItem("manual_gear_efficiency", listify(float)), 56 | 78: CarpItem("automatic_gear_efficiency", listify(float)), 57 | 10: CarpItem("torque_curve", listify(float)), 58 | 11: CarpItem("manual_final_gear", float), 59 | 79: CarpItem("automatic_final_gear", float), 60 | 12: CarpItem("engine_minimum_rpm", int), 61 | 13: CarpItem("engine_redline_rpm", int), 62 | 14: CarpItem("maximum_velocity", float), 63 | 15: CarpItem("top_speed_cap", float), 64 | 16: CarpItem("front_drive_ratio", float), 65 | 17: CarpItem("has_abs", bool_str), 66 | 18: CarpItem("maximum_braking_deceleration", float), 67 | 19: CarpItem("front_bias_brake_ratio", float), 68 | 20: CarpItem("gas_increasing_curve", listify(int)), 69 | 21: CarpItem("gas_decreasing_curve", listify(int)), 70 | 22: CarpItem("brake_increasing_curve", listify(float)), 71 | 23: CarpItem("brake_decreasing_curve", listify(float)), 72 | 24: CarpItem("wheel_base", float), 73 | 25: CarpItem("front_grip_bias", float), 74 | 26: CarpItem("power_steering", bool_str), 75 | 27: CarpItem("minimum_steering_acceleration", float), 76 | 28: CarpItem("turn_in_ramp", float), 77 | 29: CarpItem("turn_out_ramp", float), 78 | 30: CarpItem("lateral_acceleration_grip_multiplier", float), 79 | 80: CarpItem("understeer_gradient", float), 80 | 31: CarpItem("aerodynamic_downforce_multiplier", float), 81 | 32: CarpItem("gas_off_factor", float), 82 | 33: CarpItem("g_transfer_factor", float), 83 | 34: CarpItem("turning_circle_radius", float), 84 | 35: CarpItem("tire_specs_front", listify(int)), 85 | 36: CarpItem("tire_specs_rear", listify(int)), 86 | 37: CarpItem("tire_wear", float), 87 | 38: CarpItem("slide_multiplier", float), 88 | 39: CarpItem("spin_velocity_cap", float), 89 | 40: CarpItem("slide_velocity_cap", float), 90 | 41: CarpItem("slide_assistance_factor", float_to_int), 91 | 42: CarpItem("push_factor", int), 92 | 43: CarpItem("low_turn_factor", float), 93 | 44: CarpItem("high_turn_factor", float), 94 | 45: CarpItem("pitch_roll_factor", float), 95 | 46: CarpItem("road_bumpiness_factor", float), 96 | 47: CarpItem("spoiler_function_type", bool_str), 97 | 48: CarpItem("spoiler_activation_speed", float), 98 | 49: CarpItem("gradual_turn_cutoff", int), 99 | 50: CarpItem("medium_turn_cutoff", int), 100 | 51: CarpItem("sharp_turn_cutoff", int), 101 | 52: CarpItem("medium_turn_speed_modifier", float), 102 | 53: CarpItem("sharp_turn_speed_modifier", float), 103 | 54: CarpItem("extreme_turn_speed_modifier", float), 104 | 55: CarpItem("subdivide_level", int), 105 | 56: CarpItem("camera_arm", float), 106 | 57: CarpItem("body_damage", float), 107 | 58: CarpItem("engine_damage", float), 108 | 59: CarpItem("suspension_damage", float), 109 | 60: CarpItem("engine_tuning", float), 110 | 61: CarpItem("brake_balance", float), 111 | 62: CarpItem("steering_speed", float), 112 | 63: CarpItem("gear_rat_factor", float), 113 | 64: CarpItem("suspension_stiffness", float), 114 | 65: CarpItem("aero_factor", float), 115 | 66: CarpItem("tire_factor", float), 116 | 67: CarpItem("ai_acc0", listify(float)), 117 | 68: CarpItem("ai_acc1", listify(float)), 118 | 69: CarpItem("ai_acc2", listify(float)), 119 | 70: CarpItem("ai_acc3", listify(float)), 120 | 71: CarpItem("ai_acc4", listify(float)), 121 | 72: CarpItem("ai_acc5", listify(float)), 122 | 73: CarpItem("ai_acc6", listify(float)), 123 | 74: CarpItem("ai_acc7", listify(float)), 124 | } 125 | 126 | @classmethod 127 | def parse(cls, group: tuple[str, str]) -> dict[str, Any]: 128 | name, value = group 129 | match = re.findall(r"\((\d+)\)", name) 130 | key = int(match[-1]) 131 | return cls.CARP_ITEMS[key].to_dict(value) 132 | 133 | @classmethod 134 | def to_dict(cls, value: str) -> dict[str, Any]: 135 | values = filter(lambda x: x and not x.isspace(), value.splitlines()) 136 | grouped = grouper(values, 2, incomplete="strict") 137 | items = map(cls.parse, grouped) # type: ignore[arg-type] 138 | return reduce(lambda x, y: x | y, items) 139 | -------------------------------------------------------------------------------- /speedtools/specs/fce.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: fce 3 | file-extension: fce 4 | license: CC0-1.0 5 | endian: le 6 | encoding: ASCII 7 | seq: 8 | - id: magic 9 | contents: [0x14, 0x10, 0x10, 0] 10 | - id: unknown 11 | size: 4 12 | - id: num_polygons 13 | type: u4 14 | doc: Number of polygons in the entire model 15 | - id: num_vertices 16 | type: u4 17 | doc: Number of vertices in the entire model 18 | - id: num_arts 19 | type: u4 20 | doc: Number of color presets 21 | - id: vertice_table_offset 22 | type: u4 23 | doc: Offset of the vertice table in bytes after the FCE header 24 | - id: normals_table_offset 25 | type: u4 26 | doc: Offset of the vertice normal table in bytes after the FCE header 27 | - id: polygon_table_offset 28 | type: u4 29 | doc: Offset of the polygon table in bytes after the FCE header 30 | - id: unknown2 31 | size: 12 32 | - id: undamaged_vertices_offset 33 | type: u4 34 | doc: Offset of the undamaged vertices in bytes after the FCE header 35 | - id: undamaged_normals_offset 36 | type: u4 37 | doc: Offset of the undamaged normals in bytes after the FCE header 38 | - id: damaged_vertices_offset 39 | type: u4 40 | doc: Offset of the damaged vertices in bytes after the FCE header 41 | - id: damaged_normals_offset 42 | type: u4 43 | doc: Offset of the damaged normals in bytes after the FCE header 44 | - id: damage_weights_offset 45 | type: u4 46 | - id: driver_movement_offset 47 | type: u4 48 | doc: Offset of the driver movement data in bytes after the FCE header 49 | - id: unknown4 50 | size: 8 51 | - id: half_sizes 52 | type: float3 53 | doc: Car half-sizes 54 | - id: num_light_sources 55 | type: u4 56 | doc: Number of light source dummies 57 | - id: light_sources 58 | type: float3 59 | repeat: expr 60 | repeat-expr: num_light_sources 61 | doc: Light source dummies 62 | - id: unused_light_sources 63 | type: float3 64 | repeat: expr 65 | repeat-expr: 16 - num_light_sources 66 | - id: num_car_parts 67 | type: u4 68 | doc: Number of car parts 69 | - id: part_locations 70 | type: float3 71 | repeat: expr 72 | repeat-expr: num_car_parts 73 | doc: Car part locations 74 | - id: unused_parts 75 | type: float3 76 | repeat: expr 77 | repeat-expr: 64 - num_car_parts 78 | - id: part_vertex_index 79 | type: u4 80 | repeat: expr 81 | repeat-expr: num_car_parts 82 | doc: Index of the first vertice of the part in the vertice table 83 | - id: unused_part_vertex_index 84 | type: u4 85 | repeat: expr 86 | repeat-expr: 64 - num_car_parts 87 | - id: part_num_vertices 88 | type: u4 89 | repeat: expr 90 | repeat-expr: num_car_parts 91 | doc: Number of vertices used by the part 92 | - id: unused_part_num_vertices 93 | type: u4 94 | repeat: expr 95 | repeat-expr: 64 - num_car_parts 96 | - id: part_polygon_index 97 | type: u4 98 | repeat: expr 99 | repeat-expr: num_car_parts 100 | doc: Index of the first polygon of the part in the polygon table 101 | - id: unused_part_polygon_index 102 | type: u4 103 | repeat: expr 104 | repeat-expr: 64 - num_car_parts 105 | - id: part_num_polygons 106 | type: u4 107 | repeat: expr 108 | repeat-expr: num_car_parts 109 | doc: Number of polygons used by the part 110 | - id: unused_part_num_polygons 111 | type: u4 112 | repeat: expr 113 | repeat-expr: 64 - num_car_parts 114 | - id: num_colors 115 | type: u4 116 | doc: Number of car colors 117 | - id: primary_colors 118 | type: color 119 | repeat: expr 120 | repeat-expr: num_colors 121 | doc: Car primary colors 122 | - id: unused_primary_colors 123 | type: color 124 | repeat: expr 125 | repeat-expr: 16 - num_colors 126 | - id: interior_colors 127 | type: color 128 | repeat: expr 129 | repeat-expr: num_colors 130 | doc: Car interior colors 131 | - id: unused_interior_colors 132 | type: color 133 | repeat: expr 134 | repeat-expr: 16 - num_colors 135 | - id: secondary_colors 136 | type: color 137 | repeat: expr 138 | repeat-expr: num_colors 139 | doc: Car secondary colors 140 | - id: unused_secondary_colors 141 | type: color 142 | repeat: expr 143 | repeat-expr: 16 - num_colors 144 | - id: driver_colors 145 | type: color 146 | repeat: expr 147 | repeat-expr: num_colors 148 | doc: Driver colors 149 | - id: unused_driver_colors 150 | type: color 151 | repeat: expr 152 | repeat-expr: 16 - num_colors 153 | - id: unknown5 154 | size: 260 155 | - id: dummies 156 | type: dummy 157 | # size: 64 * 16 # TODO: ??? 158 | size: 64 159 | repeat: expr 160 | repeat-expr: 16 161 | - id: part_strings 162 | type: part 163 | size: 64 164 | repeat: expr 165 | repeat-expr: num_car_parts 166 | - id: unused_part_strings 167 | type: part 168 | size: 64 169 | repeat: expr 170 | repeat-expr: 64 - num_car_parts 171 | - id: unknown8 172 | size: 528 # TODO: ??? 173 | instances: 174 | vertices: 175 | pos: 8248 + vertice_table_offset 176 | type: float3 177 | repeat: expr 178 | repeat-expr: num_vertices 179 | doc: Vertice table 180 | normals: 181 | pos: 8248 + normals_table_offset 182 | type: float3 183 | repeat: expr 184 | repeat-expr: num_vertices 185 | doc: Normal table 186 | polygons: 187 | pos: 8248 + polygon_table_offset 188 | type: polygon 189 | repeat: expr 190 | repeat-expr: num_polygons 191 | doc: Polygon table 192 | undamaged_vertices: 193 | pos: 8248 + undamaged_vertices_offset 194 | type: float3 195 | repeat: expr 196 | repeat-expr: num_vertices 197 | doc: Undamaged vertice table 198 | undamaged_normals: 199 | pos: 8248 + undamaged_normals_offset 200 | type: float3 201 | repeat: expr 202 | repeat-expr: num_vertices 203 | doc: Undamaged normal table 204 | damaged_vertices: 205 | pos: 8248 + damaged_vertices_offset 206 | type: float3 207 | repeat: expr 208 | repeat-expr: num_vertices 209 | doc: Damaged vertice table 210 | damaged_normals: 211 | pos: 8248 + damaged_normals_offset 212 | type: float3 213 | repeat: expr 214 | repeat-expr: num_vertices 215 | doc: Damaged normal table 216 | vertex_damage_weights: 217 | pos: 8248 + damage_weights_offset 218 | type: f4 219 | repeat: expr 220 | repeat-expr: num_vertices 221 | doc: Vertex damage weights 222 | movement_data: 223 | pos: 8248 + driver_movement_offset 224 | type: u4 225 | repeat: expr 226 | repeat-expr: num_vertices 227 | doc: Vertex movement data 228 | types: 229 | float3: 230 | seq: 231 | - id: x 232 | type: f4 233 | - id: y 234 | type: f4 235 | - id: z 236 | type: f4 237 | color: 238 | seq: 239 | - id: hue 240 | type: u1 241 | - id: saturation 242 | type: u1 243 | - id: brightness 244 | type: u1 245 | - id: unknown 246 | size: 1 247 | polygon: 248 | seq: 249 | - id: texture 250 | type: u4 251 | doc: Texture data 252 | - id: face 253 | type: u4 254 | repeat: expr 255 | repeat-expr: 3 256 | doc: Polygon face 257 | - id: unknown 258 | # contents: [00, ff, 00, ff, 00, ff, 00, ff, 00, ff, 00, ff] 259 | size: 2 260 | repeat: expr 261 | repeat-expr: 6 262 | - id: flags 263 | type: u4 264 | doc: Polygon flags 265 | - id: u 266 | type: f4 267 | repeat: expr 268 | repeat-expr: 3 269 | doc: U texture coordinate 270 | - id: v 271 | type: f4 272 | repeat: expr 273 | repeat-expr: 3 274 | doc: V texture coordinate 275 | instances: 276 | non_reflective: 277 | value: (flags & 0x0001) != 0 278 | highly_reflective: 279 | value: (flags & 0x0002) != 0 280 | backface_culling: 281 | value: (flags & 0x0004) == 0 282 | transparent: 283 | value: (flags & 0x0008) != 0 284 | dummy: 285 | seq: 286 | - id: magic 287 | type: str 288 | size: 1 289 | - id: color 290 | type: str 291 | size: 1 292 | - id: type 293 | type: str 294 | size: 1 295 | - id: breakable 296 | type: str 297 | size: 1 298 | - id: flashing 299 | type: str 300 | size: 1 301 | - id: intensity 302 | type: str 303 | size: 1 304 | - id: time_on 305 | type: str 306 | size: 1 307 | - id: time_off 308 | type: str 309 | size: 1 310 | part: 311 | seq: 312 | - id: value 313 | type: strz 314 | repeat: eos 315 | -------------------------------------------------------------------------------- /speedtools/types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | from collections.abc import Sequence 10 | from dataclasses import dataclass, field 11 | from enum import Enum 12 | from math import pi, sqrt 13 | from typing import NamedTuple, Optional, TypeAlias 14 | 15 | from speedtools.parsers import FceParser, FrdParser, FshParser 16 | 17 | RoadEffect: TypeAlias = FrdParser.DriveablePolygon.RoadEffect 18 | CollisionType: TypeAlias = FrdParser.ObjectAttribute.CollisionType 19 | ObjectType: TypeAlias = FrdParser.ObjectHeader.ObjectType 20 | FshDataType: TypeAlias = FshParser.DataType 21 | 22 | 23 | class Action(Enum): 24 | DEFAULT_LOOP = 1 25 | DESTROY_LOW_SPEED = 2 26 | DESTROY_HIGH_SPEED = 3 27 | 28 | 29 | class BlendMode(Enum): 30 | ALPHA = 1 31 | ADDITIVE = 2 32 | 33 | 34 | class ShapeKeyType(Enum): 35 | DAMAGE = 1 36 | 37 | 38 | class Edge(Enum): 39 | FRONT = 0 40 | LEFT = 1 41 | BACK = 2 42 | RIGHT = 3 43 | 44 | 45 | class VehicleLightType(Enum): 46 | HEADLIGHT = 1 47 | TAILLIGHT = 2 48 | BRAKELIGHT = 3 49 | REVERSE = 4 50 | DIRECTIONAL = 5 51 | SIREN = 6 52 | 53 | 54 | class Vector3d(NamedTuple): 55 | x: float 56 | z: float 57 | y: float 58 | 59 | @classmethod 60 | def from_frd_float3(cls, value: FrdParser.Float3) -> Vector3d: 61 | return Vector3d(x=value.x, y=value.y, z=value.z) 62 | 63 | @classmethod 64 | def from_frd_int3(cls, value: FrdParser.Int3) -> Vector3d: 65 | return Vector3d( 66 | x=value.x / 65536.0, 67 | y=value.y / 65536.0, 68 | z=value.z / 65536.0, 69 | ) 70 | 71 | @classmethod 72 | def from_fce_float3(cls, value: FceParser.Float3) -> Vector3d: 73 | return Vector3d(x=value.x, y=value.y, z=value.z) 74 | 75 | def horizontal_plane_length(self) -> float: 76 | return sqrt(self.x**2 + self.z**2) 77 | 78 | def magnitude(self) -> float: 79 | return sqrt(self.x**2 + self.y**2 + self.z**2) 80 | 81 | def subtract(self, x: Vector3d) -> Vector3d: 82 | return Vector3d(x=self.x - x.x, y=self.y - x.y, z=self.z - x.z) 83 | 84 | 85 | class UV(NamedTuple): 86 | u: float 87 | v: float 88 | 89 | 90 | class Quaternion(NamedTuple): 91 | w: float 92 | x: float 93 | z: float 94 | y: float 95 | 96 | @classmethod 97 | def from_frd_short4(cls, value: FrdParser.Short4) -> Quaternion: 98 | return Quaternion( 99 | x=value.x / 65536.0, 100 | y=value.y / 65536.0, 101 | z=value.z / 65536.0, 102 | w=value.w / 65536.0, 103 | ) 104 | 105 | 106 | class Color(NamedTuple): 107 | red: int 108 | green: int 109 | blue: int 110 | alpha: int = 255 111 | 112 | @property 113 | def rgb(self) -> tuple[int, int, int]: 114 | return (self.red, self.green, self.blue) 115 | 116 | @property 117 | def rgb_float(self) -> tuple[float, float, float]: 118 | return (self.red / 255, self.green / 255, self.blue / 255) 119 | 120 | @property 121 | def rgba_float(self) -> tuple[float, float, float, float]: 122 | return (self.red / 255, self.green / 255, self.blue / 255, self.alpha / 255) 123 | 124 | 125 | class Matrix3x3(NamedTuple): 126 | x: Vector3d 127 | z: Vector3d 128 | y: Vector3d 129 | 130 | 131 | @dataclass(frozen=True) 132 | class Vertex: 133 | location: Vector3d 134 | normal: Vector3d | None = None 135 | color: Color | None = None 136 | 137 | 138 | @dataclass(frozen=True) 139 | class BasePolygon: 140 | face: tuple[int, ...] 141 | 142 | 143 | @dataclass(frozen=True) 144 | class ShapeKey: 145 | type: ShapeKeyType 146 | vertices: Sequence[Vertex] 147 | 148 | 149 | @dataclass(frozen=True) 150 | class BaseMesh: 151 | vertices: Sequence[Vertex] 152 | polygons: Sequence[BasePolygon] 153 | 154 | @property 155 | def vertex_locations(self) -> Sequence[Vector3d]: 156 | return [vert.location for vert in self.vertices] 157 | 158 | @property 159 | def vertex_normals(self) -> Sequence[Vector3d]: 160 | normals = [vert.normal for vert in self.vertices] 161 | if None not in normals: 162 | return normals # type: ignore[return-value] 163 | return [] 164 | 165 | @property 166 | def vertex_colors(self) -> Sequence[Color]: 167 | colors = [vert.color for vert in self.vertices] 168 | if None not in colors: 169 | return colors # type: ignore[return-value] 170 | return [] 171 | 172 | 173 | @dataclass(frozen=True) 174 | class Polygon(BasePolygon): 175 | face: tuple[int, ...] 176 | uv: tuple[UV, ...] 177 | material: int 178 | backface_culling: bool 179 | is_lane: bool = False 180 | transparent: bool = False 181 | highly_reflective: bool = False 182 | non_reflective: bool = False 183 | animation_ticks: int = 0 184 | animation_count: int = 0 185 | billboard: bool = False 186 | 187 | 188 | @dataclass(frozen=True) 189 | class Animation: 190 | length: int 191 | delay: int 192 | locations: Sequence[Vector3d] 193 | quaternions: Sequence[Quaternion] 194 | 195 | 196 | @dataclass(frozen=True) 197 | class AnimationAction: 198 | action: Action 199 | animation: Animation 200 | 201 | 202 | @dataclass(frozen=True) 203 | class DrawableMesh(BaseMesh): 204 | polygons: Sequence[Polygon] 205 | shape_keys: Sequence[ShapeKey] = field(default_factory=list) 206 | 207 | 208 | @dataclass(frozen=True) 209 | class Image: 210 | data: bytes 211 | 212 | 213 | @dataclass(frozen=True) 214 | class Bitmap(Image): 215 | width: int 216 | height: int 217 | 218 | 219 | @dataclass(frozen=True) 220 | class SunAttributes: 221 | angle_theta: float 222 | angle_rho: float 223 | radius: float 224 | rotates: bool 225 | additive: bool 226 | in_front: bool 227 | 228 | 229 | @dataclass(frozen=True) 230 | class Resource: 231 | name: str 232 | image: Image 233 | mirrored: bool = False 234 | nonmirrored: bool = False 235 | blend_mode: BlendMode | None = None 236 | sun_attributes: SunAttributes | None = None 237 | 238 | 239 | @dataclass(frozen=True) 240 | class ObjectData: 241 | mesh: DrawableMesh 242 | location: Optional[Vector3d] = None 243 | animation: Optional[Animation] = None 244 | 245 | 246 | @dataclass(frozen=True) 247 | class PhysicsData: 248 | dimension: Vector3d 249 | mass: float 250 | 251 | 252 | @dataclass(frozen=True) 253 | class TrackObject: 254 | mesh: DrawableMesh 255 | collision_type: CollisionType 256 | location: Optional[Vector3d] = None 257 | actions: Sequence[AnimationAction] = field(default_factory=tuple) 258 | transform: Optional[Matrix3x3] = None 259 | physics: PhysicsData | None = None 260 | 261 | 262 | @dataclass(frozen=True) 263 | class CollisionPolygon(BasePolygon): 264 | edges: Sequence[Edge] = field(default_factory=list) 265 | has_finite_height: bool = False 266 | has_wall_collision: bool = False 267 | 268 | 269 | @dataclass(frozen=True) 270 | class CollisionMesh(BaseMesh): 271 | polygons: Sequence[CollisionPolygon] 272 | collision_effect: RoadEffect = RoadEffect.not_driveable 273 | 274 | 275 | @dataclass(frozen=True) 276 | class TrackSegment: 277 | mesh: DrawableMesh 278 | collision_meshes: Sequence[CollisionMesh] 279 | waypoints: Sequence[Vector3d] 280 | 281 | 282 | @dataclass(frozen=True) 283 | class Part: 284 | mesh: DrawableMesh 285 | name: str 286 | location: Vector3d 287 | 288 | 289 | @dataclass(frozen=True) 290 | class LightAttributes: 291 | identifier: int 292 | color: Color 293 | blink_interval_ms: int | None 294 | flare_size: float 295 | 296 | 297 | @dataclass(frozen=True) 298 | class Light: 299 | location: Vector3d 300 | color: Color 301 | 302 | 303 | @dataclass(frozen=True) 304 | class TrackLight(Light): 305 | blink_interval_ms: int | None 306 | flare_size: float 307 | 308 | 309 | @dataclass(frozen=True) 310 | class VehicleLight(Light): 311 | type: VehicleLightType 312 | 313 | 314 | @dataclass(frozen=True) 315 | class LightStub: 316 | location: Vector3d 317 | glow_id: int 318 | 319 | 320 | @dataclass(frozen=True) 321 | class DirectionalLight: 322 | phi: float 323 | theta: float 324 | radius: float 325 | rotates: bool 326 | in_front: bool 327 | additive: bool 328 | resource: Resource 329 | 330 | @property 331 | def euler_xyz(self) -> Vector3d: 332 | z = pi / 2 - self.phi 333 | y = self.theta 334 | return Vector3d(x=0, y=y, z=z) 335 | 336 | 337 | @dataclass(frozen=True) 338 | class Camera: 339 | location: Vector3d 340 | transform: Matrix3x3 341 | 342 | 343 | @dataclass(frozen=True) 344 | class Horizon: 345 | sun_side: Color 346 | sun_top_side: Color 347 | sun_opposite_side: Color 348 | earth_bottom: Color 349 | earth_top: Color 350 | -------------------------------------------------------------------------------- /speedtools/viv_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from collections.abc import Iterable, Iterator 11 | from enum import Enum 12 | from functools import partial 13 | from itertools import compress, starmap 14 | from pathlib import Path 15 | from typing import Any, NamedTuple 16 | 17 | from more_itertools import one 18 | 19 | from speedtools.carp_data import CarpData 20 | from speedtools.parsers import FceParser, VivParser 21 | from speedtools.types import ( 22 | UV, 23 | Color, 24 | DrawableMesh, 25 | Image, 26 | Part, 27 | Polygon, 28 | Resource, 29 | ShapeKey, 30 | ShapeKeyType, 31 | Vector3d, 32 | VehicleLight, 33 | VehicleLightType, 34 | Vertex, 35 | ) 36 | from speedtools.utils import islicen 37 | 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | class Resolution(Enum): 42 | LOW = 1 43 | MEDIUM = 2 44 | HIGH = 3 45 | 46 | 47 | class PartAttributes(NamedTuple): 48 | name: str 49 | interior: bool = False 50 | resolution: Resolution = Resolution.HIGH 51 | 52 | 53 | class VivData: 54 | known_parts: dict[str, PartAttributes] = { 55 | # High Body 56 | ":HB": PartAttributes(resolution=Resolution.HIGH, name="body"), 57 | # High Left Front Wheel 58 | ":HLFW": PartAttributes(resolution=Resolution.HIGH, name="front_left_whl"), 59 | # High Right Front Wheel 60 | ":HRFW": PartAttributes(resolution=Resolution.HIGH, name="front_right_whl"), 61 | # High Left Middle Wheel 62 | ":HLMW": PartAttributes(resolution=Resolution.HIGH, name="middle_left_whl"), 63 | # High Right Middle Wheel 64 | ":HRMW": PartAttributes(resolution=Resolution.HIGH, name="middle_right_whl"), 65 | # High Left Rear Wheel 66 | ":HLRW": PartAttributes(resolution=Resolution.HIGH, name="rear_left_whl"), 67 | # High Right Rear Wheel 68 | ":HRRW": PartAttributes(resolution=Resolution.HIGH, name="rear_right_whl"), 69 | # Medium Body 70 | ":MB": PartAttributes(resolution=Resolution.MEDIUM, name="body"), 71 | # Medium Left Front Wheel 72 | ":MLFW": PartAttributes(resolution=Resolution.MEDIUM, name="front_left_whl"), 73 | # Medium Right Front Wheel 74 | ":MRFW": PartAttributes(resolution=Resolution.MEDIUM, name="front_right_whl"), 75 | # Medium Left Middle Wheel 76 | ":MLMW": PartAttributes(resolution=Resolution.MEDIUM, name="middle_left_whl"), 77 | # Medium Right Middle Wheel 78 | ":MRMW": PartAttributes(resolution=Resolution.MEDIUM, name="middle_right_whl"), 79 | # Medium Left Rear Wheel 80 | ":MLRW": PartAttributes(resolution=Resolution.MEDIUM, name="rear_left_whl"), 81 | # Medium Right Rear Wheel 82 | ":MRRW": PartAttributes(resolution=Resolution.MEDIUM, name="rear_right_whl"), 83 | # Low Body 84 | ":LB": PartAttributes(resolution=Resolution.LOW, name="body"), 85 | # Tiny Body 86 | ":TB": PartAttributes(resolution=Resolution.LOW, name="body"), 87 | # Interior 88 | ":OC": PartAttributes(resolution=Resolution.HIGH, name="interior"), 89 | # Driver's chair and steering wheel 90 | ":OND": PartAttributes(resolution=Resolution.HIGH, name="driver_chair"), 91 | # Driver holding steering wheel 92 | ":OD": PartAttributes(resolution=Resolution.HIGH, name="driver"), 93 | # Driver head 94 | ":OH": PartAttributes(resolution=Resolution.HIGH, name="driver_head"), 95 | # Dash when lit 96 | ":ODL": PartAttributes(resolution=Resolution.HIGH, name="dashboard_lit"), 97 | # Left Mirror 98 | ":OLM": PartAttributes(resolution=Resolution.HIGH, name="left_mirror"), 99 | # Right Mirror 100 | ":ORM": PartAttributes(resolution=Resolution.HIGH, name="right_mirror"), 101 | # Left Front Brake 102 | ":OLB": PartAttributes(resolution=Resolution.HIGH, name="front_left_brake"), 103 | # Right Front Brake 104 | ":ORB": PartAttributes(resolution=Resolution.HIGH, name="front_right_brake"), 105 | # Popup lights 106 | ":OL": PartAttributes(resolution=Resolution.HIGH, name="lights"), 107 | # Top of convertibles 108 | ":OT": PartAttributes(resolution=Resolution.HIGH, name="top"), 109 | # Optional spoiler 110 | ":OS": PartAttributes(resolution=Resolution.HIGH, name="spoiler"), 111 | # Helicopter main rotor 112 | "main": PartAttributes(resolution=Resolution.HIGH, name="main_rotor"), 113 | # Helicopter tail rotor 114 | "tail": PartAttributes(resolution=Resolution.HIGH, name="tail_rotor"), 115 | # Helicopter body 116 | "body": PartAttributes(resolution=Resolution.HIGH, name="body"), 117 | # Low resolution main rotor 118 | ":Lmain": PartAttributes(resolution=Resolution.LOW, name="main_rotor"), 119 | # Low resolution tail rotor 120 | ":Ltail": PartAttributes(resolution=Resolution.LOW, name="tail_rotor"), 121 | } 122 | 123 | light_types = { 124 | "H": VehicleLightType.HEADLIGHT, 125 | "T": VehicleLightType.TAILLIGHT, 126 | "B": VehicleLightType.BRAKELIGHT, 127 | "R": VehicleLightType.REVERSE, 128 | "P": VehicleLightType.DIRECTIONAL, 129 | "S": VehicleLightType.SIREN, 130 | } 131 | 132 | light_colors = { 133 | "R": Color(0xFF, 0, 0), 134 | "B": Color(0, 0, 0xFF), 135 | "W": Color(0xFF, 0xFF, 0xFF), 136 | "O": Color(0xE4, 0xA4, 0), 137 | "Y": Color(0xFF, 0xFF, 0), 138 | } 139 | 140 | body_geometry = {"car.fce", "hel.fce"} 141 | body_textures = {"car00.tga", "hel00.tga"} 142 | interior_geometry = {"dash.fce"} 143 | interior_textures = {"dash00.tga"} 144 | 145 | def __init__(self, parser: VivParser) -> None: 146 | self.viv = parser 147 | 148 | @classmethod 149 | def from_file(cls, path: Path) -> VivData: 150 | parser = VivParser.from_file(path) 151 | return cls(parser=parser) 152 | 153 | @classmethod 154 | def _make_polygon(cls, polygon: FceParser.Polygon) -> Polygon: 155 | face = tuple(vertex for vertex in polygon.face) 156 | uv = tuple(UV(u, 1 - v) for u, v in zip(polygon.u, polygon.v)) 157 | return Polygon( 158 | face=face, 159 | uv=uv, 160 | material=polygon.texture, 161 | backface_culling=polygon.backface_culling, 162 | transparent=polygon.transparent, 163 | highly_reflective=polygon.highly_reflective, 164 | non_reflective=polygon.non_reflective, 165 | ) 166 | 167 | @classmethod 168 | def _match_attributes(cls, attribute: PartAttributes) -> bool: 169 | return attribute.resolution is Resolution.HIGH 170 | 171 | @classmethod 172 | def _get_part_attributes(cls, strings: FceParser.Part) -> PartAttributes: 173 | try: 174 | return cls.known_parts[strings.value[0]] 175 | except KeyError: 176 | return PartAttributes(name=strings.value[0]) 177 | 178 | @classmethod 179 | def _make_vertex( 180 | cls, vertex: Iterable[FceParser.Float3], normal: Iterable[FceParser.Float3] 181 | ) -> Vertex: 182 | location = Vector3d.from_fce_float3(vertex) 183 | normal_vec = Vector3d.from_fce_float3(normal) 184 | return Vertex(location=location, normal=normal_vec) 185 | 186 | @classmethod 187 | def _make_part_shape_key( 188 | cls, vertices: Iterable[FceParser.Float3], normals: Iterable[FceParser.Float3] 189 | ) -> ShapeKey: 190 | vert = list(map(cls._make_vertex, vertices, normals)) 191 | return ShapeKey(type=ShapeKeyType.DAMAGE, vertices=vert) 192 | 193 | @classmethod 194 | def _make_part_mesh( 195 | cls, 196 | part_vertices: Iterable[FceParser.Float3], 197 | part_normals: Iterable[FceParser.Float3], 198 | part_polygons: Iterable[FceParser.Polygon], 199 | part_damaged_vertices: Iterable[FceParser.Float3], 200 | part_damaged_normals: Iterable[FceParser.Float3], 201 | ) -> DrawableMesh: 202 | vertices = list(map(cls._make_vertex, part_vertices, part_normals)) 203 | shape_key = cls._make_part_shape_key(part_damaged_vertices, part_damaged_normals) 204 | polygons = [cls._make_polygon(polygon) for polygon in part_polygons] 205 | return DrawableMesh(vertices=vertices, polygons=polygons, shape_keys=[shape_key]) 206 | 207 | @classmethod 208 | def _make_part( 209 | cls, location: FceParser.Float3, mesh: DrawableMesh, attribute: PartAttributes 210 | ) -> Part: 211 | location_vect = Vector3d(x=location.x, y=location.y, z=location.z) 212 | return Part(name=attribute.name, location=location_vect, mesh=mesh) 213 | 214 | @classmethod 215 | def _make_resource(cls, entry: VivParser.DirectoryEntry) -> Resource: 216 | tga = Image(entry.body) 217 | return Resource(name=entry.name, image=tga) 218 | 219 | @classmethod 220 | def _make_geometry(cls, fce: FceParser) -> Iterator[Part]: 221 | slice_vert = partial(islicen, fce.undamaged_vertices) 222 | part_vertices_iter = map(slice_vert, fce.part_vertex_index, fce.part_num_vertices) 223 | slice_norm = partial(islicen, fce.undamaged_normals) 224 | part_normals_iter = map(slice_norm, fce.part_vertex_index, fce.part_num_vertices) 225 | slice_norm = partial(islicen, fce.polygons) 226 | part_polygons_iter = map(slice_norm, fce.part_polygon_index, fce.part_num_polygons) 227 | slice_damaged_vert = partial(islicen, fce.damaged_vertices) 228 | part_damaged_vertices_iter = map( 229 | slice_damaged_vert, fce.part_vertex_index, fce.part_num_vertices 230 | ) 231 | slice_damaged_norm = partial(islicen, fce.damaged_normals) 232 | part_damaged_normals_iter = map( 233 | slice_damaged_norm, fce.part_vertex_index, fce.part_num_vertices 234 | ) 235 | meshes = map( 236 | cls._make_part_mesh, 237 | part_vertices_iter, 238 | part_normals_iter, 239 | part_polygons_iter, 240 | part_damaged_vertices_iter, 241 | part_damaged_normals_iter, 242 | ) 243 | attributes = list(map(cls._get_part_attributes, fce.part_strings)) 244 | part_data = zip(fce.part_locations, meshes, attributes, strict=True) 245 | selectors = map(cls._match_attributes, attributes) 246 | filtered_parts = compress(part_data, selectors) 247 | return starmap(cls._make_part, filtered_parts) 248 | 249 | @classmethod 250 | def _make_light(cls, location: FceParser.Float3, dummy: FceParser.Dummy) -> VehicleLight: 251 | loc = Vector3d.from_fce_float3(location) 252 | color = cls.light_colors[dummy.color] 253 | light_type = cls.light_types[dummy.magic] 254 | logger.debug(f"Color: {color}") 255 | return VehicleLight(location=loc, color=color, type=light_type) 256 | 257 | @property 258 | def parts(self) -> Iterator[Part]: 259 | fce = one(filter(lambda x: x.name in self.body_geometry, self.viv.entries)) 260 | return self._make_geometry(fce.body) 261 | 262 | @property 263 | def interior(self) -> Iterator[Part]: 264 | fce = one(filter(lambda x: x.name in self.interior_geometry, self.viv.entries)) 265 | return self._make_geometry(fce.body) 266 | 267 | @property 268 | def materials(self) -> Iterator[Resource]: 269 | return map( 270 | self._make_resource, filter(lambda x: x.name.endswith(".tga"), self.viv.entries) 271 | ) 272 | 273 | @property 274 | def body_materials(self) -> Iterator[Resource]: 275 | return filter(lambda x: x.name in self.body_textures, self.materials) 276 | 277 | @property 278 | def interior_materials(self) -> Iterator[Resource]: 279 | return filter(lambda x: x.name in self.interior_textures, self.materials) 280 | 281 | @property 282 | def performance(self) -> dict[str, Any]: 283 | carp = one(filter(lambda x: x.name == "carp.txt", self.viv.entries)) 284 | parser = CarpData() 285 | return parser.to_dict(carp.body) 286 | 287 | @property 288 | def dimensions(self) -> Vector3d: 289 | fce = one(filter(lambda x: x.name in self.body_geometry, self.viv.entries)) 290 | half_sizes = fce.body.half_sizes 291 | return Vector3d(x=half_sizes.x * 2, y=half_sizes.y * 2, z=half_sizes.z * 2) 292 | 293 | @property 294 | def lights(self) -> Iterator[VehicleLight]: 295 | fce = one(filter(lambda x: x.name in self.body_geometry, self.viv.entries)) 296 | lights = filter(lambda x: x.magic in self.light_types, fce.body.dummies) 297 | return map(self._make_light, fce.body.light_sources, lights) 298 | -------------------------------------------------------------------------------- /speedtools/frd_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from collections.abc import Iterable, Iterator, Sequence 11 | from contextlib import suppress 12 | from functools import partial 13 | from itertools import chain, compress, groupby, starmap 14 | from pathlib import Path 15 | from typing import Any, Optional 16 | 17 | from more_itertools import ( 18 | chunked, 19 | collapse, 20 | nth, 21 | strictly_n, 22 | transpose, 23 | unique_everseen, 24 | unzip, 25 | ) 26 | 27 | from speedtools.parsers import FrdParser 28 | from speedtools.types import ( 29 | UV, 30 | Action, 31 | Animation, 32 | AnimationAction, 33 | CollisionMesh, 34 | CollisionPolygon, 35 | CollisionType, 36 | Color, 37 | DrawableMesh, 38 | Edge, 39 | LightStub, 40 | Matrix3x3, 41 | ObjectType, 42 | PhysicsData, 43 | Polygon, 44 | Quaternion, 45 | RoadEffect, 46 | TrackObject, 47 | TrackSegment, 48 | Vector3d, 49 | Vertex, 50 | ) 51 | from speedtools.utils import islicen, remove_unused_vertices 52 | 53 | logger = logging.getLogger(__name__) 54 | 55 | 56 | class FrdData: 57 | high_poly_chunks = [ 58 | False, # Low-resolution track geometry 59 | False, # Low-resolution misc geometry 60 | False, # Medium-resolution track geometry 61 | False, # Medium-resolution misc geometry 62 | True, # High-resolution track geometry 63 | True, # High-resolution misc geometry 64 | True, # Road lanes 65 | True, # High-resolution misc geometry 66 | True, # High-resolution misc geometry 67 | True, # High-resolution misc geometry 68 | True, # High-resolution misc geometry 69 | ] 70 | ROAD_BLOCKS_PER_SEGMENT = 8 71 | 72 | def __init__(self, parser: FrdParser) -> None: 73 | self.frd = parser 74 | 75 | @classmethod 76 | def from_file(cls, path: Path) -> FrdData: 77 | parser = FrdParser.from_file(path) 78 | return cls(parser) 79 | 80 | @classmethod 81 | def _validate_polygon( 82 | cls, face: Sequence[int], *iterables: Iterable[Any] 83 | ) -> Iterator[tuple[Any, ...]]: 84 | polygon_data_zipped = zip(face, *iterables) 85 | return unique_everseen(polygon_data_zipped, key=lambda x: nth(x, 0)) 86 | 87 | @classmethod 88 | def _make_polygon(cls, polygon: FrdParser.Polygon, billboard: bool = False) -> Polygon: 89 | material = polygon.texture_id 90 | backface_culling = polygon.backface_culling 91 | quads_or_triangles = cls._validate_polygon(polygon.face, cls._texture_flags_to_uv(polygon)) 92 | face, uv = unzip(quads_or_triangles) # pylint: disable=unbalanced-tuple-unpacking 93 | return Polygon( 94 | face=tuple(face), 95 | uv=tuple(uv), 96 | material=material, 97 | backface_culling=backface_culling, 98 | is_lane=polygon.lane, 99 | non_reflective=True, 100 | animation_ticks=polygon.animation_ticks, 101 | animation_count=polygon.texture_count if not polygon.animate_uv else 0, 102 | billboard=billboard, 103 | ) 104 | 105 | @classmethod 106 | def _get_object_collision_type( 107 | cls, segment: Optional[FrdParser.SegmentData], obj: FrdParser.ObjectHeader 108 | ) -> CollisionType: 109 | logger.debug(f"Object: {vars(obj)}") 110 | if ( 111 | obj.type is not ObjectType.normal and obj.type is not ObjectType.billboard 112 | ) or segment is None: 113 | return CollisionType.none 114 | collision_type = CollisionType.none 115 | with suppress(IndexError): 116 | object_attribute = segment.object_attributes[obj.attribute_index] 117 | collision_type = object_attribute.collision_type 118 | return collision_type 119 | 120 | @classmethod 121 | def _make_matrix(cls, value: Sequence[float]) -> Matrix3x3: 122 | val = list(strictly_n(value, 9)) 123 | rows = [Vector3d(x=x, y=y, z=z) for x, y, z in transpose(chunked(val, 3, strict=True))] 124 | return Matrix3x3(x=rows[0], y=rows[1], z=rows[2]) 125 | 126 | @classmethod 127 | def _make_object( 128 | cls, 129 | segment: Optional[FrdParser.SegmentData], 130 | obj: FrdParser.ObjectHeader, 131 | extra: FrdParser.ObjectData, 132 | ) -> TrackObject: 133 | location = None 134 | actions = [] 135 | transform = None 136 | physics = None 137 | if obj.type in (ObjectType.normal, ObjectType.billboard): 138 | location = Vector3d(x=obj.location.x, y=obj.location.y, z=obj.location.z) 139 | if obj.type == ObjectType.special: 140 | location = Vector3d(x=obj.location.x, y=obj.location.y, z=obj.location.z) 141 | transform = cls._make_matrix(extra.special.transform) 142 | dimension = Vector3d( 143 | x=extra.special.dimensions.x, 144 | y=extra.special.dimensions.y, 145 | z=extra.special.dimensions.z, 146 | ) 147 | physics = PhysicsData(dimension=dimension, mass=extra.special.mass) 148 | elif obj.type == ObjectType.animated: 149 | locations = [ 150 | Vector3d.from_frd_int3(keyframe.location) for keyframe in extra.animation.keyframes 151 | ] 152 | quaternions = [ 153 | Quaternion.from_frd_short4(keyframe.quaternion) 154 | for keyframe in extra.animation.keyframes 155 | ] 156 | animation = Animation( 157 | length=extra.animation.num_keyframes, 158 | delay=extra.animation.delay, 159 | locations=locations, 160 | quaternions=quaternions, 161 | ) 162 | actions = [AnimationAction(action=Action.DEFAULT_LOOP, animation=animation)] 163 | vertex_locations = [Vector3d.from_frd_float3(vertex) for vertex in extra.vertices] 164 | polygons = [ 165 | cls._make_polygon(polygon=polygon, billboard=obj.type == ObjectType.billboard) 166 | for polygon in extra.polygons 167 | ] 168 | vertex_colors = [ 169 | Color(alpha=shading.alpha, red=shading.red, green=shading.green, blue=shading.blue) 170 | for shading in extra.vertex_shadings 171 | ] 172 | vertices = [ 173 | Vertex(location=loc, color=color) 174 | for loc, color in zip(vertex_locations, vertex_colors, strict=True) 175 | ] 176 | mesh = DrawableMesh(vertices=vertices, polygons=polygons) 177 | collision_type = cls._get_object_collision_type(segment=segment, obj=obj) 178 | return TrackObject( 179 | mesh=mesh, 180 | collision_type=collision_type, 181 | location=location, 182 | actions=actions, 183 | transform=transform, 184 | physics=physics, 185 | ) 186 | 187 | @classmethod 188 | def _make_collision_polygon( 189 | cls, segment: FrdParser.SegmentData, polygon: FrdParser.DriveablePolygon 190 | ) -> CollisionPolygon: 191 | all_edges = (Edge.FRONT, Edge.LEFT, Edge.BACK, Edge.RIGHT) 192 | poly_face = segment.chunks[4].polygons[polygon.polygon].face 193 | face, validated_edges = unzip( # pylint: disable=unbalanced-tuple-unpacking 194 | cls._validate_polygon(poly_face, all_edges) 195 | ) 196 | allowed_edges = list(validated_edges) 197 | edges: list[Edge] = [] 198 | if polygon.front_edge: 199 | edges.append(Edge.FRONT) 200 | if polygon.left_edge: 201 | edges.append(Edge.LEFT) 202 | if polygon.back_edge: 203 | edges.append(Edge.BACK) 204 | if polygon.right_edge: 205 | edges.append(Edge.RIGHT) 206 | edges = list(filter(lambda x: x in allowed_edges, edges)) 207 | return CollisionPolygon( 208 | face=tuple(face), 209 | edges=edges, 210 | has_finite_height=polygon.has_finite_height, 211 | has_wall_collision=polygon.has_wall_collision, 212 | ) 213 | 214 | @classmethod 215 | def _make_collision_mesh( 216 | cls, 217 | segment: FrdParser.SegmentData, 218 | road_effect: int, 219 | driveable_polygons: Iterable[FrdParser.DriveablePolygon], 220 | ) -> CollisionMesh: 221 | polygons = [ 222 | cls._make_collision_polygon(segment, polygon) for polygon in driveable_polygons 223 | ] 224 | vertex_locations = [Vector3d.from_frd_float3(vertex) for vertex in segment.vertices] 225 | vertices = [Vertex(location=loc) for loc in vertex_locations] 226 | mesh = CollisionMesh( 227 | vertices=vertices, polygons=polygons, collision_effect=RoadEffect(road_effect) 228 | ) 229 | return remove_unused_vertices(mesh) 230 | 231 | @classmethod 232 | def _make_collision_meshes(cls, segment: FrdParser.SegmentData) -> Iterator[CollisionMesh]: 233 | def driveable_polygon_key(driveable_polygon: FrdParser.DriveablePolygon) -> int: 234 | return int(driveable_polygon.road_effect.value) 235 | 236 | driveable_polygons = sorted(segment.driveable_polygons, key=driveable_polygon_key) 237 | driveable_mesh_groups = groupby(driveable_polygons, key=driveable_polygon_key) 238 | meshes = starmap(partial(cls._make_collision_mesh, segment), driveable_mesh_groups) 239 | return meshes 240 | 241 | @classmethod 242 | def _make_waypoints(cls, road_block: FrdParser.RoadBlock) -> Vector3d: 243 | return Vector3d(x=road_block.location.x, y=road_block.location.y, z=road_block.location.z) 244 | 245 | @classmethod 246 | def _make_track_segment( 247 | cls, 248 | header: FrdParser.SegmentHeader, 249 | segment: FrdParser.SegmentData, 250 | road_blocks: Iterable[FrdParser.RoadBlock], 251 | ) -> TrackSegment: 252 | polygons = chain.from_iterable(chunk.polygons for chunk in cls._high_poly_chunks(segment)) 253 | vertex_locations = [Vector3d.from_frd_float3(vertex) for vertex in segment.vertices] 254 | track_polygons = [cls._make_polygon(polygon) for polygon in polygons] 255 | collision_meshes = list(cls._make_collision_meshes(segment)) 256 | vertex_colors = [ 257 | Color(alpha=shading.alpha, red=shading.red, green=shading.green, blue=shading.blue) 258 | for shading in segment.vertex_shadings 259 | ] 260 | vertices = [ 261 | Vertex(location=loc, color=color) 262 | for loc, color in zip(vertex_locations, vertex_colors, strict=True) 263 | ] 264 | mesh = DrawableMesh(vertices=vertices, polygons=track_polygons) 265 | waypoints = [cls._make_waypoints(block) for block in road_blocks] 266 | return TrackSegment( 267 | mesh=mesh, 268 | collision_meshes=collision_meshes, 269 | waypoints=waypoints, 270 | ) 271 | 272 | @classmethod 273 | def _texture_flags_to_uv(cls, polygon: FrdParser.Polygon) -> list[UV]: 274 | uv = [[1, 1], [0, 1], [0, 0], [1, 0]] 275 | if polygon.mirror_y: 276 | uv[1][1], uv[2][1] = uv[2][1], uv[1][1] 277 | uv[0][1], uv[3][1] = uv[3][1], uv[0][1] 278 | if polygon.mirror_x: 279 | uv[0][0], uv[1][0] = uv[1][0], uv[0][0] 280 | uv[2][0], uv[3][0] = uv[3][0], uv[2][0] 281 | if polygon.invert: 282 | uv = list(map(lambda x: [1 - x[0], 1 - x[1]], uv)) 283 | if polygon.rotate: 284 | uv[0][1] = 1 - uv[0][1] 285 | uv[1][0] = 1 - uv[1][0] 286 | uv[2][1] = 1 - uv[2][1] 287 | uv[3][0] = 1 - uv[3][0] 288 | return [UV(u=item[0], v=item[1]) for item in uv] 289 | 290 | @classmethod 291 | def _high_poly_chunks(cls, block: FrdParser.SegmentData) -> Iterable[FrdParser.SegmentData]: 292 | return compress(block.chunks, cls.high_poly_chunks) 293 | 294 | @classmethod 295 | def _make_segment_objects(cls, segment: FrdParser.SegmentData) -> Iterator[TrackObject]: 296 | return cls._make_objects_from_chunks(segment=segment, chunks=segment.object_chunks) 297 | 298 | @classmethod 299 | def _make_objects_from_chunks( 300 | cls, segment: FrdParser.SegmentData | None, chunks: Iterable[FrdParser.ObjectChunk] 301 | ) -> Iterator[TrackObject]: 302 | objects = chain.from_iterable( 303 | zip(obj.objects, obj.object_extras, strict=True) for obj in chunks 304 | ) 305 | return starmap(partial(cls._make_object, segment), objects) 306 | 307 | @classmethod 308 | def _make_dummy(cls, dummy: FrdParser.SourceType) -> LightStub: 309 | location = Vector3d.from_frd_int3(dummy.location) 310 | identifier = dummy.type & 0x1F 311 | return LightStub(location=location, glow_id=identifier) 312 | 313 | @property 314 | def objects(self) -> Iterator[TrackObject]: 315 | segment_objects = collapse( 316 | map(self._make_segment_objects, self.frd.segment_data), levels=1 317 | ) 318 | global_chunks = (global_chunk.chunk for global_chunk in self.frd.global_objects) 319 | global_objects = self._make_objects_from_chunks(None, global_chunks) 320 | return chain(segment_objects, global_objects) 321 | 322 | @property 323 | def track_segments(self) -> Iterator[TrackSegment]: 324 | road_blocks = starmap( 325 | lambda i, x: islicen( 326 | self.frd.road_blocks, i * self.ROAD_BLOCKS_PER_SEGMENT, x.num_road_blocks 327 | ), 328 | enumerate(self.frd.segment_headers), 329 | ) 330 | return map( 331 | self._make_track_segment, self.frd.segment_headers, self.frd.segment_data, road_blocks 332 | ) 333 | 334 | @property 335 | def light_dummies(self) -> Iterator[LightStub]: 336 | lights = chain.from_iterable(segment.light_sources for segment in self.frd.segment_data) 337 | return map(self._make_dummy, lights) 338 | -------------------------------------------------------------------------------- /speedtools/track_data.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | import logging 8 | from collections.abc import Callable, Iterable, Iterator, Sequence 9 | from contextlib import suppress 10 | from dataclasses import replace 11 | from fnmatch import fnmatch 12 | from functools import partial, reduce 13 | from itertools import accumulate, chain, starmap 14 | from math import atan2, cos, tau 15 | from pathlib import Path 16 | from typing import TypeVar 17 | 18 | from more_itertools import collapse, one, take, triplewise 19 | 20 | from speedtools.cam_data import CamData 21 | from speedtools.can_data import CanData 22 | from speedtools.frd_data import FrdData 23 | from speedtools.fsh_data import FshData 24 | from speedtools.parsers import HeightsParser 25 | from speedtools.tr_ini import TrackIni 26 | from speedtools.types import ( 27 | Action, 28 | AnimationAction, 29 | Camera, 30 | CollisionMesh, 31 | CollisionPolygon, 32 | CollisionType, 33 | Color, 34 | DirectionalLight, 35 | Edge, 36 | Horizon, 37 | LightAttributes, 38 | LightStub, 39 | Polygon, 40 | Resource, 41 | TrackLight, 42 | TrackObject, 43 | TrackSegment, 44 | Vector3d, 45 | Vertex, 46 | ) 47 | from speedtools.utils import ( 48 | merge_mesh, 49 | remove_unused_vertices, 50 | slicen, 51 | unique_named_resources, 52 | ) 53 | 54 | logger = logging.getLogger(__name__) 55 | T = TypeVar("T") 56 | 57 | 58 | class TrackData: 59 | ANIMATION_ACTIONS = ( 60 | (Action.DESTROY_LOW_SPEED, "TR02.CAN"), 61 | (Action.DESTROY_HIGH_SPEED, "TR03.CAN"), 62 | ) 63 | SUN_DISTANCE = 3000 64 | ANIMATION_FPS = 64 65 | SFX_RESOURCE_FILE = Path("Data", "GAMEART", "SFX.FSH") 66 | 67 | def __init__( 68 | self, 69 | directory: Path, 70 | game_root: Path, 71 | mirrored: bool = False, 72 | night: bool = False, 73 | weather: bool = False, 74 | ) -> None: 75 | logger.debug(f"Opening directory {directory}") 76 | self.frd: FrdData = self.tr_open( 77 | constructor=FrdData.from_file, 78 | directory=directory, 79 | prefix="TR", 80 | postfix=".FRD", 81 | mirrored=mirrored, 82 | night=night, 83 | weather=weather, 84 | ) 85 | self.qfs: FshData = self.tr_open( 86 | constructor=FshData.from_file, 87 | directory=directory, 88 | prefix="TR", 89 | postfix="0.QFS", 90 | mirrored=mirrored, 91 | night=night, 92 | weather=weather, 93 | ) 94 | self.sky: FshData = self.tr_open( 95 | constructor=FshData.from_file, 96 | directory=directory, 97 | prefix="SKY", 98 | postfix=".QFS", 99 | mirrored=mirrored, 100 | night=night, 101 | weather=weather, 102 | ) 103 | self.ini: TrackIni = self.tr_open( 104 | constructor=TrackIni.from_file, 105 | directory=directory, 106 | prefix="TR", 107 | postfix=".INI", 108 | mirrored=mirrored, 109 | night=night, 110 | weather=weather, 111 | ) 112 | self.cam: CamData = self.tr_open( 113 | constructor=CamData.from_file, 114 | directory=directory, 115 | prefix="TR", 116 | postfix=".CAM", 117 | mirrored=mirrored, 118 | night=night, 119 | weather=weather, 120 | ) 121 | self.sfx: FshData = FshData.from_file(Path(game_root, self.SFX_RESOURCE_FILE)) 122 | self.can: Sequence[tuple[Action, CanData]] = self.tr_can_open(directory=directory) 123 | self.heights: HeightsParser = HeightsParser.from_file(Path(directory, "HEIGHTS.SIM")) 124 | self.resources: dict[int, Resource] = {} 125 | self.sfx_resources: dict[str, Resource] = {} 126 | self.light_glows: dict[int, LightAttributes] = {} 127 | self.mirrored: bool = mirrored 128 | self.night: bool = night 129 | self.weather: bool = weather 130 | 131 | @classmethod 132 | def tr_open( 133 | cls, 134 | constructor: Callable[[Path], T], 135 | directory: Path, 136 | prefix: str, 137 | postfix: str, 138 | mirrored: bool = False, 139 | night: bool = False, 140 | weather: bool = False, 141 | ) -> T: 142 | if weather and night: 143 | try_options = ["NW", "N", ""] 144 | elif night: 145 | try_options = ["N", ""] 146 | elif weather: 147 | try_options = ["W", ""] 148 | else: 149 | try_options = [""] 150 | for variant in try_options: 151 | with suppress(FileNotFoundError): 152 | return constructor(Path(directory, f"{prefix}{variant}{postfix}")) 153 | raise FileNotFoundError(f"File {prefix}{postfix} or its variants not found") 154 | 155 | @classmethod 156 | def tr_can_open(cls, directory: Path) -> Sequence[tuple[Action, CanData]]: 157 | data = [ 158 | (action, CanData.from_file(Path(directory, filename))) 159 | for action, filename in cls.ANIMATION_ACTIONS 160 | ] 161 | return data 162 | 163 | @classmethod 164 | def _finalize_object(cls, actions: Iterable[AnimationAction], obj: TrackObject) -> TrackObject: 165 | object_actions = list(obj.actions) 166 | if obj.collision_type is CollisionType.destructible: 167 | filtered_actions = filter( 168 | lambda x: x.action in (Action.DESTROY_LOW_SPEED, Action.DESTROY_HIGH_SPEED), 169 | actions, 170 | ) 171 | for action in filtered_actions: 172 | object_actions.append(action) 173 | return replace(obj, actions=object_actions) 174 | 175 | @classmethod 176 | def _select_wall_edge_idx(cls, polygon: CollisionPolygon, edge: Edge) -> tuple[int, int]: 177 | face = polygon.face 178 | if edge is Edge.FRONT: 179 | edge_vertex_idx = (face[1], face[0]) 180 | if edge is Edge.LEFT: 181 | edge_vertex_idx = (face[2], face[1]) 182 | if edge is Edge.BACK: 183 | edge_vertex_idx = (face[-1], face[2]) 184 | if edge is Edge.RIGHT: 185 | edge_vertex_idx = (face[0], face[-1]) 186 | return edge_vertex_idx # pylint: disable=possibly-used-before-assignment 187 | 188 | @classmethod 189 | def _get_wall_edge_idx(cls, polygon: CollisionPolygon) -> Iterable[tuple[int, int]]: 190 | return [cls._select_wall_edge_idx(polygon, x) for x in polygon.edges] 191 | 192 | @classmethod 193 | def _raise_vertex(cls, heights: Sequence[tuple[Vector3d, float]], vertex: Vertex) -> Vertex: 194 | def sort_key(vertex: Vertex, x: tuple[Vector3d, float]) -> float: 195 | location, _ = x 196 | diff = vertex.location.subtract(location) 197 | return diff.magnitude() 198 | 199 | def get_height(x: tuple[Vector3d, float]) -> float: 200 | _, height = x 201 | return height 202 | 203 | sorted_heights = sorted(heights, key=partial(sort_key, vertex)) 204 | closest_heights = take(3, sorted_heights) 205 | target_height = min(closest_heights, key=get_height) 206 | location = vertex.location 207 | y = location.y + get_height(target_height) 208 | new_location = location._replace(y=y) 209 | return replace(vertex, location=new_location) 210 | 211 | @classmethod 212 | def _make_polygon_wall( 213 | cls, heights: Sequence[tuple[Vector3d, float]], mesh: CollisionMesh 214 | ) -> CollisionMesh | None: 215 | polygons = filter(lambda x: x.has_wall_collision and x.edges, mesh.polygons) 216 | edges = [cls._get_wall_edge_idx(polygon) for polygon in polygons] 217 | vertices = mesh.vertices 218 | edge_vertex_idx = list(frozenset(collapse(edges))) 219 | vertex_idx_remapping = {idx: (i + len(vertices)) for i, idx in enumerate(edge_vertex_idx)} 220 | edge_vertices = [vertices[idx] for idx in edge_vertex_idx] 221 | raised_vertices = [cls._raise_vertex(heights, vertex) for vertex in edge_vertices] 222 | 223 | def make_polygon(edge: tuple[int, int]) -> CollisionPolygon: 224 | a, b = edge 225 | c = vertex_idx_remapping[b] 226 | d = vertex_idx_remapping[a] 227 | face = (a, b, c, d) 228 | return CollisionPolygon(face=face) 229 | 230 | vertices = vertices + raised_vertices # type: ignore[operator] 231 | wall_polygons = [make_polygon(edge) for edge in collapse(edges, base_type=tuple)] 232 | if not wall_polygons: 233 | return None 234 | mesh = CollisionMesh(vertices=vertices, polygons=wall_polygons) 235 | return remove_unused_vertices(mesh) 236 | 237 | @classmethod 238 | def _make_walls( 239 | cls, heights: Sequence[tuple[Vector3d, float]], segment: TrackSegment 240 | ) -> CollisionMesh: 241 | walls = map(lambda x: cls._make_polygon_wall(heights, x), segment.collision_meshes) 242 | filtered = filter(lambda x: x is not None, walls) 243 | return reduce(merge_mesh, filtered) # type: ignore[return-value] 244 | 245 | @classmethod 246 | def _finalize_segment( 247 | cls, heights: Iterable[tuple[Vector3d, float]], segment: TrackSegment 248 | ) -> TrackSegment: 249 | heights = list(heights) 250 | floor = segment.collision_meshes 251 | wall = cls._make_walls(heights=heights, segment=segment) 252 | collision_meshes = floor + [wall] # type: ignore[operator] 253 | return replace(segment, collision_meshes=collision_meshes) 254 | 255 | @classmethod 256 | def _make_waypoint_height_pair( 257 | cls, 258 | first: tuple[Iterable[Vector3d], Iterable[float]], 259 | middle: tuple[Iterable[Vector3d], Iterable[float]], 260 | last: tuple[Iterable[Vector3d], Iterable[float]], 261 | ) -> Iterable[tuple[Vector3d, float]]: 262 | fw, fh = first 263 | mw, mh = middle 264 | lw, lh = last 265 | waypoints = chain(fw, mw, lw) 266 | heights = chain(fh, mh, lh) 267 | return zip(waypoints, heights, strict=True) 268 | 269 | @property 270 | def objects(self) -> Iterator[TrackObject]: 271 | actions = [AnimationAction(action, can.animation) for action, can in self.can] 272 | return map(partial(self._finalize_object, actions), self.frd.objects) 273 | 274 | @property 275 | def track_segments(self) -> Iterator[TrackSegment]: 276 | segments = list(self.frd.track_segments) 277 | height_num = map(lambda x: len(x.waypoints), segments) 278 | height_idx = accumulate(segments, func=lambda x, y: x + len(y.waypoints), initial=0) 279 | heights = map(lambda i, n: slicen(self.heights.heights, i, n), height_idx, height_num) 280 | waypoints = map(lambda x: x.waypoints, segments) 281 | waypoints_and_heights = list(zip(waypoints, heights)) 282 | waypoints_and_heights = [ 283 | waypoints_and_heights[-1], 284 | *waypoints_and_heights, 285 | waypoints_and_heights[0], 286 | ] 287 | triples = triplewise(waypoints_and_heights) 288 | chained = starmap(self._make_waypoint_height_pair, triples) 289 | return map(self._finalize_segment, chained, segments) 290 | 291 | @property 292 | def track_resources(self) -> Iterator[Resource]: 293 | return self.qfs.resources 294 | 295 | def _init_resources(self) -> None: 296 | if self.mirrored: 297 | resources = filter(lambda resource: not resource.nonmirrored, self.qfs.resources) 298 | else: 299 | resources = filter(lambda resource: not resource.mirrored, self.qfs.resources) 300 | unique_named = unique_named_resources(iterable=resources) 301 | self.resources = dict(enumerate(unique_named)) 302 | self.sfx_resources = {res.name: res for res in self.sfx.resources} 303 | 304 | def get_polygon_material(self, polygon: Polygon) -> Resource: 305 | mat = polygon.material 306 | if not self.resources: 307 | self._init_resources() 308 | if polygon.is_lane: 309 | return self.sfx_resources[f"lin{mat}"] 310 | return self.resources[mat] 311 | 312 | def _make_light(self, stub: LightStub) -> TrackLight: 313 | attributes = self.light_glows[stub.glow_id] 314 | return TrackLight( 315 | location=stub.location, 316 | color=attributes.color, 317 | blink_interval_ms=attributes.blink_interval_ms, 318 | flare_size=attributes.flare_size, 319 | ) 320 | 321 | @property 322 | def lights(self) -> Iterator[TrackLight]: 323 | if not self.light_glows: 324 | for attribute in self.ini.glows: 325 | self.light_glows[attribute.identifier] = attribute 326 | return map(self._make_light, self.frd.light_dummies) 327 | 328 | @property 329 | def directional_light(self) -> DirectionalLight | None: 330 | sun = self.ini.sun 331 | sun_resource = self._sun_resource 332 | if sun is None and sun_resource: 333 | sun = sun_resource.sun_attributes 334 | if sun: 335 | # Angles in INI are in turns. Turns are converted to radians here. 336 | # The INI angleRho value is not really a spherical coordinate. 337 | # Some approximations are done here to convert to spherical coordinates. 338 | rho = sun.angle_rho * tau 339 | z = self.SUN_DISTANCE * cos(rho) 340 | phi = atan2(z, self.SUN_DISTANCE) 341 | theta = sun.angle_theta * tau 342 | return DirectionalLight( 343 | phi=phi, 344 | theta=theta, 345 | radius=sun.radius, 346 | resource=sun_resource, 347 | additive=sun.additive, 348 | in_front=sun.in_front, 349 | rotates=sun.rotates, 350 | ) 351 | return None 352 | 353 | @property 354 | def cameras(self) -> Iterable[Camera]: 355 | return self.cam.cameras 356 | 357 | @property 358 | def ambient_color(self) -> Color: 359 | return self.ini.ambient_color 360 | 361 | @property 362 | def horizon(self) -> Horizon: 363 | return self.ini.horizon 364 | 365 | @property 366 | def sky_images(self) -> Iterable[Resource]: 367 | weather = "W" if self.weather else "C" 368 | night = "N" if self.night else "D" 369 | return filter(lambda x: fnmatch(x.name, f"H{night}{weather}?"), self.sky.resources) 370 | 371 | @property 372 | def _sun_resource(self) -> Resource: 373 | match (self.night, self.weather): 374 | case (False, False): 375 | name = "SUND" 376 | case (True, False): 377 | name = "SUNN" 378 | case (False, True): 379 | name = "SUNW" 380 | case (True, True): 381 | name = "SUNW" 382 | return one(filter(lambda x: x.name == name, self.sky.resources)) 383 | 384 | @property 385 | def clouds(self) -> Resource: 386 | weather = "W" if self.weather else "D" 387 | night = "N" if self.night else "D" 388 | return one(filter(lambda x: x.name == f"CL{weather}{night}", self.sky.resources)) 389 | -------------------------------------------------------------------------------- /speedtools/specs/frd.ksy: -------------------------------------------------------------------------------- 1 | meta: 2 | id: frd 3 | file-extension: frd 4 | license: CC0-1.0 5 | endian: le 6 | seq: 7 | - id: unknown 8 | size: 28 9 | - id: num_segments 10 | type: u4 11 | - id: num_road_blocks 12 | type: u4 13 | - id: road_blocks 14 | type: road_block 15 | repeat: expr 16 | repeat-expr: num_road_blocks 17 | - id: segment_headers 18 | type: segment_header 19 | repeat: expr 20 | repeat-expr: num_segments + 1 21 | - id: segment_data 22 | type: segment_data 23 | parent: segment_headers[_index] 24 | repeat: expr 25 | repeat-expr: num_segments + 1 26 | - id: global_objects 27 | type: global_object_chunk 28 | repeat: expr 29 | repeat-expr: 2 30 | types: 31 | float3: 32 | seq: 33 | - id: x 34 | type: f4 35 | - id: y 36 | type: f4 37 | - id: z 38 | type: f4 39 | float4: 40 | seq: 41 | - id: x 42 | type: f4 43 | - id: y 44 | type: f4 45 | - id: z 46 | type: f4 47 | - id: w 48 | type: f4 49 | int3: 50 | seq: 51 | - id: x 52 | type: s4 53 | - id: y 54 | type: s4 55 | - id: z 56 | type: s4 57 | short4: 58 | seq: 59 | - id: x 60 | type: s2 61 | - id: y 62 | type: s2 63 | - id: z 64 | type: s2 65 | - id: w 66 | type: s2 67 | short3: 68 | seq: 69 | - id: x 70 | type: s2 71 | - id: y 72 | type: s2 73 | - id: z 74 | type: s2 75 | road_block: 76 | seq: 77 | - id: location 78 | type: float3 79 | doc: Road block node location 80 | - id: normal 81 | type: float3 82 | doc: Normal vector of the road plane 83 | - id: forward 84 | type: float3 85 | doc: Unit vector pointing forwards 86 | - id: right 87 | type: float3 88 | doc: Unit vector pointing right 89 | - id: left_wall 90 | type: f4 91 | doc: Distance to the left wall 92 | - id: right_wall 93 | type: f4 94 | doc: Distance to the right wall 95 | - id: unknown1 96 | size: 8 97 | - id: neighbors 98 | type: u2 99 | repeat: expr 100 | repeat-expr: 2 101 | doc: Neighboring nodes 102 | - id: unknown2 103 | size: 16 104 | segment_header: 105 | seq: 106 | - id: num_polygons 107 | type: u4 108 | repeat: expr 109 | repeat-expr: 11 110 | doc: Number of polygons in each chunk 111 | - id: unused1 112 | size: 44 113 | doc: Empty space 114 | - id: num_vertices 115 | type: u4 116 | doc: Number of vertices in the track segment 117 | - id: num_high_res_vertices 118 | type: u4 119 | doc: Number of high-resolution vertices in the track segment 120 | - id: num_low_res_vertices 121 | type: u4 122 | doc: Number of low-resolution vertices in the track segment 123 | - id: num_medium_res_vertices 124 | type: u4 125 | doc: Number of medium-resolution vertices in the track segment 126 | - id: num_vertices_dup 127 | type: u4 128 | doc: Number of vertices in the track segment 129 | - id: num_object_vertices 130 | type: u4 131 | doc: Number of vertices used by off-road track objects 132 | - id: unused2 133 | size: 8 134 | doc: Empty space 135 | - id: location 136 | type: float3 137 | doc: Center location of the track segment 138 | - id: bounding_points 139 | type: float3 140 | repeat: expr 141 | repeat-expr: 4 142 | doc: Coordinates of the points delimiting the segment boundary 143 | - id: neighbors 144 | type: neighbor 145 | repeat: expr 146 | repeat-expr: 300 147 | doc: List of segment numbers neighboring with this segment 148 | - id: num_objects_per_chunks 149 | type: num_objects_per_chunk 150 | repeat: expr 151 | repeat-expr: 4 152 | doc: Number of road objects stored in each object chunk. 153 | - id: num_driveable_polygons 154 | type: u4 155 | doc: Number of driveable track polygon attributes 156 | - id: min_point 157 | type: float3 158 | doc: Minimum vertex coordinate for driveable track polygon 159 | - id: max_point 160 | type: float3 161 | doc: Maximum vertex coordinate for diriveable track polygon 162 | - id: unused3 163 | size: 4 164 | doc: Empty space 165 | - id: num_road_blocks 166 | type: u4 167 | doc: Number of road blocks associated with this segment 168 | - id: num_road_objects 169 | type: u4 170 | doc: Number of non-animated road objects 171 | - id: unused4 172 | size: 4 173 | doc: Empty space 174 | - id: num_polygon_objects 175 | type: u4 176 | doc: Number of off-road polygon objects 177 | - id: unused5 178 | size: 4 179 | doc: Empty space 180 | - id: num_sound_sources 181 | type: u4 182 | doc: Number of sound sources 183 | - id: unused6 184 | size: 4 185 | doc: Empty space 186 | - id: num_light_sources 187 | type: u4 188 | doc: Number of light sources 189 | - id: unused7 190 | size: 4 191 | doc: Empty space 192 | - id: neighbor_segments 193 | type: u4 194 | repeat: expr 195 | repeat-expr: 8 196 | doc: List of segments in direct contact with this segment 197 | neighbor: 198 | seq: 199 | - id: block 200 | type: s2 201 | doc: Identifier of the neighboring block 202 | - id: unknown 203 | type: s2 204 | num_objects_per_chunk: 205 | seq: 206 | - id: num_objects 207 | type: u4 208 | - id: unknown 209 | size: 4 210 | segment_data: 211 | seq: 212 | - id: vertices 213 | type: float3 214 | repeat: expr 215 | repeat-expr: _parent.num_vertices 216 | doc: Vertice coordinates 217 | - id: vertex_shadings 218 | type: color 219 | repeat: expr 220 | repeat-expr: _parent.num_vertices 221 | doc: Vertex shading color 222 | - id: driveable_polygons 223 | type: driveable_polygon 224 | repeat: expr 225 | repeat-expr: _parent.num_driveable_polygons 226 | doc: Additional attributes for driveable track polygons 227 | - id: object_attributes 228 | type: object_attribute 229 | repeat: expr 230 | repeat-expr: _parent.num_road_objects 231 | doc: Additional attributes for track objects 232 | - id: second_object_attributes 233 | type: object_attribute_2_padded(_parent.num_polygon_objects) 234 | size: 20 * _parent.num_polygon_objects 235 | doc: Additional attributes for polygon and some track objects 236 | - id: sound_sources 237 | type: source_type 238 | repeat: expr 239 | repeat-expr: _parent.num_sound_sources 240 | doc: Sound source data 241 | - id: light_sources 242 | type: source_type 243 | repeat: expr 244 | repeat-expr: _parent.num_light_sources 245 | doc: Light source data 246 | - id: chunks 247 | type: track_polygon(_parent.num_polygons[_index]) 248 | repeat: expr 249 | repeat-expr: 11 250 | doc: Polygon data chunks 251 | - id: object_chunks 252 | type: object_chunk(_parent.num_objects_per_chunks[_index].num_objects) 253 | repeat: expr 254 | repeat-expr: 4 255 | doc: Object data chunks 256 | driveable_polygon: 257 | seq: 258 | - id: min_y 259 | type: u1 260 | doc: Minimum value of the Y coordinate 261 | - id: max_y 262 | type: u1 263 | doc: Maximum value of the Y coordinate 264 | - id: min_x 265 | type: u1 266 | doc: Minimum value of the X coordinate 267 | - id: max_x 268 | type: u1 269 | doc: Maximum value of the X coordinate 270 | - id: front_edge 271 | type: u1 272 | doc: Front edge flags 273 | - id: left_edge 274 | type: u1 275 | doc: Left edge flags 276 | - id: back_edge 277 | type: u1 278 | doc: Back edge flags 279 | - id: right_edge 280 | type: u1 281 | doc: Right edge flags 282 | - id: collision_flags 283 | type: u1 284 | doc: Polygon collision flags 285 | - id: unknown 286 | size: 1 287 | - id: polygon 288 | type: u2 289 | doc: Index of the polygon in high-resolution track chunk described by this structure 290 | - id: normal 291 | type: short3 292 | - id: forward 293 | type: short3 294 | instances: 295 | road_effect: 296 | value: collision_flags & 0x0f 297 | enum: road_effect 298 | has_finite_height: 299 | value: collision_flags & 0x20 != 0 300 | has_object_collision: 301 | value: collision_flags & 0x40 != 0 302 | has_wall_collision: 303 | value: collision_flags & 0x80 != 0 304 | enums: 305 | road_effect: 306 | 0: not_driveable 307 | 1: driveable1 308 | 2: gravel 309 | 3: driveable2 310 | 4: leaves1 311 | 5: dust1 312 | 6: driveable3 313 | 7: driveable4 314 | 8: driveable5 315 | 9: snow1 316 | 10: driveable6 317 | 11: leaves2 318 | 12: driveable7 319 | 13: dust2 320 | 14: driveable8 321 | 15: snow2 322 | object_attribute: 323 | seq: 324 | - id: location 325 | type: int3 326 | doc: Coordinate of the object reference point 327 | - id: unknown1 328 | size: 2 329 | - id: identifier 330 | type: u2 331 | doc: Unique identifier of the object 332 | - id: unknown2 333 | size: 3 334 | - id: collision_type 335 | type: u1 336 | enum: collision_type 337 | doc: Collision type of the object 338 | enums: 339 | collision_type: 340 | 0x00: none 341 | 0x01: static 342 | 0x02: destructible 343 | 0x03: unknown 344 | object_attribute_2_padded: 345 | params: 346 | - id: num_attributes 347 | type: u4 348 | seq: 349 | - id: attributes 350 | type: object_attribute_2 351 | repeat: expr 352 | repeat-expr: num_attributes 353 | object_attribute_2: 354 | seq: 355 | - id: unknown1 356 | size: 2 357 | doc: Unknown use 358 | - id: type 359 | type: u1 360 | enum: attribute_type 361 | doc: Object attribute type 362 | - id: identifier 363 | type: u1 364 | doc: Object identifier number 365 | - id: location 366 | type: int3 367 | doc: Object location 368 | - id: cross_index 369 | type: u1 370 | doc: Unknown use 371 | if: type != attribute_type::polygon_object 372 | - id: unknown2 373 | size: 3 374 | if: type != attribute_type::polygon_object 375 | enums: 376 | attribute_type: 377 | 0x01: polygon_object 378 | 0x02: road_object1 379 | 0x03: road_object2 380 | 0x04: road_object3 381 | 0x06: special 382 | source_type: 383 | seq: 384 | - id: location 385 | type: int3 386 | doc: Source location 387 | - id: type 388 | type: u4 389 | doc: Source type 390 | track_polygon: 391 | params: 392 | - id: num_polygons 393 | type: u4 394 | seq: 395 | - id: polygons 396 | type: polygon 397 | repeat: expr 398 | repeat-expr: num_polygons 399 | doc: Sequence of polygons 400 | polygon: 401 | seq: 402 | - id: face 403 | type: u2 404 | repeat: expr 405 | repeat-expr: 4 406 | doc: Indices of the vertices building the polygon 407 | - id: texture 408 | type: u2 409 | doc: Texture data 410 | - id: flags 411 | type: u2 412 | doc: Polygon flags 413 | - id: animation 414 | type: u1 415 | doc: Texture animation data 416 | instances: 417 | backface_culling: 418 | value: (flags & 0x8000) == 0 419 | mirror_y: 420 | value: (flags & 0x0020) != 0 421 | mirror_x: 422 | value: (flags & 0x0010) != 0 423 | invert: 424 | value: (flags & 0x0008) != 0 425 | rotate: 426 | value: (flags & 0x0004) != 0 427 | animate_uv: 428 | value: (flags & 0x2000) != 0 429 | lane: 430 | value: (texture & 0x0800) != 0 431 | texture_id: 432 | value: (texture & 0x07FF) 433 | texture_count: 434 | value: (animation & 0x7) 435 | animation_ticks: 436 | value: (animation >> 3) 437 | object_chunk: 438 | params: 439 | - id: num_objects 440 | type: u4 441 | seq: 442 | - id: objects 443 | type: object_header 444 | repeat: expr 445 | repeat-expr: num_objects 446 | - id: object_extras 447 | type: object_data 448 | parent: objects[_index] 449 | repeat: expr 450 | repeat-expr: objects.size 451 | global_object_chunk: 452 | seq: 453 | - id: num_objects 454 | type: u4 455 | - id: chunk 456 | type: object_chunk(num_objects) 457 | object_header: 458 | seq: 459 | - id: type 460 | type: u4 461 | enum: object_type 462 | doc: Object type 463 | - id: attribute_index 464 | type: u4 465 | doc: Index od the additional object attribute data 466 | - id: unknown 467 | size: 4 468 | - id: location 469 | type: float3 470 | doc: Location of the object 471 | - id: specific_data_size 472 | type: u4 473 | doc: Size of the object specific data 474 | - id: unused1 475 | size: 4 476 | - id: num_vertices 477 | type: u4 478 | doc: Number of vertices in object geometry 479 | - id: unused2 480 | size: 8 481 | - id: num_polygons 482 | type: u4 483 | doc: Number of polygons in object geometry 484 | - id: unused3 485 | size: 4 486 | enums: 487 | object_type: 488 | 0x02: billboard 489 | 0x03: animated 490 | 0x04: normal 491 | 0x06: special 492 | object_data: 493 | seq: 494 | - id: animation 495 | type: animation 496 | if: _parent.type == object_header::object_type::animated 497 | doc: Animation data 498 | size: _parent.specific_data_size 499 | - id: special 500 | type: special_data 501 | if: _parent.type == object_header::object_type::special 502 | doc: Special object data 503 | size: _parent.specific_data_size 504 | - id: vertices 505 | type: float3 506 | repeat: expr 507 | repeat-expr: _parent.num_vertices 508 | doc: Vertice coordinates 509 | - id: vertex_shadings 510 | type: color 511 | repeat: expr 512 | repeat-expr: _parent.num_vertices 513 | doc: Vertex shading color 514 | - id: polygons 515 | type: polygon 516 | repeat: expr 517 | repeat-expr: _parent.num_polygons 518 | doc: Object polygons 519 | animation: 520 | seq: 521 | - id: head 522 | type: u2 523 | doc: Head value with unknown use 524 | - id: type 525 | type: u1 526 | doc: Type value with unknown use 527 | - id: identifier 528 | type: u1 529 | doc: Unique identifier 530 | - id: num_keyframes 531 | type: u2 532 | doc: Number of keyframes in the animation 533 | - id: delay 534 | type: u2 535 | doc: Initial delay of the animation 536 | - id: keyframes 537 | type: keyframe 538 | repeat: expr 539 | repeat-expr: num_keyframes 540 | doc: Animation keyframes 541 | special_data: 542 | seq: 543 | - id: location 544 | type: float3 545 | - id: mass 546 | type: f4 547 | doc: Object mass 548 | - id: transform 549 | type: f4 550 | repeat: expr 551 | repeat-expr: 9 552 | - id: dimensions 553 | type: float3 554 | doc: Collider dimensions 555 | - id: unknown3 556 | type: u4 557 | - id: unknown4 558 | type: u2 559 | - id: unknown5 560 | type: u2 561 | keyframe: 562 | seq: 563 | - id: location 564 | type: int3 565 | doc: Object location at keyframe 566 | - id: quaternion 567 | type: short4 568 | doc: Object rotation at keyframe 569 | color: 570 | seq: 571 | - id: blue 572 | type: u1 573 | doc: Blue color channel 574 | - id: green 575 | type: u1 576 | doc: Green color channel 577 | - id: red 578 | type: u1 579 | doc: Red color channel 580 | - id: alpha 581 | type: u1 582 | doc: Alpha color channel 583 | -------------------------------------------------------------------------------- /speedtools/blender/io_nfs4_import.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2023 Rafał Kuźnia 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | from abc import ABCMeta, abstractmethod 11 | from collections.abc import Callable, Iterable 12 | from dataclasses import dataclass, replace 13 | from functools import total_ordering 14 | from itertools import chain, groupby 15 | from math import pi, radians 16 | from pathlib import Path 17 | from typing import Any, Literal 18 | 19 | import bpy 20 | import mathutils 21 | from bpy.props import BoolProperty, EnumProperty, StringProperty 22 | from more_itertools import collapse, duplicates_everseen, one, unique_everseen 23 | 24 | from speedtools import TrackData, VivData 25 | from speedtools.types import ( 26 | Action, 27 | AnimationAction, 28 | BaseMesh, 29 | BlendMode, 30 | Camera, 31 | Color, 32 | DirectionalLight, 33 | DrawableMesh, 34 | Light, 35 | Matrix3x3, 36 | Part, 37 | Polygon, 38 | Resource, 39 | ShapeKey, 40 | Vector3d, 41 | VehicleLightType, 42 | Vertex, 43 | ) 44 | from speedtools.utils import ( 45 | create_pil_image, 46 | image_to_png, 47 | make_horizon_texture, 48 | pil_image_to_png, 49 | ) 50 | 51 | bl_info = { 52 | "name": "Import NFS4 Track", 53 | "author": "Rafał Kuźnia", 54 | "version": (1, 0, 0), 55 | "blender": (3, 4, 1), 56 | "location": "File > Import > Track resource", 57 | "description": "Imports a NFS4 track files (meshes, textures and objects)." 58 | "Scripts/Import-Export/Track_Resource", 59 | "category": "Import-Export", 60 | } 61 | 62 | logger = logging.getLogger() 63 | logger.setLevel(logging.INFO) 64 | logger.addHandler(logging.StreamHandler()) 65 | 66 | 67 | @total_ordering 68 | @dataclass(frozen=True) 69 | class ExtendedResource: 70 | resource: Resource 71 | backface_culling: bool 72 | transparent: bool 73 | highly_reflective: bool 74 | non_reflective: bool 75 | animation_ticks: int 76 | animation_resources: tuple[Resource, ...] 77 | billboard: bool 78 | 79 | def __lt__(self, other: ExtendedResource) -> bool: 80 | return hash(self) < hash(other) 81 | 82 | 83 | class BaseImporter(metaclass=ABCMeta): 84 | def __init__( 85 | self, material_map: Callable[[Polygon], Resource], import_shading: bool = False 86 | ) -> None: 87 | self.materials: dict[ExtendedResource, bpy.types.Material] = {} 88 | self.images: dict[Resource, bpy.types.Image] = {} 89 | self.material_map = material_map 90 | self.import_shading = import_shading 91 | self.rot_mat = mathutils.Euler( 92 | (0.0, 0.0, pi) 93 | ).to_matrix() # Transformation from game space to Blender space 94 | 95 | @classmethod 96 | def duplicate_common_vertices(cls, mesh: DrawableMesh) -> DrawableMesh: 97 | unique_vert_polys = list(unique_everseen(mesh.polygons, key=lambda x: frozenset(x.face))) 98 | duplicate_vert_polys = list( 99 | duplicates_everseen(mesh.polygons, key=lambda x: frozenset(x.face)) 100 | ) 101 | faces = frozenset(chain.from_iterable(poly.face for poly in duplicate_vert_polys)) 102 | verts_to_duplicate = [mesh.vertices[x] for x in faces] 103 | mapping = {f: i for i, f in enumerate(faces, start=len(mesh.vertices))} 104 | 105 | def _make_polygon(polygon: Polygon) -> Polygon: 106 | face = tuple(mapping[f] for f in polygon.face) 107 | return replace(polygon, face=face) 108 | 109 | polygons = unique_vert_polys + [_make_polygon(polygon) for polygon in duplicate_vert_polys] 110 | vertices = list(mesh.vertices) + verts_to_duplicate 111 | return replace(mesh, vertices=vertices, polygons=polygons) 112 | 113 | def _extender_resource_map(self, polygon: Polygon) -> ExtendedResource: 114 | resource = self.material_map(polygon) 115 | animation_resources = [] 116 | for i in range(polygon.animation_count): 117 | next_poly = replace(polygon, material=(polygon.material + i)) 118 | animation_resources.append(self.material_map(next_poly)) 119 | return ExtendedResource( 120 | resource=resource, 121 | backface_culling=polygon.backface_culling, 122 | transparent=polygon.transparent, 123 | highly_reflective=polygon.highly_reflective, 124 | non_reflective=polygon.non_reflective, 125 | animation_ticks=polygon.animation_ticks, 126 | animation_resources=tuple(animation_resources), 127 | billboard=polygon.billboard, 128 | ) 129 | 130 | def _link_texture_to_shader( 131 | self, 132 | node_tree: bpy.types.NodeTree, 133 | texture: bpy.types.Node, 134 | shader: bpy.types.Node, 135 | resource: Resource, 136 | ) -> None: 137 | if self.import_shading: 138 | color_attributes = node_tree.nodes.new("ShaderNodeAttribute") 139 | color_attributes.attribute_name = "Shading" # type: ignore[attr-defined] 140 | mixer = node_tree.nodes.new("ShaderNodeMix") 141 | mixer.data_type = "RGBA" # type: ignore[attr-defined] 142 | mixer.blend_type = "MULTIPLY" # type: ignore[attr-defined] 143 | mixer.inputs[0].default_value = 1.0 # type: ignore[attr-defined] 144 | node_tree.links.new(texture.outputs["Color"], mixer.inputs["A"]) 145 | node_tree.links.new(color_attributes.outputs["Color"], mixer.inputs["B"]) 146 | node_tree.links.new(mixer.outputs["Result"], shader.inputs["Base Color"]) 147 | else: 148 | node_tree.links.new(texture.outputs["Color"], shader.inputs["Base Color"]) 149 | if resource.blend_mode is None: 150 | math_node = node_tree.nodes.new("ShaderNodeMath") 151 | math_node.operation = "ROUND" # type: ignore[attr-defined] 152 | node_tree.links.new(texture.outputs["Alpha"], math_node.inputs["Value"]) 153 | node_tree.links.new(math_node.outputs["Value"], shader.inputs["Alpha"]) 154 | else: 155 | node_tree.links.new(texture.outputs["Alpha"], shader.inputs["Alpha"]) 156 | 157 | def _set_blend_mode( 158 | self, 159 | node_tree: bpy.types.NodeTree, 160 | shader_output: bpy.types.NodeSocket, 161 | bpy_material: bpy.types.Material, 162 | resource: Resource, 163 | ) -> bpy.types.NodeSocket: 164 | output_socket = shader_output 165 | if resource.blend_mode is BlendMode.ADDITIVE: 166 | bpy_material["SPT_additive"] = True 167 | transparent_bsdf = node_tree.nodes.new("ShaderNodeBsdfTransparent") 168 | add_shader = node_tree.nodes.new("ShaderNodeAddShader") 169 | node_tree.links.new(shader_output, add_shader.inputs[0]) 170 | node_tree.links.new(transparent_bsdf.outputs["BSDF"], add_shader.inputs[1]) 171 | output_socket = add_shader.outputs["Shader"] 172 | return output_socket 173 | 174 | def _image_to_bpy_image(self, name: str, image: Any) -> bpy.types.Image: 175 | image_data = pil_image_to_png(image) 176 | bpy_image = bpy.data.images.new(name, 8, 8) 177 | bpy_image.pack(data=image_data, data_len=len(image_data)) # type: ignore[arg-type] 178 | bpy_image.source = "FILE" 179 | return bpy_image 180 | 181 | def _image_from_resource(self, resource: Resource) -> bpy.types.Image: 182 | image_data = image_to_png(resource.image) 183 | bpy_image = bpy.data.images.new(resource.name, 8, 8) 184 | bpy_image.pack(data=image_data, data_len=len(image_data)) # type: ignore[arg-type] 185 | bpy_image.source = "FILE" 186 | return bpy_image 187 | 188 | def _image_from_resource_cached(self, resource: Resource) -> bpy.types.Image: 189 | try: 190 | return self.images[resource] 191 | except KeyError: 192 | img = self._image_from_resource(resource) 193 | self.images[resource] = img 194 | return self.images[resource] 195 | 196 | def _make_material(self, ext_resource: ExtendedResource) -> bpy.types.Material: 197 | resource = ext_resource.resource 198 | bpy_material = bpy.data.materials.new(resource.name) 199 | bpy_material.use_nodes = True 200 | node_tree = bpy_material.node_tree 201 | bsdf = node_tree.nodes["Principled BSDF"] # type: ignore[union-attr] 202 | bsdf.inputs["Specular IOR Level"].default_value = 0.25 # type: ignore[union-attr] 203 | bsdf.inputs["Roughness"].default_value = 0.05 # type: ignore[union-attr] 204 | bsdf.inputs["Metallic"].default_value = 0.0 # type: ignore[union-attr] 205 | if ext_resource.transparent: 206 | bsdf.inputs["Alpha"].default_value = 0.04 # type: ignore[union-attr] 207 | bsdf.inputs["Roughness"].default_value = 0.0 # type: ignore[union-attr] 208 | bsdf.inputs["Specular IOR Level"].default_value = 0.5 # type: ignore[union-attr] 209 | bpy_material["SPT_transparent"] = True 210 | else: 211 | material_output = node_tree.nodes.get("Material Output") # type: ignore[union-attr] 212 | image = self._image_from_resource_cached(resource) 213 | image_texture = node_tree.nodes.new("ShaderNodeTexImage") # type: ignore[union-attr] 214 | image_texture.image = image # type: ignore[union-attr] 215 | image_texture.extension = "EXTEND" # type: ignore[union-attr] 216 | self._link_texture_to_shader( 217 | node_tree=node_tree, # type: ignore[arg-type] 218 | texture=image_texture, 219 | shader=bsdf, 220 | resource=resource, 221 | ) 222 | output_socket = self._set_blend_mode( 223 | node_tree=node_tree, # type: ignore[arg-type] 224 | shader_output=bsdf.outputs["BSDF"], 225 | bpy_material=bpy_material, 226 | resource=resource, 227 | ) 228 | node_tree.links.new(output_socket, material_output.inputs["Surface"]) # type: ignore[union-attr] 229 | if ext_resource.highly_reflective: 230 | # bsdf.inputs["Base Color"].default_value = (1.0, 1.0, 1.0, 1.0) # type: ignore[union-attr] 231 | bsdf.inputs["Specular IOR Level"].default_value = 0.50 # type: ignore[union-attr] 232 | if ext_resource.non_reflective: 233 | bsdf.inputs["Roughness"].default_value = 1.0 # type: ignore[union-attr] 234 | bsdf.inputs["Specular IOR Level"].default_value = 0.0 # type: ignore[union-attr] 235 | bpy_material.use_backface_culling = ext_resource.backface_culling 236 | if ext_resource.animation_resources: 237 | bpy_material["SPT_animation_images"] = [ 238 | img.name for img in ext_resource.animation_resources 239 | ] 240 | bpy_material["SPT_animation_ticks"] = ext_resource.animation_ticks 241 | for resource in ext_resource.animation_resources: 242 | self._image_from_resource(resource) 243 | bpy_material["SPT_billboard"] = ext_resource.billboard 244 | return bpy_material 245 | 246 | def _map_material(self, ext_resource: ExtendedResource) -> bpy.types.Material: 247 | try: 248 | return self.materials[ext_resource] 249 | except KeyError: 250 | bpy_material = self._make_material(ext_resource=ext_resource) 251 | self.materials[ext_resource] = bpy_material 252 | return self.materials[ext_resource] 253 | 254 | def make_base_mesh(self, name: str, mesh: BaseMesh) -> bpy.types.Mesh: 255 | vertices_rot = [mathutils.Vector(vert) @ self.rot_mat for vert in mesh.vertex_locations] 256 | bpy_mesh = bpy.data.meshes.new(name) 257 | bpy_mesh.from_pydata( 258 | vertices=vertices_rot, 259 | edges=[], 260 | faces=[polygon.face for polygon in mesh.polygons], 261 | ) 262 | return bpy_mesh 263 | 264 | def set_object_location(self, obj: bpy.types.Object, location: Vector3d) -> None: 265 | mu_location = mathutils.Vector(location) 266 | obj.location = self.rot_mat @ mu_location 267 | 268 | def set_object_action(self, obj: bpy.types.Object, action: AnimationAction) -> None: 269 | animation = action.animation 270 | obj.rotation_mode = "QUATERNION" 271 | if obj.animation_data is None: 272 | anim_data = obj.animation_data_create() 273 | else: 274 | anim_data = obj.animation_data 275 | bpy_action = bpy.data.actions.new(name=str(action.action)) 276 | anim_data.action = bpy_action 277 | for index, (location, quaternion) in enumerate( 278 | zip(animation.locations, animation.quaternions) 279 | ): 280 | rot_quat = mathutils.Euler((0.0, 0.0, pi)).to_quaternion() 281 | mu_location = rot_quat @ mathutils.Vector(location) 282 | mu_quaternion = mathutils.Quaternion(quaternion) 283 | mu_quaternion = mu_quaternion.normalized() 284 | mu_quaternion = rot_quat @ mu_quaternion.inverted() 285 | obj.rotation_quaternion = rot_quat 286 | obj.delta_location = mu_location 287 | obj.delta_rotation_quaternion = mu_quaternion 288 | interval = index * animation.delay 289 | obj.keyframe_insert( 290 | data_path="delta_location", frame=interval, options={"INSERTKEY_CYCLE_AWARE"} 291 | ) 292 | obj.keyframe_insert( 293 | data_path="delta_rotation_quaternion", 294 | frame=interval, 295 | options={"INSERTKEY_CYCLE_AWARE"}, 296 | ) 297 | points = chain.from_iterable(fcurve.keyframe_points for fcurve in bpy_action.fcurves) 298 | for point in points: 299 | point.interpolation = "LINEAR" 300 | bpy_action.name = f"{obj.name}-action-{action.action}" 301 | track = anim_data.nla_tracks.new() 302 | track.strips.new(name=bpy_action.name, start=0, action=bpy_action) 303 | 304 | def set_object_rotation( 305 | self, 306 | obj: bpy.types.Object, 307 | transform: Matrix3x3 | mathutils.Matrix, 308 | offset: mathutils.Euler | None = None, 309 | ) -> None: 310 | mu_matrix = self.rot_mat @ mathutils.Matrix(transform) # type: ignore[arg-type] 311 | if offset: 312 | mu_euler = offset 313 | mu_euler.rotate(mu_matrix.to_euler("XYZ")) # pylint: disable=all 314 | else: 315 | mu_euler = mu_matrix.to_euler("XYZ") # pylint: disable=all 316 | obj.rotation_mode = "XYZ" 317 | obj.rotation_euler = mu_euler 318 | 319 | def make_drawable_object(self, name: str, mesh: DrawableMesh) -> bpy.types.Object: 320 | bpy_mesh = self.make_base_mesh(name=name, mesh=mesh) 321 | uv_layer = bpy_mesh.uv_layers.new() 322 | uvs = collapse(polygon.uv for polygon in mesh.polygons) 323 | uv_layer.data.foreach_set("uv", list(uvs)) 324 | if mesh.vertex_normals: 325 | normals = [mathutils.Vector(normal) @ self.rot_mat for normal in mesh.vertex_normals] 326 | bpy_mesh.normals_split_custom_set_from_vertices(normals) # type: ignore[arg-type] 327 | if mesh.vertex_colors and self.import_shading: 328 | colors = collapse(color.rgba_float for color in mesh.vertex_colors) 329 | bpy_colors = bpy_mesh.color_attributes.new( 330 | name="Shading", type="BYTE_COLOR", domain="POINT" 331 | ) 332 | bpy_colors.data.foreach_set("color", tuple(colors)) # type: ignore[attr-defined] 333 | polygon_pairs = zip(mesh.polygons, bpy_mesh.polygons) 334 | sorted_by_material = sorted(polygon_pairs, key=lambda x: self._extender_resource_map(x[0])) 335 | grouped_by_material = groupby( 336 | sorted_by_material, key=lambda x: self._extender_resource_map(x[0]) 337 | ) 338 | for index, (key, group) in enumerate(grouped_by_material): 339 | material = self._map_material(key) 340 | bpy_mesh.materials.append(material) 341 | for _, bpy_polygon in group: 342 | if not mesh.vertex_normals: 343 | bpy_polygon.use_smooth = True 344 | bpy_polygon.material_index = index 345 | bpy_mesh.validate() 346 | bpy_obj = bpy.data.objects.new(name, bpy_mesh) 347 | if mesh.shape_keys: 348 | bpy_obj.shape_key_add(name="Basis") 349 | return bpy_obj 350 | 351 | def make_base_light( 352 | self, 353 | name: str, 354 | light: Light, 355 | light_type: Literal["POINT", "SUN", "SPOT", "AREA"] | None = "POINT", 356 | energy: int = 500, 357 | cutoff_distance: float = 15.0, 358 | ) -> bpy.types.Light: 359 | bpy_light = bpy.data.lights.new(name=name, type=light_type) 360 | bpy_light.color = light.color.rgb_float 361 | bpy_light.use_custom_distance = True 362 | bpy_light.cutoff_distance = cutoff_distance 363 | bpy_light.specular_factor = 0.2 364 | bpy_light.energy = energy # type: ignore[attr-defined] 365 | bpy_light.use_shadow = False 366 | return bpy_light 367 | 368 | def make_point_light_object(self, name: str, light: Light) -> bpy.types.Object: 369 | bpy_light = self.make_base_light(name=name, light=light, light_type="POINT") 370 | bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_light) 371 | self.set_object_location(obj=bpy_obj, location=light.location) 372 | return bpy_obj 373 | 374 | def make_spot_light_object( 375 | self, name: str, light: Light, energy: int, angle: float, cutoff_distance: float 376 | ) -> bpy.types.Object: 377 | bpy_light = self.make_base_light( 378 | name=name, 379 | light=light, 380 | energy=energy, 381 | light_type="SPOT", 382 | cutoff_distance=cutoff_distance, 383 | ) 384 | bpy_light.spot_size = angle # type: ignore[attr-defined] 385 | bpy_light.spot_blend = 0.5 # type: ignore[attr-defined] 386 | bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_light) 387 | self.set_object_location(obj=bpy_obj, location=light.location) 388 | return bpy_obj 389 | 390 | def make_directional_light_object( 391 | self, name: str, light: DirectionalLight 392 | ) -> bpy.types.Object: 393 | bpy_sun = bpy.data.lights.new(name=name, type="SUN") 394 | bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_sun) 395 | mu_rot = self.rot_mat @ mathutils.Euler(light.euler_xyz).to_matrix() 396 | bpy_obj.rotation_mode = "XYZ" 397 | bpy_obj.rotation_euler = mu_rot.to_euler() 398 | return bpy_obj 399 | 400 | def make_camera_object(self, name: str, camera: Camera) -> bpy.types.Object: 401 | bpy_camera = bpy.data.cameras.new(name=name) 402 | bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_camera) 403 | offset = mathutils.Euler((pi / 2, 0, 0)) 404 | self.set_object_location(obj=bpy_obj, location=camera.location) 405 | self.set_object_rotation(obj=bpy_obj, transform=camera.transform, offset=offset) 406 | return bpy_obj 407 | 408 | def make_shape_key(self, obj: bpy.types.Object, shape_key: ShapeKey) -> None: 409 | bpy_shape_key = obj.shape_key_add(name=shape_key.type.name) 410 | bpy_shape_key.interpolation = "KEY_LINEAR" 411 | for data, vertex in zip(bpy_shape_key.data, shape_key.vertices, strict=True): 412 | mu_vector = self.rot_mat @ mathutils.Vector(vertex.location) 413 | data.co = mu_vector # type: ignore[attr-defined] 414 | 415 | 416 | class TrackImportStrategy(metaclass=ABCMeta): 417 | @abstractmethod 418 | def import_track( 419 | self, 420 | track: TrackData, 421 | import_collision: bool = False, 422 | import_actions: bool = False, 423 | import_cameras: bool = False, 424 | import_ambient: bool = False, 425 | ) -> None: 426 | pass 427 | 428 | 429 | class TrackImportGLTF(TrackImportStrategy, BaseImporter): 430 | def import_track( 431 | self, 432 | track: TrackData, 433 | import_collision: bool = False, 434 | import_actions: bool = False, 435 | import_cameras: bool = False, 436 | import_ambient: bool = False, 437 | ) -> None: 438 | bpy.context.scene.render.fps = track.ANIMATION_FPS 439 | track_collection = bpy.data.collections.new("Track segments") 440 | bpy.context.scene.collection.children.link(track_collection) 441 | for index, segment in enumerate(track.track_segments): 442 | name = f"Segment {index}" 443 | segment_collection = bpy.data.collections.new(name=name) 444 | track_collection.children.link(segment_collection) 445 | mesh = self.duplicate_common_vertices(mesh=segment.mesh) 446 | bpy_obj = self.make_drawable_object(name=name, mesh=mesh) 447 | segment_collection.objects.link(bpy_obj) 448 | if import_collision: 449 | for collision_index, collision_mesh in enumerate(segment.collision_meshes): 450 | effect = collision_mesh.collision_effect 451 | name = f"Collision {collision_index}.{effect}-colonly" 452 | bpy_mesh = self.make_base_mesh(name=name, mesh=collision_mesh) 453 | bpy_obj = bpy.data.objects.new(name, bpy_mesh) 454 | segment_collection.objects.link(bpy_obj) 455 | bpy_obj.hide_set(True) 456 | bpy_obj["SPT_surface_type"] = effect.value 457 | object_collection = bpy.data.collections.new("Objects") 458 | bpy.context.scene.collection.children.link(object_collection) 459 | for index, obj in enumerate(track.objects): 460 | name = f"Object {index}" 461 | mesh = self.duplicate_common_vertices(mesh=obj.mesh) 462 | bpy_obj = self.make_drawable_object(name=name, mesh=mesh) 463 | actions = ( 464 | obj.actions 465 | if import_actions 466 | else filter(lambda x: x.action is Action.DEFAULT_LOOP, obj.actions) 467 | ) 468 | for action in actions: 469 | self.set_object_action(obj=bpy_obj, action=action) 470 | if obj.location: 471 | self.set_object_location(obj=bpy_obj, location=obj.location) 472 | if obj.transform: 473 | offset = mathutils.Euler((0, 0, pi)) 474 | self.set_object_rotation(obj=bpy_obj, transform=obj.transform, offset=offset) 475 | if obj.physics: 476 | dim = obj.physics.dimension 477 | bpy_obj["SPT_object"] = { 478 | "type": "rigid", 479 | "mass": obj.physics.mass, 480 | "dimensions": (dim.x, dim.y, dim.z), 481 | } 482 | object_collection.objects.link(bpy_obj) 483 | light_collection = bpy.data.collections.new("Lights") 484 | bpy.context.scene.collection.children.link(light_collection) 485 | for index, light in enumerate(track.lights): 486 | name = f"Light {index}" 487 | bpy_obj = self.make_point_light_object(name=name, light=light) 488 | light_collection.objects.link(bpy_obj) 489 | directional_light = track.directional_light 490 | if directional_light: 491 | sun = directional_light.resource 492 | sun_image = create_pil_image(sun.image) 493 | bpy_sun = self._image_to_bpy_image("sun", sun_image) 494 | bpy_obj = self.make_directional_light_object(name="sun", light=directional_light) 495 | bpy_obj["SPT_sun"] = { 496 | "additive": directional_light.additive, 497 | "is_front": directional_light.in_front, 498 | "rotates": directional_light.rotates, 499 | "radius": directional_light.radius, 500 | } 501 | light_collection.objects.link(bpy_obj) 502 | if import_cameras: 503 | camera_collection = bpy.data.collections.new("Cameras") 504 | bpy.context.scene.collection.children.link(camera_collection) 505 | for index, camera in enumerate(track.cameras): 506 | bpy_obj = self.make_camera_object(name=f"Camera {index}", camera=camera) 507 | camera_collection.objects.link(bpy_obj) 508 | spt_track = {} 509 | gltf_transform = mathutils.Euler((-pi / 2.0, 0.0, 0.0)).to_matrix() @ self.rot_mat 510 | waypoints = chain.from_iterable(segment.waypoints for segment in track.track_segments) 511 | waypoint_metadata = [gltf_transform @ mathutils.Vector(w) for w in waypoints] 512 | spt_track["waypoints"] = [(w.x, w.y, w.z) for w in waypoint_metadata] 513 | if import_ambient: 514 | 515 | def color_to_dict(color: Color) -> dict[str, float]: 516 | red, green, blue = color.rgb_float 517 | return {"red": red, "green": green, "blue": blue} 518 | 519 | environment = {} 520 | ambient_color = track.ambient_color 521 | environment["ambient"] = color_to_dict(ambient_color) 522 | horizon = track.horizon 523 | environment["horizon"] = { 524 | "sun_side": color_to_dict(horizon.sun_side), # type: ignore[dict-item] 525 | "sun_top": color_to_dict(horizon.sun_top_side), # type: ignore[dict-item] 526 | "sun_opposite": color_to_dict(horizon.sun_opposite_side), # type: ignore[dict-item] 527 | "earth_bottom": color_to_dict(horizon.earth_bottom), # type: ignore[dict-item] 528 | "earth_top": color_to_dict(horizon.earth_top), # type: ignore[dict-item] 529 | } 530 | spt_track["environment"] = environment # type: ignore[assignment] 531 | bpy.context.scene["SPT_track"] = spt_track 532 | sky_images = list(track.sky_images) 533 | if sky_images: 534 | horizon_image = make_horizon_texture(sky_images) 535 | bpy_image = self._image_to_bpy_image("horizon", horizon_image) 536 | clouds = track.clouds 537 | clouds_image = create_pil_image(clouds.image) 538 | bpy_clouds = self._image_to_bpy_image("clouds", clouds_image) 539 | 540 | 541 | class CarImporterSimple(BaseImporter): 542 | car_light_attributes = { # (Energy, angle, cutoff, rotation) 543 | VehicleLightType.HEADLIGHT: ( 544 | 200, 545 | 90, 546 | 100.0, 547 | mathutils.Matrix.Rotation(radians(90), 3, "X"), 548 | ), 549 | VehicleLightType.DIRECTIONAL: ( 550 | 20, 551 | 160, 552 | 1.0, 553 | mathutils.Matrix.Rotation(radians(-90), 3, "X"), 554 | ), 555 | VehicleLightType.BRAKELIGHT: ( 556 | 20, 557 | 160, 558 | 1.0, 559 | mathutils.Matrix.Rotation(radians(-90), 3, "X"), 560 | ), 561 | VehicleLightType.REVERSE: (20, 160, 1.0, mathutils.Matrix.Rotation(radians(-90), 3, "X")), 562 | VehicleLightType.TAILLIGHT: ( 563 | 10, 564 | 160, 565 | 1.0, 566 | mathutils.Matrix.Rotation(radians(-90), 3, "X"), 567 | ), 568 | VehicleLightType.SIREN: (100, 180, 40.0, mathutils.Matrix.Rotation(radians(-90), 3, "X")), 569 | } 570 | 571 | def import_car(self, car: VivData, import_interior: bool, import_lights: bool) -> None: 572 | car_collection = bpy.data.collections.new("Car parts") 573 | bpy.context.scene.collection.children.link(car_collection) 574 | parts = car.interior if import_interior else car.parts 575 | for part in parts: 576 | bpy_obj = self.make_drawable_object(name=part.name, mesh=part.mesh) 577 | self.set_object_location(obj=bpy_obj, location=part.location) 578 | car_collection.objects.link(bpy_obj) 579 | for shape_key in part.mesh.shape_keys: 580 | self.make_shape_key(obj=bpy_obj, shape_key=shape_key) 581 | light_collection = bpy.data.collections.new("Car lights") 582 | bpy.context.scene.collection.children.link(light_collection) 583 | if import_lights: 584 | for index, light in enumerate(car.lights): 585 | name = f"{light.type.name.lower()}-{index}" 586 | attributes = self.car_light_attributes[light.type] 587 | bpy_obj = self.make_spot_light_object( 588 | name=name, 589 | light=light, 590 | energy=attributes[0], 591 | angle=radians(attributes[1]), 592 | cutoff_distance=attributes[2], 593 | ) 594 | self.set_object_rotation(obj=bpy_obj, transform=attributes[3]) 595 | light_collection.objects.link(bpy_obj) 596 | dimensions = car.dimensions 597 | car_metadata = { 598 | "performance": car.performance, 599 | "dimensions": (dimensions.x, dimensions.y, dimensions.z), 600 | } 601 | bpy.context.scene["SPT_car"] = car_metadata 602 | 603 | 604 | class TrackImporter(bpy.types.Operator): 605 | """Import NFS4 Track Operator""" 606 | 607 | bl_idname = "import_scene.nfs4trk" 608 | bl_label = "Import NFS4 Track" 609 | bl_description = "Import NFS4 track files" 610 | bl_options = {"REGISTER", "UNDO"} 611 | 612 | bpy.types.Scene.nfs4trk = None # type: ignore[attr-defined] 613 | 614 | directory: StringProperty( # type: ignore[valid-type] 615 | name="Directory Path", 616 | description="Directory containing the track files", 617 | maxlen=1024, 618 | default="", 619 | ) 620 | night: BoolProperty( # type: ignore[valid-type] 621 | name="Night on", description="Import night track variant", default=False 622 | ) 623 | weather: BoolProperty( # type: ignore[valid-type] 624 | name="Weather on", description="Import rainy track variant", default=False 625 | ) 626 | mirrored: BoolProperty( # type: ignore[valid-type] 627 | name="Mirrored on", description="Import mirrored track variant", default=False 628 | ) 629 | import_shading: BoolProperty( # type: ignore[valid-type] 630 | name="Import vertex shading", 631 | description="Import original vertex shading to obtain the 'original' track look", 632 | default=False, 633 | ) 634 | import_collision: BoolProperty( # type: ignore[valid-type] 635 | name="Import collision (experimental)", 636 | description="Import collision meshes (ending with -colonly)", 637 | default=False, 638 | ) 639 | import_actions: BoolProperty( # type: ignore[valid-type] 640 | name="Import animation actions (experimental)", 641 | description="Import track animation actions from CAN files, such as object destruction animation", 642 | default=False, 643 | ) 644 | import_cameras: BoolProperty( # type: ignore[valid-type] 645 | name="Import cameras (experimental)", 646 | description="Import track-specific replay cameras", 647 | default=False, 648 | ) 649 | import_ambient: BoolProperty( # type: ignore[valid-type] 650 | name="Import ambient", 651 | description="Import ambient light", 652 | default=False, 653 | ) 654 | 655 | def invoke( 656 | self, context: bpy.types.Context, event: bpy.types.Event 657 | ) -> set[Literal["RUNNING_MODAL", "CANCELLED", "FINISHED", "PASS_THROUGH", "INTERFACE"]]: 658 | wm = context.window_manager 659 | wm.fileselect_add(self) 660 | return {"RUNNING_MODAL"} 661 | 662 | def execute( 663 | self, context: bpy.types.Context 664 | ) -> set[Literal["RUNNING_MODAL", "CANCELLED", "FINISHED", "PASS_THROUGH", "INTERFACE"]]: 665 | directory = Path(self.directory) 666 | # This should get us from track directory to game root directory 667 | game_root = directory.parent.parent.parent 668 | track = TrackData( 669 | directory=Path(self.directory), 670 | game_root=game_root, 671 | mirrored=self.mirrored, 672 | night=self.night, 673 | weather=self.weather, 674 | ) 675 | import_strategy: TrackImportStrategy 676 | import_strategy = TrackImportGLTF( 677 | material_map=track.get_polygon_material, import_shading=self.import_shading 678 | ) 679 | import_strategy.import_track( 680 | track=track, 681 | import_collision=self.import_collision, 682 | import_actions=self.import_actions, 683 | import_cameras=self.import_cameras, 684 | import_ambient=self.import_ambient, 685 | ) 686 | return {"FINISHED"} 687 | 688 | 689 | class CarImporter(bpy.types.Operator): 690 | """Import NFS4 Car Operator""" 691 | 692 | bl_idname = "import_scene.nfs4car" 693 | bl_label = "Import NFS4 Car" 694 | bl_description = "Import NFS4 Car files" 695 | bl_options = {"REGISTER", "UNDO"} 696 | 697 | bpy.types.Scene.nfs4car = None # type: ignore 698 | 699 | directory: StringProperty( # type: ignore 700 | name="Directory Path", 701 | description="Directory containing the car files", 702 | maxlen=1024, 703 | default="", 704 | ) 705 | import_interior: BoolProperty( # type: ignore[valid-type] 706 | name="Import interior", description="Import car interior geometry", default=False 707 | ) 708 | 709 | import_lights: BoolProperty( # type: ignore[valid-type] 710 | name="Import car lights", 711 | description="Import car lights and assign default attribute values", 712 | default=False, 713 | ) 714 | 715 | def invoke( 716 | self, context: bpy.types.Context, event: bpy.types.Event 717 | ) -> set[Literal["RUNNING_MODAL", "CANCELLED", "FINISHED", "PASS_THROUGH", "INTERFACE"]]: 718 | wm = context.window_manager 719 | wm.fileselect_add(self) 720 | return {"RUNNING_MODAL"} 721 | 722 | def execute( 723 | self, context: bpy.types.Context 724 | ) -> set[Literal["RUNNING_MODAL", "CANCELLED", "FINISHED", "PASS_THROUGH", "INTERFACE"]]: 725 | car = VivData.from_file(Path(self.directory, "CAR.VIV")) 726 | logger.debug(car) 727 | 728 | if self.import_interior: 729 | resource = one(car.interior_materials) 730 | else: 731 | resource = one(car.body_materials) 732 | importer = CarImporterSimple(material_map=lambda _: resource) 733 | importer.import_car( 734 | car, import_interior=self.import_interior, import_lights=self.import_lights 735 | ) 736 | 737 | return {"FINISHED"} 738 | 739 | 740 | def menu_func(self: Any, context: bpy.types.Context) -> None: 741 | self.layout.operator(TrackImporter.bl_idname, text="Track resources") 742 | self.layout.operator(CarImporter.bl_idname, text="Car resources") 743 | 744 | 745 | def register() -> None: 746 | bpy.utils.register_class(TrackImporter) 747 | bpy.utils.register_class(CarImporter) 748 | bpy.types.TOPBAR_MT_file_import.append(menu_func) 749 | 750 | 751 | def unregister() -> None: 752 | bpy.utils.unregister_class(TrackImporter) 753 | bpy.utils.unregister_class(CarImporter) 754 | bpy.types.TOPBAR_MT_file_import.remove(menu_func) 755 | 756 | 757 | if __name__ == "__main__": 758 | register() 759 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------