/ 19 min de lectura
Análisis de seguridad del protocolo de Gen1recomp

Resumen. Analizamos el protocolo de red de Gen1recomp, una reimplementación de Pokémon Red, Blue y Yellow en LÖVE2D con juego en LAN y online. Los mensajes de batalla e intercambio no validan el tipo de sus campos, y un peer malicioso puede cerrar el cliente de otro jugador con un solo mensaje JSON. También documentamos un bypass del control anti-cheat, que se decide por completo en el cliente. Buscamos un camino hacia ejecución remota de código y no lo encontramos.
Contexto del análisis
Gen1recomp reimplementa Pokémon Red, Blue y Yellow en LÖVE2D. La lógica original salió de un proyecto de assembly para Z80; el port lee la ROM, usa sus datos y assets, y reconstruye el juego en Lua. Suma juego en LAN, un relay online, una cámara 3D opcional y brackets de torneo. Una publicación de TweakTown le dio visibilidad al proyecto, y con ella llegaron jugadores reales al juego online.

El protocolo de comunicación son mensajes JSON. Eso no lo vuelve inofensivo: en Lua, el parseo de JSON y el código que consume las tablas resultantes pueden destrozar un cliente igual.
Un protocolo de red es un parser expuesto a otra máquina. Los bytes necesitan framing y los tamaños, límites. El estado tiene que sobrevivir entre paquetes, y el receptor reconstruye datos estructurados a partir de lo que le manda un peer no confiable. En código nativo, ahí se busca corrupción de memoria. En cualquier lenguaje, la deserialización y los límites de las máquinas de estado concentran los supuestos.
Gen1recomp reúne todo eso en un código compacto y legible: transporte LAN y online, decodificación de JSON, una máquina de estado peer-to-peer, serialización de party, mensajes de batalla lockstep, intercambios y un relay para el juego online. Un protocolo nuevo, usuarios reales y un relay público lo vuelven un buen objeto de análisis.
Partimos de cuatro preguntas. ¿Podía un mensaje remoto cerrar el juego? ¿Podían datos de link malformados dar control sobre la memoria? ¿Había un RCE en el camino de deserialización del protocolo? ¿Podía el protocolo propagarse entre jugadores como un gusano?
Trabajo previo con el proyecto
Antes de escribir Lincoler desarrollamos dos mods para Gen1recomp:
-
Cheat Engine, una caja de herramientas en F7 / START con las funciones habituales de un trainer de Pokémon: curar, XP, spawnear, DVs perfectos, sin encuentros, god mode, OHKO, PP y objetos infinitos, cheats de movimiento, y más.
-
Intro Bypass, que saltea la introducción de Oak y te deja en el overworld después de elegir nombre, starter, shiny flag y nivel.
El proyecto original y su código fuente están en bryanthaboi/gen1recomp.
Desarrollar esos mods nos enseñó el sistema de extensiones desde adentro. Hooks, event buses, pantallas de estado, permisos de mods, save state y el handshake de link dejaron de ser partes abstractas del proyecto: eran lugares donde ya habíamos trabajado.
Así encontramos el toggle de anti-cheat online y los huecos a su alrededor.
Cómo funciona el juego online de Gen1recomp
Para entender si nuestro objetivo era siquiera viable, primero teníamos que entender qué intenta simular el protocolo de red.
El cable link original del Game Boy era un cablecito físico entre dos máquinas. Gen1recomp tiene que recrear esa relación a través de una LAN o a través de Internet, mientras sigue haciendo que ambos juegos crean que están progresando por la misma batalla al mismo tiempo.
Para una partida en LAN, un jugador se convierte en el HOST, abre un socket ENet en el puerto UDP 7777, y el otro jugador se conecta directamente a esa dirección IP como GUEST.
flowchart LR
A["Player A — HOST<br/>Net.lua"] <-->|"ENet UDP :7777<br/>reliable JSON"| B["Player B — GUEST<br/>Net.lua"]
El juego online toma otro camino: ambos clientes hacen una conexión TCP saliente al relay público en 147.182.215.255:7778, hosteado en DigitalOcean y operado por @bryanthaboi. El HOST recibe un código de sala corto;
El GUEST lo ingresa; el relay los empareja y reenvía el tráfico entre las dos conexiones. Es un relay TCP crudo, no un proxy HTTP.
flowchart LR
A["Player A — HOST"] <-->|"TCP<br/>JSON + newline"| R["relay<br/>:7778"]
R <-->|"TCP<br/>JSON + newline"| B["Player B — GUEST"]
El relay es puro transporte. No corre una batalla, no decide quién gana, ni entiende datos de Pokémon. Solo mueve mensajes. Net.lua es el módulo de networking del juego, la parte del cliente que gestiona el socket, delimita el tráfico y convierte bytes crudos en tablas de Lua. Una vez que Net.lua decodificó uno de esos mensajes, al resto del cliente no le importa si vino directo de un peer en LAN o a través del relay online: es simplemente una tabla de Lua recibida del otro lado.
En LAN, el juego usa lua-enet, una librería que corre sobre UDP pero garantiza entrega y orden: cada mensaje llega, y llega en el orden en que fue enviado. El juego online delimita los mismos mensajes como JSON separado por saltos de línea sobre TCP. En cualquiera de los dos casos, el protocolo por encima del transporte es JSON.
En este flujo es solamente un punto de reenvío: los mensajes malformados de este post apuntan al cliente receptor, no al relay en sí.
Todo el trabajo del relay es presentar a dos desconocidos y después quitarse del medio. Ocurre en dos fases.
Fase uno: el handshake. El HOST le dice al relay que quiere abrir una sala, y el relay responde con un código corto. El GUEST ingresa ese código, el relay lo verifica, y le avisa a ambos lados que están emparejados:
sequenceDiagram
participant H as HOST
participant R as relay
participant G as GUEST
H->>R: { "type": "host" }
R->>H: { "type": "hosted", "code": "ABC123" }
G->>R: { "type": "join", "code": "ABC123" }
R->>H: { "type": "paired" }
R->>G: { "type": "paired" }
Fase dos: reenvío. Desde ese momento, el relay deja de pensar. Cada línea JSON completa que recibe de un lado se copia al otro tal cual. Nunca mira adentro:
sequenceDiagram
participant H as HOST
participant R as relay
participant G as GUEST
H->>R: { "type": "action" }
R->>G: { "type": "action" }
Note over R: nunca se inspecciona
G->>R: { "type": "hash" }
R->>H: { "type": "hash" }
Ese es todo el relay: un buzón entre dos sockets. Sin lógica de batalla, sin validación, sin estado. Y por eso exactamente funciona el ataque de este post: el relay va a reenviar sin objeciones un mensaje que ningún cliente legítimo mandaría jamás.
Antes de que empiece una batalla, ambos clientes intercambian los datos de su party. El HOST también provee la seed compartida de RNG usada para la simulación lockstep: el único número que ambas máquinas usan para tirar resultados “aleatorios” idénticos. Un mensaje de party normal se ve así:
{ "type": "party", "mons": [ { "species": "RATTATA", "level": 5, "hp": 11, "moves": [ { "id": "TACKLE", "pp": 35 } ] } ], "seed": 42}Las batallas corren en lockstep. Ambos clientes simulan toda la pelea localmente a partir de los mismos datos de party y la seed de RNG compartida. Cada evento aleatorio (si un ataque pega, si es crítico, cuánto daño hace) sale de una fórmula determinista sembrada con ese único número, así que ambas máquinas tiran resultados idénticos y las dos simulaciones se mantienen en sincro. En cada turno intercambian una acción, simulan el mismo resultado, y comparan un hash de estado. El relay nunca necesita entender si Tackle pegó, si un Pokémon se debilitó, o si alguno de los dos resultados era válido. Ambos clientes hacen ese trabajo de forma independiente.
La parte importante es menos elegante: cuando un jugador desaparece, el lado que sobrevive ve net.closed y la partida termina en empate. No se declara ganador: la batalla se cae y el juego vuelve al overworld.
Esto tiene una consecuencia práctica: un jugador que va perdiendo puede cerrarle el juego al otro en lugar de aceptar la derrota. Un crash no equivale a un empate. El proceso muere, el socket muere con él, y el otro lado queda esperando a un peer que nunca responde.
Así que nos pusimos a ver cómo un mensaje recibido se convierte en una acción. Después de que Net.lua decodifica una línea, Session:update() lee msg.type y le entrega el mensaje al handler correcto: LinkState para el lobby, LinkBattle para la pelea, TradeSession para los intercambios. Lo único que se valida en algún lado es que msg.type sea un string. Todo lo demás (slot, seed, mons, index, parts, pick) se acepta sin más y se pasa directo a la simulación.
Ahí es donde viven las variables controlables: campos del wire que terminan en operaciones aritméticas, indexación o acceso a tablas sin ninguna verificación de tipo en el medio. Si alguno de ellos pudiera dirigir el estado interno del juego remoto, sería justamente ahí.
Antes de poder convertir cualquiera de eso en un crash, necesitábamos saber qué pasa cuando la simulación se encuentra con un valor malo. Lua tiene una red de seguridad incorporada exactamente para esto: pcall (protected call), el equivalente de try/catch en otros lenguajes. Si una llamada se envuelve en pcall, un error interno se convierte en un valor de retorno en lugar de un crash. Gen1recomp usa pcall por todos lados: alrededor de llamadas a ENet, I/O de archivos, incluso la integración con Discord.
El game loop no es uno de esos lugares:
-- src/core/Game.luaif self.linkNet and not self.linkNet.closed then self.linkNet:update()endself.stack:update(dt)linkNet:update() es donde un mensaje recibido se decodifica y se despacha. No hay pcall alrededor, y tampoco alrededor de stack:update(dt). Así que cuando un mensaje hace que la simulación tire un error, por ejemplo con math.floor({}), el error no se convierte en una desconexión elegante. No se convierte en un net.closed ni en un empate. Se propaga hacia arriba a través del game loop y fuera del proceso. Lo último que ese jugador ve es el proceso cerrándose.
El proceso muere.
Un bug simple
En Lua faltan las primitivas clásicas de la familia C: memcpy, el stack smash y la aritmética de punteros escondida detrás de un casteo erróneo.
Lo que sí ofrece es tipado dinámico. Una variable no declara lo que contiene: el valor lleva su propio tipo. Al parser de JSON no le importa qué se supone que es un campo: 5 se convierte en un número, "five" se convierte en un string, y {} se convierte en una tabla. Nada verifica que el campo coincida con lo que el juego espera. El juego solo se entera cuando intenta usar el valor. Si el código asume que un campo entrante es numérico (por ejemplo, se lo pasa a math.floor o lo suma con algo) pero el emisor puso una tabla en su lugar, la falla ocurre en esa primera operación numérica.
El patrón recurrente en Gen1recomp se ve así:
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))El autor claramente esperaba que packed.level fuera un número o estuviera ausente. Pero or solo sustituye el default para nil y false. Un objeto JSON se convierte en una tabla de Lua, que es truthy.
Así que esto es JSON legal:
{"level":{}}Y esto es lo que el receptor hace con eso:
math.floor({})Lua tira bad argument #1 to 'floor' (number expected, got table). El game loop no lo atrapa. Fin del proceso.
El patrón común es más fácil de ver como un flujo de taint:
flowchart TD
A["campo JSON controlado por el atacante"] --> B["Net:poll() → Session:update()"]
B -->|"solo valida type(msg.type) == string<br/>no valida slot / seed / mons / ..."| C["handler de link o intercambio"]
C --> D["math.floor(campo) / tabla[campo] / aritmética"]
D --> E["excepción de Lua"]
E --> F["Game:step no tiene pcall"]
F --> G["el cliente se cierra"]
Los payloads seleccionados
Encontramos más campos malformados mientras leíamos el código. No todos son igual de útiles, y no convertimos cada crash teórico en una opción del menú: el mod trae ocho payloads, repartidos entre los estados donde tienen sentido. Vale la pena mostrar tres, porque cada uno mata al objetivo en un momento distinto de la partida.
Action recomposition attack
Cuando un jugador elige un movimiento, el juego manda algo así:
{ "type": "action", "kind": "move", "slot": 2}Del otro lado, el mensaje llega como una tabla de Lua. Net.lua decodifica la línea JSON, la deja en la bandeja de entrada, y el battle loop la agarra:
-- src/link/LinkBattle.luafor _, msg in ipairs(net:poll()) do if msg.type == "action" then s.remoteAction = msg tryResolve(s)tryResolve se lo pasa a decodeWireAction, que lee msg.slot (el índice del movimiento en la lista de movimientos del enemigo) y lo resuelve al movimiento real:
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))return battler.curMoves[slot]Ese es todo el significado del mensaje: el enemigo ejecutó el comando para el movimiento del slot 2 de su Pokémon. El chequeo de límites mantiene el índice dentro de la lista de movimientos, y msg.slot or 1 provee un default cuando el campo falta.
Ahora la type confusion. El formato del wire es JSON, y Lua tiene tipado dinámico: al parser no le importa que slot se supone que es un número. Simplemente decodifica lo que haya: 2 se convierte en un número, "two" se convierte en un string, {} se convierte en una tabla. Lo único que se valida en algún lado es que msg.type sea un string. Así que slot puede ser cualquier cosa que JSON pueda expresar, y el juego no se va a enterar hasta que intente usarlo.
Ejemplo: manipulación de entrada sin sanitizar
Mensaje del atacante:
{ "type": "action", "kind": "move", "slot": {}}Parámetro manipulado: msg.slot
El parámetro manipulado se usa en el juego remoto.
Código vulnerable:
-- src/link/LinkBattle.lua:576s.remoteAction = msg-- src/link/LinkBattle.lua:72local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))Resultado esperado:
El crash ocurre cuando el juego del jugador remoto intenta llamar a math.floor con un valor no numérico:
math.floor({}) -> Math operation over a table CRASHHash desync attack
Cuando un turno se resuelve, ambos clientes mandan un hash del resultado simulado para probar que están de acuerdo:
{ "type": "hash", "turn": 1, "value": "25:87:0|94:120:0", "parts": { "actives": "...", "volatile": "...", "bench": "..." }}Del otro lado, el battle loop lo almacena y corre el chequeo de determinismo:
-- src/link/LinkBattle.luaelseif msg.type == "hash" then s.remoteHashes[msg.turn or 0] = msg.value s.remoteParts[msg.turn or 0] = msg.parts checkHashes(s)checkHashes recorre los tres componentes (actives, volatile, bench) y compara cada uno contra la simulación local:
for _, component in ipairs(PARTS) do if mine[component] ~= theirs[component] thenEn términos simples, el peer está diciendo: ambos simulamos el mismo turno, y acá está la prueba de que los dos clientes siguen coincidiendo. value es el resumen; parts es el desglose.
Ahora la type confusion. Se supone que parts es una tabla con esas tres keys. Pero nada valida eso: el único chequeo en cualquier lado es que msg.type sea un string. Así que parts puede ser cualquier cosa que JSON pueda expresar, y el juego no se va a enterar hasta que intente indexarlo.
Ejemplo: manipulación de entrada sin sanitizar
Mensaje del atacante:
{ "type": "hash", "turn": 1, "value": "x", "parts": 123}Parámetro manipulado: msg.parts
El parámetro manipulado se usa en el juego remoto.
Código vulnerable:
-- src/link/LinkBattle.lua:580s.remoteParts[msg.turn or 0] = msg.parts-- src/link/LinkBattle.lua:370if mine[component] ~= theirs[component] thenResultado esperado:
El crash ocurre cuando el juego del jugador remoto intenta indexar un número como si fuera una tabla:
123["actives"] -> Indexing a number CRASHAsí que con este payload la batalla se resuelve normalmente, el turno transcurre, y recién entonces el objetivo muere verificando si el turno fue determinista. La demora es justamente el punto: el otro cliente cree que todo está bien.
Zero-index trade attack
Cuando un jugador elige un Pokémon para intercambiar, manda algo así:
{ "type": "pick", "index": 1}Del otro lado, la sesión de intercambio almacena la elección y avanza la negociación:
-- src/link/Protocol.luaelseif msg.type == "pick" then self.theirPick = msg.index self:advance()Cuando ambos lados confirmaron, el intercambio se aplica, y la elección se convierte en un índice dentro de la party que la víctima mandó:
local received = self.theirParty[self.theirPick]received.traded = trueLeído literalmente, el mensaje significa: dame el Pokémon en el slot N de la party que mandaste. El juego asume que ese índice apunta a un miembro real del equipo.
Ahora la type confusion. Los arrays de Lua empiezan en uno, pero el cero también es truthy, y nada valida que index sea un número positivo. El único chequeo en cualquier lado es que msg.type sea un string. Así que index puede ser cualquier cosa que JSON pueda expresar, y el juego no se va a enterar hasta que intente usarlo.
Ejemplo: manipulación de entrada sin sanitizar
Mensaje del atacante:
{ "type": "pick", "index": 0}Parámetro manipulado: msg.index
El parámetro manipulado se usa en el juego remoto.
Código vulnerable:
-- src/link/Protocol.lua:323self.theirPick = msg.index-- src/link/Protocol.lua:375local received = self.theirParty[self.theirPick]-- src/link/Protocol.lua:377received.traded = trueResultado esperado:
El crash ocurre cuando el juego del jugador remoto intenta leer un campo de un Pokémon que no existe:
theirParty[0] -> nil.traded CRASHAsí que con este payload el intercambio crashea, pero no roba un Pokémon. El save se escribe después de aplicar el intercambio, y el crash ocurre antes de que el estado de la víctima llegue a confirmarse.
El bypass del anti-cheat online
Esta parte es breve y sencilla.
Gen1recomp normalmente bloquea el juego online cuando un mod cargado cambia estado relevante al link. Toda la barrera vive en src/link/Handshake.lua, y se reduce a una sola pregunta: ¿este cliente puede jugar online?
-- src/link/Handshake.lua:124function Handshake.onlineBlockers(game) local blockers = {} for _, entry in ipairs(Handshake.mods(game)) do local allowed = entry.language and not entry.affectsLink and translationOnly(mod, entry.id) if not allowed then blockers[#blockers + 1] = entry end end return blockersend
-- src/link/Handshake.lua:145function Handshake.onlineAllowed(game) return #Handshake.onlineBlockers(game) == 0endEstas tres funciones forman la barrera. linkModified (línea 53) es el chequeo más amplio: ¿algún mod escribió en un registro de la superficie de link? onlineBlockers (línea 124) es la lista concreta: cada mod habilitado que no sea una traducción verificada (una traducción no cuenta, porque el texto no toca la simulación). onlineAllowed (línea 145) es la única pregunta que el cliente se hace antes de ir online: ¿esa lista está vacía? Si no lo está, el cliente se niega a conectarse. Todo esto es local: el relay nunca ve la decisión.
Nuestro Cheat Engine Mod agrega un toggle de CHEATS ONLINE. Con el toggle apagado, la barrera normal sigue en su lugar. Con él encendido, el mod guarda los originales y reemplaza las funciones en runtime:
-- cheat_engine/main.lua:121Handshake.onlineAllowed = function() return true endHandshake.onlineBlockers = function() return {} endHandshake.mods = function() return {} endHandshake.linkModified = function() return false endEso es suficiente para hacer que el cliente local se reporte como vanilla mientras sigue corriendo mods. Así es como se propaga la mentira: cuando el cliente se empareja, manda un mensaje hello al peer, y ese hello se construye llamando a Handshake.mods, Handshake.linkModified, y la fingerprint. Una vez que esas funciones mienten, el hello también miente, y el peer lee un cliente vanilla. Y como el mod guardó los originales primero, apagar el toggle restaura la barrera normal.
No hay validación de mods del lado del servidor. El relay no inspecciona el handshake. Solo mueve mensajes. La decisión de anti-cheat ocurre enteramente en el cliente que quiere hacer trampa.
F7 → CHEATS ONLINE: [OFF] ← barrera normalF7 → CHEATS ONLINE: [ON] ← cliente modeado se presenta como vanilla
Lincoler: los bugs convertidos en un menú del juego
Con el bypass de anti-cheat resuelto, el siguiente paso fue convertir los payloads en un mod: Lincoler, de link killer.

Lincoler es deliberadamente pequeño. Se trata de un menú que conoce el estado de la sesión online actual y expone solo los payloads que tienen sentido en ese estado.
flowchart TD
M(["WIRE ATTACK"])
M --> BATTLE
M --> TRADE
subgraph BATTLE["EN BATALLA"]
direction TB
B1["action slot={}"]
B2["action switch idx={}"]
B3["hash parts=123"]
end
subgraph TRADE["EN INTERCAMBIO"]
direction TB
T1["TRADE CRASH pick=0"]
end
classDef direct fill:#181B2A,stroke:#57FD6B,color:#F5F5F5
classDef spam fill:#181B2A,stroke:#F5A524,color:#F5F5F5
class B1,B2 direct
class B3,T1 spam
action slot={} y action switch idx={} se disparan una sola vez, directo.
hash parts=123 y TRADE CRASH pick=0 se repiten cada tres frames.
El menú de batalla no es el único. La pantalla de intercambio tiene la misma debilidad, así que el menú también lleva un payload de intercambio.
El HOST recibe las opciones pre-batalla y pre-intercambio porque controla la transición de modo y manda el paquete de party inicial. Esos payloads son la misma familia de bugs, un mensaje antes, pero no vamos a entrar en ellos acá: el foco de este post es la batalla y el intercambio. Para ver los payloads del HOST, el código es abierto: Lincoler en GitHub.
El laboratorio fueron dos procesos LÖVE locales en macOS con valores distintos de POKEPORT_IDENTITY, para que sus directorios de save no colisionaran:
POKEPORT_IDENTITY=pokemon-attacker love gen1recomp-src/POKEPORT_IDENTITY=pokemon-victim love gen1recomp-vanilla/En el video de abajo se puede ver a Lincoler explotando el Action recomposition attack en medio de una batalla.
Buscamos caminos hacia RCE
Un DoS que cierra clientes es un hallazgo acotado. Una ejecución remota de código habría sido de otra categoría.
Revisamos las superficies habituales de ejecución de Lua/LuaJIT: load, loadstring, require dinámico, os.execute, io.popen, FFI, trucos de metatables, round-trips de archivos de save, y callbacks de mods que reciben datos de Pokémon provistos remotamente.
Las defensas resultaron mejores de lo esperado.
El sistema de save es el ejemplo más fuerte. Los archivos de save parecen código fuente Lua (se codifican como return {...}), lo cual inicialmente parecía prometedor. Pero no se cargan con load(). SaveSerializer tiene su propio parser de gramática restringida: literales escalares, strings escapados, tablas planas, un límite de profundidad. Un nickname que parece código sigue siendo solo un string cuando el save se vuelve a cargar.
La FFI de LuaJIT está presente, pero vive en otro lado: helpers de filesystem, Discord IPC, llamadas a shell y SDL específico de plataforma. El protocolo de link pasa por ENet o luasocket. No pudimos hacer que datos de link controlados por el atacante llegaran ni cerca de una llamada a ffi.C.
Quedan hallazgos secundarios de denegación de servicio: un buffer de recepción TCP sin límite, un leak lento en el almacenamiento de hashes remotos, y ningún límite explícito de anidamiento de JSON. Ninguno era un payload limpio de un solo mensaje, así que no los convertimos en botones del menú.
Buscábamos ejecución remota de código y un payload wormable, y no encontramos ninguno. El resultado es un crash remoto con un solo mensaje, que afecta la disponibilidad del cliente del otro jugador.
Uso responsable. Este análisis es una investigación de seguridad. El ataque apunta a un cliente de juego vulnerable y no al relay de Gen1recomp ni a otra infraestructura. No lo uses contra jugadores reales.
Archivos fuente relevantes:
| Archivo | Rol |
|---|---|
src/link/Net.lua | Transporte ENet/TCP |
src/link/Session.lua | wrapper de transporte y validación de envelope |
src/link/Protocol.lua | Serialización de Pokémon e intercambios |
src/link/LinkState.lua | UI de link y máquina de estado |
src/link/LinkBattle.lua | Implementación de batalla lockstep |
src/link/Handshake.lua | Compatibilidad y barrera de mods online |
src/core/Game.lua | Game loop |
src/core/SaveSerializer.lua | Parser de save restringido |
Repos:
- Cheat Engine: https://github.com/hdbreaker/gen1recomp-mod-cheat_engine
- Intro Bypass: https://github.com/hdbreaker/gen1recomp-mod-intro_bypass
- Lincoler: https://github.com/hdbreaker/gen1recomp-mod-lincoler
- Gen1Recomp Project: https://github.com/bryanthaboi/gen1recomp
- Rama upstream usada para la contribución preparada:
bryanthaboi:dev
Nota sobre IA. Usamos OpenCode como entorno de agente, GLM 5.2 para razonamiento y DeepSeek V4 Flash para implementación y testing.