From 9882984b825a8715df462f89b9c6ca5658b12ad9 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:11:01 -0300 Subject: [PATCH 01/14] ci: migrate CodeQL to advanced setup and add CODEOWNERS (#16) The CodeQL default setup only analysed a pull request when it touched files relevant to the configured languages, so a docs-only PR produced no analysis while master still carried one per language, leaving the code_scanning branch rule unable to diff the two sides. Also add the CODEOWNERS file this repository was missing, so the branch ruleset can require code owner review like the sibling repositories do. --- .github/CODEOWNERS | 1 + .github/workflows/codeql.yml | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/codeql.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d2d6bc1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @NullSablex diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f7ff48c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +# CodeQL advanced setup. +# +# Replaces the repository's CodeQL *default* setup, which only analyses a pull +# request when it touches files relevant to the configured languages. A PR that +# changes only docs or dependency manifests produced no analysis at all, while +# `master` still carried one per language — so the `code_scanning` branch rule +# could not diff the two sides and reported "configurations not found". +# +# Running here, with no path filter, guarantees both configurations exist on +# every pull request. The categories below must keep matching the ones recorded +# on `master` (`/language:actions`, `/language:rust`) for that diff to work. +name: CodeQL + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + # Weekly, to catch newly published queries against unchanged code. + - cron: '27 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # upload the SARIF results + actions: read + strategy: + fail-fast: false + matrix: + # Keep in sync with the languages the previous default setup covered. + language: [ actions, rust ] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + languages: ${{ matrix.language }} + # Neither language needs a compiled build for CodeQL to extract it. + build-mode: none + queries: security-extended + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + category: "/language:${{ matrix.language }}" From c299a3bca947c4a1a0dbf9e1ba45b547c8440b6e Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:36:57 -0300 Subject: [PATCH 02/14] chore: re-pin rust-samp SDK to 51ba519 (v3.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traz o lookup_function do bloco de debug (base para a pilha de chamadas), Amx::exec_public_scope e o hardening de buffer/stack no FFI. Eventos do SDK (#[event]) são x86-only e viram no-op no check aarch64. --- Cargo.lock | 229 ++++++++++++++++++++++++++++++--- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- 3 files changed, 213 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c06076..500558f 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=51ba519#51ba519157e04258ef49515958491b9ba734e2db" 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=51ba519#51ba519157e04258ef49515958491b9ba734e2db" 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=51ba519#51ba519157e04258ef49515958491b9ba734e2db" 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/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 4e5b1fb..77616c0 100644 --- a/crates/dap-adapter/Cargo.toml +++ b/crates/dap-adapter/Cargo.toml @@ -11,7 +11,7 @@ name = "dap-adapter" path = "src/main.rs" [dependencies] -rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "e5b5fc1", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "51ba519", default-features = false, features = ["debug"] } pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index b293b9b..620ea73 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 = "51ba519", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" From fb86df3f0953ac5291a71935fdf15397dccd348c Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:37:45 -0300 Subject: [PATCH 03/14] feat(debugger): call stack multi-frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caminha a cadeia de frames do AMX (FRM anterior + endereço de retorno, parando no público de entrada) e expõe N frames ao editor. Cada frame traz nome da função (lookup_function), linha e variáveis em escopo — reaproveitando inspect::collect por frame. - protocolo: Event::Paused passa a carregar frames: Vec; Command:: SetVariable ganha o índice do frame. - plugin: novo módulo stack (walker puro e testável) + build_frames no on_pause; PAUSE_CTX guarda o contexto de todos os frames para editar no frame certo. - adaptador: stackTrace com N frames + source; scopes/variables/evaluate/ setVariable operam por frame (frameId/variablesReference). Cobertura: 6 testes do walker + 2 do plumbing do adaptador. aarch64 ok. --- README.md | 2 +- crates/dap-adapter/src/plugin_client.rs | 61 +++++------ crates/dap-adapter/src/session.rs | 128 +++++++++++++++++----- crates/debug-plugin/src/bridge.rs | 10 +- crates/debug-plugin/src/hook.rs | 73 +++++++++---- crates/debug-plugin/src/lib.rs | 1 + crates/debug-plugin/src/stack.rs | 138 ++++++++++++++++++++++++ crates/protocol/src/lib.rs | 54 +++++++--- 8 files changed, 369 insertions(+), 98 deletions(-) create mode 100644 crates/debug-plugin/src/stack.rs diff --git a/README.md b/README.md index 0e94f95..878c5cf 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ desenvolvimento. | Editar variável | ✅ | Durante a pausa (`setVariable`). | | Pausar em erro de runtime | ✅ | Divisão por zero e índice fora do limite; pausa na linha, antes do abort. SA-MP e open.mp. | | Mensagens localizadas | ✅ | pt-BR, en, es, ro, ru (segue o idioma do editor). | -| Call stack multi-frame | ⬜ | Hoje mostra um frame; caminhar a pilha está planejado. | +| Call stack multi-frame | ✅ | Caminha a cadeia de frames (FRM→retorno); nome da função, linha e variáveis por frame. | | Data breakpoints | ⬜ | Pausar quando uma variável muda — em avaliação. | | Mais erros de runtime | ⬜ | STACKERR / MEMACCESS / HEAPLOW — em avaliação. | diff --git a/crates/dap-adapter/src/plugin_client.rs b/crates/dap-adapter/src/plugin_client.rs index e6b3cd9..ce59677 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,39 @@ 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) - { - v.value = value.to_string(); - } +/// 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() } -/// 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 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 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..c4f5e5e 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -335,42 +335,72 @@ 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 frame referenciado. O `variablesReference` é o + /// `frameId` (1-based) definido no `scopes`; o índice do frame é `ref - 1`. fn on_variables(&mut self, req: &Request) -> Vec { - let vars: Vec = crate::plugin_client::last_vars() + let frame = frame_index(req.arguments.get("variablesReference")); + let vars: Vec = crate::plugin_client::frame_vars(frame) .into_iter() .map(|v| json!({ "name": v.name, "value": v.value, "variablesReference": 0 })) .collect(); @@ -395,6 +425,8 @@ impl Session { .unwrap_or("") .trim() .to_string(); + // O `variablesReference` do escopo identifica o frame (== frameId 1-based). + let frame = frame_index(req.arguments.get("variablesReference")); // 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, @@ -413,7 +445,7 @@ 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() + let is_array = crate::plugin_client::frame_vars(frame) .iter() .any(|v| v.name == name && v.value.trim_start().starts_with('[')); if is_array { @@ -427,10 +459,10 @@ impl Session { // 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, value }), Outgoing::Response(Response::ok(seq, req, body)), ] } @@ -447,9 +479,17 @@ impl Session { .and_then(Value::as_str) .unwrap_or("") .trim(); + // 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); - // Busca exata pelo nome da variável entre as da última pausa. - let found = crate::plugin_client::last_vars() + // Busca exata pelo nome da variável no frame selecionado. + let found = crate::plugin_client::frame_vars(frame) .into_iter() .find(|v| v.name == expr); @@ -513,6 +553,24 @@ 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) +} + +/// 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 +788,26 @@ 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 disconnect_terminates() { let mut s = Session::new(); diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index beebc8a..381582e 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -170,11 +170,11 @@ 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, value } => { + // Aplica na pausa atual, no frame selecionado. 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, value); } } } diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index fe298d7..92cf051 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -22,7 +22,8 @@ use crate::control::{ 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}; /// 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 +34,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 @@ -144,24 +151,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 +190,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`. @@ -248,14 +273,20 @@ 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 +/// Edits a simple 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`. 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 /// an array (unsupported) 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, 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). 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/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..59c9aff 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -58,21 +58,26 @@ 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, + value: i32, + }, } /// 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")] @@ -92,6 +97,16 @@ pub struct Var { pub value: String, } +/// 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). /// /// # Errors @@ -148,17 +163,30 @@ 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(), + }], }], 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() }, From 3fb6d2ef1c42f2280a4dd2798dc368ac65a4ef2e Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:51:03 -0300 Subject: [PATCH 04/14] =?UTF-8?q?feat(debugger):=20data=20breakpoints=20(p?= =?UTF-8?q?ausar=20quando=20vari=C3=A1vel=20muda)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observa variáveis (globais e locais) e pausa na primeira linha após o valor mudar. Reusa o hook por linha; watches de locais expiram quando o frame dono retorna (via stack::walk), evitando observar slot de pilha reusado. - protocolo: Command::SetDataBreakpoints { watches: Vec }. - plugin: control::DataWatch + check_data_watches (puro, com expiração por frame vivo); hook resolve frame+nome → endereço/classe/valor inicial e checa a cada linha, pausando com reason "data breakpoint". - adaptador: capability supportsDataBreakpoints; dataBreakpointInfo (dataId "frame:name") e setDataBreakpoints encaminhando ao plugin. Cobertura: 5 testes do controlador (mudança/expiração/global) + 3 do adaptador (parse_data_id, encaminhamento, capability). aarch64 ok. --- README.md | 2 +- crates/dap-adapter/src/session.rs | 128 +++++++++++++++++++++++++- crates/debug-plugin/src/bridge.rs | 1 + crates/debug-plugin/src/control.rs | 138 +++++++++++++++++++++++++++++ crates/debug-plugin/src/hook.rs | 86 +++++++++++++++++- crates/protocol/src/lib.rs | 30 +++++++ 6 files changed, 382 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 878c5cf..df27d9d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ desenvolvimento. | Pausar em erro de runtime | ✅ | Divisão por zero e índice fora do limite; pausa na linha, antes do abort. SA-MP e open.mp. | | Mensagens localizadas | ✅ | pt-BR, en, es, ro, ru (segue o idioma do editor). | | Call stack multi-frame | ✅ | Caminha a cadeia de frames (FRM→retorno); nome da função, linha e variáveis por frame. | -| Data breakpoints | ⬜ | Pausar quando uma variável muda — em avaliação. | +| Data breakpoints | ✅ | Pausar quando uma variável muda (globais e locais); locais expiram ao retornar o frame. | | Mais erros de runtime | ⬜ | STACKERR / MEMACCESS / HEAPLOW — em avaliação. | ## Estrutura (workspace Cargo) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index c4f5e5e..fe367e2 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}; @@ -107,6 +107,8 @@ 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), "evaluate" => self.on_evaluate(req), "disconnect" | "terminate" => self.on_disconnect(req), "restart" => self.on_restart(req), @@ -138,6 +140,9 @@ 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, // 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 @@ -467,6 +472,66 @@ impl Session { ] } + /// `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); + let observable = var + .as_ref() + .is_some_and(|v| !v.value.trim_start().starts_with('[')); + + 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) + } + /// `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 @@ -562,6 +627,17 @@ fn frame_index(reference: Option<&Value>) -> usize { .unwrap_or(0) } +/// 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 { @@ -808,6 +884,56 @@ mod tests { assert_eq!(frame_index(Some(&json!(0))), 0); // inválido → topo } + #[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 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(); diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index 381582e..b82d833 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -176,5 +176,6 @@ fn apply(cmd: Command) { // não houver pausa ou a variável não for editável). let _ = crate::hook::set_variable(frame, &name, value); } + Command::SetDataBreakpoints { watches } => crate::hook::set_data_breakpoints(watches), } } 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 92cf051..c03be9e 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -17,13 +17,14 @@ 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 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. @@ -100,6 +101,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. @@ -259,6 +271,26 @@ 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 /// adapter. pub fn set_breakpoints(bps: Vec) { @@ -273,6 +305,58 @@ pub fn set_breakpoints(bps: Vec) { } } +/// 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 simple 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`. Returns `Some(value)` on success, `None` if there is no diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 59c9aff..1da14fc 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -65,6 +65,19 @@ pub enum Command { name: String, 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 }, +} + +/// 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. @@ -150,6 +163,23 @@ mod tests { }, Command::Continue, Command::Step { mode: Step::Over }, + Command::SetVariable { + frame: 1, + name: "x".into(), + value: 7, + }, + 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')); From 5b641ffca5cb9f3b4696524395db23df37e98069 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:12:35 -0300 Subject: [PATCH 05/14] chore: re-pin rust-samp SDK to 6c8b528 (Amx::hlw accessor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traz o accessor hlw() (fundo do heap) necessário para detectar heap underflow. Aponta para a branch do PR NullSablex/rust-samp#54; re-pinar no master estável quando o PR mergear, antes de fechar este. --- Cargo.lock | 6 +++--- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 500558f..222d42f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "rust-samp" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=51ba519#51ba519157e04258ef49515958491b9ba734e2db" +source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" dependencies = [ "fern", "log", @@ -246,7 +246,7 @@ dependencies = [ [[package]] name = "rust-samp-codegen" version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=51ba519#51ba519157e04258ef49515958491b9ba734e2db" +source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" dependencies = [ "proc-macro2", "quote", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "rust-samp-sdk" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=51ba519#51ba519157e04258ef49515958491b9ba734e2db" +source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" dependencies = [ "bitflags 2.13.0", ] diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 77616c0..2b6b115 100644 --- a/crates/dap-adapter/Cargo.toml +++ b/crates/dap-adapter/Cargo.toml @@ -11,7 +11,7 @@ name = "dap-adapter" path = "src/main.rs" [dependencies] -rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "51ba519", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "6c8b528", default-features = false, features = ["debug"] } pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index 620ea73..dca7594 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 = "51ba519", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "6c8b528", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" From f8c4601b2e314f07ba4c999f596ca8e57d57c6ce Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:12:36 -0300 Subject: [PATCH 06/14] feat(debugger): detect STACKERR, HEAPLOW e MEMACCESS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estende o simulador de linha (runtime_error) para rastrear stk/hea/hlw/stp e detectar, antes do abort da VM e com as MESMAS condições do amx.c: - STACKERR: colisão pilha/heap (CHKMARGIN, hea+STKMARGIN>stk), inclusive antecipando o PROC do chamado num call (recursão infinita). - HEAPLOW: underflow de heap (CHKHEAP, hea=stp (VERIFYADDRESS). Conservador por construção: as checagens só rodam enquanto stk/hea (e o registrador de endereço) são rastreados exatamente; qualquer desvio/opcode não modelado (call, jump, sysreq, sctrl, aritmética) as desliga — nunca um falso-positivo. Números de opcode e condições conferidos no amx.c do omp-compiler. Mensagens localizadas nos 5 idiomas. Cobertura: +6 testes (colisão em stack/call, heaplow, memaccess na lacuna, endereço desconhecido não acusa, barreira pós-call). aarch64 ok. --- README.md | 4 +- crates/debug-plugin/src/hook.rs | 6 +- crates/debug-plugin/src/runtime_error.rs | 559 +++++++++++++++++++++-- 3 files changed, 529 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index df27d9d..ea50594 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ desenvolvimento. | Inspeção de variáveis | ✅ | int, `Float:`, `bool:`, array, hex — em escopo. | | Watch / hover | ✅ | | | Editar variável | ✅ | Durante a pausa (`setVariable`). | -| Pausar em erro de runtime | ✅ | Divisão por zero e índice fora do limite; pausa na linha, antes do abort. SA-MP e open.mp. | +| Pausar em erro de runtime | ✅ | Divisão por zero, índice fora do limite, colisão pilha/heap, underflow de heap e acesso inválido à memória; pausa na linha, antes do abort. SA-MP e open.mp. | | Mensagens localizadas | ✅ | pt-BR, en, es, ro, ru (segue o idioma do editor). | | Call stack multi-frame | ✅ | Caminha a cadeia de frames (FRM→retorno); nome da função, linha e variáveis por frame. | | Data breakpoints | ✅ | Pausar quando uma variável muda (globais e locais); locais expiram ao retornar o frame. | -| Mais erros de runtime | ⬜ | STACKERR / MEMACCESS / HEAPLOW — em avaliação. | +| Mais erros de runtime | ✅ | STACKERR (colisão pilha/heap), HEAPLOW (underflow de heap) e MEMACCESS (acesso inválido) — simulação fiel ao `amx.c`, conservadora. | ## Estrutura (workspace Cargo) diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index c03be9e..6618de1 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -265,10 +265,14 @@ fn detect_runtime_error(amx: &Amx, at: u32) -> Option "индекс массива вне диапазона", (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); From b152a5a7033b4ee317566791815c27d13947f466 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:16:38 -0300 Subject: [PATCH 07/14] chore: re-pin rust-samp SDK to stable master (336f8de, #54 merged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O PR NullSablex/rust-samp#54 (Amx::hlw) foi mergeado; sai da branch do PR e aponta para o master estável do fork. --- Cargo.lock | 6 +++--- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 222d42f..3b95cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "rust-samp" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" +source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" dependencies = [ "fern", "log", @@ -246,7 +246,7 @@ dependencies = [ [[package]] name = "rust-samp-codegen" version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" +source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" dependencies = [ "proc-macro2", "quote", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "rust-samp-sdk" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=6c8b528#6c8b5281c2edb9a0e3e63c97e66ae4d548d5e49a" +source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" dependencies = [ "bitflags 2.13.0", ] diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 2b6b115..24558ed 100644 --- a/crates/dap-adapter/Cargo.toml +++ b/crates/dap-adapter/Cargo.toml @@ -11,7 +11,7 @@ name = "dap-adapter" path = "src/main.rs" [dependencies] -rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "6c8b528", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "336f8de", default-features = false, features = ["debug"] } pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index dca7594..f7248fb 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 = "6c8b528", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "336f8de", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" From 4d2b2f33a1625e08014c5672fb17150fa74ee07a Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:32:29 -0300 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20marcar=20call=20stack,=20data=20b?= =?UTF-8?q?reakpoints=20e=20erros=20de=20runtime=20como=20conclu=C3=ADdos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alinha docs/features.md e docs/runtime-errors.md ao que o PR entrega (estavam como 'planejado'/'em avaliação'). Adiciona STACKERR/HEAPLOW/MEMACCESS à tabela de erros com as condições fiéis ao amx.c. --- docs/features.md | 8 ++++---- docs/index.md | 2 +- docs/runtime-errors.md | 14 +++++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/features.md b/docs/features.md index f91e3c4..d9a2cd9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -10,11 +10,11 @@ | Inspeção de variáveis | :material-check: | int, `Float:`, `bool:`, array, hex — em escopo. | | Watch / hover | :material-check: | | | Editar variável | :material-check: | Durante a pausa (`setVariable`). | -| Pausar em erro de runtime | :material-check: | Divisão por zero e índice fora do limite; pausa na linha, antes do abort. SA-MP e open.mp. Ver [Pausa no erro](runtime-errors.md). | +| Pausar em erro de runtime | :material-check: | Divisão por zero, índice fora do limite, colisão pilha/heap, underflow de heap e acesso inválido à memória; pausa na linha, antes do abort. SA-MP e open.mp. Ver [Pausa no erro](runtime-errors.md). | | Mensagens localizadas | :material-check: | pt-BR, en, es, ro, ru (segue o idioma do editor). | -| Call stack multi-frame | :material-checkbox-blank-outline: | Hoje mostra um frame; caminhar a pilha está planejado. | -| Data breakpoints | :material-checkbox-blank-outline: | Pausar quando uma variável muda — em avaliação. | -| Mais erros de runtime | :material-checkbox-blank-outline: | STACKERR / MEMACCESS / HEAPLOW — em avaliação. | +| Call stack multi-frame | :material-check: | Caminha a cadeia de frames (FRM→retorno); nome da função, linha e variáveis por frame. | +| Data breakpoints | :material-check: | Pausar quando uma variável muda (globais e locais); locais expiram ao retornar o frame. | +| Mais erros de runtime | :material-check: | STACKERR (colisão pilha/heap), HEAPLOW (underflow de heap) e MEMACCESS (acesso inválido) — simulação fiel ao `amx.c`, conservadora. | ## Breakpoints condicionais diff --git a/docs/index.md b/docs/index.md index de53d8b..6e5e579 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ open.mp. - **[Começando](getting-started.md)** — baixar o plugin, colocá-lo no servidor e iniciar uma sessão de depuração. -- **[Recursos](features.md)** — o que o debugger faz (e o que está planejado). +- **[Recursos](features.md)** — o que o debugger faz. Para entender por dentro, veja **[Arquitetura](architecture.md)** e **[Como funciona a pausa no erro](runtime-errors.md)**. diff --git a/docs/runtime-errors.md b/docs/runtime-errors.md index 8b629a4..13c9085 100644 --- a/docs/runtime-errors.md +++ b/docs/runtime-errors.md @@ -42,19 +42,23 @@ código-fonte de nenhum dos dois, porque ambos usam a mesma VM AMX. ## Erros detectados -| Erro | Opcode | Condição | +| Erro | Opcode / checagem | Condição (fiel ao `amx.c`) | |------|--------|----------| | Divisão por zero | `OP_SDIV` / `OP_UDIV` | divisor (`alt`) é zero | | Divisão por zero | `OP_SDIV_ALT` / `OP_UDIV_ALT` | divisor (`pri`) é zero | | Índice fora do limite | `OP_BOUNDS` | `(unsigned) pri > 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 From 66a05449e808ac2b28af0e2b67fd340c6986d0dd Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:43:22 -0300 Subject: [PATCH 09/14] =?UTF-8?q?feat(debugger):=20inspe=C3=A7=C3=A3o=20ri?= =?UTF-8?q?ca=20de=20arrays=20e=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrays viram expansíveis na árvore de variáveis (elementos como filhos com variablesReference próprio) e arrays de char são mostrados como string; editar um elemento (arr[i]) passa a ser suportado. - protocolo: Var ganha children (elementos); Command::SetVariable ganha index opcional (elemento do array). - plugin: inspect::build_array lê os elementos (até 256) e detecta string (imprimível até terminador 0, Latin-1); hook::set_variable escreve arr[index]. - adaptador: variablesReference codifica (frame, var) para expandir arrays e editar elementos; dataBreakpointInfo passa a recusar arrays por terem filhos. Cobertura: as_string (detecção/conservadorismo), encode/decode de ref de array, parse de índice. 77 testes; clippy pedantic, fmt, aarch64 ok. --- README.md | 4 +- crates/dap-adapter/src/plugin_client.rs | 14 +++ crates/dap-adapter/src/session.rs | 152 +++++++++++++++++++++--- crates/debug-plugin/src/bridge.rs | 16 ++- crates/debug-plugin/src/hook.rs | 42 ++++--- crates/debug-plugin/src/inspect.rs | 119 ++++++++++++++++--- crates/protocol/src/lib.rs | 18 ++- docs/features.md | 4 +- 8 files changed, 307 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ea50594..383f6f0 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ desenvolvimento. | Hit count | ✅ | `N`, `==N`, `>=N`, `<=N`, `>N`, ` Vec { - let frame = frame_index(req.arguments.get("variablesReference")); - let vars: Vec = crate::plugin_client::frame_vars(frame) - .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) } @@ -430,8 +462,11 @@ impl Session { .unwrap_or("") .trim() .to_string(); - // O `variablesReference` do escopo identifica o frame (== frameId 1-based). - let frame = frame_index(req.arguments.get("variablesReference")); + 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, @@ -447,17 +482,43 @@ 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. + // 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])"), ))]; } @@ -467,7 +528,12 @@ impl Session { crate::plugin_client::update_var(frame, &name, &shown); let body = json!({ "value": shown, "variablesReference": 0 }); vec![ - Outgoing::ToPlugin(Command::SetVariable { frame, name, value }), + Outgoing::ToPlugin(Command::SetVariable { + frame, + name, + index: None, + value, + }), Outgoing::Response(Response::ok(seq, req, body)), ] } @@ -491,9 +557,8 @@ impl Session { let var = crate::plugin_client::frame_vars(frame) .into_iter() .find(|v| v.name == name); - let observable = var - .as_ref() - .is_some_and(|v| !v.value.trim_start().starts_with('[')); + // 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!({ @@ -627,6 +692,37 @@ fn frame_index(reference: Option<&Value>) -> usize { .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. @@ -884,6 +980,26 @@ mod tests { 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!( diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index b82d833..664c6d0 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -170,11 +170,17 @@ fn apply(cmd: Command) { BRIDGE.gate.resume(Resume::Step(m)); } Command::Configured => BRIDGE.mark_configured(), - Command::SetVariable { frame, name, value } => { - // Aplica na pausa atual, no frame selecionado. 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, 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), } diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index 6618de1..381e22b 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -361,14 +361,15 @@ fn resolve_data_watches(reqs: Vec) -> Vec Option { +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()?; @@ -382,17 +383,28 @@ pub fn set_variable(frame: usize, 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/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 1da14fc..7939196 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -63,6 +63,10 @@ pub enum Command { 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). @@ -103,11 +107,15 @@ 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 @@ -166,8 +174,15 @@ mod tests { 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 { @@ -199,6 +214,7 @@ mod tests { vars: vec![Var { name: "g".into(), value: "1".into(), + children: vec![], }], }], description: None, diff --git a/docs/features.md b/docs/features.md index d9a2cd9..f9cd1ec 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,9 +7,9 @@ | Hit count | :material-check: | `N`, `==N`, `>=N`, `<=N`, `>N`, ` Date: Mon, 31 Aug 2026 14:53:43 -0300 Subject: [PATCH 10/14] =?UTF-8?q?feat(dap-adapter):=20watch/hover=20com=20?= =?UTF-8?q?express=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evaluate passa a avaliar expressões, não só o nome exato: literais, variáveis, elementos de array (arr[i], índice pode ser subexpressão) e A OP B com + - * / % (aritmética inteira estilo Pawn, ou float) e == != < > <= >= (comparação). Um operador por expressão, conservador — o que não avalia vira falha (o editor mostra 'não disponível'). Novo módulo expr no adaptador, puro e testável (11 testes). --- crates/dap-adapter/src/expr.rs | 263 ++++++++++++++++++++++++++++++ crates/dap-adapter/src/main.rs | 1 + crates/dap-adapter/src/session.rs | 21 +-- 3 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 crates/dap-adapter/src/expr.rs diff --git a/crates/dap-adapter/src/expr.rs b/crates/dap-adapter/src/expr.rs new file mode 100644 index 0000000..3d28d74 --- /dev/null +++ b/crates/dap-adapter/src/expr.rs @@ -0,0 +1,263 @@ +//! Avaliador de expressões simples para o `evaluate` (watch/hover). Suporta um +//! operando ou `A OP B` (um operador), com operandos **literais** (`10`, `0x0a`, +//! `1.5`, `true`), **variáveis** em escopo, ou **elementos de array** `arr[i]` +//! (o índice pode ser literal, variável ou uma subexpressão). Operadores: +//! `+ - * / %` (aritmética; inteiros seguem a semântica do Pawn) e +//! `== != < > <= >=` (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/session.rs b/crates/dap-adapter/src/session.rs index 034bab7..91bcd71 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -597,11 +597,10 @@ impl Session { self.reply_with(req, Command::SetDataBreakpoints { watches }, 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). + /// `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 @@ -618,21 +617,15 @@ impl Session { .and_then(|id| usize::try_from(id - 1).ok()) .unwrap_or(0); - // Busca exata pelo nome da variável no frame selecionado. - let found = crate::plugin_client::frame_vars(frame) - .into_iter() - .find(|v| v.name == expr); - 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))] } From 771e078b9f862b52da7c8288b64d95fb9c69d758 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:55:57 -0300 Subject: [PATCH 11/14] =?UTF-8?q?feat(debugger):=20filtro=20de=20exce?= =?UTF-8?q?=C3=A7=C3=A3o=20(ligar/desligar=20erros=20de=20runtime)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O editor passa a controlar a pausa em erros de runtime via exceptionBreakpoint Filters/setExceptionBreakpoints. Desligado, a VM aborta normalmente sem pausar. - protocolo: Command::SetExceptionFilter { runtime }. - plugin: flag atômica RUNTIME_ERRORS (ligada por padrão) que porteia o detect_runtime_error no on_break. - adaptador: capability exceptionBreakpointFilters + handler setExceptionBreakpoints. Cobertura: encaminhamento liga/desliga + capability. 85 testes. --- crates/dap-adapter/src/session.rs | 45 +++++++++++++++++++++++++++++++ crates/debug-plugin/src/bridge.rs | 1 + crates/debug-plugin/src/hook.rs | 14 +++++++++- crates/protocol/src/lib.rs | 3 +++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 91bcd71..38daaf7 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -109,6 +109,7 @@ impl Session { "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), @@ -143,6 +144,10 @@ impl Session { // Data breakpoints: pausar quando uma variável muda de valor // ("Break on Value Change" no painel Variáveis). "supportsDataBreakpoints": 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 @@ -597,6 +602,18 @@ impl Session { 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 @@ -1036,6 +1053,34 @@ mod tests { 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 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(); diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index 664c6d0..d6111c5 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -183,5 +183,6 @@ fn apply(cmd: Command) { 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/hook.rs b/crates/debug-plugin/src/hook.rs index 381e22b..d416fe9 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -11,6 +11,7 @@ //! `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; @@ -56,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) { @@ -92,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 } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7939196..7070627 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -74,6 +74,9 @@ pub enum Command { /// 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` From 49c90951c5b9378fab5bc747f0837f7a13a1da83 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:57 -0300 Subject: [PATCH 12/14] chore: re-pin rust-samp SDK to 3d15a19 (AmxDbg::function_address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traz function_address (nome->endereço de entrada) para os breakpoints de função. Aponta para a branch do PR NullSablex/rust-samp#55; re-pinar no master quando mergear, antes de fechar este. --- Cargo.lock | 6 +++--- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b95cdb..0f5d518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "rust-samp" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" +source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" dependencies = [ "fern", "log", @@ -246,7 +246,7 @@ dependencies = [ [[package]] name = "rust-samp-codegen" version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" +source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" dependencies = [ "proc-macro2", "quote", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "rust-samp-sdk" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=336f8de#336f8deb2602dd0156d64001c3d560641cb4a063" +source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" dependencies = [ "bitflags 2.13.0", ] diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 24558ed..90776c7 100644 --- a/crates/dap-adapter/Cargo.toml +++ b/crates/dap-adapter/Cargo.toml @@ -11,7 +11,7 @@ name = "dap-adapter" path = "src/main.rs" [dependencies] -rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "336f8de", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "3d15a19", default-features = false, features = ["debug"] } pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index f7248fb..edb70af 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 = "336f8de", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "3d15a19", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" From 8dc9499beb20dc417f75bb410b4acb27da43682a Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:57 -0300 Subject: [PATCH 13/14] =?UTF-8?q?feat(dap-adapter):=20breakpoints=20de=20f?= =?UTF-8?q?un=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setFunctionBreakpoints resolve cada nome no endereço de entrada da função (AmxDbg::function_address) e o une aos breakpoints de linha no conjunto único do plugin. Capability supportsFunctionBreakpoints. Útil para callbacks (OnPlayerConnect etc.) sem procurar a linha. Cobertura: resolução + união linha/função + capability. 87 testes. --- crates/dap-adapter/src/session.rs | 125 ++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 38daaf7..da0bb98 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -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), @@ -144,6 +149,8 @@ impl Session { // 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 } @@ -289,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, @@ -303,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, @@ -320,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) } @@ -1073,6 +1130,42 @@ mod tests { ))); } + #[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(); @@ -1098,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()); @@ -1115,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 From 8ca8c6357a863f36dafb8ec7c76160bcbc2b861b Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:03:00 -0300 Subject: [PATCH 14/14] chore: re-pin rust-samp to 900ebd0 (rustfmt do #55) --- Cargo.lock | 6 +++--- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f5d518..df62c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "rust-samp" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ "fern", "log", @@ -246,7 +246,7 @@ dependencies = [ [[package]] name = "rust-samp-codegen" version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ "proc-macro2", "quote", @@ -256,7 +256,7 @@ dependencies = [ [[package]] name = "rust-samp-sdk" version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=3d15a19#3d15a19a41965f0ec557b2699d5a1e5c7ca245d5" +source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" dependencies = [ "bitflags 2.13.0", ] diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 90776c7..cd617a6 100644 --- a/crates/dap-adapter/Cargo.toml +++ b/crates/dap-adapter/Cargo.toml @@ -11,7 +11,7 @@ name = "dap-adapter" path = "src/main.rs" [dependencies] -rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "3d15a19", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "900ebd0", default-features = false, features = ["debug"] } pawnpro-dbg-protocol = { path = "../protocol" } interprocess = "2" serde = { version = "1", features = ["derive"] } diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml index edb70af..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 = "3d15a19", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "900ebd0", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b"