Skip to main content

Overview

ZZAR (Zenless Zone Zero Audio Replacer) modifies game audio by patching PCK (package) files that contain WEM audio streams and BNK (sound bank) files. The tool works by:
  1. Extracting original game audio files from PCK archives
  2. Converting user audio to WEM format using Wwise
  3. Patching modified audio back into PCK files
  4. Placing the modified PCK files in a persistent directory that the game loads

PCK File Structure

PCK files are Audiokinetic’s package format (magic: AKPK) that bundle audio assets for games using Wwise middleware.

PCK Header Layout

# PCK Header Structure (pck_extractor.py:85-100)
MAGIC           # 4 bytes: 'AKPK'
header_size     # uint32: Total header size
version         # uint32: Format version (typically 1)
sec1_size       # uint32: Language strings section
sec2_size       # uint32: Banks section (BNK files)
sec3_size       # uint32: Sounds section (WEM files)
sec4_size       # uint32: External files section (optional, uses 64-bit IDs)

Section Structure

Each section contains a file table with entries:
# File Entry Structure (pck_extractor.py:56-81)
file_count      # uint32: Number of entries

# For each file:
file_id         # uint32 or uint64 (externals use 64-bit)
blocksize       # uint32: Multiplier for offset calculation
size            # uint32: File size in bytes
offset_block    # uint32: Offset = offset_block * blocksize
lang_id         # uint32: Language identifier (0=SFX, 1=English, 2=Chinese, etc.)
ZZAR maintains language mappings for multi-language games:
# pck_extractor.py:19-25
lang_map = {
    0: 'sfx',       # Sound effects (no language)
    1: 'english',
    2: 'chinese',
    3: 'japanese',
    4: 'korean'
}
Language strings are stored as UTF-16 LE in section 1 of the PCK header.

BNK File Structure

BNK (SoundBank) files are Wwise sound banks that contain multiple WEM audio files and their metadata.

BNK Chunk Structure

# BNK consists of RIFF-style chunks (bnk_handler.py:144-176)
'BKHD'  # Bank Header - metadata about the sound bank
'DIDX'  # Data Index - WEM file offsets and sizes
'DATA'  # Data Section - contains all WEM audio files
'HIRC'  # Hierarchy - sound object relationships (optional)

DIDX (Data Index) Format

# Each entry is 12 bytes (bnk_handler.py:43-49)
wem_id      # uint32: WEM file identifier
offset      # uint32: Offset into DATA section
size        # uint32: WEM file size in bytes

DATA Section Layout

# WEM files are stored sequentially with 16-byte alignment
# (bnk_handler.py:98-110)
[WEM_1 data]
[padding to 16-byte boundary]
[WEM_2 data]
[padding to 16-byte boundary]
...
Wwise requires WEM files in BNK archives to be aligned to 16-byte boundaries:
# bnk_handler.py:8-10
def align16(x):
    return (16 - (x % 16)) % 16

# Applied during packing (bnk_handler.py:98-110)
if i == 0 and i != len(wems) - 1:
    # First file: align from start of DATA section
    file_padding = align16(len(wem_data) + start_pos)
elif i != len(wems) - 1:
    # Middle files: align from current position
    file_padding = align16(len(wem_data))
else:
    # Last file: no padding needed
    file_padding = 0

Modification Methods

ZZAR supports two methods for modifying PCK files:

1. Patching Mode (Default)

Patches existing PCK files in-place, preserving the original structure:
# pck_packer.py:292-383
# 1. Copy original PCK to output location
shutil.copy2(original_pck, output_pck)

# 2. Open output PCK in read-write mode
with open(output_pck, 'r+b') as f:
    # 3. Seek to each file's offset
    f.seek(original_offset)
    
    # 4. Write new data
    if new_size == original_size:
        f.write(new_data)  # Perfect fit
    elif new_size < original_size:
        f.write(new_data)
        f.write(b'\x00' * padding)  # Pad with zeros
    else:
        f.write(new_data[:original_size])  # Truncate if larger
