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). +

+ +

+ CI + CodeQL + Docs + OpenSSF Scorecard + Release + Downloads + Stars + Rust + Licença +

+ +

+ 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: