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/CHANGELOG.md b/CHANGELOG.md
index c0cfa4e..7cdf16c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,46 @@ Podem existir falhas ou itens não declarados, causados por falha humana ou por
---
+## [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
+expressão, leitura de memória e mais três classes de erro de runtime.
+
+### Adicionado
+- **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.
+- **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 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. 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.
+
+### Infraestrutura
+- **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
Primeiro pré-lançamento (pre-release).
diff --git a/Cargo.lock b/Cargo.lock
index 6c06076..930d1aa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,13 +4,25 @@ version = 4
[[package]]
name = "bitflags"
-version = "2.13.0"
+version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[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"
+version = "0.2.0"
dependencies = [
"interprocess",
"pawnpro-dbg-protocol",
@@ -21,7 +33,7 @@ dependencies = [
[[package]]
name = "debug-plugin"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"interprocess",
"pawnpro-dbg-protocol",
@@ -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,23 +99,48 @@ 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"
+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 = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "mach2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
[[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"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
+dependencies = [
+ "libc",
+ "winapi",
+]
[[package]]
name = "num-conv"
@@ -101,9 +157,15 @@ 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"
+version = "0.2.0"
dependencies = [
"interprocess",
"serde",
@@ -118,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",
]
@@ -140,13 +202,43 @@ 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.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dfaa4bbc0847a5f6f4ccb77694a02cedc2cf11d61722d41f6ba6ae80c8f2887"
dependencies = [
"fern",
"log",
+ "retour",
"rust-samp-codegen",
"rust-samp-sdk",
"time",
@@ -154,20 +246,22 @@ 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 = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73c2a29e2ef6737ca3afe059e0006e1bc8fd88f017a38d6f2bc46e8f348eac5a"
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.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92de2d90ad83ece4e7ce5911cbe55f5bbce2f2099f9807cfed8b7980bf89f855"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
]
[[package]]
@@ -197,7 +291,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.0",
+ "syn",
]
[[package]]
@@ -214,21 +308,16 @@ 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"
-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",
@@ -237,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",
@@ -259,32 +348,75 @@ 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",
]
+[[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,8 +426,72 @@ 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"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
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/README.md b/README.md
index 0e94f95..fc0a328 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,84 @@
-# 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).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Documentação ·
+ English ·
+ 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`, ` <= >=` (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..cbb85f5 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;
@@ -14,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};
@@ -61,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() {
@@ -172,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) {
@@ -190,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 e6b3cd9..d93eb8e 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;
@@ -150,20 +161,17 @@ 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").
+ // 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);
@@ -178,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;
@@ -190,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) {
@@ -204,41 +253,53 @@ impl PluginClient {
}
}
-/// Últimas variáveis recebidas num `Paused` — servidas ao `variables` do DAP.
-/// Global porque chegam pela thread leitora e são consultadas no loop principal.
-static LAST_VARS: Mutex> = Mutex::new(Vec::new());
+/// Pilha de chamadas da última pausa — servida ao `stackTrace`/`scopes`/
+/// `variables` do DAP. Global porque chega pela thread leitora e é consultada no
+/// loop principal. Índice 0 = topo (onde a VM parou).
+static LAST_FRAMES: Mutex> = Mutex::new(Vec::new());
-fn store_vars(vars: &[wire::Var]) {
- if let Ok(mut g) = LAST_VARS.lock() {
- *g = vars.to_vec();
+fn store_frames(frames: &[wire::Frame]) {
+ if let Ok(mut g) = LAST_FRAMES.lock() {
+ *g = frames.to_vec();
}
}
-/// Variáveis da última pausa (para o handler `variables` do DAP).
-pub fn last_vars() -> Vec {
- LAST_VARS.lock().map(|g| g.clone()).unwrap_or_default()
+/// Frames da última pausa (para o `stackTrace` do DAP).
+pub fn last_frames() -> Vec {
+ LAST_FRAMES.lock().map(|g| g.clone()).unwrap_or_default()
+}
+
+/// Variáveis em escopo no frame dado (0 = topo) — para `variables`/`evaluate`.
+pub fn frame_vars(frame: usize) -> Vec {
+ LAST_FRAMES
+ .lock()
+ .ok()
+ .and_then(|g| g.get(frame).map(|f| f.vars.clone()))
+ .unwrap_or_default()
}
-/// Atualiza no cache o valor de uma variável editada via `setVariable`, 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)
+/// Atualiza no cache o valor de uma variável editada via `setVariable` no frame
+/// dado, para que o painel/watch reflitam o novo valor sem reler a VM (o plugin já
+/// a escreveu).
+pub fn update_var(frame: usize, name: &str, value: &str) {
+ if let Ok(mut g) = LAST_FRAMES.lock()
+ && let Some(f) = g.get_mut(frame)
+ && let Some(v) = f.vars.iter_mut().find(|v| v.name == name)
{
v.value = value.to_string();
}
}
-/// Linha-fonte da última pausa (para o `stackTrace` do DAP).
-static LAST_LINE: Mutex> = Mutex::new(None);
-
-fn store_line(line: Option) {
- if let Ok(mut g) = LAST_LINE.lock() {
- *g = line;
+/// Atualiza no cache o elemento `elem` do array de índice `var_index` no frame
+/// dado (após editar `arr[elem]` via `setVariable`), para o painel refletir sem
+/// reler a VM.
+pub fn update_array_elem(frame: usize, var_index: usize, elem: usize, value: &str) {
+ if let Ok(mut g) = LAST_FRAMES.lock()
+ && let Some(child) = g
+ .get_mut(frame)
+ .and_then(|f| f.vars.get_mut(var_index))
+ .and_then(|arr| arr.children.get_mut(elem))
+ {
+ child.value = value.to_string();
}
}
-
-/// Linha da última pausa, se houver.
-pub fn last_line() -> Option {
- LAST_LINE.lock().ok().and_then(|g| *g)
-}
diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs
index 4bb02b8..9c1cea2 100644
--- a/crates/dap-adapter/src/session.rs
+++ b/crates/dap-adapter/src/session.rs
@@ -5,10 +5,12 @@
//! 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};
+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
@@ -23,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ê.
@@ -51,12 +65,18 @@ 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".
source_path: Option,
+ /// Idioma das mensagens do adaptador, do `locale` do `initialize`.
+ locale: Locale,
terminated: bool,
}
@@ -98,6 +118,7 @@ impl Session {
"initialize" => self.on_initialize(req),
"launch" => self.on_launch(req),
"setBreakpoints" => self.on_set_breakpoints(req),
+ "setFunctionBreakpoints" => self.on_set_function_breakpoints(req),
"threads" => self.on_threads(req),
"continue" => self.on_continue(req),
"next" => self.on_step(req, Step::Over),
@@ -107,6 +128,12 @@ 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),
+ "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),
@@ -122,22 +149,33 @@ impl Session {
}
fn on_initialize(&mut self, req: &Request) -> Vec {
- // Capabilities mínimas da v1.
+ // 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_tag);
+ let runtime_label = messages::msg(self.locale, MsgKey::RuntimeErrorsLabel);
+ // 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,
+ "supportsSetExpression": true,
+ "supportsDataBreakpoints": true,
+ "supportsFunctionBreakpoints": true,
+ "supportsCompletionsRequest": true,
+ "supportsReadMemoryRequest": true,
+ // Filtro de exceção: o editor liga/desliga a pausa em erros de runtime.
+ "exceptionBreakpointFilters": [
+ { "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
// (derruba o servidor antigo, espera a porta, sobe um novo) — o único
@@ -279,7 +317,7 @@ impl Session {
self.breakpoints.clear();
let mut verified = Vec::new();
- let mut breakpoints = Vec::new();
+ let mut line_bps = Vec::new();
for ReqBp {
line,
condition,
@@ -293,7 +331,7 @@ impl Session {
.and_then(|d| d.line_to_address(line, file));
if let Some(a) = addr {
self.breakpoints.push((line, a));
- breakpoints.push(Breakpoint {
+ line_bps.push(Breakpoint {
addr: a,
condition,
hit_condition,
@@ -310,6 +348,56 @@ impl Session {
verified.push(json!({ "verified": addr.is_some(), "line": actual_line }));
}
+ // Substitui os breakpoints de LINHA e envia a união (linha + função) — o
+ // plugin mantém um único conjunto.
+ self.line_bps = line_bps;
+ let breakpoints = self.all_breakpoints();
+ let body = json!({ "breakpoints": verified });
+ self.reply_with(req, Command::SetBreakpoints { breakpoints }, body)
+ }
+
+ /// União dos breakpoints de linha e de função — o plugin mantém um conjunto só.
+ fn all_breakpoints(&self) -> Vec {
+ self.line_bps
+ .iter()
+ .chain(self.fn_bps.iter())
+ .cloned()
+ .collect()
+ }
+
+ /// `setFunctionBreakpoints`: substitui os breakpoints de FUNÇÃO. Cada `name` é
+ /// resolvido no endereço de entrada da função (via `AmxDbg::function_address`)
+ /// e entra na união enviada ao plugin. Responde verificado por breakpoint.
+ fn on_set_function_breakpoints(&mut self, req: &Request) -> Vec {
+ let names: Vec = req
+ .arguments
+ .get("breakpoints")
+ .and_then(Value::as_array)
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|b| b.get("name").and_then(Value::as_str).map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let mut fn_bps = Vec::new();
+ let mut verified = Vec::new();
+ for name in names {
+ let addr = self.dbg.as_ref().and_then(|d| d.function_address(&name));
+ if let Some(a) = addr {
+ fn_bps.push(Breakpoint {
+ addr: a,
+ condition: None,
+ hit_condition: None,
+ log_message: None,
+ });
+ }
+ let line = addr.and_then(|a| self.dbg.as_ref().and_then(|d| d.lookup_line(a)));
+ verified.push(json!({ "verified": addr.is_some(), "line": line }));
+ }
+
+ self.fn_bps = fn_bps;
+ let breakpoints = self.all_breakpoints();
let body = json!({ "breakpoints": verified });
self.reply_with(req, Command::SetBreakpoints { breakpoints }, body)
}
@@ -335,45 +423,120 @@ impl Session {
self.reply_with(req, Command::Step { mode }, Value::Null)
}
- /// `stackTrace`: um único frame na linha onde a VM parou (v1 sem call stack
- /// completo — o plugin ainda não caminha os frames). O frame inclui `source`
- /// apontando ao arquivo-fonte; sem isso o editor mostra "Origem Desconhecida"
- /// e não destaca a linha de execução.
+ /// `stackTrace`: a pilha de chamadas completa da última pausa (frame 0 = topo).
+ /// Cada frame carrega o nome da função, a linha-fonte e um `source` apontando ao
+ /// arquivo — sem isso o editor mostra "Origem Desconhecida" e não destaca a
+ /// linha. O `id` (1-based) identifica o frame nos `scopes`/`variables`/`evaluate`
+ /// seguintes. Antes da primeira pausa (sem frames), devolve um frame-âncora só
+ /// para o editor ter a fonte.
fn on_stack_trace(&mut self, req: &Request) -> Vec {
- let line = crate::plugin_client::last_line().unwrap_or(0);
- let mut frame = json!({
- "id": 1,
- "name": "main",
- "line": line,
- "column": 0,
- });
- if let Some(path) = self.source_path.as_deref() {
- frame["source"] = json!({
+ let source = self.source_path.as_deref().map(|path| {
+ json!({
"name": std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path),
"path": path,
- });
- }
- let body = json!({ "stackFrames": [frame], "totalFrames": 1 });
+ })
+ });
+
+ let frames = crate::plugin_client::last_frames();
+ let stack_frames: Vec = if frames.is_empty() {
+ // Sem pausa ainda: frame-âncora para ancorar a fonte no editor.
+ vec![with_source(
+ json!({ "id": 1, "name": "main", "line": 0, "column": 0 }),
+ source.as_ref(),
+ )]
+ } else {
+ frames
+ .iter()
+ .enumerate()
+ .map(|(i, f)| {
+ with_source(
+ json!({
+ "id": i + 1,
+ "name": f.name,
+ "line": f.line.unwrap_or(0),
+ "column": 0,
+ }),
+ source.as_ref(),
+ )
+ })
+ .collect()
+ };
+ let total = stack_frames.len();
+ let body = json!({ "stackFrames": stack_frames, "totalFrames": total });
self.reply(req, body)
}
- /// `scopes`: um escopo "Locais" com `variablesReference` fixo (1).
+ /// `scopes`: um escopo "Locais" por frame. O `frameId` (vindo do `stackTrace`)
+ /// vira o `variablesReference` do escopo, para o `variables` seguinte saber de
+ /// qual frame ler.
fn on_scopes(&mut self, req: &Request) -> Vec {
+ let frame_id = req
+ .arguments
+ .get("frameId")
+ .and_then(Value::as_i64)
+ .unwrap_or(1);
let body = json!({
- "scopes": [ { "name": "Locais", "variablesReference": 1, "expensive": false } ]
+ "scopes": [ { "name": "Locais", "variablesReference": frame_id, "expensive": false } ]
});
self.reply(req, body)
}
- /// `variables`: devolve as variáveis da última pausa (recebidas no `Paused`).
+ /// `variables`: variáveis do container referenciado. Se o `variablesReference`
+ /// é de um array (codificado), devolve os elementos; senão é um escopo de frame
+ /// (`ref - 1`) e devolve as variáveis de topo — arrays ganham um ref próprio
+ /// (não-zero) para o editor poder expandi-los.
fn on_variables(&mut self, req: &Request) -> Vec {
- let vars: Vec = crate::plugin_client::last_vars()
- .into_iter()
- .map(|v| json!({ "name": v.name, "value": v.value, "variablesReference": 0 }))
- .collect();
+ let reference = req
+ .arguments
+ .get("variablesReference")
+ .and_then(Value::as_i64)
+ .unwrap_or(0);
+
+ let vars: Vec = if let Some((frame, var_index)) = decode_array_ref(reference) {
+ // Elementos de um array (folhas). `memoryReference` = frame:arr:index.
+ crate::plugin_client::frame_vars(frame)
+ .get(var_index)
+ .map(|arr| {
+ arr.children
+ .iter()
+ .map(|c| {
+ 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. 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()
+ .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,
+ "memoryReference": format!("{frame}:{}", v.name),
+ })
+ })
+ .collect()
+ };
let body = json!({ "variables": vars });
self.reply(req, body)
}
@@ -395,74 +558,315 @@ impl Session {
.unwrap_or("")
.trim()
.to_string();
+ let reference = req
+ .arguments
+ .get("variablesReference")
+ .and_then(Value::as_i64)
+ .unwrap_or(0);
// Aceita inteiro (decimal/hex), float (`50.0`) e bool (`true`/`false`). O
// valor enviado ao plugin é sempre uma célula i32 (float = bits IEEE-754,
// 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 = messages::format(self.locale, MsgKey::InvalidValue, &[&raw]);
+ return vec![Outgoing::Response(Response::fail(seq, req, detail))];
};
- // Arrays não são editáveis (o plugin os rejeita). Detectamos pelo valor
- // atual em cache começar com `[` e falhamos AQUI, em vez de responder um
- // sucesso falso e desencontrar o painel do estado real da VM.
- let is_array = crate::plugin_client::last_vars()
+ // Edição de ELEMENTO de array: o `variablesReference` é o do array e o
+ // `name` é `[i]`. Resolve o nome do array e o índice, e edita a célula.
+ if let Some((frame, var_index)) = decode_array_ref(reference) {
+ let vars = crate::plugin_client::frame_vars(frame);
+ let (Some(arr), Some(i)) = (vars.get(var_index), parse_elem_index(&name)) else {
+ let detail = messages::format(self.locale, MsgKey::InvalidElement, &[&name]);
+ 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);
+ 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"),
- ))];
+ let detail = messages::format(self.locale, MsgKey::ArrayEditElement, &[&name, &name]);
+ return vec![Outgoing::Response(Response::fail(seq, req, detail))];
}
// Resposta otimista: a edição quase sempre vale (variável simples em
// escopo). O plugin efetiva a escrita; atualizamos o cache local para o
// painel/watch refletirem o novo valor sem reler a VM.
- crate::plugin_client::update_var(&name, &shown);
+ crate::plugin_client::update_var(frame, &name, &shown);
let body = json!({ "value": shown, "variablesReference": 0 });
vec![
- Outgoing::ToPlugin(Command::SetVariable { name, value }),
+ Outgoing::ToPlugin(Command::SetVariable {
+ frame,
+ name,
+ index: None,
+ value,
+ }),
Outgoing::Response(Response::ok(seq, req, body)),
]
}
- /// `evaluate`: usado pelo painel INSPEÇÃO (watch) e pelo hover. Avalia uma
- /// expressão simples — por ora, o NOME de uma variável em escopo — buscando
- /// nas variáveis da última pausa. Expressões compostas ainda não são
- /// suportadas; nesses casos respondemos com erro amigável (DAP exige falha no
- /// `evaluate` para o editor mostrar "não disponível" em vez de um valor falso).
- fn on_evaluate(&mut self, req: &Request) -> Vec {
+ /// `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();
+ .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;
+ /// `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 reference = req
+ .arguments
+ .get("variablesReference")
+ .and_then(Value::as_i64)
+ .unwrap_or(0);
+ let name = req
+ .arguments
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .to_string();
+
+ // 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 let Some((data_id, description)) = resolved {
+ json!({
+ "dataId": data_id,
+ "description": description,
+ "accessTypes": ["write"],
+ "canPersist": false,
+ })
+ } else {
+ json!({ "dataId": Value::Null, "description": name })
+ };
+ self.reply(req, body)
+ }
+
+ /// `setDataBreakpoints`: substitui o conjunto de data breakpoints. Decodifica
+ /// cada `dataId` (`"frame:name"`) de volta em frame + nome e encaminha ao
+ /// plugin, que resolve o endereço e passa a observar. Responde verificado.
+ fn on_set_data_breakpoints(&mut self, req: &Request) -> Vec {
+ let watches: Vec = req
+ .arguments
+ .get("breakpoints")
+ .and_then(Value::as_array)
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|b| parse_data_id(b.get("dataId")?.as_str()?))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let verified: Vec = watches
+ .iter()
+ .map(|_| json!({ "verified": true }))
+ .collect();
+ let body = json!({ "breakpoints": verified });
+ self.reply_with(req, Command::SetDataBreakpoints { watches }, body)
+ }
+
+ /// `setExceptionBreakpoints`: o editor envia os filtros ativos. Ligamos a
+ /// pausa em erros de runtime se o filtro `runtime` estiver na lista; senão a
+ /// desligamos (a VM aborta normalmente).
+ fn on_set_exception_breakpoints(&mut self, req: &Request) -> Vec {
+ let runtime = req
+ .arguments
+ .get("filters")
+ .and_then(Value::as_array)
+ .is_some_and(|fs| fs.iter().any(|f| f.as_str() == Some("runtime")));
+ self.reply_with(req, Command::SetExceptionFilter { runtime }, Value::Null)
+ }
+
+ /// `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.
+ 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);
- // Busca exata pelo nome da variável entre as da última pausa.
- let found = crate::plugin_client::last_vars()
+ let targets: Vec = crate::plugin_client::frame_vars(frame)
.into_iter()
- .find(|v| v.name == expr);
+ .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
+ /// (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
+ .get("expression")
+ .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);
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()
+ messages::format(self.locale, MsgKey::EmptyExpression, &[])
} else {
- format!("'{expr}' não está em escopo")
+ messages::format(self.locale, MsgKey::CannotEvaluate, &[expr])
};
vec![Outgoing::Response(Response::fail(seq, req, detail))]
}
@@ -513,6 +917,98 @@ impl Session {
}
}
+/// Índice do frame (0-based) a partir de um `variablesReference`/`frameId`
+/// (1-based, como o `stackTrace`/`scopes` definem). Ausente ou inválido → topo (0).
+fn frame_index(reference: Option<&Value>) -> usize {
+ reference
+ .and_then(Value::as_i64)
+ .and_then(|r| usize::try_from(r - 1).ok())
+ .unwrap_or(0)
+}
+
+/// Base dos `variablesReference` de array — bem acima de qualquer id de frame
+/// (escopos de frame são 1..N). Codifica `(frame, índice-da-var)` para o editor
+/// expandir os elementos de um array e editá-los.
+const ARRAY_REF_BASE: i64 = 1_000_000;
+/// Máximo de variáveis por frame no esquema de codificação.
+const ARRAY_REF_STRIDE: i64 = 10_000;
+
+/// Codifica `(frame, var_index)` num `variablesReference` de array. Índices são
+/// pequenos; `try_from` protege contra estouro (retorna 0 no impossível).
+fn encode_array_ref(frame: usize, var_index: usize) -> i64 {
+ let frame = i64::try_from(frame).unwrap_or(0);
+ let var_index = i64::try_from(var_index).unwrap_or(0);
+ ARRAY_REF_BASE + frame * ARRAY_REF_STRIDE + var_index
+}
+
+/// Decodifica um `variablesReference` em `(frame, var_index)` se for de array;
+/// `None` para refs de escopo de frame (1..N).
+fn decode_array_ref(reference: i64) -> Option<(usize, usize)> {
+ let r = reference.checked_sub(ARRAY_REF_BASE).filter(|r| *r >= 0)?;
+ Some((
+ usize::try_from(r / ARRAY_REF_STRIDE).ok()?,
+ usize::try_from(r % ARRAY_REF_STRIDE).ok()?,
+ ))
+}
+
+/// Índice de um elemento a partir do nome do filho `"[i]"` (como montado na
+/// inspeção). `None` se não casar o formato.
+fn parse_elem_index(name: &str) -> Option {
+ name.strip_prefix('[')?.strip_suffix(']')?.parse().ok()
+}
+
+/// 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 {
+ 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"` 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 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
+/// 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 +1226,218 @@ mod tests {
assert_eq!(frame["source"]["path"], "/srv/gm/molde.pwn");
}
+ #[test]
+ fn scopes_reference_follows_frame_id() {
+ // O escopo "Locais" referencia o frame pedido (frameId), para o
+ // `variables` seguinte ler daquele frame — e não de um id fixo.
+ let mut s = Session::new();
+ let out = s.handle(&req("scopes", &json!({ "frameId": 3 })));
+ let scope = &first_response(&out).body["scopes"][0];
+ assert_eq!(scope["variablesReference"], 3);
+ }
+
+ #[test]
+ fn frame_index_maps_1based_reference_to_0based() {
+ // variablesReference/frameId são 1-based (id do stackTrace); o índice do
+ // frame é `ref - 1`. Ausente ou inválido cai no topo (0).
+ assert_eq!(frame_index(Some(&json!(1))), 0);
+ assert_eq!(frame_index(Some(&json!(3))), 2);
+ assert_eq!(frame_index(None), 0);
+ assert_eq!(frame_index(Some(&json!(0))), 0); // inválido → topo
+ }
+
+ #[test]
+ fn array_ref_encode_decode_roundtrip() {
+ // Refs de array ficam acima de qualquer id de frame e decodificam de volta.
+ let r = encode_array_ref(2, 5);
+ assert!(r >= ARRAY_REF_BASE);
+ assert_eq!(decode_array_ref(r), Some((2, 5)));
+ assert_eq!(decode_array_ref(encode_array_ref(0, 0)), Some((0, 0)));
+ // Refs de escopo de frame (1..N) não são de array.
+ assert_eq!(decode_array_ref(1), None);
+ assert_eq!(decode_array_ref(9), None);
+ }
+
+ #[test]
+ fn 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![];
+ 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
+ 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));
+ 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_name_index() {
+ assert_eq!(
+ parse_data_id("0:health"),
+ Some(DataWatch {
+ frame: 0,
+ name: "health".into(),
+ index: None,
+ })
+ );
+ // Elemento de array: frame:name:index.
+ assert_eq!(
+ parse_data_id("2:arr:3"),
+ Some(DataWatch {
+ frame: 2,
+ name: "arr".into(),
+ index: Some(3),
+ })
+ );
+ // 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(), 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();
+ assert_eq!(bps.len(), 2);
+ assert_eq!(bps[0]["verified"], true);
+ }
+
+ #[test]
+ fn set_exception_breakpoints_toggles_runtime() {
+ let mut s = Session::new();
+ // Filtro presente → liga.
+ let out = s.handle(&req(
+ "setExceptionBreakpoints",
+ &json!({ "filters": ["runtime"] }),
+ ));
+ assert!(has_command(&out, |c| matches!(
+ c,
+ Command::SetExceptionFilter { runtime: true }
+ )));
+ // Lista vazia → desliga.
+ let out = s.handle(&req("setExceptionBreakpoints", &json!({ "filters": [] })));
+ assert!(has_command(&out, |c| matches!(
+ c,
+ Command::SetExceptionFilter { runtime: false }
+ )));
+ }
+
+ #[test]
+ fn function_breakpoints_resolve_and_union_with_line() {
+ let mut s = Session::new();
+ s.set_debug(sample_dbg_fn());
+ // 1 breakpoint de linha (linha 4 → addr 20).
+ s.handle(&req(
+ "setBreakpoints",
+ &json!({ "source": { "path": "a.pwn" }, "breakpoints": [ { "line": 4 } ] }),
+ ));
+ // Breakpoint de função "foo" (entrada em addr 8) + "naoexiste" (não resolve).
+ let out = s.handle(&req(
+ "setFunctionBreakpoints",
+ &json!({ "breakpoints": [ { "name": "foo" }, { "name": "naoexiste" } ] }),
+ ));
+ // Verificação: foo ok, naoexiste não.
+ let bps = first_response(&out).body["breakpoints"].as_array().unwrap();
+ assert_eq!(bps[0]["verified"], true);
+ assert_eq!(bps[1]["verified"], false);
+ // A união enviada ao plugin tem o bp de linha (20) e o de função (8).
+ assert!(has_command(
+ &out,
+ |c| matches!(c, Command::SetBreakpoints { breakpoints }
+ if breakpoints.iter().any(|b| b.addr == 20) && breakpoints.iter().any(|b| b.addr == 8))
+ ));
+ }
+
+ #[test]
+ fn initialize_advertises_function_breakpoints() {
+ let mut s = Session::new();
+ let out = s.handle(&req("initialize", &Value::Null));
+ assert_eq!(
+ first_response(&out).body["supportsFunctionBreakpoints"],
+ true
+ );
+ }
+
+ #[test]
+ fn initialize_advertises_exception_filter() {
+ let mut s = Session::new();
+ let out = s.handle(&req("initialize", &Value::Null));
+ let filters = &first_response(&out).body["exceptionBreakpointFilters"];
+ assert_eq!(filters[0]["filter"], "runtime");
+ }
+
+ #[test]
+ fn initialize_advertises_data_breakpoints() {
+ let mut s = Session::new();
+ let out = s.handle(&req("initialize", &Value::Null));
+ assert_eq!(first_response(&out).body["supportsDataBreakpoints"], true);
+ }
+
#[test]
fn disconnect_terminates() {
let mut s = Session::new();
@@ -740,15 +1448,35 @@ mod tests {
/// Bloco de debug mínimo (mesma forma do teste do amxdbg): a.pwn linha 3 → 20.
fn sample_dbg() -> AmxDbg {
+ dbg_bytes(0, |_| {})
+ }
+
+ /// Como `sample_dbg`, mas com uma função `foo` no range `[8, 40)` — para testar
+ /// `setFunctionBreakpoints` (o endereço de entrada cai na 1ª linha, addr 8).
+ fn sample_dbg_fn() -> AmxDbg {
+ dbg_bytes(1, |t| {
+ ext_u32(t, 0); // address
+ ext_i16(t, 0); // tag
+ ext_u32(t, 8); // codestart
+ ext_u32(t, 40); // codeend
+ t.push(9); // ident = Function
+ t.push(0); // vclass = global
+ ext_i16(t, 0); // dim
+ ext_cstr(t, "foo"); // name
+ })
+ }
+
+ /// Monta um `AmxDbg` com 1 arquivo, 2 linhas ((8,2),(20,3)) e `nsyms` símbolos
+ /// (escritos por `push_syms`).
+ fn dbg_bytes(nsyms: i16, push_syms: impl Fn(&mut Vec)) -> AmxDbg {
let mut t = Vec::new();
- // files: 1 (a.pwn @ 0)
ext_u32(&mut t, 0);
ext_cstr(&mut t, "a.pwn");
- // lines: 2 — (8,2), (20,3)
ext_u32(&mut t, 8);
ext_i32(&mut t, 2);
ext_u32(&mut t, 20);
ext_i32(&mut t, 3);
+ push_syms(&mut t);
let mut b = Vec::new();
ext_i32(&mut b, i32::try_from(22 + t.len()).unwrap());
b.extend_from_slice(&samp_sdk::debug::AMX_DBG_MAGIC.to_le_bytes());
@@ -757,7 +1485,7 @@ mod tests {
ext_i16(&mut b, 0); // flags
ext_i16(&mut b, 1); // files
ext_i16(&mut b, 2); // lines
- ext_i16(&mut b, 0); // symbols
+ ext_i16(&mut b, nsyms); // symbols
ext_i16(&mut b, 0); // tags
ext_i16(&mut b, 0); // automatons
ext_i16(&mut b, 0); // states
diff --git a/crates/debug-plugin/Cargo.toml b/crates/debug-plugin/Cargo.toml
index b293b9b..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 = "e5b5fc1", features = ["debug"] }
+samp = { package = "rust-samp", version = "3.4.0", features = ["debug"] }
[package.metadata.samp]
uid = "0x0d9107bbd31c8d1b"
diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs
index beebc8a..a1d9be8 100644
--- a/crates/debug-plugin/src/bridge.rs
+++ b/crates/debug-plugin/src/bridge.rs
@@ -170,11 +170,27 @@ fn apply(cmd: Command) {
BRIDGE.gate.resume(Resume::Step(m));
}
Command::Configured => BRIDGE.mark_configured(),
- Command::SetVariable { name, value } => {
- // Aplica na pausa atual. O adaptador responde ao editor de forma
- // otimista; aqui só efetivamos a escrita na VM (no-op se não houver
- // pausa ou a variável não for editável).
- let _ = crate::hook::set_variable(&name, value);
+ Command::SetVariable {
+ frame,
+ name,
+ index,
+ value,
+ } => {
+ // Aplica na pausa atual, no frame selecionado. `index` edita um elemento
+ // de array; `None`, um escalar. O adaptador responde ao editor de forma
+ // otimista; aqui só efetivamos a escrita na VM (no-op se não houver pausa
+ // ou a variável não for editável).
+ let _ = crate::hook::set_variable(frame, &name, index, value);
}
+ Command::SetDataBreakpoints { watches } => crate::hook::set_data_breakpoints(watches),
+ Command::SetExceptionFilter { runtime } => crate::hook::set_runtime_errors(runtime),
+ 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/control.rs b/crates/debug-plugin/src/control.rs
index b0be7d4..d362da2 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,
@@ -102,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;
@@ -127,6 +141,46 @@ 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.
+ #[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 +658,84 @@ mod tests {
let mut c = Controller::new();
assert_eq!(c.should_stop(123, 100), None);
}
+
+ /// Helper: watch global (nunca expira) num endereço com valor inicial.
+ fn global_watch(addr: i32, name: &str, last: i32) -> DataWatch {
+ DataWatch {
+ addr,
+ frame_frm: None,
+ last,
+ name: name.to_string(),
+ }
+ }
+
+ #[test]
+ fn data_watch_detects_change_once() {
+ let mut c = Controller::new();
+ c.set_data_watches(vec![global_watch(200, "g", 5)]);
+ // Valor mudou de 5 → 9: dispara e atualiza o último.
+ assert_eq!(
+ c.check_data_watches(|a| (a == 200).then_some(9), |_| true),
+ Some("g".to_string())
+ );
+ // Mesmo valor (9) agora: não dispara de novo.
+ assert_eq!(
+ c.check_data_watches(|a| (a == 200).then_some(9), |_| true),
+ None
+ );
+ }
+
+ #[test]
+ fn data_watch_no_change_is_silent() {
+ let mut c = Controller::new();
+ c.set_data_watches(vec![global_watch(200, "g", 5)]);
+ assert_eq!(
+ c.check_data_watches(|a| (a == 200).then_some(5), |_| true),
+ None
+ );
+ }
+
+ #[test]
+ fn data_watch_unreadable_address_does_not_fire() {
+ let mut c = Controller::new();
+ c.set_data_watches(vec![global_watch(200, "g", 5)]);
+ // Endereço inacessível (None) → conservador, não inventa mudança.
+ assert_eq!(c.check_data_watches(|_| None, |_| true), None);
+ }
+
+ #[test]
+ fn data_watch_local_expires_when_frame_returns() {
+ let mut c = Controller::new();
+ // Local no frame frm=500; valor "mudaria" para 9.
+ c.set_data_watches(vec![DataWatch {
+ addr: 496,
+ frame_frm: Some(500),
+ last: 5,
+ name: "x".to_string(),
+ }]);
+ // Frame 500 já retornou (não está vivo): o watch é descartado e não dispara,
+ // mesmo com o slot agora contendo outro valor.
+ assert_eq!(
+ c.check_data_watches(|a| (a == 496).then_some(9), |_| false),
+ None
+ );
+ assert!(!c.has_data_watches()); // podado
+ }
+
+ #[test]
+ fn data_watch_local_lives_while_frame_alive() {
+ let mut c = Controller::new();
+ c.set_data_watches(vec![DataWatch {
+ addr: 496,
+ frame_frm: Some(500),
+ last: 5,
+ name: "x".to_string(),
+ }]);
+ // Frame 500 ainda vivo → observa e dispara na mudança.
+ assert_eq!(
+ c.check_data_watches(|a| (a == 496).then_some(9), |frm| frm == 500),
+ Some("x".to_string())
+ );
+ assert!(c.has_data_watches());
+ }
}
diff --git a/crates/debug-plugin/src/hook.rs b/crates/debug-plugin/src/hook.rs
index fe298d7..5869f9b 100644
--- a/crates/debug-plugin/src/hook.rs
+++ b/crates/debug-plugin/src/hook.rs
@@ -1,53 +1,69 @@
-//! 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};
use samp::debug::AmxDbg;
use samp::prelude::Amx;
use crate::bridge::BRIDGE;
use crate::control::{
- Bp, BreakAction, Controller, StepMode, StopReason, eval_condition, interpolate_log,
+ Bp, BreakAction, Controller, DataWatch, StepMode, StopReason, eval_condition, interpolate_log,
};
use crate::gate::Resume;
use crate::inspect::{self, CellReader};
-use crate::runtime_error::{self, Locale, OP_NUM_OPCODES, OpcodeMap};
-use pawnpro_dbg_protocol::{Breakpoint, Event};
+use crate::runtime_error::{self, Locale};
+use pawnpro_dbg_protocol::{Breakpoint, Event, Frame};
+use samp::debug::OpcodeMap;
+use samp::debug::VClass;
+use samp::debug::stack;
-/// 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 (`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);
+/// 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);
-/// 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.
+/// 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)>);
+
+/// 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).
/// 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) {
@@ -56,54 +72,65 @@ 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`).
- if let Some(err) = detect_runtime_error(amx, raw_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)));
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.
- // 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)
@@ -116,9 +143,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 };
@@ -141,36 +167,34 @@ 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 (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));
+ // 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));
}
BRIDGE.send(&Event::Paused {
reason: reason.to_string(),
- line,
- vars,
+ frames,
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;
}
@@ -179,23 +203,41 @@ 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;
}
}
-/// 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`.
+/// 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));
+ 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)
+}
+
+/// 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)
@@ -204,38 +246,61 @@ 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() {
- *guard = Some(map);
+ *guard = Some(amx.opcode_map());
}
}
-/// 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()?;
let (pri, alt, frm) = (amx.pri()?, amx.alt()?, amx.frame()?);
+ // Ponteiros de pilha/heap e limites, para detectar STACKERR/HEAPLOW/MEMACCESS.
+ let (stk, hea, hlw, stp) = (amx.stack()?, amx.heap()?, amx.hlw()?, amx.stp()?);
let read_code = |off: u32| amx.read_code(off);
let read_data = |addr: i32| amx.read_cell(addr);
let decode = |raw: i32| map.decode(raw);
- runtime_error::scan_line(at, pri, alt, frm, &read_code, &read_data, &decode)
+ runtime_error::scan_line(
+ at, pri, alt, frm, stk, hea, hlw, stp, &read_code, &read_data, &decode,
+ )
}
-/// Updates the breakpoints (address + optional condition) resolved by the
-/// adapter.
+/// Verifica os data breakpoints neste passo: devolve o nome da variável observada
+/// que mudou de valor (e deve pausar), ou `None`. Barato quando não há watch
+/// armado. Para expirar watches de locais, calcula os `frm` vivos caminhando a
+/// pilha ([`stack::walk`]) — um frame cujo `frm` sumiu retornou.
+fn check_data_watch(amx: &Amx, cip: u32, frm: i32) -> 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))
+}
+
+/// 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 {
@@ -248,32 +313,157 @@ pub fn set_breakpoints(bps: Vec) {
}
}
-/// Edits a simple variable in scope at the current pause: writes `value` to its
-/// cell via the SDK's bounds-checked `Amx::write_cell`. Returns `Some(value)` on
-/// success, `None` if there is no active pause, the variable is not in scope, is
-/// an array (unsupported) or the address is inaccessible. Called by the socket
-/// thread while the VM is paused.
-#[must_use]
-pub fn set_variable(name: &str, value: i32) -> Option {
- let (amx_usize, cip, frm) = (*PAUSE_CTX.lock().ok()?)?;
- // 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).
- let amx = Amx::new(amx_usize as *mut samp::raw::types::AMX, 0);
+/// 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::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()?;
- // 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;
+ 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));
}
- if amx.write_cell(sym.effective_address(frm), value) {
- Some(value)
- } else {
- None
+ let start = i32::try_from(i64::from(base) + offset).ok()?;
+
+ // 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)`
+/// 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. 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();
+ };
+ let amx = Amx::data_only(amx_usize as *mut samp::raw::types::AMX);
+ 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)?;
+ let base = sym.effective_address(frm);
+ // Elemento de array (`name[index]`) ou escalar. Arrays só são
+ // observáveis por um elemento; escalares, sem índice.
+ let (addr, name) = if let Some(i) = req.index {
+ if !sym.is_array() {
+ return None;
+ }
+ let len = usize::try_from(sym.dims.first().map_or(0, |d| d.size)).unwrap_or(0);
+ if i >= 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);
+ let last = amx.read_cell(addr).unwrap_or(0);
+ Some(DataWatch {
+ addr,
+ frame_frm,
+ last,
+ name,
+ })
+ })
+ .collect()
+}
+
+/// Edita uma variável em escopo no `frame` pedido (0 = topo) da pausa atual,
+/// gravando `value` na célula via `Amx::write_cell` (com checagem de limites).
+/// `index` mira um elemento de array; `None`, um escalar. Devolve `None` se não
+/// houver pausa, o frame ou o índice estiverem fora de faixa, a variável não
+/// estiver em escopo, o tipo não casar com o `index` ou o endereço for
+/// inacessível. Chamado pela thread do socket com a VM pausada.
+#[must_use]
+pub fn set_variable(frame: usize, name: &str, index: Option, value: i32) -> Option {
+ let (amx_usize, cip, frm) = {
+ let guard = PAUSE_CTX.lock().ok()?;
+ let (amx_usize, frames) = guard.as_ref()?;
+ let (cip, frm) = *frames.get(frame)?;
+ (*amx_usize, cip, frm)
+ };
+ let amx = Amx::data_only(amx_usize as *mut samp::raw::types::AMX);
+
+ let guard = DBG.lock().ok()?;
+ let dbg = guard.as_ref()?;
+ let sym = dbg
+ .symbols_in_scope(cip)
+ .into_iter()
+ .find(|s| s.name == name)?;
+
+ // 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 {
+ 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..7099de7 100644
--- a/crates/debug-plugin/src/inspect.rs
+++ b/crates/debug-plugin/src/inspect.rs
@@ -19,24 +19,29 @@ 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);
- let value = if sym.is_array() {
- format_array(sym, addr, reader)
+ out.push(if sym.is_array() {
+ build_array(sym, addr, reader, dbg)
} else {
- reader.read_cell(addr).map_or_else(
+ let value = reader.read_cell(addr).map_or_else(
|| "?".to_string(),
|c| format_scalar(c, dbg.tag_name(sym.tag)),
- )
- };
- out.push(Var {
- name: sym.name.clone(),
- value,
+ );
+ Var {
+ name: sym.name.clone(),
+ value,
+ children: vec![],
+ }
});
}
out
}
+/// Máximo de elementos de array expostos (evita despejar arrays enormes na
+/// inspeção). Os primeiros `MAX_ELEMS`; o resto fica indicado por `…` no resumo.
+const MAX_ELEMS: u32 = 256;
+
/// Formata um valor escalar conforme o tag do símbolo. Em Pawn todo valor é um
/// cell de 32 bits; o tag diz como interpretá-lo:
/// - `Float`: os bits são um `f32` IEEE-754 (senão `96.5` apareceria como o
@@ -55,22 +60,77 @@ fn format_scalar(cell: i32, tag: Option<&str>) -> String {
}
}
-/// Formato compacto de um array: `[a, b, c, …]` até um limite, evitando despejar
-/// arrays enormes na inspeção.
-fn format_array(sym: &samp::debug::DbgSymbol, base: i32, reader: &impl CellReader) -> String {
- const MAX: u32 = 8;
+/// Monta a [`Var`] de um array: lê os elementos (até [`MAX_ELEMS`]) como filhos
+/// expansíveis e resume o valor. Se as células formam uma string terminada em
+/// zero (texto), o resumo vira `"texto"`; senão, `[a, b, c, …]`.
+fn build_array(
+ sym: &samp::debug::DbgSymbol,
+ base: i32,
+ reader: &impl CellReader,
+ dbg: &AmxDbg,
+) -> Var {
+ let tag = dbg.tag_name(sym.tag);
let len = sym.dims.first().map_or(0, |d| d.size);
- let show = len.min(MAX);
- let mut parts = Vec::new();
+ let show = len.min(MAX_ELEMS);
+
+ let mut cells = Vec::with_capacity(show as usize);
+ let mut children = Vec::with_capacity(show as usize);
for i in 0..show {
let addr = base.wrapping_add(i32::try_from(i).unwrap_or(0) * 4);
- match reader.read_cell(addr) {
- Some(c) => parts.push(c.to_string()),
- None => parts.push("?".to_string()),
+ let cell = reader.read_cell(addr);
+ cells.push(cell);
+ children.push(Var {
+ name: format!("[{i}]"),
+ value: cell.map_or_else(|| "?".to_string(), |c| format_scalar(c, tag)),
+ children: vec![],
+ });
+ }
+
+ // Resumo: string (se parecer texto terminado em zero) ou prévia numérica.
+ let value = as_string(&cells).map_or_else(
+ || {
+ const PREVIEW: usize = 8;
+ let parts: Vec = cells
+ .iter()
+ .take(PREVIEW)
+ .map(|c| c.map_or_else(|| "?".to_string(), |v| v.to_string()))
+ .collect();
+ let ellipsis = if len as usize > parts.len() {
+ ", …"
+ } else {
+ ""
+ };
+ format!("[{}{}]", parts.join(", "), ellipsis)
+ },
+ |s| format!("\"{s}\""),
+ );
+
+ Var {
+ name: sym.name.clone(),
+ value,
+ children,
+ }
+}
+
+/// Interpreta as células como uma string do Pawn: caracteres imprimíveis até um
+/// terminador `0`. `None` se qualquer célula for ilegível/não-imprimível ou não
+/// houver terminador — conservador, para não mostrar array de inteiros como texto.
+/// Decodifica em Latin-1 (aproxima o Windows-1252 do SA-MP nos acentos).
+fn as_string(cells: &[Option]) -> Option {
+ let mut s = String::new();
+ for cell in cells {
+ let c = (*cell)?;
+ if c == 0 {
+ return (!s.is_empty()).then_some(s); // terminador → fim da string
+ }
+ let b = u8::try_from(c).ok()?;
+ let printable = (0x20..=0x7e).contains(&b) || (0xa0..=0xff).contains(&b);
+ if !printable {
+ return None;
}
+ s.push(char::from(b));
}
- let ellipsis = if len > show { ", …" } else { "" };
- format!("[{}{}]", parts.join(", "), ellipsis)
+ None // sem terminador na faixa lida → não trata como string
}
#[cfg(test)]
@@ -78,6 +138,27 @@ mod tests {
use super::*;
use std::collections::HashMap;
+ #[test]
+ fn as_string_detects_terminated_text() {
+ // "Oi" + terminador → string.
+ let cells = vec![Some(79), Some(105), Some(0), Some(120)];
+ assert_eq!(as_string(&cells), Some("Oi".to_string()));
+ // Latin-1 (acento): 'á' = 0xE1.
+ assert_eq!(as_string(&[Some(0xE1), Some(0)]), Some("á".to_string()));
+ }
+
+ #[test]
+ fn as_string_conservative() {
+ // Sem terminador na faixa → não é string.
+ assert_eq!(as_string(&[Some(72), Some(105)]), None);
+ // Caractere não-imprimível (7 = BEL) → não é string.
+ assert_eq!(as_string(&[Some(72), Some(7), Some(0)]), None);
+ // Célula ilegível → não é string.
+ assert_eq!(as_string(&[Some(72), None, Some(0)]), None);
+ // Só o terminador (vazio) → não é string.
+ assert_eq!(as_string(&[Some(0)]), None);
+ }
+
#[test]
fn format_scalar_by_tag() {
// Float: os bits de 96.5 (1119944704) viram "96.5", não o inteiro cru.
diff --git a/crates/debug-plugin/src/lib.rs b/crates/debug-plugin/src/lib.rs
index 700d378..4adbc38 100644
--- a/crates/debug-plugin/src/lib.rs
+++ b/crates/debug-plugin/src/lib.rs
@@ -39,7 +39,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
@@ -72,7 +72,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 1faaff8..68034d0 100644
--- a/crates/debug-plugin/src/runtime_error.rs
+++ b/crates/debug-plugin/src/runtime_error.rs
@@ -20,77 +20,21 @@
//! 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;
-/// Total de opcodes (`OP_NUM_OPCODES`) — tamanho da `amx_opcodelist`.
-pub const OP_NUM_OPCODES: usize = 158;
-
-/// Nº de cells de parâmetro inline de cada opcode (gerado de `amx_BrowseRelocate`
-/// em `amx.c`). `99` = tamanho variável (CASETBL/SWITCH/inválido) → a varredura
-/// para por segurança ao encontrá-lo.
-#[rustfmt::skip]
-const OP_PARAMS: [u8; OP_NUM_OPCODES] = [
- 99,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0,1,1,1,1,1,0,0,0,
- 0,0,1,1,1,1,0,0,1,1,0,0,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,
- 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,
- 0,1,1,0,0,0,1,1,0,1,1,1,1,1,0,1,0,0,0,0,0,99,99,0,0,1,0,2,1,0,2,2,2,2,3,
- 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
- }
- }
-}
+// Opcodes, tamanhos de instrução e `STK_MARGIN` vêm do SDK
+// (`samp::debug::opcode`), fonte única compartilhada com o adaptador.
+use samp::debug::opcode::{
+ OP_ADDR_ALT, OP_ADDR_PRI, OP_BOUNDS, OP_BREAK, OP_CALL, OP_CALL_PRI, OP_CONST_ALT,
+ OP_CONST_PRI, OP_HEAP, OP_IDXADDR, OP_IDXADDR_B, OP_LIDX, OP_LIDX_B, OP_LOAD_ALT, OP_LOAD_I,
+ OP_LOAD_PRI, OP_LOAD_S_ALT, OP_LOAD_S_PRI, OP_LODB_I, OP_MOVE_ALT, OP_MOVE_PRI, OP_POP_ALT,
+ OP_POP_PRI, OP_PROC, OP_PUSH, OP_PUSH_ADR, OP_PUSH_ALT, OP_PUSH_C, OP_PUSH_PRI, OP_PUSH_R,
+ OP_PUSH_S, OP_PUSH2_C, OP_PUSH5_ADR, OP_SDIV, OP_SDIV_ALT, OP_STACK, OP_STOR_I, OP_STRB_I,
+ OP_UDIV, OP_UDIV_ALT, OP_XCHG, OP_ZERO_ALT, OP_ZERO_PRI, STK_MARGIN, operand_cells,
+};
+
+// 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)]
@@ -99,68 +43,44 @@ pub enum RuntimeError {
DivideByZero,
/// Índice de array fora do limite (`OP_BOUNDS`).
Bounds,
+ /// Colisão pilha/heap — a pilha cresceu (ou o heap subiu) até invadir a margem
+ /// do outro (`AMX_ERR_STACKERR`, `CHKMARGIN`). É o caso típico de recursão
+ /// infinita.
+ StackError,
+ /// Underflow de heap — liberou o heap abaixo do seu início (`AMX_ERR_HEAPLOW`,
+ /// `CHKHEAP`).
+ HeapLow,
+ /// Acesso inválido à memória — endereço na lacuna entre heap e pilha, ou além
+ /// do topo da pilha (`AMX_ERR_MEMACCESS`).
+ MemAccess,
}
impl RuntimeError {
- /// Texto curto para o `stopped` (reason "exception") do DAP, no idioma dado.
+ /// Chave da mensagem localizável correspondente.
#[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",
+ 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,
}
}
-}
-
-/// 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`).
+ /// Texto curto para o `stopped` (reason "exception") do DAP, no idioma dado.
#[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 }
+ pub fn message(self, locale: Locale) -> &'static str {
+ messages::msg(locale, self.key())
}
+}
- /// 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)
- }
+/// 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()
}
/// Estado simulado dos registradores durante a varredura de uma linha.
@@ -169,25 +89,36 @@ 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`]).
+/// 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` (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. 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(
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 +130,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();
@@ -211,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
}
@@ -222,7 +159,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 +170,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);
@@ -270,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 {
@@ -298,9 +427,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 +538,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 +560,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);
@@ -422,18 +765,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 d2b0451..2083cc0 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.
@@ -58,21 +59,61 @@ pub enum Command {
/// código que roda uma única vez no início (ex.: `OnGameModeInit`).
Configured,
/// Edita uma variável em escopo na pausa atual: grava `value` na célula de
- /// `name`. Só vale enquanto a VM está pausada.
- SetVariable { name: String, value: i32 },
+ /// `name`. `frame` é o índice do frame da pilha (0 = topo, onde a VM parou),
+ /// para editar a variável no escopo correto. Só vale enquanto a VM está pausada.
+ SetVariable {
+ frame: usize,
+ name: String,
+ /// Índice do elemento, quando a variável é um array (`arr[index]`);
+ /// `None` edita um escalar.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ index: Option,
+ value: i32,
+ },
+ /// Substitui o conjunto de data breakpoints (pausar quando uma variável muda).
+ /// Cada alvo vem como frame + nome porque só o plugin sabe o `frm`/endereço
+ /// para resolvê-lo; enviado enquanto a VM está pausada (o editor arma o data
+ /// breakpoint a partir do painel Variáveis).
+ SetDataBreakpoints { watches: Vec },
+ /// Liga/desliga a pausa em erros de runtime (filtro de exceção do editor).
+ /// `false` deixa a VM abortar normalmente, sem pausar antes.
+ SetExceptionFilter { runtime: bool },
+ /// 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`
+/// (0 = topo). O plugin resolve o endereço de dados e passa a observar mudanças.
+/// `index` observa um elemento de array (`name[index]`); `None` observa um escalar.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct DataWatch {
+ pub frame: usize,
+ pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub index: Option,
}
/// 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")]
@@ -83,13 +124,30 @@ 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.
+/// Um par variável→valor para a inspeção. Arrays trazem os elementos em
+/// `children` (expansíveis na árvore do editor); escalares têm `children` vazio.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Var {
pub name: String,
pub value: String,
+ /// Elementos de um array (`[0]`, `[1]`, …); vazio para escalares.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub children: Vec,
+}
+
+/// Um frame da pilha de chamadas na pausa. `name` é o nome da função (resolvido
+/// do bloco de debug pelo endereço), `line` a linha-fonte do frame e `vars` as
+/// variáveis em escopo nele.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Frame {
+ pub name: String,
+ pub line: Option,
+ pub vars: Vec,
}
/// Serializa uma mensagem como uma linha JSON (com `\n` ao final).
@@ -135,6 +193,32 @@ mod tests {
},
Command::Continue,
Command::Step { mode: Step::Over },
+ Command::SetVariable {
+ frame: 1,
+ name: "x".into(),
+ index: None,
+ value: 7,
+ },
+ Command::SetVariable {
+ frame: 0,
+ name: "arr".into(),
+ index: Some(2),
+ value: 9,
+ },
+ Command::SetDataBreakpoints {
+ watches: vec![
+ DataWatch {
+ frame: 0,
+ name: "health".into(),
+ index: None,
+ },
+ DataWatch {
+ frame: 2,
+ name: "placar".into(),
+ index: Some(3),
+ },
+ ],
+ },
] {
let line = to_line(&cmd).unwrap();
assert!(line.ends_with('\n'));
@@ -148,17 +232,31 @@ mod tests {
for ev in [
Event::Paused {
reason: "breakpoint".into(),
- line: Some(42),
- vars: vec![Var {
- name: "g".into(),
- value: "1".into(),
+ frames: vec![Frame {
+ name: "main".into(),
+ line: Some(42),
+ vars: vec![Var {
+ name: "g".into(),
+ value: "1".into(),
+ children: vec![],
+ }],
}],
description: None,
},
Event::Paused {
reason: "exception".into(),
- line: Some(7),
- vars: vec![],
+ frames: vec![
+ Frame {
+ name: "foo".into(),
+ line: Some(7),
+ vars: vec![],
+ },
+ Frame {
+ name: "main".into(),
+ line: Some(20),
+ vars: vec![],
+ },
+ ],
description: Some("divisão por zero".into()),
},
Event::Output { text: "x=5".into() },
diff --git a/crates/protocol/src/messages/langs/en.rs b/crates/protocol/src/messages/langs/en.rs
new file mode 100644
index 0000000..9ee4719
--- /dev/null
+++ b/crates/protocol/src/messages/langs/en.rs
@@ -0,0 +1,24 @@
+//! Inglês (en) — idioma-fonte e fallback dos demais.
+
+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/architecture.en-US.md b/docs/architecture.en-US.md
new file mode 100644
index 0000000..e88d421
--- /dev/null
+++ b/docs/architecture.en-US.md
@@ -0,0 +1,74 @@
+# 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/hlw/stp/pri/alt` — the VM registers.
+- `Amx::read_cell/write_cell` — data (inspecting and editing variables).
+- `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 =
+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
+
+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/architecture.md b/docs/architecture.md
index ec3f075..8171e94 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -17,9 +17,9 @@ dap-adapter ──lança──► servidor SA-MP/open.mp
| Crate | Tipo | Papel |
|-------|------|-------|
-| `protocol` | `lib` | Tipos compartilhados do IPC plugin ↔ adaptador (comandos e eventos em NDJSON sobre socket local). |
-| `debug-plugin` | `cdylib` | Carregado pelo servidor. Instala o debug hook, decide pausar (breakpoint/step/erro), coleta variáveis e bloqueia a VM até o editor mandar continuar. |
-| `dap-adapter` | `bin` | Traduz DAP ↔ protocolo próprio. Lança o servidor como processo filho (morre junto), repassa breakpoints e eventos. |
+| `protocol` | `lib` | Tipos compartilhados do IPC plugin ↔ adaptador (comandos e eventos em NDJSON sobre socket local) e as [mensagens localizadas](i18n.md). |
+| `debug-plugin` | `cdylib` | Carregado pelo servidor. Instala o debug hook, decide pausar (breakpoint/step/data breakpoint/erro), caminha a pilha, coleta variáveis, lê e escreve a memória de dados, e bloqueia a VM até o editor mandar continuar. |
+| `dap-adapter` | `bin` | Traduz DAP ↔ protocolo próprio. Lança o servidor como processo filho (morre junto), repassa breakpoints e eventos, e avalia as expressões do watch/console. |
## SDK compartilhado
@@ -27,23 +27,43 @@ 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 =
-["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
1. A VM chama o debug hook a cada linha (`.amx` compilado com `-d3`).
-2. O plugin decide pausar (breakpoint/condição/hit-count/step/erro de runtime).
-3. Coleta as variáveis em escopo e envia um evento ao adaptador.
+2. O plugin decide pausar (breakpoint/condição/hit-count/step/data breakpoint/erro
+ de runtime).
+3. Coleta as variáveis em escopo e os frames da pilha, e envia um evento ao
+ adaptador.
4. **Bloqueia** a VM (o servidor congela — esperado em dev) até o editor mandar
continuar/step.
+## Direção das mensagens
+
+O protocolo é assíncrono nos dois sentidos: o adaptador manda **comandos**
+(breakpoints, step, continuar, editar variável) e o plugin manda **eventos**
+(pausa, log, saída). Nada espera resposta — exceto uma via:
+
+`Command::ReadMemory` ↔ `Event::MemoryData` é um par **request/response**,
+correlacionado por um `id` sequencial. O adaptador registra o pedido pendente, a
+thread leitora do socket entrega os bytes ao chamador que espera, e um **timeout**
+descarta o pendente se o plugin não responder — a sessão nunca fica presa.
+
## Compilação
- **Plugin** → arquitetura do servidor (SA-MP/open.mp são 32-bit →
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/features.md b/docs/features.md
index f91e3c4..5f3ee74 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -5,16 +5,18 @@
| Breakpoints simples | :material-check: | Por linha. |
| Breakpoints condicionais | :material-check: | `var OP valor` (`==` `!=` `<` `>` `<=` `>=`); `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.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/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/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/i18n.md b/docs/i18n.md
new file mode 100644
index 0000000..a33e793
--- /dev/null
+++ b/docs/i18n.md
@@ -0,0 +1,86 @@
+# 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**.
+
+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.
+
+> 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.
+
+## Status
+
+| # | 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. Marcar ✅ nesta tabela.
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/index.md b/docs/index.md
index de53d8b..1a26cc3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,16 +4,17 @@ 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
- **[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)**.
+Para entender por dentro, veja **[Arquitetura](architecture.md)**, **[Como
+funciona a pausa no erro](runtime-errors.md)** e **[Localização](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/docs/runtime-errors.md b/docs/runtime-errors.md
index 8b629a4..af4be35 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` / `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_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.
-!!! 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
diff --git a/mkdocs.yml b/mkdocs.yml
index 96ca2d3..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
@@ -82,6 +112,7 @@ nav:
- Interno:
- Arquitetura: architecture.md
- Como funciona a pausa no erro: runtime-errors.md
+ - Localização (i18n): i18n.md
extra:
social: