Skip to content

Commit

Permalink
Change VPK class to allow for singular VPK files
Browse files Browse the repository at this point in the history
  • Loading branch information
TeamSpen210 committed Jan 23, 2025
1 parent d3c9545 commit bcd9e39
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 22 deletions.
1 change: 1 addition & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Version (dev)
* Avoid escaping `/` and `?`, these are unambiguous.
* Add :py:attr:`ValueTypes.EXT_SOUNDSCAPE <srctools.fgd.ValueTypes.EXT_SOUNDSCAPE>`.
* Support exporting FGDs with unevaulated string bases.
* Add suppport for reading non-directory VPK files, where contents are all in one file.
* FGDs may optionally permit unknown value types to be parsed/exported in string form.
* Fix some options being incorrectly handled in Strata game mounts definitions.
* Allow entities to delete multiple keyvalues at once.
Expand Down
95 changes: 73 additions & 22 deletions src/srctools/vpk.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Classes for reading and writing Valve's VPK format, version 1."""
from typing import IO, Final, Optional, Union
from typing_extensions import TypeAlias
from typing_extensions import TypeAlias, deprecated
from collections.abc import Iterable, Iterator
from enum import Enum
from types import TracebackType
Expand Down Expand Up @@ -139,11 +139,12 @@ def _check_is_ascii(value: str) -> bool:
return True


@attrs.define(eq=False)
@attrs.define(eq=False, repr=False)
class FileInfo:
"""Represents a file stored inside a VPK.
Do not call the constructor, it is only meant for VPK's use.
Attributes should not be assigned to - use :py:meth:`write()` to change the contents.
"""
vpk: 'VPK'
dir: str = attrs.field(on_setattr=attrs.setters.frozen)
Expand Down Expand Up @@ -204,9 +205,12 @@ def verify(self) -> bool:
def write(self, data: bytes, arch_index: Optional[int] = None) -> None:
"""Replace this file with the given byte data.
arch_index is the pak_01_000 file to put data into (or None for _dir).
If this file already exists in the VPK, the old data is not removed.
For this reason VPK writes should be done once per file if possible.
arch_index is the pak_01_000 file to put data into (or None for _dir). This is ignored
if the VPK is singular.
Data written to the directory file is not immediately saved, py:meth:`VPK.write_dirfile()`
must subsequently be called to do so.
- If this file already exists in the VPK, the old data is not removed from numeric files.
For this reason VPK writes should be done once per file if possible.
"""
if not self.vpk.mode.writable:
raise ValueError(f"VPK mode {self.vpk.mode.name} does not allow writing!")
Expand All @@ -218,6 +222,13 @@ def write(self, data: bytes, arch_index: Optional[int] = None) -> None:
return # Same data, don't do anything.

self.crc = new_checksum
# noinspection PyProtectedMember
prefix = self.vpk._dir_prefix

if prefix is None:
self.start_data = data
self.arch_len = 0
return

self.start_data = data[:self.vpk.dir_limit]
arch_data = data[self.vpk.dir_limit:]
Expand All @@ -226,7 +237,7 @@ def write(self, data: bytes, arch_index: Optional[int] = None) -> None:

if self.arch_len:
self.arch_index = arch_index
arch_file = get_arch_filename(self.vpk.file_prefix, arch_index)
arch_file = get_arch_filename(prefix, arch_index)
with open(os.path.join(self.vpk.folder, arch_file), 'ab') as file:
self.offset = file.seek(0, os.SEEK_END)
file.write(arch_data)
Expand All @@ -237,12 +248,20 @@ def write(self, data: bytes, arch_index: Optional[int] = None) -> None:


class VPK:
"""Represents a VPK file set in a directory."""
"""Represents a VPK archive.
VPKs can either be a singular file, or a main ``archive_dir.vpk`` with associated numerix
``archive_XXX.vpk`` files containing the data.
"""
folder: str
"""The directory the VPK is located in, used to find the numeric files."""

