# `fonts/` — las fuentes que viajan CON el cliente

Esta carpeta existe para que el cliente deje de depender de que cada jugador
tenga instalada la fuente de la interfaz. **Desde el 2026-09-11 ya no depende**:
dentro va `selawik semibold.ttf` (44 KB, SIL OFL 1.1) y `locale_game.txt` la
pide como `UI_DEF_FONT`, así que la interfaz sale igual en la máquina de
cualquiera. Abajo está lo que hay que saber para tocarla.

## Qué la lee

`CFontManager::ResolveFontPath` (`m2dev-client-src/src/EterLib/FontManager.cpp`)
busca en este orden:

1. un **alias** conocido (12 escritos en `FontManager.cpp`), que traduce
   familia → nombre de fichero;
2. **`fonts/<fichero>`**, esta carpeta;
3. `C:\Windows\Fonts\<fichero>`;
4. la variante `.ttc` de lo mismo.

O sea que lo que se ponga aquí **gana** a la copia de Windows del jugador.

⚠️ Lo lee con `stat()`, **no** con el gestor de packs. El `.ttf` tiene que ir
como **fichero suelto**: meterlo en un `.pck` no sirve de nada.

## Qué pasa si la fuente no aparece

**Degrada a Tahoma; ya no deja el cliente mudo.** `CreateFace` llama a
`CreateFallbackFace` (`m2dev-client-src/src/EterLib/FontManager.cpp:168`), que
prueba **Tahoma, Arial y Segoe UI** en ese orden — y hace lo mismo si el fichero
está pero FreeType no puede abrirlo. La interfaz sale más fea de lo que se pidió,
pero se lee, que es lo que deja volver al menú y deshacerlo.

⚠️ Aquí ponía «`CreateFace` devuelve `nullptr` y **el texto no se dibuja**». Fue
cierto hasta que existió ese respaldo y ya no lo es — lo explica el comentario de
esa misma función, que nació justo de ese modo de fallo. Corregido el 2026-09-11
midiéndolo en una instalación real.

El rastro son **dos líneas por familia que falle**, en el `log/syserr.txt` de ese
jugador, y no salen de su máquina (`g_pkExceptionSender` es `NULL`). Así salieron
al renombrar la fuente a propósito:

```
CFontManager::ResolveFontPath - Could not find font: selawik semibold
CFontManager: 'selawik semibold' no resuelve; se usa 'Tahoma' en su lugar
```

O sea que el fallo sigue siendo **invisible desde el lado que publica**, y por eso
lo vigila `tools/comprobar_fuentes.py`, que aborta.

## Cómo se comprueba que la que viaja es la que se USA

Que el `.ttf` esté en la carpeta no prueba que el cliente lo cargue, y hay una
trampa que se come la prueba entera: **`UI_FONT` de `metin2.cfg` gana al
`UI_DEF_FONT` del locale** (`CPythonSystem`, `szUIFont`: vacío = la del locale).
En cualquier máquina donde se hayan probado fuentes esa clave está puesta, así que
la interfaz sigue saliendo con la copia de Windows y la que viaja **no se carga
nunca** — y la prueba sale verde sin haber probado nada. Se quita borrando la
línea; al cerrar, el juego la reescribe vacía, que es lo mismo.

Lo que sí vale es un **control positivo**: renombrar el `.ttf` y ver que el cliente
cae a Tahoma y lo escribe en el syserr. Con sus dos trampas, las dos medidas el
2026-09-11:

1. **Mirar antes `C:\Windows\Fonts`.** Si esa familia está instalada en el
   sistema, el paso 3 de `ResolveFontPath` la encuentra igual y el control no
   distingue nada.
2. **Abrir `Metin2.exe` directamente, NO el launcher.** La fuente está en el
   `manifest.json`, así que el launcher ve que falta y **se la vuelve a bajar antes
   de arrancar**: el verde sería suyo y no del cliente.

Hecho así en la instalación de producción el 2026-09-11, con el parche 92 recién
publicado: sin la fuente, texto en Tahoma y las dos líneas de arriba; con ella,
`syserr.txt` de **0 bytes** con el cliente abierto. Y para que ese cero signifique
algo hay que saber que ese fichero **se trunca en cada arranque**
([`SYSERR.md`](../../m2dev-client-src/SYSERR.md)), así que comparar el número de
líneas entre dos partidas no dice nada.

## La regla que no se puede saltar

**Solo entra aquí una fuente que se pueda REDISTRIBUIR.** Publicar el cliente
con un `.ttf` dentro es distribuir esa fuente, y las de Microsoft lo prohíben
en su propia tabla `name`, en una línea que no admite lectura:

> Microsoft supplied font. You may use this font to create, display, and print
> content as permitted by the license terms […] **Any other use is prohibited.**

Eso cubre **Tahoma, Verdana, Segoe UI y Segoe UI Semibold** — comprobado leyendo
el `name ID 13` de cada fichero el 2026-08-31. El `fsType: Editable` que traen
no cambia nada: eso regula incrustar en un *documento*, no repartir el fichero.

La consecuencia práctica: la interfaz puede **usar** esas fuentes (cada jugador
pone su copia de Windows), pero el cliente **no puede llevarlas**.

`tools/comprobar_fuentes.py` lee la licencia embebida de todo lo que haya aquí
y **aborta** si encuentra una que no se pueda distribuir. No es un recordatorio:
es un código de salida distinto de cero, porque un párrafo en un documento no
ha frenado nunca un despliegue.

### Y la fuente que no declara NADA (2026-09-12)

Hay un tercer caso que no es «libre» ni «prohibida»: una fuente cuya tabla
`name` **está vacía** en los campos de licencia. Le pasa a **Twemoji Mozilla**
— los campos 0, 13 y 14 no traen una letra, medido.

«No dice nada» **no es permiso**, y eso es lo que el guardia contestaba: la
bloqueaba. La salida no fue ablandarlo, sino admitir que la licencia se declare
en un fichero **al lado**, `<fuente sin extensión>.LICENCIA.txt`, con **una
condición que no se puede saltar**:

> Un fichero al lado puede **suplir** una licencia que falta.
> **Nunca** puede levantar una restricción escrita dentro de la propia fuente.

O sea que sólo se mira cuando la fuente calla. A una de Microsoft se le puede
poner al lado un `.LICENCIA.txt` que jure que es OFL y **sigue bloqueada**, por
lo que dice su propia tabla. Eso no es una promesa: es el **caso 5** de
`--sabotaje`, que hace exactamente eso y exige que salte *por el motivo de la
fuente* y no por el del fichero.

⚠️ Y una trampa que costó una vuelta el mismo día, porque es la de siempre en
este proyecto con otra cara: **el `.LICENCIA.txt` lo lee un guardia que busca
palabras**, así que no puede citar literalmente una frase de la lista de
prohibidas. La primera versión explicaba dentro por qué las fuentes de
Microsoft están vetadas, citando su frase — y se bloqueó a sí misma. Lo que
explica el mecanismo va **aquí**; el fichero de licencia se queda en ser una
licencia.

## Lo que hay dentro

| | |
|---|---|
| `selawik semibold.ttf` | 44 KB. **Selawik Semibold**, la sustituta de Segoe UI que publicó la propia Microsoft bajo **SIL OFL 1.1**. Elegida el 2026-09-11 |
| `OFL.txt` | su licencia. La OFL **exige** que viaje con la fuente, y viaja porque `publish_patch.bat` espeja la carpeta entera |
| `Twemoji.Mozilla.ttf` | 1,4 MB. **Twemoji Mozilla v0.7.0**, la fuente de **emojis a color**. Bajada el 2026-09-12 de `github.com/mozilla/twemoji-colr`, byte a byte como la publican (md5 `aac75d90…`) |
| `Twemoji.Mozilla.LICENCIA.txt` | su licencia y su **procedencia medida**. Es obligatorio que viaje: el arte es CC-BY 4.0 y **exige atribuir** |
| `eb garamond semibold.ttf` | 687 KB. **EB Garamond SemiBold** (Georg Duffner / Octavio Pardo, **SIL OFL 1.1**, la misma `OFL.txt`). Entró el 2026-09-22 con la ventana de la montura rehecha como el prototipo J: es la letra de todo lo VIVO de esa ventana (`fontname: "EB Garamond SemiBold:N"` en `assets/uiscript/uiscript/monturawindow.py`). El nombre en minúsculas con el espacio es el que `ResolveFontPath` busca, como con Selawik |
| `eb garamond bold.ttf` | 687 KB. **EB Garamond Bold**, misma familia y licencia: los títulos, los valores y las líneas en negrita de esa ventana (`"EB Garamond Bold:N"`). Las dos tienen las mismas métricas hhea (1007 / −298 sobre 1000), y de ahí sale la corrección de línea base de `propuestas_interfaz/montura/rework_j/geometria.py`; `tools/montura_arte.py --comprobar` las cruza con fontTools. **Cinzel no viaja**: lo que el prototipo pinta en Cinzel va horneado en TGA |

**De Twemoji hay que saber dos cosas antes de tocarla.**

La primera es el **formato**, que no es un detalle: es **COLR versión 0**, y ése
es el único que FreeType compone **él solo** dentro de `FT_Render_Glyph`
(`ftobjs.c:4768-4830`), devolviendo el emoji ya pintado. Una fuente de emoji en
**bitmap** —CBDT o sbix, como el Noto Color Emoji clásico— **no valdría**: pide
`FT_CONFIG_OPTION_USE_PNG`, que está comentado en `ftoption.h:276`, y libpng,
que no está en `vendor/`. Sustituirla por otra sin mirar el formato deja los
emojis en blanco y negro o en nada, **sin un error en ningún log**.

La segunda es que **el nombre lleva mayúsculas**. `ResolveFontPath` cae a
`minúsculas(familia) + ".ttf"`, así que `Twemoji.Mozilla.ttf` **no se resuelve
por familia**: la carga el motor por ruta explícita, no por nombre. Es la
diferencia con `selawik semibold.ttf`, que sí se resuelve sola.

El fichero se llama `selawik semibold.ttf` — con el espacio — y **no es un
descuido**: `ResolveFontPath` cae a `minusculas(familia) + ".ttf"` cuando la
familia no tiene alias, así que ese nombre exacto es lo que la hace resoluble
**sin tocar el `.exe`**. Renombrarlo la deja invisible.

⚠️ Hasta el 2026-09-11 aquí ponía que una familia sin alias «hace falta además
su línea en `FontManager.cpp` — que es cambio de `.exe`». **Es falso**, y se
vio leyendo `ResolveFontPath`: el alias sirve para traducir familia → fichero
cuando los dos nombres no coinciden (`segoe ui semibold` → `seguisb.ttf`), pero
si el fichero se llama como la familia no hace falta ninguno.

## Las letras que la fuente no tiene (2026-09-28)

Un carácter que la fuente de la interfaz no tiene **no da error**:
`UpdateCharacterInfomation` (`m2dev-client-src/src/EterLib/GrpFontTexture.cpp`)
pinta en su sitio el glifo del **espacio**, sin una línea en ningún log. Y sólo
se ve con la fuente que viaja: quien elige otra en el launcher (`UI_FONT`:
Tahoma, Segoe UI…) la ve bien, que es por lo que nadie lo notó. Así salían el
número de miembros del gremio («N  de miembros») y la tecla de recoger de la
ayuda (H): los dos llevaban la `º`, que Selawik no tiene.

**Los textos nuevos los eligió Luis** el mismo 28-09, con la vista previa
delante (se lo dijo a otra sesión, la de `texto_dentro`). En la **ayuda**,
«Recoger Objetos: Z, la tecla bajo Esc o botón izquierdo del ratón»: la tecla es
`DIK_GRAVE`, que el cliente lee por **posición** con DirectInput (`Input.cpp`), y
lo que lleva impreso depende del teclado —`º` en el de España, `|` en el
latinoamericano—. En el **gremio**, la columna entera: «Nombre», «Nivel», «Exp.
faltante» y «Miembros». Es que la casilla del valor —`Parameter_Slot_03.sub` en
tres de ellas y un `slotbar` en la del nivel, opacos los dos— empieza **a 70 px**
de la etiqueta y se pinta **encima** de ella (`CWindow::Render`: primero el
texto, luego sus hijos), así que «Nombre Gremio» (88 px), «Nivel del Gremio»
(89), «Exp. Necesaria» (77) y «Nº de miembros» (86) salían **cortadas**, con la
`º` o sin ella —medido con la cuenta del motor, `texto_dentro.ancho_motor`—. Eso
no lo vigilaba nadie, y desde el 2026-09-29 lo vigila la regla `texto_tapado` de
`tools/auditar_maqueta.py` (su cabecera cuenta cómo mide), cuyo `--sabotaje`
reproduce justo este caso con los cuatro textos de antes.

De lo que se escribe en español, Selawik tiene todas las letras y la puntuación
de siempre —`¡ ¿ á é í ó ú ü ñ « » “ ” ‘ ’ … — – · • € ° ± × ÷ © ® ™`— y le
falta esto, medido contra su `cmap` Unicode el 2026-09-28:

| Falta | Qué es |
|---|---|
| `º` `ª` | los indicadores ordinales (U+00BA, U+00AA): «N.º», «1.ª» |
| `→` `←` `↑` `↓` | las flechas |
| `✓` `✕` `★` `♥` | símbolos (para cerrar, `×`, U+00D7, que sí está: lo usa el chat) |
| U+00A0, U+2009, U+202F | espacios especiales: **no importan**, caen al espacio, que es lo que son |

**Lo vigila `tools/comprobar_fuentes.py`, regla 4, y aborta** (el preflight lo
corre y bloquea la publicación): lee **todos los `.txt` de
`assets/locale/locale/mx/` y `es/`** —menos `insult.txt`, que no se pinta: es la
lista contra la que se filtra el chat— y exige que cada carácter esté en la
fuente que pide su `UI_DEF_FONT` (y `_LARGE` y `_SMALL`), resuelta como el
motor; y desde el 2026-09-29, también lo que manda el servidor y los nombres
de objetos y monstruos (sus apartados, abajo). Tres cosas de cómo mira, porque
cada una es un fallo que ya estaba escrito en otra comprobación:

1. **La fuente de `UI_DEF_FONT`**, no la de `UI_FONT` —la que elija cada
   jugador es suya y cambia de máquina en máquina— ni «la primera `.ttf` de la
   carpeta». Ése era el criterio de la regla hermana de `probar_emoji.py`
   (`glifos_en_la_fuente`, la del chat), y desde el 2026-09-22, que entró EB
   Garamond —que va antes que Selawik por orden alfabético y **sí** tiene `º`,
   `ª` y las flechas—, estaba midiendo contra EB Garamond. Ahora las dos
   preguntan a `comprobar_fuentes.fuente_de_la_interfaz()`.
2. **El `cmap` Unicode**, el que FreeType elige solo al abrir la cara
   (`FontManager.cpp` no llama a `FT_Select_Charmap`), **no la unión de todas
   las subtablas**: Selawik trae además una MacRoman cuyas claves son bytes
   MacRoman y no Unicode, y la unión daba por presentes **35** códigos que no
   tiene —la `ª` y el U+00A0 entre ellos—. Era el otro fallo de la regla del
   chat, y ahora las dos usan `comprobar_fuentes.cmap_del_motor()`.
3. **Los espacios no cuentan** (categoría `Zs` de Unicode), ni el juntador
   U+200D y los selectores U+FE00-U+FE0F, que el motor no pinta a propósito.
   `itemdesc.txt` separa los miles de los elixires con U+00A0 (14 veces, en mx
   y en es) y se ve bien.

Lo que **ya tiene dueño** va en `PENDIENTES`, con el texto **exacto**. Si ese
texto cambia, la excepción deja de casar y el guardia **avisa** de que sobra —no
bloquea: el arreglo de otro no se queda frenado por esta lista—, y una letra que
falte en otra clave, o en ésa con otro texto, bloquea igual. **Hoy está vacía**:
la estrenó la `→` de `FORJA_MEJORA_SUBE` mientras la tarea de `texto_dentro` la
cambiaba a «de %d %% a %d %%»; entró en `main` el 2026-09-28 (`ffcc8074e`) y, al
meter `main` en la rama de esta regla, su entrada avisó de que sobraba, que es
justo lo que tenía que hacer.

Su `--sabotaje` pasa de 5 a **14** casos, y ninguno de los nuevos escribe en
`assets/` ni en `fonts/`: trabajan sobre una copia en un temporal. Los de la
excepción se traen la suya, para no depender de lo que haya en la lista, y uno
es un control que tiene que **callar** (una excepción activa tapa su texto):
rompiendo a propósito el mecanismo, ése es el que salta. Se niega a
sabotear si el modo normal ya falla, porque entonces cualquier caso «salta» por
lo que ya estaba roto. Y uno de los nuevos es la `ª`, que está en EB Garamond y
en la tabla MacRoman de Selawik, así que sólo salta mirando lo que hay que
mirar; `probar_emoji.py` lleva desde el mismo día ese caso, y **con su regla de
antes callaba** (medido pareado, con la misma `uichat.py` saboteada).

**Lo que NO mira**, medido:

- **los literales escritos dentro de los `.quest` y de `questlib.lua`**. Lo que
  manda el servidor desde `translate.lua` sí lo mira, desde el 2026-09-29 (su
  apartado, abajo). De lo demás, medido ese día: las 75 quests activas llevan
  31 literales con letras fuera del ASCII y ninguno pide una letra que falte,
  salvo uno de `hair.quest` que ni siquiera es UTF-8 (un alemán en latin-1:
  «wäre») y está en código muerto (el `when` pide `original_vnum == 0` y el `if`
  de dentro, `!= 0`). Los 7 literales coreanos sin convertir de `questlib.lua`
  son de `giveup_quest_menu`, que no llama ninguna quest;
- **los literales del Python**, salvo los del chat, que mira `probar_emoji.py`;
- **las otras fuentes que pide una ventana**: la de la montura usa EB Garamond
  (`fontname` en su uiscript), que tiene más letras que Selawik. La regla lo
  mira todo contra la de la interfaz, que es más estricta.

### Las Puertas de la Condena (2026-09-29)

Los monstruos 30111-30119 —las nueve puertas que se rompen en el segundo piso de
las Catacumbas del Diablo (el regen `dungeon/devilcatacomb/dc2`)— se llamaban
«1ª … 9ª Puerta de la Condena», y con Selawik salían «1  Puerta de la Condena».
**Desde el 2026-09-29 se llaman «Primera … Novena Puerta de la Condena»**: lo
eligió Luis entre cuatro opciones con la vista previa delante —las otras eran
«1ra Puerta…», «Puerta 1 de la Condena» y «Puerta de la Condena I»—, pintada
con el modelo del motor (`m2dev-client-src/tools/texto/motor_texto.py`, con la
FreeType del cliente) en los dos sitios donde se ve el nombre.

Lo que hay que saber para tocarlas, medido ese día:

- **El nombre que ve el jugador sale del `mob_proto` del CLIENTE.** Son de tipo
  `DOOR`, y el servidor sólo manda el nombre de los PC y los NPC
  (`CHARACTER::EncodeInsertPacket`); para los demás el cliente lo busca en su
  proto (`RecvCharacterAppendPacket`). Se ve en la **barra del objetivo** —leído
  en `uitarget.py`: una puerta no es «bicho» (`__EsBicho`: ni `ENEMY` ni
  `STONE`), así que va sin cifra de PV y sin botón de ficha, «Lv.85 (Nivel 3.)
  Primera Puerta de la Condena»— y **sobre la puerta**, en blanco: un `DOOR` no
  tiene color de nombre propio y `GetNameColorIndex` cae al blanco por defecto.
- **Son tres ficheros de nombres y hacen falta los tres**: el servidor lee
  `mob_names.txt` (`ClientManagerBoot.cpp`), y `DumpProto` genera
  `locale/mx/mob_proto` desde `mob_names_mx.txt` y `locale/es/mob_proto` desde
  `mob_names_es.txt`. Ninguna quest ni el motor del servidor usan el nombre: la
  quest de la mazmorra (`devilcatacomb_zone`) las planta por vnum.
- **Los finales de línea no son los mismos**: `mob_names.txt` y
  `mob_names_mx.txt` son mixtos (1.340 líneas en CRLF y 11 en LF) y
  `mob_names_es.txt` es LF puro, así que sólo se cambió el nombre de esas nueve
  líneas, cada una con su final de línea intacto: 9 líneas por fichero en el diff.
- **La longitud no era problema**: «Séptima Puerta de la Condena» son 29 bytes y
  el nombre local del proto admite 64, en el cliente y en el servidor. La copia
  de la base (`mob_proto.locale_name`, `varbinary(24)`) lo guarda cortado a 24
  bytes, como a otros nombres largos, y el juego no la lee.
- **Comprobado donde se consume**, abriendo el proto: regenerado desde el
  `origin/main` sin cambios, `DumpProto` da los cuatro protos del cliente byte a
  byte; con el cambio sólo cambian los dos `mob_proto` (idénticos entre sí) y,
  descifrados como los lee `LoadNonPlayerData` (XChaCha20 y LZO), difieren en
  **9 registros y sólo en los bytes de `szLocaleName`**. Los nombres del proto
  con letras que Selawik no tiene pasan de 10 a 1, el 20220.

**Probado en el juego por el puente el mismo 2026-09-29**, en un cliente de
pruebas aparte: una copia del compartido con el `locale.pck` de la rama y un
`Metin2_dev.exe` compilado de `main` (`e103a51`, el motor de texto de los
jugadores). El cliente lee los nueve nombres de su proto
(`nonplayer.GetMonsterName`, con el 101, «Perro Salvaje», de control), y con
tres puertas de tipo `DOOR` plantadas en `metin2_map_b1` la barra del objetivo
(`SetHPTargetBoard` de `game.py`) y el nombre sobre la puerta salen enteros
—«Primera…» y «Séptima…», con su `é`—, mientras que una puerta de control con
el nombre viejo sale «1  Puerta…», con el hueco. Lo único que no sigue el camino
de la red es el prefijo «Lv.85 (Nivel 3.)»: desde Python no se fija el número
de raza de la instancia (sólo lo hace `Create` con los datos del paquete), así
que `SetEnemyVID` lo deja fuera, y se rehízo con su misma fórmula y el nivel
del proto cargado.

✅ **EN PRODUCCIÓN desde el parche 119** (2026-09-29, vivo a las 15:47:32Z):
sólo `locale.pck`, sin `.exe`, y el `locale.pck` publicado se abrió entrada a
entrada contra las fuentes de `main` (`m2pollito-tools/launcher/HISTORIAL_DE_PARCHES.md`,
«Parche 119»). Las dos mitades entraron juntas en `main` —servidor `adfa6ac`,
cliente `331bb4af1`—, porque con una sola el preflight bloquea cualquier
publicación: los `mob_proto` del cliente dejarían de ser los que salen de
`share/conf`. La mitad de servidor no hacía falta para verlo —su copia del nombre
no le llega al jugador: de un `DOOR` el cliente usa la suya—, y entró en
producción ese mismo día con el despliegue de las 17:21Z, el de `translate.lua`
(el `mob_names.txt` del contenedor ya dice «Primera…»). ✅ **Bajado por el
launcher de Luis** a las 17:20Z: `locale.pck`, `root.pck` y `fonts/LEEME.md`,
cada uno del tamaño publicado. ✅ **Y visto en partida en producción** ese
mismo día: Luis sacó la puerta como GM (`/mob 30111`) y la miró —en el `syslog`
le pega a «Primera Puerta de la Condena», el nombre que ya da el servidor—, y la
quitó con `/purge`. El `command_log` cuadra: dos `mob 30111` y dos líneas
`PURGE: Primera Puerta de la Condena`, así que no quedó ninguna suelta.

### Lo que manda el servidor: `translate.lua` (2026-09-29)

El texto de las quests no vive en el cliente: lo manda el servidor, que lo saca
de `m2dev-server/share/locale/english/translate.lua`, y el cliente lo pinta con
esta misma fuente. La ventana de las misiones (`say`, `PythonEventManager.cpp`)
y el chat, adonde llegan los avisos de mazmorra (`d.notice` es
`CHAT_TYPE_NOTICE`, `PythonChat.cpp`), usan `DefaultFont_GetResource`, o sea la
`UI_DEF_FONT`. Así salían **nueve `º` en ocho cadenas de quests que se
compilan**: siete en los avisos de la Torre del Demonio (`deviltower_zone`: «4º
piso», «5º piso», «6º piso», «8º piso»), el «2.º piso» de `dragon_lair_weekly` y
el «Nº. de jugadores» de `entry_event_map` y `oxevent`. En el chat y en la
ventana de la misión se leían «al 4  piso». Los avisos salen además en la franja
de arriba (`uitip.py`), que pinta con **Tahoma** (`CTextBar`, con la cara
escrita a fuego) y ahí se veían bien, que es por lo que nadie lo notó.

**Los textos nuevos los eligió Luis** el mismo 29-09, con la vista previa
pintada con el modelo del motor (`m2dev-client-src/tools/texto/motor_texto.py`,
con la FreeType del cliente) en los tres sitios donde salen: **el ordinal en
letra** —«cuarto», «quinto», «sexto», «octavo» y «segundo piso», y «Número de
jugadores:»—, frente a «piso 4» y a «4° piso» con el signo de grado, que
Selawik sí tiene pero es otro signo. De paso, la errata «Has habierto» → «Has
abierto». Es un cambio **sólo del servidor**, sin parche de cliente: el detalle,
y la regla de los 60 bytes de la ventana de las misiones que salió al medirlo,
están en `m2dev-server/IDIOMA_SERVIDOR.md`.

**Y la regla 4 lo mira desde ese día** (el apartado «4 bis» del script). Tres
cosas de cómo, y por qué:

1. **Sólo las cadenas que puede mandar el servidor**, no las 8.575 del
   fichero: las que citan las quests que se compilan —las líneas de
   `quest/locale_list` sin `#`, como las lee `make.py`— y las que cita
   `quest/locale.lua`, la capa de alias `locale.*` que el servidor carga
   entera; las dos, **sin los comentarios**. Citar una **tabla**
   (`gameforge.ring_warp`) cuenta como citar todas sus cadenas, porque una
   quest puede guardarla en una variable. Medido: **3.147 cadenas**, y el
   control es que `patrol_townaround`, que no se compila, lleva 19 `º` y la
   regla calla.
2. **Contra las fuentes de los dos locales a la vez**: una cadena del servidor
   la recibe el cliente de mx y el de es.
3. **El servidor se busca**, no se escribe: `$M2_SERVER` (o `$M2_SERVIDOR`), al
   lado de este repo, o al lado del checkout principal si el script corre en un
   worktree. Si la variable está puesta **manda ella** y no se busca en otro
   sitio: en el carril del corredor de sabotajes, caer al checkout compartido
   sería mirar otro árbol. Y **sin servidor, falla**: un guardia que no puede
   mirar no da verde.

Lo que eso trae, y hay que saberlo: la regla lee el **árbol de trabajo** del
servidor que tenga al lado, como la comprobación de los protos del preflight.
Una `º` nueva en `translate.lua` **no la frena el despliegue del servidor**:
salta al publicar el siguiente parche de cliente, y lo bloquea hasta que se
arregle. Lo que ya tenga dueño va en `PENDIENTES` como
`("translate.lua", clave, valor)`, con el valor tal cual está entre comillas.

Comprobado antes de commitear: contra el `main` del servidor sin arreglar
(`adfa6ac`) **falla con las 9 `º` y sólo con ellas**, y contra la rama del
arreglo pasa. Cuesta **0,06 s** más en el preflight (de 0,12 a 0,18 s, la
mediana de nueve rondas alternando la versión de `main` y la nueva). Su
`--sabotaje` pasa de 14 a **23** casos, tarda 6 s, que trabajan sobre una **copia** de `translate.lua`,
`locale_list`, `locale.lua` y los `.quest` en un temporal y eligen sus claves
del servidor de hoy (`_srv_casos`), para que un cambio de datos no deje un caso
sin probar nada: una letra en una cadena citada por una quest activa y en una
que sólo cita `locale.lua` saltan; en una de una quest apagada, **calla**, y
salta al encenderla en `locale_list`; citada en un comentario calla y en el
código salta; citar su tabla la trae; una excepción la tapa; y sin servidor,
falla. Y se probó que los casos **saben fallar**, rompiendo a propósito cada
pieza en una copia del script: las seis roturas —no quitar los comentarios,
leer las quests apagadas como activas, no leer `locale.lua`, no expandir las
tablas, no leer las quests y no mirar `PENDIENTES`— hacen fallar la batería,
tres por su propio caso y tres porque ya no queda con qué sabotear o el modo
normal falla antes.

