use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result, bail};
use plasma_prp::core::class_index::ClassIndex;
use plasma_prp::resource::prp::{self, PrpPage, PlasmaRead};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
return Ok(());
}
match args[1].as_str() {
"inspect" => cmd_inspect(&args[2..]),
"validate" => cmd_validate(&args[2..]),
"diff" => cmd_diff(&args[2..]),
"export-textures" => cmd_export_textures(&args[2..]),
"help" | "--help" | "-h" => { print_usage(); Ok(()) }
other => {
eprintln!("Unknown command: {}", other);
print_usage();
std::process::exit(1);
}
}
}
fn print_usage() {
eprintln!("plasma-prp — Plasma engine PRP file tool\n");
eprintln!("USAGE:");
eprintln!(" plasma-prp inspect <file.prp> Dump object inventory");
eprintln!(" plasma-prp inspect <file.age> Dump age page list");
eprintln!(" plasma-prp inspect <file.sdl> Dump SDL descriptors");
eprintln!(" plasma-prp validate <file.prp> ... Check for common issues");
eprintln!(" plasma-prp diff <a.prp> <b.prp> Compare two PRP files");
eprintln!(" plasma-prp export-textures <file.prp> [out_dir] Extract mipmaps as PNG");
}
fn cmd_inspect(args: &[String]) -> Result<()> {
if args.is_empty() {
bail!("Usage: plasma-prp inspect <file>");
}
let path = Path::new(&args[0]);
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"prp" => inspect_prp(path),
"age" => inspect_age(path),
"sdl" => inspect_sdl(path),
_ => {
if let Ok(()) = inspect_prp(path) {
return Ok(());
}
bail!("Unknown file type: {}", path.display());
}
}
}
fn inspect_prp(path: &Path) -> Result<()> {
let page = PrpPage::from_file(path)
.with_context(|| format!("Failed to load {}", path.display()))?;
println!("=== PRP: {} ===", path.display());
println!("Age: {} Page: {} Version: {}",
page.header.age_name, page.header.page_name, page.header.version);
println!("Sequence: 0x{:08X} Flags: 0x{:04X} Major: {}",
page.header.sequence_number, page.header.flags, page.header.major_version);
println!("Data start: 0x{:X} Index start: 0x{:X}",
page.header.data_start, page.header.index_start);
println!("Class versions: {}", page.header.class_versions.len());
println!("Total objects: {}\n", page.keys.len());
let mut counts: HashMap<u16, Vec<&str>> = HashMap::new();
for key in &page.keys {
counts.entry(key.class_type).or_default().push(&key.object_name);
}
let mut sorted: Vec<_> = counts.iter().collect();
sorted.sort_by_key(|(_, names)| std::cmp::Reverse(names.len()));
println!("{:<8} {:<40} {}", "Count", "Class", "Type ID");
println!("{}", "-".repeat(60));
for (class_type, names) in &sorted {
let class_name = ClassIndex::class_name(**class_type);
println!("{:<8} {:<40} 0x{:04X}", names.len(), class_name, class_type);
}
if page.keys.len() <= 50 || std::env::args().any(|a| a == "-v" || a == "--verbose") {
println!("\n--- All Objects ---");
for key in &page.keys {
println!(" [0x{:04X}] {} ({} bytes at 0x{:X})",
key.class_type,
key.object_name,
key.data_len,
key.start_pos);
}
} else {
println!("\n(Use -v for full object listing)");
}
Ok(())
}
fn inspect_age(path: &Path) -> Result<()> {
let desc = plasma_prp::age::description::AgeDescription::from_file(path)?;
println!("=== AGE: {} ===", desc.age_name);
println!("Sequence prefix: {} Max capacity: {} Day length: {:.1}",
desc.sequence_prefix, desc.max_capacity, desc.day_length);
println!("Linger time: {} Release version: {}\n",
desc.linger_time, desc.release_version);
println!("{:<30} {:<8} {:<8} {:<10} {}", "Page", "Suffix", "Flags", "Auto-load", "PRP File");
println!("{}", "-".repeat(80));
for page in &desc.pages {
println!("{:<30} {:<8} {:<8} {:<10} {}",
page.name, page.seq_suffix, page.flags,
if page.auto_load() { "yes" } else { "no" },
desc.prp_filename(page));
}
println!("\nCommon pages:");
for f in desc.common_page_filenames() {
println!(" {}", f);
}
Ok(())
}
fn inspect_sdl(path: &Path) -> Result<()> {
let mut mgr = plasma_prp::sdl::SdlManager::new();
let count = mgr.load_file(path)?;
println!("=== SDL: {} ({} descriptors) ===\n", path.display(), count);
let content = std::fs::read_to_string(path)?;
let descs = plasma_prp::sdl::descriptor::parse_sdl(&content)?;
for desc in &descs {
println!("STATEDESC {} (version {})", desc.name, desc.version);
for var in &desc.variables {
let default = var.default_value.as_deref().unwrap_or("-");
println!(" {:?} {}[{}] = {}", var.var_type, var.name, var.count, default);
}
println!();
}
Ok(())
}
fn cmd_validate(args: &[String]) -> Result<()> {
if args.is_empty() {
bail!("Usage: plasma-prp validate <file.prp> ...");
}
let mut total_issues = 0;
for arg in args {
let path = Path::new(arg);
if !path.exists() {
eprintln!("WARN: {} does not exist", path.display());
total_issues += 1;
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "prp" {
eprintln!("SKIP: {} (not a .prp file)", path.display());
continue;
}
let issues = validate_prp(path)?;
total_issues += issues;
}
if total_issues == 0 {
println!("OK: No issues found.");
} else {
println!("\n{} issue(s) found.", total_issues);
}
Ok(())
}
fn validate_prp(path: &Path) -> Result<usize> {
let page = PrpPage::from_file(path)
.with_context(|| format!("Failed to load {}", path.display()))?;
let mut issues = 0;
println!("Validating: {} ({} objects)", path.display(), page.keys.len());
let mut by_class: HashMap<u16, Vec<&str>> = HashMap::new();
for key in &page.keys {
by_class.entry(key.class_type).or_default().push(&key.object_name);
}
for (class_type, names) in &by_class {
let mut seen: HashMap<&str, usize> = HashMap::new();
for name in names {
*seen.entry(name).or_insert(0) += 1;
}
for (name, count) in &seen {
if *count > 1 {
println!(" WARN: Duplicate name '{}' ({} instances) in class {}",
name, count, ClassIndex::class_name(*class_type));
issues += 1;
}
}
}
for key in &page.keys {
if key.data_len == 0 {
println!(" WARN: Zero-length object '{}' ({})",
key.object_name, ClassIndex::class_name(key.class_type));
issues += 1;
}
}
let data_end = page.header.index_start;
for key in &page.keys {
let obj_end = key.start_pos + key.data_len;
if key.start_pos < page.header.data_start || obj_end > data_end {
println!(" ERR: Object '{}' data range [0x{:X}..0x{:X}] outside data section [0x{:X}..0x{:X}]",
key.object_name, key.start_pos, obj_end,
page.header.data_start, data_end);
issues += 1;
}
}
for key in page.keys_of_type(prp::class_types::HS_GMATERIAL) {
if let Some(data) = page.object_data(key) {
if let Ok(layer_names) = prp::parse_material_layers(data) {
for layer_name in &layer_names {
let found = page.keys.iter().any(|k|
k.class_type == prp::class_types::PL_LAYER && k.object_name == *layer_name);
if !found {
println!(" WARN: Material '{}' references missing layer '{}'",
key.object_name, layer_name);
issues += 1;
}
}
}
}
}
for key in page.keys_of_type(prp::class_types::PL_LAYER) {
if let Some(data) = page.object_data(key) {
if let Ok(Some(tex_name)) = prp::parse_layer_texture(data) {
let found = page.keys.iter().any(|k|
(k.class_type == prp::class_types::PL_MIPMAP
|| k.class_type == prp::class_types::PL_CUBIC_ENVIRONMAP
|| k.class_type == prp::class_types::PL_DYNAMIC_TEXT_MAP)
&& k.object_name == tex_name);
if !found {
println!(" NOTE: Layer '{}' references texture '{}' (may be in Textures page)",
key.object_name, tex_name);
}
}
}
}
match page.to_bytes() {
Ok(written) => {
let original = std::fs::read(path)?;
if original != written {
println!(" ERR: Round-trip mismatch ({} vs {} bytes)",
original.len(), written.len());
issues += 1;
}
}
Err(e) => {
println!(" ERR: Round-trip serialization failed: {}", e);
issues += 1;
}
}
if issues == 0 {
println!(" OK");
}
Ok(issues)
}
fn cmd_diff(args: &[String]) -> Result<()> {
if args.len() < 2 {
bail!("Usage: plasma-prp diff <a.prp> <b.prp>");
}
let path_a = Path::new(&args[0]);
let path_b = Path::new(&args[1]);
let page_a = PrpPage::from_file(path_a)
.with_context(|| format!("Failed to load {}", path_a.display()))?;
let page_b = PrpPage::from_file(path_b)
.with_context(|| format!("Failed to load {}", path_b.display()))?;
println!("Comparing:");
println!(" A: {} ({} objects)", path_a.display(), page_a.keys.len());
println!(" B: {} ({} objects)\n", path_b.display(), page_b.keys.len());
if page_a.header.age_name != page_b.header.age_name {
println!(" Age name: '{}' -> '{}'", page_a.header.age_name, page_b.header.age_name);
}
if page_a.header.page_name != page_b.header.page_name {
println!(" Page name: '{}' -> '{}'", page_a.header.page_name, page_b.header.page_name);
}
if page_a.header.checksum != page_b.header.checksum {
println!(" Checksum: 0x{:08X} -> 0x{:08X}", page_a.header.checksum, page_b.header.checksum);
}
let map_a: HashMap<(u16, &str), &prp::ObjectKey> = page_a.keys.iter()
.map(|k| ((k.class_type, k.object_name.as_str()), k))
.collect();
let map_b: HashMap<(u16, &str), &prp::ObjectKey> = page_b.keys.iter()
.map(|k| ((k.class_type, k.object_name.as_str()), k))
.collect();
let mut only_a: Vec<_> = map_a.keys()
.filter(|k| !map_b.contains_key(k))
.collect();
only_a.sort();
let mut only_b: Vec<_> = map_b.keys()
.filter(|k| !map_a.contains_key(k))
.collect();
only_b.sort();
let mut modified = Vec::new();
for (key, obj_a) in &map_a {
if let Some(obj_b) = map_b.get(key) {
let data_a = page_a.object_data(obj_a);
let data_b = page_b.object_data(obj_b);
match (data_a, data_b) {
(Some(a), Some(b)) if a != b => {
modified.push((key, a.len(), b.len()));
}
_ => {}
}
}
}
modified.sort();
if only_a.is_empty() && only_b.is_empty() && modified.is_empty() {
println!("Files are identical in object content.");
return Ok(());
}
if !only_a.is_empty() {
println!("--- Only in A ({}) ---", only_a.len());
for (class_type, name) in &only_a {
println!(" - [{}] {}", ClassIndex::class_name(*class_type), name);
}
}
if !only_b.is_empty() {
println!("\n+++ Only in B ({}) +++", only_b.len());
for (class_type, name) in &only_b {
println!(" + [{}] {}", ClassIndex::class_name(*class_type), name);
}
}
if !modified.is_empty() {
println!("\n~~~ Modified ({}) ~~~", modified.len());
for ((class_type, name), size_a, size_b) in &modified {
let delta = *size_b as i64 - *size_a as i64;
let sign = if delta >= 0 { "+" } else { "" };
println!(" ~ [{}] {} ({} -> {} bytes, {}{})",
ClassIndex::class_name(*class_type), name,
size_a, size_b, sign, delta);
}
}
println!("\nSummary: {} removed, {} added, {} modified",
only_a.len(), only_b.len(), modified.len());
Ok(())
}
fn cmd_export_textures(args: &[String]) -> Result<()> {
if args.is_empty() {
bail!("Usage: plasma-prp export-textures <file.prp> [out_dir]");
}
let path = Path::new(&args[0]);
let out_dir = if args.len() > 1 {
Path::new(&args[1]).to_path_buf()
} else {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("textures");
Path::new(stem).to_path_buf()
};
let page = PrpPage::from_file(path)
.with_context(|| format!("Failed to load {}", path.display()))?;
std::fs::create_dir_all(&out_dir)?;
let mipmap_keys = page.keys_of_type(prp::class_types::PL_MIPMAP);
println!("Exporting {} mipmaps from {} to {}/",
mipmap_keys.len(), path.display(), out_dir.display());
let mut exported = 0;
let mut failed = 0;
for key in &mipmap_keys {
if let Some(data) = page.object_data(key) {
match export_mipmap(data, &key.object_name, &out_dir) {
Ok(()) => exported += 1,
Err(e) => {
eprintln!(" FAIL: {}: {}", key.object_name, e);
failed += 1;
}
}
}
}
let cubic_keys = page.keys_of_type(prp::class_types::PL_CUBIC_ENVIRONMAP);
if !cubic_keys.is_empty() {
println!("Found {} cubic environment maps (face extraction not implemented)", cubic_keys.len());
}
println!("Exported {}/{} textures ({} failed)",
exported, mipmap_keys.len(), failed);
Ok(())
}
fn export_mipmap(data: &[u8], name: &str, out_dir: &Path) -> Result<()> {
use image::{ImageBuffer, Rgba, RgbaImage, DynamicImage};
let mut cursor = std::io::Cursor::new(data);
let _class_idx = cursor.read_i16()?;
let non_nil = cursor.read_u8()?;
if non_nil != 0 {
let contents = cursor.read_u8()?;
cursor.skip(6)?; if contents & 0x02 != 0 { cursor.skip(1)?; } cursor.skip(2)?; cursor.skip(4)?; let _ = cursor.read_safe_string()?; if contents & 0x01 != 0 { cursor.skip(8)?; } }
let version = cursor.read_u8()?;
if version != 2 { bail!("Unsupported bitmap version: {}", version); }
let pixel_size = cursor.read_u8()?;
let _space = cursor.read_u8()?;
let _flags = cursor.read_u16()?;
let compression_type = cursor.read_u8()?;
let dxt_type = match compression_type {
0 | 2 | 3 => cursor.read_u8()?,
1 => {
let _block_size = cursor.read_u8()?;
cursor.read_u8()?
}
_ => bail!("Unknown compression: {}", compression_type),
};
cursor.skip(8)?;
let width = cursor.read_u32()?;
let height = cursor.read_u32()?;
let _row_bytes = cursor.read_u32()?;
let total_size = cursor.read_u32()?;
let _num_levels = cursor.read_u8()?;
let pos = cursor.position() as usize;
let remaining = data.len().saturating_sub(pos);
let read_size = (total_size as usize).min(remaining);
let pixel_data = &data[pos..pos + read_size];
if width == 0 || height == 0 {
bail!("Zero-dimension texture: {}x{}", width, height);
}
let img: DynamicImage = match compression_type {
0 => {
match pixel_size {
32 => {
let expected = (width * height * 4) as usize;
if pixel_data.len() < expected {
bail!("Not enough pixel data: {} < {}", pixel_data.len(), expected);
}
let img: RgbaImage = ImageBuffer::from_fn(width, height, |x, y| {
let offset = ((y * width + x) * 4) as usize;
Rgba([
pixel_data[offset + 2], pixel_data[offset + 1], pixel_data[offset], pixel_data[offset + 3], ])
});
DynamicImage::ImageRgba8(img)
}
_ => bail!("Unsupported pixel size: {}", pixel_size),
}
}
1 => {
let (w, h) = (width as usize, height as usize);
let mut rgba = vec![0u8; w * h * 4];
match dxt_type {
1 => decode_dxt1(pixel_data, w, h, &mut rgba)?,
5 => decode_dxt5(pixel_data, w, h, &mut rgba)?,
_ => bail!("Unsupported DXT type: {}", dxt_type),
}
let img: RgbaImage = ImageBuffer::from_raw(width, height, rgba)
.ok_or_else(|| anyhow::anyhow!("Failed to create image buffer"))?;
DynamicImage::ImageRgba8(img)
}
_ => bail!("Cannot export compression type {}", compression_type),
};
let safe_name: String = name.chars()
.map(|c| if c.is_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
.collect();
let out_path = out_dir.join(format!("{}.png", safe_name));
img.save(&out_path)
.with_context(|| format!("Failed to save {}", out_path.display()))?;
Ok(())
}
fn decode_dxt1(data: &[u8], width: usize, height: usize, rgba: &mut [u8]) -> Result<()> {
let bw = (width + 3) / 4;
let bh = (height + 3) / 4;
let mut offset = 0;
for by in 0..bh {
for bx in 0..bw {
if offset + 8 > data.len() { return Ok(()); }
let c0 = u16::from_le_bytes([data[offset], data[offset + 1]]);
let c1 = u16::from_le_bytes([data[offset + 2], data[offset + 3]]);
let bits = u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
offset += 8;
let colors = dxt_color_table(c0, c1, c0 > c1);
for py in 0..4 {
for px in 0..4 {
let x = bx * 4 + px;
let y = by * 4 + py;
if x >= width || y >= height { continue; }
let idx = ((bits >> ((py * 4 + px) * 2)) & 3) as usize;
let pixel = (y * width + x) * 4;
rgba[pixel] = colors[idx][0];
rgba[pixel + 1] = colors[idx][1];
rgba[pixel + 2] = colors[idx][2];
rgba[pixel + 3] = if !((c0 <= c1) && idx == 3) { 255 } else { 0 };
}
}
}
}
Ok(())
}
fn decode_dxt5(data: &[u8], width: usize, height: usize, rgba: &mut [u8]) -> Result<()> {
let bw = (width + 3) / 4;
let bh = (height + 3) / 4;
let mut offset = 0;
for by in 0..bh {
for bx in 0..bw {
if offset + 16 > data.len() { return Ok(()); }
let a0 = data[offset] as u16;
let a1 = data[offset + 1] as u16;
let alpha_bits = u64::from_le_bytes([
0, 0, data[offset + 2], data[offset + 3],
data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
]) >> 16;
offset += 8;
let alpha_table = dxt5_alpha_table(a0 as u8, a1 as u8);
let c0 = u16::from_le_bytes([data[offset], data[offset + 1]]);
let c1 = u16::from_le_bytes([data[offset + 2], data[offset + 3]]);
let bits = u32::from_le_bytes([data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]]);
offset += 8;
let colors = dxt_color_table(c0, c1, true);
for py in 0..4 {
for px in 0..4 {
let x = bx * 4 + px;
let y = by * 4 + py;
if x >= width || y >= height { continue; }
let color_idx = ((bits >> ((py * 4 + px) * 2)) & 3) as usize;
let alpha_idx = ((alpha_bits >> ((py * 4 + px) * 3)) & 7) as usize;
let pixel = (y * width + x) * 4;
rgba[pixel] = colors[color_idx][0];
rgba[pixel + 1] = colors[color_idx][1];
rgba[pixel + 2] = colors[color_idx][2];
rgba[pixel + 3] = alpha_table[alpha_idx];
}
}
}
}
Ok(())
}
fn rgb565_to_rgb(c: u16) -> [u8; 3] {
let r = ((c >> 11) & 0x1F) as u8;
let g = ((c >> 5) & 0x3F) as u8;
let b = (c & 0x1F) as u8;
[(r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)]
}
fn dxt_color_table(c0: u16, c1: u16, four_color: bool) -> [[u8; 3]; 4] {
let a = rgb565_to_rgb(c0);
let b = rgb565_to_rgb(c1);
if four_color {
[
a,
b,
[(2 * a[0] as u16 / 3 + b[0] as u16 / 3) as u8,
(2 * a[1] as u16 / 3 + b[1] as u16 / 3) as u8,
(2 * a[2] as u16 / 3 + b[2] as u16 / 3) as u8],
[(a[0] as u16 / 3 + 2 * b[0] as u16 / 3) as u8,
(a[1] as u16 / 3 + 2 * b[1] as u16 / 3) as u8,
(a[2] as u16 / 3 + 2 * b[2] as u16 / 3) as u8],
]
} else {
[
a,
b,
[(a[0] as u16 / 2 + b[0] as u16 / 2) as u8,
(a[1] as u16 / 2 + b[1] as u16 / 2) as u8,
(a[2] as u16 / 2 + b[2] as u16 / 2) as u8],
[0, 0, 0],
]
}
}
fn dxt5_alpha_table(a0: u8, a1: u8) -> [u8; 8] {
let (a0, a1) = (a0 as u16, a1 as u16);
if a0 > a1 {
[a0 as u8, a1 as u8,
((6 * a0 + 1 * a1) / 7) as u8,
((5 * a0 + 2 * a1) / 7) as u8,
((4 * a0 + 3 * a1) / 7) as u8,
((3 * a0 + 4 * a1) / 7) as u8,
((2 * a0 + 5 * a1) / 7) as u8,
((1 * a0 + 6 * a1) / 7) as u8]
} else {
[a0 as u8, a1 as u8,
((4 * a0 + 1 * a1) / 5) as u8,
((3 * a0 + 2 * a1) / 5) as u8,
((2 * a0 + 3 * a1) / 5) as u8,
((1 * a0 + 4 * a1) / 5) as u8,
0, 255]
}
}