ENES
Jugar con mis ficheros
ENES
Jugar con mis ficheros

Bugs del original — registro canónico

Esta página se GENERA del registro canónico del repositorio. La tabla y las entradas salen del mismo fichero, así que no pueden decir cosas distintas: cada etiqueta lleva a la entrada de la que sale. Fuente: docs/bugs-del-original.md

Ultima V: Warriors of Destiny (Origin/EA, DOS, 1988) tiene defectos. Portarlo con fidelidad obliga a decidir, uno por uno, cuáles se calcan y cuáles se arreglan. Este documento es esa decisión, escrita, con la cita del binario al lado.

Creado el 2026-08-05 por el carril bugs-original a partir del censo sobre 665 actas de re/notes/, el ledger de rutinas selladas y el historial del repo. Ejecuta la directriz del usuario del 05-08-2026 y la enmienda correspondiente de FIDELITY-CONTRACT.md §Política.

Cinco, para hacerse una idea

La regla que decide

Se arregla, salvo dos casos: si el defecto toca el flujo del generador de números aleatorios —arreglarlo rompería el instrumento con el que se verifica todo lo demás— o si «arreglarlo» significaría inventar contenido que EA nunca publicó.
Y las erratas de imprenta se conservan: cerrarle a EA una comilla que dejó abierta no es arreglar un bug, es reescribir el original.

Los cubos, y por qué son cinco

Los defectos, uno por fila

36 defectos. Grado de evidencia: 25 MEDIDO · 9 DERIVADO · 1 sin determinar · 1 TESTIGO. Los 15 de las dos secciones exentas no llevan grado — una errata literal y una candidatura refutada no son afirmaciones sobre el binario que puedan estar más o menos acreditadas.

#DefectoEstadoGradoCuboEntrada
1El astillero llama «sir» a una Avatar mujer✅ ARREGLADODERIVADO§1 se arregla§1.1
2La cuenta de la taberna deja un hueco si el Avatar va solo✅ ARREGLADOMEDIDO§1 se arregla§1.2
3Dormir en una cama te despierta una hora tarde✅ ARREGLADOTESTIGO§1 se arregla§1.3
4El precio de entrega de la fragata: la cifra no cuadra y aceptar no hace nada⏳ PENDIENTEDERIVADO§1 se arregla§1.4
5Un cofre de mazmorra en la planta 0 cuelga el juego⏳ PENDIENTEMEDIDO§1 se arregla§1.5
6«Ship rigged for double speed!» miente sobre cuándo surte efecto✅ ARREGLADODERIVADO§1 se arregla§1.6
7El caballo del pozo de los deseos aparece dentro de un muro⏳ DIVERGENTEDERIVADO§1 se arregla§1.7
8Huecos de dato en textos✅ NINGUNO LLEGA AL JUGADORMEDIDO§1 se arregla§1.8
9Usar la Skull Key dentro de una mazmorra la gasta y no abre nada⏳ PENDIENTEMEDIDO§1 se arregla§1.9
10Pasar en el prompt de la Skull Key la gasta y revienta tu propia casilla⏳ DIVERGENTEMEDIDO§1 se arregla§1.10
11Mantener pulsada la V se come la gema y no te enseña el mapa✅ ARREGLADOMEDIDO§1 se arregla§1.11
12En las esperas animadas, el halo de la última antorcha se estampa sobre tu ventana✅ NO SE CALCADERIVADO§1 se arregla§1.12
13Catorce salas de mazmorra te dejan encerrado para siempre✅ ARREGLADOMEDIDO§1 se arregla§1.13
14El bono nocturno de encuentros vive en una rama muerta🔒 Calcado a propósitoMEDIDO§2 calcado§2.1
15El generador de 1 a 30 no es uniforme🔒 Calcado a propósitoDERIVADO§2 calcado§2.2
16La comprobación de «¿es persona?» del Shadowlord mira siempre la ranura 4🔒 Calcado a propósitoDERIVADO§2 calcado§2.3
17Fuera del mapa pequeño se lee siempre la casilla (31,31)🔒 Calcado a propósitoDERIVADO§2 calcado§2.4
18In Bet Xen invoca las cuatro criaturas en la misma casilla🔒 Calcado a propósitoMEDIDO§2 calcado§2.5
19La tirada se gasta también por los muertos y por los que resisten🔒 Calcado a propósitoMEDIDO§2 calcado§2.6
20El huevo de pascua de EA está en los datos y el código no lo alcanza✅ CALCADOMEDIDO§2 calcado§2.7
21El día 20 de cada mes, la marchitación del Shadowlord no perdona NADA🔒 Calcado a propósitoMEDIDO§2 calcado§2.8
22En combate, dos de los cuatro muros de campo no hacen absolutamente nada🔒 Calcado a propósitoMEDIDO§2 calcado§2.9
23El barrido del speaker nunca llega a su frecuencia nominal🔒 Calcado a propósitoMEDIDO§2 calcado§2.10
24El guion TLK lleva seis errores de programación — y se calcan byte a byte🔒 Calcado a propósitoMEDIDO§2 calcado§2.11
25La clase fantasma: «Adept of Woznir»🔒 Calcado a propósitoMEDIDO§2 calcado§2.12
26La identidad de una sala de mazmorra es UN NIBBLE: limpiar una limpia a sus gemelas🔒 Calcado a propósitoMEDIDO§2 calcado§2.13
27«a crytal sphere»🔒 Errata conservadaerrata§3 errata§3·1
28La despedida del gremio deja una comilla sin cerrar🔒 Errata conservadaerrata§3 errata§3·2
29«What didst thou say?»🔒 Errata conservadaerrata§3 errata§3·3
30La banda L5 del disolvente de la intro repite un índice y omite otro🔒 Errata conservadaerrata§3 errata§3·4
31Alistar a alguien que YA viaja contigo duplica su ficha y borra la de otro🔍 Latente, sin camino vivoMEDIDO§4 latente§4·1
32Despacho de estadísticas sin caso por defecto🔍 Latente, sin camino vivoMEDIDO§4 latente§4·2
33La otra mitad del punto fijo del RNG: los reintentos SIN COTA se colgarían🔍 Latente, sin camino vivoMEDIDO§4 latente§4·3
34text_gotoxy ignora los límites de su propia ventana🔍 Latente, sin camino vivoMEDIDO§4 latente§4·4
35Mix con un nombre sin coincidencia🔍 Latente, sin camino vivosin determinar§4 latente§4·5
36resolve_command_char puede devolver un monstruo como índice de roster🔍 Latente, sin camino vivoDERIVADO§4 latente§4·6
37La misma distancia, medida de dos maneras en el mismo fichero — el aviso de proximidad se pierde en la costura del mundo🔍 Latente, sin camino vivoMEDIDO§4 latente§4·7
38El sorteo ponderado de criaturas no comprueba la longitud de su tabla🔍 Latente, sin camino vivoMEDIDO§4 latente§4·8
39«Level 254»: el piso de mazmorra puede decrementarse desde el centinela del Underworld🔍 Latente, sin camino vivoMEDIDO§4 latente§4·9
40La acampada guarda el MES de la aparición en un byte que no lee nadie.🔍 Latente, sin camino vivoMEDIDO§4 latente§4·10
41Deceit y Despise comparten ranura — es DISEÑO❌ No es un bugrefutado§5 no es bug§5.1
42Rel Hur no tiene un bug: el bug sería nuestro❌ No es un bugrefutado§5 no es bug§5.2
43Las tiendas no comparan mal las mayúsculas❌ No es un bugrefutado§5 no es bug§5.3
44Las placas de sala que no disparan son la mecánica funcionando❌ No es un bugrefutado§5 no es bug§5.4
45Los desbordamientos de atributo no son bugs de 1988❌ No es un bugrefutado§5 no es bug§5.5
46El martillo a dos manos NO alcanza todo el campo de batalla — no en v1.16❌ No es un bugrefutado§5 no es bug§5.6
47El Shadowlord no roba «comida u oro»: roba de cinco cajones, y la comida NO está❌ No es un bugrefutado§5 no es bug§5.7
48Los grupos de enemigos MIXTOS del overworld SÍ existen en el DOS — y aquí está el byte❌ No es un bugrefutado§5 no es bug§5.8
49El Ankh no cambia la probabilidad de la aparición al acampar: es un 25 % plano❌ No es un bugrefutado§5 no es bug§5.9
50Las dos hachas y el hacha en la cabeza: la ranura NO la elige el jugador❌ No es un bugrefutado§5 no es bug§5.10
51El mapa de combate sin usar existe, es el registro 9 de BRIT.CBT, y no puede abrirse❌ No es un bugrefutado§5 no es bug§5.11

Sin JavaScript la página sale entera y desplegada; los filtros son lo único que se pierde.

El criterio, entero

Cómo se lee este registro, qué significa cada grado de evidencia y la regla de reparto completa — tal cual vienen del documento canónico.

Cómo se lee este registro

No todo lo raro es un bug. Para entrar aquí como bug hace falta un descuido demostrable —una rama muerta, un hueco donde va un dato, una promesa sin efecto, una aritmética rota— y, siempre que exista, el argumento de simetría: la rutina hermana que sí trata bien el mismo caso. Cuando ese argumento falta, la entrada baja a «quirk» o se queda fuera. La §5 recoge lo que investigamos y descartamos, que en un documento así importa tanto como lo que entra.

Cada afirmación viaja con su grado, y el grado no es decorativo:

GradoSignifica
MEDIDOLeído instrucción a instrucción en el desensamblado, por quien firma la entrada.
DERIVADODeducido del código con un razonamiento explícito, sin ver el juego correr.
INFERIDOLectura de intención. Nunca sostiene por sí sola una entrada.
TESTIGOAdemás, observado en el binario corriendo bajo DOSBox.

Dónde se dice, y dónde no se dice a propósito. Toda fila de §1, §2 y §4 declara su grado en el cuerpo. §3 (erratas) y §5 (candidaturas descartadas) no lo llevan, y eso no es un hueco: una comilla que EA dejó abierta no tiene grado de evidencia, y una candidatura refutada tampoco — su naturaleza ya dice de ellas todo lo que hay que decir. Un bug derivado del código es un bug; simplemente no es lo mismo que uno visto ocurrir, y el documento no finge que lo sea.

Grados hoy: 25 MEDIDO · 9 DERIVADO · 0 INFERIDO · 1 TESTIGO · 1 sin determinar, sobre 36 filas con grado (§1 + §2 + §4). La cifra no está escrita a mano: la deriva del propio fichero el generador de /diferencias, y re/tools/test_grados_registro.py la carea contra esta frase en los dos espejos. La frase misma la reescribe python3 re/tools/registro_cifras.py --write: tras fusionar o añadir una fila se regenera — nunca se recalcula a mano.

🔴 Aquí vivía «Casi todo lo de aquí es DERIVADO sin testigo de oráculo. Se dice en cada fila», y era falso por los dos lados (retirado el 2026-08-10). No se decía en cada fila: siete filas no declaraban ninguno —§1.8, §2.6 y cinco de las ocho viñetas de §4—, y §3 y §5 no lo declaran nunca por naturaleza. Y el grado dominante no es DERIVADO: es MEDIDO, por más del doble. La frase llevaba desde el estreno del registro afirmando de la población entera lo que era cierto de una parte, y la mitad «se dice en cada fila» describía una regla que el documento no cumplía. Las siete filas mudas se cerraron el 2026-08-10 proponiendo el grado desde la evidencia que cada una ya citaba —nunca por parecido con sus vecinas—, y la que su evidencia no sostiene se declara sin determinar, que es distinto de callar.

La regla que decide, y por qué no es «gravedad»

El criterio de corte no es lo grave que sea el bug. Es este, en orden:

  1. ¿Toca el stream de RNG? Si lo toca, se calca siempre. La verificación de este port se apoya en reproducir la secuencia de números aleatorios del original; arreglar un bug que mueve el stream rompe el instrumento con el que comprobamos todo lo demás. Estos bugs se declaran aquí y se quedan.
  2. Si no lo toca, y es un descuido claro, se arregla y se registra.
  3. Las erratas de imprenta se conservan. Cerrarle a EA una comilla que dejó abierta no es arreglar un bug: es reescribir el original. Son parte de lo que se está preservando.

Las fichas, una por defecto

1. Bugs que OpenU5 arregla (o va a arreglar)

No tocan el stream. Son descuidos claros. El estado real de cada uno va en la fila — varios están pendientes, y el registro no lo disimula.

1.1 El astillero llama «sir» a una Avatar mujer ✅ ARREGLADO

DERIVADO

El original: en el astillero, el diálogo trata al Avatar de «sir» aunque sea mujer. La rama que diría «milady» existe (DS 0x9FCC) pero está muerta.

Por qué es un bug: tres sitios del binario leen el mismo byte de género (desplazamiento +9 del registro de 32 bytes del roster, DS 0x55B1) y lo comparan contra tres constantes distintas:

SitioCompara contra¿Correcto?
SHOPPES.OVL:0x0b0c0x0Csí
SHOPPES2.OVL:0x00c30x0Bsí
SHOPPES2.OVL:0x085a0x46 = 'F'no

La creación de personaje nunca escribe 0x46 — el censo de los 16 registros de INIT.GAM da cero. Dos rutinas hermanas usan la constante buena y una usa una letra ASCII que ese campo nunca contiene: eso es un descuido, no un criterio.

OpenU5: ramifica por gender === 0x0c y dice «milady». Port: game/src/ui/shop-console.ts:1599 y :2116 (verificado 2026-08-05).

Grado: DERIVADO (re/notes/asm-shoppes-acta.md §5). Sin testigo de oráculo.

1.2 La cuenta de la taberna deja un hueco si el Avatar va solo ✅ ARREGLADO

MEDIDO

El original: al pagar la comida, el tabernero dice cuántos sois. La rutina que escribe el número (SHOPPES2.OVL:0x006a, print_alive_count_word) conmuta two…six. No hay caso singular: con un solo miembro vivo cae al ret sin imprimir nada, y la frase sale con un agujero — « gold for the ␣ of ye,».

Por qué es un bug: el curandero (SHOPPES.OVL:0x137c) trata el mismo caso límite —un solo miembro— y lo trata bien, devolviendo 0 sin preguntar. Dos rutinas del mismo territorio, el mismo borde, y sólo una lo contempla.

OpenU5: ARREGLADO — emite la frase entera, y en el caso singular dice «one». Port: la compone game/src/ui/shop-console.ts:2471 (emitTavernPriceLine) y la emiten sus dos llamadores: :2432 (la ronda de comida) y :2514 (la ronda de la casa de la bebida) — los mismos dos caminos que en el binario entran en SHOPPES2.OVL:0x00dc. La palabra del número la produce game/src/core/shops/shops.ts:871 (tavernAliveWord), que devuelve «one» donde el original cae al ret sin imprimir: es el único apartamiento deliberado de esta línea, y todo lo demás es transcripción. Aterrizado el 2026-08-06 (f3889145); el segundo llamador, el 2026-08-08. Ficha #17.

🔴 Corrección de esta misma fila (2026-08-08), y la ventana de caducidad fue de HORAS. Hasta hoy esta fila publicaba un bloque «Ampliación 2026-08-06» con tres afirmaciones «re-verificadas en el árbol». Las tres han dejado de ser ciertas, y dos de ellas lo dejaron de ser esa misma noche: la ampliación se escribió el 06-08 y el arreglo aterrizó el 06-08 a las 23:57. Se retiran nombrándolas, que es como se retira una afirmación publicada:

Lo que de aquel bloque sigue en pie es lo que hablaba del original, no del clon: la frase aparece en el corpus del ORIGINAL capturado por el espejo, así que la omisión del clon no habría sido neutra. Grado de esa parte: MEDIDO por corpus OCR, con el ruido que se ve en las citas.

