diff --git a/Cargo.lock b/Cargo.lock index 6c06076..df62c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "dap-adapter" version = "0.1.0" @@ -49,6 +61,25 @@ dependencies = [ "log", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "iced-x86" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b" +dependencies = [ + "lazy_static", +] + [[package]] name = "interprocess" version = "2.4.3" @@ -59,7 +90,7 @@ dependencies = [ "libc", "recvmsg", "widestring", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -68,6 +99,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -80,12 +117,31 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "mmap-fixed-fixed" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -101,6 +157,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pawnpro-dbg-protocol" version = "0.1.0" @@ -140,13 +202,42 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + +[[package]] +name = "retour" +version = "0.4.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ead4bc8e12d553ff70769c5f5c21f5f4f0e73c0018068a6bb5a3d7d3b9e57ec7" +dependencies = [ + "cfg-if", + "generic-array", + "iced-x86", + "libc", + "mmap-fixed-fixed", + "once_cell", + "region", + "slice-pool2", +] + [[package]] name = "rust-samp" -version = "3.2.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=e5b5fc1#e5b5fc10cd41351e684c28b0a7cc62071812f3c7" +version = "3.3.0" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ "fern", "log", + "retour", "rust-samp-codegen", "rust-samp-sdk", "time", @@ -154,20 +245,20 @@ dependencies = [ [[package]] name = "rust-samp-codegen" -version = "1.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=e5b5fc1#e5b5fc10cd41351e684c28b0a7cc62071812f3c7" +version = "1.4.0" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] name = "rust-samp-sdk" -version = "3.2.1" -source = "git+https://github.com/NullSablex/rust-samp?rev=e5b5fc1#e5b5fc10cd41351e684c28b0a7cc62071812f3c7" +version = "3.3.0" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -197,7 +288,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn", ] [[package]] @@ -214,15 +305,10 @@ dependencies = [ ] [[package]] -name = "syn" -version = "2.0.118" +name = "slice-pool2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8" [[package]] name = "syn" @@ -267,24 +353,67 @@ dependencies = [ "time-core", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -294,6 +423,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index 0e94f95..383f6f0 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ desenvolvimento. | Hit count | ✅ | `N`, `==N`, `>=N`, `<=N`, `>N`, ` <= >=` (comparação, resultado `true`/`false`). +//! +//! Sem cadeias com precedência (um operador de topo) — previsível e conservador: +//! o que não casar devolve `None` e o editor mostra "não disponível". + +use pawnpro_dbg_protocol::Var; + +#[derive(Clone, Copy)] +enum Val { + Int(i32), + Float(f32), + Bool(bool), +} + +/// Avalia `expr` contra as `vars` do frame; devolve o texto do resultado ou +/// `None` se não for avaliável. +#[must_use] +pub fn eval(expr: &str, vars: &[Var]) -> Option { + Some(format_val(eval_expr(expr.trim(), vars)?)) +} + +/// Operadores, 2-char antes de 1-char (para `<=`/`>=`/`==`/`!=`). +const OPS: [&str; 11] = ["==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%"]; + +fn eval_expr(expr: &str, vars: &[Var]) -> Option { + if let Some((op, l, r)) = split_binary(expr) { + return apply( + op, + eval_operand(l.trim(), vars)?, + eval_operand(r.trim(), vars)?, + ); + } + eval_operand(expr.trim(), vars) +} + +/// Primeiro operador binário em profundidade 0 (fora de `[]`) com lado esquerdo +/// não-vazio — assim `-5` fica como literal e `arr[i]` não é fatiado por dentro. +fn split_binary(expr: &str) -> Option<(&'static str, &str, &str)> { + let bytes = expr.as_bytes(); + let mut depth: i32 = 0; + for i in 0..bytes.len() { + match bytes[i] { + b'[' => depth += 1, + b']' => depth -= 1, + _ if depth == 0 => { + for op in OPS { + if expr[i..].starts_with(op) && !expr[..i].trim().is_empty() { + return Some((op, &expr[..i], &expr[i + op.len()..])); + } + } + } + _ => {} + } + } + None +} + +fn eval_operand(s: &str, vars: &[Var]) -> Option { + let s = s.trim(); + if let Some(v) = parse_value(s) { + return Some(v); + } + // arr[index] + if let Some(open) = s.find('[') + && let Some(stripped) = s.strip_suffix(']') + { + let name = s[..open].trim(); + let idx_expr = &stripped[open + 1..]; + let idx = as_i32(eval_expr(idx_expr, vars)?)?; + let arr = vars.iter().find(|v| v.name == name)?; + let child = arr.children.get(usize::try_from(idx).ok()?)?; + return parse_value(&child.value); + } + // variável simples (o valor em cache já vem formatado) + parse_value(&vars.iter().find(|v| v.name == s)?.value) +} + +/// Interpreta um literal/valor formatado: `true`/`false`, hex, inteiro, float. +fn parse_value(s: &str) -> Option { + let s = s.trim(); + match s { + "true" => return Some(Val::Bool(true)), + "false" => return Some(Val::Bool(false)), + _ => {} + } + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + return i32::from_str_radix(hex, 16).ok().map(Val::Int); + } + if let Ok(i) = s.parse::() { + return Some(Val::Int(i)); + } + s.parse::().ok().map(Val::Float) +} + +fn apply(op: &str, a: Val, b: Val) -> Option { + match op { + "==" | "!=" | "<" | ">" | "<=" | ">=" => cmp(op, a, b), + "+" | "-" | "*" | "/" | "%" => arith(op, a, b), + _ => None, + } +} + +fn cmp(op: &str, lhs: Val, rhs: Val) -> Option { + if let (Val::Bool(a), Val::Bool(b)) = (lhs, rhs) { + return match op { + "==" => Some(Val::Bool(a == b)), + "!=" => Some(Val::Bool(a != b)), + _ => None, // ordem em bool não faz sentido + }; + } + let (a, b) = (as_f64(lhs)?, as_f64(rhs)?); + let result = match op { + "==" => (a - b).abs() < f64::EPSILON, + "!=" => (a - b).abs() >= f64::EPSILON, + "<" => a < b, + ">" => a > b, + "<=" => a <= b, + ">=" => a >= b, + _ => return None, + }; + Some(Val::Bool(result)) +} + +fn arith(op: &str, lhs: Val, rhs: Val) -> Option { + // Ambos inteiros → aritmética inteira (semântica do Pawn: `/` e `%` truncam). + if let (Val::Int(a), Val::Int(b)) = (lhs, rhs) { + let result = match op { + "+" => a.wrapping_add(b), + "-" => a.wrapping_sub(b), + "*" => a.wrapping_mul(b), + "/" => a.checked_div(b)?, + "%" => a.checked_rem(b)?, + _ => return None, + }; + return Some(Val::Int(result)); + } + let (a, b) = (as_f64(lhs)?, as_f64(rhs)?); + let result = match op { + "+" => a + b, + "-" => a - b, + "*" => a * b, + "/" if b != 0.0 => a / b, + "%" if b != 0.0 => a % b, + _ => return None, + }; + #[expect(clippy::cast_possible_truncation)] // volta a f32 (célula Pawn) + Some(Val::Float(result as f32)) +} + +fn as_f64(v: Val) -> Option { + match v { + Val::Int(i) => Some(f64::from(i)), + Val::Float(f) => Some(f64::from(f)), + Val::Bool(_) => None, + } +} + +fn as_i32(v: Val) -> Option { + match v { + Val::Int(i) => Some(i), + _ => None, + } +} + +fn format_val(v: Val) -> String { + match v { + Val::Int(i) => i.to_string(), + Val::Float(f) => format!("{f}"), + Val::Bool(b) => b.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scalar(name: &str, value: &str) -> Var { + Var { + name: name.into(), + value: value.into(), + children: vec![], + } + } + fn array(name: &str, elems: &[&str]) -> Var { + Var { + name: name.into(), + value: "[...]".into(), + children: elems.iter().map(|e| scalar("", e)).collect(), + } + } + + fn vars() -> Vec { + vec![ + scalar("x", "5"), + scalar("y", "10"), + scalar("taxa", "96.5"), + scalar("ativo", "true"), + array("arr", &["7", "8", "9"]), + scalar("i", "1"), + ] + } + + #[test] + fn bare_variable_and_literal() { + let v = vars(); + assert_eq!(eval("x", &v), Some("5".into())); + assert_eq!(eval("42", &v), Some("42".into())); + assert_eq!(eval("0x0a", &v), Some("10".into())); + assert_eq!(eval("true", &v), Some("true".into())); + } + + #[test] + fn arithmetic_integer_semantics() { + let v = vars(); + assert_eq!(eval("x + 1", &v), Some("6".into())); + assert_eq!(eval("y - x", &v), Some("5".into())); + assert_eq!(eval("x * 3", &v), Some("15".into())); + assert_eq!(eval("7 / 2", &v), Some("3".into())); // trunca (Pawn) + assert_eq!(eval("7 % 2", &v), Some("1".into())); + assert_eq!(eval("x / 0", &v), None); // div por zero → indisponível + } + + #[test] + fn arithmetic_float() { + let v = vars(); + assert_eq!(eval("taxa + 0.5", &v), Some("97".into())); + assert_eq!(eval("taxa - 96", &v), Some("0.5".into())); + } + + #[test] + fn comparisons() { + let v = vars(); + assert_eq!(eval("x < y", &v), Some("true".into())); + assert_eq!(eval("x == 5", &v), Some("true".into())); + assert_eq!(eval("y <= 9", &v), Some("false".into())); + assert_eq!(eval("ativo == true", &v), Some("true".into())); + assert_eq!(eval("ativo < true", &v), None); // ordem em bool → None + } + + #[test] + fn array_index() { + let v = vars(); + assert_eq!(eval("arr[0]", &v), Some("7".into())); + assert_eq!(eval("arr[i]", &v), Some("8".into())); // i = 1 + assert_eq!(eval("arr[i + 1]", &v), Some("9".into())); // índice é subexpr + assert_eq!(eval("arr[9]", &v), None); // fora do limite + assert_eq!(eval("arr[0] + arr[2]", &v), Some("16".into())); + } + + #[test] + fn unresolved_is_none() { + let v = vars(); + assert_eq!(eval("zzz", &v), None); // fora de escopo + assert_eq!(eval("arr", &v), None); // array cru não é escalar + assert_eq!(eval("", &v), None); + assert_eq!(eval("x + + y", &v), None); // malformado + } +} diff --git a/crates/dap-adapter/src/main.rs b/crates/dap-adapter/src/main.rs index f20343f..fb5cd41 100644 --- a/crates/dap-adapter/src/main.rs +++ b/crates/dap-adapter/src/main.rs @@ -5,6 +5,7 @@ //! Loop síncrono sobre stdin/stdout (igual a um LSP básico). Uma thread separada //! recebe eventos do plugin (socket local) e os escreve como eventos DAP no stdout. +mod expr; mod messages; mod plugin_client; mod protocol; diff --git a/crates/dap-adapter/src/plugin_client.rs b/crates/dap-adapter/src/plugin_client.rs index e6b3cd9..c1b289d 100644 --- a/crates/dap-adapter/src/plugin_client.rs +++ b/crates/dap-adapter/src/plugin_client.rs @@ -150,17 +150,14 @@ impl PluginClient { match wire::from_line::(&line) { Ok(Event::Paused { reason, - line, - vars, + frames, description, }) => { - store_vars(&vars); - store_line(line); + store_frames(&frames); let mut body = json!({ "reason": reason, "threadId": 1, "allThreadsStopped": true, - "line": line, }); // Runtime error: `description`/`text` show the cause in the // editor's call-stack header (reason "exception"). @@ -204,41 +201,53 @@ impl PluginClient { } } -/// Últimas variáveis recebidas num `Paused` — servidas ao `variables` do DAP. -/// Global porque chegam pela thread leitora e são consultadas no loop principal. -static LAST_VARS: Mutex> = Mutex::new(Vec::new()); +/// Pilha de chamadas da última pausa — servida ao `stackTrace`/`scopes`/ +/// `variables` do DAP. Global porque chega pela thread leitora e é consultada no +/// loop principal. Índice 0 = topo (onde a VM parou). +static LAST_FRAMES: Mutex> = Mutex::new(Vec::new()); -fn store_vars(vars: &[wire::Var]) { - if let Ok(mut g) = LAST_VARS.lock() { - *g = vars.to_vec(); +fn store_frames(frames: &[wire::Frame]) { + if let Ok(mut g) = LAST_FRAMES.lock() { + *g = frames.to_vec(); } } -/// Variáveis da última pausa (para o handler `variables` do DAP). -pub fn last_vars() -> Vec { - LAST_VARS.lock().map(|g| g.clone()).unwrap_or_default() +/// Frames da última pausa (para o `stackTrace` do DAP). +pub fn last_frames() -> Vec { + LAST_FRAMES.lock().map(|g| g.clone()).unwrap_or_default() } -/// Atualiza no cache o valor de uma variável editada via `setVariable`, para que -/// o painel/watch reflitam o novo valor sem reler a VM (o plugin já a escreveu). -pub fn update_var(name: &str, value: &str) { - if let Ok(mut g) = LAST_VARS.lock() - && let Some(v) = g.iter_mut().find(|v| v.name == name) +/// Variáveis em escopo no frame dado (0 = topo) — para `variables`/`evaluate`. +pub fn frame_vars(frame: usize) -> Vec { + LAST_FRAMES + .lock() + .ok() + .and_then(|g| g.get(frame).map(|f| f.vars.clone())) + .unwrap_or_default() +} + +/// Atualiza no cache o valor de uma variável editada via `setVariable` no frame +/// dado, para que o painel/watch reflitam o novo valor sem reler a VM (o plugin já +/// a escreveu). +pub fn update_var(frame: usize, name: &str, value: &str) { + if let Ok(mut g) = LAST_FRAMES.lock() + && let Some(f) = g.get_mut(frame) + && let Some(v) = f.vars.iter_mut().find(|v| v.name == name) { v.value = value.to_string(); } } -/// Linha-fonte da última pausa (para o `stackTrace` do DAP). -static LAST_LINE: Mutex> = Mutex::new(None); - -fn store_line(line: Option) { - if let Ok(mut g) = LAST_LINE.lock() { - *g = line; +/// Atualiza no cache o elemento `elem` do array de índice `var_index` no frame +/// dado (após editar `arr[elem]` via `setVariable`), para o painel refletir sem +/// reler a VM. +pub fn update_array_elem(frame: usize, var_index: usize, elem: usize, value: &str) { + if let Ok(mut g) = LAST_FRAMES.lock() + && let Some(child) = g + .get_mut(frame) + .and_then(|f| f.vars.get_mut(var_index)) + .and_then(|arr| arr.children.get_mut(elem)) + { + child.value = value.to_string(); } } - -/// Linha da última pausa, se houver. -pub fn last_line() -> Option { - LAST_LINE.lock().ok().and_then(|g| *g) -} diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 4bb02b8..da0bb98 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -5,7 +5,7 @@ //! mapear linha ↔ endereço. A conexão com o plugin do servidor (Componente 2) //! ainda não existe; por ora os breakpoints são só resolvidos a endereço. -use pawnpro_dbg_protocol::{Breakpoint, Command, Step}; +use pawnpro_dbg_protocol::{Breakpoint, Command, DataWatch, Step}; use samp_sdk::debug::AmxDbg; use serde_json::{Value, json}; @@ -51,8 +51,12 @@ pub struct Session { seq: i64, /// Bloco de debug do `.amx` em depuração (carregado no `launch`). dbg: Option, - /// Breakpoints resolvidos: (linha-fonte, endereço de código). + /// Breakpoints de linha resolvidos: (linha-fonte, endereço de código). breakpoints: Vec<(i32, u32)>, + /// Breakpoints de linha resolvidos (forma completa, com modificadores). + line_bps: Vec, + /// Breakpoints de função resolvidos (parar ao entrar na função por nome). + fn_bps: Vec, /// Caminho do arquivo-fonte (o `source.path` que o editor enviou em /// `setBreakpoints`). Usado no `stackTrace` para o frame apontar à fonte — /// senão o editor mostra "Origem Desconhecida". @@ -98,6 +102,7 @@ impl Session { "initialize" => self.on_initialize(req), "launch" => self.on_launch(req), "setBreakpoints" => self.on_set_breakpoints(req), + "setFunctionBreakpoints" => self.on_set_function_breakpoints(req), "threads" => self.on_threads(req), "continue" => self.on_continue(req), "next" => self.on_step(req, Step::Over), @@ -107,6 +112,9 @@ impl Session { "scopes" => self.on_scopes(req), "variables" => self.on_variables(req), "setVariable" => self.on_set_variable(req), + "dataBreakpointInfo" => self.on_data_breakpoint_info(req), + "setDataBreakpoints" => self.on_set_data_breakpoints(req), + "setExceptionBreakpoints" => self.on_set_exception_breakpoints(req), "evaluate" => self.on_evaluate(req), "disconnect" | "terminate" => self.on_disconnect(req), "restart" => self.on_restart(req), @@ -138,6 +146,15 @@ impl Session { "supportsLogPoints": true, // Editar variável no painel Variáveis durante a pausa. "supportsSetVariable": true, + // Data breakpoints: pausar quando uma variável muda de valor + // ("Break on Value Change" no painel Variáveis). + "supportsDataBreakpoints": true, + // Breakpoints de função: parar ao entrar numa função por nome. + "supportsFunctionBreakpoints": true, + // Filtro de exceção: o editor liga/desliga a pausa em erros de runtime. + "exceptionBreakpointFilters": [ + { "filter": "runtime", "label": "Erros de runtime", "default": true } + ], // NÃO declaramos `supportsRestartRequest`: assim o editor faz o // restart como disconnect + novo launch, que passa pelo nosso fluxo // (derruba o servidor antigo, espera a porta, sobe um novo) — o único @@ -279,7 +296,7 @@ impl Session { self.breakpoints.clear(); let mut verified = Vec::new(); - let mut breakpoints = Vec::new(); + let mut line_bps = Vec::new(); for ReqBp { line, condition, @@ -293,7 +310,7 @@ impl Session { .and_then(|d| d.line_to_address(line, file)); if let Some(a) = addr { self.breakpoints.push((line, a)); - breakpoints.push(Breakpoint { + line_bps.push(Breakpoint { addr: a, condition, hit_condition, @@ -310,6 +327,56 @@ impl Session { verified.push(json!({ "verified": addr.is_some(), "line": actual_line })); } + // Substitui os breakpoints de LINHA e envia a união (linha + função) — o + // plugin mantém um único conjunto. + self.line_bps = line_bps; + let breakpoints = self.all_breakpoints(); + let body = json!({ "breakpoints": verified }); + self.reply_with(req, Command::SetBreakpoints { breakpoints }, body) + } + + /// União dos breakpoints de linha e de função — o plugin mantém um conjunto só. + fn all_breakpoints(&self) -> Vec { + self.line_bps + .iter() + .chain(self.fn_bps.iter()) + .cloned() + .collect() + } + + /// `setFunctionBreakpoints`: substitui os breakpoints de FUNÇÃO. Cada `name` é + /// resolvido no endereço de entrada da função (via `AmxDbg::function_address`) + /// e entra na união enviada ao plugin. Responde verificado por breakpoint. + fn on_set_function_breakpoints(&mut self, req: &Request) -> Vec { + let names: Vec = req + .arguments + .get("breakpoints") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("name").and_then(Value::as_str).map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let mut fn_bps = Vec::new(); + let mut verified = Vec::new(); + for name in names { + let addr = self.dbg.as_ref().and_then(|d| d.function_address(&name)); + if let Some(a) = addr { + fn_bps.push(Breakpoint { + addr: a, + condition: None, + hit_condition: None, + log_message: None, + }); + } + let line = addr.and_then(|a| self.dbg.as_ref().and_then(|d| d.lookup_line(a))); + verified.push(json!({ "verified": addr.is_some(), "line": line })); + } + + self.fn_bps = fn_bps; + let breakpoints = self.all_breakpoints(); let body = json!({ "breakpoints": verified }); self.reply_with(req, Command::SetBreakpoints { breakpoints }, body) } @@ -335,45 +402,107 @@ impl Session { self.reply_with(req, Command::Step { mode }, Value::Null) } - /// `stackTrace`: um único frame na linha onde a VM parou (v1 sem call stack - /// completo — o plugin ainda não caminha os frames). O frame inclui `source` - /// apontando ao arquivo-fonte; sem isso o editor mostra "Origem Desconhecida" - /// e não destaca a linha de execução. + /// `stackTrace`: a pilha de chamadas completa da última pausa (frame 0 = topo). + /// Cada frame carrega o nome da função, a linha-fonte e um `source` apontando ao + /// arquivo — sem isso o editor mostra "Origem Desconhecida" e não destaca a + /// linha. O `id` (1-based) identifica o frame nos `scopes`/`variables`/`evaluate` + /// seguintes. Antes da primeira pausa (sem frames), devolve um frame-âncora só + /// para o editor ter a fonte. fn on_stack_trace(&mut self, req: &Request) -> Vec { - let line = crate::plugin_client::last_line().unwrap_or(0); - let mut frame = json!({ - "id": 1, - "name": "main", - "line": line, - "column": 0, - }); - if let Some(path) = self.source_path.as_deref() { - frame["source"] = json!({ + let source = self.source_path.as_deref().map(|path| { + json!({ "name": std::path::Path::new(path) .file_name() .and_then(|s| s.to_str()) .unwrap_or(path), "path": path, - }); - } - let body = json!({ "stackFrames": [frame], "totalFrames": 1 }); + }) + }); + + let frames = crate::plugin_client::last_frames(); + let stack_frames: Vec = if frames.is_empty() { + // Sem pausa ainda: frame-âncora para ancorar a fonte no editor. + vec![with_source( + json!({ "id": 1, "name": "main", "line": 0, "column": 0 }), + source.as_ref(), + )] + } else { + frames + .iter() + .enumerate() + .map(|(i, f)| { + with_source( + json!({ + "id": i + 1, + "name": f.name, + "line": f.line.unwrap_or(0), + "column": 0, + }), + source.as_ref(), + ) + }) + .collect() + }; + let total = stack_frames.len(); + let body = json!({ "stackFrames": stack_frames, "totalFrames": total }); self.reply(req, body) } - /// `scopes`: um escopo "Locais" com `variablesReference` fixo (1). + /// `scopes`: um escopo "Locais" por frame. O `frameId` (vindo do `stackTrace`) + /// vira o `variablesReference` do escopo, para o `variables` seguinte saber de + /// qual frame ler. fn on_scopes(&mut self, req: &Request) -> Vec { + let frame_id = req + .arguments + .get("frameId") + .and_then(Value::as_i64) + .unwrap_or(1); let body = json!({ - "scopes": [ { "name": "Locais", "variablesReference": 1, "expensive": false } ] + "scopes": [ { "name": "Locais", "variablesReference": frame_id, "expensive": false } ] }); self.reply(req, body) } - /// `variables`: devolve as variáveis da última pausa (recebidas no `Paused`). + /// `variables`: variáveis do container referenciado. Se o `variablesReference` + /// é de um array (codificado), devolve os elementos; senão é um escopo de frame + /// (`ref - 1`) e devolve as variáveis de topo — arrays ganham um ref próprio + /// (não-zero) para o editor poder expandi-los. fn on_variables(&mut self, req: &Request) -> Vec { - let vars: Vec = crate::plugin_client::last_vars() - .into_iter() - .map(|v| json!({ "name": v.name, "value": v.value, "variablesReference": 0 })) - .collect(); + let reference = req + .arguments + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0); + + let vars: Vec = if let Some((frame, var_index)) = decode_array_ref(reference) { + // Elementos de um array (folhas, sem filhos). + crate::plugin_client::frame_vars(frame) + .get(var_index) + .map(|arr| { + arr.children + .iter() + .map(|c| { + json!({ "name": c.name, "value": c.value, "variablesReference": 0 }) + }) + .collect() + }) + .unwrap_or_default() + } else { + // Escopo do frame: variáveis de topo; arrays viram expansíveis. + let frame = frame_index(req.arguments.get("variablesReference")); + crate::plugin_client::frame_vars(frame) + .iter() + .enumerate() + .map(|(i, v)| { + let child_ref = if v.children.is_empty() { + 0 + } else { + encode_array_ref(frame, i) + }; + json!({ "name": v.name, "value": v.value, "variablesReference": child_ref }) + }) + .collect() + }; let body = json!({ "variables": vars }); self.reply(req, body) } @@ -395,6 +524,11 @@ impl Session { .unwrap_or("") .trim() .to_string(); + let reference = req + .arguments + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0); // Aceita inteiro (decimal/hex), float (`50.0`) e bool (`true`/`false`). O // valor enviado ao plugin é sempre uma célula i32 (float = bits IEEE-754, @@ -410,36 +544,137 @@ impl Session { ))]; }; - // Arrays não são editáveis (o plugin os rejeita). Detectamos pelo valor - // atual em cache começar com `[` e falhamos AQUI, em vez de responder um - // sucesso falso e desencontrar o painel do estado real da VM. - let is_array = crate::plugin_client::last_vars() + // Edição de ELEMENTO de array: o `variablesReference` é o do array e o + // `name` é `[i]`. Resolve o nome do array e o índice, e edita a célula. + if let Some((frame, var_index)) = decode_array_ref(reference) { + let vars = crate::plugin_client::frame_vars(frame); + let (Some(arr), Some(i)) = (vars.get(var_index), parse_elem_index(&name)) else { + return vec![Outgoing::Response(Response::fail( + seq, + req, + format!("elemento inválido: '{name}'"), + ))]; + }; + let array_name = arr.name.clone(); + crate::plugin_client::update_array_elem(frame, var_index, i, &shown); + let body = json!({ "value": shown, "variablesReference": 0 }); + return vec![ + Outgoing::ToPlugin(Command::SetVariable { + frame, + name: array_name, + index: Some(i), + value, + }), + Outgoing::Response(Response::ok(seq, req, body)), + ]; + } + + // Escalar: o `variablesReference` é o escopo do frame (== frameId 1-based). + let frame = frame_index(req.arguments.get("variablesReference")); + // O array inteiro não é editável — o editor deve editar um elemento (que + // vem com seu próprio ref). Falha amigável se pedirem o container. + let is_array = crate::plugin_client::frame_vars(frame) .iter() - .any(|v| v.name == name && v.value.trim_start().starts_with('[')); + .any(|v| v.name == name && !v.children.is_empty()); if is_array { return vec![Outgoing::Response(Response::fail( seq, req, - format!("'{name}' é um array; editar arrays ainda não é suportado"), + format!("'{name}' é um array; expanda e edite um elemento (ex.: {name}[0])"), ))]; } // Resposta otimista: a edição quase sempre vale (variável simples em // escopo). O plugin efetiva a escrita; atualizamos o cache local para o // painel/watch refletirem o novo valor sem reler a VM. - crate::plugin_client::update_var(&name, &shown); + crate::plugin_client::update_var(frame, &name, &shown); let body = json!({ "value": shown, "variablesReference": 0 }); vec![ - Outgoing::ToPlugin(Command::SetVariable { name, value }), + Outgoing::ToPlugin(Command::SetVariable { + frame, + name, + index: None, + value, + }), Outgoing::Response(Response::ok(seq, req, body)), ] } - /// `evaluate`: usado pelo painel INSPEÇÃO (watch) e pelo hover. Avalia uma - /// expressão simples — por ora, o NOME de uma variável em escopo — buscando - /// nas variáveis da última pausa. Expressões compostas ainda não são - /// suportadas; nesses casos respondemos com erro amigável (DAP exige falha no - /// `evaluate` para o editor mostrar "não disponível" em vez de um valor falso). + /// `dataBreakpointInfo`: o editor pergunta se dá para observar mudanças na + /// variável `name` do escopo (`variablesReference` = frame). Respondemos um + /// `dataId` opaco (`"frame:name"`) que o `setDataBreakpoints` seguinte reusa; + /// `dataId: null` recusa (variável fora do cache do frame). Não persiste entre + /// sessões (locais dependem do frame) e observamos escrita (mudança de valor). + fn on_data_breakpoint_info(&mut self, req: &Request) -> Vec { + let frame = frame_index(req.arguments.get("variablesReference")); + let name = req + .arguments + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + + // Só oferece se a variável está no cache do frame e não é array (arrays + // ainda não observáveis) — evita armar um watch que o plugin recusaria. + let var = crate::plugin_client::frame_vars(frame) + .into_iter() + .find(|v| v.name == name); + // Arrays (têm filhos) não são observáveis por data breakpoint ainda. + let observable = var.as_ref().is_some_and(|v| v.children.is_empty()); + + let body = if observable { + json!({ + "dataId": format!("{frame}:{name}"), + "description": name, + "accessTypes": ["write"], + "canPersist": false, + }) + } else { + // dataId null = não observável (o editor desabilita a opção). + json!({ "dataId": Value::Null, "description": name }) + }; + self.reply(req, body) + } + + /// `setDataBreakpoints`: substitui o conjunto de data breakpoints. Decodifica + /// cada `dataId` (`"frame:name"`) de volta em frame + nome e encaminha ao + /// plugin, que resolve o endereço e passa a observar. Responde verificado. + fn on_set_data_breakpoints(&mut self, req: &Request) -> Vec { + let watches: Vec = req + .arguments + .get("breakpoints") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|b| parse_data_id(b.get("dataId")?.as_str()?)) + .collect() + }) + .unwrap_or_default(); + + let verified: Vec = watches + .iter() + .map(|_| json!({ "verified": true })) + .collect(); + let body = json!({ "breakpoints": verified }); + self.reply_with(req, Command::SetDataBreakpoints { watches }, body) + } + + /// `setExceptionBreakpoints`: o editor envia os filtros ativos. Ligamos a + /// pausa em erros de runtime se o filtro `runtime` estiver na lista; senão a + /// desligamos (a VM aborta normalmente). + fn on_set_exception_breakpoints(&mut self, req: &Request) -> Vec { + let runtime = req + .arguments + .get("filters") + .and_then(Value::as_array) + .is_some_and(|fs| fs.iter().any(|f| f.as_str() == Some("runtime"))); + self.reply_with(req, Command::SetExceptionFilter { runtime }, Value::Null) + } + + /// `evaluate`: painel INSPEÇÃO (watch) e hover. Avalia a expressão com o + /// [`crate::expr`] contra as variáveis do frame: nome, literal, `arr[i]`, ou + /// `A OP B` (aritmética/comparação). O que não avaliar vira falha explícita + /// (o DAP exige falha para o editor mostrar "não disponível", não um valor falso). fn on_evaluate(&mut self, req: &Request) -> Vec { let expr = req .arguments @@ -447,22 +682,24 @@ impl Session { .and_then(Value::as_str) .unwrap_or("") .trim(); - - // Busca exata pelo nome da variável entre as da última pausa. - let found = crate::plugin_client::last_vars() - .into_iter() - .find(|v| v.name == expr); + // O `frameId` (1-based, do `stackTrace`) escolhe o escopo; ausente (ex.: + // console global) cai no frame do topo. + let frame = req + .arguments + .get("frameId") + .and_then(Value::as_i64) + .and_then(|id| usize::try_from(id - 1).ok()) + .unwrap_or(0); let seq = self.next_seq(); - if let Some(v) = found { - let body = json!({ "result": v.value, "variablesReference": 0 }); + if let Some(result) = crate::expr::eval(expr, &crate::plugin_client::frame_vars(frame)) { + let body = json!({ "result": result, "variablesReference": 0 }); vec![Outgoing::Response(Response::ok(seq, req, body))] } else { - // Sem a variável em escopo (ou expressão composta): falha explícita. let detail = if expr.is_empty() { "expressão vazia".to_string() } else { - format!("'{expr}' não está em escopo") + format!("não foi possível avaliar '{expr}'") }; vec![Outgoing::Response(Response::fail(seq, req, detail))] } @@ -513,6 +750,66 @@ impl Session { } } +/// Índice do frame (0-based) a partir de um `variablesReference`/`frameId` +/// (1-based, como o `stackTrace`/`scopes` definem). Ausente ou inválido → topo (0). +fn frame_index(reference: Option<&Value>) -> usize { + reference + .and_then(Value::as_i64) + .and_then(|r| usize::try_from(r - 1).ok()) + .unwrap_or(0) +} + +/// Base dos `variablesReference` de array — bem acima de qualquer id de frame +/// (escopos de frame são 1..N). Codifica `(frame, índice-da-var)` para o editor +/// expandir os elementos de um array e editá-los. +const ARRAY_REF_BASE: i64 = 1_000_000; +/// Máximo de variáveis por frame no esquema de codificação. +const ARRAY_REF_STRIDE: i64 = 10_000; + +/// Codifica `(frame, var_index)` num `variablesReference` de array. Índices são +/// pequenos; `try_from` protege contra estouro (retorna 0 no impossível). +fn encode_array_ref(frame: usize, var_index: usize) -> i64 { + let frame = i64::try_from(frame).unwrap_or(0); + let var_index = i64::try_from(var_index).unwrap_or(0); + ARRAY_REF_BASE + frame * ARRAY_REF_STRIDE + var_index +} + +/// Decodifica um `variablesReference` em `(frame, var_index)` se for de array; +/// `None` para refs de escopo de frame (1..N). +fn decode_array_ref(reference: i64) -> Option<(usize, usize)> { + let r = reference.checked_sub(ARRAY_REF_BASE).filter(|r| *r >= 0)?; + Some(( + usize::try_from(r / ARRAY_REF_STRIDE).ok()?, + usize::try_from(r % ARRAY_REF_STRIDE).ok()?, + )) +} + +/// Índice de um elemento a partir do nome do filho `"[i]"` (como montado na +/// inspeção). `None` se não casar o formato. +fn parse_elem_index(name: &str) -> Option { + name.strip_prefix('[')?.strip_suffix(']')?.parse().ok() +} + +/// Decodifica um `dataId` (`"frame:name"`, montado no `dataBreakpointInfo`) de +/// volta em um [`DataWatch`]. O `name` pode conter `:`, então só o primeiro +/// separador conta. +fn parse_data_id(data_id: &str) -> Option { + let (frame, name) = data_id.split_once(':')?; + Some(DataWatch { + frame: frame.parse().ok()?, + name: name.to_string(), + }) +} + +/// Anexa `source` (se houver) a um frame do `stackTrace`, para o editor ancorar a +/// linha ao arquivo-fonte. +fn with_source(mut frame: Value, source: Option<&Value>) -> Value { + if let Some(src) = source { + frame["source"] = src.clone(); + } + frame +} + /// Interpreta o texto digitado em `setVariable` e devolve `(célula, texto)`: /// - a **célula** é o `i32` gravado na VM (float → bits IEEE-754; bool → 0/1); /// - o **texto** é a forma amigável que volta ao painel (`50`, `1.5`, `true`). @@ -730,6 +1027,160 @@ mod tests { assert_eq!(frame["source"]["path"], "/srv/gm/molde.pwn"); } + #[test] + fn scopes_reference_follows_frame_id() { + // O escopo "Locais" referencia o frame pedido (frameId), para o + // `variables` seguinte ler daquele frame — e não de um id fixo. + let mut s = Session::new(); + let out = s.handle(&req("scopes", &json!({ "frameId": 3 }))); + let scope = &first_response(&out).body["scopes"][0]; + assert_eq!(scope["variablesReference"], 3); + } + + #[test] + fn frame_index_maps_1based_reference_to_0based() { + // variablesReference/frameId são 1-based (id do stackTrace); o índice do + // frame é `ref - 1`. Ausente ou inválido cai no topo (0). + assert_eq!(frame_index(Some(&json!(1))), 0); + assert_eq!(frame_index(Some(&json!(3))), 2); + assert_eq!(frame_index(None), 0); + assert_eq!(frame_index(Some(&json!(0))), 0); // inválido → topo + } + + #[test] + fn array_ref_encode_decode_roundtrip() { + // Refs de array ficam acima de qualquer id de frame e decodificam de volta. + let r = encode_array_ref(2, 5); + assert!(r >= ARRAY_REF_BASE); + assert_eq!(decode_array_ref(r), Some((2, 5))); + assert_eq!(decode_array_ref(encode_array_ref(0, 0)), Some((0, 0))); + // Refs de escopo de frame (1..N) não são de array. + assert_eq!(decode_array_ref(1), None); + assert_eq!(decode_array_ref(9), None); + } + + #[test] + fn parse_elem_index_reads_bracketed() { + assert_eq!(parse_elem_index("[0]"), Some(0)); + assert_eq!(parse_elem_index("[42]"), Some(42)); + assert_eq!(parse_elem_index("x"), None); + assert_eq!(parse_elem_index("[a]"), None); + } + + #[test] + fn parse_data_id_splits_frame_and_name() { + assert_eq!( + parse_data_id("0:health"), + Some(DataWatch { + frame: 0, + name: "health".into() + }) + ); + // Nome com ':' — só o primeiro separador conta. + assert_eq!( + parse_data_id("2:a:b"), + Some(DataWatch { + frame: 2, + name: "a:b".into() + }) + ); + // Sem separador ou frame não-numérico → None. + assert_eq!(parse_data_id("semdoispontos"), None); + assert_eq!(parse_data_id("x:health"), None); + } + + #[test] + fn set_data_breakpoints_forwards_watches() { + let mut s = Session::new(); + let args = json!({ + "breakpoints": [ { "dataId": "1:health" }, { "dataId": "0:g_placar" } ] + }); + let out = s.handle(&req("setDataBreakpoints", &args)); + // Encaminha os dois watches decodificados ao plugin. + assert!(has_command( + &out, + |c| matches!(c, Command::SetDataBreakpoints { watches } + if watches.len() == 2 + && watches[0] == DataWatch { frame: 1, name: "health".into() } + && watches[1] == DataWatch { frame: 0, name: "g_placar".into() }) + )); + // E responde os dois como verificados. + let bps = first_response(&out).body["breakpoints"].as_array().unwrap(); + assert_eq!(bps.len(), 2); + assert_eq!(bps[0]["verified"], true); + } + + #[test] + fn set_exception_breakpoints_toggles_runtime() { + let mut s = Session::new(); + // Filtro presente → liga. + let out = s.handle(&req( + "setExceptionBreakpoints", + &json!({ "filters": ["runtime"] }), + )); + assert!(has_command(&out, |c| matches!( + c, + Command::SetExceptionFilter { runtime: true } + ))); + // Lista vazia → desliga. + let out = s.handle(&req("setExceptionBreakpoints", &json!({ "filters": [] }))); + assert!(has_command(&out, |c| matches!( + c, + Command::SetExceptionFilter { runtime: false } + ))); + } + + #[test] + fn function_breakpoints_resolve_and_union_with_line() { + let mut s = Session::new(); + s.set_debug(sample_dbg_fn()); + // 1 breakpoint de linha (linha 4 → addr 20). + s.handle(&req( + "setBreakpoints", + &json!({ "source": { "path": "a.pwn" }, "breakpoints": [ { "line": 4 } ] }), + )); + // Breakpoint de função "foo" (entrada em addr 8) + "naoexiste" (não resolve). + let out = s.handle(&req( + "setFunctionBreakpoints", + &json!({ "breakpoints": [ { "name": "foo" }, { "name": "naoexiste" } ] }), + )); + // Verificação: foo ok, naoexiste não. + let bps = first_response(&out).body["breakpoints"].as_array().unwrap(); + assert_eq!(bps[0]["verified"], true); + assert_eq!(bps[1]["verified"], false); + // A união enviada ao plugin tem o bp de linha (20) e o de função (8). + assert!(has_command( + &out, + |c| matches!(c, Command::SetBreakpoints { breakpoints } + if breakpoints.iter().any(|b| b.addr == 20) && breakpoints.iter().any(|b| b.addr == 8)) + )); + } + + #[test] + fn initialize_advertises_function_breakpoints() { + let mut s = Session::new(); + let out = s.handle(&req("initialize", &Value::Null)); + assert_eq!( + first_response(&out).body["supportsFunctionBreakpoints"], + true + ); + } + + #[test] + fn initialize_advertises_exception_filter() { + let mut s = Session::new(); + let out = s.handle(&req("initialize", &Value::Null)); + let filters = &first_response(&out).body["exceptionBreakpointFilters"]; + assert_eq!(filters[0]["filter"], "runtime"); + } + + #[test] + fn initialize_advertises_data_breakpoints() { + let mut s = Session::new(); + let out = s.handle(&req("initialize", &Value::Null)); + assert_eq!(first_response(&out).body["supportsDataBreakpoints"], true); + } + #[test] fn disconnect_terminates() { let mut s = Session::new(); @@ -740,15 +1191,35 @@ mod tests { /// Bloco de debug mínimo (mesma forma do teste do amxdbg): a.pwn linha 3 → 20. fn sample_dbg() -> AmxDbg { + dbg_bytes(0, |_| {}) + } + + /// Como `sample_dbg`, mas com uma função `foo` no range `[8, 40)` — para testar + /// `setFunctionBreakpoints` (o endereço de entrada cai na 1ª linha, addr 8). + fn sample_dbg_fn() -> AmxDbg { + dbg_bytes(1, |t| { + ext_u32(t, 0); // address + ext_i16(t, 0); // tag + ext_u32(t, 8); // codestart + ext_u32(t, 40); // codeend + t.push(9); // ident = Function + t.push(0); // vclass = global + ext_i16(t, 0); // dim + ext_cstr(t, "foo"); // name + }) + } + + /// Monta um `AmxDbg` com 1 arquivo, 2 linhas ((8,2),(20,3)) e `nsyms` símbolos + /// (escritos por `push_syms`). + fn dbg_bytes(nsyms: i16, push_syms: impl Fn(&mut Vec)) -> AmxDbg { let mut t = Vec::new(); - // files: 1 (a.pwn @ 0) ext_u32(&mut t, 0); ext_cstr(&mut t, "a.pwn"); - // lines: 2 — (8,2), (20,3) ext_u32(&mut t, 8); ext_i32(&mut t, 2); ext_u32(&mut t, 20); ext_i32(&mut t, 3); + push_syms(&mut t); let mut b = Vec::new(); ext_i32(&mut b, i32::try_from(22 + t.len()).unwrap()); b.extend_from_slice(&samp_sdk::debug::AMX_DBG_MAGIC.to_le_bytes()); @@ -757,7 +1228,7 @@ mod tests { ext_i16(&mut b, 0); // flags ext_i16(&mut b, 1); // files ext_i16(&mut b, 2); // lines - ext_i16(&mut b, 0); // symbols + ext_i16(&mut b, nsyms); // symbols ext_i16(&mut b, 0); // tags ext_i16(&mut b, 0); // automatons ext_i16(&mut b, 0); // states diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index b293b9b..03a93ae 100644 --- a/crates/debug-plugin/Cargo.toml +++ b/crates/debug-plugin/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" -samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "e5b5fc1", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "900ebd0", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index beebc8a..d6111c5 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -170,11 +170,19 @@ fn apply(cmd: Command) { BRIDGE.gate.resume(Resume::Step(m)); } Command::Configured => BRIDGE.mark_configured(), - Command::SetVariable { name, value } => { - // Aplica na pausa atual. O adaptador responde ao editor de forma - // otimista; aqui só efetivamos a escrita na VM (no-op se não houver - // pausa ou a variável não for editável). - let _ = crate::hook::set_variable(&name, value); + Command::SetVariable { + frame, + name, + index, + value, + } => { + // Aplica na pausa atual, no frame selecionado. `index` edita um elemento + // de array; `None`, um escalar. O adaptador responde ao editor de forma + // otimista; aqui só efetivamos a escrita na VM (no-op se não houver pausa + // ou a variável não for editável). + let _ = crate::hook::set_variable(frame, &name, index, value); } + Command::SetDataBreakpoints { watches } => crate::hook::set_data_breakpoints(watches), + Command::SetExceptionFilter { runtime } => crate::hook::set_runtime_errors(runtime), } } diff --git a/crates/debug-plugin/src/control.rs b/crates/debug-plugin/src/control.rs index b0be7d4..82530d5 100644 --- a/crates/debug-plugin/src/control.rs +++ b/crates/debug-plugin/src/control.rs @@ -49,6 +49,19 @@ pub struct Bp { pub hits: u32, } +/// Um data breakpoint resolvido: o endereço absoluto de dados a observar, o último +/// valor visto e o nome (para a mensagem). `frame_frm` guarda o `frm` do frame +/// dono quando a variável é **local** — o watch expira quando esse frame retorna +/// (o slot da pilha é reusado); variáveis **globais** têm `frame_frm: None` e nunca +/// expiram. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataWatch { + pub addr: i32, + pub frame_frm: Option, + pub last: i32, + pub name: String, +} + /// O que fazer ao atingir um endereço, decidido por [`Controller::on_hit`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BreakAction { @@ -66,6 +79,8 @@ pub enum BreakAction { pub struct Controller { /// Breakpoints resolvidos. São poucos; `Vec` basta. breakpoints: Vec, + /// Data breakpoints resolvidos (endereço + último valor). Ver [`DataWatch`]. + data_watches: Vec, mode: StepMode, /// `frm` no instante em que um step foi pedido — referência para over/out. step_frame: i32, @@ -85,6 +100,7 @@ impl Controller { pub const fn new_const() -> Self { Self { breakpoints: Vec::new(), + data_watches: Vec::new(), mode: StepMode::Run, step_frame: 0, started: false, @@ -127,6 +143,48 @@ impl Controller { } } + /// Substitui o conjunto de data breakpoints (já resolvidos a endereço + valor + /// inicial pelo hook, que tem a VM). + pub fn set_data_watches(&mut self, watches: Vec) { + self.data_watches = watches; + } + + /// Verifica os data breakpoints neste passo. `read_cell` lê a célula atual de + /// um endereço de dados; `is_frm_live` diz se o frame dono (por `frm`) ainda + /// está vivo na pilha — watches de locais cujo frame retornou são descartados + /// (o slot foi reusado, observá-lo daria falso-positivo). Devolve o nome da + /// primeira variável que mudou (e portanto deve pausar), já atualizando o + /// último valor; `None` se nada mudou. + /// + /// `&mut self` porque atualiza o último valor observado e poda watches mortos. + #[must_use] + pub fn check_data_watches( + &mut self, + read_cell: impl Fn(i32) -> Option, + is_frm_live: impl Fn(i32) -> bool, + ) -> Option { + // Expira watches de locais cujo frame retornou (globais têm `frame_frm` + // `None` e permanecem). + self.data_watches + .retain(|w| w.frame_frm.is_none_or(&is_frm_live)); + for w in &mut self.data_watches { + let Some(cur) = read_cell(w.addr) else { + continue; + }; + if cur != w.last { + w.last = cur; + return Some(w.name.clone()); + } + } + None + } + + /// Há algum data breakpoint armado? (o hook evita o trabalho de checagem se não.) + #[must_use] + pub fn has_data_watches(&self) -> bool { + !self.data_watches.is_empty() + } + /// Define o modo de step, capturando o frame atual como referência. pub fn request_step(&mut self, mode: StepMode, current_frame: i32) { self.mode = mode; @@ -604,4 +662,84 @@ mod tests { let mut c = Controller::new(); assert_eq!(c.should_stop(123, 100), None); } + + /// Helper: watch global (nunca expira) num endereço com valor inicial. + fn global_watch(addr: i32, name: &str, last: i32) -> DataWatch { + DataWatch { + addr, + frame_frm: None, + last, + name: name.to_string(), + } + } + + #[test] + fn data_watch_detects_change_once() { + let mut c = Controller::new(); + c.set_data_watches(vec![global_watch(200, "g", 5)]); + // Valor mudou de 5 → 9: dispara e atualiza o último. + assert_eq!( + c.check_data_watches(|a| (a == 200).then_some(9), |_| true), + Some("g".to_string()) + ); + // Mesmo valor (9) agora: não dispara de novo. + assert_eq!( + c.check_data_watches(|a| (a == 200).then_some(9), |_| true), + None + ); + } + + #[test] + fn data_watch_no_change_is_silent() { + let mut c = Controller::new(); + c.set_data_watches(vec![global_watch(200, "g", 5)]); + assert_eq!( + c.check_data_watches(|a| (a == 200).then_some(5), |_| true), + None + ); + } + + #[test] + fn data_watch_unreadable_address_does_not_fire() { + let mut c = Controller::new(); + c.set_data_watches(vec![global_watch(200, "g", 5)]); + // Endereço inacessível (None) → conservador, não inventa mudança. + assert_eq!(c.check_data_watches(|_| None, |_| true), None); + } + + #[test] + fn data_watch_local_expires_when_frame_returns() { + let mut c = Controller::new(); + // Local no frame frm=500; valor "mudaria" para 9. + c.set_data_watches(vec![DataWatch { + addr: 496, + frame_frm: Some(500), + last: 5, + name: "x".to_string(), + }]); + // Frame 500 já retornou (não está vivo): o watch é descartado e não dispara, + // mesmo com o slot agora contendo outro valor. + assert_eq!( + c.check_data_watches(|a| (a == 496).then_some(9), |_| false), + None + ); + assert!(!c.has_data_watches()); // podado + } + + #[test] + fn data_watch_local_lives_while_frame_alive() { + let mut c = Controller::new(); + c.set_data_watches(vec![DataWatch { + addr: 496, + frame_frm: Some(500), + last: 5, + name: "x".to_string(), + }]); + // Frame 500 ainda vivo → observa e dispara na mudança. + assert_eq!( + c.check_data_watches(|a| (a == 496).then_some(9), |frm| frm == 500), + Some("x".to_string()) + ); + assert!(c.has_data_watches()); + } } diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index fe298d7..d416fe9 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -11,18 +11,21 @@ //! `extern "C"` callback and no manual `*mut AMX` poking anymore. use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use samp::debug::AmxDbg; use samp::prelude::Amx; use crate::bridge::BRIDGE; use crate::control::{ - Bp, BreakAction, Controller, StepMode, StopReason, eval_condition, interpolate_log, + Bp, BreakAction, Controller, DataWatch, StepMode, StopReason, eval_condition, interpolate_log, }; use crate::gate::Resume; use crate::inspect::{self, CellReader}; use crate::runtime_error::{self, Locale, OP_NUM_OPCODES, OpcodeMap}; -use pawnpro_dbg_protocol::{Breakpoint, Event}; +use crate::stack; +use pawnpro_dbg_protocol::{Breakpoint, Event, Frame}; +use samp::debug::VClass; /// Size (bytes) of an AMX instruction — the `cip` in the hook points to the cell /// following the `OP_BREAK`; we step this back to get the line address. @@ -33,11 +36,17 @@ static STATE: Mutex = Mutex::new(Controller::new_const()); /// Debug block of the `.amx` being debugged (loaded in the plugin's `on_load`). static DBG: Mutex> = Mutex::new(None); -/// Context of the CURRENT pause (`amx` ptr, `cip`, `frm`), valid only while the -/// VM is blocked in `on_pause`. The socket thread uses this to apply commands -/// that need the VM (e.g. editing a variable). `amx` as `usize` to be `Send` -/// (the VM thread is stopped, so the pointer stays valid during the pause). -static PAUSE_CTX: Mutex> = Mutex::new(None); +/// Context of the CURRENT pause: the `amx` ptr plus every stack frame's +/// `(cip, frm)` (index 0 = top, where the VM stopped). Valid only while the VM is +/// blocked in `on_pause`. The socket thread uses this to apply commands that need +/// the VM in a specific frame (e.g. editing a variable in the selected frame). +/// `amx` as `usize` to be `Send` (the VM thread is stopped, so the pointer stays +/// valid during the pause). +static PAUSE_CTX: Mutex> = Mutex::new(None); + +/// Pause context: the paused `amx` pointer (as `usize`) plus each frame's +/// `(cip, frm)`, index 0 = top. +type PauseCtx = (usize, Vec<(u32, i32)>); /// Opcode map of the loaded VM, to detect a runtime error before it aborts. /// `None` until `load_opcode_map` runs (and stays effectively identity for a @@ -48,6 +57,15 @@ static OPCODE_MAP: Mutex> = Mutex::new(None); /// Padrão inglês até `set_locale` rodar no carregamento do plugin. static LOCALE: Mutex = Mutex::new(Locale::En); +/// Pausa em erro de runtime ligada? (filtro de exceção do editor). Ligado por +/// padrão; o adaptador desliga via `Command::SetExceptionFilter`. +static RUNTIME_ERRORS: AtomicBool = AtomicBool::new(true); + +/// Liga/desliga a pausa em erros de runtime (div-zero, bounds, STACKERR, …). +pub fn set_runtime_errors(on: bool) { + RUNTIME_ERRORS.store(on, Ordering::Relaxed); +} + /// Define o idioma das mensagens de erro. Chamado no `on_load` a partir de /// `PAWNPRO_DBG_LOCALE` (que o adaptador propaga do editor). pub fn set_locale(locale: Locale) { @@ -84,7 +102,9 @@ pub fn on_break(amx: &Amx) { // instruction (`raw_cip`, the one about to execute) will abort the VM, pause // now with reason "exception" — the VM's ABORT would otherwise return without // calling us again. Source line is still the current break's (`cip`). - if let Some(err) = detect_runtime_error(amx, raw_cip) { + if RUNTIME_ERRORS.load(Ordering::Relaxed) + && let Some(err) = detect_runtime_error(amx, raw_cip) + { if let Ok(mut ctrl) = STATE.lock() { ctrl.hit_breakpoint(); // clears any pending step; marks started } @@ -93,6 +113,17 @@ pub fn on_break(amx: &Amx) { return; } + // Data breakpoints: pausa se uma variável observada mudou de valor desde a + // última linha. Verificado antes do breakpoint/step (é uma causa distinta de + // parada); watches de locais expiram quando o frame dono retorna. + if let Some(name) = check_data_watch(amx, cip, frm) { + if let Ok(mut ctrl) = STATE.lock() { + ctrl.hit_breakpoint(); + } + on_pause(amx, cip, frm, "data breakpoint", Some(&name)); + return; + } + let reason = { let Ok(mut ctrl) = STATE.lock() else { return }; // Breakpoint decision (condition + hit-count + logpoint) in one place. @@ -144,24 +175,23 @@ fn reason_str(r: crate::control::StopReason) -> &'static str { /// Pause: collects variables in scope, notifies the adapter and blocks until /// continue/step. Runs on the VM thread (the server freezes — expected in dev). fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&str>) { - let (line, vars) = match DBG.lock() { + let (frames, ctx) = match DBG.lock() { Ok(guard) => match guard.as_ref() { - Some(dbg) => (dbg.lookup_line(cip), inspect::collect(dbg, amx, cip, frm)), - None => (None, Vec::new()), + Some(dbg) => build_frames(dbg, amx, cip, frm), + None => (Vec::new(), Vec::new()), }, - Err(_) => (None, Vec::new()), + Err(_) => (Vec::new(), Vec::new()), }; - // Publish the pause context so the socket thread can edit variables while - // the VM is blocked just below. - if let (Ok(mut ctx), Some(ptr)) = (PAUSE_CTX.lock(), amx.amx()) { - *ctx = Some((ptr.as_ptr() as usize, cip, frm)); + // Publish the pause context (every frame's cip/frm) so the socket thread can + // edit variables in the selected frame while the VM is blocked just below. + if let (Ok(mut guard), Some(ptr)) = (PAUSE_CTX.lock(), amx.amx()) { + *guard = Some((ptr.as_ptr() as usize, ctx)); } BRIDGE.send(&Event::Paused { reason: reason.to_string(), - line, - vars, + frames, description: description.map(str::to_string), }); @@ -184,6 +214,25 @@ fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&st } } +/// Builds the full call stack at the pause: walks the AMX frame chain and, for +/// each frame, resolves the function name/line from the debug block and collects +/// the variables in scope there. Returns the frames for the protocol plus their +/// `(cip, frm)` contexts in the same order, so [`set_variable`] can target the +/// selected frame. +fn build_frames(dbg: &AmxDbg, amx: &Amx, cip: u32, frm: i32) -> (Vec, Vec<(u32, i32)>) { + let stp = amx.stp().unwrap_or(0); + let ctx = stack::walk(cip, frm, stp, |addr| amx.read_cell(addr)); + let frames = ctx + .iter() + .map(|&(fcip, ffrm)| Frame { + name: dbg.lookup_function(fcip).unwrap_or("???").to_string(), + line: dbg.lookup_line(fcip), + vars: inspect::collect(dbg, amx, fcip, ffrm), + }) + .collect(); + (frames, ctx) +} + /// Evaluates a breakpoint condition against the variables in scope at the current /// `cip`/`frm`. `true` = the condition holds (must pause). Conservative: if the /// inspection/condition cannot be evaluated, `eval_condition` returns `true`. @@ -228,10 +277,34 @@ fn detect_runtime_error(amx: &Amx, at: u32) -> Option Option { + // Sem watches: não paga o custo de caminhar a pilha. + if !STATE.lock().ok()?.has_data_watches() { + return None; + } + let stp = amx.stp().unwrap_or(0); + let live: Vec = stack::walk(cip, frm, stp, |a| amx.read_cell(a)) + .into_iter() + .map(|(_, f)| f) + .collect(); + STATE + .lock() + .ok()? + .check_data_watches(|a| amx.read_cell(a), |f| live.contains(&f)) } /// Updates the breakpoints (address + optional condition) resolved by the @@ -248,14 +321,73 @@ pub fn set_breakpoints(bps: Vec) { } } -/// Edits a simple variable in scope at the current pause: writes `value` to its -/// cell via the SDK's bounds-checked `Amx::write_cell`. Returns `Some(value)` on -/// success, `None` if there is no active pause, the variable is not in scope, is -/// an array (unsupported) or the address is inaccessible. Called by the socket -/// thread while the VM is paused. +/// Arma os data breakpoints pedidos pelo adaptador. Resolve cada `(frame, name)` +/// contra a pausa atual (o frame dá `cip`/`frm`; o símbolo em escopo dá o endereço +/// de dados e a classe global/local) e passa os watches resolvidos ao controlador. +/// Chamado pela thread do socket enquanto a VM está pausada. +pub fn set_data_breakpoints(reqs: Vec) { + let resolved = resolve_data_watches(reqs); + if let Ok(mut ctrl) = STATE.lock() { + ctrl.set_data_watches(resolved); + } +} + +/// Resolve os pedidos `(frame, name)` em [`DataWatch`]s com endereço absoluto, +/// classe (global → nunca expira; local → expira com o frame) e valor inicial. +/// Usa o contexto da pausa atual ([`PAUSE_CTX`]) e o bloco de debug. Símbolos que +/// não estão em escopo ou são arrays são ignorados (arrays ainda não observáveis). +fn resolve_data_watches(reqs: Vec) -> Vec { + let Some((amx_usize, frames)) = PAUSE_CTX.lock().ok().and_then(|g| g.clone()) else { + return Vec::new(); + }; + // Reconstrói um `Amx` sobre a VM pausada só para ler as células iniciais. + let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0); + let Ok(guard) = DBG.lock() else { + return Vec::new(); + }; + let Some(dbg) = guard.as_ref() else { + return Vec::new(); + }; + reqs.into_iter() + .filter_map(|req| { + let (cip, frm) = *frames.get(req.frame)?; + let sym = dbg + .symbols_in_scope(cip) + .into_iter() + .find(|s| s.name == req.name)?; + if sym.is_array() { + return None; // observar arrays ainda não é suportado + } + let addr = sym.effective_address(frm); + // Global: endereço absoluto, nunca expira. Local: relativo ao frame, + // expira quando o frame `frm` retorna. + let frame_frm = (sym.vclass != VClass::Global).then_some(frm); + let last = amx.read_cell(addr).unwrap_or(0); + Some(DataWatch { + addr, + frame_frm, + last, + name: req.name, + }) + }) + .collect() +} + +/// Edits a variable in scope in the given stack `frame` (0 = top) at the current +/// pause: writes `value` to its cell via the SDK's bounds-checked `Amx::write_cell`. +/// `index` targets an array element (`arr[index]`); `None` edits a scalar. Returns +/// `Some(value)` on success, `None` if there is no active pause, the frame index is +/// out of range, the variable is not in scope, is a scalar edited with an index (or +/// an array edited without one, or the index is out of bounds), or the address is +/// inaccessible. Called by the socket thread while the VM is paused. #[must_use] -pub fn set_variable(name: &str, value: i32) -> Option { - let (amx_usize, cip, frm) = (*PAUSE_CTX.lock().ok()?)?; +pub fn set_variable(frame: usize, name: &str, index: Option, value: i32) -> Option { + let (amx_usize, cip, frm) = { + let guard = PAUSE_CTX.lock().ok()?; + let (amx_usize, frames) = guard.as_ref()?; + let (cip, frm) = *frames.get(frame)?; + (*amx_usize, cip, frm) + }; // Reconstruct an `Amx` over the paused VM pointer. `write_cell` reads the // base/data segment straight from the AMX struct, so the function table is // not needed here (0 is fine). @@ -263,17 +395,28 @@ pub fn set_variable(name: &str, value: i32) -> Option { let guard = DBG.lock().ok()?; let dbg = guard.as_ref()?; - // Find the in-scope symbol with this name; arrays are not editable here. let sym = dbg .symbols_in_scope(cip) .into_iter() .find(|s| s.name == name)?; - if sym.is_array() { - return None; - } - if amx.write_cell(sym.effective_address(frm), value) { - Some(value) + + // Endereço-alvo: elemento `index` de um array, ou a célula de um escalar. + let addr = if let Some(i) = index { + if !sym.is_array() { + return None; // índice pedido em algo que não é array + } + let len = usize::try_from(sym.dims.first().map_or(0, |d| d.size)).unwrap_or(0); + if i >= len { + return None; // fora do limite do array + } + sym.effective_address(frm) + .wrapping_add(i32::try_from(i).ok()?.wrapping_mul(4)) } else { - None - } + if sym.is_array() { + return None; // array precisa de índice (o array inteiro não é editável) + } + sym.effective_address(frm) + }; + + amx.write_cell(addr, value).then_some(value) } diff --git a/crates/debug-plugin/src/inspect.rs b/crates/debug-plugin/src/inspect.rs index 157e275..d296c7b 100644 --- a/crates/debug-plugin/src/inspect.rs +++ b/crates/debug-plugin/src/inspect.rs @@ -21,22 +21,27 @@ pub fn collect(dbg: &AmxDbg, reader: &impl CellReader, cip: u32, frm: i32) -> Ve for sym in dbg.symbols_in_scope(cip) { // Effective data-segment address (global vs frame-relative) via the SDK. let addr = sym.effective_address(frm); - let value = if sym.is_array() { - format_array(sym, addr, reader) + out.push(if sym.is_array() { + build_array(sym, addr, reader, dbg) } else { - reader.read_cell(addr).map_or_else( + let value = reader.read_cell(addr).map_or_else( || "?".to_string(), |c| format_scalar(c, dbg.tag_name(sym.tag)), - ) - }; - out.push(Var { - name: sym.name.clone(), - value, + ); + Var { + name: sym.name.clone(), + value, + children: vec![], + } }); } out } +/// Máximo de elementos de array expostos (evita despejar arrays enormes na +/// inspeção). Os primeiros `MAX_ELEMS`; o resto fica indicado por `…` no resumo. +const MAX_ELEMS: u32 = 256; + /// Formata um valor escalar conforme o tag do símbolo. Em Pawn todo valor é um /// cell de 32 bits; o tag diz como interpretá-lo: /// - `Float`: os bits são um `f32` IEEE-754 (senão `96.5` apareceria como o @@ -55,22 +60,77 @@ fn format_scalar(cell: i32, tag: Option<&str>) -> String { } } -/// Formato compacto de um array: `[a, b, c, …]` até um limite, evitando despejar -/// arrays enormes na inspeção. -fn format_array(sym: &samp::debug::DbgSymbol, base: i32, reader: &impl CellReader) -> String { - const MAX: u32 = 8; +/// Monta a [`Var`] de um array: lê os elementos (até [`MAX_ELEMS`]) como filhos +/// expansíveis e resume o valor. Se as células formam uma string terminada em +/// zero (texto), o resumo vira `"texto"`; senão, `[a, b, c, …]`. +fn build_array( + sym: &samp::debug::DbgSymbol, + base: i32, + reader: &impl CellReader, + dbg: &AmxDbg, +) -> Var { + let tag = dbg.tag_name(sym.tag); let len = sym.dims.first().map_or(0, |d| d.size); - let show = len.min(MAX); - let mut parts = Vec::new(); + let show = len.min(MAX_ELEMS); + + let mut cells = Vec::with_capacity(show as usize); + let mut children = Vec::with_capacity(show as usize); for i in 0..show { let addr = base.wrapping_add(i32::try_from(i).unwrap_or(0) * 4); - match reader.read_cell(addr) { - Some(c) => parts.push(c.to_string()), - None => parts.push("?".to_string()), + let cell = reader.read_cell(addr); + cells.push(cell); + children.push(Var { + name: format!("[{i}]"), + value: cell.map_or_else(|| "?".to_string(), |c| format_scalar(c, tag)), + children: vec![], + }); + } + + // Resumo: string (se parecer texto terminado em zero) ou prévia numérica. + let value = as_string(&cells).map_or_else( + || { + const PREVIEW: usize = 8; + let parts: Vec = cells + .iter() + .take(PREVIEW) + .map(|c| c.map_or_else(|| "?".to_string(), |v| v.to_string())) + .collect(); + let ellipsis = if len as usize > parts.len() { + ", …" + } else { + "" + }; + format!("[{}{}]", parts.join(", "), ellipsis) + }, + |s| format!("\"{s}\""), + ); + + Var { + name: sym.name.clone(), + value, + children, + } +} + +/// Interpreta as células como uma string do Pawn: caracteres imprimíveis até um +/// terminador `0`. `None` se qualquer célula for ilegível/não-imprimível ou não +/// houver terminador — conservador, para não mostrar array de inteiros como texto. +/// Decodifica em Latin-1 (aproxima o Windows-1252 do SA-MP nos acentos). +fn as_string(cells: &[Option]) -> Option { + let mut s = String::new(); + for cell in cells { + let c = (*cell)?; + if c == 0 { + return (!s.is_empty()).then_some(s); // terminador → fim da string + } + let b = u8::try_from(c).ok()?; + let printable = (0x20..=0x7e).contains(&b) || (0xa0..=0xff).contains(&b); + if !printable { + return None; } + s.push(char::from(b)); } - let ellipsis = if len > show { ", …" } else { "" }; - format!("[{}{}]", parts.join(", "), ellipsis) + None // sem terminador na faixa lida → não trata como string } #[cfg(test)] @@ -78,6 +138,27 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn as_string_detects_terminated_text() { + // "Oi" + terminador → string. + let cells = vec![Some(79), Some(105), Some(0), Some(120)]; + assert_eq!(as_string(&cells), Some("Oi".to_string())); + // Latin-1 (acento): 'á' = 0xE1. + assert_eq!(as_string(&[Some(0xE1), Some(0)]), Some("á".to_string())); + } + + #[test] + fn as_string_conservative() { + // Sem terminador na faixa → não é string. + assert_eq!(as_string(&[Some(72), Some(105)]), None); + // Caractere não-imprimível (7 = BEL) → não é string. + assert_eq!(as_string(&[Some(72), Some(7), Some(0)]), None); + // Célula ilegível → não é string. + assert_eq!(as_string(&[Some(72), None, Some(0)]), None); + // Só o terminador (vazio) → não é string. + assert_eq!(as_string(&[Some(0)]), None); + } + #[test] fn format_scalar_by_tag() { // Float: os bits de 96.5 (1119944704) viram "96.5", não o inteiro cru. diff --git a/crates/debug-plugin/src/lib.rs b/crates/debug-plugin/src/lib.rs index 700d378..8c2ded1 100644 --- a/crates/debug-plugin/src/lib.rs +++ b/crates/debug-plugin/src/lib.rs @@ -15,6 +15,7 @@ mod gate; mod hook; mod inspect; mod runtime_error; +mod stack; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/debug-plugin/src/runtime_error.rs b/crates/debug-plugin/src/runtime_error.rs index 1faaff8..bdbe2a9 100644 --- a/crates/debug-plugin/src/runtime_error.rs +++ b/crates/debug-plugin/src/runtime_error.rs @@ -46,6 +46,32 @@ pub const OP_ZERO_PRI: i32 = 89; pub const OP_ZERO_ALT: i32 = 90; pub const OP_BOUNDS: i32 = 121; pub const OP_BREAK: i32 = 137; +// Opcodes de endereço/memória e pilha/heap, para os erros STACKERR/HEAPLOW/ +// MEMACCESS (números conferidos na `amx_opcodelist` do interpretador). +pub const OP_LOAD_I: i32 = 9; // pri = data[pri] +pub const OP_LODB_I: i32 = 10; // pri = data[pri] (byte/word) +pub const OP_ADDR_PRI: i32 = 13; // pri = frm + offs +pub const OP_ADDR_ALT: i32 = 14; // alt = frm + offs +pub const OP_STOR_I: i32 = 23; // data[alt] = pri +pub const OP_STRB_I: i32 = 24; // data[alt] = pri (byte/word) +pub const OP_LIDX: i32 = 25; // pri = data[pri*4 + alt] +pub const OP_LIDX_B: i32 = 26; // pri = data[(pri< "индекс массива вне диапазона", (RuntimeError::Bounds, Ro) => "index de matrice în afara limitelor", (RuntimeError::Bounds, En) => "array index out of bounds", + (RuntimeError::StackError, PtBr) => "estouro de pilha (colisão pilha/heap)", + (RuntimeError::StackError, Es) => "desbordamiento de pila (colisión pila/montículo)", + (RuntimeError::StackError, Ru) => "переполнение стека (столкновение стека и кучи)", + (RuntimeError::StackError, Ro) => "depășire de stivă (coliziune stivă/heap)", + (RuntimeError::StackError, En) => "stack overflow (stack/heap collision)", + (RuntimeError::HeapLow, PtBr) => "underflow de heap", + (RuntimeError::HeapLow, Es) => "subdesbordamiento del montículo", + (RuntimeError::HeapLow, Ru) => "переполнение кучи снизу", + (RuntimeError::HeapLow, Ro) => "subdepășire de heap", + (RuntimeError::HeapLow, En) => "heap underflow", + (RuntimeError::MemAccess, PtBr) => "acesso inválido à memória", + (RuntimeError::MemAccess, Es) => "acceso inválido a memoria", + (RuntimeError::MemAccess, Ru) => "недопустимый доступ к памяти", + (RuntimeError::MemAccess, Ro) => "acces nevalid la memorie", + (RuntimeError::MemAccess, En) => "invalid memory access", } } } +/// Endereço de dados inválido, conforme o `VERIFYADDRESS` do `amx.c`: cai na +/// lacuna livre entre o heap (`hea`) e a pilha (`stk`), ou está em/acima do topo +/// da pilha (`stp`) — inclui endereços negativos (viram enormes sem sinal). +#[must_use] +fn mem_invalid(addr: i32, hea: i32, stk: i32, stp: i32) -> bool { + (addr >= hea && addr < stk) || addr.cast_unsigned() >= stp.cast_unsigned() +} + /// Traduz o valor cru lido do code segment (via `read_code`) no número do opcode. pub struct OpcodeMap { /// `endereço do label → número do opcode`. Vazio = imagem não relocada. @@ -169,25 +228,41 @@ struct Regs { alt: i32, } -/// Varre as instruções a partir de `start` (offset de código), simulando `pri`/ -/// `alt` a partir do estado real (`pri0`/`alt0` no break), até detectar um erro -/// de runtime ou chegar ao fim da linha. +/// Opcode de controle de fluxo ou que mexe em `stk`/`hea`/`frm` de um jeito que a +/// varredura NÃO modela (saltos, `ret`, `sysreq`, `sctrl`, `switch`). Ao encontrar +/// um deles, o rastreio de `stk`/`hea` deixa de ser confiável — daí para a frente +/// não checamos mais STACKERR/HEAPLOW/MEMACCESS (conservador: não inventa erro). +/// `call`/`call.pri` são tratados à parte (checam antes de virar barreira). +fn is_control_barrier(op: i32) -> bool { + matches!(op, 32 | 47 | 48 | 120 | 122 | 123 | 128 | 129 | 130 | 135) || (51..=64).contains(&op) +} + +/// Varre as instruções a partir de `start` (offset de código), simulando os +/// registradores a partir do estado real no break, até detectar um erro de runtime +/// ou chegar ao fim da linha. /// -/// - `frm`: frame atual da VM, para resolver `LOAD_S_*` (`data[frm + offs]`). -/// - `read_code`: lê uma cell crua do CODE segment (`Amx::read_code`). -/// - `read_data`: lê uma cell do DATA segment (`Amx::read_cell`), para emular os -/// `LOAD`/`LOAD_S` — é o que traz os valores das variáveis, sem os quais a -/// detecção da divisão/bounds por variável não funcionaria. -/// - `decode`: traduz o valor cru no número do opcode (via [`OpcodeMap`]). +/// - `pri0`/`alt0`/`frm`/`stk0`/`hea0`: registradores da VM no break. `stk`/`hea` +/// são rastreados ao longo da linha para detectar colisão pilha/heap (STACKERR), +/// underflow de heap (HEAPLOW) e acesso inválido à memória (MEMACCESS), com as +/// MESMAS condições do `amx.c` (`CHKMARGIN`/`CHKHEAP`/`VERIFYADDRESS`). +/// - `hlw`/`stp`: fundo do heap e topo da pilha (limites), para HEAPLOW/MEMACCESS. +/// - `read_code`/`read_data`/`decode`: leem o code/data segment e traduzem opcodes. /// -/// Para no próximo `OP_BREAK` (fim da linha), num opcode de tamanho variável, ou -/// quando algo não decodifica — sempre conservador (não inventa erro). +/// Para no próximo `OP_BREAK`, num opcode de tamanho variável, ou quando algo não +/// decodifica. As checagens de STACKERR/HEAPLOW/MEMACCESS só ocorrem enquanto o +/// rastreio de `stk`/`hea` (e do registrador de endereço) é confiável — qualquer +/// desvio/opcode não modelado as desliga, nunca produzindo um falso-positivo. +#[expect(clippy::too_many_arguments, clippy::too_many_lines)] #[must_use] pub fn scan_line( start: u32, pri0: i32, alt0: i32, frm: i32, + stk0: i32, + hea0: i32, + hlw: i32, + stp: i32, read_code: &impl Fn(u32) -> Option, read_data: &impl Fn(i32) -> Option, decode: &impl Fn(i32) -> Option, @@ -199,6 +274,12 @@ pub fn scan_line( pri: pri0, alt: alt0, }; + // `pri`/`alt` só valem para MEMACCESS enquanto forem rastreados exatamente; + // um opcode que os escreve de forma não modelada zera a confiança. + let (mut pri_known, mut alt_known) = (true, true); + // Ponteiros de pilha/heap rastreados; `reliable` cai ao 1º opcode não modelado. + let (mut stk, mut hea) = (stk0, hea0); + let mut reliable = true; // Pilha simulada (só dos `push` dentro desta linha), para os `pop` casarem o // valor certo. Valores desconhecidos (push de algo não rastreado) são `None`. let mut stack: Vec> = Vec::new(); @@ -222,7 +303,8 @@ pub fn scan_line( None }; - // Checa erro ANTES de aplicar efeito (os operandos são os de agora). + // Checa erro ANTES de aplicar efeito (os operandos são os de agora), na + // mesma ordem em que o `amx.c` abortaria. match op { OP_SDIV | OP_UDIV if regs.alt == 0 => return Some(RuntimeError::DivideByZero), OP_SDIV_ALT | OP_UDIV_ALT if regs.pri == 0 => return Some(RuntimeError::DivideByZero), @@ -232,34 +314,223 @@ pub fn scan_line( return Some(RuntimeError::Bounds); } } + // MEMACCESS: endereço em `pri` (load) ou `alt` (store), ou computado + // (`lidx`). Só checa com o registrador de endereço confiável. + OP_LOAD_I | OP_LODB_I if reliable && pri_known => { + if mem_invalid(regs.pri, hea, stk, stp) { + return Some(RuntimeError::MemAccess); + } + } + OP_STOR_I | OP_STRB_I if reliable && alt_known => { + if mem_invalid(regs.alt, hea, stk, stp) { + return Some(RuntimeError::MemAccess); + } + } + OP_LIDX if reliable && pri_known && alt_known => { + let off = regs.pri.wrapping_mul(4).wrapping_add(regs.alt); + if mem_invalid(off, hea, stk, stp) { + return Some(RuntimeError::MemAccess); + } + } + OP_LIDX_B if reliable && pri_known && alt_known => { + if let Some(sh) = param { + let off = regs + .pri + .wrapping_shl(sh.cast_unsigned()) + .wrapping_add(regs.alt); + if mem_invalid(off, hea, stk, stp) { + return Some(RuntimeError::MemAccess); + } + } + } _ => {} } - // Aplica o efeito em pri/alt. `LOAD`/`LOAD_S` leem o data segment (o valor - // real da variável); os demais que não mexem em pri/alt apenas avançam. + // Aplica o efeito: rastreia `pri`/`alt` (valor + confiança), `stk`/`hea`, e + // checa STACKERR/HEAPLOW nos pontos em que o `amx.c` roda `CHKMARGIN`/ + // `CHKHEAP`. `call` antecipa a checagem do prólogo (`PROC`) do chamado. match op { - OP_LOAD_PRI => regs.pri = param.and_then(read_data).unwrap_or(regs.pri), - OP_LOAD_ALT => regs.alt = param.and_then(read_data).unwrap_or(regs.alt), + OP_LOAD_PRI => { + regs.pri = param.and_then(read_data).unwrap_or(regs.pri); + pri_known = param.and_then(read_data).is_some(); + } + OP_LOAD_ALT => { + regs.alt = param.and_then(read_data).unwrap_or(regs.alt); + alt_known = param.and_then(read_data).is_some(); + } OP_LOAD_S_PRI => { - regs.pri = param.and_then(|o| read_data(frm + o)).unwrap_or(regs.pri); + let v = param.and_then(|o| read_data(frm + o)); + regs.pri = v.unwrap_or(regs.pri); + pri_known = v.is_some(); } OP_LOAD_S_ALT => { - regs.alt = param.and_then(|o| read_data(frm + o)).unwrap_or(regs.alt); - } - OP_CONST_PRI => regs.pri = param.unwrap_or(regs.pri), - OP_CONST_ALT => regs.alt = param.unwrap_or(regs.alt), - OP_ZERO_PRI => regs.pri = 0, - OP_ZERO_ALT => regs.alt = 0, - OP_MOVE_PRI => regs.pri = regs.alt, - OP_MOVE_ALT => regs.alt = regs.pri, - OP_XCHG => std::mem::swap(&mut regs.pri, &mut regs.alt), - OP_PUSH_PRI => stack.push(Some(regs.pri)), - OP_PUSH_ALT => stack.push(Some(regs.alt)), - OP_PUSH_C => stack.push(param), - // `pop` recupera o último push; valor desconhecido mantém o atual. - OP_POP_PRI => regs.pri = stack.pop().flatten().unwrap_or(regs.pri), - OP_POP_ALT => regs.alt = stack.pop().flatten().unwrap_or(regs.alt), - _ => {} + let v = param.and_then(|o| read_data(frm + o)); + regs.alt = v.unwrap_or(regs.alt); + alt_known = v.is_some(); + } + OP_CONST_PRI => { + regs.pri = param.unwrap_or(regs.pri); + pri_known = param.is_some(); + } + OP_CONST_ALT => { + regs.alt = param.unwrap_or(regs.alt); + alt_known = param.is_some(); + } + OP_ZERO_PRI => { + regs.pri = 0; + pri_known = true; + } + OP_ZERO_ALT => { + regs.alt = 0; + alt_known = true; + } + OP_MOVE_PRI => { + regs.pri = regs.alt; + pri_known = alt_known; + } + OP_MOVE_ALT => { + regs.alt = regs.pri; + alt_known = pri_known; + } + OP_XCHG => { + std::mem::swap(&mut regs.pri, &mut regs.alt); + std::mem::swap(&mut pri_known, &mut alt_known); + } + // Endereços: `addr` = frm+offs; `idxaddr` = pri*4+alt (ou pri< { + regs.pri = frm.wrapping_add(param.unwrap_or(0)); + pri_known = param.is_some(); + } + OP_ADDR_ALT => { + regs.alt = frm.wrapping_add(param.unwrap_or(0)); + alt_known = param.is_some(); + } + OP_IDXADDR => { + regs.pri = regs.pri.wrapping_mul(4).wrapping_add(regs.alt); + pri_known = pri_known && alt_known; + } + OP_IDXADDR_B => { + if let Some(sh) = param { + regs.pri = regs + .pri + .wrapping_shl(sh.cast_unsigned()) + .wrapping_add(regs.alt); + } + pri_known = pri_known && alt_known && param.is_some(); + } + // Loads indiretos: após a checagem MEMACCESS acima, `pri` recebe o dado. + OP_LOAD_I | OP_LODB_I | OP_LIDX | OP_LIDX_B => { + regs.pri = read_data(regs.pri).unwrap_or(regs.pri); + pri_known = false; // valor vindo da memória: não rastreado adiante + } + OP_PUSH_PRI => { + stack.push(Some(regs.pri)); + stk -= 4; + } + OP_PUSH_ALT => { + stack.push(Some(regs.alt)); + stk -= 4; + } + OP_PUSH_C => { + stack.push(param); + stk -= 4; + } + OP_PUSH => { + stack.push(param.and_then(read_data)); + stk -= 4; + } + OP_PUSH_S => { + stack.push(param.and_then(|o| read_data(frm + o))); + stk -= 4; + } + OP_PUSH_ADR => { + stack.push(param.map(|o| frm.wrapping_add(o))); + stk -= 4; + } + OP_PUSH_R => { + if let Some(n) = param.filter(|n| *n >= 0) { + for _ in 0..n { + stack.push(Some(regs.pri)); + } + stk -= 4 * n; + } else { + reliable = false; + } + } + OP_POP_PRI => { + let v = stack.pop().flatten(); + regs.pri = v.unwrap_or(regs.pri); + pri_known = v.is_some(); + stk += 4; + } + OP_POP_ALT => { + let v = stack.pop().flatten(); + regs.alt = v.unwrap_or(regs.alt); + alt_known = v.is_some(); + stk += 4; + } + OP_STACK => { + if let Some(o) = param { + regs.alt = stk; + alt_known = true; + stk = stk.wrapping_add(o); + if reliable && hea + STK_MARGIN > stk { + return Some(RuntimeError::StackError); + } + } else { + reliable = false; + } + } + OP_HEAP => { + if let Some(o) = param { + regs.alt = hea; + alt_known = true; + hea = hea.wrapping_add(o); + if reliable && hea + STK_MARGIN > stk { + return Some(RuntimeError::StackError); + } + if reliable && hea < hlw { + return Some(RuntimeError::HeapLow); + } + } else { + reliable = false; + } + } + OP_PROC => { + stk -= 4; // PUSH(frm) + if reliable && hea + STK_MARGIN > stk { + return Some(RuntimeError::StackError); + } + } + OP_CALL | OP_CALL_PRI => { + // O prólogo (`PROC`) do chamado fará PUSH(retorno)+PUSH(frm) e então + // `CHKMARGIN`: antecipamos essa checagem (recursão infinita estoura + // aqui). Depois a varredura não pode seguir para dentro do chamado. + if reliable && hea + STK_MARGIN > stk - 8 { + return Some(RuntimeError::StackError); + } + reliable = false; + } + // `push2`..`push5`: empilham N valores (efeito só em `stk`, N cells). + OP_PUSH2_C..=OP_PUSH5_ADR => { + let n = 2 + (op - OP_PUSH2_C) / 4; + for _ in 0..n { + stack.push(None); + } + stk -= 4 * n; + } + _ if is_control_barrier(op) => { + reliable = false; + pri_known = false; + alt_known = false; + } + // Qualquer outro opcode pode escrever `pri`/`alt` de forma não modelada + // (aritmética etc.): zera a confiança neles (mantém `stk`/`hea`, que + // esses opcodes não tocam). + _ => { + pri_known = false; + alt_known = false; + } } cip += CELL * (1 + nparams); @@ -298,9 +569,29 @@ mod tests { None } - /// Atalho: varre sem memória de dados (frm=0). + /// Limites de pilha/heap "folgados" para os testes de DIV/BOUNDS, em que não + /// se quer disparar STACKERR/HEAPLOW/MEMACCESS: pilha bem acima do heap, heap + /// no fundo, topo distante. + const STK: i32 = 0x1_0000; + const HEA: i32 = 0; + const HLW: i32 = 0; + const STP: i32 = 0x10_0000; + + /// Atalho: varre sem memória de dados (frm=0), com limites folgados. fn scan(mem: Vec) -> Option { - scan_line(0, 99, 99, 0, &reader(mem), &no_data, &ident) + scan_line( + 0, + 99, + 99, + 0, + STK, + HEA, + HLW, + STP, + &reader(mem), + &no_data, + &ident, + ) } #[test] @@ -389,7 +680,19 @@ mod tests { 92 => Some(0), // frm-8 = b _ => None, }; - let r = scan_line(0, 1, 1, 100, &reader(mem), &read_data, &ident); + let r = scan_line( + 0, + 1, + 1, + 100, + STK, + HEA, + HLW, + STP, + &reader(mem), + &read_data, + &ident, + ); assert_eq!(r, Some(RuntimeError::DivideByZero)); } @@ -399,11 +702,193 @@ mod tests { let mem = code(&[&[OP_LOAD_S_PRI, -20], &[OP_BOUNDS, 2], &[OP_BREAK]]); let read_data = |addr: i32| (addr == 80).then_some(5); // frm(100) - 20 assert_eq!( - scan_line(0, 0, 0, 100, &reader(mem), &read_data, &ident), + scan_line( + 0, + 0, + 0, + 100, + STK, + HEA, + HLW, + STP, + &reader(mem), + &read_data, + &ident + ), Some(RuntimeError::Bounds) ); } + /// Varre com pilha/heap/limites explícitos (frm=0, sem memória de dados). + fn scan_stk( + pri: i32, + alt: i32, + stk: i32, + hea: i32, + hlw: i32, + stp: i32, + mem: Vec, + ) -> Option { + scan_line( + 0, + pri, + alt, + 0, + stk, + hea, + hlw, + stp, + &reader(mem), + &no_data, + &ident, + ) + } + + #[test] + fn detects_stack_heap_collision_on_stack_op() { + // stk=1100, hea=1000; `stack -56` → stk=1044; hea+64=1064 > 1044 → colisão. + assert_eq!( + scan_stk( + 0, + 0, + 1100, + 1000, + 0, + 0x10_0000, + code(&[&[OP_STACK, -56], &[OP_BREAK]]) + ), + Some(RuntimeError::StackError) + ); + // Folga suficiente (stk=2000): sem colisão. + assert_eq!( + scan_stk( + 0, + 0, + 2000, + 1000, + 0, + 0x10_0000, + code(&[&[OP_STACK, -56], &[OP_BREAK]]) + ), + None + ); + } + + #[test] + fn detects_stack_overflow_on_recursive_call() { + // stk=1064, hea=1000; o PROC do chamado fará stk-8=1056; hea+64=1064 > 1056. + assert_eq!( + scan_stk( + 0, + 0, + 1064, + 1000, + 0, + 0x10_0000, + code(&[&[OP_CALL, 0], &[OP_BREAK]]) + ), + Some(RuntimeError::StackError) + ); + } + + #[test] + fn detects_heap_underflow_on_heap_release() { + // hea=1000, hlw=1000; `heap -4` → hea=996 < hlw → underflow (pilha folgada). + assert_eq!( + scan_stk( + 0, + 0, + 0x10_0000, + 1000, + 1000, + 0x20_0000, + code(&[&[OP_HEAP, -4], &[OP_BREAK]]) + ), + Some(RuntimeError::HeapLow) + ); + // Liberação dentro do limite (hlw=900): ok. + assert_eq!( + scan_stk( + 0, + 0, + 0x10_0000, + 1000, + 900, + 0x20_0000, + code(&[&[OP_HEAP, -4], &[OP_BREAK]]) + ), + None + ); + } + + #[test] + fn detects_mem_access_in_heap_stack_gap() { + // pri=5000 na lacuna [hea=1000, stk=8000) → load.i inválido. + assert_eq!( + scan_stk( + 0, + 0, + 8000, + 1000, + 0, + 0x10_0000, + code(&[&[OP_CONST_PRI, 5000], &[OP_LOAD_I], &[OP_BREAK]]) + ), + Some(RuntimeError::MemAccess) + ); + // pri=500 é global (abaixo do heap) → endereço válido. + assert_eq!( + scan_stk( + 0, + 0, + 8000, + 1000, + 0, + 0x10_0000, + code(&[&[OP_CONST_PRI, 500], &[OP_LOAD_I], &[OP_BREAK]]) + ), + None + ); + } + + #[test] + fn mem_access_skipped_when_address_unknown() { + // const.pri 5000 (na lacuna) ; add (opcode não modelado, zera a confiança) ; + // load.i → como `pri` deixou de ser rastreado, NÃO acusa (conservador). + const OP_ADD: i32 = 78; + assert_eq!( + scan_stk( + 0, + 0, + 8000, + 1000, + 0, + 0x10_0000, + code(&[&[OP_CONST_PRI, 5000], &[OP_ADD], &[OP_LOAD_I], &[OP_BREAK]]) + ), + None + ); + } + + #[test] + fn no_check_after_call_barrier() { + // Após um `call` (barreira: pilha deixa de ser confiável), um `stack` que + // colidiria NÃO é reportado — evita falso-positivo com fluxo não seguido. + // call não estoura aqui (pilha bem folgada). + assert_eq!( + scan_stk( + 0, + 0, + 0x10_0000, + 0, + 0, + 0x20_0000, + code(&[&[OP_CALL, 0], &[OP_STACK, -0x0F_FFF0], &[OP_BREAK]]) + ), + None + ); + } + #[test] fn opcode_map_identity_when_not_relocated() { let map = OpcodeMap::new(None); diff --git a/crates/debug-plugin/src/stack.rs b/crates/debug-plugin/src/stack.rs new file mode 100644 index 0000000..66ec4cf --- /dev/null +++ b/crates/debug-plugin/src/stack.rs @@ -0,0 +1,138 @@ +//! Caminhada da pilha de chamadas (call stack) do AMX — a lógica pura, separada +//! da leitura de memória real (`Amx::read_cell`), via um leitor injetável. Assim +//! a caminhada é testável com um mapa de memória falso, sem servidor. +//! +//! # Layout de frame do AMX +//! +//! A pilha do AMX cresce para BAIXO (endereços menores = mais recente), então os +//! frames dos chamadores ficam em endereços MAIORES. O prólogo `OP_PROC` empilha o +//! `FRM` anterior e aponta `FRM` para o topo; a instrução `OP_CALL` empilhou antes +//! o endereço de retorno. Relativo ao `frm` corrente: +//! +//! ```text +//! [frm] = FRM do chamador (salvo pelo PROC) +//! [frm + CELL] = endereço de retorno no chamador (empilhado pelo CALL) +//! ``` +//! +//! O `amx_Exec` empilha um endereço de retorno `0` antes de entrar no público de +//! entrada; ao chegar nele, `[frm + CELL] == 0` encerra a caminhada. + +/// Tamanho de uma cell do AMX (32 bits). O `cip`/`OP_BREAK` do resto do plugin já +/// assume 4 (ver `hook::BREAK_OP_SIZE`). +const CELL: i32 = 4; + +/// Teto de profundidade da caminhada — guarda contra uma pilha corrompida (frame +/// que não sobe, ciclo) para não girar sem fim no hook de debug. +const MAX_DEPTH: usize = 128; + +/// Caminha a pilha a partir do frame do topo `(top_cip, top_frm)` e devolve os +/// frames `(cip, frm)` do topo (índice 0, onde a VM parou) até o público de +/// entrada. `stp` é o topo da pilha (`Amx::stp`), o limite superior válido de um +/// endereço de dados; `read_cell` lê uma cell do segmento de dados (`None` se +/// inacessível). +/// +/// Para cada chamador, o `cip` é o endereço de retorno salvo — um offset de +/// código dentro da função chamadora, que mapeia à linha do ponto de chamada. +#[must_use] +pub fn walk( + top_cip: u32, + top_frm: i32, + stp: i32, + read_cell: impl Fn(i32) -> Option, +) -> Vec<(u32, i32)> { + let mut frames = vec![(top_cip, top_frm)]; + let mut frm = top_frm; + + for _ in 0..MAX_DEPTH { + // Frame precisa caber na pilha para ler os dois slots do cabeçalho. + if frm <= 0 || frm + CELL >= stp { + break; + } + let (Some(ret), Some(prev)) = (read_cell(frm + CELL), read_cell(frm)) else { + break; + }; + // `amx_Exec` empurra retorno 0 antes do público de entrada: sem chamador. + if ret <= 0 { + break; + } + frames.push((ret.cast_unsigned(), prev)); + // O frame do chamador deve estar ACIMA (endereço maior) e dentro da pilha; + // caso contrário a cadeia é inválida e paramos após registrar a linha. + if prev <= frm || prev >= stp { + break; + } + frm = prev; + } + + frames +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Monta um leitor de memória falso a partir de pares (endereço, valor). + fn mem(pairs: &[(i32, i32)]) -> impl Fn(i32) -> Option { + let map: HashMap = pairs.iter().copied().collect(); + move |addr| map.get(&addr).copied() + } + + #[test] + fn single_frame_when_return_is_zero() { + // Público de entrada: [frm+4] = 0 (retorno sentinela do amx_Exec). + let read = mem(&[(1000, 0), (1004, 0)]); + let frames = walk(40, 1000, 2000, read); + assert_eq!(frames, vec![(40, 1000)]); + } + + #[test] + fn walks_two_levels() { + // foo (frm=1000) chamado por main (frm=1500), main é o público de entrada. + // foo: [1000]=1500 (FRM de main), [1004]=800 (retorno em main) + // main: [1500]=1900 (FRM anterior), [1504]=0 (entrada → para) + let read = mem(&[(1000, 1500), (1004, 800), (1500, 1900), (1504, 0)]); + let frames = walk(40, 1000, 2000, read); + assert_eq!(frames, vec![(40, 1000), (800, 1500)]); + } + + #[test] + fn walks_three_levels() { + // bar(1000) ← foo(1400) ← main(1800, entrada). + let read = mem(&[ + (1000, 1400), + (1004, 600), // retorno em foo + (1400, 1800), + (1404, 300), // retorno em main + (1800, 1950), + (1804, 0), // entrada + ]); + let frames = walk(64, 1000, 2000, read); + assert_eq!(frames, vec![(64, 1000), (600, 1400), (300, 1800)]); + } + + #[test] + fn stops_on_unreadable_cell() { + // Sem dados para [1000]/[1004]: só o frame do topo. + let read = mem(&[]); + let frames = walk(40, 1000, 2000, read); + assert_eq!(frames, vec![(40, 1000)]); + } + + #[test] + fn stops_when_frame_does_not_climb() { + // prev (1000) não sobe em relação a frm (1000): registra a linha do + // chamador e para, sem laço infinito. + let read = mem(&[(1000, 1000), (1004, 800)]); + let frames = walk(40, 1000, 2000, read); + assert_eq!(frames, vec![(40, 1000), (800, 1000)]); + } + + #[test] + fn stops_when_frame_out_of_stack() { + // frm no limite de stp: não há espaço para o cabeçalho do frame. + let read = mem(&[(1996, 100), (2000, 0)]); + let frames = walk(40, 1998, 2000, read); + assert_eq!(frames, vec![(40, 1998)]); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index d2b0451..7070627 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -58,21 +58,46 @@ pub enum Command { /// código que roda uma única vez no início (ex.: `OnGameModeInit`). Configured, /// Edita uma variável em escopo na pausa atual: grava `value` na célula de - /// `name`. Só vale enquanto a VM está pausada. - SetVariable { name: String, value: i32 }, + /// `name`. `frame` é o índice do frame da pilha (0 = topo, onde a VM parou), + /// para editar a variável no escopo correto. Só vale enquanto a VM está pausada. + SetVariable { + frame: usize, + name: String, + /// Índice do elemento, quando a variável é um array (`arr[index]`); + /// `None` edita um escalar. + #[serde(default, skip_serializing_if = "Option::is_none")] + index: Option, + value: i32, + }, + /// Substitui o conjunto de data breakpoints (pausar quando uma variável muda). + /// Cada alvo vem como frame + nome porque só o plugin sabe o `frm`/endereço + /// para resolvê-lo; enviado enquanto a VM está pausada (o editor arma o data + /// breakpoint a partir do painel Variáveis). + SetDataBreakpoints { watches: Vec }, + /// Liga/desliga a pausa em erros de runtime (filtro de exceção do editor). + /// `false` deixa a VM abortar normalmente, sem pausar antes. + SetExceptionFilter { runtime: bool }, +} + +/// Um data breakpoint pedido: a variável `name` em escopo no frame `frame` +/// (0 = topo). O plugin resolve o endereço de dados e passa a observar mudanças. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DataWatch { + pub frame: usize, + pub name: String, } /// Evento do plugin para o adaptador. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "camelCase")] pub enum Event { - /// A VM pausou. `line` é a linha-fonte (mapeada do `cip`, se possível) e - /// `vars` são os símbolos em escopo no momento — enviados junto para evitar - /// uma ida-e-volta de inspeção enquanto a VM está bloqueada. + /// A VM pausou. `frames` é a pilha de chamadas completa — do topo (frame 0, + /// onde a VM parou) até o público de entrada. Cada frame traz sua linha-fonte + /// e as variáveis em escopo naquele frame, enviadas junto para evitar + /// idas-e-voltas de inspeção enquanto a VM está bloqueada. Paused { reason: String, - line: Option, - vars: Vec, + frames: Vec, /// Texto descritivo opcional (ex.: mensagem de um erro de runtime quando /// `reason == "exception"`). Vira o `description`/`text` do `stopped` DAP. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -85,11 +110,25 @@ pub enum Event { Exited, } -/// Um par variável→valor para a inspeção. +/// Um par variável→valor para a inspeção. Arrays trazem os elementos em +/// `children` (expansíveis na árvore do editor); escalares têm `children` vazio. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Var { pub name: String, pub value: String, + /// Elementos de um array (`[0]`, `[1]`, …); vazio para escalares. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +/// Um frame da pilha de chamadas na pausa. `name` é o nome da função (resolvido +/// do bloco de debug pelo endereço), `line` a linha-fonte do frame e `vars` as +/// variáveis em escopo nele. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Frame { + pub name: String, + pub line: Option, + pub vars: Vec, } /// Serializa uma mensagem como uma linha JSON (com `\n` ao final). @@ -135,6 +174,30 @@ mod tests { }, Command::Continue, Command::Step { mode: Step::Over }, + Command::SetVariable { + frame: 1, + name: "x".into(), + index: None, + value: 7, + }, + Command::SetVariable { + frame: 0, + name: "arr".into(), + index: Some(2), + value: 9, + }, + Command::SetDataBreakpoints { + watches: vec![ + DataWatch { + frame: 0, + name: "health".into(), + }, + DataWatch { + frame: 2, + name: "g_placar".into(), + }, + ], + }, ] { let line = to_line(&cmd).unwrap(); assert!(line.ends_with('\n')); @@ -148,17 +211,31 @@ mod tests { for ev in [ Event::Paused { reason: "breakpoint".into(), - line: Some(42), - vars: vec![Var { - name: "g".into(), - value: "1".into(), + frames: vec![Frame { + name: "main".into(), + line: Some(42), + vars: vec![Var { + name: "g".into(), + value: "1".into(), + children: vec![], + }], }], description: None, }, Event::Paused { reason: "exception".into(), - line: Some(7), - vars: vec![], + frames: vec![ + Frame { + name: "foo".into(), + line: Some(7), + vars: vec![], + }, + Frame { + name: "main".into(), + line: Some(20), + vars: vec![], + }, + ], description: Some("divisão por zero".into()), }, Event::Output { text: "x=5".into() }, diff --git a/docs/features.md b/docs/features.md index f91e3c4..f9cd1ec 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,14 +7,14 @@ | Hit count | :material-check: | `N`, `==N`, `>=N`, `<=N`, `>N`, ` limite` | +| Colisão pilha/heap (`STACKERR`) | `OP_STACK` / `OP_HEAP` / `OP_PROC` / `OP_CALL` (`CHKMARGIN`) | `hea + STKMARGIN > stk` (no `CALL`, antecipa o `PROC` do chamado) | +| Underflow de heap (`HEAPLOW`) | `OP_HEAP` (`CHKHEAP`) | `hea < hlw` | +| Acesso inválido à memória (`MEMACCESS`) | `OP_LOAD_I` / `OP_STOR_I` / `OP_LIDX` (`VERIFYADDRESS`) | endereço em `[hea, stk)` ou `>= stp` | Ao detectar, o debugger pausa com `reason: "exception"` e a mensagem no idioma do editor, mostrando a linha e as variáveis — como um breakpoint normal. -!!! note "Cobertura parcial por design" - Só erros previsíveis por análise da próxima instrução. `STACKERR`, - `MEMACCESS` e `HEAPLOW` dependem de estado dinâmico e estão em avaliação — não - é "pausa em qualquer exceção". +!!! note "Conservador por design" + As checagens de `STACKERR`/`HEAPLOW`/`MEMACCESS` rastreiam `stk`/`hea` ao longo + da linha e só disparam enquanto esse rastreio é exato; qualquer desvio ou + opcode não modelado (salto, `sysreq`, aritmética) as desliga — **nunca** um + falso-positivo. Não é "pausa em qualquer exceção". ## Primitivas do SDK usadas