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/33] 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/33] 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/33] 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/33] =?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/33] 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/33] 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/33] 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/33] =?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/33] =?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/33] =?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/33] =?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/33] 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/33] =?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/33] 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" From acc9181ab29fda4bacbf1f44f9d9c4840519c943 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:23:27 -0300 Subject: [PATCH 15/33] feat(dap-adapter): localizar mensagens do adaptador (5 idiomas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As mensagens voltadas ao editor (valor/elemento inválido, editar array, expressão vazia/não avaliável, rótulo do filtro de exceção) passam a seguir o idioma — antes eram só pt-BR, enquanto os erros de runtime já eram localizados. O locale vem do argumento 'locale' do initialize (o cliente informa). Novo módulo l10n (Locale + Msg) espelhando pt-BR/en/es/ru/ro. 89 testes. --- crates/dap-adapter/src/l10n.rs | 151 ++++++++++++++++++++++++++++++ crates/dap-adapter/src/main.rs | 1 + crates/dap-adapter/src/session.rs | 40 ++++---- 3 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 crates/dap-adapter/src/l10n.rs diff --git a/crates/dap-adapter/src/l10n.rs b/crates/dap-adapter/src/l10n.rs new file mode 100644 index 0000000..4b4a90c --- /dev/null +++ b/crates/dap-adapter/src/l10n.rs @@ -0,0 +1,151 @@ +//! Localização das mensagens do adaptador voltadas ao editor. Espelha os 5 +//! idiomas dos erros de runtime do plugin (pt-BR, en, es, ru, ro). O locale vem +//! do argumento `locale` do `initialize` (o cliente informa o idioma). + +/// Idioma das mensagens. Mesma resolução por prefixo do plugin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Locale { + PtBr, + Es, + Ru, + Ro, + #[default] + En, +} + +impl Locale { + /// Resolve do código de locale (`pt-BR`, `es`, …). Desconhecido → inglês. + #[must_use] + pub fn from_code(s: &str) -> Self { + let s = s.to_ascii_lowercase(); + if s.starts_with("pt") { + Self::PtBr + } else if s.starts_with("es") { + Self::Es + } else if s.starts_with("ru") { + Self::Ru + } else if s.starts_with("ro") { + Self::Ro + } else { + Self::En + } + } +} + +/// Mensagem localizável do adaptador (com seus argumentos). +pub enum Msg<'a> { + /// Rótulo do filtro de exceção "Erros de runtime". + RuntimeErrorsLabel, + /// Valor inválido em `setVariable` (`{raw}` = texto digitado). + InvalidValue(&'a str), + /// Elemento de array inválido (`{name}` = nome do filho, ex.: `[i]`). + InvalidElement(&'a str), + /// Tentativa de editar o array inteiro (`{name}` = nome do array). + ArrayEditElement(&'a str), + /// Expressão vazia no `evaluate`. + EmptyExpression, + /// Falha ao avaliar uma expressão (`{expr}`). + CannotEvaluate(&'a str), +} + +impl Msg<'_> { + /// Texto no idioma dado. + #[must_use] + pub fn text(&self, locale: Locale) -> String { + use Locale::{En, Es, PtBr, Ro, Ru}; + use Msg::{ + ArrayEditElement, CannotEvaluate, EmptyExpression, InvalidElement, InvalidValue, + RuntimeErrorsLabel, + }; + match (self, locale) { + (RuntimeErrorsLabel, PtBr) => "Erros de runtime".into(), + (RuntimeErrorsLabel, Es) => "Errores de runtime".into(), + (RuntimeErrorsLabel, Ru) => "Ошибки времени выполнения".into(), + (RuntimeErrorsLabel, Ro) => "Erori de runtime".into(), + (RuntimeErrorsLabel, En) => "Runtime errors".into(), + + (InvalidValue(r), PtBr) => format!( + "valor inválido: '{r}' (use inteiro, ex.: 100/0x64; float, ex.: 1.5; ou true/false)" + ), + (InvalidValue(r), Es) => format!( + "valor inválido: '{r}' (use un entero, ej.: 100/0x64; un float, ej.: 1.5; o true/false)" + ), + (InvalidValue(r), Ru) => format!( + "недопустимое значение: '{r}' (целое, напр. 100/0x64; дробное, напр. 1.5; или true/false)" + ), + (InvalidValue(r), Ro) => format!( + "valoare invalidă: '{r}' (folosiți un întreg, ex.: 100/0x64; un float, ex.: 1.5; sau true/false)" + ), + (InvalidValue(r), En) => format!( + "invalid value: '{r}' (use an integer, e.g. 100/0x64; a float, e.g. 1.5; or true/false)" + ), + + (InvalidElement(n), PtBr | Es) => format!("elemento inválido: '{n}'"), + (InvalidElement(n), Ru) => format!("недопустимый элемент: '{n}'"), + (InvalidElement(n), Ro) => format!("element invalid: '{n}'"), + (InvalidElement(n), En) => format!("invalid element: '{n}'"), + + (ArrayEditElement(n), PtBr) => { + format!("'{n}' é um array; expanda e edite um elemento (ex.: {n}[0])") + } + (ArrayEditElement(n), Es) => { + format!("'{n}' es un array; expándalo y edite un elemento (ej.: {n}[0])") + } + (ArrayEditElement(n), Ru) => { + format!("'{n}' — массив; разверните его и измените элемент (напр. {n}[0])") + } + (ArrayEditElement(n), Ro) => { + format!("'{n}' este un array; extindeți-l și editați un element (ex.: {n}[0])") + } + (ArrayEditElement(n), En) => { + format!("'{n}' is an array; expand it and edit an element (e.g. {n}[0])") + } + + (EmptyExpression, PtBr) => "expressão vazia".into(), + (EmptyExpression, Es) => "expresión vacía".into(), + (EmptyExpression, Ru) => "пустое выражение".into(), + (EmptyExpression, Ro) => "expresie goală".into(), + (EmptyExpression, En) => "empty expression".into(), + + (CannotEvaluate(e), PtBr) => format!("não foi possível avaliar '{e}'"), + (CannotEvaluate(e), Es) => format!("no se pudo evaluar '{e}'"), + (CannotEvaluate(e), Ru) => format!("не удалось вычислить '{e}'"), + (CannotEvaluate(e), Ro) => format!("nu s-a putut evalua '{e}'"), + (CannotEvaluate(e), En) => format!("could not evaluate '{e}'"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locale_from_code() { + assert_eq!(Locale::from_code("pt-BR"), Locale::PtBr); + assert_eq!(Locale::from_code("ES"), Locale::Es); + assert_eq!(Locale::from_code("ru-RU"), Locale::Ru); + assert_eq!(Locale::from_code("ro"), Locale::Ro); + assert_eq!(Locale::from_code("en-US"), Locale::En); + assert_eq!(Locale::from_code("zh"), Locale::En); + assert_eq!(Locale::default(), Locale::En); + } + + #[test] + fn messages_localized() { + assert_eq!(Msg::EmptyExpression.text(Locale::PtBr), "expressão vazia"); + assert_eq!(Msg::EmptyExpression.text(Locale::En), "empty expression"); + assert_eq!( + Msg::CannotEvaluate("x+").text(Locale::En), + "could not evaluate 'x+'" + ); + assert_eq!( + Msg::ArrayEditElement("arr").text(Locale::En), + "'arr' is an array; expand it and edit an element (e.g. arr[0])" + ); + assert_eq!( + Msg::RuntimeErrorsLabel.text(Locale::Ru), + "Ошибки времени выполнения" + ); + } +} diff --git a/crates/dap-adapter/src/main.rs b/crates/dap-adapter/src/main.rs index fb5cd41..ee37401 100644 --- a/crates/dap-adapter/src/main.rs +++ b/crates/dap-adapter/src/main.rs @@ -6,6 +6,7 @@ //! recebe eventos do plugin (socket local) e os escreve como eventos DAP no stdout. mod expr; +mod l10n; mod messages; mod plugin_client; mod protocol; diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index da0bb98..dea5122 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -9,6 +9,7 @@ use pawnpro_dbg_protocol::{Breakpoint, Command, DataWatch, Step}; use samp_sdk::debug::AmxDbg; use serde_json::{Value, json}; +use crate::l10n::{Locale, Msg}; use crate::messages::{Event, Request, Response}; /// Mensagem de saída do `session`. Mantém o `session` puro: ele decide, o @@ -61,6 +62,8 @@ pub struct Session { /// `setBreakpoints`). Usado no `stackTrace` para o frame apontar à fonte — /// senão o editor mostra "Origem Desconhecida". source_path: Option, + /// Idioma das mensagens do adaptador, do `locale` do `initialize`. + locale: Locale, terminated: bool, } @@ -130,6 +133,14 @@ impl Session { } fn on_initialize(&mut self, req: &Request) -> Vec { + // O cliente informa o idioma no `initialize`; guardamos para localizar as + // mensagens do adaptador (o plugin recebe o seu próprio via `launch`). + self.locale = req + .arguments + .get("locale") + .and_then(Value::as_str) + .map_or_else(Locale::default, Locale::from_code); + let runtime_label = Msg::RuntimeErrorsLabel.text(self.locale); // Capabilities mínimas da v1. let caps = json!({ "supportsConfigurationDoneRequest": true, @@ -153,7 +164,7 @@ impl Session { "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 } + { "filter": "runtime", "label": runtime_label, "default": true } ], // NÃO declaramos `supportsRestartRequest`: assim o editor faz o // restart como disconnect + novo launch, que passa pelo nosso fluxo @@ -535,13 +546,8 @@ impl Session { // bool = 0/1); `shown` é o texto amigável que volta para o painel. let seq = self.next_seq(); let Some((value, shown)) = parse_set_value(&raw) else { - return vec![Outgoing::Response(Response::fail( - seq, - req, - format!( - "valor inválido: '{raw}' (use inteiro, ex.: 100/0x64; float, ex.: 1.5; ou true/false)" - ), - ))]; + let detail = Msg::InvalidValue(&raw).text(self.locale); + return vec![Outgoing::Response(Response::fail(seq, req, detail))]; }; // Edição de ELEMENTO de array: o `variablesReference` é o do array e o @@ -549,11 +555,8 @@ impl Session { 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 detail = Msg::InvalidElement(&name).text(self.locale); + return vec![Outgoing::Response(Response::fail(seq, req, detail))]; }; let array_name = arr.name.clone(); crate::plugin_client::update_array_elem(frame, var_index, i, &shown); @@ -577,11 +580,8 @@ impl Session { .iter() .any(|v| v.name == name && !v.children.is_empty()); if is_array { - return vec![Outgoing::Response(Response::fail( - seq, - req, - format!("'{name}' é um array; expanda e edite um elemento (ex.: {name}[0])"), - ))]; + let detail = Msg::ArrayEditElement(&name).text(self.locale); + return vec![Outgoing::Response(Response::fail(seq, req, detail))]; } // Resposta otimista: a edição quase sempre vale (variável simples em @@ -697,9 +697,9 @@ impl Session { vec![Outgoing::Response(Response::ok(seq, req, body))] } else { let detail = if expr.is_empty() { - "expressão vazia".to_string() + Msg::EmptyExpression.text(self.locale) } else { - format!("não foi possível avaliar '{expr}'") + Msg::CannotEvaluate(expr).text(self.locale) }; vec![Outgoing::Response(Response::fail(seq, req, detail))] } From f0881439b73c6ec50ff5dc5a60a20b539dd6f2b9 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:26:07 -0300 Subject: [PATCH 16/33] feat(dap-adapter): autocomplete no watch/console (completions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responde ao completions do DAP sugerindo as variáveis em escopo cujo nome começa com o identificador antes do cursor. Capability supportsCompletionsRequest. Cobertura: word_prefix (extração do prefixo). 90 testes. --- crates/dap-adapter/src/session.rs | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index dea5122..0e30c08 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -118,6 +118,7 @@ impl Session { "dataBreakpointInfo" => self.on_data_breakpoint_info(req), "setDataBreakpoints" => self.on_set_data_breakpoints(req), "setExceptionBreakpoints" => self.on_set_exception_breakpoints(req), + "completions" => self.on_completions(req), "evaluate" => self.on_evaluate(req), "disconnect" | "terminate" => self.on_disconnect(req), "restart" => self.on_restart(req), @@ -162,6 +163,8 @@ impl Session { "supportsDataBreakpoints": true, // Breakpoints de função: parar ao entrar numa função por nome. "supportsFunctionBreakpoints": true, + // Autocomplete no watch/console: sugere variáveis em escopo. + "supportsCompletionsRequest": true, // Filtro de exceção: o editor liga/desliga a pausa em erros de runtime. "exceptionBreakpointFilters": [ { "filter": "runtime", "label": runtime_label, "default": true } @@ -671,6 +674,36 @@ impl Session { self.reply_with(req, Command::SetExceptionFilter { runtime }, Value::Null) } + /// `completions`: autocomplete no watch/console. Sugere as variáveis em escopo + /// no frame cujos nomes começam com o "pedaço" já digitado (o identificador + /// antes do cursor). Sem prefixo, sugere todas. + fn on_completions(&mut self, req: &Request) -> Vec { + let frame = req + .arguments + .get("frameId") + .and_then(Value::as_i64) + .and_then(|id| usize::try_from(id - 1).ok()) + .unwrap_or(0); + let text = req + .arguments + .get("text") + .and_then(Value::as_str) + .unwrap_or(""); + let column = req + .arguments + .get("column") + .and_then(Value::as_i64) + .unwrap_or(0); + let prefix = word_prefix(text, column); + + let targets: Vec = crate::plugin_client::frame_vars(frame) + .into_iter() + .filter(|v| prefix.is_empty() || v.name.starts_with(&prefix)) + .map(|v| json!({ "label": v.name, "type": "variable" })) + .collect(); + self.reply(req, json!({ "targets": targets })) + } + /// `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 @@ -790,6 +823,20 @@ fn parse_elem_index(name: &str) -> Option { name.strip_prefix('[')?.strip_suffix(']')?.parse().ok() } +/// Identificador sendo digitado antes do cursor (`column`, 1-based em `text`) — +/// a corrida final de `[A-Za-z0-9_]`. Usado para filtrar o autocomplete. +fn word_prefix(text: &str, column: i64) -> String { + let n = usize::try_from(column).unwrap_or(0).saturating_sub(1); + let typed: String = text.chars().take(n).collect(); + let mut tail: Vec = typed + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + tail.reverse(); + tail.into_iter().collect() +} + /// Decodifica um `dataId` (`"frame:name"`, montado no `dataBreakpointInfo`) de /// volta em um [`DataWatch`]. O `name` pode conter `:`, então só o primeiro /// separador conta. @@ -1059,6 +1106,15 @@ mod tests { assert_eq!(decode_array_ref(9), None); } + #[test] + fn word_prefix_extracts_trailing_identifier() { + assert_eq!(word_prefix("hea", 4), "hea"); // cursor no fim + assert_eq!(word_prefix("x + he", 7), "he"); // após operador + assert_eq!(word_prefix("arr[i", 6), "i"); // dentro de colchete + assert_eq!(word_prefix("x + ", 5), ""); // depois de espaço → vazio + assert_eq!(word_prefix("health", 4), "hea"); // cursor no meio + } + #[test] fn parse_elem_index_reads_bracketed() { assert_eq!(parse_elem_index("[0]"), Some(0)); From 8153b561eda573b5ea03e08830844947d6fbcff0 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:33:50 -0300 Subject: [PATCH 17/33] =?UTF-8?q?refactor(i18n):=20centralizar=20tradu?= =?UTF-8?q?=C3=A7=C3=B5es=20em=20protocol/messages=20(5=20idiomas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifica a localização num só lugar (crates/protocol/src/messages), com Locale, MsgKey (11 chaves) e um módulo por idioma em langs/.rs (get -> &str, templates com {}). Plugin e adaptador passam a usar essa fonte única: - plugin: runtime_error re-exporta o Locale e delega message() a messages::msg. - adaptador: remove l10n.rs; usa messages::format/msg (from_tag no initialize). O match por MsgKey em cada idioma é exaustivo — falta chave = não compila. Cobertura documentada em docs/i18n.md (en, pt-BR, es, ru, ro: 11/11). 90 testes. --- crates/dap-adapter/src/l10n.rs | 151 -------------------- crates/dap-adapter/src/main.rs | 1 - crates/dap-adapter/src/session.rs | 17 +-- crates/debug-plugin/src/lib.rs | 2 +- crates/debug-plugin/src/runtime_error.rs | 87 +++-------- crates/protocol/src/lib.rs | 1 + crates/protocol/src/messages/langs/en.rs | 24 ++++ crates/protocol/src/messages/langs/es.rs | 24 ++++ crates/protocol/src/messages/langs/mod.rs | 9 ++ crates/protocol/src/messages/langs/pt_br.rs | 24 ++++ crates/protocol/src/messages/langs/ro.rs | 25 ++++ crates/protocol/src/messages/langs/ru.rs | 26 ++++ crates/protocol/src/messages/mod.rs | 129 +++++++++++++++++ docs/i18n.md | 40 ++++++ 14 files changed, 329 insertions(+), 231 deletions(-) delete mode 100644 crates/dap-adapter/src/l10n.rs create mode 100644 crates/protocol/src/messages/langs/en.rs create mode 100644 crates/protocol/src/messages/langs/es.rs create mode 100644 crates/protocol/src/messages/langs/mod.rs create mode 100644 crates/protocol/src/messages/langs/pt_br.rs create mode 100644 crates/protocol/src/messages/langs/ro.rs create mode 100644 crates/protocol/src/messages/langs/ru.rs create mode 100644 crates/protocol/src/messages/mod.rs create mode 100644 docs/i18n.md diff --git a/crates/dap-adapter/src/l10n.rs b/crates/dap-adapter/src/l10n.rs deleted file mode 100644 index 4b4a90c..0000000 --- a/crates/dap-adapter/src/l10n.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Localização das mensagens do adaptador voltadas ao editor. Espelha os 5 -//! idiomas dos erros de runtime do plugin (pt-BR, en, es, ru, ro). O locale vem -//! do argumento `locale` do `initialize` (o cliente informa o idioma). - -/// Idioma das mensagens. Mesma resolução por prefixo do plugin. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Locale { - PtBr, - Es, - Ru, - Ro, - #[default] - En, -} - -impl Locale { - /// Resolve do código de locale (`pt-BR`, `es`, …). Desconhecido → inglês. - #[must_use] - pub fn from_code(s: &str) -> Self { - let s = s.to_ascii_lowercase(); - if s.starts_with("pt") { - Self::PtBr - } else if s.starts_with("es") { - Self::Es - } else if s.starts_with("ru") { - Self::Ru - } else if s.starts_with("ro") { - Self::Ro - } else { - Self::En - } - } -} - -/// Mensagem localizável do adaptador (com seus argumentos). -pub enum Msg<'a> { - /// Rótulo do filtro de exceção "Erros de runtime". - RuntimeErrorsLabel, - /// Valor inválido em `setVariable` (`{raw}` = texto digitado). - InvalidValue(&'a str), - /// Elemento de array inválido (`{name}` = nome do filho, ex.: `[i]`). - InvalidElement(&'a str), - /// Tentativa de editar o array inteiro (`{name}` = nome do array). - ArrayEditElement(&'a str), - /// Expressão vazia no `evaluate`. - EmptyExpression, - /// Falha ao avaliar uma expressão (`{expr}`). - CannotEvaluate(&'a str), -} - -impl Msg<'_> { - /// Texto no idioma dado. - #[must_use] - pub fn text(&self, locale: Locale) -> String { - use Locale::{En, Es, PtBr, Ro, Ru}; - use Msg::{ - ArrayEditElement, CannotEvaluate, EmptyExpression, InvalidElement, InvalidValue, - RuntimeErrorsLabel, - }; - match (self, locale) { - (RuntimeErrorsLabel, PtBr) => "Erros de runtime".into(), - (RuntimeErrorsLabel, Es) => "Errores de runtime".into(), - (RuntimeErrorsLabel, Ru) => "Ошибки времени выполнения".into(), - (RuntimeErrorsLabel, Ro) => "Erori de runtime".into(), - (RuntimeErrorsLabel, En) => "Runtime errors".into(), - - (InvalidValue(r), PtBr) => format!( - "valor inválido: '{r}' (use inteiro, ex.: 100/0x64; float, ex.: 1.5; ou true/false)" - ), - (InvalidValue(r), Es) => format!( - "valor inválido: '{r}' (use un entero, ej.: 100/0x64; un float, ej.: 1.5; o true/false)" - ), - (InvalidValue(r), Ru) => format!( - "недопустимое значение: '{r}' (целое, напр. 100/0x64; дробное, напр. 1.5; или true/false)" - ), - (InvalidValue(r), Ro) => format!( - "valoare invalidă: '{r}' (folosiți un întreg, ex.: 100/0x64; un float, ex.: 1.5; sau true/false)" - ), - (InvalidValue(r), En) => format!( - "invalid value: '{r}' (use an integer, e.g. 100/0x64; a float, e.g. 1.5; or true/false)" - ), - - (InvalidElement(n), PtBr | Es) => format!("elemento inválido: '{n}'"), - (InvalidElement(n), Ru) => format!("недопустимый элемент: '{n}'"), - (InvalidElement(n), Ro) => format!("element invalid: '{n}'"), - (InvalidElement(n), En) => format!("invalid element: '{n}'"), - - (ArrayEditElement(n), PtBr) => { - format!("'{n}' é um array; expanda e edite um elemento (ex.: {n}[0])") - } - (ArrayEditElement(n), Es) => { - format!("'{n}' es un array; expándalo y edite un elemento (ej.: {n}[0])") - } - (ArrayEditElement(n), Ru) => { - format!("'{n}' — массив; разверните его и измените элемент (напр. {n}[0])") - } - (ArrayEditElement(n), Ro) => { - format!("'{n}' este un array; extindeți-l și editați un element (ex.: {n}[0])") - } - (ArrayEditElement(n), En) => { - format!("'{n}' is an array; expand it and edit an element (e.g. {n}[0])") - } - - (EmptyExpression, PtBr) => "expressão vazia".into(), - (EmptyExpression, Es) => "expresión vacía".into(), - (EmptyExpression, Ru) => "пустое выражение".into(), - (EmptyExpression, Ro) => "expresie goală".into(), - (EmptyExpression, En) => "empty expression".into(), - - (CannotEvaluate(e), PtBr) => format!("não foi possível avaliar '{e}'"), - (CannotEvaluate(e), Es) => format!("no se pudo evaluar '{e}'"), - (CannotEvaluate(e), Ru) => format!("не удалось вычислить '{e}'"), - (CannotEvaluate(e), Ro) => format!("nu s-a putut evalua '{e}'"), - (CannotEvaluate(e), En) => format!("could not evaluate '{e}'"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn locale_from_code() { - assert_eq!(Locale::from_code("pt-BR"), Locale::PtBr); - assert_eq!(Locale::from_code("ES"), Locale::Es); - assert_eq!(Locale::from_code("ru-RU"), Locale::Ru); - assert_eq!(Locale::from_code("ro"), Locale::Ro); - assert_eq!(Locale::from_code("en-US"), Locale::En); - assert_eq!(Locale::from_code("zh"), Locale::En); - assert_eq!(Locale::default(), Locale::En); - } - - #[test] - fn messages_localized() { - assert_eq!(Msg::EmptyExpression.text(Locale::PtBr), "expressão vazia"); - assert_eq!(Msg::EmptyExpression.text(Locale::En), "empty expression"); - assert_eq!( - Msg::CannotEvaluate("x+").text(Locale::En), - "could not evaluate 'x+'" - ); - assert_eq!( - Msg::ArrayEditElement("arr").text(Locale::En), - "'arr' is an array; expand it and edit an element (e.g. arr[0])" - ); - assert_eq!( - Msg::RuntimeErrorsLabel.text(Locale::Ru), - "Ошибки времени выполнения" - ); - } -} diff --git a/crates/dap-adapter/src/main.rs b/crates/dap-adapter/src/main.rs index ee37401..fb5cd41 100644 --- a/crates/dap-adapter/src/main.rs +++ b/crates/dap-adapter/src/main.rs @@ -6,7 +6,6 @@ //! recebe eventos do plugin (socket local) e os escreve como eventos DAP no stdout. mod expr; -mod l10n; mod messages; mod plugin_client; mod protocol; diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 0e30c08..10db895 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -9,7 +9,8 @@ use pawnpro_dbg_protocol::{Breakpoint, Command, DataWatch, Step}; use samp_sdk::debug::AmxDbg; use serde_json::{Value, json}; -use crate::l10n::{Locale, Msg}; +use pawnpro_dbg_protocol::messages::{self, Locale, MsgKey}; + use crate::messages::{Event, Request, Response}; /// Mensagem de saída do `session`. Mantém o `session` puro: ele decide, o @@ -140,8 +141,8 @@ impl Session { .arguments .get("locale") .and_then(Value::as_str) - .map_or_else(Locale::default, Locale::from_code); - let runtime_label = Msg::RuntimeErrorsLabel.text(self.locale); + .map_or_else(Locale::default, Locale::from_tag); + let runtime_label = messages::msg(self.locale, MsgKey::RuntimeErrorsLabel); // Capabilities mínimas da v1. let caps = json!({ "supportsConfigurationDoneRequest": true, @@ -549,7 +550,7 @@ impl Session { // bool = 0/1); `shown` é o texto amigável que volta para o painel. let seq = self.next_seq(); let Some((value, shown)) = parse_set_value(&raw) else { - let detail = Msg::InvalidValue(&raw).text(self.locale); + let detail = messages::format(self.locale, MsgKey::InvalidValue, &[&raw]); return vec![Outgoing::Response(Response::fail(seq, req, detail))]; }; @@ -558,7 +559,7 @@ impl Session { 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 { - let detail = Msg::InvalidElement(&name).text(self.locale); + let detail = messages::format(self.locale, MsgKey::InvalidElement, &[&name]); return vec![Outgoing::Response(Response::fail(seq, req, detail))]; }; let array_name = arr.name.clone(); @@ -583,7 +584,7 @@ impl Session { .iter() .any(|v| v.name == name && !v.children.is_empty()); if is_array { - let detail = Msg::ArrayEditElement(&name).text(self.locale); + let detail = messages::format(self.locale, MsgKey::ArrayEditElement, &[&name, &name]); return vec![Outgoing::Response(Response::fail(seq, req, detail))]; } @@ -730,9 +731,9 @@ impl Session { vec![Outgoing::Response(Response::ok(seq, req, body))] } else { let detail = if expr.is_empty() { - Msg::EmptyExpression.text(self.locale) + messages::format(self.locale, MsgKey::EmptyExpression, &[]) } else { - Msg::CannotEvaluate(expr).text(self.locale) + messages::format(self.locale, MsgKey::CannotEvaluate, &[expr]) }; vec![Outgoing::Response(Response::fail(seq, req, detail))] } diff --git a/crates/debug-plugin/src/lib.rs b/crates/debug-plugin/src/lib.rs index 8c2ded1..1f8d3e2 100644 --- a/crates/debug-plugin/src/lib.rs +++ b/crates/debug-plugin/src/lib.rs @@ -73,7 +73,7 @@ impl SampPlugin for Debugger { // Idioma das mensagens de erro, do locale do editor (propagado pelo // adaptador). Ausente/desconhecido → inglês. if let Ok(loc) = std::env::var("PAWNPRO_DBG_LOCALE") { - hook::set_locale(crate::runtime_error::Locale::from_str(&loc)); + hook::set_locale(crate::runtime_error::Locale::from_tag(&loc)); } // Carrega o bloco de debug do `.amx`, se o caminho foi informado, para a diff --git a/crates/debug-plugin/src/runtime_error.rs b/crates/debug-plugin/src/runtime_error.rs index bdbe2a9..2c2cdc4 100644 --- a/crates/debug-plugin/src/runtime_error.rs +++ b/crates/debug-plugin/src/runtime_error.rs @@ -87,36 +87,10 @@ const OP_PARAMS: [u8; OP_NUM_OPCODES] = [ 3,3,3,4,4,4,4,5,5,5,5,2,2,2,2, ]; -/// Idioma das mensagens de erro, resolvido do locale do editor. Espelha o -/// conjunto da engine LSP (mesma regra `from_str`: prefixo de 2 letras). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Locale { - PtBr, - Es, - Ru, - Ro, - #[default] - En, -} - -impl Locale { - /// Resolve do código de locale (`pt-BR`, `es`, ...). Desconhecido → inglês. - #[must_use] - pub fn from_str(s: &str) -> Self { - let s = s.to_ascii_lowercase(); - if s.starts_with("pt") { - Self::PtBr - } else if s.starts_with("es") { - Self::Es - } else if s.starts_with("ru") { - Self::Ru - } else if s.starts_with("ro") { - Self::Ro - } else { - Self::En - } - } -} +// Idioma das mensagens: definido uma vez no protocolo (compartilhado com o +// adaptador). Re-exportado para os usos internos do plugin. +pub use pawnpro_dbg_protocol::messages::Locale; +use pawnpro_dbg_protocol::messages::{self, MsgKey}; /// Erro de runtime iminente detectado no hook. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,37 +112,22 @@ pub enum RuntimeError { } impl RuntimeError { + /// Chave da mensagem localizável correspondente. + #[must_use] + fn key(self) -> MsgKey { + match self { + RuntimeError::DivideByZero => MsgKey::DivideByZero, + RuntimeError::Bounds => MsgKey::Bounds, + RuntimeError::StackError => MsgKey::StackError, + RuntimeError::HeapLow => MsgKey::HeapLow, + RuntimeError::MemAccess => MsgKey::MemAccess, + } + } + /// Texto curto para o `stopped` (reason "exception") do DAP, no idioma dado. #[must_use] pub fn message(self, locale: Locale) -> &'static str { - use Locale::{En, Es, PtBr, Ro, Ru}; - match (self, locale) { - (RuntimeError::DivideByZero, PtBr) => "divisão por zero", - (RuntimeError::DivideByZero, Es) => "división por cero", - (RuntimeError::DivideByZero, Ru) => "деление на ноль", - (RuntimeError::DivideByZero, Ro) => "împărțire la zero", - (RuntimeError::DivideByZero, En) => "division by zero", - (RuntimeError::Bounds, PtBr) => "índice de array fora do limite", - (RuntimeError::Bounds, Es) => "índice de matriz fuera de límite", - (RuntimeError::Bounds, Ru) => "индекс массива вне диапазона", - (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", - } + messages::msg(locale, self.key()) } } @@ -907,18 +866,6 @@ mod tests { assert_eq!(map.decode(0x9999), None); } - #[test] - fn locale_resolves_by_prefix() { - assert_eq!(Locale::from_str("pt-BR"), Locale::PtBr); - assert_eq!(Locale::from_str("PT"), Locale::PtBr); // case-insensitive - assert_eq!(Locale::from_str("es"), Locale::Es); - assert_eq!(Locale::from_str("ru-RU"), Locale::Ru); - assert_eq!(Locale::from_str("ro"), Locale::Ro); - assert_eq!(Locale::from_str("en-US"), Locale::En); - assert_eq!(Locale::from_str("zh"), Locale::En); // desconhecido → inglês - assert_eq!(Locale::default(), Locale::En); - } - #[test] fn message_localized() { assert_eq!( diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7070627..ecba4ca 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; +pub mod messages; pub mod transport; /// Modo de step pedido pelo adaptador. diff --git a/crates/protocol/src/messages/langs/en.rs b/crates/protocol/src/messages/langs/en.rs new file mode 100644 index 0000000..8e1106d --- /dev/null +++ b/crates/protocol/src/messages/langs/en.rs @@ -0,0 +1,24 @@ +//! English (en) — the source language / fallback. + +use crate::messages::MsgKey; + +/// One line per `MsgKey`. `{}` markers are positional (filled by `messages::format`). +#[allow(clippy::match_same_arms)] +#[must_use] +pub fn get(key: MsgKey) -> &'static str { + match key { + MsgKey::DivideByZero => "division by zero", + MsgKey::Bounds => "array index out of bounds", + MsgKey::StackError => "stack overflow (stack/heap collision)", + MsgKey::HeapLow => "heap underflow", + MsgKey::MemAccess => "invalid memory access", + MsgKey::RuntimeErrorsLabel => "Runtime errors", + MsgKey::InvalidValue => { + "invalid value: '{}' (use an integer, e.g. 100/0x64; a float, e.g. 1.5; or true/false)" + } + MsgKey::InvalidElement => "invalid element: '{}'", + MsgKey::ArrayEditElement => "'{}' is an array; expand it and edit an element (e.g. {}[0])", + MsgKey::EmptyExpression => "empty expression", + MsgKey::CannotEvaluate => "could not evaluate '{}'", + } +} diff --git a/crates/protocol/src/messages/langs/es.rs b/crates/protocol/src/messages/langs/es.rs new file mode 100644 index 0000000..d9544b5 --- /dev/null +++ b/crates/protocol/src/messages/langs/es.rs @@ -0,0 +1,24 @@ +//! Español (es). Preservar los marcadores `{}` en la misma posición lógica que +//! en el original. + +use crate::messages::MsgKey; + +#[allow(clippy::match_same_arms)] +#[must_use] +pub fn get(key: MsgKey) -> &'static str { + match key { + MsgKey::DivideByZero => "división por cero", + MsgKey::Bounds => "índice de matriz fuera de límite", + MsgKey::StackError => "desbordamiento de pila (colisión pila/montículo)", + MsgKey::HeapLow => "subdesbordamiento del montículo", + MsgKey::MemAccess => "acceso inválido a memoria", + MsgKey::RuntimeErrorsLabel => "Errores de runtime", + MsgKey::InvalidValue => { + "valor inválido: '{}' (use un entero, ej.: 100/0x64; un float, ej.: 1.5; o true/false)" + } + MsgKey::InvalidElement => "elemento inválido: '{}'", + MsgKey::ArrayEditElement => "'{}' es un array; expándalo y edite un elemento (ej.: {}[0])", + MsgKey::EmptyExpression => "expresión vacía", + MsgKey::CannotEvaluate => "no se pudo evaluar '{}'", + } +} diff --git a/crates/protocol/src/messages/langs/mod.rs b/crates/protocol/src/messages/langs/mod.rs new file mode 100644 index 0000000..3807067 --- /dev/null +++ b/crates/protocol/src/messages/langs/mod.rs @@ -0,0 +1,9 @@ +//! Tabelas de tradução, um módulo por idioma. Cada um expõe `get(MsgKey)`; +//! o roteamento por `Locale` fica no pai (`messages::msg`). Preservar os +//! marcadores `{}` na mesma posição lógica do original. + +pub mod en; +pub mod es; +pub mod pt_br; +pub mod ro; +pub mod ru; diff --git a/crates/protocol/src/messages/langs/pt_br.rs b/crates/protocol/src/messages/langs/pt_br.rs new file mode 100644 index 0000000..63cb695 --- /dev/null +++ b/crates/protocol/src/messages/langs/pt_br.rs @@ -0,0 +1,24 @@ +//! Português (Brasil) (pt-BR). Preservar os marcadores `{}` na mesma posição +//! lógica do original. + +use crate::messages::MsgKey; + +#[allow(clippy::match_same_arms)] +#[must_use] +pub fn get(key: MsgKey) -> &'static str { + match key { + MsgKey::DivideByZero => "divisão por zero", + MsgKey::Bounds => "índice de array fora do limite", + MsgKey::StackError => "estouro de pilha (colisão pilha/heap)", + MsgKey::HeapLow => "underflow de heap", + MsgKey::MemAccess => "acesso inválido à memória", + MsgKey::RuntimeErrorsLabel => "Erros de runtime", + MsgKey::InvalidValue => { + "valor inválido: '{}' (use inteiro, ex.: 100/0x64; float, ex.: 1.5; ou true/false)" + } + MsgKey::InvalidElement => "elemento inválido: '{}'", + MsgKey::ArrayEditElement => "'{}' é um array; expanda e edite um elemento (ex.: {}[0])", + MsgKey::EmptyExpression => "expressão vazia", + MsgKey::CannotEvaluate => "não foi possível avaliar '{}'", + } +} diff --git a/crates/protocol/src/messages/langs/ro.rs b/crates/protocol/src/messages/langs/ro.rs new file mode 100644 index 0000000..81a94da --- /dev/null +++ b/crates/protocol/src/messages/langs/ro.rs @@ -0,0 +1,25 @@ +//! Română (ro). Păstrați marcajele `{}` în aceeași poziție logică ca în original. + +use crate::messages::MsgKey; + +#[allow(clippy::match_same_arms)] +#[must_use] +pub fn get(key: MsgKey) -> &'static str { + match key { + MsgKey::DivideByZero => "împărțire la zero", + MsgKey::Bounds => "index de matrice în afara limitelor", + MsgKey::StackError => "depășire de stivă (coliziune stivă/heap)", + MsgKey::HeapLow => "subdepășire de heap", + MsgKey::MemAccess => "acces nevalid la memorie", + MsgKey::RuntimeErrorsLabel => "Erori de runtime", + MsgKey::InvalidValue => { + "valoare invalidă: '{}' (folosiți un întreg, ex.: 100/0x64; un float, ex.: 1.5; sau true/false)" + } + MsgKey::InvalidElement => "element invalid: '{}'", + MsgKey::ArrayEditElement => { + "'{}' este un array; extindeți-l și editați un element (ex.: {}[0])" + } + MsgKey::EmptyExpression => "expresie goală", + MsgKey::CannotEvaluate => "nu s-a putut evalua '{}'", + } +} diff --git a/crates/protocol/src/messages/langs/ru.rs b/crates/protocol/src/messages/langs/ru.rs new file mode 100644 index 0000000..d87c76e --- /dev/null +++ b/crates/protocol/src/messages/langs/ru.rs @@ -0,0 +1,26 @@ +//! Русский (ru). Сохранять маркеры `{}` в той же логической позиции, что и в +//! оригинале. + +use crate::messages::MsgKey; + +#[allow(clippy::match_same_arms)] +#[must_use] +pub fn get(key: MsgKey) -> &'static str { + match key { + MsgKey::DivideByZero => "деление на ноль", + MsgKey::Bounds => "индекс массива вне диапазона", + MsgKey::StackError => "переполнение стека (столкновение стека и кучи)", + MsgKey::HeapLow => "переполнение кучи снизу", + MsgKey::MemAccess => "недопустимый доступ к памяти", + MsgKey::RuntimeErrorsLabel => "Ошибки времени выполнения", + MsgKey::InvalidValue => { + "недопустимое значение: '{}' (целое, напр. 100/0x64; дробное, напр. 1.5; или true/false)" + } + MsgKey::InvalidElement => "недопустимый элемент: '{}'", + MsgKey::ArrayEditElement => { + "'{}' — массив; разверните его и измените элемент (напр. {}[0])" + } + MsgKey::EmptyExpression => "пустое выражение", + MsgKey::CannotEvaluate => "не удалось вычислить '{}'", + } +} diff --git a/crates/protocol/src/messages/mod.rs b/crates/protocol/src/messages/mod.rs new file mode 100644 index 0000000..b867182 --- /dev/null +++ b/crates/protocol/src/messages/mod.rs @@ -0,0 +1,129 @@ +//! Localização das mensagens do debugger voltadas ao usuário (editor), num só +//! lugar compartilhado pelo plugin e pelo adaptador: um [`MsgKey`] por mensagem, +//! um módulo por idioma em [`langs`] com `get(MsgKey) -> &'static str`, e o +//! roteamento por [`Locale`] aqui. Os textos são **templates** com marcadores +//! `{}` (posicionais); [`format`] os preenche. Ver a cobertura em `docs/i18n.md`. + +mod langs; + +/// Idioma das mensagens. Resolvido por prefixo da tag do editor; desconhecidos +/// caem em inglês (idioma-fonte de fallback do código). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Locale { + PtBr, + Es, + Ru, + Ro, + #[default] + En, +} + +impl Locale { + /// Resolve o `Locale` de uma tag de idioma (`pt-BR`, `es`, `ru`, …), pelo + /// prefixo de duas letras. Desconhecido → inglês. + #[must_use] + pub fn from_tag(s: &str) -> Self { + let s = s.to_ascii_lowercase(); + if s.starts_with("pt") { + Self::PtBr + } else if s.starts_with("es") { + Self::Es + } else if s.starts_with("ru") { + Self::Ru + } else if s.starts_with("ro") { + Self::Ro + } else { + Self::En + } + } +} + +/// Chave de uma mensagem localizável. Uma linha por chave em cada idioma +/// ([`langs`]), para localizar e manter com facilidade. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MsgKey { + // --- Erros de runtime (detectados no plugin) --- + DivideByZero, + Bounds, + StackError, + HeapLow, + MemAccess, + // --- Mensagens do adaptador (respostas ao editor) --- + RuntimeErrorsLabel, + InvalidValue, + InvalidElement, + ArrayEditElement, + EmptyExpression, + CannotEvaluate, +} + +/// Template da mensagem `key` no idioma dado (com marcadores `{}` crus). +#[must_use] +pub fn msg(locale: Locale, key: MsgKey) -> &'static str { + match locale { + Locale::PtBr => langs::pt_br::get(key), + Locale::Es => langs::es::get(key), + Locale::Ru => langs::ru::get(key), + Locale::Ro => langs::ro::get(key), + Locale::En => langs::en::get(key), + } +} + +/// Mensagem `key` no idioma dado, com os `{}` preenchidos por `args` (posicional). +/// `{}` sem argumento vira vazio; argumentos sobrando são ignorados. +#[must_use] +pub fn format(locale: Locale, key: MsgKey, args: &[&str]) -> String { + let template = msg(locale, key); + let parts: Vec<&str> = template.split("{}").collect(); + let mut out = String::with_capacity(template.len()); + for (i, part) in parts.iter().enumerate() { + out.push_str(part); + if i + 1 < parts.len() { + out.push_str(args.get(i).copied().unwrap_or("")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locale_from_str_by_prefix() { + assert_eq!(Locale::from_tag("pt-BR"), Locale::PtBr); + assert_eq!(Locale::from_tag("ES"), Locale::Es); + assert_eq!(Locale::from_tag("ru-RU"), Locale::Ru); + assert_eq!(Locale::from_tag("ro"), Locale::Ro); + assert_eq!(Locale::from_tag("en-US"), Locale::En); + assert_eq!(Locale::from_tag("zh"), Locale::En); + assert_eq!(Locale::default(), Locale::En); + } + + #[test] + fn format_fills_placeholders_positional() { + // Sem args: template inalterado. + assert_eq!( + format(Locale::En, MsgKey::EmptyExpression, &[]), + "empty expression" + ); + // Um arg. + assert_eq!( + format(Locale::En, MsgKey::CannotEvaluate, &["x+"]), + "could not evaluate 'x+'" + ); + // Dois `{}` com o mesmo valor (nome repetido). + assert_eq!( + format(Locale::En, MsgKey::ArrayEditElement, &["arr", "arr"]), + "'arr' is an array; expand it and edit an element (e.g. arr[0])" + ); + } + + #[test] + fn runtime_error_messages_localized() { + assert_eq!(msg(Locale::PtBr, MsgKey::DivideByZero), "divisão por zero"); + assert_eq!(msg(Locale::En, MsgKey::DivideByZero), "division by zero"); + assert_eq!(msg(Locale::En, MsgKey::Bounds), "array index out of bounds"); + assert_eq!(msg(Locale::Ru, MsgKey::HeapLow), "переполнение кучи снизу"); + } +} diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..062fb45 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,40 @@ +# Localização (i18n) + +As mensagens do debugger voltadas ao usuário (erros de runtime e respostas do +adaptador ao editor) ficam **num só lugar**, compartilhado pelo plugin e pelo +adaptador: + +| Onde | O quê | +|------|-------| +| `crates/protocol/src/messages/mod.rs` | `Locale`, `MsgKey` (chaves) e o roteamento (`msg`/`format`) | +| `crates/protocol/src/messages/langs/.rs` | uma tabela `match` por idioma: `get(MsgKey) -> &'static str` | + +Os textos são **templates** com marcadores posicionais `{}`, preenchidos por +`messages::format(locale, key, &[args])`. O idioma vem do editor (argumento +`locale` do `initialize`, no adaptador; env `PAWNPRO_DBG_LOCALE`, no plugin) e é +resolvido por prefixo (`Locale::from_tag`); desconhecido cai em **inglês**. + +**Legenda:** ✅ completo · 🟡 parcial · ⬜ ausente. + +## Status + +Há **11 chaves** de mensagem (5 erros de runtime + 6 do adaptador). Cada idioma +abaixo cobre todas. + +| # | Idioma | Código | Arquivo | Status | +|---|--------|--------|---------|:------:| +| 1 | Inglês (fonte) | `en` | `langs/en.rs` | ✅ (11/11) | +| 2 | Português (BR) | `pt-BR` | `langs/pt_br.rs` | ✅ (11/11) | +| 3 | Espanhol | `es` | `langs/es.rs` | ✅ (11/11) | +| 4 | Russo | `ru` | `langs/ru.rs` | ✅ (11/11) | +| 5 | Romeno | `ro` | `langs/ro.rs` | ✅ (11/11) | + +## Adicionar um idioma + +1. Criar `crates/protocol/src/messages/langs/.rs` com `pub fn get(key: MsgKey) -> &'static str` cobrindo **todas** as `MsgKey` (preservando os `{}` na mesma posição lógica). +2. Registrar o módulo em `langs/mod.rs` (`pub mod ;`). +3. Adicionar a variante ao enum `Locale`, ao `Locale::from_tag` (prefixo) e ao `match` de `messages::msg`. +4. Atualizar esta tabela. + +O compilador garante a cobertura: o `match` por `MsgKey` em cada `get` é +exaustivo, então **falta uma chave = não compila**. From a5e97569d1a22da523436150e94cb6978c2990f1 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:41:50 -0300 Subject: [PATCH 18/33] docs(i18n): roadmap de 50 idiomas + entrada no nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Espelha a meta de cobertura: 5 implementados (✅) e 45 no roadmap (⬜). Adiciona a página de localização ao nav do mkdocs. --- docs/i18n.md | 74 ++++++++++++++++++++++++++++++++++++++++++---------- mkdocs.yml | 1 + 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/i18n.md b/docs/i18n.md index 062fb45..a33e793 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -14,27 +14,73 @@ Os textos são **templates** com marcadores posicionais `{}`, preenchidos por `locale` do `initialize`, no adaptador; env `PAWNPRO_DBG_LOCALE`, no plugin) e é resolvido por prefixo (`Locale::from_tag`); desconhecido cai em **inglês**. +Há **11 chaves** de mensagem (5 erros de runtime + 6 do adaptador). O `match` por +`MsgKey` em cada idioma é **exaustivo** — falta uma chave = não compila. Por isso, +um idioma marcado ✅ cobre necessariamente todas as 11. + **Legenda:** ✅ completo · 🟡 parcial · ⬜ ausente. -## Status +> Meta de cobertura: pelo menos **50 idiomas**. A ordem prioriza a relevância +> histórica na comunidade SA-MP / open.mp. Hoje há **5** implementados. -Há **11 chaves** de mensagem (5 erros de runtime + 6 do adaptador). Cada idioma -abaixo cobre todas. +## Status -| # | Idioma | Código | Arquivo | Status | -|---|--------|--------|---------|:------:| -| 1 | Inglês (fonte) | `en` | `langs/en.rs` | ✅ (11/11) | -| 2 | Português (BR) | `pt-BR` | `langs/pt_br.rs` | ✅ (11/11) | -| 3 | Espanhol | `es` | `langs/es.rs` | ✅ (11/11) | -| 4 | Russo | `ru` | `langs/ru.rs` | ✅ (11/11) | -| 5 | Romeno | `ro` | `langs/ro.rs` | ✅ (11/11) | +| # | Idioma | Código | Status | +|---|--------|--------|:------:| +| 1 | Inglês (fonte) | `en` | ✅ | +| 2 | Português (BR) | `pt-BR` | ✅ | +| 3 | Espanhol | `es` | ✅ | +| 4 | Russo | `ru` | ✅ | +| 5 | Romeno | `ro` | ✅ | +| 6 | Alemão | `de` | ⬜ | +| 7 | Francês | `fr` | ⬜ | +| 8 | Italiano | `it` | ⬜ | +| 9 | Polonês | `pl` | ⬜ | +| 10 | Turco | `tr` | ⬜ | +| 11 | Holandês | `nl` | ⬜ | +| 12 | Ucraniano | `uk` | ⬜ | +| 13 | Chinês (Simpl.) | `zh-CN` | ⬜ | +| 14 | Chinês (Trad.) | `zh-TW` | ⬜ | +| 15 | Indonésio | `id` | ⬜ | +| 16 | Árabe | `ar` | ⬜ | +| 17 | Português (PT) | `pt-PT` | ⬜ | +| 18 | Húngaro | `hu` | ⬜ | +| 19 | Tcheco | `cs` | ⬜ | +| 20 | Sérvio | `sr` | ⬜ | +| 21 | Búlgaro | `bg` | ⬜ | +| 22 | Grego | `el` | ⬜ | +| 23 | Sueco | `sv` | ⬜ | +| 24 | Lituano | `lt` | ⬜ | +| 25 | Croata | `hr` | ⬜ | +| 26 | Eslovaco | `sk` | ⬜ | +| 27 | Hebraico | `he` | ⬜ | +| 28 | Tailandês | `th` | ⬜ | +| 29 | Vietnamita | `vi` | ⬜ | +| 30 | Persa | `fa` | ⬜ | +| 31 | Japonês | `ja` | ⬜ | +| 32 | Coreano | `ko` | ⬜ | +| 33 | Finlandês | `fi` | ⬜ | +| 34 | Dinamarquês | `da` | ⬜ | +| 35 | Norueguês | `nb` | ⬜ | +| 36 | Hindi | `hi` | ⬜ | +| 37 | Bengali | `bn` | ⬜ | +| 38 | Filipino | `fil` | ⬜ | +| 39 | Malaio | `ms` | ⬜ | +| 40 | Letão | `lv` | ⬜ | +| 41 | Estoniano | `et` | ⬜ | +| 42 | Esloveno | `sl` | ⬜ | +| 43 | Bielorrusso | `be` | ⬜ | +| 44 | Macedônio | `mk` | ⬜ | +| 45 | Albanês | `sq` | ⬜ | +| 46 | Bósnio | `bs` | ⬜ | +| 47 | Catalão | `ca` | ⬜ | +| 48 | Azerbaijano | `az` | ⬜ | +| 49 | Cazaque | `kk` | ⬜ | +| 50 | Georgiano | `ka` | ⬜ | ## Adicionar um idioma 1. Criar `crates/protocol/src/messages/langs/.rs` com `pub fn get(key: MsgKey) -> &'static str` cobrindo **todas** as `MsgKey` (preservando os `{}` na mesma posição lógica). 2. Registrar o módulo em `langs/mod.rs` (`pub mod ;`). 3. Adicionar a variante ao enum `Locale`, ao `Locale::from_tag` (prefixo) e ao `match` de `messages::msg`. -4. Atualizar esta tabela. - -O compilador garante a cobertura: o `match` por `MsgKey` em cada `get` é -exaustivo, então **falta uma chave = não compila**. +4. Marcar ✅ nesta tabela. diff --git a/mkdocs.yml b/mkdocs.yml index 96ca2d3..4eaa81f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - Interno: - Arquitetura: architecture.md - Como funciona a pausa no erro: runtime-errors.md + - Localização (i18n): i18n.md extra: social: From ed5a737c6770707442ff60c48a52531d26f859fa Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:46:44 -0300 Subject: [PATCH 19/33] feat(debugger): data breakpoint em elemento de array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrays deixam de ser inobserváveis: dá para observar arr[i]. dataBreakpointInfo oferece o elemento (dataId frame:name:index) quando o variablesReference é de um array; o plugin resolve o endereço do elemento (base + index*4) e observa. - protocolo: DataWatch ganha index opcional. - adaptador: dataBreakpointInfo/parse_data_id tratam o índice. - plugin: resolve_data_watches lê o elemento e nomeia o watch 'arr[i]'. 90 testes; clippy pedantic, fmt, aarch64 ok. --- crates/dap-adapter/src/session.rs | 72 +++++++++++++++++++------------ crates/debug-plugin/src/hook.rs | 25 ++++++++--- crates/protocol/src/lib.rs | 7 ++- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 10db895..ef85299 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -610,7 +610,11 @@ impl Session { /// `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 reference = req + .arguments + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0); let name = req .arguments .get("name") @@ -618,23 +622,35 @@ impl Session { .unwrap_or("") .to_string(); - // Só oferece se a variável está no cache do frame e não é array (arrays - // ainda não observáveis) — evita armar um watch que o plugin recusaria. - let var = crate::plugin_client::frame_vars(frame) - .into_iter() - .find(|v| v.name == name); - // Arrays (têm filhos) não são observáveis por data breakpoint ainda. - let observable = var.as_ref().is_some_and(|v| v.children.is_empty()); + // Resolve `(dataId, descrição)`: escalar (`variablesReference` = escopo do + // frame) ou ELEMENTO de array (`variablesReference` = ref do array, `name` + // = `[i]`). `dataId` codifica `frame:name[:index]`; `null` = não observável. + let resolved = if let Some((frame, var_index)) = decode_array_ref(reference) { + let vars = crate::plugin_client::frame_vars(frame); + match (vars.get(var_index), parse_elem_index(&name)) { + (Some(arr), Some(i)) => Some(( + format!("{frame}:{}:{i}", arr.name), + format!("{}[{i}]", arr.name), + )), + _ => None, + } + } else { + let frame = frame_index(req.arguments.get("variablesReference")); + // Escalar em escopo (arrays têm filhos e não são observáveis inteiros). + crate::plugin_client::frame_vars(frame) + .iter() + .any(|v| v.name == name && v.children.is_empty()) + .then(|| (format!("{frame}:{name}"), name.clone())) + }; - let body = if observable { + let body = if let Some((data_id, description)) = resolved { json!({ - "dataId": format!("{frame}:{name}"), - "description": name, + "dataId": data_id, + "description": description, "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) @@ -838,15 +854,15 @@ fn word_prefix(text: &str, column: i64) -> String { tail.into_iter().collect() } -/// Decodifica um `dataId` (`"frame:name"`, montado no `dataBreakpointInfo`) de -/// volta em um [`DataWatch`]. O `name` pode conter `:`, então só o primeiro -/// separador conta. +/// Decodifica um `dataId` (`"frame:name"` ou `"frame:name:index"`, montado no +/// `dataBreakpointInfo`) de volta em um [`DataWatch`]. Nomes Pawn são +/// identificadores (sem `:`), então os campos são posicionais. 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(), - }) + let mut parts = data_id.splitn(3, ':'); + let frame = parts.next()?.parse().ok()?; + let name = parts.next()?.to_string(); + let index = parts.next().and_then(|s| s.parse().ok()); + Some(DataWatch { frame, name, index }) } /// Anexa `source` (se houver) a um frame do `stackTrace`, para o editor ancorar a @@ -1125,20 +1141,22 @@ mod tests { } #[test] - fn parse_data_id_splits_frame_and_name() { + fn parse_data_id_splits_frame_name_index() { assert_eq!( parse_data_id("0:health"), Some(DataWatch { frame: 0, - name: "health".into() + name: "health".into(), + index: None, }) ); - // Nome com ':' — só o primeiro separador conta. + // Elemento de array: frame:name:index. assert_eq!( - parse_data_id("2:a:b"), + parse_data_id("2:arr:3"), Some(DataWatch { frame: 2, - name: "a:b".into() + name: "arr".into(), + index: Some(3), }) ); // Sem separador ou frame não-numérico → None. @@ -1158,8 +1176,8 @@ mod tests { &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() }) + && watches[0] == DataWatch { frame: 1, name: "health".into(), index: None } + && watches[1] == DataWatch { frame: 0, name: "g_placar".into(), index: None }) )); // E responde os dois como verificados. let bps = first_response(&out).body["breakpoints"].as_array().unwrap(); diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index d416fe9..c0fa006 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -355,10 +355,25 @@ fn resolve_data_watches(reqs: Vec) -> Vec= len { + return None; + } + let addr = base.wrapping_add(i32::try_from(i).ok()?.wrapping_mul(4)); + (addr, format!("{}[{i}]", req.name)) + } else { + if sym.is_array() { + return None; + } + (base, req.name) + }; // 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); @@ -367,7 +382,7 @@ fn resolve_data_watches(reqs: Vec) -> Vec, } /// Evento do plugin para o adaptador. @@ -192,10 +195,12 @@ mod tests { DataWatch { frame: 0, name: "health".into(), + index: None, }, DataWatch { frame: 2, - name: "g_placar".into(), + name: "placar".into(), + index: Some(3), }, ], }, From cb79f8afd0ce2c84d0842986fb360e1f389f2a10 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:52:46 -0300 Subject: [PATCH 20/33] feat(dap-adapter): setExpression (editar lvalue no watch/console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edita um lvalue (name ou arr[i], índice podendo ser subexpressão) digitado no watch/console, encaminhando como SetVariable ao plugin. Capability supportsSetExpression. Cobertura: parse_lvalue + encaminhamento de elemento. 92 testes; aarch64 ok. --- crates/dap-adapter/src/session.rs | 106 ++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index ef85299..d62144c 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -116,6 +116,7 @@ impl Session { "scopes" => self.on_scopes(req), "variables" => self.on_variables(req), "setVariable" => self.on_set_variable(req), + "setExpression" => self.on_set_expression(req), "dataBreakpointInfo" => self.on_data_breakpoint_info(req), "setDataBreakpoints" => self.on_set_data_breakpoints(req), "setExceptionBreakpoints" => self.on_set_exception_breakpoints(req), @@ -159,6 +160,8 @@ impl Session { "supportsLogPoints": true, // Editar variável no painel Variáveis durante a pausa. "supportsSetVariable": true, + // Editar via expressão (ex.: `arr[i]`) no watch/console. + "supportsSetExpression": true, // Data breakpoints: pausar quando uma variável muda de valor // ("Break on Value Change" no painel Variáveis). "supportsDataBreakpoints": true, @@ -604,6 +607,62 @@ impl Session { ] } + /// `setExpression`: edita um lvalue (`name` ou `arr[i]`) no watch/console. O + /// índice pode ser subexpressão. Encaminha ao plugin como `SetVariable`. + fn on_set_expression(&mut self, req: &Request) -> Vec { + let expr = req + .arguments + .get("expression") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let raw = req + .arguments + .get("value") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let frame = req + .arguments + .get("frameId") + .and_then(Value::as_i64) + .and_then(|id| usize::try_from(id - 1).ok()) + .unwrap_or(0); + + let seq = self.next_seq(); + let Some((value, shown)) = parse_set_value(&raw) else { + let detail = messages::format(self.locale, MsgKey::InvalidValue, &[&raw]); + return vec![Outgoing::Response(Response::fail(seq, req, detail))]; + }; + let vars = crate::plugin_client::frame_vars(frame); + let Some((name, index)) = parse_lvalue(&expr, &vars) else { + let detail = messages::format(self.locale, MsgKey::CannotEvaluate, &[&expr]); + return vec![Outgoing::Response(Response::fail(seq, req, detail))]; + }; + + // Cache otimista (o painel reflete sem reler a VM). + if let Some(i) = index { + if let Some(vi) = vars.iter().position(|v| v.name == name) { + crate::plugin_client::update_array_elem(frame, vi, i, &shown); + } + } else { + crate::plugin_client::update_var(frame, &name, &shown); + } + + let body = json!({ "value": shown, "variablesReference": 0 }); + vec![ + Outgoing::ToPlugin(Command::SetVariable { + frame, + name, + index, + value, + }), + Outgoing::Response(Response::ok(seq, req, body)), + ] + } + /// `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; @@ -840,6 +899,24 @@ fn parse_elem_index(name: &str) -> Option { name.strip_prefix('[')?.strip_suffix(']')?.parse().ok() } +/// Interpreta um lvalue (`name` ou `name[expr]`) para `setExpression`. O índice +/// pode ser literal ou subexpressão (resolvida por [`crate::expr`] contra as +/// variáveis do frame). `None` se não for um lvalue simples. +fn parse_lvalue(expr: &str, vars: &[pawnpro_dbg_protocol::Var]) -> Option<(String, Option)> { + let expr = expr.trim(); + if let Some(open) = expr.find('[') + && let Some(stripped) = expr.strip_suffix(']') + { + let name = expr[..open].trim().to_string(); + let idx = crate::expr::eval(&stripped[open + 1..], vars)? + .parse::() + .ok()?; + return (!name.is_empty()).then_some((name, Some(idx))); + } + (!expr.is_empty() && expr.chars().all(|c| c.is_alphanumeric() || c == '_')) + .then(|| (expr.to_string(), None)) +} + /// Identificador sendo digitado antes do cursor (`column`, 1-based em `text`) — /// a corrida final de `[A-Za-z0-9_]`. Usado para filtrar o autocomplete. fn word_prefix(text: &str, column: i64) -> String { @@ -1123,6 +1200,35 @@ mod tests { assert_eq!(decode_array_ref(9), None); } + #[test] + fn parse_lvalue_scalar_and_element() { + let no_vars: Vec = vec![]; + assert_eq!(parse_lvalue("x", &no_vars), Some(("x".into(), None))); + assert_eq!( + parse_lvalue("arr[2]", &no_vars), + Some(("arr".into(), Some(2))) + ); + // Não é lvalue simples. + assert_eq!(parse_lvalue("x + 1", &no_vars), None); + assert_eq!(parse_lvalue("arr[", &no_vars), None); + assert_eq!(parse_lvalue("", &no_vars), None); + } + + #[test] + fn set_expression_forwards_element_edit() { + let mut s = Session::new(); + let out = s.handle(&req( + "setExpression", + &json!({ "expression": "arr[2]", "value": "9", "frameId": 1 }), + )); + assert!(has_command( + &out, + |c| matches!(c, Command::SetVariable { name, index, value, .. } + if name == "arr" && *index == Some(2) && *value == 9) + )); + assert_eq!(first_response(&out).body["value"], "9"); + } + #[test] fn word_prefix_extracts_trailing_identifier() { assert_eq!(word_prefix("hea", 4), "hea"); // cursor no fim From 10a49416580d284b1d2cc1adc9eda689e96eec7c Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:59:13 -0300 Subject: [PATCH 21/33] =?UTF-8?q?feat(debugger):=20readMemory=20(ver=20mem?= =?UTF-8?q?=C3=B3ria=20de=20dados=20crua)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona um canal request/response ao protocolo (Command::ReadMemory ↔ Event::MemoryData, correlacionados por id) — o primeiro caminho em que o plugin responde a uma consulta. Variáveis expõem memoryReference (frame:name[:index]); o plugin resolve o endereço e lê count bytes; o adaptador responde em base64. - protocolo: ReadMemory/MemoryData. - plugin: hook::read_memory (resolve endereço, lê cells, responde via evento). - adaptador: plugin_client.read_memory (envia e espera com timeout, via canal); variables com memoryReference; on_read_memory + Outgoing::ReadMemory resolvido no main (base64_encode próprio, sem dependência). Capability supportsReadMemoryRequest. Cobertura: base64 (RFC), parse do memoryReference. 94 testes; aarch64 ok. --- crates/dap-adapter/src/main.rs | 57 ++++++++++++++- crates/dap-adapter/src/plugin_client.rs | 54 +++++++++++++- crates/dap-adapter/src/session.rs | 96 +++++++++++++++++++++++-- crates/debug-plugin/src/bridge.rs | 8 +++ crates/debug-plugin/src/hook.rs | 57 +++++++++++++++ crates/protocol/src/lib.rs | 15 ++++ 6 files changed, 281 insertions(+), 6 deletions(-) diff --git a/crates/dap-adapter/src/main.rs b/crates/dap-adapter/src/main.rs index fb5cd41..cbb85f5 100644 --- a/crates/dap-adapter/src/main.rs +++ b/crates/dap-adapter/src/main.rs @@ -15,7 +15,7 @@ use std::io::{self, BufReader}; use std::process::Child; use std::sync::Arc; -use messages::Request; +use messages::{Request, Response}; use plugin_client::{DapOut, PluginClient}; use session::{Outgoing, Session, SpawnSpec}; @@ -62,6 +62,26 @@ fn main() -> io::Result<()> { c.send(&cmd); } } + Outgoing::ReadMemory { + seq, + address, + frame, + name, + index, + offset, + count, + } => { + // Leitura bloqueante no plugin (com timeout), então a resposta. + let bytes = plugin + .as_ref() + .and_then(|c| c.read_memory(frame, name, index, offset, count)) + .unwrap_or_default(); + let body = serde_json::json!({ + "address": address, + "data": base64_encode(&bytes), + }); + emit(&out, &Outgoing::Response(Response::ok(seq, &req, body))); + } } } if session.is_terminated() { @@ -173,6 +193,32 @@ fn libc_prctl_pdeathsig() { } } +/// Codifica bytes em base64 (alfabeto padrão) — o campo `data` do `readMemory` +/// do DAP é base64. Evita uma dependência externa para algo tão pequeno. +fn base64_encode(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b1 = u32::from(chunk[0]); + let b2 = u32::from(chunk.get(1).copied().unwrap_or(0)); + let b3 = u32::from(chunk.get(2).copied().unwrap_or(0)); + let n = (b1 << 16) | (b2 << 8) | b3; + out.push(A[(n >> 18 & 63) as usize] as char); + out.push(A[(n >> 12 & 63) as usize] as char); + out.push(if chunk.len() > 1 { + A[(n >> 6 & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + A[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + /// Escreve uma resposta/evento DAP gerado pelo `session` no stdout, usando o /// `DapOut` para serializar o `seq` de forma consistente com a thread do plugin. fn emit(out: &DapOut, outgoing: &Outgoing) { @@ -191,6 +237,15 @@ mod tests { use super::*; use std::sync::{Arc, Mutex}; + #[test] + fn base64_encode_matches_rfc() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"M"), "TQ=="); + assert_eq!(base64_encode(b"Ma"), "TWE="); + assert_eq!(base64_encode(b"Man"), "TWFu"); + assert_eq!(base64_encode(b"hello"), "aGVsbG8="); + } + /// Sink `Write` que acumula tudo num buffer compartilhado, para inspecionar /// o que o `DapOut` produziu. #[derive(Clone)] diff --git a/crates/dap-adapter/src/plugin_client.rs b/crates/dap-adapter/src/plugin_client.rs index c1b289d..7a44b4c 100644 --- a/crates/dap-adapter/src/plugin_client.rs +++ b/crates/dap-adapter/src/plugin_client.rs @@ -2,15 +2,26 @@ //! socket da sessão, envia [`Command`]s e recebe [`Event`]s do plugin numa //! thread, traduzindo-os em eventos DAP escritos no stdout. +use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, LazyLock, Mutex}; use std::thread; +use std::time::Duration; use interprocess::local_socket::traits::Stream as _; use pawnpro_dbg_protocol::transport::{self, LocalStream}; use pawnpro_dbg_protocol::{self as wire, Command, Event}; use serde_json::json; +/// Pedidos de leitura de memória pendentes (`id` → canal de resposta). A thread +/// leitora entrega os bytes do `MemoryData` ao chamador que espera no loop. +static PENDING_READS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +/// Contador de `id` de leitura de memória. +static READ_ID: AtomicU64 = AtomicU64::new(1); + /// Metade de envio do socket local — para enviar comandos ao plugin. type SendHalf = ::SendHalf; @@ -175,6 +186,14 @@ impl PluginClient { json!({ "category": "console", "output": format!("{text}\n") }), ); } + Ok(Event::MemoryData { id, bytes }) => { + // Entrega ao pedido de leitura que espera no loop principal. + if let Ok(mut p) = PENDING_READS.lock() + && let Some(tx) = p.remove(&id) + { + let _ = tx.send(bytes); + } + } Ok(Event::Exited) => { out.event("terminated", serde_json::Value::Null); break; @@ -187,6 +206,39 @@ impl PluginClient { client } + /// Lê `count` bytes de memória de dados a partir da variável `name` (elemento + /// `index`, se array) no `frame`, mais `offset`. Envia `ReadMemory` e bloqueia + /// (com timeout) esperando o `MemoryData` correlacionado. `None` no timeout. + #[must_use] + pub fn read_memory( + &self, + frame: usize, + name: String, + index: Option, + offset: i64, + count: usize, + ) -> Option> { + let id = READ_ID.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::channel(); + PENDING_READS.lock().ok()?.insert(id, tx); + self.send(&Command::ReadMemory { + id, + frame, + name, + index, + offset, + count, + }); + let result = rx.recv_timeout(Duration::from_secs(2)).ok(); + // Limpa o pendente se deu timeout (no sucesso a thread já removeu). + if result.is_none() + && let Ok(mut p) = PENDING_READS.lock() + { + p.remove(&id); + } + result + } + /// Envia um comando ao plugin. Se ainda não conectou, enfileira (será enviado /// assim que o socket abrir). pub fn send(&self, cmd: &Command) { diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index d62144c..33b0a59 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -25,6 +25,18 @@ pub enum Outgoing { ConnectPlugin(String), /// Encaminhar um comando ao plugin (breakpoints/continue/step). ToPlugin(Command), + /// Ler memória crua do plugin (bloqueia esperando a resposta) e responder o + /// `readMemory` do editor. Resolvido no `main` (o `session` é puro). `seq` é a + /// resposta já numerada; `address` é o texto que volta no campo `address`. + ReadMemory { + seq: i64, + address: String, + frame: usize, + name: String, + index: Option, + offset: i64, + count: usize, + }, } /// Comando do servidor a executar, mais as variáveis de depuração que o plugin lê. @@ -121,6 +133,7 @@ impl Session { "setDataBreakpoints" => self.on_set_data_breakpoints(req), "setExceptionBreakpoints" => self.on_set_exception_breakpoints(req), "completions" => self.on_completions(req), + "readMemory" => self.on_read_memory(req), "evaluate" => self.on_evaluate(req), "disconnect" | "terminate" => self.on_disconnect(req), "restart" => self.on_restart(req), @@ -169,6 +182,8 @@ impl Session { "supportsFunctionBreakpoints": true, // Autocomplete no watch/console: sugere variáveis em escopo. "supportsCompletionsRequest": true, + // Ler memória de dados crua (hex view) a partir de uma variável. + "supportsReadMemoryRequest": true, // Filtro de exceção: o editor liga/desliga a pausa em erros de runtime. "exceptionBreakpointFilters": [ { "filter": "runtime", "label": runtime_label, "default": true } @@ -493,20 +508,28 @@ impl Session { .unwrap_or(0); let vars: Vec = if let Some((frame, var_index)) = decode_array_ref(reference) { - // Elementos de um array (folhas, sem filhos). + // Elementos de um array (folhas). `memoryReference` = frame:arr:index. 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 }) + let mem = parse_elem_index(&c.name) + .map(|i| format!("{frame}:{}:{i}", arr.name)); + json!({ + "name": c.name, + "value": c.value, + "variablesReference": 0, + "memoryReference": mem, + }) }) .collect() }) .unwrap_or_default() } else { - // Escopo do frame: variáveis de topo; arrays viram expansíveis. + // Escopo do frame: variáveis de topo; arrays viram expansíveis. Cada + // uma expõe `memoryReference` (frame:name) para o `readMemory`. let frame = frame_index(req.arguments.get("variablesReference")); crate::plugin_client::frame_vars(frame) .iter() @@ -517,7 +540,12 @@ impl Session { } else { encode_array_ref(frame, i) }; - json!({ "name": v.name, "value": v.value, "variablesReference": child_ref }) + json!({ + "name": v.name, + "value": v.value, + "variablesReference": child_ref, + "memoryReference": format!("{frame}:{}", v.name), + }) }) .collect() }; @@ -750,6 +778,48 @@ impl Session { self.reply_with(req, Command::SetExceptionFilter { runtime }, Value::Null) } + /// `readMemory`: lê memória de dados crua a partir do `memoryReference` de uma + /// variável (`frame:name` ou `frame:name:index`, montado em `variables`). A + /// leitura em si (bloqueante, no plugin) é feita pelo `main` via + /// [`Outgoing::ReadMemory`]. + fn on_read_memory(&mut self, req: &Request) -> Vec { + let mem_ref = req + .arguments + .get("memoryReference") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let offset = req + .arguments + .get("offset") + .and_then(Value::as_i64) + .unwrap_or(0); + let count = req + .arguments + .get("count") + .and_then(Value::as_i64) + .and_then(|c| usize::try_from(c).ok()) + .unwrap_or(0); + + let seq = self.next_seq(); + let Some(DataWatch { frame, name, index }) = parse_data_id(&mem_ref) else { + return vec![Outgoing::Response(Response::fail( + seq, + req, + format!("memoryReference inválido: '{mem_ref}'"), + ))]; + }; + vec![Outgoing::ReadMemory { + seq, + address: mem_ref, + frame, + name, + index, + offset, + count, + }] + } + /// `completions`: autocomplete no watch/console. Sugere as variáveis em escopo /// no frame cujos nomes começam com o "pedaço" já digitado (o identificador /// antes do cursor). Sem prefixo, sugere todas. @@ -1200,6 +1270,24 @@ mod tests { assert_eq!(decode_array_ref(9), None); } + #[test] + fn read_memory_parses_reference() { + let mut s = Session::new(); + let out = s.handle(&req( + "readMemory", + &json!({ "memoryReference": "0:health", "offset": 2, "count": 8 }), + )); + assert!(out.iter().any(|o| matches!(o, + Outgoing::ReadMemory { frame: 0, name, index: None, offset: 2, count: 8, .. } + if name == "health"))); + // Referência inválida → resposta de falha. + let out = s.handle(&req( + "readMemory", + &json!({ "memoryReference": "lixo", "offset": 0, "count": 4 }), + )); + assert!(!first_response(&out).success); + } + #[test] fn parse_lvalue_scalar_and_element() { let no_vars: Vec = vec![]; diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index d6111c5..a1d9be8 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -184,5 +184,13 @@ fn apply(cmd: Command) { } Command::SetDataBreakpoints { watches } => crate::hook::set_data_breakpoints(watches), Command::SetExceptionFilter { runtime } => crate::hook::set_runtime_errors(runtime), + Command::ReadMemory { + id, + frame, + name, + index, + offset, + count, + } => crate::hook::read_memory(id, frame, &name, index, offset, count), } } diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index c0fa006..e4b68a4 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -321,6 +321,63 @@ pub fn set_breakpoints(bps: Vec) { } } +/// Lê `count` bytes da memória de dados a partir da variável `name` (elemento +/// `index`, se array) no `frame`, mais `offset`, e responde com um +/// `Event::MemoryData` correlacionado por `id`. Vazio se não resolver/ler. +pub fn read_memory( + id: u64, + frame: usize, + name: &str, + index: Option, + offset: i64, + count: usize, +) { + let bytes = read_memory_inner(frame, name, index, offset, count).unwrap_or_default(); + BRIDGE.send(&Event::MemoryData { id, bytes }); +} + +fn read_memory_inner( + frame: usize, + name: &str, + index: Option, + offset: i64, + count: usize, +) -> Option> { + let (amx_usize, frames) = PAUSE_CTX.lock().ok().and_then(|g| g.clone())?; + let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0); + let (cip, frm) = *frames.get(frame)?; + let guard = DBG.lock().ok()?; + let dbg = guard.as_ref()?; + let sym = dbg + .symbols_in_scope(cip) + .into_iter() + .find(|s| s.name == name)?; + let mut base = sym.effective_address(frm); + if let Some(i) = index { + if !sym.is_array() { + return None; + } + base = base.wrapping_add(i32::try_from(i).ok()?.wrapping_mul(4)); + } + let start = i32::try_from(i64::from(base) + offset).ok()?; + + // `read_cell` lê cells de 4 bytes alinhadas; alinha para baixo e pula o resto. + let aligned = start & !3; + let skip = usize::try_from(start - aligned).ok()?; + let mut out = Vec::with_capacity(skip + count); + let mut addr = aligned; + while out.len() < skip + count { + let Some(cell) = amx.read_cell(addr) else { + break; // endereço inacessível: devolve o que leu até aqui + }; + out.extend_from_slice(&cell.to_le_bytes()); + addr = addr.wrapping_add(4); + } + // Fatia [skip, skip+count) do que foi lido (pode ser menor no fim do segmento). + let end = (skip + count).min(out.len()); + Some(out.get(skip..end).unwrap_or(&[]).to_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. diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index d7859b4..2083cc0 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -78,6 +78,18 @@ pub enum Command { /// 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 }, + /// Lê memória de dados crua: `count` bytes a partir do endereço da variável + /// `name` (elemento `index`, se array) no frame `frame`, mais `offset`. O + /// plugin responde com um [`Event::MemoryData`] correlacionado por `id`. + ReadMemory { + id: u64, + frame: usize, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + index: Option, + offset: i64, + count: usize, + }, } /// Um data breakpoint pedido: a variável `name` em escopo no frame `frame` @@ -112,6 +124,9 @@ pub enum Event { Output { text: String }, /// O script terminou / a VM foi descarregada. Exited, + /// Resposta a um [`Command::ReadMemory`]: os `bytes` lidos, correlacionados + /// pelo `id` do pedido. Vazio se o endereço não pôde ser resolvido/lido. + MemoryData { id: u64, bytes: Vec }, } /// Um par variável→valor para a inspeção. Arrays trazem os elementos em From 7cadf61b04b48bac209391d73635267f63cd3579 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:06:58 -0300 Subject: [PATCH 22/33] =?UTF-8?q?refactor:=20enxugar=20coment=C3=A1rios=20?= =?UTF-8?q?redundantes=20nas=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O bloco de capabilities do initialize tinha um comentário por flag, traduzindo o nome dela. Mantidos só os que carregam informação não-óbvia (filtro de exceção e o motivo de não declarar supportsRestartRequest). Traduz também o único comentário em inglês do plugin, no hook. --- crates/dap-adapter/src/session.rs | 16 ++-------------- crates/debug-plugin/src/hook.rs | 4 ++-- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 33b0a59..9c1cea2 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -157,32 +157,20 @@ impl Session { .and_then(Value::as_str) .map_or_else(Locale::default, Locale::from_tag); let runtime_label = messages::msg(self.locale, MsgKey::RuntimeErrorsLabel); - // Capabilities mínimas da v1. + // Capabilities mínimas da v1. `supportsEvaluateForHovers` reaproveita o + // `evaluate` do painel INSPEÇÃO ao passar o mouse no código. let caps = json!({ "supportsConfigurationDoneRequest": true, "supportsTerminateRequest": true, - // Habilita avaliar variável ao passar o mouse no código (hover) — usa - // o mesmo `evaluate` do painel INSPEÇÃO (watch). "supportsEvaluateForHovers": true, - // Breakpoint condicional: o plugin avalia `var OP valor` e só pausa - // se verdadeiro. "supportsConditionalBreakpoints": true, - // Breakpoint por contagem de acertos (`5`, `>=3`, `%2`). "supportsHitConditionalBreakpoints": true, - // Logpoint: breakpoint que loga `msg com {var}` sem pausar. "supportsLogPoints": true, - // Editar variável no painel Variáveis durante a pausa. "supportsSetVariable": true, - // Editar via expressão (ex.: `arr[i]`) no watch/console. "supportsSetExpression": true, - // Data breakpoints: pausar quando uma variável muda de valor - // ("Break on Value Change" no painel Variáveis). "supportsDataBreakpoints": true, - // Breakpoints de função: parar ao entrar numa função por nome. "supportsFunctionBreakpoints": true, - // Autocomplete no watch/console: sugere variáveis em escopo. "supportsCompletionsRequest": true, - // Ler memória de dados crua (hex view) a partir de uma variável. "supportsReadMemoryRequest": true, // Filtro de exceção: o editor liga/desliga a pausa em erros de runtime. "exceptionBreakpointFilters": [ diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index e4b68a4..6576c61 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -183,8 +183,8 @@ fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&st Err(_) => (Vec::new(), Vec::new()), }; - // 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. + // Publica o contexto da pausa (cip/frm de cada frame) para a thread do socket + // editar variáveis no frame selecionado enquanto a VM está bloqueada abaixo. if let (Ok(mut guard), Some(ptr)) = (PAUSE_CTX.lock(), amx.amx()) { *guard = Some((ptr.as_ptr() as usize, ctx)); } From aba544d388e73bc2afda2aa1416093e482da3f17 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:06:59 -0300 Subject: [PATCH 23/33] =?UTF-8?q?docs:=20atualizar=20README=20e=20document?= =?UTF-8?q?a=C3=A7=C3=A3o=20para=20os=20recursos=20atuais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README com badges (CI, CodeQL, docs, release, licença), navegação para a documentação e tabela de recursos alinhada ao que a branch entrega. docs/features.md ganha as seções de breakpoints de função, expressões do watch/console, data breakpoints e leitura de memória; architecture.md descreve o canal request/response (ReadMemory/MemoryData); index.md e getting-started.md atualizados; CHANGELOG com a seção Não lançado. --- CHANGELOG.md | 19 +++++++++ README.md | 87 ++++++++++++++++++++++++++++------------- docs/architecture.md | 23 ++++++++--- docs/features.md | 63 +++++++++++++++++++++++++---- docs/getting-started.md | 13 +++++- docs/index.md | 11 +++--- 6 files changed, 169 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cfa4e..c167bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ Podem existir falhas ou itens não declarados, causados por falha humana ou por --- +## [Não lançado] + +### Adicionado +- **Call stack multi-frame** — o plugin caminha a cadeia de frames do AMX (FRM → endereço de retorno) e entrega nome da função, linha e variáveis de cada frame; o editor navega entre eles e a inspeção segue o frame selecionado. +- **Breakpoints de função** — parar ao entrar numa função pelo nome. O adaptador resolve o nome no bloco `AMX_DBG` e envia ao plugin a união dos breakpoints de linha e de função; nome que não resolve volta como não verificado. +- **Data breakpoints** — pausar quando um valor muda, em globais, locais e **elementos de array** (`arr[3]`). Watches de locais expiram quando o frame dono retorna, para não disparar com o lixo de outro frame no mesmo slot; endereço ilegível não gera disparo. +- **Inspeção rica de arrays e strings** — arrays são expansíveis (cada elemento vira um filho) e arrays de char são resumidos como **string** quando o conteúdo parece texto terminado em zero. +- **`setExpression`** — editar um lvalue (`x = 1`, `arr[i] = 10`) direto no watch ou no console, com o índice podendo ser uma subexpressão. O array inteiro não é editável, só os elementos. +- **`readMemory`** — hex view da memória de dados crua a partir de qualquer variável: cada variável passa a expor um `memoryReference`, e o adaptador devolve os bytes em base64 (encoder próprio, sem dependência nova). +- **Autocomplete** (`completions`) — sugestão de variáveis em escopo no watch e no console. +- **Filtro de exceção** — o editor liga e desliga a pausa em erros de runtime pelo painel de breakpoints. +- **Mais erros de runtime** — `STACKERR` (colisão pilha/heap), `HEAPLOW` (underflow de heap) e `MEMACCESS` (acesso inválido à memória), com simulação fiel ao `amx.c` e conservadora: o rastreio de `pri`/`alt`/`stk`/`hea` perde a confiança ao primeiro opcode não modelado, então nunca há falso-positivo. +- **Avaliador de expressões** para watch/hover — um operador de topo com `+ - * / %` (semântica de truncamento do Pawn) e `== != < > <= >=`, sobre literais, variáveis e `arr[i]`. + +### Alterado +- **Mensagens localizadas centralizadas** em `crates/protocol/src/messages` — `Locale`, as 11 `MsgKey` e uma tabela por idioma (pt-BR, en, es, ro, ru), compartilhadas pelo plugin e pelo adaptador. O `match` por chave é exaustivo: idioma incompleto não compila. +- **Primeiro canal request/response do protocolo** — `Command::ReadMemory` ↔ `Event::MemoryData`, correlacionados por `id` e com timeout, sem prender a sessão se o plugin não responder. +- Documentação reescrita: `README.md`, `docs/features.md`, `docs/architecture.md`, `docs/index.md` e a nova página de [localização](docs/i18n.md), com a meta de 50 idiomas. + ## [0.1.0] - 04/07/2026 Primeiro pré-lançamento (pre-release). diff --git a/README.md b/README.md index 383f6f0..b8672a7 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,80 @@ -# PawnPro Debugger +