✅ **Los textos, EN PRODUCCIÓN desde el 2026-09-29 a las 17:22Z** (sólo datos: el
detalle está en `m2dev-server/IDIOMA_SERVIDOR.md`), después de que Luis los viera
en un banco con ese mismo `translate.lua`. **Y la regla, en `main` desde ese mismo
día**, en ese orden: con el `main` del servidor sin arreglar habría bloqueado
cualquier publicación de cliente. Como lee el árbol de trabajo del servidor que
tiene al lado, el checkout compartido de `m2dev-server` se puso al día en el
mismo momento.

### Los nombres de objetos y monstruos (2026-09-29)

Los nombres tampoco están en el locale: salen de
`m2dev-server/share/conf/{item,mob}_names*.txt` y se pintan con esta misma
fuente —el nombre sobre el monstruo, la barra del objetivo, el tooltip del
objeto, el chat—. Por ahí se colaron las nueve **Puertas de la Condena**, con su
`ª`, hasta el parche 119 (su apartado, arriba). Llegan por dos caminos, y la
regla mide cada fichero contra quien lo pinta:

- **el del cliente**: `DumpProto` hace `locale/<loc>/item_proto` y `mob_proto`
  desde `{item,mob}_names_<loc>.txt` (`CollectLocaleFiles`, `dump_proto.cpp`),
  y el cliente carga los de su locale (`PythonApplication.cpp`). El `_mx` se
  mide contra la fuente del locale mx, y el `_es` contra la del es;
- **el del servidor**: el core `db` carga `item_names.txt` y `mob_names.txt`
  (`ClientManagerBoot.cpp`), y el de juego mete esos nombres en sus mensajes,
  que reciben los dos locales: se miden contra lo que tengan las fuentes de los
  dos, como `translate.lua`.

Los `_en` **no**: el preflight se los da a `DumpProto` para el proto por
defecto, que el cliente no publica ni lee (`PROTOS_DEL_CLIENTE`, en
`preflight.py`). De cada fila se mira lo que se quedan los lectores —la segunda
columna, el nombre; la primera fila es la cabecera y la saltan—, y en todas:
también las 150 de **rango** de los ficheros de objetos (`110000~110099`), que
el lector guarda por su primer vnum, que es con el que las busca la fila de
rango del proto (`ProtoReader.cpp`). Son **21.861 filas** en los seis ficheros.

**Lo que ya estaba roto va en `DEUDA_NOMBRES`**, con su texto exacto: 21 objetos
y el monstruo 20220, cada uno con el mismo texto en los tres ficheros de su
tipo. Son nombres **coreanos sin convertir**: el texto en EUC-KR se leyó como
latin-1 y se guardó así, de modo que en el juego son una ristra de letras
latinas —el monstruo 20220 se llama «Å©¸®½º¸¶½º ¼ø·Ï»—, con un hueco en cada
`º`, que es la única de sus letras que Selawik no tiene. Son basura con esta
regla o sin ella, y la deuda funciona como `PENDIENTES`: tapa ese texto en esa
clave y nada más —otra clave, u otro texto en ésta, bloquea—, y si alguien lo
traduce, **avisa** de que la entrada sobra. Leídos como EUC-KR salen enteros los
22, y esto es lo que dicen (la traducción es mía, y aproximada):

| vnum | En coreano | Qué dice |
|---|---|---|
| 31010 | 붉은 서신 조각 | Fragmento de carta roja |
| 31011 | 붉은 마지막 서신 조각 | Fragmento de la última carta roja |
| 31022 | 어인족의 비늘 | Escama del pueblo pez |
| 31027 | 질 좋은 흙 | Tierra de buena calidad |
| 31028 | 벽돌 | Ladrillo |
| 31033 | 붉은 도적단의 망토 | Capa de la banda de ladrones rojos |
| 31034 | 거인족의 보주 | Orbe de los gigantes |
| 31035 | 검은 돌조각 | Fragmento de piedra negra |
| 31036 | 검은 황금 견본 | Muestra de oro negro |
| 31037 | 검은 황금 꾸러미 | Fardo de oro negro |
| 31062 | 질 좋은 한철 | Hierro frío de buena calidad |
| 31064 | 불성초 | una hierba («bulseongcho») |
| 31073 | 신비의 환약 | Píldora misteriosa |
| 50118 | 용각보물상자 | Cofre del tesoro de cuerno de dragón |
| 50119 | 영석보따리 | Hatillo de piedras espirituales |
| 50123 | 아이스크림 | Helado |
| 50197, 50198, 50199 | 알 수 없는 보따리 | Hatillo desconocido |
| 50253 | 자주빛 금박상자 | Caja morada con pan de oro |
| 53505 | 녹각 보관함 | Estuche de cuerno de ciervo |
| monstruo 20220 | 크리스마스 순록 | Reno de Navidad |

El `item_names_en.txt` no sirve para traducirlos: once dicen «NoNAme» y otros
no casan (el 31073, «píldora misteriosa» en coreano, es allí «Gold Hunter's
Band»). Y **hoy no los ve ningún jugador**, medido el 2026-09-29 con el lector
del catálogo (`m2dev-server/CATALOGO.md`: `cargar_fuentes` y
`cerrar_alcanzables` sobre `main @ 23437e0`, con los mismos 2.798 objetos
inalcanzables que da el catálogo publicado): los 21 objetos **no tienen ni una
fuente** —ni botín, ni tienda, ni cofre, ni misión, ni crafteo—, y el reno
20220 no está en ningún mapa, grupo ni misión activa (con el Herrero y el Perro
Salvaje de control). Sólo salen si un GM los saca, así que traducirlos no cambia
nada de lo que ve un jugador; si alguien les da una fuente, se traducen en el
mismo cambio.

Comprobado antes de commitear: con la deuda vacía la regla **falla** con los
22 —28 `º` en cada grupo: el servidor, mx y es—, y con ella pasa. Cuesta
**0,015 s** más en el preflight (de 0,171 a 0,186 s, la mediana de once rondas
alternando la versión de `main` y la nueva). Su `--sabotaje` pasa de 23 a
**38** casos, sobre una copia de los ficheros de nombres en un temporal y con
las claves elegidas de los ficheros de hoy (`_nom_casos`): una letra que falte
en el `_mx`, en el `mob_names_es.txt` (que es LF puro) y en una fila de rango
salta; la deuda tapa su texto en los tres ficheros —el control— y no tapa ni el
mismo texto en otro vnum ni otro texto en el mismo; una deuda que sobra avisa;
sin un fichero, sin UTF-8 o sin servidor, falla; y el `_en`, que no se pinta,
calla. Cuatro de ellos prueban **contra qué fuente** se mide cada cosa, poniendo
un locale en EB Garamond —que tiene todo lo de Selawik y además la `ª`—: el
`_es` con la fuente de es calla y el `_mx` salta, y lo del servidor salta aunque
sólo lo pueda pintar uno de los dos locales, también en `translate.lua`, que
hasta ese día no lo probaba ningún caso. Y se probó que la batería **sabe
fallar**, rompiendo a propósito cada pieza en una copia del script: de las
**14** roturas —no leer un fichero, saltarse las filas de rango, mirar la deuda
sólo por la clave o sólo por el texto, no avisar de la que sobra, dar por bueno
un fichero que falta, leer el `_en`, callar sin servidor, no llamar a la regla
y medir contra la fuente equivocada de cuatro formas—, trece las caza su propio
caso, y la de leer el `_en`, el modo normal, que ya falla.

Como el resto de la regla, lee el **árbol de trabajo** del servidor que tenga al
lado: un nombre nuevo con una letra que falte no frena el despliegue del
servidor, sino **la siguiente publicación de cliente**, hasta que se arregle.

## Si algún día se mete otra

Hay que hacer **las tres cosas**, o no llega:

1. dejar el `.ttf` aquí **con el nombre que el motor va a buscar** (el alias, o
   la familia en minúsculas) y su licencia al lado;
2. comprobar que `publish_patch.bat` espeja esta carpeta — lo hace desde el
   2026-08-31 (línea 158), pero antes solo copiaba `bgm`, `mark`, `pack` y
   `upload`, así que una carpeta nueva **funcionaba en dev y no llegaba a un
   jugador jamás**;
3. correr `python tools/comprobar_fuentes.py`, que lee la licencia de todo lo
   que haya aquí y aborta si alguna no se puede distribuir.

Y si se quiere que sea la de **todos** y no solo una opción, además:
`UI_DEF_FONT` (y `_LARGE` y `_SMALL`) en `assets/locale/locale/{mx,es}/locale_game.txt`.
El motor parte esa cadena por el **último** `:` (`CGraphicText::OnLoad`), así
que una familia con espacios dentro es correcta; lo que no cabe es un nombre de
más de 31 caracteres. Y en cuanto la pida `UI_DEF_FONT`, la regla 4 de
`comprobar_fuentes.py` exige que tenga **todas las letras que el locale usa**:
una fuente sin la `ñ` bloquea la publicación (es el caso 10 de su `--sabotaje`).
