XPlor/definitions/volition/vpp.xscript
njohnson 908100f487 Enhance Volition format definitions for Saints Row series
Major improvements to VPP, PEG, and ASM parsers:

vpp.xscript:
- Support all VPP versions (4-10) for Saints Row 1-4 and Red Faction
- Add container type detection (str2, packfile, etc.)
- Improved compression handling (zlib, LZO, auto-detection)
- Better recursive parsing of nested archives

peg.xscript:
- Full PEG texture container parsing
- Support for multiple texture formats (DXT1, DXT3, DXT5, etc.)
- Xbox 360 and PC format variants

asm.xscript:
- Animation state machine container parsing

New format definitions:
- anim.xscript: Animation data
- audio.xscript: Audio containers (bank files)
- mesh.xscript: 3D mesh geometry
- morph.xscript: Morph targets
- rig.xscript: Skeletal rigs
- sim.xscript: Simulation data
- zone.xscript: Level zone data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:53:59 -05:00

970 lines
32 KiB
Plaintext

// Volition VPP Package Format
// Used by: Saints Row series, Red Faction series
// Versions: 3, 4, 6
// Magic: 0x51890ACE (little-endian) or 0xCE0A8951 (big-endian)
type volition_vpp ui("VPP Archives", root) byteorder LE
{
criteria {
// PC, Xbox 360, PS3 variants
require _ext == "vpp_pc" || _ext == "str2_pc" ||
_ext == "vpp_xbox2" || _ext == "str2_xbox2" ||
_ext == "vpp_ps3" || _ext == "str2_ps3" ||
_ext == "vpp";
// Check magic in both endianness
le_magic = u32at(0);
require le_magic == 0x51890ACE || le_magic == 0xCE0A8951;
}
// Save original filename
original_name = _name;
// Detect platform from extension
is_xbox = ends_with(_ext, "xbox2");
is_ps3 = ends_with(_ext, "ps3");
// Store platform in context for child file parsing
ctx_set("_vpp_is_xbox", is_xbox);
ctx_set("_vpp_is_ps3", is_ps3);
// Detect endianness from magic
magic_check = u32at(0);
if (magic_check == 0xCE0A8951) {
if (is_ps3) {
set_name(original_name + " (PS3)");
} else {
set_name(original_name + " (Xbox 360)");
}
// Parse as big-endian for Xbox 360/PS3
parse_here("volition_vpp_be");
} else {
set_name(original_name + " (PC)");
// Parse as little-endian for PC
parse_here("volition_vpp_impl");
}
}
// BE wrapper - explicit byteorder overrides inherited LE
type volition_vpp_be ui("VPP Archives") byteorder BE
{
parse_here("volition_vpp_impl");
}
// Shared implementation - inherits byteorder from caller
type volition_vpp_impl ui("VPP Archives")
{
u32 magic ui("Magic");
u32 version ui("Version");
file_size_total = size() ui("File Size");
if (version == 3) {
parse_here("volition_vpp_v3");
} else if (version == 4) {
parse_here("volition_vpp_v4");
} else if (version == 6) {
parse_here("volition_vpp_v6");
} else if (version == 10) {
// Version 10 - Saints Row IV, Agents of Mayhem
parse_here("volition_vpp_v10");
} else {
unknown_version = version ui("Unsupported Version");
set_preview("vpp_data.bin", read(size() - pos()));
set_viewer("hex");
}
}
// VPP Version 3 - Used by older games
// Inherits byteorder from caller
type volition_vpp_v3 ui("VPP Archives")
{
// Header continues after magic/version (already read 8 bytes)
header_name = cstring() ui("Creator");
header_path = cstring() ui("Path");
// Seek to fixed header position for flags
seek(0x100);
u32 flags ui("Flags");
u32 dir_count ui("Entry Count");
u32 package_size ui("Package Size");
u32 dir_size ui("Directory Size");
u32 names_size ui("Names Size");
u32 uncompressed_size ui("Uncompressed Size");
u32 compressed_size ui("Compressed Size");
is_compressed = (flags & 1) != 0;
compression_type = "None";
if (is_compressed) { compression_type = "ZLIB"; }
compression = compression_type ui("Compression");
// Directory starts at 2048 bytes
dir_offset = 2048;
seek(dir_offset);
// Parse directory entries (28 bytes each for V3)
skip_tree("entries");
entry_idx = 0;
repeat(dir_count) {
u32 e_name_offset;
u32 e_runtime_offset;
u32 e_data_offset;
u32 e_name_hash;
u32 e_uncompressed_size;
u32 e_compressed_size;
u32 e_package_ptr;
ctx_set("_vpp_entry_name_off", e_name_offset);
ctx_set("_vpp_entry_data_off", e_data_offset);
ctx_set("_vpp_entry_size", e_uncompressed_size);
ctx_set("_vpp_entry_csize", e_compressed_size);
ctx_set("_vpp_entry_idx", entry_idx);
saved_pos = pos();
// Read name from names section (with bounds check)
names_offset = dir_offset + dir_size;
name_offset_abs = names_offset + e_name_offset;
if (name_offset_abs < size()) {
seek(name_offset_abs);
entry_name = cstring();
} else {
entry_name = "[Invalid: offset beyond EOF]";
}
ctx_set("_vpp_entry_name", entry_name);
seek(saved_pos);
dummy = bytesat(0, 1);
entry_child = dummy |> parse volition_vpp_entry_row;
push("entries", entry_child);
entry_idx = entry_idx + 1;
}
total_entries = dir_count ui("Total Entries");
file_size = size() ui("File Size");
}
// VPP Version 4 - Adds extensions section
// Inherits byteorder from caller
type volition_vpp_v4 ui("VPP Archives")
{
// Skip to fixed header position
seek(0x100);
u32 flags ui("Flags");
u32 dir_count ui("Entry Count");
u32 package_size ui("Package Size");
u32 dir_size ui("Directory Size");
u32 names_size ui("Names Size");
u32 extensions_size ui("Extensions Size");
u32 uncompressed_size ui("Uncompressed Size");
u32 compressed_size ui("Compressed Size");
is_compressed = (flags & 1) != 0;
compression_type = "None";
if (is_compressed) { compression_type = "ZLIB"; }
compression = compression_type ui("Compression");
// Directory starts at 2048 bytes
dir_offset = 2048;
seek(dir_offset);
// Parse directory entries (28 bytes each for V4)
skip_tree("entries");
entry_idx = 0;
repeat(dir_count) {
u32 e_name_offset;
u32 e_ext_offset;
u32 e_runtime_offset;
u32 e_data_offset;
u32 e_uncompressed_size;
u32 e_compressed_size;
u32 e_package_ptr;
ctx_set("_vpp_entry_name_off", e_name_offset);
ctx_set("_vpp_entry_data_off", e_data_offset);
ctx_set("_vpp_entry_size", e_uncompressed_size);
ctx_set("_vpp_entry_csize", e_compressed_size);
ctx_set("_vpp_entry_idx", entry_idx);
saved_pos = pos();
// Read name from names section (with bounds check)
names_offset = dir_offset + dir_size;
name_offset_abs = names_offset + e_name_offset;
if (name_offset_abs < size()) {
seek(name_offset_abs);
entry_name = cstring();
} else {
entry_name = "[Invalid: offset beyond EOF]";
}
ctx_set("_vpp_entry_name", entry_name);
seek(saved_pos);
dummy = bytesat(0, 1);
entry_child = dummy |> parse volition_vpp_entry_row;
push("entries", entry_child);
entry_idx = entry_idx + 1;
}
total_entries = dir_count ui("Total Entries");
file_size = size() ui("File Size");
}
// VPP Version 6 - Saints Row 3/4, Red Faction Guerrilla
// Inherits byteorder from caller
type volition_vpp_v6 ui("VPP Archives")
{
// V6 header is at offset 0x150
seek(0x150);
u32 flags ui("Flags");
u32 dir_count_raw;
u32 package_size ui("Package Size");
u32 dir_size_raw;
u32 names_size_raw;
u32 uncompressed_size ui("Uncompressed Size");
u32 compressed_size ui("Compressed Size");
is_compressed = (flags & 1) != 0;
is_condensed = (flags & 2) != 0;
compression_type = "None";
if (is_compressed) { compression_type = "ZLIB"; }
if (is_condensed) { compression_type = "Condensed"; }
compression = compression_type ui("Compression");
// Directory starts at 2048 bytes
dir_offset = 2048;
// Some VPP files have header fields as zeros
// In this case, scan directory to find entries and calculate sizes
// But only if there's actual data beyond the header
if (dir_count_raw == 0 && size() > dir_offset + 24) {
// Scan directory to count entries (each entry is 24 bytes)
seek(dir_offset);
scan_idx = 0;
max_entries = (size() - dir_offset) / 24;
if (max_entries > 10000) { max_entries = 10000; }
max_name_offset = 0;
repeat(max_entries) {
u32 check_name_off;
u32 check_runtime;
u32 check_data_off;
u32 check_usize;
u32 check_csize;
u32 check_ptr;
// Check if this looks like a valid entry
is_valid = (check_runtime == 0) && (check_ptr == 0) && (check_usize > 0 || check_csize > 0);
if (is_valid) {
if (check_name_off > max_name_offset) {
max_name_offset = check_name_off;
}
scan_idx = scan_idx + 1;
}
}
dir_count = scan_idx;
dir_size = scan_idx * 24;
// Calculate names_size by finding the end of the last filename
names_offset_calc = dir_offset + dir_size;
names_offset_calc = ((names_offset_calc + 2047) / 2048) * 2048;
if (names_offset_calc + max_name_offset < size()) {
seek(names_offset_calc + max_name_offset);
last_name = cstring();
names_size = max_name_offset + len(last_name) + 1;
} else {
names_size = 0;
}
} else if (dir_count_raw == 0) {
// File too small for directory or truly empty
dir_count = 0;
dir_size = 0;
names_size = 0;
} else {
dir_count = dir_count_raw;
dir_size = dir_size_raw;
names_size = names_size_raw;
}
entry_count_display = dir_count ui("Entry Count");
dir_size_display = dir_size ui("Directory Size");
names_size_display = names_size ui("Names Size");
// Calculate section offsets
names_offset = dir_offset + dir_size;
names_offset = ((names_offset + 2047) / 2048) * 2048;
data_offset_base = names_offset + names_size;
data_offset_base = ((data_offset_base + 2047) / 2048) * 2048;
data_base_display = data_offset_base ui("Data Section Base");
// Check nesting depth (to prevent deep recursion in nested VPPs)
vpp_depth = ctx_get("_vpp_depth");
if (vpp_depth == 0) { vpp_depth = 0; }
// PASS 1: GPEG collection disabled for now (causes issues with nested VPPs)
// TODO: Re-enable once nested parsing is stable
// PASS 2: Parse directory and create file children (V6)
seek(dir_offset);
skip_tree("entries");
entry_idx = 0;
// Track cumulative compressed offset for STR2 containers where files are individually compressed
// In such cases, e_data_offset is virtual (based on uncompressed sizes), but data stores compressed files
compressed_offset = 0;
repeat(dir_count) {
u32 e_name_offset;
u32 e_runtime_offset;
u32 e_data_offset;
u32 e_uncompressed_size;
u32 e_compressed_size;
u32 e_package_ptr;
saved_pos = pos();
// Read filename (with bounds check)
name_offset_abs = names_offset + e_name_offset;
if (name_offset_abs < size()) {
seek(name_offset_abs);
entry_name = cstring();
} else {
entry_name = "[Invalid: offset beyond EOF]";
}
// Always create entry row for table display
ctx_set("_vpp_entry_name", entry_name);
ctx_set("_vpp_entry_data_off", e_data_offset);
ctx_set("_vpp_entry_size", e_uncompressed_size);
ctx_set("_vpp_entry_csize", e_compressed_size);
ctx_set("_vpp_entry_idx", entry_idx);
dummy = bytesat(0, 1);
entry_child = dummy |> parse volition_vpp_entry_row;
push("entries", entry_child);
// Parse file children at depth 0 (top-level VPP) and depth 1 (STR2 inside VPP)
// Stop at depth 2+ to prevent infinite recursion
if (vpp_depth <= 1) {
// Determine actual size to read and file position
// Two cases:
// 1. Uncompressed files (csize == 0xFFFFFFFF): e_data_offset is actual offset, use directly
// 2. Compressed files (csize < usize): e_data_offset is virtual, use cumulative compressed offset
if (e_compressed_size == 0xFFFFFFFF) {
// Uncompressed file - e_data_offset is the actual position
read_size = e_uncompressed_size;
actual_csize = e_compressed_size;
file_offset = data_offset_base + e_data_offset;
} else {
// Compressed file - files are packed consecutively by compressed size
// e_data_offset is virtual (uncompressed layout), use cumulative compressed offset
read_size = e_compressed_size;
actual_csize = e_compressed_size;
file_offset = data_offset_base + compressed_offset;
compressed_offset = compressed_offset + e_compressed_size;
}
// Read file data and create child
if (file_offset + read_size <= size() && read_size > 0) {
seek(file_offset);
file_data = read(read_size);
ctx_set("_vpp_file_name", entry_name);
ctx_set("_vpp_file_usize", e_uncompressed_size);
ctx_set("_vpp_file_csize", actual_csize);
file_child = file_data |> parse volition_vpp_file;
push("files", file_child);
file_child = 0; // Clear to avoid duplicate (last file)
}
}
seek(saved_pos);
entry_idx = entry_idx + 1;
}
ui_table("entries", "entries", "Index,Name,Offset,Size,CompSize");
total_entries = dir_count ui("Total Entries");
file_size = size() ui("File Size");
}
// Entry row for table display - inherits byteorder
type volition_vpp_entry_row ui("Entry")
{
set_hidden();
Index = ctx_get("_vpp_entry_idx");
Name = ctx_get("_vpp_entry_name");
set_name(Name);
Offset = ctx_get("_vpp_entry_data_off");
Size = ctx_get("_vpp_entry_size");
CompSize = ctx_get("_vpp_entry_csize");
}
// File child for VPP archives - uses LE for file content processing
// (file contents have their own format independent of archive byte order)
type volition_vpp_file ui("VPP File") byteorder LE
{
file_name = ctx_get("_vpp_file_name");
set_name(file_name);
usize = ctx_get("_vpp_file_usize");
csize = ctx_get("_vpp_file_csize");
// Read first 8 bytes to check for SR compression header before consuming stream
total_size = size();
first_byte = u8at(0);
second_byte = u8at(1);
third_byte = u8at(2);
fourth_byte = u8at(3);
// Check for Saints Row header: bytes 4-7 = uncompressed size (BE)
sr_header_usize = 0;
if (total_size >= 8) {
sr_header_usize = (u8at(4) * 16777216) + (u8at(5) * 65536) + (u8at(6) * 256) + u8at(7);
}
raw_data = read(total_size);
// Determine if decompression is needed
is_compressed = (csize != 0xFFFFFFFF) && (csize < usize) && (total_size > 0);
if (is_compressed) {
// Check magic bytes for compression format
first_byte = u8at(0);
second_byte = u8at(1);
third_byte = u8at(2);
fourth_byte = u8at(3);
if (first_byte == 0x78) {
// Standard zlib compression (78 xx header)
file_data = raw_data |> zlib;
compression_used = "ZLIB" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else if (first_byte == 0x0F && second_byte == 0xF5 && third_byte == 0x12 &&
(fourth_byte == 0xEE || fourth_byte == 0xED)) {
// XMem/LZX with proper header (LZXNATIVE=0x0FF512EE or LZXTDECODE=0x0FF512ED)
file_data = raw_data |> xmem;
compression_used = "XMem" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else if (first_byte == 0x00 && total_size >= 8 && sr_header_usize == usize) {
// Saints Row custom compression header format:
// Bytes 0-3: Header with compressed payload size
// Bytes 4-7: Uncompressed size (BE u32) - already checked as sr_header_usize
// Bytes 8+: LZO compressed payload
// Extract payload (skip 8-byte header)
payload = bytesof(raw_data, 8, total_size - 8);
file_data = lzo(payload, usize);
if (len(file_data) == usize) {
compression_used = "LZO (SR header)" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else {
// LZO failed - use raw data
file_data = raw_data;
compression_used = "Unknown (raw)" ui("Compression");
}
} else if (first_byte == 0x00 && total_size >= 8) {
// First byte is 0 but header doesn't match expected size - try other decompression
file_data = raw_data |> zlib_auto;
if (len(file_data) > 0 && len(file_data) >= usize / 2) {
compression_used = "ZLIB (auto)" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else {
file_data = lzo(raw_data, usize);
if (len(file_data) == usize) {
compression_used = "LZO" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else {
file_data = raw_data;
compression_used = "Unknown (raw)" ui("Compression");
}
}
} else {
// Unknown compression format - try zlib auto-detect first
file_data = raw_data |> zlib_auto;
if (len(file_data) > 0 && len(file_data) >= usize / 2) {
compression_used = "ZLIB (auto)" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else {
// ZLIB failed - try LZO (commonly used by Saints Row PS3/Xbox)
file_data = lzo(raw_data, usize);
if (len(file_data) == usize) {
compression_used = "LZO" ui("Compression");
decompressed_size = len(file_data) ui("Decompressed Size");
} else {
// LZO failed too - use raw data
file_data = raw_data;
compression_used = "Unknown (raw)" ui("Compression");
}
}
}
} else {
file_data = raw_data;
}
file_size = len(file_data) ui("Size");
// Check for XML file types that should display as text
is_xml = ends_with(file_name, ".xtbl") ||
ends_with(file_name, ".cte_xtbl") ||
ends_with(file_name, ".dtodx") ||
ends_with(file_name, ".vint_doc") ||
ends_with(file_name, ".xml");
if (is_xml) {
set_display("XML File");
set_viewer("text");
set_text(ascii(file_data));
}
// Check for ASM manifest files
is_asm = ends_with(file_name, ".asm_pc") ||
ends_with(file_name, ".asm_xbox2") ||
ends_with(file_name, ".asm_ps3");
if (is_asm && len(file_data) > 8) {
// Check for ASM magic (0xBEEFFEED LE or 0xEDFEEFBE BE)
asm_magic = u32at(0);
if (asm_magic == 0xBEEFFEED || asm_magic == 0xEDFEEFBE) {
set_display("ASM Manifest");
ctx_set("_vpp_file_name", file_name);
asm_child = file_data |> parse volition_asm_nested;
push("manifests", asm_child);
asm_child = 0; // Clear to avoid duplicate
}
}
// Check for nested STR2 archives (streaming containers)
is_str2 = ends_with(file_name, ".str2_pc") ||
ends_with(file_name, ".str2_xbox2") ||
ends_with(file_name, ".str2_ps3");
if (is_str2 && len(file_data) > 8) {
str2_magic = u32at(0);
if (str2_magic == 0x51890ACE || str2_magic == 0xCE0A8951) {
set_display("STR2 Container");
ctx_set("_vpp_file_name", file_name);
nested_vpp = file_data |> parse volition_vpp_nested;
push("nested", nested_vpp);
nested_vpp = 0;
}
}
// Check for CPEG/GPEG/CVBM/GVBM texture files
// CPEG/CVBM = CPU texture header, GPEG/GVBM = GPU pixel data
is_cpeg = ends_with(file_name, ".cpeg_pc") || ends_with(file_name, ".cpeg_xbox2") ||
ends_with(file_name, ".cpeg_ps3") || ends_with(file_name, ".peg_pc") ||
ends_with(file_name, ".cvbm_pc") || ends_with(file_name, ".cvbm_xbox2") ||
ends_with(file_name, ".cvbm_ps3");
is_gpeg = ends_with(file_name, ".gpeg_pc") || ends_with(file_name, ".gpeg_xbox2") ||
ends_with(file_name, ".gpeg_ps3") || ends_with(file_name, ".g_peg_pc") ||
ends_with(file_name, ".gvbm_pc") || ends_with(file_name, ".gvbm_xbox2") ||
ends_with(file_name, ".gvbm_ps3");
if (is_cpeg && len(file_data) > 8) {
// Check for PEG magic (GEKV or VKEG)
peg_magic = u32at(0);
if (peg_magic == 0x564B4547 || peg_magic == 0x47454B56) {
set_display("PEG Texture");
ctx_set("_vpp_file_name", file_name);
// Look up paired GPEG data (stored in context by VPP parser pass 1)
cpeg_base = basename(file_name);
gpeg_exists = ctx_get("_gpeg_exists_" + cpeg_base);
// Pass GPEG data to PEG parser if available
if (gpeg_exists == 1) {
gpeg_data = ctx_get("_gpeg_data_" + cpeg_base);
ctx_set("_peg_gpeg_data", gpeg_data);
ctx_set("_peg_has_gpeg", 1);
} else {
ctx_set("_peg_has_gpeg", 0);
}
peg_child = file_data |> parse volition_peg_nested;
push("textures", peg_child);
peg_child = 0; // Clear to avoid duplicate tree node
}
}
// GPEG files (pixel data only) - just show as binary preview
if (is_gpeg && len(file_data) > 0) {
set_display("GPEG Pixel Data");
set_viewer("hex");
}
// Check for mesh files (csmesh, ccmesh, clmesh)
is_mesh = ends_with(file_name, ".csmesh_pc") || ends_with(file_name, ".csmesh_xbox2") ||
ends_with(file_name, ".csmesh_ps3") ||
ends_with(file_name, ".ccmesh_pc") || ends_with(file_name, ".ccmesh_xbox2") ||
ends_with(file_name, ".ccmesh_ps3") ||
ends_with(file_name, ".clmesh_pc") || ends_with(file_name, ".clmesh_xbox2") ||
ends_with(file_name, ".clmesh_ps3");
if (is_mesh && len(file_data) > 32) {
// Mesh files are validated by extension, magic check happens in parser
set_display("Mesh");
ctx_set("_vpp_file_name", file_name);
mesh_child = file_data |> parse volition_mesh_nested;
push("meshes", mesh_child);
mesh_child = 0;
}
// Check for material library files
is_matlib = ends_with(file_name, ".matlib_pc") || ends_with(file_name, ".matlib_xbox2") ||
ends_with(file_name, ".matlib_ps3");
if (is_matlib && len(file_data) > 0) {
set_display("Material Library");
set_viewer("text");
set_text(ascii(file_data));
}
// Check for rig/skeleton files
is_rig = ends_with(file_name, ".rig_pc") || ends_with(file_name, ".rig_xbox2") ||
ends_with(file_name, ".rig_ps3");
if (is_rig && len(file_data) > 16) {
// Rig files don't have a magic number, just parse by extension
set_display("Rig/Skeleton");
ctx_set("_vpp_file_name", file_name);
rig_child = file_data |> parse volition_rig_nested;
push("rigs", rig_child);
rig_child = 0;
}
// Check for zone files (czh, czn, gzn)
is_zone = ends_with(file_name, ".czh_pc") || ends_with(file_name, ".czh_xbox2") ||
ends_with(file_name, ".czh_ps3") ||
ends_with(file_name, ".czn_pc") || ends_with(file_name, ".czn_xbox2") ||
ends_with(file_name, ".czn_ps3") ||
ends_with(file_name, ".gzn_pc") || ends_with(file_name, ".gzn_xbox2") ||
ends_with(file_name, ".gzn_ps3");
if (is_zone && len(file_data) > 20) {
// Zone files are validated by extension, signature check happens in parser
zone_display = "Zone";
if (contains(file_name, ".czh")) { zone_display = "Zone Header"; }
if (contains(file_name, ".czn")) { zone_display = "Zone Data"; }
if (contains(file_name, ".gzn")) { zone_display = "Zone GPU"; }
set_display(zone_display);
ctx_set("_vpp_file_name", file_name);
zone_child = file_data |> parse volition_zone_nested;
push("zones", zone_child);
zone_child = 0;
}
// Lua scripts
is_lua = ends_with(file_name, ".lua");
if (is_lua) {
set_display("Lua Script");
set_viewer("text");
set_text(ascii(file_data));
}
// Other text files
is_text = ends_with(file_name, ".txt") || ends_with(file_name, ".csv") ||
ends_with(file_name, ".cfg") || ends_with(file_name, ".log");
if (is_text) {
set_display("Text File");
set_viewer("text");
set_text(ascii(file_data));
}
// Audio banks (Wwise BNK)
is_bnk = ends_with(file_name, ".bnk_pc") || ends_with(file_name, ".bnk_xbox2") ||
ends_with(file_name, ".bnk_ps3");
if (is_bnk && len(file_data) > 12) {
bnk_magic = ascii(bytesat(0, 4));
if (bnk_magic == "BKHD") {
set_display("Audio Bank");
ctx_set("_vpp_file_name", file_name);
bnk_child = file_data |> parse volition_bnk_nested;
push("audio", bnk_child);
bnk_child = 0;
}
}
// Animation files
is_anim = ends_with(file_name, ".anim_pc") || ends_with(file_name, ".anim_xbox2") ||
ends_with(file_name, ".anim_ps3");
if (is_anim && len(file_data) > 40) {
set_display("Animation");
ctx_set("_vpp_file_name", file_name);
anim_child = file_data |> parse volition_anim_nested;
push("animations", anim_child);
anim_child = 0;
}
// Cloth simulation files
is_sim = ends_with(file_name, ".sim_pc") || ends_with(file_name, ".sim_xbox2") ||
ends_with(file_name, ".sim_ps3");
if (is_sim && len(file_data) > 80) {
sim_ver = i32at(0);
if (sim_ver == 2 || sim_ver == 5) {
set_display("Cloth Simulation");
ctx_set("_vpp_file_name", file_name);
sim_child = file_data |> parse volition_sim_nested;
push("simulations", sim_child);
sim_child = 0;
}
}
// Morph target files
is_morph = ends_with(file_name, ".cmorph_pc") || ends_with(file_name, ".cmorph_xbox2") ||
ends_with(file_name, ".cmorph_ps3");
if (is_morph && len(file_data) > 20) {
set_display("Morph Targets");
ctx_set("_vpp_file_name", file_name);
morph_child = file_data |> parse volition_morph_nested;
push("morphs", morph_child);
morph_child = 0;
}
// GPU data files - label properly (raw binary, hex viewer OK)
is_gpu_mesh = ends_with(file_name, ".gcmesh_pc") || ends_with(file_name, ".gcmesh_xbox2") ||
ends_with(file_name, ".gcmesh_ps3") ||
ends_with(file_name, ".gsmesh_pc") || ends_with(file_name, ".gsmesh_xbox2") ||
ends_with(file_name, ".gsmesh_ps3") ||
ends_with(file_name, ".glmesh_pc") || ends_with(file_name, ".glmesh_xbox2") ||
ends_with(file_name, ".glmesh_ps3");
if (is_gpu_mesh) {
set_display("GPU Mesh Data");
}
is_gpu_morph = ends_with(file_name, ".gmorph_pc") || ends_with(file_name, ".gmorph_xbox2") ||
ends_with(file_name, ".gmorph_ps3");
if (is_gpu_morph) {
set_display("GPU Morph Data");
}
// Check for v_file_header (streaming reference header)
// Signature 0x3854 at start of file indicates streaming references
if (len(file_data) > 28) {
vfh_sig = u16at(0);
if (vfh_sig == 0x3854) {
ctx_set("_vfh_file_name", file_name);
vfh_child = file_data |> parse volition_v_file_header;
push("headers", vfh_child);
vfh_child = 0;
}
}
set_preview(file_name, file_data);
}
// VPP Version 10 - Saints Row IV remaster, Agents of Mayhem
// Inherits byteorder from caller
type volition_vpp_v10 ui("VPP Archives")
{
// V10 has similar structure to V6 but different offsets
// Header is at 0x150
seek(0x150);
u32 flags ui("Flags");
u32 dir_count ui("Entry Count");
u32 package_size ui("Package Size");
u32 dir_size ui("Directory Size");
u32 names_size ui("Names Size");
u32 uncompressed_size ui("Uncompressed Size");
u32 compressed_size ui("Compressed Size");
skip(4); // padding
u32 data_offset ui("Data Offset");
is_compressed = (flags & 1) != 0;
is_condensed = (flags & 2) != 0;
compression_type = "None";
if (is_compressed) { compression_type = "ZLIB"; }
if (is_condensed) { compression_type = "Condensed"; }
compression = compression_type ui("Compression");
// Directory starts at 2048 bytes
dir_offset = 2048;
// Calculate section offsets
names_offset = dir_offset + dir_size;
names_offset = ((names_offset + 2047) / 2048) * 2048;
data_offset_base = names_offset + names_size;
data_offset_base = ((data_offset_base + 2047) / 2048) * 2048;
data_base_display = data_offset_base ui("Data Section Base");
// Check nesting depth (to prevent deep recursion in nested VPPs)
vpp_depth = ctx_get("_vpp_depth");
if (vpp_depth == 0) { vpp_depth = 0; }
// PASS 1: GPEG collection disabled for now (causes issues with nested VPPs)
// TODO: Re-enable once nested parsing is stable
// PASS 2: Parse directory and create file children (V10)
seek(dir_offset);
skip_tree("entries");
entry_idx = 0;
// Track cumulative compressed offset for STR2 containers where files are individually compressed
compressed_offset = 0;
repeat(dir_count) {
u32 e_name_offset;
u32 e_runtime_offset;
u32 e_data_offset;
u32 e_uncompressed_size;
u32 e_compressed_size;
u32 e_package_ptr;
saved_pos = pos();
// Read filename (with bounds check)
name_offset_abs = names_offset + e_name_offset;
if (name_offset_abs < size()) {
seek(name_offset_abs);
entry_name = cstring();
} else {
entry_name = "[Invalid: offset beyond EOF]";
}
// Always create entry row for table display
ctx_set("_vpp_entry_name", entry_name);
ctx_set("_vpp_entry_data_off", e_data_offset);
ctx_set("_vpp_entry_size", e_uncompressed_size);
ctx_set("_vpp_entry_csize", e_compressed_size);
ctx_set("_vpp_entry_idx", entry_idx);
dummy = bytesat(0, 1);
entry_child = dummy |> parse volition_vpp_entry_row;
push("entries", entry_child);
// Parse file children at depth 0 (top-level VPP) and depth 1 (STR2 inside VPP)
// Stop at depth 2+ to prevent infinite recursion
if (vpp_depth <= 1) {
// Determine actual size to read and file position
// Two cases:
// 1. Uncompressed files (csize == 0xFFFFFFFF): e_data_offset is actual offset, use directly
// 2. Compressed files (csize < usize): e_data_offset is virtual, use cumulative compressed offset
if (e_compressed_size == 0xFFFFFFFF) {
// Uncompressed file - e_data_offset is the actual position
read_size = e_uncompressed_size;
actual_csize = e_compressed_size;
file_offset = data_offset_base + e_data_offset;
} else {
// Compressed file - files are packed consecutively by compressed size
// e_data_offset is virtual (uncompressed layout), use cumulative compressed offset
read_size = e_compressed_size;
actual_csize = e_compressed_size;
file_offset = data_offset_base + compressed_offset;
compressed_offset = compressed_offset + e_compressed_size;
}
// Read file data and create child
if (file_offset + read_size <= size() && read_size > 0) {
seek(file_offset);
file_data = read(read_size);
ctx_set("_vpp_file_name", entry_name);
ctx_set("_vpp_file_usize", e_uncompressed_size);
ctx_set("_vpp_file_csize", actual_csize);
file_child = file_data |> parse volition_vpp_file;
push("files", file_child);
file_child = 0; // Clear to avoid duplicate (last file)
}
}
seek(saved_pos);
entry_idx = entry_idx + 1;
}
ui_table("entries", "entries", "Index,Name,Offset,Size,CompSize");
total_entries = dir_count ui("Total Entries");
file_size = size() ui("File Size");
}
// Nested VPP type - for parsing STR2 files within VPP archives
// No criteria block needed since called directly via parse()
type volition_vpp_nested ui("STR2 Container") byteorder LE
{
file_name = ctx_get("_vpp_file_name");
set_name(file_name + " [Contents]"); // Distinct from parent volition_vpp_file
// Increment depth to signal nested parsing
current_depth = ctx_get("_vpp_depth");
if (current_depth == 0) { current_depth = 0; }
ctx_set("_vpp_depth", current_depth + 1);
// Detect endianness from magic
magic_check = u32at(0);
if (magic_check == 0xCE0A8951) {
// Big-endian (Xbox 360/PS3)
parse_here("volition_vpp_be");
} else {
// Little-endian (PC)
parse_here("volition_vpp_impl");
}
// Restore depth
ctx_set("_vpp_depth", current_depth);
}
// Nested PEG type - for parsing CPEG/GPEG files within VPP archives
// No criteria block needed since called directly via parse()
type volition_peg_nested ui("PEG Texture") byteorder LE
{
file_name = ctx_get("_vpp_file_name");
set_name(file_name + " [Texture]"); // Distinct from parent volition_vpp_file
// Detect endianness from magic
// GEKV (0x47454B56 in file) reads as 0x564B4547 in LE mode = PC
// VKEG (0x564B4547 in file) reads as 0x47454B56 in LE mode = Xbox/PS3
magic_check = u32at(0);
if (magic_check == 0x47454B56) {
// Big-endian (Xbox 360/PS3)
parse_here("volition_peg_be");
} else {
// Little-endian (PC)
parse_here("volition_peg_impl");
}
}
// v_file_header - Streaming reference header
// Found at start of files in STR2 containers (zone files, meshes, etc.)
// Signature: 0x3854
type volition_v_file_header ui("Streaming Header") byteorder LE
{
file_name = ctx_get("_vfh_file_name");
set_name(file_name + " [Header]");
u16 signature ui("Signature"); // 0x3854
u16 version ui("Version"); // 4
u32 ref_data_size ui("Reference Data Size");
u32 ref_data_start ui("Reference Data Start");
u32 ref_count ui("Reference Count");
skip(16); // padding
header_size = 28 ui("Header Size");
// Parse reference strings
if (ref_count > 0 && ref_data_start > 0) {
seek(ref_data_start);
ref_idx = 0;
repeat(ref_count) {
ref_name = cstring();
ctx_set("_vfh_ref_name", ref_name);
ctx_set("_vfh_ref_idx", ref_idx);
ref_entry = bytesat(0, 1) |> parse volition_v_file_ref;
push("references", ref_entry);
ref_entry = 0;
ref_idx = ref_idx + 1;
}
}
total_refs = ref_count ui("Total References");
}
// Reference entry for v_file_header
type volition_v_file_ref ui("Reference")
{
ref_name = ctx_get("_vfh_ref_name");
set_name(ref_name);
index = ctx_get("_vfh_ref_idx") ui("Index");
name = ref_name ui("Referenced File");
}