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>
970 lines
32 KiB
Plaintext
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");
|
|
}
|