PawnPro Debugger

-Debugger visual (DAP) para a linguagem **Pawn** (SA-MP / open.mp), integrado ao -[PawnPro](https://github.com/NullSablex/PawnPro), para um servidor local de -desenvolvimento. +

+ Debugger visual (DAP) para a linguagem Pawn (SA-MP / open.mp). +

+ +

+ CI + CodeQL + Docs + Release + Rust + Licença +

+ +

+ Documentação · + Começando · + Releases · + Extensão PawnPro +

+ +--- + +Depure código Pawn direto no editor, num servidor local de desenvolvimento: +breakpoints, step, inspeção e edição de variáveis, e **pausa na linha exata de um +erro de runtime** — antes de a VM abortar. Integrado à extensão +[PawnPro](https://github.com/NullSablex/PawnPro), que lança o adaptador e cuida do +ciclo (recompilar, subir o servidor, conectar). ## Recursos -| Recurso | Status | Detalhe | -|---------|:------:|---------| -| Breakpoints simples | ✅ | Por linha. | -| Breakpoints condicionais | ✅ | `var OP valor` (`==` `!=` `<` `>` `<=` `>=`); `int`/`Float:`/`bool:`/hex. | -| Hit count | ✅ | `N`, `==N`, `>=N`, `<=N`, `>N`, `=N`, `<=N`, `>N`, `` `<=` `>=`); `int`/`Float:`/`bool:`/hex. | | Hit count | :material-check: | `N`, `==N`, `>=N`, `<=N`, `>N`, `=N`, `<=N`, `>N`, ` <= >=`. + +O que não casar devolve "não avaliável", em vez de um valor inventado. + +Editar aceita as duas vias do DAP: `setVariable` (dar um valor novo no painel +**Variáveis**) e `setExpression` (`arr[i] = 10` no watch ou no console). No +`setExpression` o lado esquerdo é um **lvalue** — `nome` ou `nome[expr]`; o array +inteiro não é editável, só os elementos. + +## Data breakpoints + +"Break on Value Change" no painel **Variáveis**: o plugin guarda o último valor +observado e pausa quando ele muda. Cobre: + +- **globais** — endereço absoluto, o watch nunca expira; +- **locais** — endereço relativo ao frame; o watch **expira** quando o frame dono + retorna (não dispara com o lixo de outro frame no mesmo slot); +- **elementos de array** (`arr[3]`) — observados individualmente. + +Se o endereço não puder ser lido, o plugin é conservador: não inventa mudança. + +## Ler memória + +Toda variável exposta ao editor carrega um `memoryReference`, então o **hex view** +abre a partir dela e navega pela memória de dados crua (a região a partir do +endereço da variável ou do elemento, mais o `offset` pedido). É o único caminho do +protocolo que faz **request/response** com o plugin: o adaptador correlaciona o +pedido pela resposta e desiste no timeout, em vez de bloquear a sessão. + +A leitura respeita o alinhamento de células de 4 bytes da VM e devolve apenas o +que conseguiu ler — no fim do segmento, menos bytes do que o pedido. diff --git a/docs/getting-started.md b/docs/getting-started.md index ca4f035..9fcdf9e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -34,7 +34,12 @@ Copie o arquivo para a pasta correta do seu servidor: ## 3. Depurar Abra o `.pwn` do gamemode no editor (com a extensão PawnPro) e pressione **F5**. -Coloque breakpoints na margem e depure normalmente. +Coloque breakpoints na margem e depure normalmente. A extensão recompila o +gamemode com `-d3` (o bloco de debug com linhas e símbolos) antes de subir o +servidor — sem ele não há breakpoint nem inspeção. + +Enquanto a execução está pausada, o servidor fica **congelado**: é esperado num +servidor local de desenvolvimento. ### Exemplo de `launch.json` @@ -46,3 +51,9 @@ Coloque breakpoints na margem e depure normalmente. "program": "${workspaceFolder}/gamemodes/meugm.amx" } ``` + +## Próximo passo + +Veja [Recursos](features.md) para o que dá para fazer na pausa — call stack, +inspeção e edição de variáveis, data breakpoints, hex view da memória e a pausa +automática em [erros de runtime](runtime-errors.md). diff --git a/docs/index.md b/docs/index.md index 6e5e579..1a26cc3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,9 +4,10 @@ Debugger visual (DAP) para a linguagem **Pawn** (SA-MP / open.mp), integrado à extensão [PawnPro](https://github.com/NullSablex/PawnPro), para um servidor local de desenvolvimento. -Permite depurar código Pawn direto no editor: breakpoints (simples, condicionais -e por contagem de acertos), logpoints, step, inspeção e edição de variáveis, e -**pausar na linha de um erro de runtime** antes de a VM abortar — em SA-MP e +Permite depurar código Pawn direto no editor: breakpoints (simples, condicionais, +por contagem de acertos e de função), logpoints, step, call stack multi-frame, +inspeção e edição de variáveis, data breakpoints, leitura da memória crua e +**pausa na linha de um erro de runtime** antes de a VM abortar — em SA-MP e open.mp. ## Por onde começar @@ -15,5 +16,5 @@ open.mp. iniciar uma sessão de depuração. - **[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)**. +Para entender por dentro, veja **[Arquitetura](architecture.md)**, **[Como +funciona a pausa no erro](runtime-errors.md)** e **[Localização](i18n.md)**. From 5d8831b53ed255975dd6b9efe635c0dfe332e091 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:13:47 -0300 Subject: [PATCH 24/33] =?UTF-8?q?refactor:=20traduzir=20e=20enxugar=20os?= =?UTF-8?q?=20coment=C3=A1rios=20do=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hook.rs estava inteiramente comentado em inglês, destoando do resto do repo; plugin_client.rs, inspect.rs e langs/en.rs tinham pontos isolados. Todos traduzidos. Na passagem, corta o que repetia a assinatura ou já estava dito noutro lugar: as notas '&mut self porque...' no control.rs, a lista de parâmetros no doc de scan_line e a terceira repetição do argumento conservador. Corrige também o doc de resolve_data_watches, que ainda dizia que arrays não são observáveis — elementos de array passaram a ser em ed5a737. --- crates/dap-adapter/src/plugin_client.rs | 4 +- crates/debug-plugin/src/control.rs | 4 - crates/debug-plugin/src/hook.rs | 163 +++++++++++------------ crates/debug-plugin/src/inspect.rs | 2 +- crates/debug-plugin/src/runtime_error.rs | 13 +- crates/protocol/src/messages/langs/en.rs | 2 +- 6 files changed, 85 insertions(+), 103 deletions(-) diff --git a/crates/dap-adapter/src/plugin_client.rs b/crates/dap-adapter/src/plugin_client.rs index 7a44b4c..d93eb8e 100644 --- a/crates/dap-adapter/src/plugin_client.rs +++ b/crates/dap-adapter/src/plugin_client.rs @@ -170,8 +170,8 @@ impl PluginClient { "threadId": 1, "allThreadsStopped": true, }); - // Runtime error: `description`/`text` show the cause in the - // editor's call-stack header (reason "exception"). + // Erro de runtime: `description`/`text` mostram a causa + // no cabeçalho da call stack do editor (reason "exception"). if let Some(desc) = description { body["description"] = json!(desc); body["text"] = json!(desc); diff --git a/crates/debug-plugin/src/control.rs b/crates/debug-plugin/src/control.rs index 82530d5..d362da2 100644 --- a/crates/debug-plugin/src/control.rs +++ b/crates/debug-plugin/src/control.rs @@ -118,8 +118,6 @@ impl Controller { /// só é chamado se houver `condition`. A ordem segue o DAP: primeiro a /// condição lógica filtra, depois o acerto conta para o hit-count, e por fim /// `log_message` decide entre pausar e registrar. - /// - /// `&mut self` porque o contador de acertos do breakpoint é atualizado aqui. pub fn on_hit(&mut self, cip: u32, eval: impl FnOnce(&str) -> bool) -> BreakAction { let Some(bp) = self.breakpoints.iter_mut().find(|b| b.addr == cip) else { return BreakAction::None; @@ -155,8 +153,6 @@ impl Controller { /// (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, diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index 6576c61..e9e2295 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -1,14 +1,12 @@ -//! Debug-break handling — the decision layer the SDK now feeds. +//! Tratamento do debug break — a camada de decisão que o SDK alimenta. //! -//! The VM plumbing (installing the hook, reading `cip`/`frm`, bounds-checked -//! cell read/write) lives in the `samp` SDK: this module only decides whether to -//! pause at a given line and, on a pause, collects variables ([`inspect`]), -//! notifies the adapter ([`bridge`]) and **blocks** until continue/step -//! ([`gate`]). +//! O encanamento da VM (instalar o hook, ler `cip`/`frm`, ler/escrever células +//! com checagem de limites) fica no SDK `samp`; este módulo só decide se pausa +//! numa linha e, na pausa, coleta variáveis ([`inspect`]), avisa o adaptador +//! ([`bridge`]) e **bloqueia** até continuar/step ([`gate`]). //! -//! [`on_break`] is invoked from `SampPlugin::on_debug_break` (see `lib.rs`), -//! which the SDK wires up via `samp::plugin::enable_debug_hook`. No hand-written -//! `extern "C"` callback and no manual `*mut AMX` poking anymore. +//! [`on_break`] é chamado por `SampPlugin::on_debug_break` (ver `lib.rs`), que o +//! SDK liga via `samp::plugin::enable_debug_hook`. use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,30 +25,29 @@ 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. +/// Tamanho (bytes) de uma instrução AMX. No hook, o `cip` aponta para a célula +/// SEGUINTE ao `OP_BREAK`; voltamos isto para chegar ao endereço da linha. const BREAK_OP_SIZE: u32 = 4; -/// Execution control (breakpoints/step), shared with the TCP thread. +/// Controle de execução (breakpoints/step), compartilhado com a thread TCP. static STATE: Mutex = Mutex::new(Controller::new_const()); -/// Debug block of the `.amx` being debugged (loaded in the plugin's `on_load`). +/// Bloco de debug do `.amx` depurado (carregado no `on_load` do plugin). static DBG: 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). +/// Contexto da pausa ATUAL, válido só enquanto a VM está bloqueada em +/// [`on_pause`]. A thread do socket usa isto para atender comandos que precisam +/// da VM num frame específico (editar variável, ler memória). O ponteiro do +/// `amx` vai como `usize` para ser `Send` — a thread da VM está parada, então +/// ele continua válido durante a pausa. static PAUSE_CTX: Mutex> = Mutex::new(None); -/// Pause context: the paused `amx` pointer (as `usize`) plus each frame's -/// `(cip, frm)`, index 0 = top. +/// Ponteiro do `amx` pausado (como `usize`) e o `(cip, frm)` de cada frame, +/// índice 0 = topo (onde a VM parou). 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 -/// non-relocated image). Built once per VM at load. +/// Mapa de opcodes da VM carregada, para detectar erro de runtime antes do +/// abort. `None` até `load_opcode_map` rodar; numa imagem não-relocada o mapa é +/// efetivamente identidade. Montado uma vez por VM, na carga. static OPCODE_MAP: Mutex> = Mutex::new(None); /// Idioma das mensagens de erro (resolvido do locale do editor, via env var). @@ -74,39 +71,37 @@ pub fn set_locale(locale: Locale) { } } -/// Reads cells through the SDK's bounds-checked `Amx::read_cell`, which mirrors -/// `amx_GetAddr`. Lets [`inspect::collect`] stay decoupled from the SDK and -/// testable with a fake reader. +/// Lê células pelo `Amx::read_cell` do SDK (com checagem de limites, espelhando +/// `amx_GetAddr`). Mantém [`inspect::collect`] desacoplado do SDK e testável com +/// um leitor falso. impl CellReader for Amx { fn read_cell(&self, data_addr: i32) -> Option { Amx::read_cell(self, data_addr) } } -/// Handles a debug break: reads `cip`/`frm` from the VM and decides the pause -/// reason. Breakpoints (with an optional condition) take priority over step. No -/// panic crosses back into the SDK (the trampoline catches it anyway); locks are -/// taken with `if let Ok`. +/// Trata um debug break: lê `cip`/`frm` da VM e decide o motivo da pausa. Nenhum +/// panic atravessa de volta para o SDK — os locks são tomados com `if let Ok`. /// -/// Called from `SampPlugin::on_debug_break`. +/// Chamado por `SampPlugin::on_debug_break`. pub fn on_break(amx: &Amx) { let (Some(raw_cip), Some(frm)) = (amx.cip(), amx.frame()) else { return; }; - // In the debug hook `cip` already pointed to the instruction AFTER the - // `OP_BREAK` (the ip advanced one 4-byte cell). The line/breakpoint table - // uses the address of the break itself, so we step back 4 to match. + // No hook, o `cip` já aponta para a instrução DEPOIS do `OP_BREAK` (o ip + // avançou uma célula de 4 bytes). A tabela de linhas/breakpoints usa o + // endereço do próprio break: voltamos 4 para bater. let cip = raw_cip.wrapping_sub(BREAK_OP_SIZE); - // Runtime-error detection takes priority over breakpoint/step: if the NEXT - // 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`). + // Erro de runtime tem prioridade sobre breakpoint/step: se a PRÓXIMA + // instrução (`raw_cip`) for abortar a VM, pausa agora com reason + // "exception" — o ABORT da VM retornaria sem nos chamar de novo. A linha + // mostrada continua sendo a do break atual (`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 + ctrl.hit_breakpoint(); // limpa step pendente e marca como iniciado } let locale = LOCALE.lock().map(|g| *g).unwrap_or_default(); on_pause(amx, cip, frm, "exception", Some(err.message(locale))); @@ -126,15 +121,15 @@ pub fn on_break(amx: &Amx) { let reason = { let Ok(mut ctrl) = STATE.lock() else { return }; - // Breakpoint decision (condition + hit-count + logpoint) in one place. - // The condition is evaluated lazily against the in-scope variables. + // Decisão do breakpoint (condição + hit-count + logpoint) num só lugar; + // a condição é avaliada preguiçosamente contra as variáveis em escopo. match ctrl.on_hit(cip, |expr| eval_breakpoint_condition(amx, cip, frm, expr)) { BreakAction::Pause => { ctrl.hit_breakpoint(); Some(StopReason::Breakpoint) } - // Logpoint: emit the (interpolated) message and keep running, but a - // pending step can still stop us this line. + // Logpoint: emite a mensagem interpolada e segue — mas um step + // pendente ainda pode parar nesta linha. BreakAction::Log(template) => { emit_logpoint(amx, cip, frm, &template); ctrl.should_stop(cip, frm) @@ -147,9 +142,8 @@ pub fn on_break(amx: &Amx) { } } -/// Interpolates a logpoint message with the in-scope variables and sends it to the -/// adapter as an `Output` event (no pause). Mirrors the variable lookup used by -/// breakpoint conditions. +/// Interpola a mensagem de um logpoint com as variáveis em escopo e a envia ao +/// adaptador como um evento `Output`, sem pausar. fn emit_logpoint(amx: &Amx, cip: u32, frm: i32, template: &str) { let Ok(guard) = DBG.lock() else { return }; let Some(dbg) = guard.as_ref() else { return }; @@ -172,8 +166,8 @@ 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). +/// Pausa: coleta as variáveis em escopo, avisa o adaptador e bloqueia até +/// continuar/step. Roda na thread da VM (o servidor congela — esperado em dev). fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&str>) { let (frames, ctx) = match DBG.lock() { Ok(guard) => match guard.as_ref() { @@ -195,12 +189,11 @@ fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&st description: description.map(str::to_string), }); - // Block until the adapter sends continue/step; apply the action to the - // controller. + // Bloqueia até o adaptador mandar continuar/step. let action = BRIDGE.wait_resume(); - // Leaving the pause: invalidate the context (the VM resumes and the pointers - // no longer hold). + // Saindo da pausa: invalida o contexto (a VM retoma e os ponteiros não + // valem mais). if let Ok(mut ctx) = PAUSE_CTX.lock() { *ctx = None; } @@ -209,16 +202,15 @@ fn on_pause(amx: &Amx, cip: u32, frm: i32, reason: &str, description: Option<&st Resume::Continue => ctrl.resume(), Resume::Step(mode) => ctrl.request_step(mode, frm), } - // `Run` is the post-continue state; the step was already armed above. + // `Run` é o estado pós-continue; o step já foi armado acima. let _ = StepMode::Run; } } -/// 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. +/// Monta a call stack da pausa: caminha a cadeia de frames do AMX e, para cada +/// um, resolve nome da função e linha no bloco de debug e coleta as variáveis em +/// escopo ali. Devolve os frames do protocolo e os contextos `(cip, frm)` na +/// mesma ordem, para [`set_variable`] e [`read_memory`] mirarem o frame escolhido. 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)); @@ -233,18 +225,18 @@ fn build_frames(dbg: &AmxDbg, amx: &Amx, cip: u32, frm: i32) -> (Vec, Vec (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`. +/// Avalia a condição de um breakpoint contra as variáveis em escopo no `cip`/`frm` +/// atual. `true` = a condição vale (deve pausar). Conservador: o que não puder ser +/// avaliado também dá `true`, para não engolir o breakpoint. fn eval_breakpoint_condition(amx: &Amx, cip: u32, frm: i32, expr: &str) -> bool { let Ok(guard) = DBG.lock() else { return true }; let Some(dbg) = guard.as_ref() else { return true; }; let vars = inspect::collect(dbg, amx, cip, frm); - // Resolve a variable name to its ALREADY FORMATTED value (e.g. "96.5", - // "true", "12"); `eval_condition` reinterprets it by type. Arrays (value - // "[...]") do not match as a literal → conservative condition. + // Resolve o nome para o valor JÁ FORMATADO (ex.: "96.5", "true", "12"), que + // `eval_condition` reinterpreta por tipo. Array (valor "[...]") não casa como + // literal → cai no caminho conservador. let lookup = |name: &str| -> Option { vars.iter() .find(|v| v.name == name) @@ -253,15 +245,15 @@ fn eval_breakpoint_condition(amx: &Amx, cip: u32, frm: i32, expr: &str) -> bool eval_condition(expr, &lookup) } -/// Loads the debug block used by inspection (call in the plugin's `on_load`). +/// Carrega o bloco de debug usado pela inspeção (chamar no `on_load` do plugin). pub fn load_debug(dbg: AmxDbg) { if let Ok(mut guard) = DBG.lock() { *guard = Some(dbg); } } -/// Builds this VM's opcode map (inverse of `amx_opcodelist` for a relocated -/// image) for runtime-error detection. Call once per VM in `on_amx_load`. +/// Monta o mapa de opcodes desta VM (inverso de `amx_opcodelist` numa imagem +/// relocada), para a detecção de erro de runtime. Uma vez por VM, no `on_amx_load`. pub fn load_opcode_map(amx: &Amx) { let map = OpcodeMap::new(amx.opcode_table(OP_NUM_OPCODES)); if let Ok(mut guard) = OPCODE_MAP.lock() { @@ -269,10 +261,10 @@ pub fn load_opcode_map(amx: &Amx) { } } -/// Scans the source line starting at `at` (a code-segment offset, the first -/// instruction after the `OP_BREAK`) and checks whether any instruction will -/// abort the VM. Simulates `pri`/`alt` from their real values at the break, since -/// the faulting instruction sits mid-line. `None` = safe / undecodable. +/// Varre a linha-fonte a partir de `at` (offset de código: a primeira instrução +/// depois do `OP_BREAK`) e checa se alguma instrução vai abortar a VM. Simula +/// `pri`/`alt` a partir dos valores reais no break, já que a instrução que falha +/// fica no meio da linha. `None` = segura ou indecodificável. fn detect_runtime_error(amx: &Amx, at: u32) -> Option { let guard = OPCODE_MAP.lock().ok()?; let map = guard.as_ref()?; @@ -307,8 +299,8 @@ fn check_data_watch(amx: &Amx, cip: u32, frm: i32) -> Option { .check_data_watches(|a| amx.read_cell(a), |f| live.contains(&f)) } -/// Updates the breakpoints (address + optional condition) resolved by the -/// adapter. +/// Atualiza os breakpoints (endereço + condição opcional) resolvidos pelo +/// adaptador. pub fn set_breakpoints(bps: Vec) { if let Ok(mut ctrl) = STATE.lock() { ctrl.set_breakpoints(bps.into_iter().map(|b| Bp { @@ -391,8 +383,8 @@ pub fn set_data_breakpoints(reqs: Vec) { /// 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). +/// Usa o contexto da pausa atual ([`PAUSE_CTX`]) e o bloco de debug. Um array só +/// é observável por um elemento (`index`); pedidos que não resolvem são ignorados. 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(); @@ -445,13 +437,12 @@ fn resolve_data_watches(reqs: Vec) -> Vec, value: i32) -> Option { let (amx_usize, cip, frm) = { @@ -460,9 +451,9 @@ pub fn set_variable(frame: usize, name: &str, index: Option, value: i32) 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). + // Reconstrói um `Amx` sobre o ponteiro da VM pausada. `write_cell` lê o + // base/data segment direto da struct AMX, então a tabela de funções não é + // necessária aqui (0 serve). let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0); let guard = DBG.lock().ok()?; diff --git a/crates/debug-plugin/src/inspect.rs b/crates/debug-plugin/src/inspect.rs index d296c7b..7099de7 100644 --- a/crates/debug-plugin/src/inspect.rs +++ b/crates/debug-plugin/src/inspect.rs @@ -19,7 +19,7 @@ pub trait CellReader { pub fn collect(dbg: &AmxDbg, reader: &impl CellReader, cip: u32, frm: i32) -> Vec { let mut out = Vec::new(); for sym in dbg.symbols_in_scope(cip) { - // Effective data-segment address (global vs frame-relative) via the SDK. + // Endereço efetivo no data segment (global ou relativo ao frame), via SDK. let addr = sym.effective_address(frm); out.push(if sym.is_array() { build_array(sym, addr, reader, dbg) diff --git a/crates/debug-plugin/src/runtime_error.rs b/crates/debug-plugin/src/runtime_error.rs index 2c2cdc4..ef9c63b 100644 --- a/crates/debug-plugin/src/runtime_error.rs +++ b/crates/debug-plugin/src/runtime_error.rs @@ -200,17 +200,12 @@ fn is_control_barrier(op: i32) -> bool { /// registradores a partir do estado real no break, até detectar um erro de runtime /// ou chegar ao fim da linha. /// -/// - `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. +/// Os registradores vêm do estado real no break; `stk`/`hea` são rastreados ao +/// longo da linha e checados com as MESMAS condições do `amx.c` +/// (`CHKMARGIN`/`CHKHEAP`/`VERIFYADDRESS`). /// /// 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. +/// decodifica. Ver [`is_control_barrier`] para quando as checagens se desligam. #[expect(clippy::too_many_arguments, clippy::too_many_lines)] #[must_use] pub fn scan_line( diff --git a/crates/protocol/src/messages/langs/en.rs b/crates/protocol/src/messages/langs/en.rs index 8e1106d..9ee4719 100644 --- a/crates/protocol/src/messages/langs/en.rs +++ b/crates/protocol/src/messages/langs/en.rs @@ -1,4 +1,4 @@ -//! English (en) — the source language / fallback. +//! Inglês (en) — idioma-fonte e fallback dos demais. use crate::messages::MsgKey; From 01a8fdf06965495d0527df8cfb782fcea8780e31 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:13:48 -0300 Subject: [PATCH 25/33] docs: completar os opcodes de STACKERR e MEMACCESS A tabela omitia OP_CALL_PRI no STACKERR e OP_LODB_I/OP_STRB_I/OP_LIDX_B no MEMACCESS, todos checados em runtime_error.rs. --- docs/runtime-errors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runtime-errors.md b/docs/runtime-errors.md index 13c9085..af4be35 100644 --- a/docs/runtime-errors.md +++ b/docs/runtime-errors.md @@ -47,9 +47,9 @@ código-fonte de nenhum dos dois, porque ambos usam a mesma VM AMX. | 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) | +| Colisão pilha/heap (`STACKERR`) | `OP_STACK` / `OP_HEAP` / `OP_PROC` / `OP_CALL` / `OP_CALL_PRI` (`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` | +| Acesso inválido à memória (`MEMACCESS`) | `OP_LOAD_I` / `OP_LODB_I` / `OP_STOR_I` / `OP_STRB_I` / `OP_LIDX` / `OP_LIDX_B` (`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. From 69871d93a6cd31683cd66bf65cee0ca91ac672e6 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:21:34 -0300 Subject: [PATCH 26/33] docs: badges de downloads, stars e OpenSSF Scorecard no README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona também .github/workflows/scorecard.yml: sem ele o badge do Scorecard não tem o que exibir, porque a API pública do OpenSSF só conhece repositórios que publicam resultados (publish_results, em push no branch padrão). O workflow segue o padrão dos outros: actions pinadas por SHA, permissões mínimas por job e execução semanal além do push no master. O SARIF também vai para o code scanning. --- .github/workflows/scorecard.yml | 56 +++++++++++++++++++++++++++++++++ README.md | 3 ++ 2 files changed, 59 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..38d13e3 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,56 @@ +# OpenSSF Scorecard. +# +# Scores the repository's supply-chain practices (branch protection, pinned +# actions, token permissions, dependency update tooling, …) and uploads the +# result twice: as SARIF to code scanning, and — via `publish_results` — to the +# public OpenSSF API, which is what backs the Scorecard badge in the README. +# +# `publish_results` only works on a push to the default branch of a public +# repository; on a pull request the workflow still runs and reports to code +# scanning, without publishing. +name: Scorecard + +on: + push: + branches: [ "master" ] + # Weekly, so the score keeps up with the checks Scorecard adds over time. + schedule: + - cron: '31 4 * * 1' + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write # upload the SARIF results + id-token: write # OIDC token, required to publish the results + contents: read + actions: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + # Kept for 5 days so a failed upload can still be inspected. + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md index b8672a7..ee4d9e6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,10 @@ CI CodeQL Docs + OpenSSF Scorecard Release + Downloads + Stars Rust Licença

From 720bc0c2bd19cfd6c662f0622fc5567920dbdb10 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:28:51 -0300 Subject: [PATCH 27/33] docs(i18n): site em pt-BR e en-US com mkdocs-static-i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona o plugin mkdocs-static-i18n (docs_structure: suffix), com pt-BR como idioma padrão na raiz e en-US em /en-US/. As seis páginas foram traduzidas, e o nav tem nav_translations; fallback_to_default cobre páginas futuras que ainda não tenham versão traduzida. O tema do locale en-US aponta para 'en': o Material só traz tabela de interface para 'en', e 'en-US' quebra o build (TemplateNotFound em partials/languages/en-US.html). requirements.in/txt recompilados com hashes. Validado como no CI: venv limpo, pip install --require-hashes e mkdocs build --strict. --- README.md | 1 + docs/architecture.en-US.md | 67 +++++++++++++++++++++++++ docs/features.en-US.md | 92 +++++++++++++++++++++++++++++++++++ docs/getting-started.en-US.md | 60 +++++++++++++++++++++++ docs/i18n.en-US.md | 86 ++++++++++++++++++++++++++++++++ docs/index.en-US.md | 20 ++++++++ docs/requirements.in | 2 + docs/requirements.txt | 20 +++++--- docs/runtime-errors.en-US.md | 74 ++++++++++++++++++++++++++++ mkdocs.yml | 30 ++++++++++++ 10 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 docs/architecture.en-US.md create mode 100644 docs/features.en-US.md create mode 100644 docs/getting-started.en-US.md create mode 100644 docs/i18n.en-US.md create mode 100644 docs/index.en-US.md create mode 100644 docs/runtime-errors.en-US.md diff --git a/README.md b/README.md index ee4d9e6..fc0a328 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@

Documentação · + English · Começando · Releases · Extensão PawnPro diff --git a/docs/architecture.en-US.md b/docs/architecture.en-US.md new file mode 100644 index 0000000..81ae2d9 --- /dev/null +++ b/docs/architecture.en-US.md @@ -0,0 +1,67 @@ +# Architecture + +The debugger is a Cargo workspace with three crates, plus the SDK as a +dependency. + +``` +editor (VS Code, DAP) + │ Debug Adapter Protocol (stdio) + ▼ +dap-adapter ──launches──► SA-MP/open.mp server + │ own protocol (NDJSON / local socket) │ loads + └─────────────────────────────────────────────┤ + ▼ + debug-plugin (inside the VM) +``` + +## Crates + +| Crate | Kind | Role | +|-------|------|------| +| `protocol` | `lib` | Shared types for the plugin ↔ adapter IPC (commands and events as NDJSON over a local socket) and the [localized messages](i18n.md). | +| `debug-plugin` | `cdylib` | Loaded by the server. Installs the debug hook, decides when to pause (breakpoint/step/data breakpoint/error), walks the stack, collects variables, reads and writes data memory, and blocks the VM until the editor says continue. | +| `dap-adapter` | `bin` | Translates DAP ↔ the own protocol. Launches the server as a child process (which dies with it), relays breakpoints and events, and evaluates watch/console expressions. | + +## Shared SDK + +The parser for the `AMX_DBG` format (address ↔ line ↔ symbol ↔ function) and the +VM primitives come from the [`rust-samp`](https://rust-samp.nullsablex.com/) SDK: + +- `samp::debug` / `samp_sdk::debug` — parser for the debug block. +- `Amx::cip/frame/stack/heap/stp/pri/alt` — the VM registers. +- `Amx::read_cell/write_cell` — data (inspecting and editing variables). +- `Amx::read_code` / `Amx::opcode_table` — code (decoding opcodes; see + [Pausing on an error](runtime-errors.md)). + +Using the SDK as the single source avoids duplicating the parser between plugin +and adapter (the adapter depends on `rust-samp-sdk` with `default-features = +false, features = ["debug"]` — the pure logic only, no FFI). + +## The flow of a pause + +1. The VM calls the debug hook on every line (`.amx` compiled with `-d3`). +2. The plugin decides whether to pause (breakpoint/condition/hit-count/step/data + breakpoint/runtime error). +3. It collects the in-scope variables and the stack frames, and sends an event to + the adapter. +4. It **blocks** the VM (the server freezes — expected in development) until the + editor says continue/step. + +## Direction of messages + +The protocol is asynchronous both ways: the adapter sends **commands** +(breakpoints, step, continue, edit a variable) and the plugin sends **events** +(pause, log, exit). Nothing waits for a reply — except one path: + +`Command::ReadMemory` ↔ `Event::MemoryData` is a **request/response** pair, +correlated by a sequential `id`. The adapter registers the pending request, the +socket reader thread hands the bytes to the caller waiting for them, and a +**timeout** drops the pending entry if the plugin never answers — the session +never gets stuck. + +## Compilation + +- **Plugin** → the server's architecture (SA-MP/open.mp are 32-bit → + `i686-unknown-linux-gnu`). +- **Adapter** → the host's architecture (where the editor runs). +- Edition **2024**, `resolver = "3"`. diff --git a/docs/features.en-US.md b/docs/features.en-US.md new file mode 100644 index 0000000..a04c795 --- /dev/null +++ b/docs/features.en-US.md @@ -0,0 +1,92 @@ +# Features + +| Feature | Status | Detail | +|---------|:------:|--------| +| Plain breakpoints | :material-check: | By line. | +| Conditional breakpoints | :material-check: | `var OP value` (`==` `!=` `<` `>` `<=` `>=`); `int`/`Float:`/`bool:`/hex. | +| Hit count | :material-check: | `N`, `==N`, `>=N`, `<=N`, `>N`, `=N`, +`<=N`, `>N`, ` <= >=`. + +Anything that does not match returns "not evaluable" instead of an invented +value. + +Editing supports both DAP routes: `setVariable` (typing a new value in the +**Variables** panel) and `setExpression` (`arr[i] = 10` in the watch panel or the +console). In `setExpression` the left-hand side is an **lvalue** — `name` or +`name[expr]`; a whole array is not editable, only its elements. + +## Data breakpoints + +"Break on Value Change" in the **Variables** panel: the plugin keeps the last +observed value and pauses when it changes. It covers: + +- **globals** — absolute address, the watch never expires; +- **locals** — frame-relative address; the watch **expires** when the owning + frame returns (so it will not fire on another frame's leftovers in the same + slot); +- **array elements** (`arr[3]`) — watched individually. + +If the address cannot be read, the plugin stays conservative: it does not invent +a change. + +## Reading memory + +Every variable exposed to the editor carries a `memoryReference`, so the **hex +view** opens from it and browses raw data memory (the region starting at the +variable's or element's address, plus the requested `offset`). It is the only +path in the protocol that does **request/response** with the plugin: the adapter +correlates the request with its reply and gives up on a timeout instead of +blocking the session. + +Reads honor the VM's 4-byte cell alignment and return only what could actually be +read — at the end of the segment, fewer bytes than requested. diff --git a/docs/getting-started.en-US.md b/docs/getting-started.en-US.md new file mode 100644 index 0000000..19af2ee --- /dev/null +++ b/docs/getting-started.en-US.md @@ -0,0 +1,60 @@ +# Getting started + +To debug you need two things: the **PawnPro** extension in your editor and the +debugger **plugin** in your server. The extension handles everything when a +session starts (recompiling, launching the server, connecting) — the only manual +step is putting the plugin in the server, **once**. + +## 1. Download the plugin + +Grab the plugin binary from the [releases page][releases]: + +- **Linux:** `pawnpro_debug.so` +- **Windows:** `pawnpro_debug.dll` + +[releases]: https://github.com/NullSablex/PawnPro-Debugger/releases + +## 2. Put it in the server + +Copy the file into the right folder of your server: + +- **SA-MP:** `plugins/pawnpro_debug.so`, and add `pawnpro_debug` to the `plugins` + line of `server.cfg`. +- **open.mp:** `components/pawnpro_debug.so`. + +!!! warning "Do not rename the file" + The name must be **`pawnpro_debug`** (`.so` on Linux, `.dll` on Windows). The + extension looks for the plugin by exactly that name; under any other name, + debugging will not start. + +!!! note "The extension only checks" + On startup the extension verifies that the plugin is present and warns if it + is missing — it does **not** install anything into your server. + +## 3. Debug + +Open your gamemode's `.pwn` in the editor (with the PawnPro extension) and press +**F5**. Set breakpoints in the gutter and debug as usual. The extension +recompiles the gamemode with `-d3` (the debug block, carrying lines and symbols) +before launching the server — without it there are no breakpoints and no +inspection. + +While execution is paused the server is **frozen**: expected on a local +development server. + +### Example `launch.json` + +```json +{ + "type": "pawn", + "request": "launch", + "name": "Debug gamemode", + "program": "${workspaceFolder}/gamemodes/mygm.amx" +} +``` + +## Next step + +See [Features](features.md) for what you can do while paused — call stack, +inspecting and editing variables, data breakpoints, the memory hex view and +automatic pausing on [runtime errors](runtime-errors.md). diff --git a/docs/i18n.en-US.md b/docs/i18n.en-US.md new file mode 100644 index 0000000..d787b24 --- /dev/null +++ b/docs/i18n.en-US.md @@ -0,0 +1,86 @@ +# Localization (i18n) + +The debugger's user-facing messages (runtime errors and the adapter's replies to +the editor) live in **one place**, shared by the plugin and the adapter: + +| Where | What | +|-------|------| +| `crates/protocol/src/messages/mod.rs` | `Locale`, `MsgKey` (the keys) and the routing (`msg`/`format`) | +| `crates/protocol/src/messages/langs/.rs` | one `match` table per language: `get(MsgKey) -> &'static str` | + +The texts are **templates** with positional `{}` markers, filled in by +`messages::format(locale, key, &[args])`. The language comes from the editor (the +`locale` argument of `initialize`, in the adapter; the `PAWNPRO_DBG_LOCALE` env +var, in the plugin) and is resolved by prefix (`Locale::from_tag`); an unknown one +falls back to **English**. + +There are **11 message keys** (5 runtime errors + 6 from the adapter). Each +language's `match` on `MsgKey` is **exhaustive** — a missing key does not compile. +That is why a language marked ✅ necessarily covers all 11. + +**Legend:** ✅ complete · 🟡 partial · ⬜ missing. + +> Coverage goal: at least **50 languages**. The order prioritizes historical +> relevance in the SA-MP / open.mp community. Today there are **5** implemented. + +## Status + +| # | Language | Code | Status | +|---|----------|------|:------:| +| 1 | English (source) | `en` | ✅ | +| 2 | Portuguese (BR) | `pt-BR` | ✅ | +| 3 | Spanish | `es` | ✅ | +| 4 | Russian | `ru` | ✅ | +| 5 | Romanian | `ro` | ✅ | +| 6 | German | `de` | ⬜ | +| 7 | French | `fr` | ⬜ | +| 8 | Italian | `it` | ⬜ | +| 9 | Polish | `pl` | ⬜ | +| 10 | Turkish | `tr` | ⬜ | +| 11 | Dutch | `nl` | ⬜ | +| 12 | Ukrainian | `uk` | ⬜ | +| 13 | Chinese (Simpl.) | `zh-CN` | ⬜ | +| 14 | Chinese (Trad.) | `zh-TW` | ⬜ | +| 15 | Indonesian | `id` | ⬜ | +| 16 | Arabic | `ar` | ⬜ | +| 17 | Portuguese (PT) | `pt-PT` | ⬜ | +| 18 | Hungarian | `hu` | ⬜ | +| 19 | Czech | `cs` | ⬜ | +| 20 | Serbian | `sr` | ⬜ | +| 21 | Bulgarian | `bg` | ⬜ | +| 22 | Greek | `el` | ⬜ | +| 23 | Swedish | `sv` | ⬜ | +| 24 | Lithuanian | `lt` | ⬜ | +| 25 | Croatian | `hr` | ⬜ | +| 26 | Slovak | `sk` | ⬜ | +| 27 | Hebrew | `he` | ⬜ | +| 28 | Thai | `th` | ⬜ | +| 29 | Vietnamese | `vi` | ⬜ | +| 30 | Persian | `fa` | ⬜ | +| 31 | Japanese | `ja` | ⬜ | +| 32 | Korean | `ko` | ⬜ | +| 33 | Finnish | `fi` | ⬜ | +| 34 | Danish | `da` | ⬜ | +| 35 | Norwegian | `nb` | ⬜ | +| 36 | Hindi | `hi` | ⬜ | +| 37 | Bengali | `bn` | ⬜ | +| 38 | Filipino | `fil` | ⬜ | +| 39 | Malay | `ms` | ⬜ | +| 40 | Latvian | `lv` | ⬜ | +| 41 | Estonian | `et` | ⬜ | +| 42 | Slovenian | `sl` | ⬜ | +| 43 | Belarusian | `be` | ⬜ | +| 44 | Macedonian | `mk` | ⬜ | +| 45 | Albanian | `sq` | ⬜ | +| 46 | Bosnian | `bs` | ⬜ | +| 47 | Catalan | `ca` | ⬜ | +| 48 | Azerbaijani | `az` | ⬜ | +| 49 | Kazakh | `kk` | ⬜ | +| 50 | Georgian | `ka` | ⬜ | + +## Adding a language + +1. Create `crates/protocol/src/messages/langs/.rs` with `pub fn get(key: MsgKey) -> &'static str` covering **every** `MsgKey` (keeping the `{}` markers in the same logical position). +2. Register the module in `langs/mod.rs` (`pub mod ;`). +3. Add the variant to the `Locale` enum, to `Locale::from_tag` (by prefix) and to the `match` in `messages::msg`. +4. Mark it ✅ in this table. diff --git a/docs/index.en-US.md b/docs/index.en-US.md new file mode 100644 index 0000000..6911232 --- /dev/null +++ b/docs/index.en-US.md @@ -0,0 +1,20 @@ +# PawnPro Debugger + +Visual debugger (DAP) for the **Pawn** language (SA-MP / open.mp), integrated +with the [PawnPro](https://github.com/NullSablex/PawnPro) extension, for a local +development server. + +It lets you debug Pawn code straight from the editor: breakpoints (plain, +conditional, hit-count and function), logpoints, stepping, a multi-frame call +stack, variable inspection and editing, data breakpoints, raw memory reads and +**pausing on the line of a runtime error** before the VM aborts — on both SA-MP +and open.mp. + +## Where to start + +- **[Getting started](getting-started.md)** — download the plugin, drop it into + your server and start a debugging session. +- **[Features](features.md)** — what the debugger does. + +For the internals, see **[Architecture](architecture.md)**, **[How pausing on an +error works](runtime-errors.md)** and **[Localization](i18n.md)**. diff --git a/docs/requirements.in b/docs/requirements.in index 5c812f1..1cde508 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -3,3 +3,5 @@ # with: # pip-compile --generate-hashes --output-file=docs/requirements.txt docs/requirements.in mkdocs-material==9.7.7 +# Multi-language docs (pt-BR default, en-US); see mkdocs.yml `plugins.i18n`. +mkdocs-static-i18n==1.3.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index 612805f..b5e3ba4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,9 @@ -# Build dependencies for the MkDocs Material documentation site, pinned by hash. -# Regenerate when bumping: -# pip-compile --generate-hashes --output-file=docs/requirements.txt docs/requirements.in -# Install in CI: pip install --require-hashes -r docs/requirements.txt +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --generate-hashes --no-index --output-file=docs/requirements.txt docs/requirements.in +# babel==2.18.0 \ --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 @@ -280,7 +282,9 @@ mergedeep==1.3.4 \ mkdocs==1.6.1 \ --hash=sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2 \ --hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e - # via mkdocs-material + # via + # mkdocs-material + # mkdocs-static-i18n mkdocs-get-deps==0.2.2 \ --hash=sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1 \ --hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650 @@ -288,11 +292,15 @@ mkdocs-get-deps==0.2.2 \ mkdocs-material==9.7.7 \ --hash=sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f \ --hash=sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855 - # via -r requirements.in + # via -r docs/requirements.in mkdocs-material-extensions==1.3.1 \ --hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \ --hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 # via mkdocs-material +mkdocs-static-i18n==1.3.1 \ + --hash=sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65 \ + --hash=sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf + # via -r docs/requirements.in packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 diff --git a/docs/runtime-errors.en-US.md b/docs/runtime-errors.en-US.md new file mode 100644 index 0000000..00f24a9 --- /dev/null +++ b/docs/runtime-errors.en-US.md @@ -0,0 +1,74 @@ +# How pausing on an error works + +Debuggers for other languages pause on the exact line of an error (division by +zero, index out of bounds). On the AMX VM that is not trivial, and it is the +debugger's most technical feature. This page explains how it works. + +## The problem + +The VM aborts a runtime error through the `ABORT` macro, which **returns +immediately** from `amx_Exec` without calling the debug hook and without +preserving the exact `cip` of the instruction. In other words: when the error +happens the hook is never called — there is no way to "catch" the error +afterwards. + +The way out is to **predict**: the hook is called on every line; in it, we look at +the upcoming instruction and check whether it will fail with the current +registers, pausing **before** the VM aborts. + +## `OP_BREAK` is per line, not per instruction + +The compiler emits an `OP_BREAK` at the **start** of each source line — not before +every instruction. The dangerous instruction (a division, an `OP_BOUNDS`) usually +sits in the **middle** of the line, after several `load`/`push`/`pop` that change +the registers. + +So looking at the opcode right after the break is not enough: the debugger +**scans the line** (from the break to the next break), simulating the `pri`/`alt` +registers from the real state and reading the data segment (`Amx::read_cell`) for +operands that come from variables. When it reaches a risky instruction, it checks +it with the correct values. + +## Relocation (computed goto) + +On servers built with computed goto (GCC/Clang — the SA-MP and open.mp builds), +the loader **rewrites every opcode** in the code segment into the **address** of +the label that handles it. So `Amx::read_code(cip)` returns a pointer, not the +opcode number. + +To recover the opcode, the debugger inverts the VM's dispatch table, obtained at +runtime through `Amx::opcode_table` (the same mechanism the loader uses). That +makes detection **portable**: it works on SA-MP and open.mp without depending on +either one's source, because both use the same AMX VM. + +## Detected errors + +| Error | Opcode / check | Condition (faithful to `amx.c`) | +|-------|----------------|----------------------------------| +| Division by zero | `OP_SDIV` / `OP_UDIV` | the divisor (`alt`) is zero | +| Division by zero | `OP_SDIV_ALT` / `OP_UDIV_ALT` | the divisor (`pri`) is zero | +| Index out of bounds | `OP_BOUNDS` | `(unsigned) pri > limit` | +| Stack/heap collision (`STACKERR`) | `OP_STACK` / `OP_HEAP` / `OP_PROC` / `OP_CALL` / `OP_CALL_PRI` (`CHKMARGIN`) | `hea + STKMARGIN > stk` (on `CALL`, anticipating the callee's `PROC`) | +| Heap underflow (`HEAPLOW`) | `OP_HEAP` (`CHKHEAP`) | `hea < hlw` | +| Invalid memory access (`MEMACCESS`) | `OP_LOAD_I` / `OP_LODB_I` / `OP_STOR_I` / `OP_STRB_I` / `OP_LIDX` / `OP_LIDX_B` (`VERIFYADDRESS`) | address within `[hea, stk)` or `>= stp` | + +On detection the debugger pauses with `reason: "exception"` and the message in the +editor's language, showing the line and the variables — just like a regular +breakpoint. + +!!! note "Conservative by design" + The `STACKERR`/`HEAPLOW`/`MEMACCESS` checks track `stk`/`hea` along the line + and only fire while that tracking is exact; any branch or unmodeled opcode + (a jump, a `sysreq`, arithmetic) turns them off — **never** a false positive. + This is not "break on any exception". + +## SDK primitives used + +Every read from the VM comes from the +[`rust-samp`](https://rust-samp.nullsablex.com/) SDK, with no hand-written FFI in +the debugger: + +- `Amx::pri()` / `Amx::alt()` — the accumulator registers. +- `Amx::read_code(offset)` — reads the code segment (the instruction). +- `Amx::opcode_table(count)` — the dispatch table, to decode under relocation. +- `Amx::read_cell(addr)` — reads a variable's real value during the simulation. diff --git a/mkdocs.yml b/mkdocs.yml index 4eaa81f..6046d79 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,36 @@ theme: edit: material/pencil view: material/eye +# `search` fica listado explicitamente: declarar `plugins` substitui o padrão do +# MkDocs, que traz a busca embutida. +plugins: + - search + - i18n: + docs_structure: suffix + fallback_to_default: true + languages: + - locale: pt-BR + name: Português (BR) + default: true + build: true + - locale: en-US + name: English (US) + build: true + site_description: Visual debugger (DAP) for Pawn (SA-MP / open.mp) + # O tema fica em `en`: o Material traduz a interface por idioma e não + # tem uma tabela para `en-US` (só `en`), o que quebraria o build. + theme: + language: en + nav_translations: + Início: Home + Uso: Usage + Começando: Getting started + Recursos: Features + Interno: Internals + Arquitetura: Architecture + Como funciona a pausa no erro: How pausing on an error works + Localização (i18n): Localization (i18n) + markdown_extensions: - admonition - attr_list From 34d7ec7ddbf595f8a34374cede2e1753d935aac7 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:43:54 -0300 Subject: [PATCH 28/33] docs: registrar docs, i18n do site e Scorecard no changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seção Não lançado parou nos recursos; faltavam o README com badges, o site bilíngue, a padronização dos comentários, as duas correções de documentação e o workflow do OpenSSF Scorecard. --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c167bfb..746692f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,16 @@ Podem existir falhas ou itens não declarados, causados por falha humana ou por ### Alterado - **Mensagens localizadas centralizadas** em `crates/protocol/src/messages` — `Locale`, as 11 `MsgKey` e uma tabela por idioma (pt-BR, en, es, ro, ru), compartilhadas pelo plugin e pelo adaptador. O `match` por chave é exaustivo: idioma incompleto não compila. - **Primeiro canal request/response do protocolo** — `Command::ReadMemory` ↔ `Event::MemoryData`, correlacionados por `id` e com timeout, sem prender a sessão se o plugin não responder. -- Documentação reescrita: `README.md`, `docs/features.md`, `docs/architecture.md`, `docs/index.md` e a nova página de [localização](docs/i18n.md), com a meta de 50 idiomas. +- **Documentação reescrita** — `README.md` (com badges de CI, CodeQL, docs, OpenSSF Scorecard, release, downloads, stars e licença), `docs/features.md`, `docs/architecture.md`, `docs/index.md`, `docs/getting-started.md` e a nova página de [localização](docs/i18n.md), com a meta de 50 idiomas. +- **Site da documentação bilíngue** — `mkdocs-static-i18n` com **pt-BR** na raiz e **en-US** em `/en-US/`, as seis páginas traduzidas e o nav por `nav_translations`. O tema do locale `en-US` aponta para `en`, porque o Material só traz tabela de interface para `en`. +- **Comentários do código padronizados em português** — `hook.rs` estava inteiramente em inglês (mais pontos isolados em `plugin_client.rs`, `inspect.rs` e `langs/en.rs`); na passagem, cortado o que apenas repetia a assinatura. + +### Corrigido +- Tabela de erros em `docs/runtime-errors.md`: faltavam `OP_CALL_PRI` no STACKERR e `OP_LODB_I`/`OP_STRB_I`/`OP_LIDX_B` no MEMACCESS, todos já checados no plugin. +- Doc de `resolve_data_watches`, que ainda dizia que arrays não eram observáveis — elementos de array passaram a ser. + +### Infraestrutura +- **OpenSSF Scorecard** (`.github/workflows/scorecard.yml`) — análise em push no `master`, semanal e manual, com actions pinadas por SHA e permissões mínimas. Publica na API pública do OpenSSF (o que sustenta o badge) e envia o SARIF ao code scanning. ## [0.1.0] - 04/07/2026 From 19f1430a8bb398b366454ea8b8440756b69c7844 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:55:37 -0300 Subject: [PATCH 29/33] chore(release): 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fecha o ciclo desde a v0.1.0: call stack multi-frame, data breakpoints, breakpoints de função, três novos erros de runtime, inspeção de arrays/strings, setExpression, readMemory, expressões no watch, autocomplete e a documentação em inglês. Sobe a versão do workspace e o marcador embutido no plugin (a extensão casa apenas o prefixo PAWNPRO_DEBUG_MARKER, então a versão dentro dele é livre; o tamanho segue 26 bytes). O changelog passa a cobrir todas as mudanças desde o último release, incluindo os re-pins do SDK e a infraestrutura do repositório. --- CHANGELOG.md | 43 +++++++++++++++++++--------------- Cargo.lock | 6 ++--- Cargo.toml | 2 +- crates/debug-plugin/src/lib.rs | 2 +- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 746692f..0de4995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,33 +8,38 @@ Podem existir falhas ou itens não declarados, causados por falha humana ou por --- -## [Não lançado] +## [0.2.0] - 31/08/2026 + +Segundo pré-lançamento. Amplia o conjunto DAP suportado: a depuração deixa de ser +"breakpoint e inspeção" e passa a cobrir call stack, data breakpoints, edição por +expressão, leitura de memória e mais três classes de erro de runtime. ### Adicionado -- **Call stack multi-frame** — o plugin caminha a cadeia de frames do AMX (FRM → endereço de retorno) e entrega nome da função, linha e variáveis de cada frame; o editor navega entre eles e a inspeção segue o frame selecionado. -- **Breakpoints de função** — parar ao entrar numa função pelo nome. O adaptador resolve o nome no bloco `AMX_DBG` e envia ao plugin a união dos breakpoints de linha e de função; nome que não resolve volta como não verificado. -- **Data breakpoints** — pausar quando um valor muda, em globais, locais e **elementos de array** (`arr[3]`). Watches de locais expiram quando o frame dono retorna, para não disparar com o lixo de outro frame no mesmo slot; endereço ilegível não gera disparo. -- **Inspeção rica de arrays e strings** — arrays são expansíveis (cada elemento vira um filho) e arrays de char são resumidos como **string** quando o conteúdo parece texto terminado em zero. -- **`setExpression`** — editar um lvalue (`x = 1`, `arr[i] = 10`) direto no watch ou no console, com o índice podendo ser uma subexpressão. O array inteiro não é editável, só os elementos. -- **`readMemory`** — hex view da memória de dados crua a partir de qualquer variável: cada variável passa a expor um `memoryReference`, e o adaptador devolve os bytes em base64 (encoder próprio, sem dependência nova). -- **Autocomplete** (`completions`) — sugestão de variáveis em escopo no watch e no console. +- **Call stack multi-frame** — caminha a cadeia de frames do AMX (FRM → endereço de retorno) e entrega nome da função, linha e variáveis de cada frame. O editor navega entre os frames e a inspeção segue o frame selecionado. +- **Data breakpoints** — pausar quando um valor muda, em globais, locais e **elementos de array** (`arr[3]`). Watches de locais expiram quando o frame dono retorna, para não dispararem com o conteúdo de outro frame no mesmo slot. +- **Breakpoints de função** — parar ao entrar numa função pelo nome; um nome que não resolve volta ao editor como não verificado, sem quebrar a sessão. +- **Três novos erros de runtime** — `STACKERR` (colisão pilha/heap), `HEAPLOW` (underflow de heap) e `MEMACCESS` (acesso inválido à memória), somando-se à divisão por zero e ao índice fora do limite. A simulação é fiel ao `amx.c` e conservadora: o rastreio dos registradores perde a confiança ao primeiro opcode não modelado, então não há falso-positivo. - **Filtro de exceção** — o editor liga e desliga a pausa em erros de runtime pelo painel de breakpoints. -- **Mais erros de runtime** — `STACKERR` (colisão pilha/heap), `HEAPLOW` (underflow de heap) e `MEMACCESS` (acesso inválido à memória), com simulação fiel ao `amx.c` e conservadora: o rastreio de `pri`/`alt`/`stk`/`hea` perde a confiança ao primeiro opcode não modelado, então nunca há falso-positivo. -- **Avaliador de expressões** para watch/hover — um operador de topo com `+ - * / %` (semântica de truncamento do Pawn) e `== != < > <= >=`, sobre literais, variáveis e `arr[i]`. +- **Inspeção de arrays e strings** — arrays são expansíveis (cada elemento vira um filho) e arrays de char são mostrados como **string** quando o conteúdo é texto terminado em zero. +- **Edição por expressão** (`setExpression`) — `x = 1` ou `arr[i] = 10` direto no watch ou no console, com o índice podendo ser uma subexpressão. O array inteiro não é editável, só os elementos. +- **Leitura de memória** (`readMemory`) — hex view da memória de dados crua a partir de qualquer variável, que agora expõe um `memoryReference`. +- **Expressões no watch e no hover** — um operador de topo com `+ - * / %` (seguindo o truncamento do Pawn) e `== != < > <= >=`, sobre literais, variáveis e `arr[i]`. +- **Autocomplete** — sugestão de variáveis em escopo no watch e no console. +- **Documentação em inglês** — o site passa a ter **pt-BR** na raiz e **en-US** em `/en-US/`, com todas as páginas traduzidas e o menu localizado. ### Alterado -- **Mensagens localizadas centralizadas** em `crates/protocol/src/messages` — `Locale`, as 11 `MsgKey` e uma tabela por idioma (pt-BR, en, es, ro, ru), compartilhadas pelo plugin e pelo adaptador. O `match` por chave é exaustivo: idioma incompleto não compila. -- **Primeiro canal request/response do protocolo** — `Command::ReadMemory` ↔ `Event::MemoryData`, correlacionados por `id` e com timeout, sem prender a sessão se o plugin não responder. -- **Documentação reescrita** — `README.md` (com badges de CI, CodeQL, docs, OpenSSF Scorecard, release, downloads, stars e licença), `docs/features.md`, `docs/architecture.md`, `docs/index.md`, `docs/getting-started.md` e a nova página de [localização](docs/i18n.md), com a meta de 50 idiomas. -- **Site da documentação bilíngue** — `mkdocs-static-i18n` com **pt-BR** na raiz e **en-US** em `/en-US/`, as seis páginas traduzidas e o nav por `nav_translations`. O tema do locale `en-US` aponta para `en`, porque o Material só traz tabela de interface para `en`. -- **Comentários do código padronizados em português** — `hook.rs` estava inteiramente em inglês (mais pontos isolados em `plugin_client.rs`, `inspect.rs` e `langs/en.rs`); na passagem, cortado o que apenas repetia a assinatura. +- **Mensagens do adaptador agora são localizadas** — antes só os erros de runtime seguiam o idioma do editor. As mensagens dos dois lados foram unificadas em `crates/protocol/src/messages`, com 11 chaves em pt-BR, en, es, ro e ru; a tabela de cada idioma é exaustiva, então um idioma incompleto não compila. +- **O protocolo ganhou um canal request/response** — `ReadMemory` ↔ `MemoryData`, correlacionados por `id` e com timeout, para a sessão não ficar presa se o plugin não responder. O restante do protocolo continua assíncrono nos dois sentidos. +- **README e documentação reescritos** — recursos, arquitetura e a página de localização atualizados para o que esta versão entrega. -### Corrigido -- Tabela de erros em `docs/runtime-errors.md`: faltavam `OP_CALL_PRI` no STACKERR e `OP_LODB_I`/`OP_STRB_I`/`OP_LIDX_B` no MEMACCESS, todos já checados no plugin. -- Doc de `resolve_data_watches`, que ainda dizia que arrays não eram observáveis — elementos de array passaram a ser. +### Dependências +- **SDK `rust-samp`** atualizado ao longo do ciclo, acompanhando o que cada versão liberou: `v3.3.1` (correção), `v3.4.0`, o acessor `Amx::hlw` (exigido pelo HEAPLOW) e `AmxDbg::function_address` (exigido pelos breakpoints de função). +- Atualizações de `serde` (1.0.229), `serde_json` (1.0.151), das GitHub Actions e das dependências da documentação. ### Infraestrutura -- **OpenSSF Scorecard** (`.github/workflows/scorecard.yml`) — análise em push no `master`, semanal e manual, com actions pinadas por SHA e permissões mínimas. Publica na API pública do OpenSSF (o que sustenta o badge) e envia o SARIF ao code scanning. +- **CodeQL** migrado para advanced setup, garantindo as duas análises (`actions`, `rust`) em todo pull request, com `CODEOWNERS`. +- **OpenSSF Scorecard** — análise semanal e em push no `master`, publicando o resultado na API pública do OpenSSF e o SARIF no code scanning. +- **Dependabot** para `github-actions`, `cargo` e `pip`, agrupado em um PR por ecossistema. ## [0.1.0] - 04/07/2026 diff --git a/Cargo.lock b/Cargo.lock index df62c68..459d832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "dap-adapter" -version = "0.1.0" +version = "0.2.0" dependencies = [ "interprocess", "pawnpro-dbg-protocol", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "debug-plugin" -version = "0.1.0" +version = "0.2.0" dependencies = [ "interprocess", "pawnpro-dbg-protocol", @@ -165,7 +165,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "pawnpro-dbg-protocol" -version = "0.1.0" +version = "0.2.0" dependencies = [ "interprocess", "serde", diff --git a/Cargo.toml b/Cargo.toml index a1815cc..fbed91b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "AGPL-3.0-or-later" repository = "https://github.com/NullSablex/PawnPro-Debugger" diff --git a/crates/debug-plugin/src/lib.rs b/crates/debug-plugin/src/lib.rs index 1f8d3e2..56a5fb2 100644 --- a/crates/debug-plugin/src/lib.rs +++ b/crates/debug-plugin/src/lib.rs @@ -40,7 +40,7 @@ static FIRST_AMX_SEEN: AtomicBool = AtomicBool::new(false); /// (`src/core/server.ts`). NÃO renomear o valor — é contrato com a extensão. #[used] #[unsafe(no_mangle)] -pub static PAWNPRO_DEBUG_MARKER: [u8; 26] = *b"PAWNPRO_DEBUG_MARKER:0.1.0"; +pub static PAWNPRO_DEBUG_MARKER: [u8; 26] = *b"PAWNPRO_DEBUG_MARKER:0.2.0"; /// Mantém o marcador vivo até o link final. `#[used]` sozinho não basta para um /// cdylib: o linker ainda pode descartar o dado por não ser referenciado nem From 2f38f57710c2ff092657d88dae5fc3d999522858 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:07:57 -0300 Subject: [PATCH 30/33] docs: mover a data do release 0.2.0 para 01/09/2026 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de4995..5255142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Podem existir falhas ou itens não declarados, causados por falha humana ou por --- -## [0.2.0] - 31/08/2026 +## [0.2.0] - 01/09/2026 Segundo pré-lançamento. Amplia o conjunto DAP suportado: a depuração deixa de ser "breakpoint e inspeção" e passa a cobrir call stack, data breakpoints, edição por From f041dde9e2394eebcd73f776dabaac6afa0ec163 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:41:17 -0300 Subject: [PATCH 31/33] refactor: usar as primitivas de VM do SDK (rust-samp-sdk 3.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A numeração de opcodes, o tamanho das instruções, o OpcodeMap, a caminhada da pilha e a leitura de faixa de memória eram fatos genéricos da VM AMX que viviam no plugin. Passaram para o SDK e voltam como API: - samp::debug::opcode substitui as 44 constantes locais, a tabela OP_PARAMS e STK_MARGIN; operand_cells troca o índice cru por uma consulta checada. - samp::debug::stack substitui crates/debug-plugin/src/stack.rs, removido com seus 6 testes (reescritos no SDK). - Amx::read_bytes substitui o alinhamento e o fatiamento manuais do readMemory. - Amx::data_only substitui Amx::new(ptr, 0) nos três pontos da pausa. - Amx::opcode_map substitui OpcodeMap::new(amx.opcode_table(OP_NUM_OPCODES)). O plugin perde ~250 linhas sem mudança de comportamento: 88 testes (os 6 que saíram vivem agora no SDK), clippy pedantic e fmt verdes. --- CHANGELOG.md | 1 + Cargo.lock | 10 +- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- crates/debug-plugin/src/hook.rs | 36 ++---- crates/debug-plugin/src/lib.rs | 1 - crates/debug-plugin/src/runtime_error.rs | 122 +++----------------- crates/debug-plugin/src/stack.rs | 138 ----------------------- docs/architecture.en-US.md | 12 +- docs/architecture.md | 12 +- 10 files changed, 49 insertions(+), 287 deletions(-) delete mode 100644 crates/debug-plugin/src/stack.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5255142..be09646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ expressão, leitura de memória e mais três classes de erro de runtime. ### Dependências - **SDK `rust-samp`** atualizado ao longo do ciclo, acompanhando o que cada versão liberou: `v3.3.1` (correção), `v3.4.0`, o acessor `Amx::hlw` (exigido pelo HEAPLOW) e `AmxDbg::function_address` (exigido pelos breakpoints de função). +- **Lógica genérica de VM devolvida ao SDK** (`rust-samp-sdk` 3.4.0) — a numeração dos opcodes, o tamanho das instruções, o decodificador da relocação por computed-goto (`OpcodeMap`), a caminhada da cadeia de frames e a leitura de faixas de memória eram fatos da VM AMX que viviam aqui. Agora vêm de `samp::debug::opcode`, `samp::debug::stack` e `Amx::read_bytes`/`call_stack`/`data_only`, e o plugin ficou ~250 linhas menor sem perder comportamento. - Atualizações de `serde` (1.0.229), `serde_json` (1.0.151), das GitHub Actions e das dependências da documentação. ### Infraestrutura diff --git a/Cargo.lock b/Cargo.lock index 459d832..4afac02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,8 +232,8 @@ dependencies = [ [[package]] name = "rust-samp" -version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" +version = "3.4.0" +source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" 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=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" +source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" dependencies = [ "proc-macro2", "quote", @@ -255,8 +255,8 @@ dependencies = [ [[package]] name = "rust-samp-sdk" -version = "3.3.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=900ebd0#900ebd0f73ec5351a17428d1ea77032413383b1a" +version = "3.4.0" +source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" dependencies = [ "bitflags 2.13.0", ] diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index cd617a6..8155b16 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 = "900ebd0", default-features = false, features = ["debug"] } +rust-samp-sdk = { git = "https://github.com/NullSablex/rust-samp", rev = "9524c09", 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 03a93ae..efc5761 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 = "900ebd0", features = ["debug"] } +samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp", rev = "9524c09", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs index e9e2295..5869f9b 100644 --- a/crates/debug-plugin/src/hook.rs +++ b/crates/debug-plugin/src/hook.rs @@ -20,10 +20,11 @@ use crate::control::{ }; use crate::gate::Resume; use crate::inspect::{self, CellReader}; -use crate::runtime_error::{self, Locale, OP_NUM_OPCODES, OpcodeMap}; -use crate::stack; +use crate::runtime_error::{self, Locale}; use pawnpro_dbg_protocol::{Breakpoint, Event, Frame}; +use samp::debug::OpcodeMap; use samp::debug::VClass; +use samp::debug::stack; /// Tamanho (bytes) de uma instrução AMX. No hook, o `cip` aponta para a célula /// SEGUINTE ao `OP_BREAK`; voltamos isto para chegar ao endereço da linha. @@ -255,9 +256,8 @@ pub fn load_debug(dbg: AmxDbg) { /// Monta o mapa de opcodes desta VM (inverso de `amx_opcodelist` numa imagem /// relocada), para a detecção de erro de runtime. Uma vez por VM, no `on_amx_load`. pub fn load_opcode_map(amx: &Amx) { - let map = OpcodeMap::new(amx.opcode_table(OP_NUM_OPCODES)); if let Ok(mut guard) = OPCODE_MAP.lock() { - *guard = Some(map); + *guard = Some(amx.opcode_map()); } } @@ -336,7 +336,7 @@ fn read_memory_inner( count: usize, ) -> Option> { let (amx_usize, frames) = PAUSE_CTX.lock().ok().and_then(|g| g.clone())?; - let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0); + let amx = Amx::data_only(amx_usize as *mut samp::raw::types::AMX); let (cip, frm) = *frames.get(frame)?; let guard = DBG.lock().ok()?; let dbg = guard.as_ref()?; @@ -353,21 +353,9 @@ fn read_memory_inner( } let start = i32::try_from(i64::from(base) + offset).ok()?; - // `read_cell` lê cells de 4 bytes alinhadas; alinha para baixo e pula o resto. - let aligned = start & !3; - let skip = usize::try_from(start - aligned).ok()?; - let mut out = Vec::with_capacity(skip + count); - let mut addr = aligned; - while out.len() < skip + count { - let Some(cell) = amx.read_cell(addr) else { - break; // endereço inacessível: devolve o que leu até aqui - }; - out.extend_from_slice(&cell.to_le_bytes()); - addr = addr.wrapping_add(4); - } - // Fatia [skip, skip+count) do que foi lido (pode ser menor no fim do segmento). - let end = (skip + count).min(out.len()); - Some(out.get(skip..end).unwrap_or(&[]).to_vec()) + // O SDK cuida do alinhamento de cells e para no primeiro endereço + // inacessível — no fim do segmento devolve menos que `count`. + amx.read_bytes(start, count) } /// Arma os data breakpoints pedidos pelo adaptador. Resolve cada `(frame, name)` @@ -389,8 +377,7 @@ fn resolve_data_watches(reqs: Vec) -> Vec, value: i32) let (cip, frm) = *frames.get(frame)?; (*amx_usize, cip, frm) }; - // Reconstrói um `Amx` sobre o ponteiro da VM pausada. `write_cell` lê o - // base/data segment direto da struct AMX, então a tabela de funções não é - // necessária aqui (0 serve). - let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0); + let amx = Amx::data_only(amx_usize as *mut samp::raw::types::AMX); let guard = DBG.lock().ok()?; let dbg = guard.as_ref()?; diff --git a/crates/debug-plugin/src/lib.rs b/crates/debug-plugin/src/lib.rs index 56a5fb2..4adbc38 100644 --- a/crates/debug-plugin/src/lib.rs +++ b/crates/debug-plugin/src/lib.rs @@ -15,7 +15,6 @@ mod gate; mod hook; mod inspect; mod runtime_error; -mod stack; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/debug-plugin/src/runtime_error.rs b/crates/debug-plugin/src/runtime_error.rs index ef9c63b..68034d0 100644 --- a/crates/debug-plugin/src/runtime_error.rs +++ b/crates/debug-plugin/src/runtime_error.rs @@ -20,72 +20,16 @@ //! essa tabela (ponteiro → opcode). Em imagens não-relocadas, o valor já é o //! número. -use std::collections::HashMap; - -/// Números de opcode da VM AMX (ordem do enum em `amx.c`). Só os que o simulador -/// de linha consome (efeito em `pri`/`alt`) ou detecta. -pub const OP_LOAD_PRI: i32 = 1; // pri = data[offs] -pub const OP_LOAD_ALT: i32 = 2; // alt = data[offs] -pub const OP_LOAD_S_PRI: i32 = 3; // pri = data[frm+offs] -pub const OP_LOAD_S_ALT: i32 = 4; // alt = data[frm+offs] -pub const OP_CONST_PRI: i32 = 11; -pub const OP_CONST_ALT: i32 = 12; -pub const OP_MOVE_PRI: i32 = 33; // pri = alt -pub const OP_MOVE_ALT: i32 = 34; // alt = pri -pub const OP_XCHG: i32 = 35; -pub const OP_PUSH_PRI: i32 = 36; -pub const OP_PUSH_ALT: i32 = 37; -pub const OP_PUSH_C: i32 = 39; -pub const OP_POP_PRI: i32 = 42; -pub const OP_POP_ALT: i32 = 43; -pub const OP_SDIV: i32 = 73; -pub const OP_SDIV_ALT: i32 = 74; -pub const OP_UDIV: i32 = 76; -pub const OP_UDIV_ALT: i32 = 77; -pub const OP_ZERO_PRI: i32 = 89; -pub const OP_ZERO_ALT: i32 = 90; -pub const OP_BOUNDS: i32 = 121; -pub const OP_BREAK: i32 = 137; -// Opcodes de endereço/memória e pilha/heap, para os erros STACKERR/HEAPLOW/ -// MEMACCESS (números conferidos na `amx_opcodelist` do interpretador). -pub const OP_LOAD_I: i32 = 9; // pri = data[pri] -pub const OP_LODB_I: i32 = 10; // pri = data[pri] (byte/word) -pub const OP_ADDR_PRI: i32 = 13; // pri = frm + offs -pub const OP_ADDR_ALT: i32 = 14; // alt = frm + offs -pub const OP_STOR_I: i32 = 23; // data[alt] = pri -pub const OP_STRB_I: i32 = 24; // data[alt] = pri (byte/word) -pub const OP_LIDX: i32 = 25; // pri = data[pri*4 + alt] -pub const OP_LIDX_B: i32 = 26; // pri = data[(pri< 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. - inverse: HashMap, -} - -impl OpcodeMap { - /// Constrói o mapa a partir da `amx_opcodelist` (de `Amx::opcode_table`). - #[must_use] - pub fn new(opcode_table: Option>) -> Self { - let inverse = opcode_table - .map(|table| { - table - .into_iter() - .enumerate() - .map(|(op, addr)| (addr, i32::try_from(op).unwrap_or(-1))) - .collect() - }) - .unwrap_or_default(); - Self { inverse } - } - - /// Opcode real a partir do valor cru em `code[cip]`. Resolve o endereço de - /// label (computed-goto) ou aceita um número de opcode pequeno (não relocado). - /// `None` se não for nenhum dos dois. - #[must_use] - pub fn decode(&self, raw: i32) -> Option { - if self.inverse.is_empty() { - return Some(raw); - } - if let Some(&op) = self - .inverse - .get(&usize::try_from(raw.cast_unsigned()).ok()?) - { - return Some(op); - } - (0..i32::try_from(OP_NUM_OPCODES).ok()?) - .contains(&raw) - .then_some(raw) - } -} - /// Estado simulado dos registradores durante a varredura de uma linha. struct Regs { pri: i32, @@ -246,7 +148,7 @@ pub fn scan_line( if op == OP_BREAK { return None; } - let nparams = u32::from(*OP_PARAMS.get(usize::try_from(op).ok()?)?); + let nparams = u32::from(operand_cells(op)?); if nparams == 99 { return None; // tamanho variável → não dá para avançar com segurança } @@ -495,6 +397,8 @@ pub fn scan_line( #[cfg(test)] mod tests { use super::*; + use samp::debug::OpcodeMap; + use samp::debug::opcode::OP_NUM_OPCODES; /// Monta um "code segment" a partir de uma lista de (opcode, params...). fn code(instrs: &[&[i32]]) -> Vec { diff --git a/crates/debug-plugin/src/stack.rs b/crates/debug-plugin/src/stack.rs deleted file mode 100644 index 66ec4cf..0000000 --- a/crates/debug-plugin/src/stack.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! 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/docs/architecture.en-US.md b/docs/architecture.en-US.md index 81ae2d9..76bf79f 100644 --- a/docs/architecture.en-US.md +++ b/docs/architecture.en-US.md @@ -28,10 +28,16 @@ The parser for the `AMX_DBG` format (address ↔ line ↔ symbol ↔ function) a VM primitives come from the [`rust-samp`](https://rust-samp.nullsablex.com/) SDK: - `samp::debug` / `samp_sdk::debug` — parser for the debug block. -- `Amx::cip/frame/stack/heap/stp/pri/alt` — the VM registers. +- `Amx::cip/frame/stack/heap/hlw/stp/pri/alt` — the VM registers. - `Amx::read_cell/write_cell` — data (inspecting and editing variables). -- `Amx::read_code` / `Amx::opcode_table` — code (decoding opcodes; see - [Pausing on an error](runtime-errors.md)). +- `Amx::read_bytes` — raw memory ranges (the `readMemory` hex view). +- `Amx::read_code` / `Amx::opcode_map` — code and opcode decoding under + relocation (see [Pausing on an error](runtime-errors.md)). +- `samp::debug::opcode` — opcode numbering, `STK_MARGIN` and each instruction's + size (`operand_cells`), which the line scan uses to step forward. +- `samp::debug::stack::walk` / `Amx::call_stack` — walking the frame chain. +- `Amx::data_only` — wraps a paused VM for data-side access with no function + table (the socket thread's situation during a pause). Using the SDK as the single source avoids duplicating the parser between plugin and adapter (the adapter depends on `rust-samp-sdk` with `default-features = diff --git a/docs/architecture.md b/docs/architecture.md index f97e92d..fb4c8d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,10 +27,16 @@ O parser do formato `AMX_DBG` (endereço ↔ linha ↔ símbolo ↔ função) e primitivas de VM vêm do SDK [`rust-samp`](https://rust-samp.nullsablex.com/): - `samp::debug` / `samp_sdk::debug` — parser do bloco de debug. -- `Amx::cip/frame/stack/heap/stp/pri/alt` — registradores da VM. +- `Amx::cip/frame/stack/heap/hlw/stp/pri/alt` — registradores da VM. - `Amx::read_cell/write_cell` — dados (inspecionar/editar variáveis). -- `Amx::read_code` / `Amx::opcode_table` — código (decodificar opcodes; ver - [Pausa no erro](runtime-errors.md)). +- `Amx::read_bytes` — faixa de memória crua (o hex view do `readMemory`). +- `Amx::read_code` / `Amx::opcode_map` — código e a decodificação de opcodes sob + relocação (ver [Pausa no erro](runtime-errors.md)). +- `samp::debug::opcode` — numeração dos opcodes, `STK_MARGIN` e o tamanho de + cada instrução (`operand_cells`), que a varredura da linha usa para avançar. +- `samp::debug::stack::walk` / `Amx::call_stack` — caminhada da cadeia de frames. +- `Amx::data_only` — embrulha a VM pausada para acesso só de dados, sem tabela + de funções (é a situação da thread do socket durante a pausa). Usar o SDK como fonte única evita duplicar o parser entre o plugin e o adaptador (o adaptador depende do `rust-samp-sdk` com `default-features = false, features = From 5e4cc9fc46c74d036a67c4300f19958a41028e35 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:39:13 -0300 Subject: [PATCH 32/33] chore(deps): consumir rust-samp 3.4.0 do crates.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependência git + rev existia porque o debugger precisava de API do SDK que ainda não estava publicada. Com o lançamento da 3.4.0 (rust-samp e rust-samp-sdk), passa a ser dependência de versão: acaba o re-pin por SHA a cada mudança no SDK e o build deixa de resolver nada pelo GitHub — não há mais nenhuma dependência git no Cargo.lock. --- CHANGELOG.md | 3 +- Cargo.lock | 51 ++++++++++++++++++---------------- crates/dap-adapter/Cargo.toml | 2 +- crates/debug-plugin/Cargo.toml | 2 +- docs/architecture.en-US.md | 3 +- docs/architecture.md | 3 +- 6 files changed, 35 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be09646..846bfb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ expressão, leitura de memória e mais três classes de erro de runtime. - **README e documentação reescritos** — recursos, arquitetura e a página de localização atualizados para o que esta versão entrega. ### Dependências -- **SDK `rust-samp`** atualizado ao longo do ciclo, acompanhando o que cada versão liberou: `v3.3.1` (correção), `v3.4.0`, o acessor `Amx::hlw` (exigido pelo HEAPLOW) e `AmxDbg::function_address` (exigido pelos breakpoints de função). +- **SDK `rust-samp` agora vem do crates.io** (`3.4.0`), no lugar da dependência `git` + `rev` que o projeto carregava desde o início — ela existia porque o debugger precisava de API ainda não publicada. O build deixa de depender do GitHub para resolver dependências. +- Ao longo do ciclo o SDK acompanhou o que cada versão liberou: `v3.3.1` (correção), `v3.4.0`, o acessor `Amx::hlw` (exigido pelo HEAPLOW) e `AmxDbg::function_address` (exigido pelos breakpoints de função). - **Lógica genérica de VM devolvida ao SDK** (`rust-samp-sdk` 3.4.0) — a numeração dos opcodes, o tamanho das instruções, o decodificador da relocação por computed-goto (`OpcodeMap`), a caminhada da cadeia de frames e a leitura de faixas de memória eram fatos da VM AMX que viviam aqui. Agora vêm de `samp::debug::opcode`, `samp::debug::stack` e `Amx::read_bytes`/`call_stack`/`data_only`, e o plugin ficou ~250 linhas menor sem perder comportamento. - Atualizações de `serde` (1.0.229), `serde_json` (1.0.151), das GitHub Actions e das dependências da documentação. diff --git a/Cargo.lock b/Cargo.lock index 4afac02..930d1aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cfg-if" @@ -107,15 +107,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" @@ -128,9 +128,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mmap-fixed-fixed" @@ -180,18 +180,18 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -233,7 +233,8 @@ dependencies = [ [[package]] name = "rust-samp" version = "3.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dfaa4bbc0847a5f6f4ccb77694a02cedc2cf11d61722d41f6ba6ae80c8f2887" dependencies = [ "fern", "log", @@ -246,7 +247,8 @@ dependencies = [ [[package]] name = "rust-samp-codegen" version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c2a29e2ef6737ca3afe059e0006e1bc8fd88f017a38d6f2bc46e8f348eac5a" dependencies = [ "proc-macro2", "quote", @@ -256,9 +258,10 @@ dependencies = [ [[package]] name = "rust-samp-sdk" version = "3.4.0" -source = "git+https://github.com/NullSablex/rust-samp?rev=9524c09#9524c09808576b42d4f4df36d2b9ffa6078075d4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92de2d90ad83ece4e7ce5911cbe55f5bbce2f2099f9807cfed8b7980bf89f855" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -312,9 +315,9 @@ checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8" [[package]] name = "syn" -version = "3.0.0" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -323,9 +326,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -345,9 +348,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -489,6 +492,6 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/dap-adapter/Cargo.toml b/crates/dap-adapter/Cargo.toml index 8155b16..0f0c357 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 = "9524c09", default-features = false, features = ["debug"] } +rust-samp-sdk = { version = "3.4.0", 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 efc5761..9075077 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 = "9524c09", features = ["debug"] } +samp = { package = "rust-samp", version = "3.4.0", features = ["debug"] } [package.metadata.samp] uid = "0x0d9107bbd31c8d1b" diff --git a/docs/architecture.en-US.md b/docs/architecture.en-US.md index 76bf79f..e88d421 100644 --- a/docs/architecture.en-US.md +++ b/docs/architecture.en-US.md @@ -41,7 +41,8 @@ VM primitives come from the [`rust-samp`](https://rust-samp.nullsablex.com/) SDK Using the SDK as the single source avoids duplicating the parser between plugin and adapter (the adapter depends on `rust-samp-sdk` with `default-features = -false, features = ["debug"]` — the pure logic only, no FFI). +false, features = ["debug"]` — the pure logic only, no FFI). Both come from +crates.io at version **3.4.0**; the build has no `git` dependency. ## The flow of a pause diff --git a/docs/architecture.md b/docs/architecture.md index fb4c8d6..8171e94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,8 @@ primitivas de VM vêm do SDK [`rust-samp`](https://rust-samp.nullsablex.com/): Usar o SDK como fonte única evita duplicar o parser entre o plugin e o adaptador (o adaptador depende do `rust-samp-sdk` com `default-features = false, features = -["debug"]`, só a lógica pura, sem FFI). +["debug"]`, só a lógica pura, sem FFI). Ambos vêm do crates.io na versão +**3.4.0** — o build não depende de nenhuma dependência `git`. ## Fluxo de uma pausa From a664a9d751a79a084a990597325214e0c2506c7b Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:44:04 -0300 Subject: [PATCH 33/33] docs(changelog): completar a 0.2.0 com tudo desde a v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faltavam a fileira de badges do README, o marcador do plugin subindo para 0.2.0, a correção da tabela de opcodes em runtime-errors.md e as atualizações de dependência do Dependabot nos três ecossistemas. Conferido item a item contra os 50 commits desde a tag. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 846bfb3..7cdf16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,11 +30,16 @@ expressão, leitura de memória e mais três classes de erro de runtime. ### Alterado - **Mensagens do adaptador agora são localizadas** — antes só os erros de runtime seguiam o idioma do editor. As mensagens dos dois lados foram unificadas em `crates/protocol/src/messages`, com 11 chaves em pt-BR, en, es, ro e ru; a tabela de cada idioma é exaustiva, então um idioma incompleto não compila. - **O protocolo ganhou um canal request/response** — `ReadMemory` ↔ `MemoryData`, correlacionados por `id` e com timeout, para a sessão não ficar presa se o plugin não responder. O restante do protocolo continua assíncrono nos dois sentidos. -- **README e documentação reescritos** — recursos, arquitetura e a página de localização atualizados para o que esta versão entrega. +- **README e documentação reescritos** — recursos, arquitetura e a página de localização atualizados para o que esta versão entrega. O README ganhou a fileira de badges (CI, CodeQL, docs, OpenSSF Scorecard, release, downloads, estrelas e licença) e uma linha de navegação para a documentação, os releases e a extensão. +- **Marcador do plugin atualizado para `PAWNPRO_DEBUG_MARKER:0.2.0`** — a extensão casa apenas o prefixo `PAWNPRO_DEBUG_MARKER`, então o reconhecimento do plugin oficial não muda; o tamanho segue 26 bytes. + +### Corrigido +- Tabela de erros em [Como funciona a pausa no erro](docs/runtime-errors.md): faltavam `OP_CALL_PRI` no STACKERR e `OP_LODB_I`/`OP_STRB_I`/`OP_LIDX_B` no MEMACCESS, todos já checados pelo plugin. ### Dependências - **SDK `rust-samp` agora vem do crates.io** (`3.4.0`), no lugar da dependência `git` + `rev` que o projeto carregava desde o início — ela existia porque o debugger precisava de API ainda não publicada. O build deixa de depender do GitHub para resolver dependências. - Ao longo do ciclo o SDK acompanhou o que cada versão liberou: `v3.3.1` (correção), `v3.4.0`, o acessor `Amx::hlw` (exigido pelo HEAPLOW) e `AmxDbg::function_address` (exigido pelos breakpoints de função). +- Atualizações do **Dependabot** nos três ecossistemas, agrupadas por ecossistema a partir de #13: `cargo` (`serde` 1.0.228 → 1.0.229 e o grupo cargo com 2 atualizações), `github-actions` (`checkout`, `upload-artifact`, `download-artifact`, `setup-python`, `rust-cache`, `action-gh-release`) e `pip` (`mkdocs-material`, `pymdown-extensions`). Nenhuma altera comportamento do debugger. - **Lógica genérica de VM devolvida ao SDK** (`rust-samp-sdk` 3.4.0) — a numeração dos opcodes, o tamanho das instruções, o decodificador da relocação por computed-goto (`OpcodeMap`), a caminhada da cadeia de frames e a leitura de faixas de memória eram fatos da VM AMX que viviam aqui. Agora vêm de `samp::debug::opcode`, `samp::debug::stack` e `Amx::read_bytes`/`call_stack`/`data_only`, e o plugin ficou ~250 linhas menor sem perder comportamento. - Atualizações de `serde` (1.0.229), `serde_json` (1.0.151), das GitHub Actions e das dependências da documentação.