Download crates/openwa-debug-cli/src/main.rs from nmarshall/wormz-a: direct link, hf CLI and curl.
- Browser
- Download file 21.5 kB
-
https://huggingface.co/nmarshall/wormz-a/resolve/main/crates/openwa-debug-cli/src/main.rs
- Command line
-
hf download hf://nmarshall/wormz-a/crates/openwa-debug-cli/src/main.rs
-
curl -L -o main.rs https://huggingface.co/nmarshall/wormz-a/resolve/main/crates/openwa-debug-cli/src/main.rs
21.5 kB
| //! CLI tool for the OpenWA debug server. | |
| //! | |
| //! Stateless: connects, sends one request, prints the response, disconnects. | |
| //! | |
| //! Usage: | |
| //! openwa-debug ping | |
| //! openwa-debug help | |
| //! openwa-debug read <ghidra_addr> [len] [--format hex|raw] [--port N] | |
| use std::io::{self, BufReader, BufWriter, Write}; | |
| use std::net::TcpStream; | |
| use std::process; | |
| use std::time::Duration; | |
| use openwa_debug_proto::*; | |
| fn main() { | |
| let args: Vec<String> = std::env::args().skip(1).collect(); | |
| if args.is_empty() { | |
| print_usage(); | |
| process::exit(1); | |
| } | |
| let mut port = DEFAULT_PORT; | |
| let mut format = Format::Hex; | |
| let mut positional = Vec::new(); | |
| // Parse args | |
| let mut i = 0; | |
| while i < args.len() { | |
| match args[i].as_str() { | |
| "--port" => { | |
| i += 1; | |
| port = args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else(|| { | |
| eprintln!("Error: --port requires a number"); | |
| process::exit(1); | |
| }); | |
| } | |
| "--format" => { | |
| i += 1; | |
| format = match args.get(i).map(|s| s.as_str()) { | |
| Some("hex") => Format::Hex, | |
| Some("raw") => Format::Raw, | |
| _ => { | |
| eprintln!("Error: --format must be 'hex' or 'raw'"); | |
| process::exit(1); | |
| } | |
| }; | |
| } | |
| other => positional.push(other.to_string()), | |
| } | |
| i += 1; | |
| } | |
| let request = match positional.first().map(|s| s.as_str()) { | |
| Some("ping") => Request::Ping, | |
| Some("help") => Request::Help, | |
| Some("read") => { | |
| let addr_str = positional.get(1).unwrap_or_else(|| { | |
| eprintln!("Error: read requires an address"); | |
| process::exit(1); | |
| }); | |
| // Detect shell eating '->' as redirect: address ends with '-' | |
| if addr_str.ends_with('-') { | |
| eprintln!( | |
| "Error: address '{}' looks truncated β did the shell eat '->'?", | |
| addr_str | |
| ); | |
| eprintln!(" Hint: quote the argument: read \"0x7A0884->0xA0->0x0\""); | |
| process::exit(1); | |
| } | |
| let expr = resolve_address_expr(addr_str, port); | |
| let len = positional.get(2).map(|s| parse_u32(s)).unwrap_or(256); | |
| match expr { | |
| AddressExpr::Simple { addr, absolute } => Request::Read { | |
| addr, | |
| len, | |
| absolute, | |
| }, | |
| AddressExpr::Chain { | |
| addr, | |
| chain, | |
| absolute, | |
| } => Request::ReadChain { | |
| addr, | |
| chain, | |
| len, | |
| absolute, | |
| }, | |
| } | |
| } | |
| Some("inspect") => { | |
| let class_name = positional | |
| .get(1) | |
| .unwrap_or_else(|| { | |
| eprintln!("Usage: inspect <class_name> <addr>"); | |
| process::exit(1); | |
| }) | |
| .clone(); | |
| let addr_str = positional.get(2).unwrap_or_else(|| { | |
| eprintln!("Usage: inspect <class_name> <addr>"); | |
| process::exit(1); | |
| }); | |
| let expr = resolve_address_expr(addr_str, port); | |
| match expr { | |
| AddressExpr::Simple { addr, absolute } => Request::Inspect { | |
| class_name, | |
| addr, | |
| chain: vec![], | |
| absolute, | |
| }, | |
| AddressExpr::Chain { | |
| addr, | |
| chain, | |
| absolute, | |
| } => Request::Inspect { | |
| class_name, | |
| addr, | |
| chain, | |
| absolute, | |
| }, | |
| } | |
| } | |
| Some("objects") => Request::ListObjects, | |
| Some("suspend") => Request::Suspend, | |
| Some("resume") => Request::Resume, | |
| Some("step") => { | |
| let count = positional.get(1).map(|s| parse_u32(s) as i32).unwrap_or(1); | |
| Request::Step { count } | |
| } | |
| Some("frame") => Request::Frame, | |
| Some("break") => { | |
| let frame = positional | |
| .get(1) | |
| .map(|s| { | |
| if s == "clear" || s == "off" { | |
| -1 | |
| } else { | |
| parse_u32(s) as i32 | |
| } | |
| }) | |
| .unwrap_or_else(|| { | |
| eprintln!("Usage: break <frame> | break clear"); | |
| process::exit(1); | |
| }); | |
| Request::Break { frame } | |
| } | |
| Some("snapshot") => Request::Snapshot, | |
| Some(cmd) => { | |
| eprintln!("Unknown command: {cmd}"); | |
| print_usage(); | |
| process::exit(1); | |
| } | |
| None => { | |
| print_usage(); | |
| process::exit(1); | |
| } | |
| }; | |
| match send_request(port, &request) { | |
| Ok(response) => print_response(&response, format), | |
| Err(e) => { | |
| eprintln!("Error: {e}"); | |
| process::exit(1); | |
| } | |
| } | |
| } | |
| fn send_request(port: u16, request: &Request) -> io::Result<Response> { | |
| let stream = TcpStream::connect_timeout( | |
| &format!("127.0.0.1:{port}").parse().unwrap(), | |
| Duration::from_secs(5), | |
| )?; | |
| stream.set_read_timeout(Some(Duration::from_secs(10)))?; | |
| stream.set_nodelay(true)?; | |
| let mut reader = BufReader::new(stream.try_clone()?); | |
| let mut writer = BufWriter::new(stream); | |
| write_frame(&mut writer, request)?; | |
| read_frame(&mut reader) | |
| } | |
| fn print_response(response: &Response, format: Format) { | |
| match response { | |
| Response::Pong => println!("pong"), | |
| Response::Help { commands } => { | |
| println!("Available commands:"); | |
| for cmd in commands { | |
| println!(" {} β {}", cmd.usage, cmd.description); | |
| } | |
| } | |
| Response::ReadResult { | |
| ghidra_addr, | |
| runtime_addr, | |
| data, | |
| pointers, | |
| } => match format { | |
| Format::Hex => print_hex_read(*ghidra_addr, *runtime_addr, data, pointers), | |
| Format::Raw => { | |
| let stdout = io::stdout(); | |
| let mut out = stdout.lock(); | |
| let _ = out.write_all(data); | |
| } | |
| }, | |
| Response::ReadChainResult { | |
| steps, | |
| ghidra_addr, | |
| runtime_addr, | |
| data, | |
| pointers, | |
| } => match format { | |
| Format::Hex => { | |
| println!("Pointer chain ({} steps):", steps.len()); | |
| for (i, step) in steps.iter().enumerate() { | |
| println!( | |
| " [{}] *(ghidra:0x{:08X}) = 0x{:08X} + 0x{:X} = 0x{:08X}", | |
| i, step.deref_addr, step.value, step.offset, step.result_addr | |
| ); | |
| } | |
| println!(); | |
| print_hex_read(*ghidra_addr, *runtime_addr, data, pointers); | |
| } | |
| Format::Raw => { | |
| let stdout = io::stdout(); | |
| let mut out = stdout.lock(); | |
| let _ = out.write_all(data); | |
| } | |
| }, | |
| Response::Suspended { frame } => println!("Suspended at frame {frame}"), | |
| Response::Resumed => println!("Resumed"), | |
| Response::FrameInfo { | |
| frame, | |
| paused, | |
| breakpoint, | |
| } => { | |
| let state = if *paused { "PAUSED" } else { "running" }; | |
| print!("Frame {frame} [{state}]"); | |
| if *breakpoint >= 0 { | |
| print!(" breakpoint={breakpoint}"); | |
| } | |
| println!(); | |
| } | |
| Response::BreakSet { frame } => { | |
| if *frame >= 0 { | |
| println!("Breakpoint set at frame {frame}"); | |
| } else { | |
| println!("Breakpoint cleared"); | |
| } | |
| } | |
| Response::Snapshot { frame, text } => { | |
| println!("=== Snapshot at frame {frame} ===\n"); | |
| print!("{text}"); | |
| } | |
| Response::InspectResult { | |
| class_name, | |
| ghidra_addr, | |
| runtime_addr, | |
| fields, | |
| } => { | |
| println!( | |
| "{} at ghidra:0x{:08X} (runtime:0x{:08X})\n", | |
| class_name, ghidra_addr, runtime_addr | |
| ); | |
| for f in fields { | |
| println!( | |
| " +0x{:04X} {:<20} [{:>2}] {}", | |
| f.offset, f.name, f.size, f.display | |
| ); | |
| } | |
| } | |
| Response::ObjectList { objects } => { | |
| if objects.is_empty() { | |
| println!("No tracked objects"); | |
| } else { | |
| println!("Tracked objects ({}):\n", objects.len()); | |
| for obj in objects { | |
| println!( | |
| " {:<20} runtime:0x{:08X} size:0x{:X} fields:{}", | |
| obj.class_name, obj.runtime_addr, obj.size, obj.field_count | |
| ); | |
| } | |
| } | |
| } | |
| Response::AliasResult(alias) => { | |
| println!( | |
| "{} at runtime:0x{:08X}", | |
| alias.class_name, alias.runtime_addr | |
| ); | |
| } | |
| Response::FieldResult(field) => { | |
| println!("offset:0x{:X} size:{}", field.offset, field.size); | |
| } | |
| Response::Error { message } => { | |
| eprintln!("Server error: {message}"); | |
| process::exit(1); | |
| } | |
| } | |
| } | |
| fn print_hex_read(ghidra_addr: u32, runtime_addr: u32, data: &[u8], pointers: &[PointerInfo]) { | |
| println!( | |
| "Reading 0x{:X} bytes at ghidra:0x{:08X} (runtime:0x{:08X})\n", | |
| data.len(), | |
| ghidra_addr, | |
| runtime_addr | |
| ); | |
| // Hex dump with ASCII sidebar | |
| for (chunk_idx, chunk) in data.chunks(16).enumerate() { | |
| let offset = chunk_idx * 16; | |
| // Offset column | |
| print!("{:08X} ", offset); | |
| // Hex bytes | |
| for (i, byte) in chunk.iter().enumerate() { | |
| print!("{:02X} ", byte); | |
| if i == 7 { | |
| print!(" "); | |
| } | |
| } | |
| // Padding for short last line | |
| if chunk.len() < 16 { | |
| for i in chunk.len()..16 { | |
| print!(" "); | |
| if i == 7 { | |
| print!(" "); | |
| } | |
| } | |
| } | |
| // ASCII sidebar | |
| print!(" |"); | |
| for byte in chunk { | |
| if *byte >= 0x20 && *byte <= 0x7E { | |
| print!("{}", *byte as char); | |
| } else { | |
| print!("."); | |
| } | |
| } | |
| println!("|"); | |
| } | |
| // Pointer annotations | |
| if !pointers.is_empty() { | |
| println!("\nPointers detected:"); | |
| for p in pointers { | |
| let kind_str = match p.kind { | |
| PointerKind::Vtable => "VTABLE", | |
| PointerKind::Code => "CODE", | |
| PointerKind::Data => "DATA", | |
| PointerKind::Object => "OBJECT", | |
| PointerKind::Heap => "HEAP", | |
| }; | |
| let detail_str = p | |
| .detail | |
| .as_deref() | |
| .map(|d| format!(" {d}")) | |
| .unwrap_or_default(); | |
| println!( | |
| " +0x{:03X} 0x{:08X} {:<7} ghidra:0x{:08X}{}", | |
| p.offset, p.raw_value, kind_str, p.ghidra_value, detail_str | |
| ); | |
| } | |
| } | |
| } | |
| enum Format { | |
| Hex, | |
| Raw, | |
| } | |
| /// Parsed address expression β either a simple address or a pointer chain. | |
| enum AddressExpr { | |
| /// Simple address (with optional offset already applied) | |
| Simple { addr: u32, absolute: bool }, | |
| /// Pointer chain: start address + list of deref offsets | |
| Chain { | |
| addr: u32, | |
| chain: Vec<u32>, | |
| absolute: bool, | |
| }, | |
| } | |
| /// Split a string on '+' or '[' into (base, offset_str). Returns (s, None) if no compound. | |
| fn split_compound(s: &str) -> (&str, Option<&str>) { | |
| if let Some(pos) = s.find('+') { | |
| let base = &s[..pos]; | |
| let off = &s[pos + 1..]; | |
| (base, Some(off)) | |
| } else if let Some(pos) = s.find('[') { | |
| let base = &s[..pos]; | |
| let off_str = s[pos + 1..].trim_end_matches(']'); | |
| (base, Some(off_str)) | |
| } else { | |
| (s, None) | |
| } | |
| } | |
| /// Resolve an offset string β could be numeric or a field name. | |
| fn resolve_offset(s: &str, class: &Option<String>, port: u16) -> u32 { | |
| if looks_like_hex(s) { | |
| return parse_u32(s); | |
| } | |
| // Try as field name | |
| if let Some(cls) = class { | |
| if let Ok(Response::FieldResult(f)) = send_request( | |
| port, | |
| &Request::ResolveField { | |
| class_name: cls.clone(), | |
| field_name: s.to_string(), | |
| }, | |
| ) { | |
| return f.offset; | |
| } | |
| } | |
| // Fall back to numeric parse (will exit on error) | |
| parse_u32(s) | |
| } | |
| fn parse_hex(s: &str) -> u32 { | |
| let s = s | |
| .strip_prefix("0x") | |
| .or_else(|| s.strip_prefix("0X")) | |
| .unwrap_or(s); | |
| u32::from_str_radix(s, 16).unwrap_or_else(|_| { | |
| eprintln!("Error: invalid hex address '{s}'"); | |
| process::exit(1); | |
| }) | |
| } | |
| fn parse_u32(s: &str) -> u32 { | |
| // Support both hex (0x...) and decimal | |
| if s.starts_with("0x") || s.starts_with("0X") { | |
| let hex = &s[2..]; | |
| u32::from_str_radix(hex, 16).unwrap_or_else(|_| { | |
| eprintln!("Error: invalid hex number '{s}'"); | |
| process::exit(1); | |
| }) | |
| } else { | |
| s.parse().unwrap_or_else(|_| { | |
| eprintln!("Error: invalid number '{s}'"); | |
| process::exit(1); | |
| }) | |
| } | |
| } | |
| /// A resolved chain segment β either a normal "deref then offset" step, | |
| /// or a field-name step where the offset should be applied BEFORE the deref. | |
| enum ChainSegment { | |
| /// Normal hex offset: protocol uses it directly (deref current, add offset). | |
| HexOffset(u32), | |
| /// Field offset: needs to be folded into the previous address/step | |
| /// (add offset to current, THEN deref). | |
| FieldOffset(u32), | |
| } | |
| /// Resolve a symbolic address expression via the debug server. | |
| /// | |
| /// The underlying chain protocol semantics: each step is "deref current addr, | |
| /// then add offset." For hex chains like `0x7A0884->0xA0->0x2C`, this works | |
| /// directly β the offset is added after the deref. | |
| /// | |
| /// For field-name chains like `ddgame->task_land`, the user intent is "go to | |
| /// the task_land field (offset 0x54C) and follow that pointer." We translate | |
| /// this by folding the field offset into the PREVIOUS address/step: | |
| /// | |
| /// `ddgame->task_land` β base=ddgame+0x54C, chain=[0] | |
| /// `wrapper->ddgame->rng_state` β base=wrapper+ddgame_off, chain=[rng_state_off] | |
| fn resolve_address_expr(s: &str, port: u16) -> AddressExpr { | |
| let (s, absolute) = if let Some(rest) = s.strip_prefix("abs:") { | |
| (rest, true) | |
| } else { | |
| (s, false) | |
| }; | |
| // Split on "->" for chain syntax | |
| let parts: Vec<&str> = if s.contains("->") { | |
| s.split("->").collect() | |
| } else { | |
| vec![s] | |
| }; | |
| // Resolve the base (first part) | |
| let base_part = parts[0]; | |
| let (base_addr, base_absolute, mut current_class) = resolve_base(base_part, absolute, port); | |
| if parts.len() == 1 { | |
| return AddressExpr::Simple { | |
| addr: base_addr, | |
| absolute: base_absolute, | |
| }; | |
| } | |
| // Resolve each chain segment, tracking whether it's a field name or hex. | |
| let mut segments: Vec<ChainSegment> = Vec::new(); | |
| for part in &parts[1..] { | |
| segments.push(resolve_chain_segment_typed(part, ¤t_class, port)); | |
| current_class = None; | |
| } | |
| // Convert to protocol chain format. | |
| // HexOffset segments pass through directly (deref then add offset). | |
| // FieldOffset segments get folded into the previous position (add offset, | |
| // then deref via a [0] chain entry). | |
| let mut final_base = base_addr; | |
| let mut chain: Vec<u32> = Vec::new(); | |
| for seg in &segments { | |
| match seg { | |
| ChainSegment::HexOffset(offset) => { | |
| chain.push(*offset); | |
| } | |
| ChainSegment::FieldOffset(offset) => { | |
| // Fold into previous: add to base or previous chain entry | |
| if chain.is_empty() { | |
| final_base = final_base.wrapping_add(*offset); | |
| } else { | |
| let last = chain.last_mut().unwrap(); | |
| *last = last.wrapping_add(*offset); | |
| } | |
| chain.push(0); // deref with no extra offset | |
| } | |
| } | |
| } | |
| AddressExpr::Chain { | |
| addr: final_base, | |
| chain, | |
| absolute: base_absolute, | |
| } | |
| } | |
| /// Resolve the base address part β could be a hex literal, a named alias, or alias+offset. | |
| fn resolve_base(s: &str, absolute: bool, port: u16) -> (u32, bool, Option<String>) { | |
| let (base_str, offset_str) = split_compound(s); | |
| // If it looks like hex (starts with digit or 0x), parse directly | |
| if looks_like_hex(base_str) { | |
| let extra = offset_str.map(parse_u32).unwrap_or(0); | |
| return (parse_hex(base_str).wrapping_add(extra), absolute, None); | |
| } | |
| // Try resolving as a named alias | |
| match send_request( | |
| port, | |
| &Request::ResolveAlias { | |
| name: base_str.to_string(), | |
| }, | |
| ) { | |
| Ok(Response::AliasResult(alias)) => { | |
| // Resolved! Use runtime address (absolute) | |
| let class = Some(alias.class_name); | |
| let extra = offset_str | |
| .map(|o| resolve_offset(o, &class, port)) | |
| .unwrap_or(0); | |
| (alias.runtime_addr.wrapping_add(extra), true, class) | |
| } | |
| _ => { | |
| // Fall back to hex parse (will error if not valid hex) | |
| let extra = offset_str.map(parse_u32).unwrap_or(0); | |
| (parse_hex(base_str).wrapping_add(extra), absolute, None) | |
| } | |
| } | |
| } | |
| /// Resolve a chain segment, returning whether it was a field name or hex offset. | |
| fn resolve_chain_segment_typed(s: &str, current_class: &Option<String>, port: u16) -> ChainSegment { | |
| let (base_str, offset_str) = split_compound(s); | |
| let extra = offset_str | |
| .map(|o| resolve_offset(o, current_class, port)) | |
| .unwrap_or(0); | |
| // If it looks like hex, parse directly β standard chain semantics | |
| if looks_like_hex(base_str) { | |
| return ChainSegment::HexOffset(parse_u32(base_str).wrapping_add(extra)); | |
| } | |
| // Try resolving as a field name if we know the current class | |
| if let Some(class) = current_class { | |
| if let Ok(Response::FieldResult(f)) = send_request( | |
| port, | |
| &Request::ResolveField { | |
| class_name: class.clone(), | |
| field_name: base_str.to_string(), | |
| }, | |
| ) { | |
| return ChainSegment::FieldOffset(f.offset.wrapping_add(extra)); | |
| } | |
| } | |
| // Fall back to numeric parse β treat as hex semantics | |
| ChainSegment::HexOffset(parse_u32(base_str).wrapping_add(extra)) | |
| } | |
| /// Check if a string looks like a hex number (starts with 0x or a digit). | |
| fn looks_like_hex(s: &str) -> bool { | |
| s.starts_with("0x") | |
| || s.starts_with("0X") | |
| || s.chars().next().is_some_and(|c| c.is_ascii_digit()) | |
| } | |
| fn print_usage() { | |
| eprintln!("Usage: openwa-debug <command> [args...] [--port N] [--format hex|raw]"); | |
| eprintln!(); | |
| eprintln!("Commands:"); | |
| eprintln!(" ping Check if server is running"); | |
| eprintln!(" help List server commands"); | |
| eprintln!(" read <addr> [len] Read memory at address (default len=256)"); | |
| eprintln!(" inspect <class> <addr> Typed struct inspection (named fields)"); | |
| eprintln!(" objects List tracked live objects"); | |
| eprintln!(" suspend Pause game at next frame boundary"); | |
| eprintln!(" resume Resume game"); | |
| eprintln!(" step [N] Advance N frames (default 1), then pause"); | |
| eprintln!(" frame Show current frame and pause state"); | |
| eprintln!(" break <N> Set frame breakpoint (break clear to remove)"); | |
| eprintln!(" snapshot Dump canonicalized game state (for diffing)"); | |
| eprintln!(); | |
| eprintln!("Address syntax:"); | |
| eprintln!(" 0x669F8C Ghidra VA (rebased automatically)"); | |
| eprintln!(" abs:0x7FFF0000 Absolute runtime address (no rebase)"); | |
| eprintln!(" 0x669F8C+0x10 Address + hex offset"); | |
| eprintln!(" 0x669F8C[16] Address + decimal offset (bracket notation)"); | |
| eprintln!(" 0x7A0884->0xA0->0x2C Pointer chain (deref at each ->)"); | |
| eprintln!(" ddgame Named alias (resolved via server)"); | |
| eprintln!(" ddgame->rng_state Field name chain (deref + field lookup)"); | |
| eprintln!(" ddgame+rng_state Named offset (no deref, just offset)"); | |
| eprintln!(); | |
| eprintln!("Options:"); | |
| eprintln!(" --port <N> Server port (default: 19840)"); | |
| eprintln!(" --format hex|raw Output format (default: hex)"); | |
| } | |