Censo del corpus (2026-08-06), con su denominador: 6 apariciones en 3 ficheros de game/e2e/espejo-tour/routes-ad/ —ad02, ad08, ad19—, con dos palabras de cuenta distintas y las dos con ruido de OCR: «gold for the si of ye, sir.» (4×, presumible six) y «pj!d for the (mree of ye» (2×, presumible three). Son dos tamaños de grupo imprimiendo su palabra en el hueco exacto donde la escribe print_alive_count_word.

🔴 Y lo que este censo NO acredita, que es la mitad que importa: capturas del caso SINGULAR hay CERO. Ninguna de las 6 muestra el hueco. Eso no refuta el defecto —lo medido en el desensamblado sigue en pie— sino que fija que el corpus del espejo no puede ser su testigo: el tour no lleva nunca un grupo de un solo miembro vivo a una taberna, así que el caso que produciría el agujero no está muestreado. Lo que el corpus sí acredita es la FORMA de la frase y que el original la imprime. El hueco sigue sin testigo de oráculo, y el que haría falta es una compra de taberna con g_alive_b = 1.

⚠ Y sigue sin montarse, dicho aquí para que no se lea como olvido. Al cerrar esta fila (2026-08-08) se sopesó montarlo y se decidió que no: exige una instancia de oráculo propia y una partida llevada a un solo miembro vivo, y lo que acreditaría —que el original imprime el hueco— ya está derivado del desensamblado con el cuerpo leído entero (abajo). El testigo subiría el grado de esa media línea, no cambiaría el veredicto ni el arreglo. Queda como pendiente declarado, no como deuda silenciosa: lo que esta fila NO tiene es una captura del caso singular, y no la tiene a sabiendas.

(Método, por si le sirve al siguiente: el primer censo dio 4 en 2 ficheros, y estaba mal. El patrón exigía el prefijo «gold», que en ad02 el OCR se había comido —«pj!d»—, así que el patrón llevaba dentro una suposición sobre el texto de alrededor y en un corpus con ruido esa suposición no falla: DESCUENTA en silencio. La cifra buena sale de buscar por el trozo estable, «of ye».)

Grado: MEDIDO — cuerpo leído entero en re/disasm/SHOPPES2.OVL.asm (2026-08-05). El conmutador son cinco cmp contra 2, 3, 4, 5 y 6 (0x006d-0x0084); cualquier otro valor, el 1 incluido, cae en el jmp 0x00aa de 0x0086 y de ahí al ret de 0x00aa, sin llegar a ninguna de las cinco llamadas al impresor. Confirma la derivación de re/notes/asm-shoppes-acta.md §26.1 y §27, cuya cita era correcta. Sin testigo de oráculo. (Nota de vocabulario, para quien siga la cita: el §26.1 declara su propio grado con las palabras «derivado del código, no observado en el original corriendo». Ese «derivado» NO es el grado DERIVADO de la tabla de arriba — es exactamente lo que esta tabla llama MEDIDO sin TESTIGO: leído en el desensamblado, no visto correr. El acta y esta fila afirman lo mismo con vocabularios distintos; no hay contradicción de grado.)

1.3 Dormir en una cama te despierta una hora tarde ✅ ARREGLADO

TESTIGO

El original: CMDS.OVL calcula la hora de destino y la normaliza mal al cruzar la medianoche. Leído entero:

059e: mov al, [g_hour]        ; hora actual
05a1: sub ah, ah
05a3: add ax, si              ; + horas pedidas (si llega como ASCII del dígito)
05a5: sub ax, 0x30            ; ASCII → número
05a8: mov [bp-6], ax          ; destino = hora + horas
05ab: cmp ax, 0x17            ; ¿pasa de las 23?
05ae: jle 0x5b4               ; no → sin ajuste
05b0: sub word ptr [bp-6], 0x17   ; ★ sí → RESTA 23, NO 24

Con el destino ≥ 24 el ajuste se queda una hora corto: dormir 2 h desde las 22:00 da destino 24 → 24 − 23 = 1:00, cuando la medianoche son las 0:00. Despiertas una hora tarde, siempre que el sueño cruce medianoche.

Por qué es un bug: en un reloj de 24 horas, normalizar restando 23 no tiene lectura de diseño. Es el error aritmético en su forma más simple.

OpenU5: duerme el número de horas pedido. Port: game/src/core/world/camp.ts:227 (bedSleep, verificado 2026-08-05). Ficha #22.

Un matiz que NO metemos aquí: el mismo bucle sale cuando hora == destino, o sea que el original duerme hasta el filo de la hora, no N horas completas (empezar a las 10:37 y dormir 3 h te despierta a las 13:00). Eso sí puede ser diseño, y se decidirá al portar el sueño fiel — no se vende como bug.

Grado: TESTIGO (ver abajo) sobre lectura MEDIDA instrucción a instrucción en re/disasm/CMDS.OVL.asm (2026-08-05). Corrige de paso la cita heredada de las actas (re/notes/cama-241-acta.md §6, re/notes/sueno-cama-249.md §1-2), que situaban la resta en 0x05ab: ahí está el cmp, y la resta es 0x05b0. Las actas tampoco recogían el sub ax,0x30, que es lo que revela que el argumento llega como ASCII.

★ TESTIGO DE EJECUCIÓN OBTENIDO, con control negativo. Predicción registrada antes de correr: sembrando las 22:00 y durmiendo 3 h, el destino con bug es 25 − 23 = 2 (despertar a las 02:00) y sin bug 25 − 24 = 1 (la 01:00). Dos corridas del mismo instrumento, sobre el binario real bajo DOSBox:

CaminoHora al despertar
Dormir en CAMA (Iolo's Hut, celda 12,14 — la única cama del mapa)02:00★ el bug, medido
Acampar al aire libre01:00control negativo

La cama despierta una hora tarde, exactamente como predice la resta de 23. Y la acampada, que usa otro camino de sueño, da las 01:00 correctas — lo que convierte esa corrida en control negativo: el mismo arnés sabe producir el resultado bueno, así que el 02:00 no es un artefacto suyo. Sonda, predicción y control en re/tools/sleep_hour_probe.py (main = intemperie, cama = el testigo).

Grado del hallazgo: TESTIGO. El destino se consume en 0x062e mov si,[bp-6], dentro del bucle que avanza el reloj hasta g_hour == destino (0x063b-0x0645).

Segunda divergencia del clon en esta misma rutina — CERRADA el 2026-08-08 (ficha #22). El binario avanza cada hora en seis pasos de diez minutos, y en cada uno re-encaja a los NPC en su horario y comprueba si hay algo encima del grupo — o sea, seis oportunidades por hora de que salte «Thrown out of bed!». El clon hace hoy lo mismo: bedSleep recorre hours * BED_STEPS_PER_HOUR pasos y llama a advanceClock(…, BED_STEP_MINUTES, …) con el snap de NPC y el gate dentro de cada vuelta (game/src/core/world/camp.ts:263-273, verificado 2026-08-12). No mueve el stream de RNG; movía el MUESTREO, y a la baja.

🔴 Aquí ponía, hasta el 2026-08-12, que el clon daba «un solo paso de 60 minutos por hora» y tenía «seis veces menos» ocasiones de expulsarte de la cama. Era cierto cuando se escribió y dejó de serlo al aterrizar 5d7e0a04, sin que nadie volviera a mirar: cuatro días publicando como defecto vivo del port algo ya arreglado. Se retira nombrando lo que afirmaba, no borrándolo — y la cita que la sustituye está leída en el árbol de hoy, no copiada de la ficha que la cerró.

⚠ El señuelo que había en el sitio SÍ existió y también se cerró: el comentario de esa línea decía «0x0647 advance_clock(10) por paso» justo al lado de una llamada que pasaba 60 — citaba correctamente el binario y se leía como si describiera el clon. Hoy el argumento es BED_STEP_MINUTES y el comentario y el código dicen lo mismo. La lección se queda porque el mecanismo no depende de esta rutina: un comentario que cita el binario junto a código que no lo cumple se lee como documentación del código, y quien audite de pasada lo da por fiel sin mirar el argumento.

1.4 El precio de entrega de la fragata: la cifra no cuadra y aceptar no hace nada ⏳ PENDIENTE

DERIVADO

El original: dos defectos en la misma rama (SHOPPES2.OVL:0x08a8).

  1. El texto promete 87.000 de oro y el código compara contra 0x2710 = 10.000.
  2. Si aceptas y puedes pagar, la rama no hace nada: eco de «Yes», comprobación de que te lo puedes permitir, y salir. No cobra, no entrega, no toca las banderas de nave.

Por qué es un bug: el esquife, en esa misma rutina, sí entra en la rutina de compra que cobra y entrega. La rama de la fragata tiene la comprobación y le falta el efecto.

OpenU5: no modela la rama. Port: game/src/core/shops/shop-tables.ts:62 (SHIP_REPLACEMENT_PRICE = 10000), y game/src/core/shops/shops.ts:1057 declara en su docblock que el camino de reemplazo no está modelado. Ficha #13.

🔴 Corrección de esta misma fila (2026-08-06). Antes decía que el clon «fijó el precio en 10.000 (la cifra del código, no la del texto)». Es falso: la constante existe pero no la lee ningún código. Su única otra aparición en todo game/src es la mención dentro de ese comentario. Es una constante muerta, así que el clon no fijó ningún precio — simplemente no hay camino de reemplazo. La verificación anterior comprobó que el símbolo existía en esa línea, no que alguien lo usara, y una cita de port sólo vale si comprueba lo segundo.

Género hermano, y ya van tres en esta misma zona del clon: el import muerto de tavernRoundPrice en game/src/ui/shop-console.ts:69 (§1.2) y las dos cadenas huérfanas de la cuenta de taberna en i18n. «Planeado y nunca cableado» describe mejor lo que pasa en tiendas/taberna que tres rarezas independientes.

Grado: el defecto del original, DERIVADO (re/notes/asm-shoppes-acta.md, ledger SHOPPES2.OVL:2216). El estado del clon, MEDIDO contra el árbol (2026-08-06).

1.5 Un cofre de mazmorra en la planta 0 cuelga el juego ⏳ PENDIENTE

MEDIDO

El original: el botín de cofre pide una cantidad con mínimo 1 y máximo planta·8 (SJOG.OVL:0x179e, la llamada en 0x1897). En la planta 0 eso deja el máximo en 0, por debajo del mínimo. Y el generador no lo comprueba:

20b0: mov bx, [bp+6]   ; bx = mínimo
20b3: mov cx, [bp+4]   ; cx = máximo
20b6: sub cx, bx       ; 0 − 1 = 0xFFFF
20b8: inc cx           ; → 0x0000
20b9: xor dx, dx
20bb: div cx           ; división por cero → INT 00h

Por qué es un bug: rand_range es el generador compartido de todo el juego y no tiene guarda máximo < mínimo; el div convierte el descuido en el final de la partida. La tabla de cantidades DS:0x41C4 = [31, 0, 3, 3, 3, 7, 7] corrobora el diagnóstico: tiene un cero justo en la ranura que este camino no consulta.

🔴 CORREGIDO (07-08-2026): no es un cuelgue, es una SALIDA A DOS con mensaje. La versión anterior de esta ficha decía «cuelgue duro», y está medido que no lo es. El arranque instala un manejador propio de INT 00h (0x0244-0x024c → CS:0x0212), que es el de la C-runtime de Microsoft: imprime por STDERR run-time error R6003 - integer divide by 0 y llama a exit(255). La salida es ordenada: cierra los handles abiertos y restaura los vectores de interrupción.

Cómo se identificó el runtime, sin depender de leer ese cuerpo: la tabla de mensajes DS:0xa452 (pares id-word + cadena NUL, centinela 0xFFFF) decodifica a R6000 stack overflow · R6001 null pointer assignment · R6002 floating point not loaded · R6003 integer divide by 0 · R6009 not enough space for environment. Los códigos R60xx son de Microsoft C.

Qué cambia y qué no. El defecto sigue siendo real —no hay guarda máximo < mínimo en un helper que usa todo el juego— y la divergencia deliberada del port (lanzar en vez de dividir) sigue siendo correcta. Lo que cambia es el modo de fallo: no se congela la máquina, se termina el proceso y se pierde el progreso no guardado.

Sin medir, y por eso no se afirma: si la terminación restaura el modo de vídeo. No se han leído los encadenados de salida 0x037d/0x038c/0x07a2, así que no afirmamos que el mensaje llegue a verse — sólo que se emite por STDERR.

Alcance: con los mapas de fábrica no ocurre — hay 3 cofres en todo DUNGEON.DAT y ninguno en la planta 0. La puerta queda abierta para cualquier mapa generado en runtime.

OpenU5: ✅ ya cubierto. JavaScript no tiene división entera por cero, así que el cuelgue no se hereda, y además OriginalRng.next (Port: game/src/core/rng-original.ts:95, verificado 2026-08-05) rechaza el rango imposible con un error explícito en vez de propagarlo. Es divergencia deliberada, y la autoriza la excepción de cuelgues del contrato. Detalle que importa para la paridad: el rechazo ocurre antes de consumir el paso del generador, así que un llamador que capture la excepción no se lleva un paso del stream por delante — hay test que lo fija en los dos sentidos (el imposible no avanza la semilla; el dominio legal sale idéntico). Este es el único bug del registro que el contrato ya autorizaba a arreglar antes de la enmienda, por su excepción de cuelgues.

🔴 CORREGIDO 2026-08-06: la conclusión era correcta y la evidencia citada apuntaba al sitio equivocado. Esta fila decía rand_range(1, planta·4) y citaba como llamador SJOG.OVL.asm 0x182b-0x183c. Al re-leer la rutina entera aparecen cuatro llamadas al generador, y la citada no es la del botín ni puede colgar:

llamadamínimomáximo¿cuelga en planta 0?
0x183c (la que se citaba)1planta·4 + 4 (shl,shl,add ax,4 en 0x1838)no — el máximo es 4
0x1855 · 0x186e07no
0x1897, rama si==11planta·8 (mov cl,3/shl ax,cl en 0x1886)SÍ — el máximo es 0

Dos errores independientes se cancelaban en apariencia: se citó la llamada equivocada y se leyó su aritmética sin el add ax,4, que es justo lo que la hace inofensiva. El desplazamiento del sitio real es de tres bits, no de dos: planta·8.

La convención de argumentos queda acreditada por el propio cuerpo —bx = [bp+6] es el mínimo porque es lo que se suma al final (add dx, bx en 0x20bd)— y por el orden de los push del llamador: el 1 se empuja primero, así que cae en [bp+6].

Grado: MEDIDO (rand_range leído en ULTIMA.EXE.asm 0x2091-0x20c5 — su prólogo está en 0x2091, no en 0x2092, por el desajuste de #9; las cuatro llamadas leídas en SJOG.OVL.asm dentro de la rutina 0x179e). El alcance —los 3 cofres— es RELAYADO del acta.

La segunda puerta de 0x1897 NO existe — MEDIDA y cerrada (2026-08-06). La rama else toma su máximo de [si+0x41c4], y la sospecha era que algún si alcanzable cayera en la ranura que vale 0 y colgara sin necesidad de la planta 0. Se cierra enumerando las dos cosas, no comprobando si el 0 está en la tabla:

⇒ ningún si alcanzable por el else lee un 0, y el máximo mínimo que puede salir de ahí es 3. El único 0 está en la ranura 1 — que es justo la que el else no puede leer nunca, porque si == 1 se aparta a su rama tres instrucciones antes.

Y el 0 no es un descuido: es un marcador. La fila 1 es la del oro, cuya cantidad no sale de la tabla sino de g_floor·8. El 0 significa «esta fila no usa la tabla». O sea: el mismo valor que parecía una segunda puerta latente es la señal de la fila que ES la puerta conocida. Buscar el 0 en la tabla lleva al sitio correcto por el motivo equivocado.

La rutina tiene UNA puerta, no dos. Las cuatro llamadas al generador, con su régimen:

llamadamínimomáximo¿puede max < min?
0x183c (guarda de fila)1planta·4 + 4 ≥ 4no, nunca
0x1855 (si=5, poción)07no
0x186e (si=6, pergamino)07no
0x1897 vía si=1 (oro)1planta·8SÍ en la planta 0 ← la puerta
0x1897 vía else1{31,3,3,3}no

(cuatro instrucciones call, cinco caminos: 0x1897 tiene dos entradas.)

Una acotación más, para que nadie la convierta en probabilidad de oído: en la planta 0 la fila del oro sólo se alcanza si la guarda cruza, y su guarda es 4 sobre una tirada rand(1, 4) — o sea la tirada tiene que caer justo en el tope de su rango. Aquí NO se publica el «1 de cada 4» que sale de suponer uniformidad: §2.2 documenta que este generador no es uniforme, y su distribución para un rango de anchura 4 no se ha medido.

1.6 «Ship rigged for double speed!» miente sobre cuándo surte efecto ✅ ARREGLADO

DERIVADO

El original: los planos del HMS Cape dan doble velocidad a la fragata. Pero recoger los planos con (G)et ya escribe 0xFF en el byte, así que la doble velocidad está activa desde que los recoges. El or 0x80 que hace (U)se sobre un byte que ya vale 0xFF es un no-op, y el mensaje «Ship rigged for double speed!» (DS 0x49C2) es puro decorado.

Por qué es un bug: el mensaje anuncia un cambio de estado que ocurrió antes y que esa acción no produce.

OpenU5: ✅ anuncia donde el estado cambia. El mensaje de EA se emite ahora en el (G)et de los planos, que es donde la fragata queda aparejada de verdad, y el (U)se a bordo emite un eco veraz —texto nuestro, sin cita DS, porque no existe en el binario—. Port: el anuncio en game/src/core/game.ts:4946, el eco en game/src/core/endgame/use-tools.ts:111 (ambos verificados 2026-08-05). La mecánica no se toca: la doble velocidad se sigue concediendo en la recogida, y la escritura no-op del (U)se se conserva calcada. Se descartó a propósito la otra forma posible —hacer que el (U)se sea quien conceda la velocidad—: eso no habría sido arreglar un bug sino rediseñar la mecánica del original.

Grado: DERIVADO (re/notes/trama-140-acta.md §1.6).

1.7 El caballo del pozo de los deseos aparece dentro de un muro ⏳ DIVERGENTE

DERIVADO

El original: el pozo coloca el caballo en (x+1, y) sin comprobar que la casilla sea transitable. Puede acabar dentro de un muro o en el agua.

Por qué es un bug: es una escritura ciega de posición. Su hermana —el paso al este al salir de la cama— hace lo mismo, pero ahí el dato la respalda: barridas las 32 mapas pequeños, las 264 casillas de cama izquierda tienen cama derecha al este, 264 de 264. En el pozo no hay tal invariante: el patrón cae en muro en torno a la mitad de los casos.

OpenU5: ya diverge (comprueba la pasabilidad). Port: game/src/core/game.ts:5717 (spawnWishHorse, verificado 2026-08-05). Ficha #227.

Grado: DERIVADO (re/notes/deriv-211-acta.md; el censo 264/264 de la hermana, MEDIDO).

1.8 Huecos de dato en textos ✅ NINGUNO LLEGA AL JUGADOR

MEDIDO

La regla, primero: cuando falta el número —no la puntuación— es un hueco y se arregla. Es la frontera con la §3, y es deliberada: falta un dato ⇒ se arregla; falta una comilla ⇒ se conserva.

Los dos casos del original que la cumplen, y qué hace OpenU5 con cada uno:

Cómo se leyó mal esto una vez, y la regla que salió. La primera versión de esta sección daba los dos por calcados, citando world/shrines.ts:186 como prueba del calco. Esa línea es un comentario que describe lo que hace el original, no la conducta del clon. De ahí la regla que ahora sigue todo el registro: el estado del port va con cita de fichero:línea verificada, igual que el binario va con su cita de asm. Una cita a un comentario no es una cita a la conducta.

Y su reverso, para cuando el registro crezca: una fila cuyo estado de port nadie haya abierto se marca explícitamente «(sin verificar)», no se deja muda. La regla existe para que una fila futura no se lea como verificada por el mero contagio de sus vecinas, que es exactamente cómo esta sección llegó a afirmar un calco que no existía.

Cobertura hoy: 25 de 26. El denominador son las filas de §1 y §2 —las únicas donde «qué hace OpenU5» significa algo; §3, §4 y §5 no llevan estado de port por diseño—, y el numerador las que traen al menos una cita Port: de fichero:línea. La que falta es §2.6, marcada «(sin verificar)» como manda la regla de arriba. re/tools/test_cobertura_port.py recalcula las dos cifras del propio fichero y se pone rojo si esta frase deja de cuadrar, en los dos espejos; la frase se regenera con python3 re/tools/registro_cifras.py --write — nunca a mano.

🔴 Aquí vivía un «12 de 12 filas» y era falso por los dos lados. Retirado el 07-08-2026 tras recorrer los 25 commits del fichero: el 12 no coincide con ningún denominador en ningún momento de su historia —filas totales 19→21 · filas de §1+§2 14→16 · filas de §1 8→9 · citas Port: 14→16 · filas con cita 13→15—. Y «la cobertura es total» tampoco era cierto el día que se escribió: era 13 de 14, porque §2.6 ya estaba muda. El párrafo que ESTRENABA la regla de marcar las filas mudas contenía una, y se declaraba completo.

Grado (PROPUESTO el 2026-08-10 desde la evidencia que esta fila ya citaba): MEDIDO para el santuario. El cuerpo de shrine_visit (CAST2.OVL:0x0966) está declarado leído entero —«CUERPO ENTERO LEÍDO 2026-08-07 · 958 B declarados = 958 B reales» (re/notes/shrines.md §1)—, y es esa misma lectura la que trae las seis direcciones que esta fila cita (0x0b26, 0x0b2a, 0x0b2d, 0x0b47, 0x0b6f, 0x0b74). Cuerpo leído instrucción a instrucción es, por la tabla de arriba, MEDIDO. La media línea de la taberna hereda el grado de §1.2 y no se re-declara aquí. Sin TESTIGO en ninguna de las dos mitades. ⚠ Al citar, re/notes/shrines.md y no re/notes/cast2-shrines-acta.md: ese segundo es anterior al 07-08 y sigue tabulando shrine_visit como «❌ NO LEÍDA», así que quien cite el acta equivocada verá esta fila sin evidencia.

1.9 Usar la Skull Key dentro de una mazmorra la gasta y no abre nada ⏳ PENDIENTE

MEDIDO

El original: el handler de la Skull Key del comando (U)se descuenta la llave antes de comprobar dónde estás.

CAST.OVL
18c4  dec byte ptr [g_skull_keys]        ← el cobro
18c8  print "Skull Key\n"                  (DS 0x48fe)
18cf  cmp byte ptr [g_location], 0x21    ← el gate, DESPUÉS del cobro
18d6  cmp byte ptr [g_location], 0x7f
18db  jbe → print "Not here!\n"            (DS 0x4909) y salir

En la banda 0x21..0x7f el jugador pierde una llave y lo único que ve es «Not here!». Y el rechazo es especialmente mudo: en ese camino la bandera de resultado sigue en 1, así que la cola común del despachador (0x1b8a) ni siquiera emite el «Failed!» ni su tono.

Esa banda son las ocho mazmorras. No es una franja teórica. MAINOUT.OVL:0x0887-0x088c escribe g_location = [bp-2] + 1 al entrar, y el sub ax,0x4000 de dos instrucciones antes fija la base en 0x20 (para que el offset del fichero no sea negativo), con bloques de 0x200 B — una mazmorra cada uno. Ocho mazmorras ⇒ g_location = 0x21..0x28.

Y se llega de verdad, aunque la mazmorra tenga su propio teclado. dng_dispatch_key (DUNGEON.OVL:0x06c4) sólo atiende un puñado de códigos; todo lo demás cae a su rama por defecto (0x07a0), que llama a kernel_cmd_dispatch (ULTIMA.EXE:0x3178). Allí 0x34e8 cmp ax,0x55 — la 'U' — salta a 0x340c, que imprime «Use item» y entra al despachador de objetos sin ningún gate de localización. Es decir: estar en una mazmorra, pulsar U y elegir la llave.

Segundo testigo, y viene del propio clon: la tarjeta #123 de OpenU5 arregló que usar la Skull Key dentro de una mazmorra cayera al camino de superficie y desmagificara una puerta del mapa de arriba. Ese arreglo no habría hecho falta si la rama fuese inalcanzable.

★ El control está en el handler de al lado. La alfombra, el handler inmediatamente anterior del mismo despachador y el mismo tipo de consumible, hace lo contrario: dos guardas (0x1869 localización, 0x187f tile) y sólo después 0x18a1 dec byte [g_carpets]. Guarda-luego-consume, a 35 bytes de distancia, en la misma rutina — así que no vale la defensa de «convención descuidada de la época»: el patrón correcto lo tenían escrito al lado. Y el mismo texto cuesta dos precios: «Not here!» está dos veces en DATA.OVL, 0x48f3 (rechazo de la alfombra, gratis) y 0x4909 (rechazo de la llave, cuesta una llave); el jugador ve la misma línea y no puede distinguirlas. El contraste se ve también en el censo de guardas: g_skull_keys aparece en 2 sitios de todo el binario (el dec y una lectura de inventario) y no tiene ni un solo cmp; g_carpets aparece en 10, con guarda de cero (CMDS:0x0fd6), tope a 99 (SJOG:0x14a9) y dos vías de reposición.

El coste está acotado, y conviene decirlo: el dec no lleva guarda, pero no puede envolver 0 → 255, porque el selector de objetos no entrega objetos con contador 0 — ZSTATS.OVL:0x05a4 find_next_owned acepta una entrada sólo si byte [bx+si] != 0 (0x05ba-0x05bd) y si no sigue buscando. Se pierde una llave por pulsación, no el inventario. Ojo: esa seguridad vive en otra rutina, no en el dec.

Qué NO es: no confundir con el gem. (V)iew a gem (ULTIMA.EXE:0x341a) tiene la misma forma —descuenta y luego mira g_location— pero sus dos ramas hacen algo (visor de superficie LOOKOBJ.OVL:0x10fc / visor de mazmorra DNGLOOK.OVL:0x06a8). Ahí el objeto se gasta y se usa; aquí se gasta y se rechaza.

OpenU5: hoy lo calca. Port: game/src/core/game.ts:3170-3181 (useSkullKey, skullKeys-- y después if (location >= 0x21 && location <= 0x7f) → "Not here!", verificado 2026-08-07). Entra en esta sección y no en la §2 porque no toca el stream — el único rand_range de las 1054 B del despachador es la moneda de orientación de la alfombra (0x1899), fuera de este camino—, así que le aplica el punto 2 de la regla. Arreglarlo es mover el dec detrás del gate.

Grado: MEDIDO (re/notes/skullkey-alcanzabilidad.md; cuerpo de CAST.OVL:0x1792 leído instrucción a instrucción en re/notes/cola-cast-acta.md §3). Sin TESTIGO: no se ha corrido el original bajo DOSBox para esta fila.


1.10 Pasar en el prompt de la Skull Key la gasta y revienta tu propia casilla ⏳ DIVERGENTE

MEDIDO

Hermana de 1.9, mismo handler, otro camino: aquí no estás en una mazmorra, simplemente no eliges dirección.

El original: el worker que abre la puerta (CAST2.OVL:0x0768) devuelve tres valores distintos, y el llamador sólo distingue dos.

CAST2.OVL:0x0768
 076e  call 0x306        → pide dirección
 0775  cancelado         → return 0xFFFF
 079d  no era puerta     → return 0
 07a3  abierta           → return 1

CAST.OVL (rama de la llave)
 18dd  call → el worker
 18e3  or ax, ax
 18e5  jne 0x18ea        ← 0xFFFF es DISTINTO DE CERO: la cancelación entra por «éxito»
 18e7  jmp 0x1b8a          (sólo el 0 sale por aquí)
 18ea  cmp byte [g_location], 0x80 ; jb 0x18f4
 18f4  push [g_cmb_scratch_x] ; push [g_cmb_scratch_y]
 18fc  call → explosion_fx_at_cell (ULTIMA.EXE:0x3522)

El or ax,ax parte el espacio en {0} y {1, 0xFFFF} — o sea mete la cancelación en el mismo saco que el éxito.

Y las coordenadas no están vacías: son las tuyas. CAST2.OVL:0x0306 siembra g_cmb_scratch_x/_y con la celda del propio lanzador, incondicionalmente y antes del prompt, y el camino de ESPACIO no las toca. Así que 0x18f4 entrega tu casilla.

⇒ Pulsar ESPACIO en el prompt gasta la llave y dispara el efecto sobre la casilla del propio grupo, sin ningún mensaje de error. explosion_fx_at_cell (fila sellada, cuerpo entero leído, 66 B) pinta blit_tile(y, x, 0) — el tile 0 —, hace noise_burst(2000,3000,10) y redibuja el viewport. Límite, en la misma frase: que el tile 0 se llame «Explosion» sale de docs/manual/companion/tiles-lookup.js, tabla de terceros; del binario sólo se deriva que es el tile 0. Y esta fila no depende del contenido de esa rutina: lo que aquí se afirma es con qué coordenadas se la llama.

★ La ceguera es COMPLEMENTARIA, y sólo se ve leyendo el PAR. El otro llamador del mismo worker —el brazo del hechizo In Ex Por, CAST.OVL:0x1026— hace cmp ax,0xffff, o sea parte el espacio en {0xFFFF} y {0, 1}: acierta la cancelación y confunde «no había puerta» con «abierta». Cada uno maneja bien exactamente uno de los dos fallos y mal el otro, y ninguno es superconjunto del otro. Una fila del ledger sola no lo enseña: hace falta mirar los dos llamadores a la vez.

OpenU5: DIVERGE, y no se arregla — se declara. El clon gasta la llave igual (fiel) pero en la cancelación no dibuja nada. Port: game/src/core/game.ts:3170-3195 (useSkullKey; la cancelación sale en game/src/core/game.ts:3187, if (!dir) return events;) — leído y verificado 2026-08-07. Es decir, al clon le falta el efecto que el original sí pinta. Es presentación pura y no toca el stream — el único rand_range del despachador es la moneda de la alfombra (0x1899), fuera de este camino—, así que no hay riesgo de paridad en ninguna de las dos direcciones. Entra aquí como divergencia declarada, no como arreglo pendiente. ⚠️ Nota menor de cita: el comentario del port atribuye la cancelación a 0x18e7, y 0x18e7 es el camino de «no era puerta»; la cancelación sale por 0x18ea.

Grado: MEDIDO. El mecanismo de la ceguera complementaria y el censo de llamadores del worker salen de re/notes/inexpor-dos-llamadores-acta.md (carril bugs-original); la lectura de CAST2.OVL:0x0306 que convierte la ceguera en esta consecuencia concreta es de cola-cast (re/notes/cast2-shrines-acta.md §1.2). Sin TESTIGO: no se ha corrido el original bajo DOSBox para esta fila.


1.11 Mantener pulsada la V se come la gema y no te enseña el mapa ✅ ARREGLADO

MEDIDO

Lo que ve el jugador: pulsa V para mirar el mapa, la consola dice «View a gem!» — y no aparece nada. Una gema menos y un turno gastado. Reportado por un jugador de OpenU5 en la cámara del trono del Palacio de Blackthorn, con captura.

El original: la vista de gema es un bucle que se cierra con la primera tecla que haya en el búfer, y la repetición automática del teclado mete ahí la misma tecla que la abrió.

LOOKOBJ.OVL  gem_view
10fc..1187  pinta la rejilla 32×32 y el marcador
118a        sub si, si
118c        jmp 0x11b6              ← al sondeo, SIN purgar el búfer antes
11b6        call → kernel 0x1b38 → 0x1d5e
11bb        je 0x118e               ← 0 = sin tecla, sigue parpadeando
11c3        ret                     ← con tecla: SALE

ULTIMA.EXE  0x1d5e  (el sondeo)
1d6a        mov ah, 1 ; int 0x16    ← ¿hay tecla? (no destructivo)
1d86        mov ah, 6 ; int 0x21    ← la LEE y la CONSUME

El teclado de un PC no distingue una repetición de una pulsación nueva: la BIOS mete en el búfer el mismo código, y el juego nunca cambia esa cadencia — censo de los 28 ficheros del corpus: cada int 16h del juego usa AH=1 (sondeo) o AH=2 (teclas modificadoras), y cero usan AH=03h, que es la función de fijar el retardo y la velocidad de repetición. Se queda la del sistema. Así que al mantener la V: la abre la pulsación, la cierra la primera repetición, y la siguiente vuelve a abrirla gastando otra gema, porque el bucle principal la lee como una V nueva.

Por qué el jugador no lo relaciona con mantener la tecla. El eco «View a gem!» sólo se imprime al ABRIR (ULTIMA.EXE:0x341e, antes del gate de gemas); la salida del bucle no imprime nada. Con dos pulsaciones-equivalentes se ve un eco y ninguna vista: idéntico a que el comando estuviera roto. Es la paridad la que decide si queda abierta, no el número de ecos — que cuenta sólo aberturas.

Medido en OpenU5 antes del arreglo (repeticiones sintéticas de V, contando ecos de consola y estado final):

pulsacionesecosgemasturnosvista
11−10abierta
21−1+1cerrada ← lo que reportó el jugador
32−2+1abierta
42−2+2cerrada

Alcanzabilidad: alta, y sin nada raro. No hace falta machacar la tecla: basta una pulsación deliberada que pase del retardo de repetición del sistema — el que cada jugador tiene configurado en su teclado, y que en el original era el de la BIOS de su PC. Mirar el mapa es justamente el gesto en el que uno se queda un momento con el dedo puesto.

OpenU5 lo arregla: una repetición automática ya no cierra la vista; hay que soltar y volver a pulsar. La pulsación que abre y sus repeticiones son el mismo gesto, y un gesto no puede abrir y cerrar a la vez. Port: game/src/main.ts (rama canvasGemActive del keydown), con la vista del catalejo (zodiacActive) igualada por coherencia — ésa no gastaba nada, sólo parpadeaba.

★ Por qué la guarda NO está en todo el teclado. Porque en Ultima V se anda manteniendo la flecha: la repetición automática es el paso continuo. Bloquearla en general habría cambiado este defecto por una regresión mayor, y encima menos fiel. Se corrige sólo lo que no tiene defensa —que la repetición de la misma pulsación que abrió un modal lo cierre— y el andar-manteniendo se comprobó intacto tras el arreglo.

No confundir con §1.9. Aquella fila dice que en el gem «el objeto se gasta y se usa», por contraste con la Skull Key que se gasta y se rechaza. Sigue siendo cierta: habla del gate de localización, donde las dos ramas del gem hacen algo. Esta fila es otra manera de perder la gema, por el bucle de la vista y no por el gate.

Grado: MEDIDO en los dos lados. Binario: cuerpo de gem_view y del sondeo leídos instrucción a instrucción, más el censo de int 16h sobre el corpus entero. Port: tabla de paridad de arriba, reproducida sobre el caso exacto del reporte. Sin TESTIGO: no se ha corrido el original bajo DOSBox manteniendo la tecla.

1.12 En las esperas animadas, el halo de la última antorcha se estampa sobre tu ventana ✅ NO SE CALCA

DERIVADO

El original: la rutina que redibuja la ventana de juego (ULTIMA.EXE 0x5910) tiene dos ramas: recálculo —rehace la visibilidad entera— e incremental, que recorre las 121 casillas y repinta con terreno crudo sólo las que valen cero (59ad). El problema es de dónde salen esos ceros. El pase que calcula los halos de las antorchas usa el buffer de la ventana de la party como cuaderno de borrador: marca cada casilla que visita poniéndola a cero (5b89), y lo hace con un índice en coordenadas locales de la antorcha, mientras que sus escrituras al buffer de luces sí llevan el origen (5b48-5b5c). Dos convenciones de índice distintas dentro de la misma rutina.

Nadie más deja ceros: tras el recálculo, 0x5D0A recorre las 121 y convierte los ceros en 0xFF (5d76-5d8a). Así que la única fuente de ceros del sistema es el borrador del pase de luces ⇒ un fotograma incremental repinta exactamente la huella de la última antorcha procesada, con la forma que tenía en su propio marco, caiga donde caiga en tu pantalla.

Por qué es un bug: revela terreno en el sitio equivocado, y lo hace por reutilizar un buffer con dos significados. No es raro: 0x10d0 llama al redibujo 32 veces por invocación (bucle 0x1070, cada 8 vueltas del driver de sonido) sin ningún evento de juego en medio, y el indicador de recálculo se apaga en la primera (598a) — 31 de esas 32 pasadas van por la rama incremental. Lo ves durante las esperas animadas y desaparece en cuanto la party hace algo.

OpenU5: ✅ no lo calca. El clon recalcula la visibilidad entera en cada fotograma (computeVisibleWindow es una función pura, sin estado entre fotogramas), así que no tiene ni el buffer compartido ni las dos ramas. Reproducirlo obligaría a introducir estado mutable entre fotogramas más el reparto por indicador, para obtener un parpadeo de terreno mal colocado. Port: game/src/core/world/visibility.ts (el docblock lo declara con las citas). No confundir con el PUENTE, que es la parte de esta misma rutina que sí es mecánica y sí se calca (ficha #256): fuera del radio de la party, una casilla transparente sólo se ve si su vecina de origen está también iluminada — por eso el halo de una antorcha sólo amplía tu visión cuando toca tu propio círculo de luz.

Grado: DERIVADO (re/notes/visibilidad-256-acta.md §2; cuerpos de 0x5910, 0x5A28, 0x5D0A y 0x5E4A leídos instrucción a instrucción). Sin TESTIGO: no se ha corrido el original bajo DOSBox para fotografiar la huella.


1.13 Catorce salas de mazmorra te dejan encerrado para siempre ✅ ARREGLADO

MEDIDO

Lo que ve el jugador: en Doom, baja del nivel 2 al 3 por la escalera U/D, cae en una sala sin paredes con ratas gigantes y wisps, gana el combate — y ya no puede moverse en ninguna dirección. Ni andar, ni Klimb, ni hechizo. La partida se acaba ahí. Reportado por un jugador de OpenU5 el 16-08.

El original: la sala está bien; el que no tiene salida es el sitio donde te deja. Al terminar un combate de sala, dng_enter_room te devuelve a la celda por la que entraste: guarda g_party_x/y al entrar (DUNGEON.OVL 0x0084/0x008c) y los restaura por las dos ramas de salida (0x00fa-0x0103), sin mirar por qué borde del tablero saliste. Y catorce celdas de sala del juego tienen los cuatro vecinos a muro y ninguna puerta secreta que revelar con (S)earch — censo sobre los bytes crudos de DUNGEON.DAT, 14 de las 198 celdas de sala. Sobre el tile ya despejado (0xF6 & 0xAF = 0xA6, 0x00f5) el original no acepta nada:

DUNGEON.OVL  0x05FF   andar     hi ∈ {0xB,0xC,0xD} → "Blocked!"   (los cuatro vecinos son 0xB0)
             0x1e5e   Klimb ↑   hi ∈ {0x10,0x30}, o el bit 0x08 del tile CON garfio
             0x1e79   Klimb ↓   hi ∈ {0x20,0x30,0x60}
CAST.OVL     0x0fd2   Uus Por   cmp byte ptr [g_location],0x28 ; jmp <fallo silencioso>
             0x0ffc   Des Por   ídem — 0x28 = 40 = Doom: vetados en esa mazmorra

El patrón que se repite en las catorce: la celda de sala ocupa el sitio donde estaría el rellano de una escalera, y la escalera pareja sigue existiendo en la planta contigua (0x10 arriba, 0x20 abajo, 0x30 ambas). El nibble alto de la sala no lleva la escalera, así que el rellano deja de ser rellano.

Cuatro tienen una puerta accidental. El gate de Klimb-arriba lee el bit 0x08 del tile crudo sin mirar el nibble alto (1e52: and ax,8), y en una celda de sala ese bit es el bit 3 del número de sala. Consecuencia no buscada: las cinco selladas con sala ≥8 se leen «iluminadas» y con el Grapple el original sube por el techo. Escapan cuatro; la quinta es la sala del desenlace (Doom planta 7), donde el tile de encima es foso de caída y on_enter (0x0C76) te devuelve — sube y cae, planta 7 → 7. El landing no lo impide porque dng_landing_ok (0x1C0C) sólo mira el tile destino con mode ≠ 0, y Klimb pasa mode = 0. ⇒ diez encerronas duras en 1988, y cuatro que dependen de llevar un objeto opcional. La del reporte (sala 6, bit 3 = 0) está entre las diez.

OpenU5 lo arregla con una condición nueva en el gate de Klimb, declarada como divergencia deliberada donde vive: sobre una sala YA despejada, si la celda de la planta contigua es la escalera cuya pareja ocupaba este rellano, se klimba por ahí. No es un valor inventado — la relación ya está en los datos de 1988; lo que se restituye es la salida por donde entraste. Dos propiedades que acotan la intervención, ambas medidas:

Port: game/src/core/dungeon/dungeon.ts (parejaDeEscaleraBajoSala, con la guarda de intervención mínima en klimbCaps). Guarda: game/tests/salas-selladas-mazmorra.test.ts.

Grado: MEDIDO en los dos lados. Binario: cuerpos de dng_enter_room, del gate de Klimb, de dng_landing_ok y de los dos gates de CAST leídos instrucción a instrucción, más el censo de las 14 celdas sobre los bytes crudos de DUNGEON.DAT. Port: la conducta de las 14 ejercida celda a celda, antes y después. Sin TESTIGO: no se ha corrido el original bajo DOSBox hasta una de las catorce. Derivación completa en re/notes/salas-selladas-mazmorra.md.


2. Bugs que OpenU5 calca a propósito

Casi todos mueven el stream de números aleatorios, o dependen de él. Arreglarlos rompería el instrumento con el que se verifica el port entero. Se quedan, y se declaran.

Hay una segunda razón para calcar, y la estrena §2.7: cuando el defecto consiste en que el original no hace algo, «arreglarlo» significa inventar contenido que EA nunca publicó. Eso no es portar, y por eso también se calca. (Este párrafo se añadió con §2.7: hasta entonces la sección afirmaba que el stream era el único motivo, y con esa fila habría dejado de ser cierto.)

2.1 El bono nocturno de encuentros vive en una rama muerta

MEDIDO

El original: el umbral de aparición de enemigos suma +3 «de noche» (MAINOUT.OVL:0x0D8C). La condición es hora >= 0x20 o hora < 5. Pero 0x20 es 32, y la hora vive en 0..23: esa mitad de la condición no se cumple jamás. El bono sólo entra por la otra, de 00:00 a 04:59.

Por qué importa, y no es una curiosidad: en terreno normal el umbral base es 1, y la tirada es rand(1,30), así que sin el bono no aparece nada nunca. El resultado es que en el Ultima V de 1988 el atardecer, de 20:00 a 23:59, no tiene encuentros aleatorios en terreno normal. La franja que uno esperaría peligrosa es la más segura del día.

INFERIDO, y se etiqueta: 0x20 donde la intención plausible era 20 decimal (0x14) tiene toda la pinta del clásico dedazo hex/decimal. Es lectura de intención y no sostiene nada por sí sola; el hecho —la rama no se cumple— es MEDIDO.

OpenU5: calca la rama muerta. Port: game/src/core/world/loops/spawn.ts:37 (spawnThreshold, verificado 2026-08-05). Una opción conmutable queda como posibilidad futura, cuando el espejo de verificación ya no dependa del stream.

Grado: MEDIDO (re/notes/loops.md §1.1 y §1.2, asm verificado).

2.2 El generador de 1 a 30 no es uniforme

DERIVADO

El original: rand30 reparte P(1) = 4/61, P(2..29) = 2/61, P(30) = 1/61. La causa es una corrección de signo inerte en ULTIMA.EXE:0x3abe (0x3ad0-0x3ad1): la instrucción está y no hace nada.

La derivación, para que nadie tenga que rehacerla. 0x3ac9 empuja 60 y llama a 0x3aae, que es rand_range(0, n) (sub ax,ax / push / push [bp+4] / call 0x2092). rand_range es inclusivo por los dos extremos, así que devuelve r ∈ 0..60: 61 valores equiprobables. Después cdq / sub ax,dx / sar ax,1 es el idiom de división entera por 2 —y ahí está la corrección de signo inerte, porque r nunca es negativo—, y 0x3ad8-0x3adc sube el 0 a 1. Contando:

valorsale decasos
1r ∈ {0,1} (elevado por el inc) y r ∈ {2,3}4
2..29dos r cada uno56
30r = 601
61

🔴 CORREGIDO 2026-08-06: esta fila decía 3/61, y era falso. El error se delata solo con una suma: 3 + 28·2 + 1 = 60, que no es el denominador. Una distribución que no suma su propio denominador está mal por construcción, y comprobarlo cuesta una línea — CIFRAS.md §F tenía el 4/61 correcto y era esta fila la que había que arreglar.

✅ REPLICADO de forma INDEPENDIENTE el 06-08-2026. El 4/61 se ha derivado dos veces por caminos distintos y sin que un autor conociera el del otro: aquí, con la tabla de conteo de la fila de arriba; y en el área web, enumerando los 61 valores de max(1, rand(0,60) >> 1) — mismo resultado, P(1)=4/61 · P(30)=1/61, y la distribución suma su denominador en las dos. Se anota porque el grado sube y no se ve en el número: una cifra replicada por dos métodos independientes no es «lo derivó su autor».

Por qué es un bug: el mapeo de rango pierde la uniformidad que el propio código intenta imponer. El extremo alto sale cuatro veces menos que el bajo (1/61 contra 4/61).

🔴 Ese «cuatro» decía «tres» hasta el 2026-08-10, y es el RESIDUO de la corrección del 06-08. Aquella corrección cambió 3/61 por 4/61 en la tabla y en el enunciado, y dejó intacta la frase que INTERPRETA la cifra tres párrafos más abajo — que seguía siendo el cociente viejo, escrito con letras en vez de con números. Se anota porque el modo de fallo se repite: una cifra corregida no arrastra a las frases que la traducen a lenguaje llano, y esas frases son justamente las que se citan fuera del documento, porque son las legibles.

OpenU5: lo calca exactamente, y no puede no calcarlo: es el corazón del stream. Port: game/src/core/rng-original.ts:97 (el % span sobre & 0x7fff, verificado 2026-08-05). Aviso para quien lo reimplemente — esto invalida cualquier versión «limpia» del estilo 1 + rand(0,29).

Grado: DERIVADO del ledger (asm-kernel-l4-tanda1).

2.3 La comprobación de «¿es persona?» del Shadowlord mira siempre la ranura 4

DERIVADO

El original: al buscar a quién poseer, la puerta lee el tipo de NPC con mov bx, cx en TOWN.OVL:0x111f — pero cx es el contador de un bucle ya agotado y vale 4. Consulta siempre la ranura 4, no la que está evaluando (su índice se había destruido con shl si,4 en 0x1103).

Por qué es un bug: las dos rutinas hermanas, 0x85e (Astaroth) y 0x8d4 (Nosfentor), sí recargan el argumento para hacer su propio test. Tres sitios, el mismo test, y sólo uno se olvida de recargar.

Efecto real: dormido en la partida canónica — las ocho ciudades de virtud tienen a una persona en la ranura 4, así que la puerta pasa igualmente.

OpenU5: calcado, con tests. Consume 32 tiradas pase lo que pase, igual que el original. Port: game/src/core/world/shadowlord-urban.ts:108 (possessGateRoll, verificado 2026-08-05).

Grado: DERIVADO (re/notes/shadowlord-urban.md §4).

2.4 Fuera del mapa pequeño se lee siempre la casilla (31,31)

DERIVADO

El original: con una coordenada fuera de rango, get_tile_ptr no recorta ni envuelve: devuelve un puntero fijo, DS:0x6A07, que es el último byte del buffer — la casilla (31,31). Es una lectura fuera de rango que resulta inofensiva por accidente.

OpenU5: calcado. Es lo que explica el borde de hierba alrededor de Britain. Port: game/src/core/world/map.ts:48 (edgeFillTile, verificado 2026-08-05).

Grado: DERIVADO, ya declarado en re/deliberate-divergences.md.

2.5 In Bet Xen invoca las cuatro criaturas en la misma casilla

MEDIDO

El original: el segundo bucle de CAST.OVL:0x07b4 no vuelve a llamar al selector de casilla, así que las hasta cuatro criaturas invocadas aparecen todas encima de la misma.

Por qué es un bug: el selector existe y se usa para la primera. No volver a llamarlo es la definición de descuido.

OpenU5: hoy diverge (llama al selector por criatura). Port: game/src/core/combat/combat.ts:2589 (verificado 2026-08-05). Se está corrigiendo hacia el calco, no hacia el arreglo, precisamente por la regla del stream. Ficha #6.

Grado: MEDIDO por el carril de combate.

2.6 La tirada se gasta también por los muertos y por los que resisten

MEDIDO

DUNGEON.OVL:0x0948 pide su número aleatorio siempre, incluso para miembros que no pueden verse afectados. Consume stream. Calcado; es uno de los ejemplos vinculantes del contrato.

OpenU5: (sin verificar). Nadie ha abierto el clon para esta fila. Consume stream, así que por la regla 1 se calca de todos modos — pero «se calca» es aquí una CONSECUENCIA DE LA REGLA, no una conducta observada, y el registro no las confunde.

Grado (PROPUESTO el 2026-08-10 desde la evidencia que esta fila ya citaba): MEDIDO el mecanismo. La fila dng_field_sleep del ledger declara «CUERPO ENTERO LEIDO 0x0948-0x09E4 (ret, sin args)» y dentro de esa misma lectura está, literal, la afirmación de esta fila: «rand_range se llama SIEMPRE, con pushes (1, 0x1e) = rango 1..30 — o sea la tirada se consume tambien por los muertos y por los que resisten». Sin TESTIGO: re/verified/dungeon.md la clasifica bajo «asm + cruce modelo↔clon», no bajo paridad de oráculo. Nótese que el grado es del BINARIO; el estado del port sigue (sin verificar), que es otra cosa y va arriba.

2.7 El huevo de pascua de EA está en los datos y el código no lo alcanza ✅ CALCADO

MEDIDO

El original. Al hablar con cualquier NPC, antes de mirar las palabras clave de su guion, el juego prueba una tabla fija de palabras: NAME, JOB, WORK, BYE, THANK y luego una ristra de palabrotas, a las que el NPC responde siempre lo mismo — "With language like that, how did you become an Avatar? (DS 0x93D0).

La tabla vive en DS 0x4AA8 y tiene 35 entradas. La última, la 34, es ELECTRONIC ARTS.

Nunca se prueba. La tabla tiene exactamente dos consumidores en los 28 ficheros desensamblados —el bucle del prompt «Your interest?» (TALK.OVL:0x0B43) y el de respuesta a una pregunta del NPC (TALK.OVL:0x0CB4)— y los dos llevan el mismo tope:

0bab / 0d15:  cmp byte ptr [bp-2], 0x22     ; 0x22 = 34
              jae  <salir>                   ; comparado DESPUÉS de incrementar

⇒ se prueban los índices 0..33 y la 34 jamás. La cadena DS 0x9318 no tiene ninguna otra referencia en todo el desensamblado: es dato muerto.

Y el remate, que es lo que convence de que estaba pensado para funcionar: ELECTRONIC ARTS mide 15 caracteres exactos, que es justo el tope del búfer de entrada del prompt (TALK.OVL:0x0A33, mov ax,0xf). Cabe al carácter. Alguien lo escribió para que se pudiera teclear, y el 0x22 del bucle lo dejó fuera.

Grado: MEDIDO en las dos mitades — el contenido de la tabla leído del fichero de datos, y el tope leído en las dos instrucciones. El censo de consumidores es por hexadecimal sobre los 28 .asm; no descarta un acceso por puntero calculado, que no se ha censado.

Qué hace OpenU5: lo calca — y para calcarlo hubo que QUITAR algo. Aquí es donde este caso se sale de la sección: el clon sí respondía. Su lista de palabrotas llevaba 30 entradas — las 29 del binario en el mismo orden más ELECTRONIC ARTS, verificado comparando las dos listas elemento a elemento contra el fichero de datos. O sea que no modelaba el original de otra manera: le añadía una respuesta que el original no da.

Ésa es la razón de que esta fila esté en §2 y no en §1. No calcamos por el stream —esto no consume ni una tirada—; calcamos porque hacer que el huevo de pascua funcione sería inventar contenido que EA nunca publicó. Un clon fiel no puede añadir.

Port: game/src/core/dialogue/conversation.ts (PROFANITY_KEYWORDS, verificado 2026-08-06). La entrada retirada, con el porqué pegado al código para que nadie la reponga «por completitud», y un test que se pone rojo si vuelve.

De dónde venía la entrada de más, que es la parte instructiva. No se coló al escribir el clon: se heredó de una cifra mal contada que estaba en tres sitios a la vez — el comentario del propio motor de conversación, la cita de la fila del ledger y el censo de cobertura del corpus. Los tres decían «índices 5..33 = 28 palabrotas + ELECTRONIC ARTS», y 5..33 son 29 huecos, los 29 palabrota. Nadie había vuelto a contar contra el fichero de datos. El port heredó el error de la cita, no al revés, y los tres sitios se corrigieron en el mismo commit que este arreglo — porque si se deja uno, el próximo lector repone la entrada desde el texto viejo.

2.8 El día 20 de cada mes, la marchitación del Shadowlord no perdona NADA

MEDIDO

El original: cuando un Shadowlord toma un pueblo, la vegetación se marchita (CAST2.OVL:0x022a). El barrido siembra su propio generador con el día del mes — srand(g_day)— y para cada trigal o árbol tira rand(0,7): si sale 0, esa casilla se salva (0x025f/0x027e, or ax,ax / je). Uno de cada ocho, por diseño.

El día 20 no se salva ninguna. La razón no está en el barrido: está en el generador. 20 es un punto fijo de la función de paso de rand_range (ULTIMA.EXE:0x2092):

20 + 0x9248 = 0x925C   ; add ax, 0x9248
ror16(·, 3) = 0x924B   ; d1c8 ×3
    ^ 0x9248 = 0x0003  ; xor ax, 0x9248
      + 0x11 = 20      ; add ax, 0x11   → la semilla NO cambia

Con la semilla estancada en 20, rand(0,7) devuelve siempre 4 — nunca 0 —, así que la puerta de salvación no se abre ni una vez. Ese día la marchitación es total.

Medido, día a día (1000 tiradas por día, sobre el generador del clon, cuya paridad con el binario está verificada bajo dosbox-x):

díacasillas salvadas de 1000
1..19, 21..28entre 103 y 141 (≈ 1/8, lo esperado)
200

Y el punto fijo es único: barriendo los 65.536 estados de 16 bits, 20 es el único valor cuyo paso se devuelve a sí mismo, y su única preimagen es él mismo — no se llega a él avanzando, sólo sembrándolo.

Por qué es un bug y no una rareza: el código pide explícitamente salvar 1/8 y un día de cada 28 salva 0. Es visible en pantalla, es determinista y le pasa a todo el mundo.

OpenU5: lo calca, y no puede no calcarlo — igual que §2.2, el defecto vive en el corazón del generador, y «arreglarlo» sería cambiar el RNG con el que se verifica el port entero. Port: game/src/core/world/shadowlord-wither.ts:81 (new OriginalRng(day & 0xff), verificado 2026-08-07).

Grado: mecanismo MEDIDO (la aritmética del punto fijo se comprueba a mano en cuatro líneas). Alcanzabilidad MEDIDA Y POSITIVA: día 20, determinista, cada mes, siempre que la marchitación corra. SIN TESTIGO VIVO bajo dosbox-x: la tabla de arriba sale del clon —generador con paridad verificada—, no de ver el pueblo marchitarse el día 20.

Cabo honesto, medido y NEGATIVO — la vía que parecía obvia no lo es. El juego re-siembra el RNG del reloj de pared al acampar, y la sospecha natural es que ahí pueda colarse el 20. No puede. La rutina que fabrica esa semilla (ULTIMA.EXE:0x2056) combina hora, minuto, segundo y centésima y termina con and ax, 0xfff; enumerando las 8.640.000 lecturas de reloj posibles, ninguna produce 20 (de hecho sólo 2688 de los 4096 valores de 12 bits son producibles, y el 20 no está entre ellos). Así que la re-siembra de acampada no es una puerta a este defecto, y las consecuencias más graves del punto fijo quedan fuera de alcance — ver §4. (Aritmética replicada de forma independiente el 2026-08-10 y con fuente ejecutable desde entonces: re/tools/test_semilla_reloj.py, en la batería, re-extrae las constantes del cuerpo de 0x2056 y re-deriva las tres cifras en cada corrida, careándolas con las publicadas aquí.)

2.9 En combate, dos de los cuatro muros de campo no hacen absolutamente nada

MEDIDO

El original: los cuatro hechizos de muro (In Flam Grav, In Nox Grav, In Zu Grav, In Sanct Grav) tienen dos ramas según dónde estés (CAST.OVL:0x004c, 0054 cmp byte [g_location],0x80). En mazmorra siembran un tile de campo en la casilla de enfrente. En combate no siembran nada: fijan un «arma-hechizo» (00ef mov al,[bx+0x4592]) y llaman al despachador de ataque normal — el MISMO al que llaman Grav Por, Vas Flam y Xen Corp (0100 call 0xffffc14a, que resuelve a COMSUBS.OVL:0x0c52). Y el daño de esas cuatro armas sale de la tabla attackValues: 18 · 0 · 21 · 0.

⇒ En combate, In Zu Grav e In Sanct Grav gastan el hechizo mezclado, gastan el maná, gastan el turno y hacen CERO — ni campo, porque esa rama no siembra, ni daño, porque su entrada de la tabla es 0. Los otros dos al menos golpean (18 y 21), pero tampoco levantan muro: el jugador que lanza «un muro de fuego» en la arena está lanzando un dardo.

Por qué es un bug y no una decisión: las dos ramas existen y hacen cosas distintas a propósito, así que el combate no está «sin implementar». Lo que falla es que a dos de las cuatro armas nadie les puso valor: el hechizo llega hasta el motor y el motor no tiene qué aplicar. Un no-efecto que cuesta recursos no es una mecánica.

Cómo se sabe que en combate NO se siembra campo (y no es que no lo hayamos encontrado): el censo no es «no vimos escrituras» sino quién referencia la tabla de tiles de campo. DS:0x4596 (los tiles 0x80-0x83) aparece una sola vez en todo el desensamblado — en la rama de mazmorra. La cadena de combate está leída entera (attack_dispatch_by_reach → player_ranged_attack / melee_strike_resolve → hit_roll / apply_damage_death_loot) y ninguna de las cuatro rutinas escribe un tile.

OpenU5: CALCADO. Los valores 18/0/21/0 se transcriben, no se «corrigen»: inventarle daño a In Zu Grav sería inventar contenido que EA nunca publicó — la misma razón de §2.7 — y además movería el stream. Port: game/src/core/combat/combat.ts:406 (SPELL_WEAPON_STATS, las cuatro entradas con los dos ceros) y :457 (combatCastEffect, la traducción a ataque), enganchados en game/src/main.ts:2268 (verificado 2026-08-08). El calco lo guarda un test: game/tests/field-wall-dungeon.test.ts se pone rojo si alguien «arregla» los ceros.

Grado: mecanismo MEDIDO (las dos ramas, la tabla de armas, la de daño y la cadena de combate entera, leídas). Alcanzabilidad SIN CENSAR: los cuatro están permitidos en combate por la máscara DS:0x1C90 (0x03), así que el camino existe — pero nadie ha contado cuántas partidas lo pisan. Ficha #91.


2.10 El barrido del speaker nunca llega a su frecuencia nominal

MEDIDO

El original: pcspeaker_glide (ULTIMA.EXE:0x43ae) recibe cuatro argumentos —inicio, fin, paso, total— y los 30 call-sites estáticos del juego empujan un fin concreto. La rutina lo usa UNA sola vez: para calcular la pendiente (0x43bc sub ax,[bp+0xa] · 0x43c2 imul · 0x43cc idiv = trunc(((fin − inicio)·paso)/total)); nunca lo compara — el corte del bucle es 0x43ec cmp di,[bp+4]; jl contra total, y el tono de cada vuelta suena ANTES del incremento. Encima, el imul descarta la parte alta (0x43c5 guarda sólo ax; 0x43cb cwd re-deriva el signo) y el idiv trunca hacia cero, así que el error de redondeo se acumula vuelta a vuelta. Medido sitio a sitio (re/notes/firma-43ae-todos-los-callsites.md §3, re-derivado en re/notes/barrido-137-acta.md): el último divisor escrito al PIT (0x22e2 div 0x1234DE → out 0x42 ×2) no corresponde a la nominal en NINGUNO de los 30, y ese registro RETIENE lo escrito hasta el gate OFF (0x43f7 → 0x230e). Los dos peores: OUTSUBS.OVL:0x0492 pide 2500→800 y el barrido muere en 1005 Hz (+205); MAINOUT.OVL:0x113b/0x12a6 pide 660→150 y muere en 272 Hz (+122 — más de una octava por encima de lo que el llamador pidió).

Por qué es un bug y no una decisión: el dato existe y se consume a medias. Cada llamador escribe una frecuencia de destino que la rutina usa para la pendiente y jamás para el destino — una promesa sin efecto. El argumento de simetría: la primitiva hermana set_tone (0x22e2) entrega Hz exactos (= su argumento), y el propio barrido arranca EXACTO en inicio; sólo el extremo fin queda sin garantía. Donde la división es exacta el desvío es una vuelta (−inc, inaudible como tal); donde trunca, se dispara hasta las +205 Hz de arriba.

OpenU5: CALCADO (ficha #137). El port sintetizaba una rampa limpia inicio→fin que SÍ llegaba a la nominal: divergencia audible de CLASE, en los 30 sitios a la vez. Se calca la escalera real — mismo incremento truncado, mismo corte temprano, misma frecuencia final efectiva. Port: game/src/skin/fiel/speaker.ts:214 (glide, la aritmética del cuerpo con su truncado de 16 bits) y :1148-1154 (la síntesis conmuta divisor a divisor con un setValueAtTime por vuelta, sin rampa), en uso por los seis cues glide del catálogo (:801, :805, :808, :809, :871, :874 — verificado 2026-08-18). El calco lo guarda game/tests/glide-escalera-137.test.ts con los finales derivados del binario escritos en crudo (1005 · 272 · 233 · 1980 · 1976): se pone rojo si alguien repone la rampa. Arreglarlo sería inventar un sonido que 1988 nunca emitió — la razón de §2.7 y §2.9 — y decidir si EA «quería» el barrido corto es lectura de intención, no de bytes.

Grado: MEDIDO (el cuerpo 0x43ae-0x43ff re-leído instrucción a instrucción sobre re/disasm/ULTIMA.EXE.asm:7424-7459, con set_tone 0x22e2 y el gate OFF 0x230e; los dos call-sites testigo leídos en crudo: OUTSUBS.OVL.asm:462-470 y MAINOUT.OVL.asm:1745-1753; el 30/30 del censo, en la nota citada). Ficha #137.

2.11 El guion TLK lleva seis errores de programación — y se calcan byte a byte

MEDIDO

El origen del careo: el paquete de errores de diálogo que la comunidad documenta desde el newsgroup (hilo de r/Ultima 130bqnu, 2023, con fix público de terceros — raldi/ultima5-tools — que este port NO usa). Cada caso se re-verificó aquí contra NUESTROS bytes — los .TLK crudos y TALK.OVL — no de oídas; los seis existen en v1.16.

La mecánica que los produce, común a los seis (MEDIDA en re/disasm/TALK.OVL.asm): el intérprete de guiones empareja de forma RÍGIDA — la keyword k-ésima es la línea 2k+5 (0x09a7-0x09a9: shl ax,1 / add ax,5) y su respuesta la 2k+6 (0x0bba-0x0bbc); un <Or> (0x87) en posición de respuesta salta un chunk y procesa el siguiente (0x0fbc → 0x07be); el impresor de respuestas PARA en el terminador 0x00 (0x0788); en un label, el default es UN solo chunk (0x096e) y las sub-keywords van a pregunta+2+2k (0x0bd4). Con esa aritmética, cualquier byte de más, de menos o mal colocado en los datos descuadra el guion — y EA dejó cinco:

casoel dato roto (cita cruda)lo que hace el DOS
Thrud (KEEP 8)86 1c en KEEP.TLK:0x12a8 — 28 decimal donde iba 0x28=40promete la Jeweled Sword y da la ballesta (slot 28); el 86 08 de 0x12a6 (Jewel Shield) sí es correcto
Weblock (CASTLE 14)gorn<00>hass<00><87><00>respuesta en CASTLE.TLK:0x1b2f — el <Or> va DESPUÉS de «hass» (formato sano: spir<00><87><00>musi, 0x010c)preguntar GORN responde el literal «hass»; HASS y la respuesta del prisionero son inalcanzables
Sir Arbuthnot (DWELLING 15), «coin»la keyword está en DOS cadenas — roya/coin (0x1d6c) y magi/coin (0x1e10) — y gana la primeraCOIN responde la continuación del job, no la moneda del Códice (que queda alcanzable sólo vía MAGI)
Sir Arbuthnot, label 2faltan los bytes y<00> ante la rama del honor (el label hermano 1 SÍ los tiene, ~0x1ec0)responder «y» a «Art thou the one true Avatar?» cae al default «Oh.» — el sí se trata como un no y la rama del honor + AskName es inalcanzable
Malik (TOWNE 3)la oferta dice 3 (dígito en TOWNE.TLK:0x6d3) y el opcode cobra 85 '004' (0x6fa)ofrece el chisme por 3 monedas y cobra 4
Mario (TOWNE 23)un 0x00 ESPURIO tras «charity!» parte la respuesta de mino/crimla respuesta se trunca ahí; la tercera sección y las keywords caug/tort quedan muertas

Testigo del original (Mario): el corpus del espejo (OCR de frames de EA, ruta ad03) muestra la respuesta a CRIME cortando en «charity!» y volviendo a «Your interest?» — la conducta bugueada, observada en el binario corriendo.

Por qué se calcan y no se arreglan: no tocan el stream, pero (a) «arreglarlos» sería publicar contenido que ningún jugador de 1988 vio — la razón de §2.7 —, y (b) el careo del espejo verifica el port contra la conducta OBSERVADA del original: un port «arreglado» divergiría del propio instrumento de verificación (la mitad de Mario ya está fotografiada en el corpus).

El port los reproducía a medias, y las dos infidelidades se retiraron (2026-08-26): (1) el intérprete imprimía TODOS los defaultAnswers de un label — con Arbuthnot label 2 soltaba «Oh.» Y la rama inalcanzable del honor; hoy procesa UNO, el calco de 0x096e (Port: game/src/core/dialogue/conversation.ts:1078-1091, censo: es el único label del juego con >1 default). (2) el extractor heredaba de Ultima5Redux un «hack» que cosía el 0x00 espurio de Mario; retirado (Port: extractor/src/parsers/tlk.ts:181 y la guarda de frontera :346). Los otros cuatro ya fluían por los datos: Thrud entrega el byte crudo (game/src/core/dialogue/effects.ts:206, applyGiveItem), Malik cobra el data del opcode (conversation.ts:925 → effects.ts), Weblock y el «coin» eclipsado salen del espejo TLK tal cual. Candados: game/tests/bugs-original-tlk.test.ts (13 asertos, rojo-antes verificado en los dos fixes), game/tests/dialogue.test.ts (Thrud [8, 28] — su comentario decía «28 = escudo enjoyado» y era falso: corregido) y extractor/tests/tlk.test.ts (el parser conserva el truncado y el <Or> transpuesto).

Grado: MEDIDO (mecánica y los seis datos leídos en crudo; la mitad de Mario además con testigo del original vía el corpus del espejo). Carril bugs-original, 2026-08-26.

2.12 La clase fantasma: «Adept of Woznir»

MEDIDO

El original: un byte de clase que no sea ninguna letra de "AMBFDTPRS" hace que la ficha de stats muestre la clase «Adept of Woznir». El mecanismo (MEDIDO, ledger frontier.json — cuerpo entero de draw_stat_page, ZSTATS 0x0082-0x0274): strchr_index (ULTIMA.EXE:0x4d76, llamado en ZSTATS:0x00e0) NO devuelve −1 al fallar — su bucle sale por el NUL y devuelve el contador, o sea 9 con nueve letras selectoras — y la tabla de nombres de clase (DS 0x1a44) tiene DIEZ punteros: el décimo (DS 0x8f8 → DATA.OVL fileoff 0x908) apunta a la cadena real «Adept of Woznir» del pool, justo detrás de «Shepherd». El ledger dejaba anotado «no adjudico cuál es la cadena de la décima entrada»; queda adjudicado aquí leyendo el dato. La comunidad lo conoce (hilo uoit80; Garriott, preguntado, no lo conocía — es del equipo del port PC), y la simetría delata que la mitad es deliberada y la otra no: la tabla de NOMBRES tiene su décima entrada apuntando a una cadena real (huevo de pascua), pero la de SANGRADO (DS 0x1a58) tiene NUEVE words — el índice 9 lee la primera word de la tabla vecina (0x908 = 2312) y el bucle emite 2312 espacios antes del nombre.

Alcanzabilidad: sólo con un save editado — la creación de personaje y el roster nunca escriben letras fuera de la selectora (ledger: alcanzabilidad censada en el único llamador, siempre miembros del grupo). Familia de §5.5, pero aquí el binario tiene CONDUCTA concreta y con nombre, así que se calca en vez de documentarse como curiosidad.

OpenU5: calcado el nombre — letra inválida → «Adept of Woznir» (Port: game/src/skin/fiel/ztats.ts:192, verificado 2026-08-26; candado en game/tests/fiel-ztats.test.ts). Los 2312 espacios del sangrado NO se calcan (indent 1): divergencia declarada en el propio docblock — reproducir una lectura fuera de tabla no es fidelidad observable útil en una ventana de 15 columnas.

Grado: MEDIDO (mecanismo y cardinales en el ledger, instrucción a instrucción; la cadena de la décima entrada leída en crudo de DATA.OVL:0x908). Carril bugs-original, 2026-08-26.

2.13 La identidad de una sala de mazmorra es UN NIBBLE: limpiar una limpia a sus gemelas

MEDIDO

El origen del careo: un jugador de la edición GOG/DOS reporta que en Covetous encuentra muchas salas ya vacías sin haberlas tocado, y que además una sala que sí limpió vuelve a estar armada (hilo r/Ultima 1dxojlh, 2024). Los comentarios del hilo proponen la semántica —16 salas-arquetipo por mazmorra, instancias repetidas que comparten flag, «Covetous cheats to increase its apparent room count»— sin cita del binario. Contra nuestros bytes las dos mitades del reporte son ciertas, y son dos mecanismos distintos.

El mecanismo de la primera mitad, MEDIDO. Una celda de sala es el tile 0xFn, y el número de sala es el nibble bajo, y nada más: dng_enter_room toma el tile crudo y lo enmascara en DUNGEON.OVL:0x0024 (250f00 and ax,0xf). El bit persistente que recuerda la sala se indexa (dungIdx<<4) + roomNumber (DNGLOOK.OVL:0x08a2 shl ax,4 + 0x08a6 add ax,[bp+4], puesto en 0x08bf-0x08c9) — sin piso, sin X, sin Y. Y quien aplica ese bit al mapa recorre los 512 bytes de la mazmorra entera —las ocho plantas— comparando sólo el nibble: DNGLOOK.OVL:0x093a, bucle 0x094d-0x0973, con 0952: 24f0 and al,0xf0 / 0957: 3cf0 cmp al,0xf0 (¿es celda de sala?), 0960: 250f00 and ax,0xf (el número), 0967: e86aff call 0x8d4 (¿tiene el bit?) y 096e: 8024af and byte ptr [si],0xaf (degrada 0xFn→0xAn, o sea sala consumida). Ese bucle tiene un solo llamador en los 28 .asm: DUNGEON.OVL:0x0e4a (e8fdeb call 0xfffffa4a → stub kernel 0x7c1a, resuelto con dispatch_table.stubs()), la carga del mapa fresco. Control positivo del mismo método: el 0x00de de dng_enter_room (e8b1f9 call 0xfffffa92 → stub 0x7c62 → DNGLOOK 0x0844), que es la marca, resuelve por la misma vía.

⇒ Ganar UNA celda apaga TODAS las celdas del mismo nibble en toda la mazmorra, a la siguiente carga de mapa. Dentro de la misma visita no se nota: DUNGEON.OVL:0x00f5 (80a05a59af and byte ptr [bx+si+0x595a],0xaf) degrada sólo la celda de entrada.

Y el dato dice cuánto muerde, leído de DUNGEON.DAT (4096 B = 8 bloques de 512):

mazmorraceldas 0xFnnúmeros distintospeor sala
Deceit · Destard · Shame · Hythloth · Doom16 c/u161 celda — sin compartición
Despise00— (no tiene salas)
Wrong3616salas 1·2·5·6·11·12 con 4 celdas cada una
Covetous8216sala 1 con 17 celdas, repartidas en 7 de las 8 plantas

(Comando: contar los bytes con b & 0xF0 == 0xF0 por bloque de 512 y agrupar por b & 0xF. Los cardinales reproducen exactamente el censo independiente que re/notes/dungeon.md §14.1.1 hizo sobre game/assets/maps/dungeons.json, que es el control cruzado del orden de bloques.)

⇒ Cinco de las siete mazmorras con salas no pueden sufrirlo —su reparto es 1:1— y las dos que sí son justo Wrong y Covetous. En Covetous, limpiar una sola instancia de la sala 1 apaga las otras dieciséis. La frase de la comunidad —«Covetous hace trampa para inflar su número aparente de salas»— es literalmente cierta: 82 entradas de sala, 16 salas.

La segunda mitad —la sala que reaparece— es OTRO mecanismo, y ya estaba medido aquí. Antes de poner el bit, DNGLOOK.OVL:0x0844 busca la clave ((g_location & 0xF) << 4) + roomNumber en una tabla estática de DGROUP (0x0850-0x0887; cuenta en DS:0x3840, tabla en DS:0x383a) y, si la encuentra, salta al epílogo sin ejecutar el or. Volcada en crudo (delta fileoff = DS+0x10):

DATA.OVL 0x384a  (= DS:0x383a)  50 5b 41 46 4b 4c
DATA.OVL 0x3850  (= DS:0x3840)  06

0x41·0x46·0x4b·0x4c = Wrong 1, 6, 11, 12; 0x50·0x5b = Covetous 0, 11. Esas seis no se apuntan nunca ⇒ se re-arman entre visitas: son las únicas seis re-jugables de las 112 del juego (derivación completa en re/notes/dungeon.md §14.1.2).

★ Lo que este careo añade, y que la derivación anterior declaraba no haber podido derivar («POR QUÉ ESAS 6: NO derivable de los datos, y lo he intentado»): la POBLACIÓN de la lista de excepciones sí es derivable. De los 112 pares (mazmorra, sala) con al menos una celda, 20 tienen más de una instancia — o sea, comparten bit. Las seis exentas son las seis multi-instancia (4·4·4·4 en Wrong, 15 y 5 en Covetous); ninguna sala 1:1 está exenta. Si las seis se hubieran sorteado entre las 112, la probabilidad de que las seis cayeran dentro de esas 20 es 1,6 × 10⁻⁵. ⇒ la tabla no es una lista arbitraria: es una mitigación escrita a mano del defecto de arriba, y parcial — deja fuera 14 de las 20, entre ellas la peor (Covetous sala 1, 17 celdas). Lo que sigue sin derivarse es cuáles seis de las veinte.

Por qué se calca: el bitmap es estado del .GAM (offset 0x33A, 14 bytes = 7 mazmorras × 16 salas) y su semántica gobierna qué salas re-pelea el jugador; arreglarlo cambiaría el recorrido, el botín y la XP de dos mazmorras enteras respecto de lo que se jugó en 1988.

Port: calcado en las dos mitades — game/src/core/dungeon/dungeon.ts:1387-1401 (applyClearedRooms = 0x093a: recorre todas las plantas y degrada toda celda Room cuyo sub & 0xf tenga el bit) y :1925-1930 (dungeonMarkRoomCleared, con la guarda ROOM_CLEAR_EXEMPT de las seis claves). Candados: game/tests/dungeon.test.ts (las 6 claves exentas + control no-exento de las mismas dos mazmorras + el ciclo completo consumo→sin bit→mapa fresco→re-armada) y el spec ch24b-covetous-roomno-reread.spec.ts, cuya invariante declarada es precisamente la fusión («ganar CUALQUIER celda del roomNo degrada TODAS las celdas de ese roomNo»).

Grado: MEDIDO (las dos rutinas leídas instrucción a instrucción en re/disasm/, el llamador único resuelto por dispatch_table.stubs(), la tabla de excepciones y el reparto de celdas volcados en crudo de DATA.OVL y DUNGEON.DAT). Carril bugs-25-28, 2026-08-29.



3. Erratas que se conservan como parte del original

Aquí falta puntuación o sobra una letra. Arreglarlo sería corregir a los autores, y este proyecto preserva el juego, no lo edita.

«a crytal sphere»

errata

— el nombre del tile en LOOK2.DAT, con la errata de 1988. Conservada byte a byte en los datos de OpenU5.

La despedida del gremio deja una comilla sin cerrar

errata

— la cadena DS 0x78a0 abre "What else, y nunca cierra. Conservada verbatim en game/src/core/world/cmd-strings.ts:473.

«What didst thou say?»

errata

(DS 0x9450) — mismo caso.

La banda L5 del disolvente de la intro repite un índice y omite otro

errata

(repite 0x13, se deja 0x1d), de modo que una línea de barrido no la revela esa pasada. Es inofensivo —el solape de la banda siguiente la cubre— y se conserva; hay un test que lo fija precisamente como prueba de que el volcado es byte-exacto. ---

4. Defectos latentes, sin camino vivo demostrado

Reales en el código, sin que podamos afirmar que un jugador de 1988 los tocara. Se listan para quien retome el hilo, no como bugs sufridos.

Alistar a alguien que YA viaja contigo duplica su ficha y borra la de otro

MEDIDO

(TALK.OVL:0x080a, join_party). El barrido busca al recluta por los tres primeros caracteres de su nombre recorriendo las ranuras 15 hacia abajo hasta la 1 (0x0818 arranca en 15; 0x089a resta 0x20; 0x08a2 para al llegar a 0). Un miembro que ya está en el grupo vive en las ranuras 1..partySize−1, o sea dentro de ese barrido: lo encuentra. Y entonces ejecuta el intercambio de siempre (0x08c8-0x0912) contra la ranura partySize — copia su registro de 32 B al hueco libre y el del hueco al suyo —, e incrementa g_party_size. Resultado: su ficha aparece dos veces y la del otro registro se pierde, con el equipo dentro, porque el repne movsw mueve el record entero. No hay guarda en el cuerpo: el único filtro es el nombre. Alcanzabilidad: ABIERTA. Hace falta un .TLK cuyo opcode de alistamiento nombre a alguien que ya pueda estar en el grupo — plausible con los compañeros que se recuperan de una posada, sin censar. MEDIDO en el desensamblado; el camino vivo, no. OpenU5 NO lo calca: guarda contra ese caso y sale por la salida de «sin coincidencia», que es una del binario, sin inventar texto (core/party.joinByName, verificado 2026-08-06). Corromper el roster no es fidelidad observable, es pérdida de datos — y es la frontera que separa esta sección de §2.

Despacho de estadísticas sin caso por defecto

MEDIDO

(COMBAT.OVL:0x13e2). Los cuatro cmp cubren −4..−1 y cualquier otro valor cae en 0x1473, que lee [bp-4] nunca escrito por ese camino: devuelve basura de pila. Las dos puertas concretas son (a) el registro con su bit 0x40 puesto y selector distinto de 0, y (b) el bit limpio y selector igual a 0. De sus tres llamadores, dos pasan constantes seguras (−1 y −2) y el tercero propaga un valor de su propio llamador: la alcanzabilidad necesita subir un nivel más en el grafo de llamadas. MEDIDO en el desensamblado; alcanzabilidad ABIERTA.

La otra mitad del punto fijo del RNG: los reintentos SIN COTA se colgarían

MEDIDO

(§2.8 es la mitad alcanzable; ésta no lo es). Si g_rng_seed valiese 20, rand_range devolvería una constante para siempre, y hay bucles que re-tiran hasta que el valor les sirve sin contador: el re-sorteo de Shadowlords a medianoche (ULTIMA.EXE:0x5039 or di,di / je 0x5004) y el sorteo de aparición del sobremundo. Con una constante que choque, ninguno termina — no es lentitud, es cuelgue, y lo que se congela es el RNG del juego entero. Alcanzabilidad: MEDIDA Y NEGATIVA por las dos vías conocidas. (a) La semilla de reloj no puede valer 20: de las 8.640.000 lecturas posibles, cero lo producen (ULTIMA.EXE:0x2056, and ax,0xfff; sólo 2688 de 4096 valores son producibles y el 20 no está). (b) El stream vivo arranca en 0 y su ciclo tiene 47.343 estados que no incluyen el 20; como la única preimagen del 20 es él mismo, no se cae dentro avanzando. ⇒ Queda sólo la puerta del srand(g_day) de §2.8, que usa un generador PROPIO y un bucle acotado: allí marchita de más y termina, no cuelga. OpenU5 NO le pone cota a esos bucles —el binario no la tiene y ponerla sería divergencia—: se declara y se prueba (game/tests/survival.test.ts). Grado (PROPUESTO el 2026-08-10 desde la evidencia citada): MEDIDO el mecanismo. Es la única de las siete filas mudas cuya evidencia trae el grado con esas palabras, y por partida doble: re/notes/kernel-survival.md §8.3 escribe «Mismo grado que el defecto de rand_range sin guarda max<min: mecanismo medido, alcanzabilidad estrecha», y la nota de la rutina que contiene el bucle (re/notes/reloj-advance-clock.md, [0x4f7c, 0x51a0) = 548 B declarados = 548 B reales) cierra con «Sin grado TESTIGO: no he corrido el original bajo DOSBox para esta fila». ⚠ Cabo CERRADO (2026-08-10): la aritmética de la alcanzabilidad —las 8.640.000 lecturas de reloj y los 2688 de 4096 valores producibles— se replicó de forma independiente desde las constantes re-extraídas del desensamblado (mismas tres cifras; el 20 sigue sin ser producible) y desde entonces tiene fuente ejecutable: re/tools/test_semilla_reloj.py, en la batería, la re-deriva entera y carea las cifras publicadas en cada corrida.

text_gotoxy ignora los límites de su propia ventana

MEDIDO

(ULTIMA.EXE:0x1bf2): el registro de ventana lleva campos de límite en +2/+3 y la rutina compara contra los valores cableados de la pantalla física. Invisible para la ventana 0, donde coinciden. Además, salirse de rango es un no-op silencioso — quien lo porte recortando, diverge. Grado (PROPUESTO el 2026-08-10 desde la evidencia citada): MEDIDO. La fila del ledger declara «CUERPO ENTERO LEIDO 0x1bf2-0x1c1f (48 B, 23 insn, ret 4 = DOS argumentos)» y las dos afirmaciones de esta viñeta son suyas, textuales: «(a) NO CONSULTA LOS LIMITES DE SU PROPIA VENTANA: … este cuerpo compara contra INMEDIATOS, no contra [si+2]/[si+3]» y «(c) FUERA DE RANGO ES UN NO-OP SILENCIOSO: no recorta, no senala». Acta: re/notes/asm-kernel-l4-tanda1.md §3.1, que además declara que las tres salen de la lectura del cuerpo y no de la cita de nombrado. Sin TESTIGO.

Mix con un nombre sin coincidencia

sin determinar

entra en el selector de cantidad con índice negativo y lee fuera de rango (CMDS.OVL:0x1b13 sólo comprueba −1, no −2). OpenU5 lo evita a propósito. Grado: SIN DETERMINAR, y se declara en vez de callarlo. Al repasar los grados el 2026-08-10 ésta es la única de las siete filas mudas cuya evidencia no sostiene ninguno: la única fuente que enuncia el −2 es una línea de re/notes/cast-input.md §7 —«Mix sólo comprueba -1 (0x1b13 cmp ax,0xffff); un -2 cae en el selector de CANTIDAD con índice negativo … bug latente del binario nunca ejercitado con cuidado»— que no declara grado, y el acta que sí tiene el cuerpo entero de la rutina contenedora (cmd_mix, 0x1ad8-0x1c1f, 328 B, en re/notes/asm-town-zstats-acta.md §66.3) cubre la dirección pero no menciona el −2. O sea: la afirmación es plausible y nadie ha leído el sitio con esa pregunta en la mano. No se le asigna grado por parecido con sus vecinas — que es exactamente cómo se fabrican los grados falsos. Lo que hace falta para cerrarla: releer 0x1b13 y el selector de cantidad comprobando qué hace con −2.

resolve_command_char puede devolver un monstruo como índice de roster

DERIVADO

(ULTIMA.EXE:0x4988); dos de sus tres parientes sí comprueban. Grado (PROPUESTO el 2026-08-10 desde la evidencia citada): DERIVADO, sobre cuerpo MEDIDO. El acta declara «Cuerpo leído ENTERO ULTIMA.EXE:0x4988-0x4a83 (252 B, ret sin argumentos)», y la afirmación de esta viñeta la escribe ella misma con su grado puesto: «Consecuencia derivada del layout del ledger: si g_cmb_actor apunta a un MONSTRUO, su campo +2 no tiene el bit 0x80, y su campo +3 no es un slot de roster. resolve_command_char lo devuelve igualmente como índice de miembro del party» (re/notes/resolve-command-char-178c-acta.md). Es decir: el cuerpo está medido y la consecuencia es una derivación sobre el layout, así que la fila se queda con el grado de lo MÁS DÉBIL de los dos, que es lo que la fila afirma. Sin TESTIGO.

La misma distancia, medida de dos maneras en el mismo fichero — el aviso de proximidad se pierde en la costura del mundo

MEDIDO

(MAINOUT.OVL:0x007a). Dos rutinas vecinas calculan «distancia al grupo» con reglas distintas: - object_proximity_activate (cuerpo entero leído: 96 B, 43 instrucciones, ret sin inmediato) comprueba un solo objeto —si = 0x5c62 es constante, nunca se itera— con cuatro condiciones: tile no nulo, mismo piso, y |Δx| < 6 y |Δy| < 6 en valor absoluto pelado. Es una caja de Chebyshev de 11×11 alrededor del grupo; si pasa, un call a ULTIMA.EXE:0x3ae6 y nada más. - pick_spawn_coords (MAINOUT.OVL:0x0f4e), en el MISMO fichero, mide esa misma magnitud en el toro de 8 bits: su par |Δ| <= 6 / |Δ| >= 0xFA no son dos umbrales, son una sola condición con el envolvimiento incluido, porque las coordenadas se suman en 8 bits y envuelven en 256. Consecuencia si las coordenadas envuelven también en la primera: un objeto pegado al grupo por el otro lado de la costura da |Δ| ≈ 250, no < 6, así que el aviso no suena. Un fallo por omisión, silencioso y sólo en el borde. Grados, separados a propósito. MEDIDO: los dos cuerpos, las cuatro condiciones, la caja 11×11, y que la asimetría existe. MEDIDO también: esta rutina no consume RNG — su único call es de presentación. NO ADJUDICADO: cuál de las dos reglas es la correcta. Puede que en este contexto las coordenadas no envuelvan (espacio distinto) o puede ser un defecto del original; lo medido es la discrepancia, no el veredicto. Hay un tercer sitio del kernel con el mismo idiom del toro de 8 bits, y allí está usado bien — lo que acredita que el idiom es deliberado en la casa, pero no resuelve este sitio. ALCANZABILIDAD: ALCANZABLE. Dos piezas, con grados distintos: - Pieza 1, MEDIDA sobre game/assets/maps/overworld.json (256×256). En la franja de ±6 celdas a cada lado de la costura —que es justo el alcance de la caja 11×11— la navegabilidad es casi total: 3039 de 3072 celdas navegables en la franja vertical (98,9 %) y 3006 de 3072 en la horizontal (97,9 %). La costura no es una zona inaccesible: es mar abierto, y el grupo la cruza en cuanto navega. Las 15 y 26 celdas transitables a pie son islotes, que además hacen alcanzable el caso terrestre. - Pieza 2, por DOS VÍAS INDEPENDIENTES. Para que el defecto se manifieste, el actor tiene que quedar al otro lado de la costura — y el binario lo produce por partida doble. (a) DERIVADA: el sorteo de aparición forma la coordenada sumando en 8 bits, o sea con envolvimiento, y contempla explícitamente el caso «cerca por el otro lado» para rechazarlo (su comparación contra 0xfa); un sorteo que sabe envolver coloca al otro lado. (b) MEDIDA: el movedor MAINOUT.OVL:0x1578 suma en 16 bits y almacena truncando (0x16c7 mov al,[bp-6] / 0x16ca mov [si+0x5c5c],al, sin clamp y sin comprobar), así que la coordenada guardada envuelve módulo 256. Cualquier actor movido por esa rutina puede acabar al otro lado. SIN TESTIGO VIVO: nadie lo ha visto ocurrir con el binario corriendo. La fórmula exacta que esta fila sostiene es mecanismo medido, alcanzabilidad medida y derivada, sin testigo. Cabo honesto, que viaja con la fila: la identidad del ocupante de la ranura 1 sale del comentario del port y de la lectura de otros dos consumidores, no de una lectura del asignador. Si esa ranura resultara reservada a algo que nunca se acerca a la costura, la pieza 2 se cae y el veredicto vuelve a «teórico». Queda escrito para que se pueda tirar del hilo sin repetir todo. Port: NO MODELADO — no hay hook de proximidad por OBJETO en el clon (verificado 2026-08-06). ⚠ Y hay un homónimo que despista: el clon sí tiene proximidad, pero por TILE y de otra rutina (ambient_sfx_tick 0x4102 → game/src/core/sfx.ts:301, game/src/skin/coreview.ts:780). No es ésta. Quien lo porte hereda el problema entero: implementar cualquiera de las dos reglas sin adjudicar antes es elegir a ciegas.

El sorteo ponderado de criaturas no comprueba la longitud de su tabla

MEDIDO

(MAINOUT.OVL:0x0e04, weighted_pick, 74 B, cuerpo entero leído). Tira una sola vez rand_range(0,255) y recorre la tabla de pesos restando mientras peso <= resto, devolviendo el primer índice cuyo peso es estrictamente mayor. El bucle no compara contra ninguna longitud (0x0e33-0x0e3d: mov al,[bx+si] / cmp ax,dx / jbe, sin contador de tope): si los pesos sumaran menos de 256, el recorrido se saldría de la tabla y devolvería un índice inventado. La corrección no vive en el código: vive en los datos. Es la misma familia que la raíz de TALK — rutinas que confían en que su entrada sea perfecta. 🔴 Y el terminador no es una red. Un peso 0 no detiene el bucle nunca: la condición de continuar es peso <= resto, y 0 <= resto se cumple siempre. Los 0x00 que cierran dos de las tablas serían atravesados, no un tope — lo único que impide el desbordamiento es la suma exacta. Alcanzabilidad: MEDIDA Y NEGATIVA. Las cuatro tablas que le pasa su único llamador (tile_to_monster, MAINOUT.OVL:0x0e4e, en 0x0eb8/0x0eca/0x0f2c/0x0f3c, punteros DS 0x2BDC · 0x2BE8 · 0x2BF0 · 0x2BF6) suman exactamente 256 cada una, leídas byte a byte de DATA.OVL (fileoff = DS+0x10): 12 pesos 60+50+40+30+20+15+15+10+10+3+2+1, 7 64+56+56+32+32+8+8, 5 72+72+40+38+34 y 2 128+128. Y la longitud de cada tabla es justo el hueco hasta el puntero siguiente, que es el control que las delimita sin suponerlas. Con la tirada acotada a 255, el índice cae siempre dentro y los dos 0x00 terminadores no se alcanzan jamás. ⇒ con los datos de fábrica el defecto no tiene camino vivo. Lo sostiene el DATO, no una guarda — una edición que rebajara un peso lo despierta. Port: NO lo calca: le pone la cota que el binario no tiene (game/src/core/combat/encounters.ts:170, while (i < weights.length && weights[i] <= roll)). Divergencia declarada y no observable con las tablas de fábrica, precisamente porque el binario nunca llega al borde. Las cuatro tablas viajan volcadas byte a byte en ese mismo fichero (SPAWN_TABLES, líneas 148-156) y un test bloquea que no deriven de data.json. Grado (PROPUESTO el 2026-08-10 desde la evidencia citada): MEDIDO, y aquí la evidencia trae la palabra literal. La fila del ledger declara «CUERPO ENTERO LEIDO 0x0e04-0x0e4e (74 B, 35 insn, ret 2 = UN argumento … corte VALIDADO contra el size)» y remata «No adjudico si alguna tabla real suma de menos —no las he censado—; lo MEDIDO es que la rutina no se defiende». La alcanzabilidad se cerró después, en otra lectura de cuerpo, midiendo las cuatro tablas sobre la IMAGEN, y esa nota fija además el grado por equivalencia: es «el mismo grado que la ficha #23 dio a la división por cero de rand_range» —mecanismo MEDIDO, alcanzabilidad medida y negativa—. Acta en prosa: re/notes/asm100-censo-acta.md §58.2. Sin TESTIGO.

«Level 254»: el piso de mazmorra puede decrementarse desde el centinela del Underworld

MEDIDO

(candidato a mecanismo del «level 254 en Doom» que reportan dos testigos independientes de la comunidad, hilo r/Ultima j3dwk0 — sin receta de reproducción publicada). g_floor (DS 0x5895) usa 0xFF como centinela «Underworld», y 0xFF − 1 = 0xFE = 254. Censados los ESCRITORES de g_floor en todo el desensamblado (patrones inc/dec/add/mov byte): los tres dec de contexto de mazmorra — DNGLOOK.OVL:0x1077 (subir planta), DUNGEON.OVL:0x1dc6 (salida de sala de combate con resultado 5 = escalera arriba) y el add con delta −1 de change_level (DUNGEON.OVL:0x1cc1, guarda en 0x1c8d) — se guardan sólo contra g_floor == 0 (cmp …,0 / je), nunca contra el centinela: con 0xFF en la variable, los tres pasan y escriben 254. El mecanismo tiene TESTIGO: en el binario corriendo (oráculo dosbox-x headless, run-dir propio), con el centinela sembrado en contexto de mazmorra un (K)limb-arriba deja g_floor=0xFE (=254) y el siguiente 0xFD — sonda re/tools/level254_probe.py, con control positivo en dominio sano (7→6) y los bytes del escritor (0x1cc1) y su guarda (0x1c9a) verificados en RAM en la base de DUNGEON.OVL. Y el camino vivo quedó derivado: NO EXISTE con flujo y datos de fábrica. El invariante «location de mazmorra ⇒ piso 0..7» lo conservan los 24 escritores de g_floor y los 33 de g_location: toda entrada normaliza (MAINOUT 0x088f-0x08b4; Doom 0x28 especial-caseada a piso 0 en 0x0896), toda salida escribe capa Y location en la misma rutina (dng_exit 0x1d08), y las restauraciones de combate/escena/slots son pares simétricos (censo completo: re/notes/level254-camino-vivo-acta.md §3). La única puerta es SAVED.GAM, que lleva g_location/g_floor en crudo (offsets 0x2ED/0x2EF) y se carga sin validación — exactamente la zona que el hilo de reddit documenta corrompida por un editor de saves: el «confusor plausible» resulta ser el único VEHÍCULO, y los dos testigos quedan explicados sin camino vivo. La fila se queda en §4 (su condición de subir a §2 era que se derivara un camino vivo; se derivó su ausencia). OpenU5 NO puede reproducirlo por construcción: el piso de mazmorra vive en un estado por-mazmorra (game/src/core/dungeon/dungeon.ts:930-941, guardas <= 0 / >= N-1) y el centinela del Underworld no comparte variable con él (dungeon-cmds.ts:465); un .GAM editado tampoco lo cuela (el lector discrimina el byte sobrecargado, saveNative.ts:459 → sótano −1). Divergencia estructural declarada e inobservable sin fichero exógeno: no se restaura (contraste con Thrud/Mario, que tenían camino vivo; mismo trato que las tablas de spawn de esta sección). Grado: MEDIDO el mecanismo, con TESTIGO (sonda, 2026-08-27); alcanzabilidad derivada y negativa (acta level254). Carril bugs-original 2026-08-26 · carril level254 2026-08-27.

La acampada guarda el MES de la aparición en un byte que no lee nadie.

MEDIDO

Justo antes de llamar a la escena de la aparición, el helper de resultados del (H)ole up hace 04fc: a07d58 mov al, byte ptr [g_month] / 04ff: a28d58 mov byte ptr [0x588d], al (CMDS.OVL, camp 0x0000-0x054e). 0x588d no se lee jamás. Censados los 28 .asm resolviendo los 157 símbolos de re/ledger/globals.json y sumando el offset decimal de toda referencia [g_sym+N] —el desensamblador emite símbolo+offset en decimal, así que un grep de 0x588d da cero en falso—, más el hex crudo, y excluyendo la columna de encoding: 1 referencia, la escritura. Controles del mismo censo, con el mismo predicado: el vecino 0x588c (el cooldown real) sale con 4 —las dos lecturas de CMDS.OVL:0x03ea y 0x044c, la recarga a 0x0e de 0x0505 y el decremento por hora de ULTIMA.EXE:0x4fd7— y 0x588e (g_time_spell_turns) con 13 en 7 ficheros; y el único hit descartado es SHOPPES.OVL:0x0f4b, donde 588d son los bytes de encoding de un call, no un operando. 0x588d tampoco tiene entrada en el ledger de globales: el censo salta de 0x588c a 0x588e. Su vecindad y su valor delatan la intención —una cadencia de aparición por mes, junto al cooldown de 14 horas que sí está cableado—, pero el gate real es sólo rand(0,99) < 25 (§5.9) y nada consulta el mes guardado. Alcanzabilidad: NINGUNA por construcción — un byte que sólo se escribe no tiene conducta observable; viaja al .GAM con el bloque, y ahí se queda. Se registra aquí porque es el resto de una mecánica que no se terminó, no un defecto que alguien sufriera. OpenU5 no lo calca (no hay dónde: el port no tiene ese byte), y no hace falta candado. Grado: MEDIDO (la escritura leída en crudo; la ausencia de lectores por censo con control positivo y negativo). Carril bugs-25-28, 2026-08-29. ---

5. Lo que investigamos y NO es un bug

Un registro de bugs sin esta sección es propaganda. Todo lo de aquí llegó a la mesa como candidato y salió descartado.

5.1 Deceit y Despise comparten ranura — es DISEÑO

refutado

Ocho mazmorras y siete ranuras de bitmap parece un descuido. No lo es: DUNGEON.CBT mide exactamente 39.424 bytes = 112 registros = 7×16, no 8×16. El fichero de datos confirma que siempre fueron siete. Se calca, y no se cuenta como bug.

5.2 Rel Hur no tiene un bug: el bug sería nuestro

refutado

El hechizo del viento remapea la dirección antes de aplicarla, y ese remapeo es correcto en el binario. Lo apuntamos aquí porque quien lo porte pasando el argumento tal cual intercambia los cuatro puntos cardinales por parejas — y el fallo sería silencioso. Es un riesgo de porte, no un defecto de 1988.

5.3 Las tiendas no comparan mal las mayúsculas

refutado

Se llegó a escribir que el menú del herrero ecoa minúsculas mientras el manejador compara mayúsculas. La medición dice otra cosa: 53 comparaciones contra mayúsculas y ninguna contra minúsculas en todo el territorio de tiendas. La explicación —que el lector de teclas normaliza— está etiquetada como inferida por su propio autor y no se ha derivado. Sin mecanismo, no hay bug que declarar.

5.4 Las placas de sala que no disparan son la mecánica funcionando

refutado

En 35 de 128 salas de mazmorra hay disparadores cuya casilla es intransitable en combate, así que no se activan nunca. Suena a datos rotos; es la consecuencia normal de las reglas — la placa sólo se dispara con un movimiento que cuaje, y a esa casilla no se puede entrar. OpenU5 lo reproduce fiel sin hacer nada especial.

5.5 Los desbordamientos de atributo no son bugs de 1988

refutado

Hay una familia de fórmulas que se comporta de forma absurda con atributos muy altos: el regateo llega a dar precio negativo (la tienda te paga) con inteligencia ≥ 67, el peaje del troll te regala oro con fuerza 99, un personaje con destreza 99 queda casi inmóvil e intocable.

Ninguno es alcanzable jugando. El techo real de atributos del juego es 30 —lo imponen los santuarios, y la creación de personaje no se acerca—, y todas estas fórmulas se portan bien hasta 30. Sólo muerden con una partida editada. Se documentan como curiosidad, con su régimen declarado, y no como defectos que nadie sufriera.

5.6 El martillo a dos manos NO alcanza todo el campo de batalla — no en v1.16

refutado

La afirmación circula por la comunidad («the 2-handed hammer can hit any square on the battlefield in that version», comentario en el hilo de Thrud, r/Ultima 130pa6f) sin más fuente que la memoria del newsgroup. Contra nuestros bytes es falsa para v1.16: la tabla de alcance por arma (DS 0x1664, DATA.OVL fileoff 0x1674) lleva 0 en la entrada 31 (2H Hammer, fileoff 0x1693, byte leído en crudo), y la mecánica está medida — alcance 0 toma la vía de melé con el cursor acotado al anillo adyacente (COMSUBS:0x0C52, range==0 → cursor 0x0504 con range=1; re/notes/comsubs-ataque-jugador.md §2, que además midió que la tabla de armas no contiene ni un solo 1). Los alcances 15 («toda la pantalla») son de Magic Bow, Magic Axe y los siete pergaminos — el martillo no está entre ellos. El «in that version» del testigo puede referirse a otra versión o plataforma (no censado aquí); en la 1.16 que este port calca, no existe. El port usa la misma tabla extraída (data.json attackRangeValues[31] = 0) y no necesita cambio ni candado nuevo.

5.7 El Shadowlord no roba «comida u oro»: roba de cinco cajones, y la comida NO está

refutado

La comunidad da por sabido que el robo de la Falsedad en ciudad —«An air of falsehood doth surround thee… Something was stolen!»— se lleva comida u oro (hilo r/Ultima 1nta3yg). Media creencia es cierta y la otra media es falsa, y las dos se leen en el mismo tramo: TALK.OVL:0x1180, llamado incondicionalmente al final de toda conversación de pueblo (0x1305).

Lo cierto: sólo la Falsedad roba. El gate es 1187: 803e585900 cmp byte ptr [g_shadowlord_here_idx], 0 / 118c: 7403 je — con Odio o Cobardía presentes se sale por 118e: e9e700 jmp 0x1278 sin tocar nada, ni siquiera el stream.

Lo falso: no hay «comida u oro», hay una cascada de cinco prioridades, y el oro es la ÚLTIMA. Leída fila a fila, con el puntero que cada rama empuja:

prioridadquépunterotiradas
1llaves · gemas · antorchasDS 0x57ac / 0x57ad / 0x57aerand(0,2) por intento, y re-tira si la categoría está vacía (los tres je de 0x11e7/0x11fd/0x1209 saltan hacia atrás, a 0x11c7)
2equipo, índice más alto no vacíoDS 0x57c0, 48 ranuras, 1210: be2f00 mov si,0x2f bajando0
3pociones, índice más altoDS 0x5828, 8 ranuras0
4pergaminos, índice más altoDS 0x5820, 8 ranuras0
5oroDS 0x57aa (1262: b8aa57 mov ax,0x57aa)rand(1,15), suelo 0

La comida no aparece en ninguna rama, y no es que quede fuera de encuadre: g_food está en DS 0x57a8, dos bytes antes del oro que sí se roba. El censo del tramo con control: g_food sale 0 veces en TALK.OVL.asm —contra 8 veces en 5 ficheros en el resto del corpus, que es el control positivo de que el desensamblador sí resuelve ese símbolo— y el único 57a8 crudo del fichero es 06b8: b8a857 mov ax, 0x57a8, que está en la jump-table de los opcodes del guion TLK (06a2: sub ax,0x41 / cmp ax,0xa), o sea la rama que da comida, no la que la quita, y con el helper de suma con techo 9999 (0x270f). ⇒ el overlay conoce el puntero de la comida y la rutina del robo empieza en el oro a propósito.

Consecuencia de mecánica que la creencia popular tapa: llevar una sola antorcha protege el oro por completo — la rama 1 gasta la antorcha y sale por jmp 0x1278 sin llegar nunca a la rama 5. No es un bug: es la cascada haciendo lo que dice. (Derivación completa y cableado en re/notes/shadowlord-urbano-acta.md §2; el port lo tiene en game/src/core/world/faulinei-theft.ts.)

5.8 Los grupos de enemigos MIXTOS del overworld SÍ existen en el DOS — y aquí está el byte

refutado

La comunidad lleva años sin aclararse: ¿el DOS genera grupos mezclados —esqueletos con magos, headless con ettins— como el C64, o eso es exclusivo de las versiones de 8 bits? El hilo r/Ultima 1kyqi90 (2025) tiene testigos contradictorios («100 % sound, does happen in PC version» frente a nunca haberlo visto en DOSBox). En el binario del DOS está, y es una sola instrucción.

combat_spawn_encounter (ULTIMA.EXE:0x6bc2) coloca el spawn #0 con el tipo base y luego recorre i = 1 … count−1. Cada iteración empieza poniendo el tipo base (6d20: 8b4604 mov ax,[bp+4] / 6d23: 8946fe mov [bp-2],ax), y sólo dentro de una ventana inicial de count/4 + 1 spawns (6cf7-6d09, el idiom de división con signo por 4, +1) tira 6d2b: b80800 mov ax,8 / 6d2f: e87ccd call 0x3aae = rand(0,8) — nueve resultados — y con ax == 0 (6d32: 0bc0 or ax,ax / 6d34: 750c jne) sustituye el tipo:

6d36: 8b5e04    mov bx, word ptr [bp + 4]
6d39: 8a87d416  mov al, byte ptr [bx + 0x16d4]     ← la tabla de COMPAÑERO, stride 1
6d3f: 8946fe    mov word ptr [bp - 2], ax

DS 0x16d4 = DATA.OVL fileoff 0x16e4, 48 bytes, uno por tipo de enemigo. Volcada en crudo, las entradas que zanjan la discusión son exactamente las dos que se discutían:

DATA.OVL 0x1705 = 00   tipo 33 SKELETONS  → tipo  0 WIZARDS
DATA.OVL 0x1707 = 24   tipo 35 ETTINS     → tipo 36 HEADLESSES
DATA.OVL 0x1708 = 23   tipo 36 HEADLESSES → tipo 35 ETTINS
DATA.OVL 0x1704 = 29   tipo 32 ORCS       → tipo 41 TROLLS

Y la cuenta del grupo sale de DS 0x13c2 + tipo*8 (6c85: 8a87c213 mov al,[bx+0x13c2], con bx = tipo<<3): SKELETONS y HEADLESSES llevan 8, que es uno de los tres valores exactos que se saltan la tirada (6c8e/6c94/6c9a: 8, 16, 1). Con cuenta 8 la ventana es 8/4+1 = 3, o sea los spawns 1 y 2 tiran ⇒ P(al menos un mago en una emboscada de esqueletos) = 1 − (8/9)² = 17/81 ≈ 21 %. Frecuente, pero no garantizado: por eso los dos testigos pueden tener razón a la vez. Y hay grupos que nunca se mezclan — los tipos cuya entrada apunta a sí mismos (murciélagos, slimes, mongbats, dragones, corpsers, sand traps, gargolas, guardias): quien sólo haya peleado con ésos no lo habrá visto jamás.

No es un bug: es una mecánica implementada y correcta, y el port la calca (game/src/core/combat/encounters.ts, ENCOUNTER_FRIEND_TYPE volcada del binario, y rollEncounterGroup). Se registra aquí porque llegó a la mesa como candidato —«el DOS no lo hace»— y la respuesta del binario es la contraria.

Observación de método, sin promover a fila: la cota de 0x6cc4-0x6cca (cmp 0x19 / mov 0x1a) no tiene camino vivo: ninguna de las 48 cuentas de DS 0x13c2 pasa de 16, así que count nunca supera 25 y el mov no se ejecuta. Es una rama muerta más, de la familia de §2.1, y no se abre fila propia porque su efecto sería nulo aunque se alcanzara.

5.9 El Ankh no cambia la probabilidad de la aparición al acampar: es un 25 % plano

refutado

Un jugador propone que equipar Ankhs sube la probabilidad de que aparezca la figura al acampar —la que cura, despierta y sube de nivel—, con un test casero de 9 aciertos en 20 tiradas con 6 Ankhs (hilo r/Ultima w2huwh). El gate está en CMDS.OVL, dentro del helper de resultados de camp() (0x0000-0x054e), y lee tres cosas, ninguna del inventario:

04e7: f6460882  test byte ptr [bp + 8], 0x82   ; flags del camp
04eb: 7518      jne 0x505                      ; con flag → no hay aparición
04ed: 2bc0      sub ax, ax                     ; push 0
04f0: b86300    mov ax, 0x63                   ; push 99
04f4: e81b5c    call 0x6112                    ; rand(0,99)   [kernel 0x2092]
04f7: 3d1900    cmp ax, 0x19                   ; >= 25 ?
04fa: 7d09      jge 0x505                      ; sí → no hay aparición
04fc: a07d58    mov al, byte ptr [g_month]     ; (byte muerto — §4)
0502: e8d1ba    call 0xffffbfd6                ; la escena: OUTSUBS camp_results 0x0658

⇒ 25 exactos de 100, sin más entradas que el flag del contexto. Ni Ankh, ni equipo, ni karma, ni atributos: el cuerpo del gate no toca g_equip_qty (DS 0x57c0) ni ningún campo del roster. El bucle de camp sí lee equipo en otro sitio y para otra cosa —el regenerador del Ring of Regeneration (índice 0x2c), kernel 0x400c, llamado desde CMDS.OVL:0x0207 doce veces por hora, derivado en re/notes/camp-ambush-spec.md §0—, y eso suma HP: no mueve ninguna probabilidad.

Lo que sí modula la frecuencia observada, y explica un 9/20 sin necesidad de Ankhs: (a) el cooldown g_unk_588c, que la propia rutina recarga a 14 (0505: c6068c580e) y que se decrementa una vez por hora de juego (ULTIMA.EXE:0x4fd7 → resta saturante 0x3f36), de modo que las dos comprobaciones de la cabecera (0x03ea y 0x044c, cmp …,1 / jb) bloquean el helper entero —curación parcial incluida— mientras no expire; y (b) la emboscada, que corta la acampada antes del gate (retorno temprano ax=1, re/notes/ camp-ambush-spec.md §3). Con 20 tiradas, la desviación de un 25 % plano ni siquiera sale del ruido. No es un bug ni una mecánica oculta: es una moneda de 25 %.

5.10 Las dos hachas y el hacha en la cabeza: la ranura NO la elige el jugador

refutado

Dos observaciones de la comunidad viajan juntas (hilo r/Ultima w2wfgu): un pantallazo del libro oficial muestra dos hachas mágicas equipadas y el juego lo impide («se des-equipa al seleccionar la pila»), y con un editor de memoria se puede poner un hacha en la ranura de la CABEZA y atacar tres veces. Las dos son ciertas, ninguna es un bug, y el binario explica las dos con el mismo hecho: la rutina de (R)eady no recibe ninguna ranura.

try_equip_or_unequip (ZSTATS.OVL:0x0c5c, ret 4) toma sólo (equipId, charIdx) y deriva el destino de una tabla de CLASE por ítem, DS 0x1a7e (= DATA.OVL fileoff 0x1a8e, 48 bytes), leída en 0d94: 8a877e1a mov al, byte ptr [bx + 0x1a7e] y despachada por seis cmp consecutivos (0d9a-0dc5). Volcada en crudo:

DATA.OVL 0x1a8e  80 80 80 80 20 20 20 20 20 40 40 40 40 40 40 40
DATA.OVL 0x1a9e  20 30 20 30 20 20 20 20 20 20 30 00 30 00 20 30
DATA.OVL 0x1aae  30 30 30 30 30 20 20 20 20 30 02 02 02 04 04 04
claseranura del registro de personajecomprobación de ocupación
0x80 casco+0x19 (0x55c1)0dce: cmp byte [bx+0x55c1],0xff
0x40 armadura+0x1a (0x55c2)0e53
0x20 una mano+0x1b / +0x1c0e71: call 0x0c0a (manos libres)
0x30 dos manos+0x1b, exige las dos0ea1: call 0x0c0a, cmp ax,2
0x04 amuleto+0x1e (0x55c6)0ec3
0x02 anillo+0x1d (0x55c5)0ee5
0x00 no equipable—rechazo mudo en 0c82 (sólo ids 27 Arrows y 29 Quarrels)

⇒ «poner un hacha en la cabeza» no se rechaza: es que no se puede ni proponer. Todas las armas llevan clase 0x20 o 0x30, así que su único destino posible son las manos. Esa ranura sólo se cambia escribiendo el byte +0x19 del roster a mano, exactamente como dice el reporte.

Y las dos hachas tampoco necesitan una guarda anti-duplicado, porque no hay ninguna. Antes de toda la lógica de equipar, 0cb4 llama a is_equipped (ZSTATS.OVL:0x0518), que compara el id del ítem contra las seis ranuras del registro (052d, 0537, 053f, 0547, 054f, 0557 = +0x19…+0x1e) y devuelve 1 al primer acierto; con eso 0cbf se va a la rama de des-equipar (0cc7: call 0x8c80). Seleccionar un ítem que ya llevas puesto siempre lo quita, tengas una copia o diez ⇒ un mismo id no puede ocupar dos ranuras jamás. El pantallazo del libro sólo es alcanzable editando memoria.

★ Y lo que el reporte lee como exploit es la mecánica normal: el ataque del jugador SIEMPRE recorre TRES ranuras. COMSUBS.OVL:0x0d96 no lleva bucle ni condición — son tres bloques literales seguidos: 0df8: mov al,[si+0x55c1] (casco), 0e05: [si+0x55c3] (mano A) y 0e12: [si+0x55c4] (mano B), cada uno con su call 0xd3c. La armadura y el anillo/amuleto no se leen. El filtro de cada ranura es 0d49: cmp byte [bx+0x15fc],0 / je 0xd91, o sea «¿ese ítem tiene valor de ataque?». Y con la tabla de ataque en la mano (DS 0x15fc = DATA.OVL fileoff 0x160c) hay equipo legítimo que ataca desde el casco y desde el escudo: entrada 3 = 0x04 y entrada 6 = 0x06, que por la tabla de clase son casco (0x80) y una-mano (0x20). Con el casco de pinchos, un arma y el escudo de pinchos salen tres golpes sin tocar un solo byte del save — y hay testigo de la conducta en el original: el banner «armed with Spiked Helm, Magic Axe» de una partida grabada, anotado en re/notes/fenton-piloto.md. Lo que el editor añade no son los tres golpes: es cuál ítem se sienta en +0x19.

(Las seis cadenas de rechazo se citan por offset y no verbatim, a propósito: son texto de EA y este registro cita bytes.) El port refleja las tres cosas —TYPE_TABLE volcada del binario en game/src/core/equip.ts:48, el toggle-off por id y ATTACK_SLOTS en :140— así que tampoco hay divergencia que declarar.

5.11 El mapa de combate sin usar existe, es el registro 9 de BRIT.CBT, y no puede abrirse

refutado

u/raldi reporta (hilo r/Ultima 13qddnq) un mapa de combate sin usar en los ficheros, con un disparador en el muro OESTE que abre un pasaje. Está, y es exacto.

BRIT.CBT son 5 632 B = 16 registros de 352 (0x160, el mismo tamaño que carga ULTIMA.EXE:0x60ec con 60fc: mov ax,0x160). En el registro 9 —el que el port llama Basement— la columna x=3 es el muro oeste, 0x4F StoneBrickWall en las once filas salvo en (3,5), que es 0x4E StoneBrickWallSecret, el único de todo el registro. Y sus ocho disparadores tienen todos at = (3,5):

#sprite que estampadestino 1destino 2
0 · 1 · 20x44 BrickFloor(2,5) · (2,4) · (2,6)(1,5) · (1,4) · (1,6)
3 · 40x44 BrickFloor(0,6) · (0,4)(0,5) · (0,4)
5 · 6 · 70x4F StoneBrickWall(2,3) · (0,3) · (1,7)(1,3) · (0,7) · (2,7)

⇒ pisar la placa excava un corredor de suelo de ladrillo hacia el OESTE por las columnas 0-2 en las filas 4-6 —que hasta entonces son 0xFF BlackSquare, vacío— y lo tapia por arriba y por abajo con muro nuevo. Es literalmente «un pasaje que se abre».

Es también el único de los dieciséis con datos de disparador. Los otros quince llevan el bloque entero a cero (at = (0,0), sprite 0x00, destinos (0,0)) — dicho con el predicado explícito, porque el ingenuo («¿el campo existe?») da 16 de 16 y el que discrimina es «¿el bloque es distinto de cero?», que da 1 de 16.

Y no puede dispararse, por tres razones independientes, las tres medidas:

  1. El registro 9 no lo carga nadie. Los dos únicos llamadores de 0x60ec son ULTIMA.EXE:0x633d —que le pasa [bp-2], el índice de arena de enter_combat_vs_actor (0x6150)— y ULTIMA.EXE:0x6366, que le pasa 0 literal (6363: sub ax,ax), la hoguera de la emboscada de acampada. Censadas todas las escrituras de [bp-2] en esa función, son catorce inmediatos y ni uno es 9: 1 (62a0) · 2 (62a8) · 3 (62b0) · 4 (62b8) · 5 (62c0) · 6 (62c8) · 7 (62d0) · 8 (6308) · 0xa (61fa) · 0xb (6258) · 0xc (626e) · 0xd (6260) · 0xe (6249) · 0xf (627c). Todos los caminos confluyen en 633a: push word ptr [bp - 2], y los que no casan con ningún tile caen por 6301 a la rama del 2 o a la del 8. ⇒ de los dieciséis arenas, el 9 es el único que ningún camino puede pedir.
  2. En los arenas de Britannia el barrido de disparadores ni se llama. El gate es SJOG.OVL:0x1d35 test byte ptr [g_unk_58a1], 0x82 (y dentro, COMBAT.OVL:0x1127/0x112e, bits 7 y 1). Censados los cinco escritores de 0x58a1 en los 28 .asm —DUNGEON.OVL:0x00bf = 0x82, DUNGEON.OVL:0x0c3e y 0x1d9a = 2, ULTIMA.EXE:0x3e65 = 6 (y ése cuelga de 3e5e: cmp byte [g_location],0x20 / jbe, o sea sólo mazmorra) y ULTIMA.EXE:0x5f91, que copia el argumento [bp+8] de run_combat_encounter 0x5f86—, los dos llamadores de esa función pasan 0 (el arena de Britannia, 6340: sub ax,ax … 6347) y 4 (la acampada, 6369: mov ax,4 … 6373). 0 & 0x82 = 0 y 4 & 0x82 = 0 ⇒ los disparadores son un mecanismo de SALA DE MAZMORRA, y en BRIT.CBT no corren nunca. (Que es también por lo que los quince bloques a cero de los otros arenas son inertes y no estampan nada en la esquina (0,0): esa esquina es transitable en nueve de los dieciséis, y de haber corrido el barrido se notaría.)
  3. La placa no es pisable. 0x4E StoneBrickWallSecret no es transitable, y la única vía de disparo es un movimiento conseguido sobre la celda (SJOG.OVL:0x1d11 → 0x1d42). Es la misma clase que §5.4.

⇒ contenido cortado, no un bug: no hay descuido que arreglar ni rama muerta que calcar — el registro está en el fichero y ningún índice lo nombra. El port lo hereda tal cual (los 16 registros se extraen enteros a game/assets/maps/combatmaps.json, y CombatMapIndex.Basement existe en game/src/core/combat/encounters.ts sin que nada lo seleccione), que es lo correcto: un extractor byte-exacto no decide qué datos merecen viajar.


Se omite §6 «Estado y pendientes»: es metadato del propio documento —qué espejo sobrescribe a cuál al ensamblar el sitio— y no describe ningún defecto del juego de 1988. El registro completo está en el repositorio.