diff --git a/crates/dap-adapter/src/main.rs b/crates/dap-adapter/src/main.rs index cbb85f5..f577e3c 100644 --- a/crates/dap-adapter/src/main.rs +++ b/crates/dap-adapter/src/main.rs @@ -42,16 +42,35 @@ fn main() -> io::Result<()> { for outgoing in session.handle(&req) { match outgoing { Outgoing::Response(_) | Outgoing::Event(_) => emit(&out, &outgoing), - Outgoing::SpawnServer(spec) => match spawn_server(&spec, &out) { - Ok(child) => server = Some(child), - Err(e) => out.event( - "output", - serde_json::json!({ - "category": "stderr", - "output": format!("Falha ao iniciar o servidor: {e}\n"), - }), - ), - }, + // Subir e reiniciar são o mesmo ato: derrubar o que houver e + // pôr um servidor no lugar. No `launch` não há o que derrubar; + // no `restart` há, e o `Drop` do `ServerChild` cuida disso ao + // atribuir `None`. Reconectar depois é inofensivo no primeiro + // caso — o `launch` emite `ConnectPlugin` logo em seguida — e + // necessário no segundo, porque o canal morreu com o processo. + Outgoing::SpawnServer(spec) => { + server = None; + match spawn_server(&spec, &out) { + Ok(child) => { + server = Some(child); + // Só conecta se ainda não há cliente. Trocar o + // `PluginClient` jogaria fora a fila de comandos que + // ele acumulou — e o editor manda `setBreakpoints` + // ANTES do `launch`, então é justamente ali que os + // breakpoints estão esperando. + if plugin.is_none() { + plugin = Some(PluginClient::connect(&spec.session, out.clone())); + } + } + Err(e) => out.event( + "output", + serde_json::json!({ + "category": "stderr", + "output": format!("Falha ao iniciar o servidor: {e}\n"), + }), + ), + } + } Outgoing::ConnectPlugin(id) => { // A conexão é assíncrona (com retry e feedback próprios); // não bloqueia o loop nem falha de imediato. @@ -158,10 +177,40 @@ fn forward_stream(stream: R, category: &'static st std::thread::spawn(move || pump_stream(stream, category, &out)); } +/// Bytes do console do servidor em texto. +/// +/// O SA-MP/open.mp escreve o console em Windows-1252, não em UTF-8: em +/// português `ção` sai como `\xe7\xe3o`, que `from_utf8_lossy` trocaria por `�`. +/// Tentamos UTF-8 primeiro — é o que um gamemode moderno pode emitir — e só +/// caímos no cp1252 quando a sequência não é UTF-8 válida. +/// +/// A conversão é direta e sem dependência: em cp1252 os bytes 0xA0–0xFF já são +/// os mesmos code points Unicode (herança do Latin-1); apenas 0x80–0x9F têm +/// tabela própria. +fn decodificar_console(bytes: &[u8]) -> String { + /// Os 32 code points de 0x80–0x9F, onde cp1252 difere do Latin-1. + const ALTOS: [char; 32] = [ + '\u{20AC}', '\u{81}', '\u{201A}', '\u{192}', '\u{201E}', '\u{2026}', '\u{2020}', + '\u{2021}', '\u{2C6}', '\u{2030}', '\u{160}', '\u{2039}', '\u{152}', '\u{8D}', '\u{17D}', + '\u{8F}', '\u{90}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', + '\u{2014}', '\u{2DC}', '\u{2122}', '\u{161}', '\u{203A}', '\u{153}', '\u{9D}', '\u{17E}', + '\u{178}', + ]; + if let Ok(s) = std::str::from_utf8(bytes) { + return s.to_owned(); + } + bytes + .iter() + .map(|&b| match b { + 0x80..=0x9F => ALTOS[usize::from(b - 0x80)], + _ => char::from(b), + }) + .collect() +} + /// Lógica síncrona de `forward_stream`, isolada para ser testável sem thread. /// Lê `stream` linha a linha e emite cada linha como `output`. `read_until('\n')` -/// (em vez de `lines()`) preserva a quebra de linha e a última linha sem `\n`, e -/// não falha com bytes não-UTF-8 (lidos com substituição). +/// (em vez de `lines()`) preserva a quebra de linha e a última linha sem `\n`. fn pump_stream(stream: R, category: &'static str, out: &DapOut) { use std::io::BufRead; let mut reader = BufReader::new(stream); @@ -170,7 +219,7 @@ fn pump_stream(stream: R, category: &'static str, out: &DapOut) { if n == 0 { break; // EOF — o servidor fechou a pipe } - let text = String::from_utf8_lossy(&buf).into_owned(); + let text = decodificar_console(&buf); out.event( "output", serde_json::json!({ "category": category, "output": text }), @@ -237,6 +286,26 @@ mod tests { use super::*; use std::sync::{Arc, Mutex}; + /// O console do SA-MP/open.mp é Windows-1252: `from_utf8_lossy` trocava + /// cada acento por `\u{FFFD}` no CONSOLE DE DEPURAÇÃO. + #[test] + fn console_decodifica_windows1252() { + // Bytes como o servidor os escreve: "ção" = 0xE7 0xE3 0x6F. + let cru = b"deslocamento=4 (acentua\xe7\xe3o: cora\xe7\xe3o)\n"; + assert_eq!( + decodificar_console(cru), + "deslocamento=4 (acentuação: coração)\n" + ); + } + + /// UTF-8 válido tem prioridade: um gamemode moderno pode emitir UTF-8, e + /// interpretá-lo como cp1252 daria mojibake ao contrário. + #[test] + fn console_preserva_utf8_valido() { + assert_eq!(decodificar_console("ação".as_bytes()), "ação"); + assert_eq!(decodificar_console(b"plain ascii"), "plain ascii"); + } + #[test] fn base64_encode_matches_rfc() { assert_eq!(base64_encode(b""), ""); @@ -276,8 +345,9 @@ mod tests { assert!(raw.contains("alpha\\n")); assert!(raw.contains("beta")); // última linha sem `\n` ainda é emitida assert!(raw.contains("\"category\":\"stdout\"")); - // O byte inválido não derruba nada (substituído por U+FFFD, emitido como - // UTF-8 cru pelo serde, não escapado). - assert!(raw.contains('\u{fffd}')); + // Byte que não é UTF-8 válido não derruba nada e não vira `\u{FFFD}`: + // cai na leitura cp1252, onde 0xFF é `ÿ`. + assert!(raw.contains('ÿ')); + assert!(!raw.contains('\u{fffd}')); } } diff --git a/crates/dap-adapter/src/messages.rs b/crates/dap-adapter/src/messages.rs index 429e8c0..9f53871 100644 --- a/crates/dap-adapter/src/messages.rs +++ b/crates/dap-adapter/src/messages.rs @@ -42,7 +42,6 @@ impl Response { } } - #[allow(dead_code)] // usado quando os handlers passarem a falhar explicitamente pub fn fail(seq: i64, req: &Request, message: impl Into) -> Self { Self { seq, diff --git a/crates/dap-adapter/src/plugin_client.rs b/crates/dap-adapter/src/plugin_client.rs index d93eb8e..ac274cc 100644 --- a/crates/dap-adapter/src/plugin_client.rs +++ b/crates/dap-adapter/src/plugin_client.rs @@ -11,6 +11,7 @@ use std::thread; use std::time::Duration; use interprocess::local_socket::traits::Stream as _; +use pawnpro_dbg_protocol::messages::{self, Locale, MsgKey}; use pawnpro_dbg_protocol::transport::{self, LocalStream}; use pawnpro_dbg_protocol::{self as wire, Command, Event}; use serde_json::json; @@ -86,6 +87,27 @@ pub struct PluginClient { writer: Arc>, } +/// Avisa no console quando o plugin do servidor e o adaptador têm versões +/// diferentes. +/// +/// Versões diferentes conversam até a primeira mensagem que um dos lados não +/// entende, e o sintoma é a depuração simplesmente não fazer nada. Dizer qual é +/// a diferença troca uma falha muda por uma instrução. +fn avisar_se_versao_difere(plugin: &str, out: &DapOut) { + let minha = env!("CARGO_PKG_VERSION"); + if plugin == minha { + return; + } + // Mesmo locale que o adaptador propaga ao plugin: a fonte já existe. + let loc = std::env::var("PAWNPRO_DBG_LOCALE") + .map_or_else(|_| Locale::default(), |t| Locale::from_tag(&t)); + let texto = messages::format(loc, MsgKey::PluginVersaoDiferente, &[plugin, minha, minha]); + out.event( + "output", + json!({ "category": "important", "output": format!("{texto}\n") }), + ); +} + impl PluginClient { /// Inicia a conexão com o plugin (socket da sessão `id`) numa thread, com /// retry. Retorna imediatamente — os comandos enviados nesse meio-tempo são @@ -194,6 +216,7 @@ impl PluginClient { let _ = tx.send(bytes); } } + Ok(Event::Hello { version }) => avisar_se_versao_difere(&version, &out), Ok(Event::Exited) => { out.event("terminated", serde_json::Value::Null); break; @@ -303,3 +326,45 @@ pub fn update_array_elem(frame: usize, var_index: usize, elem: usize, value: &st child.value = value.to_string(); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct Sink(Arc>>); + impl std::io::Write for Sink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Versões iguais são o caso normal: avisar ali seria ruído em toda sessão. + #[test] + fn versao_igual_nao_avisa() { + let buf = Arc::new(Mutex::new(Vec::new())); + let out = DapOut::new(Box::new(Sink(Arc::clone(&buf))), 1); + avisar_se_versao_difere(env!("CARGO_PKG_VERSION"), &out); + assert!(buf.lock().unwrap().is_empty()); + } + + /// Versão diferente é o caso de #85: sem o aviso, a depuração falha em + /// silêncio e nada no editor explica por quê. + #[test] + fn versao_diferente_avisa_com_as_duas() { + let buf = Arc::new(Mutex::new(Vec::new())); + let out = DapOut::new(Box::new(Sink(Arc::clone(&buf))), 1); + avisar_se_versao_difere("0.1.0", &out); + let saida = String::from_utf8_lossy(&buf.lock().unwrap()).into_owned(); + assert!(saida.contains("0.1.0"), "a versão do plugin"); + assert!( + saida.contains(env!("CARGO_PKG_VERSION")), + "e a do adaptador" + ); + } +} diff --git a/crates/dap-adapter/src/session.rs b/crates/dap-adapter/src/session.rs index 9c1cea2..7be194a 100644 --- a/crates/dap-adapter/src/session.rs +++ b/crates/dap-adapter/src/session.rs @@ -18,8 +18,11 @@ use crate::messages::{Event, Request, Response}; pub enum Outgoing { Response(Response), Event(Event), - /// Subir o servidor do jogo como processo FILHO do adaptador. Morre junto com - /// o adaptador (encerrar/reiniciar), sem o editor rastrear nada. + /// Pôr um servidor no lugar do que houver, como processo FILHO do + /// adaptador. Serve tanto ao `launch` (não há o que derrubar) quanto ao + /// `restart` (derruba o atual e sobe outro com a mesma `session`, mantendo + /// a sessão de depuração viva). O filho morre junto com o adaptador, sem o + /// editor rastrear nada. SpawnServer(SpawnSpec), /// Conectar no plugin pelo id de sessão do socket local (pedido no `launch`). ConnectPlugin(String), @@ -40,6 +43,7 @@ pub enum Outgoing { } /// Comando do servidor a executar, mais as variáveis de depuração que o plugin lê. +#[derive(Clone)] pub struct SpawnSpec { pub exe: String, pub args: Vec, @@ -53,6 +57,7 @@ pub struct SpawnSpec { /// Um breakpoint como pedido pelo editor (DAP), antes de resolver a linha em /// endereço. Campos opcionais espelham os modificadores do DAP. +#[derive(Clone)] struct ReqBp { line: i32, condition: Option, @@ -75,9 +80,50 @@ pub struct Session { /// `setBreakpoints`). Usado no `stackTrace` para o frame apontar à fonte — /// senão o editor mostra "Origem Desconhecida". source_path: Option, + /// Guarda contra laço: o restart pede a recompilação por evento e a + /// extensão reenvia o `restart`. Sem isto, um `.pwn` cuja data continuasse + /// à frente do `.amx` (compilação falhou, relógio adiantado) pediria + /// rebuild para sempre. + rebuild_pedido: bool, /// Idioma das mensagens do adaptador, do `locale` do `initialize`. locale: Locale, terminated: bool, + /// Breakpoints como o editor os pediu — linha e modificadores, antes de + /// virarem endereço. + /// + /// O endereço depende do `.amx`: recompilar o gamemode muda o mapa + /// linha↔endereço, e os endereços resolvidos antes passam a apontar para + /// instruções erradas. Guardar o pedido original é o que permite resolver + /// tudo de novo depois de um restart. + requested_bps: Vec<(String, Vec)>, + /// Como o servidor foi iniciado, guardado do `launch`. + /// + /// É o que permite reiniciá-lo **sem encerrar a sessão**: o canal + /// plugin↔adaptador é um socket nomeado pela `session`, não parentesco de + /// processo, então um servidor novo com a mesma `session` reabre o mesmo + /// canal e o `plugin_client` reconecta pelo retry que já existe. + spawn_spec: Option, +} + +/// `true` se o `.pwn` ao lado do `.amx` foi modificado depois dele. +/// +/// Sem os dois arquivos, ou sem conseguir ler as datas, responde `false`: +/// pedir uma recompilação que talvez não seja possível travaria o restart. +fn fonte_mais_novo(amx: &str) -> bool { + let Some(fonte) = amx + .strip_suffix(".amx") + .or_else(|| amx.strip_suffix(".AMX")) + .map(|base| format!("{base}.pwn")) + else { + return false; + }; + let (Ok(f), Ok(a)) = (std::fs::metadata(&fonte), std::fs::metadata(amx)) else { + return false; + }; + let (Ok(fm), Ok(am)) = (f.modified(), a.modified()) else { + return false; + }; + fm > am } impl Session { @@ -157,11 +203,15 @@ impl Session { .and_then(Value::as_str) .map_or_else(Locale::default, Locale::from_tag); let runtime_label = messages::msg(self.locale, MsgKey::RuntimeErrorsLabel); - // Capabilities mínimas da v1. `supportsEvaluateForHovers` reaproveita o - // `evaluate` do painel INSPEÇÃO ao passar o mouse no código. + // `supportsEvaluateForHovers` reaproveita o `evaluate` do painel + // INSPEÇÃO ao passar o mouse no código. let caps = json!({ "supportsConfigurationDoneRequest": true, "supportsTerminateRequest": true, + // Sem isto o editor encerra e recria a sessão para reiniciar. Com + // isto ele manda `restart`, e o servidor é trocado por baixo sem a + // depuração cair. + "supportsRestartRequest": true, "supportsEvaluateForHovers": true, "supportsConditionalBreakpoints": true, "supportsHitConditionalBreakpoints": true, @@ -176,10 +226,6 @@ impl Session { "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 - // jeito de o código de carga rodar de novo. }); let seq = self.next_seq(); let resp = Response::ok(seq, req, caps); @@ -249,25 +295,86 @@ impl Session { .and_then(Value::as_str) .unwrap_or("") .to_string(); - out.push(Outgoing::SpawnServer(SpawnSpec { + let spec = SpawnSpec { exe, args, cwd, session: session_id.clone(), amx_path, locale, - })); + }; + self.spawn_spec = Some(spec.clone()); + out.push(Outgoing::SpawnServer(spec)); } } let seq = self.next_seq(); - // O adaptador conecta no plugin com retry (o servidor leva um tempo para - // subir e abrir o socket), então a ordem spawn → connect não exige espera. - out.push(Outgoing::ConnectPlugin(session_id)); + // Com servidor próprio, quem sobe já conecta (o handler do `SpawnServer` + // faz as duas coisas, e a conexão traz retry porque o servidor leva um + // tempo para abrir o socket). Sem ele — attach — este é o único caminho. + if self.spawn_spec.is_none() { + out.push(Outgoing::ConnectPlugin(session_id)); + } out.push(Outgoing::Response(Response::ok(seq, req, Value::Null))); out } + /// Resolve todos os breakpoints de linha pedidos contra o bloco de debug + /// atual, substituindo os endereços anteriores. + /// + /// Roda no `setBreakpoints` e de novo no `restart`: o endereço depende do + /// `.amx`, e recompilar o gamemode muda o mapa linha↔endereço. Sem + /// reresolver, os endereços antigos apontariam para instruções erradas — + /// a VM pararia no lugar errado, ou em lugar nenhum. + /// + /// Devolve o `verified` de cada breakpoint, na ordem em que foram pedidos. + fn resolver_bps_de_linha(&mut self) -> Vec { + self.breakpoints.clear(); + let mut verified = Vec::new(); + let mut line_bps = Vec::new(); + + for (arquivo, pedidos) in self.requested_bps.clone() { + let file = if arquivo.is_empty() { + None + } else { + Some(arquivo.as_str()) + }; + for ReqBp { + line, + condition, + hit_condition, + log_message, + } in pedidos + { + let addr = self + .dbg + .as_ref() + .and_then(|d| d.line_to_address(line, file)); + if let Some(a) = addr { + self.breakpoints.push((line, a)); + line_bps.push(Breakpoint { + addr: a, + condition, + hit_condition, + log_message, + }); + } + // Um breakpoint é "verificado" quando casou um endereço real. A + // linha pedida pode não ser "quebrável" — o endereço desliza para + // a próxima linha executável. Devolvemos a linha REAL (via + // `lookup_line`) para o editor reposicionar o marcador onde a VM + // vai de fato parar. + let actual_line = addr + .and_then(|a| self.dbg.as_ref().and_then(|d| d.lookup_line(a))) + .unwrap_or(line); + verified.push(json!({ "verified": addr.is_some(), "line": actual_line })); + } + } + + self.line_bps = line_bps; + verified + } + fn on_set_breakpoints(&mut self, req: &Request) -> Vec { let file = req .arguments @@ -315,42 +422,13 @@ impl Session { }) .unwrap_or_default(); - self.breakpoints.clear(); - let mut verified = Vec::new(); - let mut line_bps = Vec::new(); - for ReqBp { - line, - condition, - hit_condition, - log_message, - } in requested - { - let addr = self - .dbg - .as_ref() - .and_then(|d| d.line_to_address(line, file)); - if let Some(a) = addr { - self.breakpoints.push((line, a)); - line_bps.push(Breakpoint { - addr: a, - condition, - hit_condition, - log_message, - }); - } - // Um breakpoint é "verificado" quando casou um endereço real. A linha - // pedida pode não ser "quebrável" — o endereço desliza para a próxima - // linha executável. Devolvemos a linha REAL (via `lookup_line` do - // endereço) para o editor reposicionar o marcador onde a VM vai parar. - let actual_line = addr - .and_then(|a| self.dbg.as_ref().and_then(|d| d.lookup_line(a))) - .unwrap_or(line); - verified.push(json!({ "verified": addr.is_some(), "line": actual_line })); - } + // Guarda o pedido como veio: é o que permite resolver tudo de novo se o + // `.amx` mudar (recompilação + restart). + let chave = file.unwrap_or_default().to_string(); + self.requested_bps.retain(|(f, _)| f != &chave); + self.requested_bps.push((chave, requested.clone())); - // 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 verified = self.resolver_bps_de_linha(); let breakpoints = self.all_breakpoints(); let body = json!({ "breakpoints": verified }); self.reply_with(req, Command::SetBreakpoints { breakpoints }, body) @@ -405,8 +483,22 @@ impl Session { /// `configurationDone`: o cliente terminou de enviar a configuração inicial /// (breakpoints). Sinaliza `Configured` ao plugin, que então libera a VM /// segura na carga — assim breakpoints em `OnGameModeInit` e afins são pegos. + /// `configurationDone`: o editor terminou de configurar a sessão. + /// + /// Reenvia os breakpoints antes de liberar a VM. O editor manda + /// `setBreakpoints` **antes** do `launch`, quando ainda não há canal com o + /// plugin — e comando sem canal é descartado. Este é o ponto do protocolo + /// em que tudo já foi configurado e o servidor já está subindo, então é aqui + /// que o conjunto real de breakpoints tem de chegar ao plugin. fn on_configuration_done(&mut self, req: &Request) -> Vec { - self.reply_with(req, Command::Configured, Value::Null) + let seq = self.next_seq(); + vec![ + Outgoing::ToPlugin(Command::SetBreakpoints { + breakpoints: self.all_breakpoints(), + }), + Outgoing::ToPlugin(Command::Configured), + Outgoing::Response(Response::ok(seq, req, Value::Null)), + ] } /// `continue`: retoma a VM (manda `Continue` ao plugin). @@ -893,25 +985,124 @@ impl Session { /// velho), encerramos a sessão com `terminated` + `restart`, fazendo o editor /// relançar do zero — passando pelo nosso fluxo que derruba o servidor antigo, /// espera a porta e sobe um novo. + /// Reinicia o servidor **mantendo a sessão de depuração viva**. + /// + /// Antes isto encerrava a sessão (`terminated` com `restart: true`) e + /// deixava o editor recriar tudo — o que derrubava a depuração a cada + /// recarga do gamemode, justamente o ciclo mais comum de quem depura. + /// + /// Dá para fazer melhor porque o canal plugin↔adaptador é um **socket + /// nomeado** pela `session`, não parentesco de processo: matando o servidor + /// e subindo outro com a mesma `session`, o plugin reabre o mesmo socket e + /// o `plugin_client` reconecta pelo retry que já existia. + /// + /// O estado que vale continua no adaptador — breakpoints de linha, de + /// função e o bloco de debug — então nada precisa ser reenviado pelo editor. + /// + /// Sem `spawn_spec` (sessão de *attach*, em que o servidor não é nosso), + /// cai no comportamento antigo: quem subiu o servidor é quem sabe reiniciá-lo. fn on_restart(&mut self, req: &Request) -> Vec { - self.terminated = true; + let Some(spec) = self.spawn_spec.clone() else { + self.terminated = true; + let seq = self.next_seq(); + let ev_seq = self.next_seq(); + return vec![ + Outgoing::Response(Response::ok(seq, req, Value::Null)), + Outgoing::Event(Event::new(ev_seq, "terminated", json!({ "restart": true }))), + ]; + }; + + // Fonte mais novo que o binário: recompilar é atribuição da extensão + // (o compilador e as flags são conhecimento dela), então pedimos por + // evento e paramos aqui — ela compila e reenvia o `restart`. A + // verificação fica NESTE ponto porque é por onde todo restart passa, + // venha do botão nativo do editor ou do comando do PawnPro. + if !self.rebuild_pedido && fonte_mais_novo(&spec.amx_path) { + self.rebuild_pedido = true; + let seq = self.next_seq(); + let ev_seq = self.next_seq(); + return vec![ + Outgoing::Response(Response::ok(seq, req, Value::Null)), + Outgoing::Event(Event::new( + ev_seq, + "pawnproRebuild", + json!({ "program": spec.amx_path }), + )), + ]; + } + // Chegou aqui: ou o binário está em dia, ou a extensão acabou de + // recompilar. O ciclo se fecha para o próximo restart. + self.rebuild_pedido = false; + + // O `.amx` pode ter sido recompilado entre a sessão e o restart — é o + // motivo mais comum para reiniciar. O mapa linha↔endereço muda junto, + // então recarregar o bloco de debug e reresolver os breakpoints é o que + // impede a VM de parar no lugar errado. + if let Ok(bytes) = std::fs::read(&spec.amx_path) + && let Ok(dbg) = AmxDbg::from_amx(&bytes).or_else(|_| AmxDbg::parse(&bytes)) + { + self.dbg = Some(dbg); + } + let verified = self.resolver_bps_de_linha(); + let seq = self.next_seq(); - let resp = Response::ok(seq, req, Value::Null); let ev_seq = self.next_seq(); - let ev = Event::new(ev_seq, "terminated", json!({ "restart": true })); - vec![Outgoing::Response(resp), Outgoing::Event(ev)] + let bp_seq = self.next_seq(); + // `continued` avisa o editor de que não há mais frame parado: o processo + // que os produzia acabou de morrer, e os painéis precisam limpar. + // Os `breakpoint` avisam onde cada marcador ficou depois da recompilação + // — a linha pode ter andado, ou o breakpoint deixado de ser válido. + let sessao = spec.session.clone(); + let mut saida = vec![ + Outgoing::SpawnServer(spec), + // O canal antigo morreu com o processo, e o `PluginClient` só tem + // retry na conexão inicial — reusá-lo mandaria comandos para um + // socket morto. Reconectar aqui é o que dá um canal vivo ao plugin + // do servidor novo. + Outgoing::ConnectPlugin(sessao), + // E o plugin novo sobe sem breakpoint nenhum: o estado morreu junto + // com o processo anterior. Reresolver os endereços e avisar o editor + // não basta — sem isto a VM roda sem saber onde parar, e a execução + // passa direto pelo breakpoint. + Outgoing::ToPlugin(Command::SetBreakpoints { + breakpoints: self.all_breakpoints(), + }), + // O plugin bloqueia a VM na carga esperando este sinal (com timeout + // de 10 s). Sem ele o servidor novo ficaria parado até o timeout, e + // breakpoints em `OnGameModeInit` e afins se perderiam. + Outgoing::ToPlugin(Command::Configured), + Outgoing::Response(Response::ok(seq, req, Value::Null)), + Outgoing::Event(Event::new( + ev_seq, + "continued", + json!({ "threadId": 1, "allThreadsContinued": true }), + )), + ]; + + // Um `breakpoint` por marcador, com a linha onde ele de fato ficou. + for (i, v) in verified.iter().enumerate() { + let mut corpo = v.clone(); + if let Some(obj) = corpo.as_object_mut() { + obj.insert("id".into(), json!(i + 1)); + } + saida.push(Outgoing::Event(Event::new( + bp_seq + i64::try_from(i).unwrap_or(0), + "breakpoint", + json!({ "reason": "changed", "breakpoint": corpo }), + ))); + } + saida } - /// Breakpoints resolvidos a endereço (para o plugin, no futuro). - #[allow(dead_code)] // consumido pelo Componente 2 (envio ao plugin) + /// Breakpoints de linha resolvidos a endereço de código. + #[cfg(test)] #[must_use] pub fn resolved_breakpoints(&self) -> &[(i32, u32)] { &self.breakpoints } - /// Injeta um bloco de debug diretamente (usado em testes e quando a extração - /// do `.amx` é feita por fora). - #[allow(dead_code)] // usado em testes e pela integração futura + /// Injeta um bloco de debug diretamente, sem passar pelo `.amx`. + #[cfg(test)] pub fn set_debug(&mut self, dbg: AmxDbg) { self.dbg = Some(dbg); } @@ -1068,6 +1259,227 @@ mod tests { assert_eq!(parse_set_value(""), None); } + /// Um `launch` com `serverCommand` guarda como o servidor foi iniciado, e é + /// isso que permite reiniciá-lo depois sem derrubar a sessão. + #[test] + fn restart_troca_o_servidor_sem_encerrar_a_sessao() { + let mut ses = Session::new(); + let saida = ses.handle(&req( + "launch", + &json!({ + "program": "/tmp/gm.amx", + "session": "s1", + "serverCommand": { "exe": "/tmp/omp-server", "args": [], "cwd": "/tmp" } + }), + )); + assert!( + saida.iter().any(|o| matches!(o, Outgoing::SpawnServer(_))), + "o launch deve subir o servidor" + ); + + let saida = ses.handle(&req("restart", &json!({}))); + assert!( + saida.iter().any(|o| matches!(o, Outgoing::SpawnServer(_))), + "o restart deve trocar o servidor" + ); + assert!( + !saida + .iter() + .any(|o| matches!(o, Outgoing::Event(e) if e.event == "terminated")), + "o restart NÃO deve encerrar a sessão" + ); + assert!(!ses.terminated, "a sessão segue viva depois do restart"); + } + + /// Sequência real do editor: `setBreakpoints` chega ANTES do `launch` + /// (o editor manda os breakpoints já na configuração da sessão). Se o + /// pedido não sobreviver ao launch, nenhum breakpoint funciona. + #[test] + fn breakpoints_pedidos_antes_do_launch_sobrevivem() { + let mut ses = Session::new(); + ses.set_debug(sample_dbg()); + ses.handle(&req( + "setBreakpoints", + &json!({ "source": { "path": "a.pwn" }, "breakpoints": [ { "line": 4 } ] }), + )); + assert_eq!( + ses.resolved_breakpoints(), + &[(4, 20)], + "resolveu antes do launch" + ); + + // O launch chega depois e recarrega o bloco de debug do `.amx`. + ses.handle(&req( + "launch", + &json!({ "program": "/tmp/inexistente.amx", "session": "s1" }), + )); + assert_eq!( + ses.resolved_breakpoints(), + &[(4, 20)], + "o launch não pode descartar os breakpoints já resolvidos" + ); + } + + /// O editor manda `setBreakpoints` antes do `launch`, quando ainda não há + /// canal com o plugin — e comando sem canal é descartado. O + /// `configurationDone` precisa reenviar, senão nenhum breakpoint funciona. + #[test] + fn configuration_done_reenvia_os_breakpoints() { + let mut ses = Session::new(); + ses.set_debug(sample_dbg()); + ses.handle(&req( + "setBreakpoints", + &json!({ "source": { "path": "a.pwn" }, "breakpoints": [ { "line": 4 } ] }), + )); + + let saida = ses.handle(&req("configurationDone", &json!({}))); + assert!( + has_command( + &saida, + |c| matches!(c, Command::SetBreakpoints { breakpoints } + if breakpoints.len() == 1 && breakpoints[0].addr == 20) + ), + "o configurationDone deve reenviar o conjunto real ao plugin" + ); + assert!( + has_command(&saida, |c| matches!(c, Command::Configured)), + "e liberar a VM depois" + ); + } + + /// Recompilar o gamemode muda o mapa linha↔endereço. O restart tem de + /// reresolver os breakpoints contra o `.amx` novo — senão os endereços + /// antigos apontariam para instruções erradas. + #[test] + fn restart_reresolve_os_breakpoints() { + let mut ses = Session::new(); + ses.handle(&req( + "launch", + &json!({ + "program": "/tmp/gm.amx", + "session": "s1", + "serverCommand": { "exe": "/tmp/omp-server", "args": [], "cwd": "/tmp" } + }), + )); + ses.set_debug(sample_dbg()); + ses.handle(&req( + "setBreakpoints", + &json!({ "source": { "path": "a.pwn" }, "breakpoints": [ { "line": 4 } ] }), + )); + assert_eq!( + ses.resolved_breakpoints(), + &[(4, 20)], + "o setBreakpoints resolve contra o mapa atual" + ); + + // O restart limpa e resolve de novo, a partir do pedido guardado. + let saida = ses.handle(&req("restart", &json!({}))); + assert_eq!( + ses.resolved_breakpoints(), + &[(4, 20)], + "o breakpoint continua valendo depois do restart" + ); + assert!( + saida + .iter() + .any(|o| matches!(o, Outgoing::Event(e) if e.event == "breakpoint")), + "o editor precisa saber onde cada marcador ficou" + ); + // Reresolver e avisar o editor não basta: o servidor novo traz um plugin + // novo, sem breakpoint nenhum, e num canal novo. Sem reconectar e + // reenviar, a VM roda sem saber onde parar e a execução passa direto. + assert!( + saida + .iter() + .any(|o| matches!(o, Outgoing::ConnectPlugin(id) if id == "s1")), + "o canal antigo morreu com o processo: o restart precisa reconectar" + ); + assert!( + has_command( + &saida, + |c| matches!(c, Command::SetBreakpoints { breakpoints } + if breakpoints.len() == 1 && breakpoints[0].addr == 20) + ), + "e reenviar os breakpoints ao plugin do servidor novo" + ); + assert!( + has_command(&saida, |c| matches!(c, Command::Configured)), + "e liberar a VM, que fica bloqueada na carga esperando o sinal" + ); + } + + /// Fonte mais novo que o binário: o restart pede a recompilação à extensão + /// em vez de subir o servidor com o `.amx` velho — os breakpoints se + /// resolveriam contra o mapa antigo, e a VM pararia no lugar errado. + #[test] + fn restart_pede_rebuild_quando_o_fonte_mudou() { + let dir = std::env::temp_dir().join("pawnpro-teste-rebuild"); + std::fs::create_dir_all(&dir).unwrap(); + let amx = dir.join("gm.amx"); + let pwn = dir.join("gm.pwn"); + std::fs::write(&amx, b"binario").unwrap(); + // O fonte é escrito depois, então é o mais novo. + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&pwn, b"fonte").unwrap(); + + let mut ses = Session::new(); + ses.handle(&req( + "launch", + &json!({ + "program": amx.to_str().unwrap(), + "session": "s1", + "serverCommand": { "exe": "/tmp/omp-server", "args": [], "cwd": "/tmp" } + }), + )); + + let saida = ses.handle(&req("restart", &json!({}))); + assert!( + saida + .iter() + .any(|o| matches!(o, Outgoing::Event(e) if e.event == "pawnproRebuild")), + "o fonte é mais novo: tem de pedir a recompilação" + ); + assert!( + !saida.iter().any(|o| matches!(o, Outgoing::SpawnServer(_))), + "e NÃO subir o servidor com o binário velho" + ); + + // A extensão compila e reenvia: agora o restart segue normalmente, sem + // pedir de novo — senão seria laço. + let saida = ses.handle(&req("restart", &json!({}))); + assert!( + saida.iter().any(|o| matches!(o, Outgoing::SpawnServer(_))), + "no reenvio o servidor sobe" + ); + assert!( + !saida + .iter() + .any(|o| matches!(o, Outgoing::Event(e) if e.event == "pawnproRebuild")), + "e não pede rebuild de novo" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Sem `serverCommand` (attach), o servidor não é nosso: reiniciá-lo não é + /// atribuição do adaptador, e o comportamento antigo continua valendo. + #[test] + fn restart_sem_servidor_proprio_encerra_como_antes() { + let mut ses = Session::new(); + ses.handle(&req("launch", &json!({ "program": "/tmp/gm.amx" }))); + + let saida = ses.handle(&req("restart", &json!({}))); + assert!( + !saida.iter().any(|o| matches!(o, Outgoing::SpawnServer(_))), + "sem spawn_spec não há o que reiniciar" + ); + assert!( + saida + .iter() + .any(|o| matches!(o, Outgoing::Event(e) if e.event == "terminated")), + "cai no comportamento antigo: encerra e deixa o editor recriar" + ); + } + fn req(command: &str, args: &Value) -> Request { // Request não é Deserialize-friendly de construir à mão; via JSON. serde_json::from_value(json!({ diff --git a/crates/debug-plugin/src/bridge.rs b/crates/debug-plugin/src/bridge.rs index a1d9be8..8dfdd99 100644 --- a/crates/debug-plugin/src/bridge.rs +++ b/crates/debug-plugin/src/bridge.rs @@ -138,6 +138,12 @@ fn handle_client(stream: LocalStream) { if let Ok(mut guard) = BRIDGE.out.lock() { *guard = Some(send); } + // Antes de qualquer outra coisa: quem somos. O adaptador compara com a + // própria versão e avisa o usuário se forem incompatíveis, em vez de + // deixar a depuração falhar sem explicação. + BRIDGE.send(&Event::Hello { + version: env!("CARGO_PKG_VERSION").to_string(), + }); let reader = BufReader::new(recv); for line in reader.lines() { let Ok(line) = line else { break }; diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 2083cc0..eb535b1 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -122,6 +122,15 @@ pub enum Event { /// Saída de um **logpoint**: a mensagem já interpolada. A VM NÃO pausa; o /// adaptador encaminha como um evento `output` do DAP (canal do console). Output { text: String }, + /// Primeira mensagem do plugin ao aceitar a conexão: quem ele é. + /// + /// Sem isto, plugin e adaptador de versões diferentes conversavam até a + /// primeira mensagem que um dos lados não entendia — e o sintoma era a + /// depuração não fazer nada, sem erro em lugar nenhum. + /// + /// Um adaptador antigo ignora este evento (evento desconhecido não derruba + /// a conexão), então anunciar não quebra quem já está instalado. + Hello { version: String }, /// O script terminou / a VM foi descarregada. Exited, /// Resposta a um [`Command::ReadMemory`]: os `bytes` lidos, correlacionados diff --git a/crates/protocol/src/messages/langs/en.rs b/crates/protocol/src/messages/langs/en.rs index 9ee4719..54b788b 100644 --- a/crates/protocol/src/messages/langs/en.rs +++ b/crates/protocol/src/messages/langs/en.rs @@ -13,6 +13,9 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::HeapLow => "heap underflow", MsgKey::MemAccess => "invalid memory access", MsgKey::RuntimeErrorsLabel => "Runtime errors", + MsgKey::PluginVersaoDiferente => { + "Debug plugin {} with adapter {}. Update the server plugin to {}." + } MsgKey::InvalidValue => { "invalid value: '{}' (use an integer, e.g. 100/0x64; a float, e.g. 1.5; or true/false)" } diff --git a/crates/protocol/src/messages/langs/es.rs b/crates/protocol/src/messages/langs/es.rs index d9544b5..d98d3f1 100644 --- a/crates/protocol/src/messages/langs/es.rs +++ b/crates/protocol/src/messages/langs/es.rs @@ -13,6 +13,9 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::HeapLow => "subdesbordamiento del montículo", MsgKey::MemAccess => "acceso inválido a memoria", MsgKey::RuntimeErrorsLabel => "Errores de runtime", + MsgKey::PluginVersaoDiferente => { + "Plugin de depuración {} con adaptador {}. Actualiza el plugin del servidor a {}." + } MsgKey::InvalidValue => { "valor inválido: '{}' (use un entero, ej.: 100/0x64; un float, ej.: 1.5; o true/false)" } diff --git a/crates/protocol/src/messages/langs/pt_br.rs b/crates/protocol/src/messages/langs/pt_br.rs index 63cb695..baa9ac6 100644 --- a/crates/protocol/src/messages/langs/pt_br.rs +++ b/crates/protocol/src/messages/langs/pt_br.rs @@ -13,6 +13,9 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::HeapLow => "underflow de heap", MsgKey::MemAccess => "acesso inválido à memória", MsgKey::RuntimeErrorsLabel => "Erros de runtime", + MsgKey::PluginVersaoDiferente => { + "Plugin de depuração {} com adaptador {}. Atualize o plugin do servidor para {}." + } MsgKey::InvalidValue => { "valor inválido: '{}' (use inteiro, ex.: 100/0x64; float, ex.: 1.5; ou true/false)" } diff --git a/crates/protocol/src/messages/langs/ro.rs b/crates/protocol/src/messages/langs/ro.rs index 81a94da..0e0e9cd 100644 --- a/crates/protocol/src/messages/langs/ro.rs +++ b/crates/protocol/src/messages/langs/ro.rs @@ -12,6 +12,9 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::HeapLow => "subdepășire de heap", MsgKey::MemAccess => "acces nevalid la memorie", MsgKey::RuntimeErrorsLabel => "Erori de runtime", + MsgKey::PluginVersaoDiferente => { + "Plugin de depanare {} cu adaptor {}. Actualizează pluginul serverului la {}." + } MsgKey::InvalidValue => { "valoare invalidă: '{}' (folosiți un întreg, ex.: 100/0x64; un float, ex.: 1.5; sau true/false)" } diff --git a/crates/protocol/src/messages/langs/ru.rs b/crates/protocol/src/messages/langs/ru.rs index d87c76e..90a89ae 100644 --- a/crates/protocol/src/messages/langs/ru.rs +++ b/crates/protocol/src/messages/langs/ru.rs @@ -13,6 +13,9 @@ pub fn get(key: MsgKey) -> &'static str { MsgKey::HeapLow => "переполнение кучи снизу", MsgKey::MemAccess => "недопустимый доступ к памяти", MsgKey::RuntimeErrorsLabel => "Ошибки времени выполнения", + MsgKey::PluginVersaoDiferente => { + "Плагин отладки {}, адаптер {}. Обновите плагин сервера до {}." + } MsgKey::InvalidValue => { "недопустимое значение: '{}' (целое, напр. 100/0x64; дробное, напр. 1.5; или true/false)" } diff --git a/crates/protocol/src/messages/mod.rs b/crates/protocol/src/messages/mod.rs index b867182..fb4f9fc 100644 --- a/crates/protocol/src/messages/mod.rs +++ b/crates/protocol/src/messages/mod.rs @@ -50,6 +50,8 @@ pub enum MsgKey { MemAccess, // --- Mensagens do adaptador (respostas ao editor) --- RuntimeErrorsLabel, + /// Plugin do servidor e adaptador em versões diferentes. + PluginVersaoDiferente, InvalidValue, InvalidElement, ArrayEditElement, diff --git a/notes/forum-post-0.2.0.bbcode b/notes/forum-post-0.2.0.bbcode new file mode 100644 index 0000000..6e2fe0f --- /dev/null +++ b/notes/forum-post-0.2.0.bbcode @@ -0,0 +1,89 @@ +[align=center][size=30][b][color=#E67E22]🐛 PawnPro Debugger v0.2.0[/color][/b][/size] + +[size=15][i]Depurador visual (DAP) para Pawn — SA-MP e open.mp[/i] +[i]Visual debugger (DAP) for Pawn — SA-MP and open.mp[/i][/size] + +[url=https://github.com/NullSablex/PawnPro-Debugger][b]github.com/NullSablex/PawnPro-Debugger[/b][/url][/align] + +[quote] +🇧🇷 Nova versão do Debugger no ar 🎉 — e [b]hoje sai também a nova versão da extensão PawnPro[/b], com esta atualização do debugger integrada e outras novidades e melhorias no editor. A ideia desta release é simples: parar de depurar gamemode no [b]printf[/b]. + +🇬🇧 New Debugger release out 🎉 — and [b]the new PawnPro extension version ships today too[/b], with this debugger update integrated plus other improvements. The point of this release is simple: stop debugging gamemodes with [b]printf[/b]. +[/quote] + +[size=22][b]✨ O que tem de novo / What's new[/b][/size] + +[b]🧭 Call stack completa[/b] +🇧🇷 A pilha de chamadas inteira, não só a linha atual: função, linha e [b]variáveis de cada frame[/b]. Clicou no frame do chamador, a inspeção acompanha. +🇬🇧 The whole call stack, not just the current line: function, line and [b]variables per frame[/b]. Click a caller's frame and inspection follows it. + +[b]👁️ Data breakpoints[/b] +🇧🇷 "Break on Value Change": pausa quando um valor muda — globais, locais e [b]elementos de array[/b] ([b]arr[3][/b]). Ideal pra achar quem está escrevendo onde não devia. +🇬🇧 "Break on Value Change": pauses when a value changes — globals, locals and [b]array elements[/b]. Great for finding who writes where they shouldn't. + +[b]💥 Pausa no erro de runtime, na linha exata[/b] +🇧🇷 Divisão por zero, índice fora do limite e agora também [b]colisão pilha/heap[/b], [b]underflow de heap[/b] e [b]acesso inválido à memória[/b]. O debugger pausa [b]antes[/b] da VM abortar, mostrando a linha e as variáveis. É conservador por design: se não tiver certeza, não acusa — nada de falso-positivo. +🇬🇧 Divide by zero, out-of-bounds and now [b]stack/heap collision[/b], [b]heap underflow[/b] and [b]invalid memory access[/b]. It pauses [b]before[/b] the VM aborts, showing the line and the variables. Conservative by design: no false positives. + +[b]🔢 Arrays e strings de verdade[/b] +🇧🇷 Arrays expandem elemento a elemento no painel, e array de char aparece como [b]string[/b]. Dá pra editar um elemento direto ([b]arr[idx] = 10[/b]) no watch ou no console. +🇬🇧 Arrays expand element by element, char arrays show as [b]strings[/b], and you can edit an element right from the watch or console. + +[b]🧠 Watch, hover e autocomplete[/b] +🇧🇷 Expressões simples no watch e ao passar o mouse ([b]+ - * / %[/b] e comparações, com [b]arr[idx][/b]), e sugestão de variáveis em escopo no console. +🇬🇧 Simple expressions in watch and on hover, plus in-scope variable completion in the console. + +[b]🧬 Memória crua (hex view)[/b] +🇧🇷 Qualquer variável abre a memória de dados em hexadecimal, pra quando o valor formatado não conta a história toda. +🇬🇧 Any variable opens raw data memory in hex, for when the formatted value isn't the whole story. + +[b]🌍 Mensagens e documentação em mais idiomas[/b] +🇧🇷 As mensagens seguem o idioma do editor (pt-BR, en, es, ro, ru) e a documentação agora tem versão em inglês. +🇬🇧 Messages follow the editor's language (pt-BR, en, es, ro, ru) and the docs now have an English version. + +[size=18][b]📋 Changelog completo / Full changelog[/b][/size] +[url=https://github.com/NullSablex/PawnPro-Debugger/blob/master/CHANGELOG.md]CHANGELOG.md[/url] + +[size=22][b]🎮 Como se usa na prática / How it works in practice[/b][/size] + +🇧🇷 Você precisa de duas coisas: a extensão [b]PawnPro[/b] no editor e o plugin do debugger no seu servidor local — [b]uma vez só[/b]. Depois disso: + +[list] +[*][b]F5[/b] no gamemode. A extensão recompila com [b]-d3[/b], sobe o servidor e conecta sozinha. +[*][b]Clique na margem[/b] pra colocar um breakpoint. Botão direito nele: [b]condição[/b] ([b]playerid == 3[/b]), [b]contagem de acertos[/b] ([b]%10[/b] = a cada 10) ou [b]logpoint[/b] — que imprime [b]Vida: {health}[/b] no console [b]sem pausar[/b] e sem recompilar. +[*]Pausou: veja variáveis, mude o valor na hora, ande com [b]F10[/b]/[b]F11[/b] e navegue pelos frames da call stack. +[*]Botão direito numa variável → [b]Break on Value Change[/b] pra parar quando ela mudar. +[/list] + +🇬🇧 You need two things: the [b]PawnPro[/b] extension and the debugger plugin in your local server — [b]once[/b]. Then it's F5, breakpoints in the gutter (with conditions, hit counts and logpoints), stepping, and value-change breakpoints from the Variables panel. + +[size=22][b]🤔 E qual a diferença de não usar? / Why bother?[/b][/size] + +🇧🇷 Sem debugger, o ciclo num servidor SA-MP ou open.mp é conhecido: espalhar [b]printf[/b], recompilar, reiniciar, ler log, apagar os [b]printf[/b] — e repetir quando o problema volta. Quando o script dá [b]run time error[/b], você recebe o erro e reconstrói o resto de cabeça. + +Com o debugger, o programa [b]para no ponto[/b]: você vê o valor real de cada variável, quem chamou quem, e a [b]linha exata[/b] onde o erro ia acontecer — antes de a VM abortar. Sem recompilar pra investigar, sem sujar o código de log temporário. + +🇬🇧 Without a debugger the loop is printf → recompile → restart → read logs → delete the printfs. With it, execution [b]stops at the spot[/b]: real values, who called who, and the [b]exact line[/b] of a runtime error before the VM aborts. + +[quote] +🇧🇷 [b]Importante:[/b] isso é uma ferramenta de [b]desenvolvimento local[/b]. Enquanto a execução está pausada, o servidor fica congelado — é o comportamento esperado ao depurar passo a passo, e o motivo de não ser algo pra rodar em servidor de produção. + +🇬🇧 [b]Heads-up:[/b] this is a [b]local development[/b] tool. While paused, the server is frozen — expected when single-stepping, and why it isn't meant for production. +[/quote] + +[size=22][b]🎬 Em breve / Coming soon[/b][/size] + +🇧🇷 Estão a caminho [b]vídeos curtos[/b] mostrando como instalar a extensão, colocar o plugin no servidor e usar o debugger no dia a dia — mini-tutoriais diretos ao ponto. [b]Sem data definida[/b] ainda; sai quando estiver bom. +🇬🇧 [b]Short videos[/b] are on the way: installing the extension, setting up the plugin and using the debugger day to day. [b]No date yet[/b] — they ship when they're ready. + +[list] +[*][url=https://github.com/NullSablex/PawnPro][b]PawnPro[/b][/url] — 🇧🇷 extensão do editor (nova versão hoje) / 🇬🇧 the editor extension (new version today) +[*][url=https://github.com/NullSablex/PawnPro-Debugger][b]PawnPro Debugger[/b][/url] — 🇧🇷 este projeto / 🇬🇧 this project +[*][url=https://pawnpro-debugger.nullsablex.com/][b]Documentação / Docs[/b][/url] — 🇧🇷 começando, recursos e como funciona por dentro +[*][url=https://github.com/NullSablex/rust-samp][b]rust-samp[/b][/url] — 🇧🇷 SDK em Rust que sustenta o debugger / 🇬🇧 the Rust SDK underneath +[/list] + +[align=center][i]🇧🇷 Feedback, issues e PRs são bem-vindos — inclusive relato de caso que o debugger não pegou. +🇬🇧 Feedback, issues and PRs are welcome — including cases the debugger failed to catch.[/i] + +[url=https://github.com/NullSablex/PawnPro-Debugger][b]⭐ github.com/NullSablex/PawnPro-Debugger[/b][/url][/align]