Patching mode will truncate audio files that are larger than the original. This can result in incomplete audio playback. Use rebuild mode for files with different sizes.

2. Rebuild Mode

Completely reconstructs the PCK file from scratch:
# pck_packer.py:385-421
# 1. Write new header with recalculated section sizes
f.write(MAGIC)
f.write(struct.pack('<6I', header_size, version, 
                    sec1_size, sec2_size, sec3_size, sec4_size))

# 2. Write language map
f.write(language_map)

# 3. Build file tables with new offsets
bt_write_info = _build_file_table(soundbank_titles)
bf_write_info = _build_file_table(soundbank_files)
sf_write_info = _build_file_table(stream_files)

# 4. Write all audio data sequentially
_write_audio_data(bt_write_info)
_write_audio_data(bf_write_info)
_write_audio_data(sf_write_info)

BNK Modification Workflow

When replacing audio in a BNK file within a PCK:
# pck_packer.py:205-252
# 1. Extract BNK from original PCK
original_file.seek(offset)
bnk_bytes = original_file.read(size)

# 2. Load BNK structure
bnk = BNKFile(bnk_bytes=bnk_bytes)

# 3. Replace individual WEM files
for wem_file in wem_files:
    wem_id = int(wem_file.stem)
    bnk.replace_wem(wem_id, wem_path=wem_file)

# 4. Recalculate DIDX offsets
bnk._correct_offsets()

# 5. Get modified BNK as bytes
modified_bnk_bytes = bnk.get_bytes()

# 6. Replace entire BNK in PCK
soundbank_titles[lang_id][bnk_id] = [
    (file_index, len(modified_bnk_bytes), 0)
]

Game Integration

ZZAR places modified PCK files in a persistent directory that takes precedence over the original game files:
Game Directory/
├── ZenlessZoneZero_Data/
│   └── StreamingAssets/
│       └── Audio/           # Original game audio
│           ├── GeneratedSoundBanks/
│           │   ├── SoundBank_SFX_1.pck
│           │   └── ...
│           └── Streamed/
│               ├── Streamed_SFX_1.pck
│               └── ...
└── Persistent/              # ZZAR modified files (higher priority)
    └── GeneratedSoundBanks/
        └── SoundBank_SFX_1.pck  # Modified version
The game’s asset loading system checks the Persistent directory first, allowing ZZAR to override original audio without modifying game installation files.

File Identification

ZZAR uses numeric IDs to identify audio files:
  • File ID: Unique identifier for each audio asset (32-bit or 64-bit)
  • BNK ID: Identifier for sound bank files
  • WEM ID: Identifier for individual WEM audio files within BNK archives
  • Lang ID: Language identifier for multi-language content
# Example: Replacing a WEM file in a BNK
# pck_packer.py:267-283
for bnk_dir in replacements_dir.glob('*_bnk'):
    bnk_id = int(bnk_dir.name.replace('_bnk', ''))  # e.g., 2882561007_bnk
    
    for wem_file in bnk_dir.glob('*.wem'):
        wem_id = int(wem_file.stem)  # e.g., 134133939.wem
        replace_wem(bnk_id, wem_id, wem_file)

Performance Considerations

ZZAR keeps file handles open during packing to avoid loading entire PCK files into memory:
# pck_packer.py:42, 117-118
self.file_list = []  # List of file handles
self.file_list.append(open(original_pck_path, 'rb'))
This allows processing multi-gigabyte PCK files efficiently.
ZZAR builds an in-memory index of all files in a PCK for fast lookups:
# Structure: {lang_id: {file_id: [(file_index, size, offset)]}}
self.soundbank_titles = {}  # BNK files
self.soundbank_files = {}   # Embedded WEM files
self.stream_files = {}      # External WEM files
This index enables O(1) file lookups during patching operations.

Build docs developers (and LLMs) love