file_prefix: str
"""The VPK filename, without ``_dir.vpk``."""
# The full name of the VPK, excluding the folder.
_filename: str

# If None, this is a singular VPK. Otherwise it is the filename without _dir.vpk, prefixed
# ready for finding numeric files.
_dir_prefix: Optional[str]

# fileinfo[extension][directory][filename]
_fileinfo: dict[str, dict[str, dict[str, FileInfo]]]
Expand Down Expand Up @@ -282,15 +301,16 @@ def __init__(
) -> None:
"""Create a VPK file.
:param dir_file: The path to the directory file. This must end in ``_dir.vpk``.
:param dir_file: The path to the directory file, or file if singular.
:param mode: The (r)ead, (w)rite or (a)ppend mode.
:param dir_data_limit: The maximum amount of data to save in the dir file.
:param dir_data_limit: The maximum amount of data to save in the dir file. Ignored if singular.
:param version: The desired version if the file is not read.
"""
if version not in (1, 2):
raise ValueError(f"Invalid version ({version}) - must be 1 or 2!")

self.folder = self.file_prefix = ''
self.folder = self._filename = ''
self._dir_prefix = None
# Calls the property which sets the above correctly and checks the type.
self.path = dir_file

Expand All @@ -313,25 +333,57 @@ def _check_writable(self) -> None:

@property
def path(self) -> Union[str, 'os.PathLike[str]']: # TODO: Incorrect, Mypy doesn't have 2-type properties.
"""The filename of the directory VPK file.
"""The full path of the directory VPK file, or the single file.
This can be assigned to set :py:attr:`folder` and :py:attr:`file_prefix`.
This can be assigned to set :py:attr:`folder` and :py:attr:`filename`.
"""
return os.path.join(self.folder, self.file_prefix + '_dir.vpk')
return os.path.join(self.folder, self._filename)

@path.setter
def path(self, path: Union[str, 'os.PathLike[str]']) -> None:
"""Set the location and folder from the directory VPK file."""
folder, filename = os.path.split(path)
self.folder, self.filename = os.path.split(path)

if not filename.endswith('_dir.vpk'):
raise Exception('Must create with a _dir VPK file!')
@property
def filename(self) -> str:
"""The filename of the directory VPK file, or the single file.
"""
return self._filename

self.folder = folder
self.file_prefix = filename[:-8]
@filename.setter
def filename(self, filename: str, /) -> None:
self._filename = filename
if filename.endswith('_dir.vpk'):
self._dir_prefix = filename[:-8]
else:
self._dir_prefix = None

@property
def file_prefix(self) -> str:
"""The VPK filename, without ``.vpk``, or ``_dir.vpk`` if a directory.
:deprecated: Do not assign to this, it assumes the VPK is always a directory file.\
Instead, assign to filename or path.
"""
if self._dir_prefix is not None:
return self._dir_prefix
return self.filename.removesuffix('.vpk')

@file_prefix.setter
@deprecated("Set path instead.")
def file_prefix(self, prefix: str, /) -> None:
self._filename = prefix + '_dir.vpk'
self._dir_prefix = prefix

@property
def is_directory(self) -> bool:
"""If true, this is a ``_dir.vpk`` file, potentially with data stored in auxilliary VPKS."""
return self._dir_prefix is not None

def load_dirfile(self) -> None:
"""Read in the directory file to get all filenames. This erases all changes in the file."""
"""Read in the directory file to get all filenames. This erases all changes made to the object."""
self._fileinfo.clear()
self.footer_data = b''
if self.mode is OpenModes.WRITE:
# Erase the directory file, we ignore current contents.
open(self.path, 'wb').close()
Expand Down Expand Up @@ -370,7 +422,6 @@ def load_dirfile(self) -> None:

header_len = dirfile.tell() + tree_length

self._fileinfo.clear()
entry = struct.Struct('<IHHIIH')
# Read directory contents
# These are in a tree of extension, directory, file. '' terminates a part.
Expand Down

0 comments on commit bcd9e39

Please sign in to comment.