ENES
Play with my files
ENES
Play with my files

Bugs of the original — canonical register

This page is GENERATED from the canonical register in the repository. The table and the entries come from the same file, so they cannot say different things: every label leads to the entry it came from. Source: docs/bugs-del-original.md

Ultima V: Warriors of Destiny (Origin/EA, DOS, 1988) has defects. Porting it faithfully forces a decision, one by one, about which ones to reproduce and which ones to fix. This document is that decision, written down, with the binary citation next to it.

Created on 2026-08-05 by the bugs-original lane from the census over 665 notes in re/notes/, the ledger of sealed routines and the repository history. It implements the user's directive of 2026-08-05 and the corresponding amendment to FIDELITY-CONTRACT.md §Policy.

Five, to get the idea

The rule that decides

It gets fixed, with two exceptions: if the defect touches the random number generator's flow —fixing it would break the instrument everything else is verified with— or if "fixing" it would mean inventing content EA never shipped.
And typographical errata are preserved: closing a quotation mark EA left open is not fixing a bug, it is rewriting the original.

The buckets, and why there are five

The defects, one per row

36 defects. Grade of evidence: 25 MEASURED · 9 DERIVED · 1 undetermined · 1 WITNESSED. The 15 in the two exempt sections carry no grade — a literal typo and a refuted candidate are not claims about the binary that could be more or less substantiated.

#DefectStatusGradeBucketEntry
1The shipyard calls a female Avatar "sir"✅ FIXEDDERIVED§1 fixed§1.1
2The tavern bill leaves a hole if the Avatar travels alone✅ FIXEDMEASURED§1 fixed§1.2
3Sleeping in a bed wakes you an hour late✅ FIXEDWITNESSED§1 fixed§1.3
4The frigate's delivery price: the figure does not match and accepting does nothing⏳ PENDINGDERIVED§1 fixed§1.4
5A dungeon chest on floor 0 hangs the game⏳ PENDINGMEASURED§1 fixed§1.5
6"Ship rigged for double speed!" lies about when it takes effect✅ FIXEDDERIVED§1 fixed§1.6
7The wishing well's horse appears inside a wall⏳ DIVERGENTDERIVED§1 fixed§1.7
8Data holes in text✅ NONE REACHES THE PLAYERMEASURED§1 fixed§1.8
9Using the Skull Key inside a dungeon spends it and opens nothing⏳ PENDINGMEASURED§1 fixed§1.9
10Passing at the Skull Key prompt spends it and blows up your own square⏳ DIVERGENTMEASURED§1 fixed§1.10
11Holding down V eats the gem and does not show you the map✅ FIXEDMEASURED§1 fixed§1.11
12During animated waits, the last torch's halo is stamped over your window✅ NOT REPRODUCEDDERIVED§1 fixed§1.12
13Fourteen dungeon rooms leave you locked in forever✅ FIXEDMEASURED§1 fixed§1.13
14The nocturnal encounter bonus lives in a dead branch🔒 Reproduced on purposeMEASURED§2 reproduced§2.1
15The 1-to-30 generator is not uniform🔒 Reproduced on purposeDERIVED§2 reproduced§2.2
16The Shadowlord's "is this a person?" check always looks at slot 4🔒 Reproduced on purposeDERIVED§2 reproduced§2.3
17Off the small map you always read cell (31,31)🔒 Reproduced on purposeDERIVED§2 reproduced§2.4
18In Bet Xen summons all four creatures onto the same cell🔒 Reproduced on purposeMEASURED§2 reproduced§2.5
19The roll is spent on the dead and on those who resist too🔒 Reproduced on purposeMEASURED§2 reproduced§2.6
20EA's easter egg is in the data and the code never reaches it✅ REPRODUCEDMEASURED§2 reproduced§2.7
21On the 20th of every month, the Shadowlord's withering spares NOTHING🔒 Reproduced on purposeMEASURED§2 reproduced§2.8
22In combat, two of the four field walls do nothing whatsoever🔒 Reproduced on purposeMEASURED§2 reproduced§2.9
23The speaker sweep never reaches its nominal frequency🔒 Reproduced on purposeMEASURED§2 reproduced§2.10
24The TLK script carries six programming errors — reproduced byte for byte🔒 Reproduced on purposeMEASURED§2 reproduced§2.11
25The phantom class: "Adept of Woznir"🔒 Reproduced on purposeMEASURED§2 reproduced§2.12
26A dungeon room's identity is ONE NIBBLE: clearing one clears its twins🔒 Reproduced on purposeMEASURED§2 reproduced§2.13
27"a crytal sphere"🔒 Errata preservederrata§3 errata§3·1
28The guild farewell leaves a quotation mark unclosed🔒 Errata preservederrata§3 errata§3·2
29"What didst thou say?"🔒 Errata preservederrata§3 errata§3·3
30The intro dissolve's L5 band repeats one index and omits another🔒 Errata preservederrata§3 errata§3·4
31Recruiting someone who is ALREADY travelling with you duplicates their record and destroys another🔍 Latent, no live pathMEASURED§4 latent§4·1
32Stat dispatch with no default case🔍 Latent, no live pathMEASURED§4 latent§4·2
33The other half of the RNG fixed point: the retries WITH NO CAP would hang🔍 Latent, no live pathMEASURED§4 latent§4·3
34text_gotoxy ignores its own window's bounds🔍 Latent, no live pathMEASURED§4 latent§4·4
35Mix with a name that does not match🔍 Latent, no live pathundetermined§4 latent§4·5
36resolve_command_char can return a monster as a roster index🔍 Latent, no live pathDERIVED§4 latent§4·6
37The same distance, measured two ways in the same file — the proximity cue is lost at the world seam🔍 Latent, no live pathMEASURED§4 latent§4·7
38The weighted creature roll never checks its table's length🔍 Latent, no live pathMEASURED§4 latent§4·8
39"Level 254": the dungeon floor can be decremented from the Underworld sentinel🔍 Latent, no live pathMEASURED§4 latent§4·9
40Camping stores the MONTH of the apparition in a byte nobody reads.🔍 Latent, no live pathMEASURED§4 latent§4·10
41Deceit and Despise share a slot — that is DESIGN❌ Not a bugrefuted§5 not a bug§5.1
42Rel Hur has no bug: the bug would be ours❌ Not a bugrefuted§5 not a bug§5.2
43The shops do not mis-compare upper case❌ Not a bugrefuted§5 not a bug§5.3
44Room plates that never fire are the mechanic working❌ Not a bugrefuted§5 not a bug§5.4
45Attribute overflows are not 1988 bugs❌ Not a bugrefuted§5 not a bug§5.5
46The two-handed hammer does NOT reach the whole battlefield — not in v1.16❌ Not a bugrefuted§5 not a bug§5.6
47The Shadowlord does not steal "food or gold": it steals from five drawers, and food is NOT one❌ Not a bugrefuted§5 not a bug§5.7
48MIXED overworld enemy groups DO exist in the DOS version — and here is the byte❌ Not a bugrefuted§5 not a bug§5.8
49The Ankh does not change the camping apparition's odds: it is a flat 25 %❌ Not a bugrefuted§5 not a bug§5.9
50The two axes and the axe on the head: the player never picks the slot❌ Not a bugrefuted§5 not a bug§5.10
51The unused combat map exists, it is BRIT.CBT record 9, and it cannot be opened❌ Not a bugrefuted§5 not a bug§5.11

Without JavaScript the page comes out whole and expanded; the filters are all that is lost.

The criterion, in full

How to read this register, what each grade of evidence means and the complete sorting rule — exactly as they come from the canonical document.

How to read this register

Not everything odd is a bug. To enter here as a bug, there has to be a demonstrable oversight — a dead branch, a hole where a piece of data belongs, a promise with no effect, broken arithmetic — and, whenever one exists, the symmetry argument: the sibling routine that does handle the same case correctly. When that argument is missing, the entry drops to "quirk" or stays out. §5 collects what we investigated and discarded, which in a document like this matters as much as what goes in.

Every claim travels with its grade, and the grade is not decoration:

GradeMeaning
MEASUREDRead instruction by instruction in the disassembly, by whoever signs the entry.
DERIVEDDeduced from the code with explicit reasoning, without seeing the game run.
INFERREDA reading of intent. Never supports an entry on its own.
WITNESSEDAdditionally observed in the binary running under DOSBox.

Where it is said, and where it is deliberately not. Every row of §1, §2 and §4 declares its grade in the body. §3 (errata) and §5 (discarded candidates) do not carry one, and that is not a hole: a quotation mark EA left open has no grade of evidence, and neither does a refuted candidate — their nature already says everything there is to say about them. A bug derived from the code is a bug; it simply is not the same as one seen happening, and the document does not pretend otherwise.

Grades today: 25 MEASURED · 9 DERIVED · 0 INFERRED · 1 WITNESSED · 1 undetermined, over 36 rows carrying a grade (§1 + §2 + §4). The figure is not written by hand: the /differences generator derives it from the file itself, and re/tools/test_grados_registro.py matches it against this sentence in both mirrors. The sentence itself is rewritten by python3 re/tools/registro_cifras.py --write: after a merge or a new row it is regenerated — never recomputed by hand.

🔴 "Almost everything here is DERIVED without an oracle witness. Each row says so." used to live here, and it was false on both counts (withdrawn 2026-08-10). It was not said in each row: seven rows declared no grade at all —§1.8, §2.6 and five of the eight bullets of §4— and §3 and §5 never declare one, by nature. And the dominant grade is not DERIVED: it is MEASURED, by more than double. The sentence had, since the register's first day, asserted of the whole population what was true of a part, and its "each row says so" half described a rule the document did not keep. The seven silent rows were closed on 2026-08-10 by proposing the grade from the evidence each one already cited —never by resemblance to its neighbours— and the one whose evidence sustains none is declared undetermined, which is not the same as saying nothing.

The rule that decides, and why it is not "severity"

The cut-off criterion is not how severe the bug is. It is this, in this order:

  1. Does it touch the RNG stream? If it does, it is always reproduced. This port's verification rests on reproducing the original's sequence of random numbers; fixing a bug that moves the stream breaks the very instrument we check everything else with. These bugs are declared here and they stay.
  2. If it does not, and it is a clear oversight, it gets fixed and recorded.
  3. Typographical errata are preserved. Closing a quotation mark EA left open is not fixing a bug: it is rewriting the original. They are part of what is being preserved.

The entries, one per defect

1. Bugs OpenU5 fixes (or is going to fix)

These do not touch the stream. They are clear oversights. The real status of each one is in its row — several are pending, and the register does not hide it.

1.1 The shipyard calls a female Avatar "sir" ✅ FIXED

DERIVED

The original: in the shipyard, the dialogue addresses the Avatar as "sir" even if she is a woman. The branch that would say "milady" exists (DS 0x9FCC) but it is dead.

Why it is a bug: three sites in the binary read the same gender byte (offset +9 of the 32-byte roster record, DS 0x55B1) and compare it against three different constants:

SiteCompares againstCorrect?
SHOPPES.OVL:0x0b0c0x0Cyes
SHOPPES2.OVL:0x00c30x0Byes
SHOPPES2.OVL:0x085a0x46 = 'F'no

Character creation never writes 0x46 — the census of the 16 INIT.GAM records returns zero. Two sibling routines use the right constant and one uses an ASCII letter that field never contains: that is an oversight, not a criterion.

OpenU5: branches on gender === 0x0c and says "milady". Port: game/src/ui/shop-console.ts:1599 and :2116 (verified 2026-08-05).

Grade: DERIVED (re/notes/asm-shoppes-acta.md §5). No oracle witness.

1.2 The tavern bill leaves a hole if the Avatar travels alone ✅ FIXED

MEASURED

The original: when you pay for food, the tavern keeper says how many of you there are. The routine that writes the number (SHOPPES2.OVL:0x006a, print_alive_count_word) switches on two…six. There is no singular case: with a single living member it falls through to the ret without printing anything, and the sentence comes out with a gap — " gold for the ␣ of ye,".

Why it is a bug: the healer (SHOPPES.OVL:0x137c) handles the same edge case — a single member — and handles it well, returning 0 without asking. Two routines from the same territory, the same boundary, and only one of them considers it.

OpenU5: FIXED — it emits the whole sentence, and in the singular case it says "one". Port: game/src/ui/shop-console.ts:2471 (emitTavernPriceLine) composes it, and its two callers emit it: :2432 (the food round) and :2514 (the drink's round of the house) — the same two paths that enter SHOPPES2.OVL:0x00dc in the binary. The count word comes from game/src/core/shops/shops.ts:871 (tavernAliveWord), which returns "one" where the original falls through to the ret without printing: that is the single deliberate departure on this line, and everything else is transcription. Landed 2026-08-06 (f3889145); the second caller, 2026-08-08. Card #17.

🔴 Correction to this very row (2026-08-08), and the staleness window was HOURS. Until today this row published an "Extension 2026-08-06" block with three claims "re-verified against the tree". All three have ceased to be true, and two of them ceased that same night: the extension was written on 06-08 and the fix landed on 06-08 at 23:57. They are retired by name, which is how a published claim gets retired:

What still stands from that block is the part about the original, not the clone: the sentence does appear in the corpus of the ORIGINAL captured by the mirror, so the clone's omission would not have been neutral. Grade for that part: MEASURED via OCR corpus, with the noise visible in the quotations.

Corpus census (2026-08-06), with its denominator: 6 occurrences across 3 files under game/e2e/espejo-tour/routes-ad/ — ad02, ad08, ad19 — with two distinct count words, both carrying OCR noise: "gold for the si of ye, sir." (4×, presumably six) and "pj!d for the (mree of ye" (2×, presumably three). Two different party sizes, each printing its word in exactly the slot print_alive_count_word writes to.

🔴 And what the census does NOT establish, which is the half that matters: captures of the SINGULAR case number ZERO. None of the 6 shows the gap. That does not refute the defect — what was read in the disassembly still stands — but it settles that the mirror corpus cannot be its witness: the tour never takes a party of one live member into a tavern, so the case that would produce the hole is not sampled. What the corpus does establish is the sentence's SHAPE and that the original prints it. The gap remains without an oracle witness, and the one it needs is a tavern purchase with g_alive_b = 1.

⚠ And it is still not mounted — said here so it does not read as an oversight. When this row was closed (2026-08-08) mounting it was weighed and declined: it requires a dedicated oracle instance and a savegame walked down to a single living member, and what it would establish — that the original prints the gap — is already derived from the disassembly with the whole body read (below). The witness would raise the grade of that half-line; it would not change the verdict or the fix. It stands as a declared pending, not a silent debt: what this row lacks is a capture of the singular case, and it lacks it knowingly.

(Method note, in case it helps whoever picks this up: the first census returned 4 across 2 files, and it was wrong. The pattern required the prefix "gold", which OCR had mangled in ad02 — "pj!d" — so the pattern carried an assumption about the surrounding text, and in a noisy corpus that assumption does not fail loudly: it UNDERCOUNTS silently. The good figure comes from searching on the stable fragment, "of ye".)

Grade: MEASURED — full body read in re/disasm/SHOPPES2.OVL.asm (2026-08-05). The switch is five cmps against 2, 3, 4, 5 and 6 (0x006d-0x0084); any other value, 1 included, falls into the jmp 0x00aa at 0x0086 and from there to the ret at 0x00aa, never reaching any of the five calls to the printer. This confirms the derivation in re/notes/asm-shoppes-acta.md §26.1 and §27, whose citation was correct. No oracle witness. (A vocabulary note, for whoever follows the citation: §26.1 declares its own grade with the words "derived from the code, not observed in the running original". That "derived" is NOT the DERIVED grade of the table above — it is exactly what this table calls MEASURED without WITNESS: read in the disassembly, not watched running. The record and this row state the same thing in different vocabularies; there is no grade contradiction.)

1.3 Sleeping in a bed wakes you an hour late ✅ FIXED

WITNESSED

The original: CMDS.OVL computes the target hour and normalises it incorrectly when crossing midnight. Read in full:

059e: mov al, [g_hour]        ; current hour
05a1: sub ah, ah
05a3: add ax, si              ; + hours requested (si arrives as the digit's ASCII)
05a5: sub ax, 0x30            ; ASCII → number
05a8: mov [bp-6], ax          ; target = hour + hours
05ab: cmp ax, 0x17            ; past 23?
05ae: jle 0x5b4               ; no → no adjustment
05b0: sub word ptr [bp-6], 0x17   ; ★ yes → SUBTRACTS 23, NOT 24

With a target of 24 or more the adjustment falls one hour short: sleeping 2 h from 22:00 gives a target of 24 → 24 − 23 = 1:00, when midnight is 0:00. You wake an hour late, whenever the sleep crosses midnight.

Why it is a bug: on a 24-hour clock, normalising by subtracting 23 has no design reading. It is the arithmetic error in its simplest form.

OpenU5: sleeps the number of hours requested. Port: game/src/core/world/camp.ts:227 (bedSleep, verified 2026-08-05). Card #22.

One nuance we do NOT put here: the same loop exits when hour == target, meaning the original sleeps until the hour boundary, not N complete hours (starting at 10:37 and sleeping 3 h wakes you at 13:00). That one may well be design, and it will be decided when faithful sleep is ported — it is not sold as a bug.

Grade: WITNESSED (see below) on top of a MEASURED instruction-by-instruction read of re/disasm/CMDS.OVL.asm (2026-08-05). It also corrects the citation inherited from the notes (re/notes/cama-241-acta.md §6, re/notes/sueno-cama-249.md §1-2), which placed the subtraction at 0x05ab: that is the cmp, and the subtraction is at 0x05b0. The notes also omitted the sub ax,0x30, which is what reveals the argument arrives as ASCII.

★ EXECUTION WITNESS OBTAINED, with a negative control. Prediction registered before running: seeding 22:00 and sleeping 3 h, the buggy target is 25 − 23 = 2 (waking at 02:00) and the correct one 25 − 24 = 1 (01:00). Two runs of the same instrument, against the real binary under DOSBox:

RouteWake-up hour
Sleeping in a BED (Iolo's Hut, cell 12,14 — the map's only bed)02:00★ the bug, measured
Camping outdoors01:00negative control

The bed wakes you an hour late, exactly as the subtraction of 23 predicts. And camping, which uses a different sleep path, gives the correct 01:00 — which turns that run into a negative control: the same harness is able to produce the right answer, so the 02:00 is not an artefact of it. Probe, prediction and control in re/tools/sleep_hour_probe.py (main = outdoors, cama = the witness).

Finding grade: WITNESSED. The target is consumed at 0x062e mov si,[bp-6], inside the loop that advances the clock until g_hour == target (0x063b-0x0645).

A second divergence of the clone in this same routine — CLOSED on 2026-08-08 (card #22). The binary advances each hour in six ten-minute steps, and on each one it re-snaps the NPCs to their schedule and checks whether anything is on top of the party — that is, six chances per hour for "Thrown out of bed!" to fire. The clone now does the same: bedSleep walks hours * BED_STEPS_PER_HOUR steps and calls advanceClock(…, BED_STEP_MINUTES, …) with the NPC snap and the gate inside every turn (game/src/core/world/camp.ts:263-273, verified 2026-08-12). It does not move the RNG stream; it used to move the SAMPLING, downwards.

🔴 Until 2026-08-12 this said the clone took "a single 60-minute step per hour" and had "six times fewer" chances to throw you out of bed. It was true when written and stopped being true when 5d7e0a04 landed, with nobody looking again: four days publishing as a live defect of the port something already fixed. It is withdrawn by naming what it claimed, not by deleting it — and the citation replacing it was read in today's tree, not copied from the card that closed it.

⚠ The decoy at the site DID exist and was closed too: the comment on that line read "0x0647 advance_clock(10) per step" right next to a call that passed 60 — it cited the binary correctly and read as if it described the clone. Today the argument is BED_STEP_MINUTES and comment and code say the same thing. The lesson stays because the mechanism does not depend on this routine: a comment citing the binary next to code that does not obey it reads as documentation of the code, and anyone auditing in passing takes it for faithful without looking at the argument.

1.4 The frigate's delivery price: the figure does not match and accepting does nothing ⏳ PENDING

DERIVED

The original: two defects in the same branch (SHOPPES2.OVL:0x08a8).

  1. The text promises 87,000 gold and the code compares against 0x2710 = 10,000.
  2. If you accept and can pay, the branch does nothing: it echoes "Yes", checks that you can afford it, and exits. It does not charge, does not deliver, does not touch the ship flags.

Why it is a bug: the skiff, in that very same routine, does enter the purchase routine that charges and delivers. The frigate branch has the check and is missing the effect.

OpenU5: does not model the branch. Port: game/src/core/shops/shop-tables.ts:62 (SHIP_REPLACEMENT_PRICE = 10000), and game/src/core/shops/shops.ts:1057 states in its docblock that the replacement path is not modelled. Card #13.

🔴 Correction to this very row (2026-08-06). It used to say the clone "fixed the price at 10,000 (the code's figure, not the text's)". That is false: the constant exists but no code reads it. Its only other appearance in all of game/src is the mention inside that comment. It is a dead constant, so the clone fixed no price at all — there simply is no replacement path. The earlier verification confirmed the symbol existed at that line, not that anything used it, and a port citation is only worth something if it checks the latter.

Sibling genre, and that makes three in this same area of the clone: the dead import of tavernRoundPrice in game/src/ui/shop-console.ts:69 (§1.2) and the two orphaned tavern-bill strings in i18n. "Planned and never wired" describes what happens in shops/tavern better than three independent oddities.

Grade: the original's defect, DERIVED (re/notes/asm-shoppes-acta.md, ledger SHOPPES2.OVL:2216). The clone's state, MEASURED against the tree (2026-08-06).

1.5 A dungeon chest on floor 0 hangs the game ⏳ PENDING

MEASURED

The original: chest loot asks for a quantity with minimum 1 and maximum floor·8 (SJOG.OVL:0x179e, the call at 0x1897). On floor 0 that leaves the maximum at 0, below the minimum. And the generator does not check for it:

20b0: mov bx, [bp+6]   ; bx = minimum
20b3: mov cx, [bp+4]   ; cx = maximum
20b6: sub cx, bx       ; 0 − 1 = 0xFFFF
20b8: inc cx           ; → 0x0000
20b9: xor dx, dx
20bb: div cx           ; division by zero → INT 00h

Why it is a bug: rand_range is the shared generator for the whole game and it has no maximum < minimum guard; the div turns the oversight into the end of the run. The quantity table DS:0x41C4 = [31, 0, 3, 3, 3, 7, 7] corroborates the diagnosis: it has a zero in exactly the slot this path does not consult.

🔴 CORRECTED (2026-08-07): this is not a hang, it is an ORDERLY EXIT with a message. An earlier version of this entry said «hard hang», and it is measured that it is not. Startup installs its own INT 00h handler (0x0244-0x024c → CS:0x0212), which is the Microsoft C runtime one: it writes run-time error R6003 - integer divide by 0 to STDERR and calls exit(255), closing open handles and restoring the interrupt vectors.

How the runtime was identified, without relying on reading that body: the message table at DS:0xa452 (id-word + NUL-terminated string pairs, 0xFFFF sentinel) decodes to R6000 stack overflow · R6001 null pointer assignment · R6002 floating point not loaded · R6003 integer divide by 0 · R6009 not enough space for environment. The R60xx codes are Microsoft C.

What changes and what does not. The defect is still real —there is no maximum < minimum guard in a helper the whole game uses— and the port's deliberate divergence (throwing instead of dividing) is still correct. What changes is the failure mode: the machine does not freeze, the process terminates and unsaved progress is lost.

Not measured, and therefore not claimed: whether termination restores the video mode. The exit chain 0x037d/0x038c/0x07a2 has not been read, so we do not claim the message is actually visible — only that it is written to STDERR.

Reach: it does not happen with the factory maps — there are 3 chests in all of DUNGEON.DAT and none on floor 0. The door stays open for any map generated at runtime.

OpenU5: ✅ already covered. JavaScript has no integer division by zero, so the hang is not inherited, and on top of that OriginalRng.next (Port: game/src/core/rng-original.ts:95, verified 2026-08-05) rejects the impossible range with an explicit error rather than propagating it. That is a deliberate divergence, authorised by the contract's hangs exception — this is the only bug in the register the contract already allowed fixing before the amendment. One detail that matters for parity: the rejection happens before the generator step is consumed, so a caller that catches the exception is not left one step ahead in the stream — there is a test pinning both halves (the impossible range does not advance the seed; the legal domain comes out identical).

🔴 CORRECTED 2026-08-06: the conclusion was right and the cited evidence pointed at the wrong site. This entry said rand_range(1, floor·4) and cited SJOG.OVL.asm 0x182b-0x183c as the caller. Re-reading the whole routine turns up four calls to the generator, and the one cited is neither the loot call nor able to hang:

callminimummaximumhangs on floor 0?
0x183c (the one cited)1floor·4 + 4 (shl,shl,add ax,4 at 0x1838)no — the maximum is 4
0x1855 · 0x186e07no
0x1897, branch si==11floor·8 (mov cl,3/shl ax,cl at 0x1886)YES — the maximum is 0

Two independent errors cancelled each other into a plausible result: the wrong call was cited, and its arithmetic was read without the add ax,4 — which is exactly what makes it harmless. The real site shifts by three bits, not two: floor·8.

The argument convention is established by the body itself — bx = [bp+6] is the minimum because it is what gets added back at the end (add dx, bx at 0x20bd) — and by the caller's push order: the 1 is pushed first, so it lands in [bp+6].

Grade: MEASURED (rand_range read in ULTIMA.EXE.asm 0x2091-0x20c5 — its prologue sits at 0x2091, not 0x2092, because of the offset described in the prologue note; the four calls read in SJOG.OVL.asm inside routine 0x179e). The reach — the 3 chests — is RELAYED from the note.

The second door at 0x1897 DOES NOT EXIST — measured and closed (2026-08-06). The else branch takes its maximum from [si+0x41c4], and the suspicion was that some reachable si might land on the slot holding 0 and hang without needing floor 0. It closes by enumerating two things, rather than by checking whether the 0 is in the table:

⇒ no si reachable by the else ever reads a 0, and the smallest maximum it can produce is 3. The only 0 sits in slot 1 — precisely the one slot the else can never read, because si == 1 is peeled off into its own branch three instructions earlier.

And the 0 is not an oversight: it is a marker. Row 1 is the gold row, whose amount comes not from the table but from g_floor·8. The 0 means "this row does not use the table". Which is to say: the very value that looked like a latent second door is the sign of the row that IS the known door. Hunting for the 0 in the table leads to the right place for the wrong reason.

The routine has ONE door, not two. The four calls to the generator, with their regimes:

callminimummaximumcan max < min?
0x183c (row guard)1floor·4 + 4 ≥ 4no, never
0x1855 (si=5, potion)07no
0x186e (si=6, scroll)07no
0x1897 via si=1 (gold)1floor·8YES on floor 0 ← the door
0x1897 via else1{31,3,3,3}no

(four call instructions, five paths: 0x1897 has two entries.)

One more bound, so nobody turns it into a probability by ear: on floor 0 the gold row is only reached if its guard clears, and that guard is 4 against a rand(1, 4) roll — the roll has to land exactly on the top of its range. The "1 in 4" that follows from assuming uniformity is deliberately NOT published here: §2.2 documents that this generator is not uniform, and its distribution over a 4-wide range has not been measured.

1.6 "Ship rigged for double speed!" lies about when it takes effect ✅ FIXED

DERIVED

The original: the HMS Cape plans give the frigate double speed. But picking the plans up with (G)et already writes 0xFF into the byte, so double speed is active from the moment you pick them up. The or 0x80 that (U)se performs on a byte already reading 0xFF is a no-op, and the message "Ship rigged for double speed!" (DS 0x49C2) is pure decoration.

Why it is a bug: the message announces a state change that happened earlier and that this action does not produce.

OpenU5: ✅ announces where the state changes. EA's message is now emitted on the (G)et of the plans, which is where the frigate actually becomes rigged, and the (U)se aboard emits a truthful echo — our own text, with no DS citation, because it does not exist in the binary. Port: the announcement at game/src/core/game.ts:4946, the echo at game/src/core/endgame/use-tools.ts:111 (both verified 2026-08-05). The mechanic is untouched: double speed is still granted on pickup, and the (U)se's no-op write is reproduced as-is. The other possible shape — making the (U)se the thing that grants the speed — was deliberately rejected: that would not have been fixing a bug but redesigning the original's mechanic.

Grade: DERIVED (re/notes/trama-140-acta.md §1.6).

1.7 The wishing well's horse appears inside a wall ⏳ DIVERGENT

DERIVED

The original: the well places the horse at (x+1, y) without checking that the cell is passable. It can end up inside a wall or in the water.

Why it is a bug: it is a blind position write. Its sibling — the step east when getting out of bed — does the same thing, but there the data backs it up: across the 32 small maps, all 264 left-bed cells have a right bed to the east, 264 out of 264. At the well there is no such invariant: the pattern lands in a wall roughly half the time.

OpenU5: already diverges (it checks passability). Port: game/src/core/game.ts:5717 (spawnWishHorse, verified 2026-08-05). Card #227.

Grade: DERIVED (re/notes/deriv-211-acta.md; the sibling's 264/264 census, MEASURED).

1.8 Data holes in text ✅ NONE REACHES THE PLAYER

MEASURED

The rule first: when what is missing is the number — not the punctuation — it is a hole and it gets fixed. That is the boundary with §3, and it is deliberate: a missing piece of data ⇒ fix it; a missing quotation mark ⇒ preserve it.

The two cases in the original that meet it, and what OpenU5 does with each:

How this was once misread, and the rule that came out of it. The first version of this section listed both as faithfully reproduced, citing world/shrines.ts:186 as proof. That line is a comment describing what the original does, not the clone's behaviour. Hence the rule the whole register now follows: the port's state travels with a verified file:line citation, just as the binary travels with its asm citation. A citation to a comment is not a citation to behaviour.

And its converse, for when the register grows: a row whose port state nobody has opened is explicitly marked "(unverified)", never left silent. The rule exists so that a future row is not read as verified by mere contagion from its neighbours, which is exactly how this section came to assert a reproduction that did not exist.

Coverage today: 25 of 26. The denominator is the rows of §1 and §2 — the only ones where "what does OpenU5 do" means anything; §3, §4 and §5 carry no port state by design — and the numerator those carrying at least one Port: file:line citation. The missing one is §2.6, marked "(unverified)" as the rule above requires. re/tools/test_cobertura_port.py recomputes both figures from the file itself and goes red if this sentence stops matching, in both mirrors; the sentence is regenerated with python3 re/tools/registro_cifras.py --write — never by hand.

🔴 A "12 rows of 12" used to live here, and it was false on both counts. Withdrawn on 2026-08-07 after walking all 25 commits of the file: 12 matches no denominator at any point in its history — total rows 19→21 · §1+§2 rows 14→16 · §1 rows 8→9 · Port: citations 14→16 · rows with a citation 13→15. And "coverage is total" was not true the day it was written either: it was 13 of 14, because §2.6 was already silent. The paragraph that INTRODUCED the rule about marking silent rows contained one, and declared itself complete.

Grade (PROPOSED on 2026-08-10 from the evidence this row already cited): MEASURED for the shrine. The body of shrine_visit (CAST2.OVL:0x0966) is declared read in full — "CUERPO ENTERO LEÍDO 2026-08-07 · 958 B declared = 958 B actual" (re/notes/shrines.md §1) — and it is that same reading which brings the six addresses this row cites (0x0b26, 0x0b2a, 0x0b2d, 0x0b47, 0x0b6f, 0x0b74). A body read instruction by instruction is, by the table above, MEASURED. The tavern half-line inherits the grade of §1.2 and is not re-declared here. No WITNESS for either half. ⚠ When citing, use re/notes/shrines.md and not re/notes/cast2-shrines-acta.md: the latter predates 07-08 and still tabulates shrine_visit as "❌ NOT READ", so anyone citing the wrong record will see this row as lacking evidence.

1.9 Using the Skull Key inside a dungeon spends it and opens nothing ⏳ PENDING

MEASURED

The original: the Skull Key handler of the (U)se command decrements the key before checking where you are.

CAST.OVL
18c4  dec byte ptr [g_skull_keys]        ← the charge
18c8  print "Skull Key\n"                  (DS 0x48fe)
18cf  cmp byte ptr [g_location], 0x21    ← the gate, AFTER the charge
18d6  cmp byte ptr [g_location], 0x7f
18db  jbe → print "Not here!\n"            (DS 0x4909) and leave

In the band 0x21..0x7f the player loses a key and all they see is "Not here!". The rejection is especially silent: on that path the result flag is still 1, so the dispatcher's common tail (0x1b8a) does not even emit the "Failed!" or its tone.

That band is the eight dungeons. It is not a theoretical range. MAINOUT.OVL:0x0887-0x088c writes g_location = [bp-2] + 1 on entry, and the sub ax,0x4000 two instructions earlier pins the base at 0x20 (so the file offset is not negative), with 0x200-byte blocks — one dungeon each. Eight dungeons ⇒ g_location = 0x21..0x28.

And it is genuinely reachable, even though the dungeon has its own keyboard loop. dng_dispatch_key (DUNGEON.OVL:0x06c4) handles only a handful of codes; everything else falls to its default branch (0x07a0), which calls kernel_cmd_dispatch (ULTIMA.EXE:0x3178). There 0x34e8 cmp ax,0x55 — the 'U' — jumps to 0x340c, which prints "Use item" and enters the item dispatcher with no location gate at all. That is: be in a dungeon, press U, pick the key.

A second witness, and it comes from the clone itself: OpenU5 ticket #123 fixed the case where using the Skull Key inside a dungeon fell through to the surface path and unmagicked a door on the map above. That fix would not have been needed had the branch been unreachable.

★ The control sits in the handler next door. The carpet, the immediately preceding handler in the same dispatcher and the same kind of consumable, does the opposite: two gates (0x1869 location, 0x187f tile) and only then 0x18a1 dec byte [g_carpets]. Guard-then-consume, 35 bytes away, inside the same routine — so "sloppy convention of the era" is not available as a defence: the correct pattern was written right next to it. And the same text carries two prices: "Not here!" appears twice in DATA.OVL, 0x48f3 (the carpet's refusal, free) and 0x4909 (the key's refusal, costs a key); the player sees the same line and cannot tell them apart. The guard census shows the same contrast: g_skull_keys appears at 2 sites in the whole binary (the dec and one inventory read) and has not a single cmp; g_carpets appears at 10, with a zero guard (CMDS:0x0fd6), a cap at 99 (SJOG:0x14a9) and two ways to replenish.

The cost is bounded, and that is worth stating: the dec has no guard, but it cannot wrap 0 → 255, because the item picker never hands over an item with count 0 — ZSTATS.OVL:0x05a4 find_next_owned accepts an entry only if byte [bx+si] != 0 (0x05ba-0x05bd), and otherwise keeps searching. You lose one key per keypress, not your inventory. Note that this safety lives in another routine, not in the dec.

What it is not: do not confuse it with the gem. (V)iew a gem (ULTIMA.EXE:0x341a) has the same shape — decrement, then look at g_location — but both its branches do something (surface viewer LOOKOBJ.OVL:0x10fc / dungeon viewer DNGLOOK.OVL:0x06a8). There the item is spent and used; here it is spent and refused.

OpenU5: today it reproduces the bug. Port: game/src/core/game.ts:3170-3181 (useSkullKey, skullKeys-- and then if (location >= 0x21 && location <= 0x7f) → "Not here!", verified 2026-08-07). It belongs in this section rather than §2 because it does not touch the stream — the only rand_range in the dispatcher's 1054 bytes is the carpet's orientation coin (0x1899), off this path — so rule 2 applies. Fixing it means moving the dec after the gate.

Grade: MEASURED (re/notes/skullkey-alcanzabilidad.md; the body of CAST.OVL:0x1792 read instruction by instruction in re/notes/cola-cast-acta.md §3). No WITNESS: the original has not been run under DOSBox for this row.


1.10 Passing at the Skull Key prompt spends it and blows up your own square ⏳ DIVERGENT

MEASURED

Sibling of 1.9 — same handler, different path: you are not in a dungeon, you simply do not pick a direction.

The original: the door-opening worker (CAST2.OVL:0x0768) returns three distinct values, and the caller only tells two apart.

CAST2.OVL:0x0768
 076e  call 0x306        → asks for a direction
 0775  cancelled         → return 0xFFFF
 079d  not a door        → return 0
 07a3  opened            → return 1

CAST.OVL (the key's arm)
 18dd  call → the worker
 18e3  or ax, ax
 18e5  jne 0x18ea        ← 0xFFFF IS NON-ZERO: cancellation enters through the "success" door
 18e7  jmp 0x1b8a          (only the 0 leaves here)
 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)

or ax,ax splits the space into {0} and {1, 0xFFFF} — it puts cancellation in the same bucket as success.

And the coordinates are not empty: they are yours. CAST2.OVL:0x0306 seeds g_cmb_scratch_x/_y with the caster's own cell, unconditionally and before the prompt, and the SPACE path never touches them. So 0x18f4 hands over your own square.

⇒ Pressing SPACE at the prompt spends the key and fires the cell FX on the party's own square, with no error message at all. explosion_fx_at_cell (sealed row, whole body read, 66 B) does blit_tile(y, x, 0) — tile 0 —, noise_burst(2000,3000,10) and a viewport redraw. Limit, in the same sentence: calling tile 0 "Explosion" comes from docs/manual/companion/tiles-lookup.js, a third-party table; from the binary all that is derived is that it is tile 0. And this entry does not depend on that routine's contents: what is claimed here is which coordinates it is called with.

★ The blindness is COMPLEMENTARY, and only shows when you read the PAIR. The other caller of the same worker — the In Ex Por spell arm, CAST.OVL:0x1026 — does cmp ax,0xffff, splitting the space into {0xFFFF} and {0, 1}: it gets cancellation right and confuses "there was no door" with "opened". Each handles exactly one of the two failures correctly and the other one wrongly, and neither is a superset of the other. A single ledger row cannot show this; you have to look at both callers at once.

OpenU5: DIVERGES, and is not fixed — it is declared. The clone spends the key the same way (faithful) but on cancellation it draws nothing. Port: game/src/core/game.ts:3170-3195 (useSkullKey; cancellation leaves at game/src/core/game.ts:3187, if (!dir) return events;) — read and verified 2026-08-07. That is, the clone is missing the effect the original does paint. It is pure presentation and does not touch the stream — the dispatcher's only rand_range is the carpet's orientation coin (0x1899), off this path — so there is no parity risk in either direction. It belongs here as a declared divergence, not as a pending fix. ⚠️ Minor citation note: the port's comment attributes cancellation to 0x18e7, and 0x18e7 is the "not a door" path; cancellation leaves through 0x18ea.

Grade: MEASURED. The complementary-blindness mechanism and the worker's caller census come from re/notes/inexpor-dos-llamadores-acta.md (lane bugs-original); the reading of CAST2.OVL:0x0306 that turns the blindness into this concrete consequence is cola-cast's (re/notes/cast2-shrines-acta.md §1.2). No WITNESS: the original has not been run under DOSBox for this entry.


1.11 Holding down V eats the gem and does not show you the map ✅ FIXED

MEASURED

What the player sees: presses V to look at the map, the console says "View a gem!" — and nothing appears. One gem fewer and a turn spent. Reported by an OpenU5 player in the throne room of Blackthorn's Palace, with a screenshot.

The original: the gem view is a loop that closes on the first key sitting in the buffer, and the keyboard's auto-repeat puts the very key that opened it right there.

LOOKOBJ.OVL  gem_view
10fc..1187  draws the 32×32 grid and the marker
118a        sub si, si
118c        jmp 0x11b6              ← straight to the poll, WITHOUT flushing the buffer first
11b6        call → kernel 0x1b38 → 0x1d5e
11bb        je 0x118e               ← 0 = no key, keep blinking
11c3        ret                     ← with a key: EXIT

ULTIMA.EXE  0x1d5e  (the poll)
1d6a        mov ah, 1 ; int 0x16    ← any key? (non-destructive)
1d86        mov ah, 6 ; int 0x21    ← READS it and CONSUMES it

A PC keyboard does not tell a repeat from a fresh press: the BIOS puts the same code in the buffer, and the game never changes that cadence — census of the 28 files of the corpus: every int 16h in the game uses AH=1 (poll) or AH=2 (modifier keys), and zero use AH=03h, which is the function that sets the repeat delay and rate. The system's setting stands. So while you hold V down: the press opens it, the first repeat closes it, and the next one opens it again, spending another gem, because the main loop reads it as a fresh V.

Why the player does not connect it to holding the key. The "View a gem!" echo is only printed on OPENING (ULTIMA.EXE:0x341e, before the gem gate); leaving the loop prints nothing. With two press-equivalents you see one echo and no view: identical to the command being broken. It is parity that decides whether it stays open, not the number of echoes — which counts openings only.

Measured in OpenU5 before the fix (synthetic V repeats, counting console echoes and final state):

pressesechoesgemsturnsview
11−10open
21−1+1closed ← what the player reported
32−2+1open
42−2+2closed

Reachability: high, and nothing exotic about it. You do not have to mash the key: one deliberate press held past the system's repeat delay is enough — the delay each player has configured on their keyboard, and which in the original was their PC's BIOS setting. Looking at the map is precisely the gesture where one lingers with a finger on the key.

OpenU5 fixes it: an auto-repeat no longer closes the view; you have to release and press again. The press that opens and its repeats are the same gesture, and one gesture cannot open and close at once. Port: game/src/main.ts (the canvasGemActive branch of the keydown), with the zodiac view (zodiacActive) brought in line for consistency — that one spent nothing, it only flickered.

★ Why the guard is NOT on the whole keyboard. Because in Ultima V you walk by holding the arrow down: auto-repeat is continuous movement. Blocking it in general would have traded this defect for a bigger regression, and a less faithful one. Only what has no defence is fixed — that the repeat of the same press which opened a modal should close it — and walk-by-holding was checked intact after the fix.

Not to be confused with §1.9. That entry says that with the gem "the item is spent and is used", by contrast with the Skull Key which is spent and refused. It still holds: it is about the location gate, where both gem branches do something. This entry is another way to lose the gem, through the view's loop and not through the gate.

Grade: MEASURED on both sides. Binary: bodies of gem_view and of the poll read instruction by instruction, plus the int 16h census over the whole corpus. Port: the parity table above, reproduced on the exact case of the report. No WITNESS: the original has not been run under DOSBox holding the key down.

1.12 During animated waits, the last torch's halo is stamped over your window ✅ NOT REPRODUCED

DERIVED

The original: the routine that redraws the play window (ULTIMA.EXE 0x5910) has two branches: full recompute — it redoes visibility from scratch — and incremental, which walks the 121 squares and repaints with raw terrain only the ones that are zero (59ad). The problem is where those zeros come from. The pass that computes the torch halos uses the party's window buffer as scratch paper: it marks every square it visits by writing zero (5b89), and it does so with an index in the torch's own local coordinates, while its writes to the light buffer do carry the origin (5b48-5b5c). Two different indexing conventions inside the same routine.

Nobody else leaves zeros behind: after a full recompute, 0x5D0A walks the 121 squares and turns the zeros into 0xFF (5d76-5d8a). So the only source of zeros in the system is the light pass's scratch paper ⇒ an incremental frame repaints exactly the visited-footprint of the last torch processed, with the shape it had in its own frame, wherever that lands on your screen.

Why it is a bug: it reveals terrain in the wrong place, and it does so by reusing one buffer for two meanings. It is not rare, either: 0x10d0 calls the redraw 32 times per invocation (loop 0x1070, every 8 turns of the sound driver) with no game event in between, and the recompute flag is cleared on the first one (598a) — 31 of those 32 passes take the incremental branch. You see it during animated waits, and it goes away as soon as the party does anything.

OpenU5: ✅ does not reproduce it. The clone recomputes visibility in full every frame (computeVisibleWindow is a pure function, with no state between frames), so it has neither the shared buffer nor the two branches. Reproducing it would mean introducing mutable state between frames plus the flag-driven split, in order to obtain a flicker of misplaced terrain. Port: game/src/core/world/visibility.ts (the docblock declares it with the citations). Not to be confused with the BRIDGE, which is the part of that same routine that is mechanics and is reproduced (ticket #256): outside the party's radius, a transparent square is only seen if the neighbour it was reached from is lit as well — which is why a torch's halo only extends your sight once it touches your own circle of light.

Grade: DERIVED (re/notes/visibilidad-256-acta.md §2; bodies of 0x5910, 0x5A28, 0x5D0A and 0x5E4A read instruction by instruction). No WITNESS: the original has not been run under DOSBox to photograph the footprint.


1.13 Fourteen dungeon rooms leave you locked in forever ✅ FIXED

MEASURED

What the player sees: in Doom, goes down from level 2 to level 3 by the U/D ladder, lands in a room with no walls holding giant rats and wisps, wins the fight — and can no longer move in any direction. No walking, no Klimb, no spell. The game ends there. Reported by an OpenU5 player on 16-08.

The original: the room is fine; what has no exit is the square it puts you back on. When a room fight ends, dng_enter_room returns you to the cell you entered from: it saves g_party_x/y on entry (DUNGEON.OVL 0x0084/0x008c) and restores them on both exit branches (0x00fa-0x0103), without looking at which board edge you left by. And fourteen room cells in the game have all four neighbours walled and no secret door for (S)earch to reveal — census over the raw bytes of DUNGEON.DAT, 14 of the 198 room cells. On the already-cleared tile (0xF6 & 0xAF = 0xA6, 0x00f5) the original accepts nothing:

DUNGEON.OVL  0x05FF   walk      hi ∈ {0xB,0xC,0xD} → "Blocked!"   (all four neighbours are 0xB0)
             0x1e5e   Klimb ↑   hi ∈ {0x10,0x30}, or bit 0x08 of the tile WITH the grapple
             0x1e79   Klimb ↓   hi ∈ {0x20,0x30,0x60}
CAST.OVL     0x0fd2   Uus Por   cmp byte ptr [g_location],0x28 ; jmp <silent failure>
             0x0ffc   Des Por   same — 0x28 = 40 = Doom: both are vetoed in that dungeon

The pattern repeats in all fourteen: the room cell sits where a ladder's landing would be, and the paired ladder still exists on the adjacent floor (0x10 up, 0x20 down, 0x30 both). The room's high nibble does not carry the ladder, so the landing stops being a landing.

Four of them have an accidental door. The Klimb-up gate reads bit 0x08 of the raw tile without looking at the high nibble (1e52: and ax,8), and in a room cell that bit is bit 3 of the room number. Unintended consequence: the five sealed cells whose room number is ≥8 read as "lit", and with the Grapple the original climbs out through the ceiling. Four escape; the fifth is the endgame room (Doom floor 7), where the tile above is a falling pit and on_enter (0x0C76) brings you back — up and down, floor 7 → 7. The landing check does not stop it because dng_landing_ok (0x1C0C) only inspects the destination tile with mode ≠ 0, and Klimb passes mode = 0. ⇒ ten hard traps in 1988, and four that depend on carrying an optional item. The one in the report (room 6, bit 3 = 0) is among the ten.

OpenU5 fixes it with a new condition in the Klimb gate, declared as a deliberate divergence where it lives: on an already-cleared room, if the cell on the adjacent floor is the ladder whose pair occupied this landing, you climb that way. It is not an invented value — the relation is already in 1988's data; what is restored is the way you came in. Two properties bound the intervention, both measured:

Port: game/src/core/dungeon/dungeon.ts (parejaDeEscaleraBajoSala, with the minimal-intervention guard in klimbCaps). Guard: game/tests/salas-selladas-mazmorra.test.ts.

Grade: MEASURED on both sides. Binary: bodies of dng_enter_room, of the Klimb gate, of dng_landing_ok and of the two CAST gates read instruction by instruction, plus the census of the 14 cells over the raw bytes of DUNGEON.DAT. Port: the behaviour of all 14 exercised cell by cell, before and after. No WITNESS: the original has not been run under DOSBox as far as one of the fourteen. Full derivation in re/notes/salas-selladas-mazmorra.md.


2. Bugs OpenU5 reproduces on purpose

Almost all of these move the random number stream, or depend on it. Fixing them would break the instrument the entire port is verified with. They stay, and they are declared.

There is a second reason to reproduce a defect, and §2.7 is the first to use it: when the defect is that the original does not do something, "fixing" it means inventing content EA never shipped. That is not porting, and so it too gets reproduced. (This paragraph was added with §2.7: until then the section claimed the stream was the only reason, and that row would have made the claim false.)

2.1 The nocturnal encounter bonus lives in a dead branch

MEASURED

The original: the enemy spawn threshold adds +3 "at night" (MAINOUT.OVL:0x0D8C). The condition is hour >= 0x20 or hour < 5. But 0x20 is 32, and the hour lives in 0..23: that half of the condition is never satisfied. The bonus only comes in through the other half, from 00:00 to 04:59.

Why it matters, and is not a curiosity: on normal terrain the base threshold is 1, and the roll is rand(1,30), so without the bonus nothing ever spawns. The upshot is that in the Ultima V of 1988 dusk, from 20:00 to 23:59, has no random encounters on normal terrain. The stretch you would expect to be dangerous is the safest of the day.

INFERRED, and labelled as such: 0x20 where the plausible intent was 20 decimal (0x14) looks very much like the classic hex/decimal slip. That is a reading of intent and supports nothing on its own; the fact — the branch is never satisfied — is MEASURED.

OpenU5: reproduces the dead branch. Port: game/src/core/world/loops/spawn.ts:37 (spawnThreshold, verified 2026-08-05). A toggle remains a future possibility, once the verification mirror no longer depends on the stream.

Grade: MEASURED (re/notes/loops.md §1.1 and §1.2, asm verified).

2.2 The 1-to-30 generator is not uniform

DERIVED

The original: rand30 yields P(1) = 4/61, P(2..29) = 2/61, P(30) = 1/61. The cause is an inert sign correction in ULTIMA.EXE:0x3abe (0x3ad0-0x3ad1): the instruction is there and does nothing.

The derivation, so nobody has to redo it. 0x3ac9 pushes 60 and calls 0x3aae, which is rand_range(0, n). rand_range is inclusive at both ends, so it returns r ∈ 0..60: 61 equally likely values. Then cdq / sub ax,dx / sar ax,1 is the integer-divide-by-2 idiom — and that is where the inert sign correction lives, because r is never negative — and 0x3ad8-0x3adc raises 0 to 1. Counting:

valuecomes fromcases
1r ∈ {0,1} (raised by the inc) and r ∈ {2,3}4
2..29two r each56
30r = 601
61

✅ INDEPENDENTLY REPLICATED on 2026-08-06. The 4/61 has been derived twice, by different routes, with neither author aware of the other's: here, from the counting table above; and in the web area, by enumerating all 61 values of max(1, rand(0,60) >> 1) — same result, P(1)=4/61 · P(30)=1/61, and in both the distribution sums to its own denominator. Worth recording because the grade rises and the number does not show it: a figure replicated by two independent methods is no longer «its author derived it».

🔴 CORRECTED 2026-08-06: this row said 3/61, and it was false. The error gives itself away with one sum: 3 + 28·2 + 1 = 60, which is not the denominator. A distribution that does not sum to its own denominator is wrong by construction, and checking costs one line.

Why it is a bug: the range mapping loses the uniformity the code itself is trying to impose. The high end comes up four times less often than the low end (1/61 against 4/61).

🔴 That "four" read "three" until 2026-08-10, and it is the RESIDUE of the 06-08 correction. That correction replaced 3/61 with 4/61 in the table and in the statement, and left untouched the sentence that INTERPRETS the figure three paragraphs below — which still carried the old ratio, written in words instead of digits. It is recorded because the failure mode repeats: a corrected figure does not drag along the sentences that translate it into plain language, and those sentences are precisely the ones quoted outside the document, because they are the readable ones.

OpenU5: reproduces it exactly, and cannot do otherwise: it is the heart of the stream. Port: game/src/core/rng-original.ts:97 (the % span over & 0x7fff, verified 2026-08-05). A warning for anyone reimplementing this — it invalidates any "clean" version along the lines of 1 + rand(0,29).

Grade: DERIVED from the ledger (asm-kernel-l4-tanda1).

2.3 The Shadowlord's "is this a person?" check always looks at slot 4

DERIVED

The original: when looking for someone to possess, the gate reads the NPC type with mov bx, cx at TOWN.OVL:0x111f — but cx is the counter of a loop that has already finished and holds 4. It always consults slot 4, not the one being evaluated (its index had been destroyed by shl si,4 at 0x1103).

Why it is a bug: the two sibling routines, 0x85e (Astaroth) and 0x8d4 (Nosfentor), do reload the argument to run their own test. Three sites, the same test, and only one forgets to reload.

Real effect: dormant in the canonical game — the eight virtue cities all have a person in slot 4, so the gate passes anyway.

OpenU5: reproduced, with tests. It consumes 32 rolls no matter what, just like the original. Port: game/src/core/world/shadowlord-urban.ts:108 (possessGateRoll, verified 2026-08-05).

Grade: DERIVED (re/notes/shadowlord-urban.md §4).

2.4 Off the small map you always read cell (31,31)

DERIVED

The original: with an out-of-range coordinate, get_tile_ptr neither clamps nor wraps: it returns a fixed pointer, DS:0x6A07, which is the last byte of the buffer — cell (31,31). It is an out-of-range read that happens to be harmless by accident.

OpenU5: reproduced. It is what explains the grass border around Britain. Port: game/src/core/world/map.ts:48 (edgeFillTile, verified 2026-08-05).

Grade: DERIVED, already declared in re/deliberate-divergences.md.

2.5 In Bet Xen summons all four creatures onto the same cell

MEASURED

The original: the second loop of CAST.OVL:0x07b4 does not call the cell picker again, so the up-to-four summoned creatures all appear on top of the same one.

Why it is a bug: the picker exists and is used for the first one. Not calling it again is the definition of an oversight.

OpenU5: today it diverges (it calls the picker per creature). Port: game/src/core/combat/combat.ts:2589 (verified 2026-08-05). It is being corrected towards reproducing the bug, not towards fixing it, precisely because of the stream rule. Card #6.

Grade: MEASURED by the combat lane.

2.6 The roll is spent on the dead and on those who resist too

MEASURED

DUNGEON.OVL:0x0948 asks for its random number always, even for members who cannot be affected. It consumes stream. Reproduced; it is one of the contract's binding examples.

OpenU5: (unverified). Nobody has opened the clone for this row. It consumes stream, so by rule 1 it is reproduced regardless — but "it is reproduced" is here a CONSEQUENCE OF THE RULE, not observed behaviour, and the register does not conflate the two.

Grade (PROPOSED on 2026-08-10 from the evidence this row already cited): the mechanism is MEASURED. The ledger's dng_field_sleep row declares "CUERPO ENTERO LEIDO 0x0948-0x09E4 (ret, no args)" and inside that very reading sits, verbatim, this row's claim: "rand_range is called ALWAYS, with pushes (1, 0x1e) = range 1..30 — that is, the roll is consumed by the dead and by those who resist too". No WITNESS: re/verified/dungeon.md files it under "asm + model↔clone cross-check", not under oracle parity. Note the grade is of the BINARY; the port state remains (unverified), which is a different thing and lives above.

2.7 EA's easter egg is in the data and the code never reaches it ✅ REPRODUCED

MEASURED

The original. When you talk to any NPC, before looking at the keywords of that NPC's script, the game tries a fixed word table: NAME, JOB, WORK, BYE, THANK, and then a string of profanities, to which the NPC always gives the same reply — "With language like that, how did you become an Avatar? (DS 0x93D0).

The table lives at DS 0x4AA8 and has 35 entries. The last one, number 34, is ELECTRONIC ARTS.

It is never tried. The table has exactly two consumers across the 28 disassembled files — the "Your interest?" prompt loop (TALK.OVL:0x0B43) and the answer-to-a-question loop (TALK.OVL:0x0CB4) — and both carry the same bound:

0bab / 0d15:  cmp byte ptr [bp-2], 0x22     ; 0x22 = 34
              jae  <exit>                    ; compared AFTER the increment

⇒ indices 0..33 are tried and 34 never is. The string DS 0x9318 has no other reference anywhere in the disassembly: it is dead data.

And the detail that settles it as intended-to-work: ELECTRONIC ARTS is exactly 15 characters, which is precisely the input buffer's cap for that prompt (TALK.OVL:0x0A33, mov ax,0xf). It fits to the character. Someone wrote it so it could be typed, and the loop's 0x22 left it out.

Grade: MEASURED on both halves — the table's contents read from the data file, and the bound read in both instructions. The consumer census is by hex over the 28 .asm files; it does not rule out access through a computed pointer, which was not censused.

What OpenU5 does: it reproduces the defect — and reproducing it meant REMOVING something. This is where the case steps outside the section: the clone did answer. Its profanity list held 30 entries — the binary's 29 in the same order plus ELECTRONIC ARTS, verified by comparing both lists element by element against the data file. So it was not modelling the original differently: it was adding a reply the original does not give.

That is why this row sits in §2 and not in §1. We do not reproduce it for the stream — this costs not a single roll; we reproduce it because making the easter egg work would mean inventing content EA never shipped. A faithful clone cannot add.

Port: game/src/core/dialogue/conversation.ts (PROFANITY_KEYWORDS, verified 2026-08-06). The entry removed, with the reason kept next to the code so nobody restores it "for completeness", and a test that turns red if it comes back.

Where the extra entry came from, which is the instructive part. It was not introduced while writing the clone: it was inherited from a miscount that lived in three places at once — the conversation engine's own comment, the ledger row's citation, and the corpus coverage census. All three said "indices 5..33 = 28 profanities + ELECTRONIC ARTS", and 5..33 is 29 slots, all 29 of them profanities. Nobody had recounted against the data file. The port inherited the error from the citation, not the other way round, and all three places were corrected in the same commit as this fix — because leaving one means the next reader restores the entry from the stale text.

2.8 On the 20th of every month, the Shadowlord's withering spares NOTHING

MEASURED

The original: when a Shadowlord takes a town, the vegetation withers (CAST2.OVL:0x022a). The sweep seeds its own generator with the day of the month — srand(g_day)— and for each wheat field or tree it rolls rand(0,7): on a 0 that cell is spared (0x025f/0x027e, or ax,ax / je). One in eight, by design.

On the 20th none is spared. The reason is not in the sweep: it is in the generator. 20 is a fixed point of rand_range's step function (ULTIMA.EXE:0x2092):

20 + 0x9248 = 0x925C   ; add ax, 0x9248
ror16(·, 3) = 0x924B   ; d1c8 ×3
    ^ 0x9248 = 0x0003  ; xor ax, 0x9248
      + 0x11 = 20      ; add ax, 0x11   → the seed does NOT change

With the seed stuck at 20, rand(0,7) returns always 4 — never 0 — so the sparing gate never opens once. That day the withering is total.

Measured, day by day (1000 rolls per day, on the clone's generator, whose parity with the binary is verified under dosbox-x):

daycells spared out of 1000
1..19, 21..28between 103 and 141 (≈ 1/8, as expected)
200

And the fixed point is unique: sweeping all 65,536 states of 16 bits, 20 is the only value whose step returns itself, and its only preimage is itself — you cannot reach it by stepping, only by seeding it.

Why it is a bug and not a curiosity: the code explicitly asks to spare 1/8 and one day in every 28 spares 0. It is visible on screen, it is deterministic and it happens to everyone.

OpenU5: clones it, and cannot not clone it — just like §2.2, the defect lives in the heart of the generator, and "fixing" it would mean changing the RNG the whole port is verified against. Port: game/src/core/world/shadowlord-wither.ts:81 (new OriginalRng(day & 0xff), verified 2026-08-07).

Grade: mechanism MEASURED (the fixed-point arithmetic checks out by hand in four lines). Reachability MEASURED AND POSITIVE: day 20, deterministic, every month, whenever the withering runs. NO LIVE WITNESS under dosbox-x: the table above comes from the clone —a parity-verified generator—, not from watching the town wither on the 20th.

An honest loose end, measured and NEGATIVE — the obvious route is not one. The game reseeds the RNG from the wall clock when camping, and the natural suspicion is that 20 could slip in there. It cannot. The routine that builds that seed (ULTIMA.EXE:0x2056) combines hour, minute, second and hundredth and ends with and ax, 0xfff; enumerating all 8,640,000 possible clock readings, none produces 20 (in fact only 2688 of the 4096 12-bit values are producible at all, and 20 is not among them). So the camping reseed is not a door to this defect, and the fixed point's worst consequences stay out of reach — see §4. (Arithmetic independently replicated on 2026-08-10 and with an executable source since then: re/tools/test_semilla_reloj.py, in the battery, re-extracts the constants from the body of 0x2056 and re-derives all three figures on every run, checking them against what is published here.)

2.9 In combat, two of the four field walls do nothing whatsoever

MEASURED

The original: the four wall spells (In Flam Grav, In Nox Grav, In Zu Grav, In Sanct Grav) have two branches depending on where you are (CAST.OVL:0x004c, 0054 cmp byte [g_location],0x80). In a dungeon they write a field tile into the cell ahead. In combat they seed nothing: they set a "spell weapon" (00ef mov al,[bx+0x4592]) and call the ordinary attack dispatcher — the SAME one Grav Por, Vas Flam and Xen Corp call (0100 call 0xffffc14a, which resolves to COMSUBS.OVL:0x0c52). And the damage of those four weapons comes from the attackValues table: 18 · 0 · 21 · 0.

⇒ In combat, In Zu Grav and In Sanct Grav spend the mixed spell, spend the mana, spend the turn and do ZERO — no field, because that branch seeds none, and no damage, because their table entry is 0. The other two at least hit (18 and 21), but they raise no wall either: the player casting "a wall of fire" in the arena is throwing a dart.

Why this is a bug and not a decision: both branches exist and do different things on purpose, so combat is not "unimplemented". What fails is that nobody gave two of the four weapons a value: the spell reaches the engine and the engine has nothing to apply. A non-effect that costs resources is not a mechanic.

How we know combat seeds no field (rather than us failing to find it): the census is not "we saw no writes" but who references the field-tile table. DS:0x4596 (tiles 0x80-0x83) appears exactly once in the whole disassembly — in the dungeon branch. The combat chain has been read end to end (attack_dispatch_by_reach → player_ranged_attack / melee_strike_resolve → hit_roll / apply_damage_death_loot) and none of the four routines writes a tile.

OpenU5: CLONED. The values 18/0/21/0 are transcribed, not "fixed": inventing damage for In Zu Grav would mean inventing content EA never shipped — the same reason as §2.7 — and it would also move the stream. Port: game/src/core/combat/combat.ts:406 (SPELL_WEAPON_STATS, the four entries including the two zeros) and :457 (combatCastEffect), wired at game/src/main.ts:2268 (verified 2026-08-08). A test guards the clone: game/tests/field-wall-dungeon.test.ts turns red if anyone "fixes" the zeros.

Grade: mechanism MEASURED (both branches, the weapon table, the damage table and the whole combat chain, read). Reachability NOT SURVEYED: all four are allowed in combat by the DS:0x1C90 mask (0x03), so the path exists — but nobody has counted how many playthroughs step on it. Ticket #91.


2.10 The speaker sweep never reaches its nominal frequency

MEASURED

The original: pcspeaker_glide (ULTIMA.EXE:0x43ae) takes four arguments — start, end, step, total — and all 30 static call-sites in the game push a concrete end. The routine uses it exactly ONCE: to compute the slope (0x43bc sub ax,[bp+0xa] · 0x43c2 imul · 0x43cc idiv = trunc(((end − start)·step)/total)); it never compares it — the loop's cut is 0x43ec cmp di,[bp+4]; jl against total, and each turn's tone sounds BEFORE the increment. On top of that, the imul discards the high word (0x43c5 keeps only ax; 0x43cb cwd re-derives the sign) and the idiv truncates toward zero, so the rounding error accumulates turn after turn. Measured site by site (re/notes/firma-43ae-todos-los-callsites.md §3, re-derived in re/notes/barrido-137-acta.md): the last divisor written to the PIT (0x22e2 div 0x1234DE → out 0x42 ×2) matches the nominal in NONE of the 30, and that register RETAINS what was written until the gate goes OFF (0x43f7 → 0x230e). The two worst: OUTSUBS.OVL:0x0492 asks for 2500→800 and the sweep dies at 1005 Hz (+205); MAINOUT.OVL:0x113b/0x12a6 asks for 660→150 and dies at 272 Hz (+122 — more than an octave above what the caller asked for).

Why this is a bug and not a decision: the datum exists and is only half-consumed. Every caller writes a destination frequency that the routine uses for the slope and never for the destination — a promise with no effect. The symmetry argument: the sibling primitive set_tone (0x22e2) delivers exact Hz (= its argument), and the sweep itself starts EXACTLY at start; only the end extreme goes unguaranteed. Where the division is exact the deviation is one turn (−inc, inaudible as such); where it truncates, it blows up to the +205 Hz above.

OpenU5: CLONED (ticket #137). The port synthesized a clean start→end ramp that DID reach the nominal: an audible CLASS divergence, across all 30 sites at once. The real staircase is cloned — same truncated increment, same early cut, same effective final frequency. Port: game/src/skin/fiel/speaker.ts:214 (glide, the body's arithmetic with its 16-bit truncation) and :1148-1154 (the synthesis switches divisor by divisor with one setValueAtTime per turn, no ramp), in use by the catalog's six glide cues (:801, :805, :808, :809, :871, :874 — verified 2026-08-18). A test guards the clone: game/tests/glide-escalera-137.test.ts, with the binary-derived finals written as raw literals (1005 · 272 · 233 · 1980 · 1976) — it turns red if anyone restores the ramp. "Fixing" it would mean inventing a sound 1988 never emitted — the same reason as §2.7 and §2.9 — and deciding whether EA "wanted" the short sweep is a reading of intent, not of bytes.

Grade: MEASURED (the body 0x43ae-0x43ff re-read instruction by instruction over re/disasm/ULTIMA.EXE.asm:7424-7459, together with set_tone 0x22e2 and the gate-OFF 0x230e; the two witness call-sites read raw: OUTSUBS.OVL.asm:462-470 and MAINOUT.OVL.asm:1745-1753; the 30/30 census, in the note cited). Ticket #137.

2.11 The TLK script carries six programming errors — reproduced byte for byte

MEASURED

Where the lead came from: the dialogue-error pack the community has documented since the newsgroup days (r/Ultima thread 130bqnu, 2023, with a third-party public fix — raldi/ultima5-tools — which this port does NOT use). Every case was re-verified here against OUR bytes — the raw .TLK files and TALK.OVL — not taken on faith; all six exist in v1.16.

The mechanism behind all six (MEASURED in re/disasm/TALK.OVL.asm): the script interpreter pairs RIGIDLY — the k-th keyword is line 2k+5 (0x09a7-0x09a9: shl ax,1 / add ax,5) and its answer line 2k+6 (0x0bba-0x0bbc); an <Or> (0x87) sitting in an answer slot skips one chunk and processes the next (0x0fbc → 0x07be); the answer printer STOPS at the 0x00 terminator (0x0788); inside a label, the default is a SINGLE chunk (0x096e) and sub-keywords live at question+2+2k (0x0bd4). Under that arithmetic, any extra, missing or misplaced byte in the data derails the script — and EA left five:

casethe broken data (raw cite)what DOS does
Thrud (KEEP 8)86 1c at KEEP.TLK:0x12a8 — decimal 28 where 0x28=40 belongedpromises the Jeweled Sword and hands out the crossbow (slot 28); the 86 08 at 0x12a6 (Jewel Shield) is correct
Weblock (CASTLE 14)gorn<00>hass<00><87><00>answer at CASTLE.TLK:0x1b2f — the <Or> comes AFTER "hass" (healthy format: spir<00><87><00>musi, 0x010c)asking about GORN answers the literal "hass"; HASS and the prisoner answer are unreachable
Sir Arbuthnot (DWELLING 15), "coin"the keyword sits in TWO chains — roya/coin (0x1d6c) and magi/coin (0x1e10) — and the first one winsCOIN answers the job follow-up, not the magical coin (still reachable via MAGI)
Sir Arbuthnot, label 2the y<00> bytes before the honor branch are missing (sister label 1 HAS them, ~0x1ec0)answering "y" to "Art thou the one true Avatar?" falls to the default "Oh." — your yes is treated as a no and the honor + AskName branch is unreachable
Malik (TOWNE 3)the offer says 3 (digit at TOWNE.TLK:0x6d3) and the opcode charges 85 '004' (0x6fa)offers the gossip for 3 coins and charges 4
Mario (TOWNE 23)a SPURIOUS 0x00 after "charity!" splits the mino/crim answerthe answer is truncated there; the third section and the caug/tort keywords are dead

Witness from the original (Mario): the mirror corpus (OCR of EA frames, route ad03) shows the answer to CRIME cutting off at "charity!" and returning to "Your interest?" — the buggy conduct, observed with the binary running.

Why they are reproduced rather than fixed: they do not touch the RNG stream, but (a) "fixing" them would publish content no 1988 player ever saw — the §2.7 reason — and (b) the mirror verifies the port against the original's OBSERVED conduct: a "fixed" port would diverge from the very instrument of verification (Mario's half is already photographed in the corpus).

The port only half-reproduced them, and both infidelities were removed (2026-08-26): (1) the interpreter printed ALL of a label's defaultAnswers — with Arbuthnot label 2 it emitted "Oh." AND the unreachable honor branch; today it processes ONE, the calque of 0x096e (Port: game/src/core/dialogue/conversation.ts:1078-1091; census: it is the only label in the game with >1 default). (2) the extractor inherited from Ultima5Redux a "hack" that stitched Mario's spurious 0x00 back together; removed (Port: extractor/src/parsers/tlk.ts:181 plus the boundary guard at :346). The other four already flowed through the data: Thrud delivers the raw byte (game/src/core/dialogue/effects.ts:206, applyGiveItem), Malik charges the opcode's data (conversation.ts:925 → effects.ts), Weblock and the shadowed "coin" come out of the TLK mirror as-is. Locks: game/tests/bugs-original-tlk.test.ts (13 asserts, red-before verified on both fixes), game/tests/dialogue.test.ts (Thrud [8, 28] — its comment used to say "28 = jeweled shield" and was false: corrected) and extractor/tests/tlk.test.ts (the parser preserves the truncation and the transposed <Or>).

Grade: MEASURED (mechanism and all six data read raw; Mario's half additionally has a witness of the original via the mirror corpus). bugs-original lane, 2026-08-26.

2.12 The phantom class: "Adept of Woznir"

MEASURED

The original: a class byte that is none of the letters in "AMBFDTPRS" makes the stats sheet display the class "Adept of Woznir". The mechanism (MEASURED, ledger frontier.json — the whole body of draw_stat_page, ZSTATS 0x0082-0x0274): strchr_index (ULTIMA.EXE:0x4d76, called at ZSTATS:0x00e0) does NOT return −1 on failure — its loop exits on the NUL and returns the counter, i.e. 9 with nine selector letters — and the class-name pointer table (DS 0x1a44) has TEN pointers: the tenth (DS 0x8f8 → DATA.OVL fileoff 0x908) points at the real pool string "Adept of Woznir", right after "Shepherd". The ledger had left "not adjudicating what the tenth entry's string is" on record; adjudicated here by reading the data. The community knows it (thread uoit80; Garriott, when asked, did not — it is the PC port team's), and the asymmetry tells the deliberate half from the accidental one: the NAME table has its tenth entry pointing at a real string (easter egg), but the INDENT table (DS 0x1a58) has NINE words — index 9 reads the first word of the neighbouring table (0x908 = 2312) and the loop emits 2312 spaces before the name.

Reachability: only with an edited save — character creation and the roster never write letters outside the selector string (ledger: reachability censused at the single caller, always party members). Same family as §5.5, but here the binary has concrete, named conduct, so it is reproduced instead of filed as a curiosity.

OpenU5: the name is reproduced — invalid letter → "Adept of Woznir" (Port: game/src/skin/fiel/ztats.ts:192, verified 2026-08-26; lock in game/tests/fiel-ztats.test.ts). The 2312 indent spaces are NOT reproduced (indent 1): divergence declared in the docblock itself — replaying an out-of-table read is not useful observable fidelity in a 15-column window.

Grade: MEASURED (mechanism and cardinals in the ledger, instruction by instruction; the tenth entry's string read raw from DATA.OVL:0x908). bugs-original lane, 2026-08-26.

2.13 A dungeon room's identity is ONE NIBBLE: clearing one clears its twins

MEASURED

Where the check came from: a player on the GOG/DOS edition reports finding many rooms in Covetous already empty without having touched them, and that a room he did clear comes back armed (r/Ultima thread 1dxojlh, 2024). The thread's comments propose the semantics — 16 archetype rooms per dungeon, repeated instances sharing a flag, "Covetous cheats to increase its apparent room count" — with no citation of the binary. Against our bytes both halves of the report are true, and they are two different mechanisms.

The first half's mechanism, MEASURED. A room cell is the tile 0xFn, and the room number is the low nibble, and nothing else: dng_enter_room takes the raw tile and masks it at DUNGEON.OVL:0x0024 (250f00 and ax,0xf). The persistent bit that remembers the room is indexed (dungIdx<<4) + roomNumber (DNGLOOK.OVL:0x08a2 shl ax,4 + 0x08a6 add ax,[bp+4], set at 0x08bf-0x08c9) — no floor, no X, no Y. And whoever applies that bit to the map walks all 512 bytes of the whole dungeon — the eight floors — comparing only the nibble: DNGLOOK.OVL:0x093a, loop 0x094d-0x0973, with 0952: 24f0 and al,0xf0 / 0957: 3cf0 cmp al,0xf0 (is it a room cell?), 0960: 250f00 and ax,0xf (the number), 0967: e86aff call 0x8d4 (does it have the bit?) and 096e: 8024af and byte ptr [si],0xaf (degrades 0xFn→0xAn, i.e. room spent). That loop has exactly one caller across the 28 .asm: DUNGEON.OVL:0x0e4a (e8fdeb call 0xfffffa4a → kernel stub 0x7c1a, resolved with dispatch_table.stubs()), the fresh map load. Positive control for the same method: the 0x00de of dng_enter_room (e8b1f9 call 0xfffffa92 → stub 0x7c62 → DNGLOOK 0x0844), which is the marking call, resolves the same way.

⇒ Winning ONE cell switches off EVERY cell with the same nibble in the whole dungeon, at the next map load. Within the same visit it does not show: DUNGEON.OVL:0x00f5 (80a05a59af and byte ptr [bx+si+0x595a],0xaf) degrades only the cell you entered from.

And the data says how hard it bites, read from DUNGEON.DAT (4096 B = 8 blocks of 512):

dungeon0xFn cellsdistinct numbersworst room
Deceit · Destard · Shame · Hythloth · Doom16 each161 cell — no sharing
Despise00— (it has no rooms)
Wrong3616rooms 1·2·5·6·11·12 with 4 cells each
Covetous8216room 1 with 17 cells, spread over 7 of the 8 floors

(Command: count the bytes with b & 0xF0 == 0xF0 per 512-byte block and group by b & 0xF. The cardinals reproduce exactly the independent census that re/notes/dungeon.md §14.1.1 ran over game/assets/maps/dungeons.json, which is the cross-control on the block order.)

⇒ Five of the seven dungeons with rooms cannot suffer it — their split is 1:1 — and the two that can are precisely Wrong and Covetous. In Covetous, clearing a single instance of room 1 switches off the other sixteen. The community's phrase — "Covetous cheats to increase its apparent room count" — is literally true: 82 room entrances, 16 rooms.

The second half — the room that comes back — is ANOTHER mechanism, and it was already measured here. Before setting the bit, DNGLOOK.OVL:0x0844 looks the key ((g_location & 0xF) << 4) + roomNumber up in a static DGROUP table (0x0850-0x0887; count at DS:0x3840, table at DS:0x383a) and, if it finds it, jumps to the epilogue without executing the or. Dumped raw (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. Those six are never recorded ⇒ they re-arm between visits: they are the only six replayable rooms of the game's 112 (full derivation in re/notes/dungeon.md §14.1.2).

★ What this check adds, and which the earlier derivation declared it could not derive ("WHY THOSE 6: NOT derivable from the data, and I tried"): the POPULATION of the exception list is derivable after all. Of the 112 (dungeon, room) pairs with at least one cell, 20 have more than one instance — that is, they share a bit. The six exempt rooms are six of those multi-instance ones (4·4·4·4 in Wrong, 15 and 5 in Covetous); no 1:1 room is exempt. Had the six been drawn at random from the 112, the probability of all six landing inside those 20 is 1.6 × 10⁻⁵. ⇒ the table is not an arbitrary list: it is a hand-written mitigation of the defect above, and a partial one — it leaves out 14 of the 20, including the worst (Covetous room 1, 17 cells). What remains underived is which six of the twenty.

Why it is cloned: the bitmap is .GAM state (offset 0x33A, 14 bytes = 7 dungeons × 16 rooms) and its semantics govern which rooms the player re-fights; fixing it would change the route, the loot and the XP of two whole dungeons with respect to what was played in 1988.

Port: cloned in both halves — game/src/core/dungeon/dungeon.ts:1387-1401 (applyClearedRooms = 0x093a: walks every floor and degrades every Room cell whose sub & 0xf has the bit) and :1925-1930 (dungeonMarkRoomCleared, with the ROOM_CLEAR_EXEMPT guard for the six keys). Locks: game/tests/dungeon.test.ts (the 6 exempt keys + a non-exempt control from the same two dungeons + the full cycle spend→no bit→fresh map→re-armed) and the ch24b-covetous-roomno-reread.spec.ts spec, whose declared invariant is precisely the merge ("winning ANY cell of the roomNo degrades ALL cells of that roomNo").

Grade: MEASURED (both routines read instruction by instruction in re/disasm/, the single caller resolved with dispatch_table.stubs(), the exception table and the cell split dumped raw from DATA.OVL and DUNGEON.DAT). bugs-25-28 lane, 2026-08-29.



3. Errata preserved as part of the original

Here punctuation is missing or a letter is wrong. Fixing it would be correcting the authors, and this project preserves the game, it does not edit it.

"a crytal sphere"

errata

— the tile name in LOOK2.DAT, with its 1988 typo. Preserved byte for byte in OpenU5's data.

The guild farewell leaves a quotation mark unclosed

errata

— the string DS 0x78a0 opens "What else, and never closes it. Preserved verbatim in game/src/core/world/cmd-strings.ts:473.

"What didst thou say?"

errata

(DS 0x9450) — same case.

The intro dissolve's L5 band repeats one index and omits another

errata

(it repeats 0x13 and skips 0x1d), so one scanline is not revealed on that pass. It is harmless — the next band's overlap covers it — and it is preserved; there is a test pinning it precisely as proof that the dump is byte-exact. ---

4. Latent defects, with no demonstrated live path

Real in the code, without our being able to claim a 1988 player ever hit them. They are listed for whoever picks up the thread, not as bugs anyone suffered.

Recruiting someone who is ALREADY travelling with you duplicates their record and destroys another

MEASURED

(TALK.OVL:0x080a, join_party). The scan looks the recruit up by the first three characters of their name, walking slots 15 down to 1 (0x0818 starts at 15; 0x089a subtracts 0x20; 0x08a2 stops at 0). A member already in the party lives in slots 1..partySize−1 — inside that scan — so it finds them. It then runs the usual swap (0x08c8-0x0912) against slot partySize, copying their 32-byte record into the free slot and the free slot's into theirs, and increments g_party_size. The result: their record appears twice and the other one is lost, equipment included, because the repne movsw moves the whole record. There is no guard in the body; the only filter is the name. Reachability: OPEN. It needs a .TLK whose recruit opcode names someone who may already be in the party — plausible with companions picked back up from an inn, not censused. MEASURED in the disassembly; the live path is not. OpenU5 does NOT reproduce it: it guards the case and exits through the binary's own "no match" path, without inventing text (core/party.joinByName, verified 2026-08-06). Corrupting the roster is not observable fidelity, it is data loss — and that is the boundary that separates this section from §2.

Stat dispatch with no default case

MEASURED

(COMBAT.OVL:0x13e2). The four cmps cover −4..−1 and any other value falls to 0x1473, which reads [bp-4] never written on that path: it returns stack garbage. The two concrete doors are (a) the record with its 0x40 bit set and a selector other than 0, and (b) the bit clear and a selector equal to 0. Of its three callers, two pass safe constants (−1 and −2) and the third propagates a value from its own caller: reachability requires going one level further up the call graph. MEASURED in the disassembly; reachability OPEN.

The other half of the RNG fixed point: the retries WITH NO CAP would hang

MEASURED

(§2.8 is the reachable half; this one is not). If g_rng_seed were 20, rand_range would return a constant forever, and there are loops that re-roll until the value suits them with no counter: the midnight Shadowlord reshuffle (ULTIMA.EXE:0x5039 or di,di / je 0x5004) and the overworld spawn draw. With a constant that collides, neither terminates — it is not slowness, it is a hang, and what freezes is the whole game's RNG. Reachability: MEASURED AND NEGATIVE on both known routes. (a) The clock seed cannot be 20: of the 8,640,000 possible readings, zero produce it (ULTIMA.EXE:0x2056, and ax,0xfff; only 2688 of 4096 values are producible and 20 is not among them). (b) The live stream starts at 0 and its cycle has 47,343 states that do not include 20; since 20's only preimage is itself, you cannot fall into it by stepping. ⇒ That leaves only the srand(g_day) door of §2.8, which uses its OWN generator and a bounded loop: there it withers too much and terminates, it does not hang. OpenU5 does NOT cap those loops —the binary does not, and capping would be divergence—: it is declared and tested (game/tests/survival.test.ts). Grade (PROPOSED on 2026-08-10 from the cited evidence): the mechanism is MEASURED. It is the only one of the seven silent rows whose evidence carries the grade in those very words, and twice over: re/notes/kernel-survival.md §8.3 writes "Same grade as the rand_range defect with no max<min guard: mechanism measured, reachability narrow", and the note for the routine containing the loop (re/notes/reloj-advance-clock.md, [0x4f7c, 0x51a0) = 548 B declared = 548 B actual) closes with "No WITNESS grade: I have not run the original under DOSBox for this row". ⚠ Loose end CLOSED (2026-08-10): the reachability arithmetic —the 8,640,000 clock readings and the 2688 of 4096 producible values— was independently replicated from the constants re-extracted from the disassembly (same three figures; 20 remains unproducible) and has an executable source since then: re/tools/test_semilla_reloj.py, in the battery, re-derives the whole thing and checks the published figures on every run.

text_gotoxy ignores its own window's bounds

MEASURED

(ULTIMA.EXE:0x1bf2): the window record carries bound fields at +2/+3 and the routine compares against the hardcoded physical screen values. Invisible for window 0, where they coincide. Also, going out of range is a silent no-op — anyone porting this with clamping diverges. Grade (PROPOSED on 2026-08-10 from the cited evidence): MEASURED. The ledger row declares "CUERPO ENTERO LEIDO 0x1bf2-0x1c1f (48 B, 23 insn, ret 4 = TWO arguments)" and both claims of this bullet are its own, verbatim: "(a) DOES NOT CONSULT ITS OWN WINDOW'S BOUNDS: … this body compares against IMMEDIATES, not against [si+2]/[si+3]" and "(c) OUT OF RANGE IS A SILENT NO-OP: it does not clamp, it does not signal". Record: re/notes/asm-kernel-l4-tanda1.md §3.1, which additionally declares that all three come from the body reading and not from the naming sweep's citation. No WITNESS.

Mix with a name that does not match

undetermined

enters the quantity selector with a negative index and reads out of range (CMDS.OVL:0x1b13 only checks for −1, not −2). OpenU5 avoids it on purpose. Grade: UNDETERMINED, and it is declared rather than passed over in silence. On the 2026-08-10 grade pass this is the only one of the seven silent rows whose evidence supports none: the sole source stating the −2 is one line of re/notes/cast-input.md §7 —"Mix only checks -1 (0x1b13 cmp ax,0xffff); a -2 falls into the QUANTITY selector with a negative index … latent bug of the binary never exercised carefully"— which declares no grade, and the record that does hold the containing routine's full body (cmd_mix, 0x1ad8-0x1c1f, 328 B, in re/notes/asm-town-zstats-acta.md §66.3) covers the address but never mentions the −2. That is: the claim is plausible and nobody has read the site with that question in hand. No grade is assigned by resemblance to its neighbours — which is exactly how false grades are manufactured. What closing it needs: re-read 0x1b13 and the quantity selector, checking what they do with −2.

resolve_command_char can return a monster as a roster index

DERIVED

(ULTIMA.EXE:0x4988); two of its three relatives do check. Grade (PROPOSED on 2026-08-10 from the cited evidence): DERIVED, over a MEASURED body. The record declares "Body read IN FULL ULTIMA.EXE:0x4988-0x4a83 (252 B, ret with no arguments)", and this bullet's claim is written by that record itself with its grade already attached: "Consequence derived from the ledger layout: if g_cmb_actor points at a MONSTER, its +2 field lacks the 0x80 bit and its +3 field is not a roster slot. resolve_command_char returns it as a party member index all the same" (re/notes/resolve-command-char-178c-acta.md). So: the body is measured and the consequence is a derivation over the layout, and the row takes the grade of the WEAKER of the two, which is what the row asserts. No WITNESS.

The same distance, measured two ways in the same file — the proximity cue is lost at the world seam

MEASURED

(MAINOUT.OVL:0x007a). Two neighbouring routines compute "distance to the party" under different rules: - object_proximity_activate (whole body read: 96 B, 43 instructions, ret with no immediate) checks a single object —si = 0x5c62 is a constant, never iterated— under four conditions: non-null tile, same floor, and |Δx| < 6 and |Δy| < 6 as a plain absolute value. It is an 11×11 Chebyshev box around the party; if it passes, one call to ULTIMA.EXE:0x3ae6 and nothing else. - pick_spawn_coords (MAINOUT.OVL:0x0f4e), in the SAME file, measures that same magnitude on the 8-bit torus: its pair |Δ| <= 6 / |Δ| >= 0xFA are not two thresholds, they are one single condition with the wrap included, because the coordinates are added in 8 bits and wrap at 256. Consequence if the coordinates wrap in the first one too: an object right next to the party on the other side of the seam yields |Δ| ≈ 250, not < 6, so the cue does not fire. A failure by omission, silent, and only at the edge. Grades, deliberately kept apart. MEASURED: both bodies, the four conditions, the 11×11 box, and that the asymmetry exists. ALSO MEASURED: this routine consumes no RNG — its only call is presentation. NOT ADJUDICATED: which of the two rules is the correct one. It may be that in this context the coordinates do not wrap (a different space), or it may be a defect of the original; what is measured is the discrepancy, not the verdict. There is a third site in the kernel with the same 8-bit torus idiom, and there it is used correctly — which establishes that the idiom is deliberate house style, but does not settle this site. REACHABILITY: REACHABLE. Two pieces, with different grades: - Piece 1, MEASURED over game/assets/maps/overworld.json (256×256). In the ±6-cell strip on each side of the seam — exactly the reach of the 11×11 box — navigability is nearly total: 3039 of 3072 cells navigable in the vertical strip (98.9 %) and 3006 of 3072 in the horizontal one (97.9 %). The seam is not an unreachable zone: it is open sea, and the party crosses it as soon as it sails. The 15 and 26 cells walkable on foot are islets, which additionally make the land case reachable. - Piece 2, via TWO INDEPENDENT ROUTES. For the defect to show, the actor must end up on the other side of the seam — and the binary produces that twice over. (a) DERIVED: the spawn draw builds the coordinate by adding in 8 bits, i.e. with wrap, and explicitly contemplates the "near from the other side" case in order to reject it (its comparison against 0xfa); a draw that knows how to wrap places on the other side. (b) MEASURED: the mover MAINOUT.OVL:0x1578 adds in 16 bits and stores truncated (0x16c7 mov al,[bp-6] / 0x16ca mov [si+0x5c5c],al, no clamp, no check), so the stored coordinate wraps modulo 256. Any actor moved by that routine can end up on the other side. NO LIVE WITNESS: nobody has seen it happen with the binary running. The exact formula this row upholds is mechanism measured, reachability measured and derived, no witness. Honest loose end, travelling with the row: the identity of the occupant of slot 1 comes from the port's comment and from reading two other consumers, not from reading the assigner. Should that slot turn out to be reserved for something that never approaches the seam, piece 2 falls and the verdict returns to "theoretical". It is written down so the thread can be pulled without redoing everything. Port: NOT MODELLED — there is no per-OBJECT proximity hook in the clone (verified 2026-08-06). ⚠ And there is a look-alike that misleads: the clone does have proximity, but per TILE and from another routine (ambient_sfx_tick 0x4102 → game/src/core/sfx.ts:301, game/src/skin/coreview.ts:780). That is not this one. Whoever ports it inherits the whole problem: implementing either rule without adjudicating first is choosing blind.

The weighted creature roll never checks its table's length

MEASURED

(MAINOUT.OVL:0x0e04, weighted_pick, 74 B, whole body read). It rolls rand_range(0,255) once and walks the weight table subtracting while weight <= remainder, returning the first index whose weight is strictly greater. The loop compares against no length whatsoever (0x0e33-0x0e3d: mov al,[bx+si] / cmp ax,dx / jbe, with no bound counter): if the weights summed to less than 256, the walk would run off the end of the table and return a made-up index. Correctness does not live in the code — it lives in the data. Same family as the root of TALK: routines that trust their input to be perfect. 🔴 And the terminator is not a safety net. A weight of 0 never stops the loop: the continue condition is weight <= remainder, and 0 <= remainder always holds. The 0x00 bytes closing two of the tables would be walked straight past, not treated as a cap — the only thing preventing the overrun is the exact sum. Reachability: MEASURED AND NEGATIVE. The four tables its single caller passes (tile_to_monster, MAINOUT.OVL:0x0e4e, at 0x0eb8/0x0eca/0x0f2c/0x0f3c, pointers DS 0x2BDC · 0x2BE8 · 0x2BF0 · 0x2BF6) each sum to exactly 256, read byte by byte from DATA.OVL (fileoff = DS+0x10): 12 weights 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 and 2 128+128. And each table's length is exactly the gap to the next pointer, which is the control that bounds them without assuming them. With the roll capped at 255 the index always lands inside, and the two 0x00 terminators are never reached. ⇒ with factory data the defect has no live path. What holds it is the DATA, not a guard — one edit lowering a weight wakes it up. Port: does NOT copy it: it adds the bound the binary lacks (game/src/core/combat/encounters.ts:170, while (i < weights.length && weights[i] <= roll)). A declared divergence, and not observable with the factory tables precisely because the binary never reaches the edge. The four tables travel byte-dumped in that same file (SPAWN_TABLES, lines 148-156) and a test blocks them from drifting off data.json. Grade (PROPOSED on 2026-08-10 from the cited evidence): MEASURED, and here the evidence carries the word literally. The ledger row declares "CUERPO ENTERO LEIDO 0x0e04-0x0e4e (74 B, 35 insn, ret 2 = ONE argument … cut VALIDATED against the size)" and closes with "I do not adjudicate whether some real table sums short —I have not censused them—; what is MEASURED is that the routine does not defend itself". Reachability was closed afterwards, in another body reading, measuring the four tables against the IMAGE, and that note also fixes the grade by equivalence: it is "the same grade card #23 gave to the rand_range division by zero" — mechanism MEASURED, reachability measured and negative. Record in prose: re/notes/asm100-censo-acta.md §58.2. No WITNESS.

"Level 254": the dungeon floor can be decremented from the Underworld sentinel

MEASURED

(candidate mechanism for the "level 254 in Doom" that two independent community witnesses report, r/Ultima thread j3dwk0 — no published reproduction recipe). g_floor (DS 0x5895) uses 0xFF as the "Underworld" sentinel, and 0xFF − 1 = 0xFE = 254. The WRITERS of g_floor were censused across the whole disassembly (inc/dec/add/mov byte patterns): the three dungeon-context decs — DNGLOOK.OVL:0x1077 (climb up a floor), DUNGEON.OVL:0x1dc6 (combat-room exit with result 5 = ladder up) and change_level's delta −1 add (DUNGEON.OVL:0x1cc1, guard at 0x1c8d) — guard only against g_floor == 0 (cmp …,0 / je), never against the sentinel: with 0xFF in the variable all three pass and write 254. The mechanism now has a WITNESS: in the running binary (headless dosbox-x oracle, own run dir), with the sentinel seeded in dungeon context a (K)limb-up leaves g_floor=0xFE (=254) and the next one 0xFD — probe re/tools/level254_probe.py, with a positive control in the sane domain (7→6) and the writer's bytes (0x1cc1) and its guard (0x1c9a) verified in RAM at DUNGEON.OVL's load base. And the live path got derived: it does NOT EXIST with factory flow and data. The invariant "dungeon location ⇒ floor 0..7" is preserved by all 24 g_floor writers and all 33 g_location writers: every entrance normalizes (MAINOUT 0x088f-0x08b4; Doom 0x28 special-cased to floor 0 at 0x0896), every exit writes layer AND location in the same routine (dng_exit 0x1d08), and the combat/scene/slot restores are symmetric pairs (full census: re/notes/level254-camino-vivo-acta.md §3). The only gate is SAVED.GAM, which carries g_location/g_floor raw (offsets 0x2ED/0x2EF) and is loaded without validation — exactly the region the reddit thread documents as corrupted by a save editor: the "plausible confounder" turns out to be the only VEHICLE, and the two witnesses are explained without a live path. The row stays in §4 (its condition for moving to §2 was a live path being derived; its absence was derived instead). OpenU5 cannot reproduce it by construction: the dungeon floor lives in per-dungeon state (game/src/core/dungeon/dungeon.ts:930-941, <= 0 / >= N-1 guards) and the Underworld sentinel does not share a variable with it (dungeon-cmds.ts:465); an edited .GAM does not smuggle it in either (the reader disambiguates the overloaded byte, saveNative.ts:459 → basement −1). Structural divergence, declared and unobservable without an exogenous file: not restored (contrast with Thrud/Mario, which had live paths; same treatment as this section's spawn tables). Grade: MEASURED mechanism, with WITNESS (probe, 2026-08-27); reachability derived and negative (level254 acta). bugs-original lane 2026-08-26 · level254 lane 2026-08-27.

Camping stores the MONTH of the apparition in a byte nobody reads.

MEASURED

Right before calling the apparition scene, the (H)ole up results helper does 04fc: a07d58 mov al, byte ptr [g_month] / 04ff: a28d58 mov byte ptr [0x588d], al (CMDS.OVL, camp 0x0000-0x054e). 0x588d is never read. The 28 .asm were censused resolving the 157 symbols of re/ledger/globals.json and adding the decimal offset of every [g_sym+N] reference — the disassembler emits symbol+offset in decimal, so a grep for 0x588d returns a false zero — plus the raw hex, and excluding the encoding column: 1 reference, the write. Controls from the same census, with the same predicate: the neighbour 0x588c (the real cooldown) comes out at 4 — the two reads at CMDS.OVL:0x03ea and 0x044c, the 0x0e reload at 0x0505 and the hourly decrement at ULTIMA.EXE:0x4fd7 — and 0x588e (g_time_spell_turns) at 13 across 7 files; and the single discarded hit is SHOPPES.OVL:0x0f4b, where 588d are the encoding bytes of a call, not an operand. 0x588d has no entry in the globals ledger either: the census jumps from 0x588c to 0x588e. Its neighbourhood and its value give the intent away — a per-month apparition cadence, alongside the 14-hour cooldown that is wired — but the real gate is only rand(0,99) < 25 (§5.9) and nothing consults the stored month. Reachability: NONE by construction — a byte that is only ever written has no observable conduct; it travels to the .GAM with its block and stays there. It is recorded here because it is the residue of a mechanic that was never finished, not a defect anyone suffered. OpenU5 does not clone it (there is nowhere to: the port has no such byte), and no lock is needed. Grade: MEASURED (the write read raw; the absence of readers by census with positive and negative controls). bugs-25-28 lane, 2026-08-29. ---

5. What we investigated and is NOT a bug

A bug register without this section is propaganda. Everything here reached the table as a candidate and left discarded.

5.1 Deceit and Despise share a slot — that is DESIGN

refuted

Eight dungeons and seven bitmap slots looks like an oversight. It is not: DUNGEON.CBT is exactly 39,424 bytes = 112 records = 7×16, not 8×16. The data file confirms there were always seven. It is reproduced, and it does not count as a bug.

5.2 Rel Hur has no bug: the bug would be ours

refuted

The wind spell remaps the direction before applying it, and that remapping is correct in the binary. We note it here because anyone porting it by passing the argument through unchanged swaps the four cardinal points in pairs — and the failure would be silent. It is a porting hazard, not a 1988 defect.

5.3 The shops do not mis-compare upper case

refuted

It was written at one point that the blacksmith's menu echoes lower case while the handler compares upper case. The measurement says otherwise: 53 comparisons against upper case and none against lower case across the whole shop territory. The explanation — that the key reader normalises — is labelled as inferred by its own author and has not been derived. With no mechanism, there is no bug to declare.

5.4 Room plates that never fire are the mechanic working

refuted

In 35 of 128 dungeon rooms there are triggers whose cell is impassable in combat, so they never activate. It sounds like broken data; it is the normal consequence of the rules — the plate only fires on a move that succeeds, and that cell cannot be entered. OpenU5 reproduces it faithfully without doing anything special.

5.5 Attribute overflows are not 1988 bugs

refuted

There is a family of formulas that behaves absurdly with very high attributes: haggling reaches a negative price (the shop pays you) with intelligence ≥ 67, the troll toll gives you gold with strength 99, a character with dexterity 99 ends up nearly immobile and untouchable.

None of them is reachable in play. The game's real attribute ceiling is 30 — the shrines impose it, and character creation does not come close — and all these formulas port correctly up to 30. They only bite with an edited save. They are documented as a curiosity, with their regime declared, and not as defects anyone suffered.

5.6 The two-handed hammer does NOT reach the whole battlefield — not in v1.16

refuted

The claim circulates in the community ("the 2-handed hammer can hit any square on the battlefield in that version", a comment in the Thrud thread, r/Ultima 130pa6f) with no source beyond newsgroup memory. Against our bytes it is false for v1.16: the per-weapon range table (DS 0x1664, DATA.OVL fileoff 0x1674) carries 0 in entry 31 (2H Hammer, fileoff 0x1693, byte read raw), and the mechanics are measured — range 0 takes the melee path with the cursor bounded to the adjacent ring (COMSUBS:0x0C52, range==0 → cursor 0x0504 with range=1; re/notes/comsubs-ataque-jugador.md §2, which also measured that the weapon table contains not a single 1). The range-15 ("whole screen") entries belong to the Magic Bow, the Magic Axe and the seven scrolls — the hammer is not among them. The witness's "in that version" may refer to another version or platform (not censused here); in the 1.16 this port clones, it does not exist. The port uses the same extracted table (data.json attackRangeValues[31] = 0) and needs no change and no new lock.

5.7 The Shadowlord does not steal "food or gold": it steals from five drawers, and food is NOT one

refuted

The community takes it for granted that Falsehood's urban theft — "An air of falsehood doth surround thee… Something was stolen!" — takes food or gold (r/Ultima thread 1nta3yg). Half the belief is true and the other half is false, and both halves are in the same stretch: TALK.OVL:0x1180, called unconditionally at the end of every town conversation (0x1305).

What is true: only Falsehood steals. The gate is 1187: 803e585900 cmp byte ptr [g_shadowlord_here_idx], 0 / 118c: 7403 je — with Hatred or Cowardice present it leaves via 118e: e9e700 jmp 0x1278 without touching anything, not even the stream.

What is false: there is no "food or gold", there is a five-priority cascade, and gold is the LAST one. Read row by row, with the pointer each branch pushes:

prioritywhatpointerrolls
1keys · gems · torchesDS 0x57ac / 0x57ad / 0x57aerand(0,2) per attempt, and it re-rolls if the category is empty (the three je at 0x11e7/0x11fd/0x1209 jump backwards, to 0x11c7)
2equipment, highest non-empty indexDS 0x57c0, 48 slots, 1210: be2f00 mov si,0x2f counting down0
3potions, highest indexDS 0x5828, 8 slots0
4scrolls, highest indexDS 0x5820, 8 slots0
5goldDS 0x57aa (1262: b8aa57 mov ax,0x57aa)rand(1,15), floor 0

Food appears in no branch, and it is not a matter of framing: g_food sits at DS 0x57a8, two bytes before the gold that does get stolen. The census of the stretch, with its control: g_food appears 0 times in TALK.OVL.asm — against 8 times across 5 files in the rest of the corpus, which is the positive control that the disassembler does resolve that symbol — and the file's only raw 57a8 is 06b8: b8a857 mov ax, 0x57a8, which lives in the TLK script opcode jump table (06a2: sub ax,0x41 / cmp ax,0xa), i.e. the branch that gives food rather than taking it, and with the add-with-ceiling helper (9999, 0x270f). ⇒ the overlay knows the food pointer and the theft routine starts at gold on purpose.

A mechanical consequence the popular belief hides: carrying a single torch protects your gold entirely — branch 1 spends the torch and leaves via jmp 0x1278, never reaching branch

  1. That is not a bug: it is the cascade doing what it says. (Full derivation and wiring in

re/notes/shadowlord-urbano-acta.md §2; the port has it in game/src/core/world/faulinei-theft.ts.)

5.8 MIXED overworld enemy groups DO exist in the DOS version — and here is the byte

refuted

The community has gone years without settling it: does the DOS build generate mixed groups — skeletons with mages, headless with ettins — the way the C64 does, or is that exclusive to the 8-bit versions? Thread r/Ultima 1kyqi90 (2025) has contradictory witnesses ("100% sound, does happen in PC version" against never having seen it in DOSBox). In the DOS binary it is there, and it is a single instruction.

combat_spawn_encounter (ULTIMA.EXE:0x6bc2) places spawn #0 with the base type and then walks i = 1 … count−1. Every iteration starts by setting the base type (6d20: 8b4604 mov ax,[bp+4] / 6d23: 8946fe mov [bp-2],ax), and only inside an initial window of count/4 + 1 spawns (6cf7-6d09, the signed divide-by-4 idiom, +1) does it roll 6d2b: b80800 mov ax,8 / 6d2f: e87ccd call 0x3aae = rand(0,8) — nine outcomes — and with ax == 0 (6d32: 0bc0 or ax,ax / 6d34: 750c jne) it substitutes the type:

6d36: 8b5e04    mov bx, word ptr [bp + 4]
6d39: 8a87d416  mov al, byte ptr [bx + 0x16d4]     ← the COMPANION table, stride 1
6d3f: 8946fe    mov word ptr [bp - 2], ax

DS 0x16d4 = DATA.OVL fileoff 0x16e4, 48 bytes, one per enemy type. Dumped raw, the entries that settle the argument are exactly the two that were being argued about:

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

And the group size comes from DS 0x13c2 + type*8 (6c85: 8a87c213 mov al,[bx+0x13c2], with bx = type<<3): SKELETONS and HEADLESSES carry 8, one of the three exact values that skip the roll (6c8e/6c94/6c9a: 8, 16, 1). With count 8 the window is 8/4+1 = 3, i.e. spawns 1 and 2 roll ⇒ P(at least one mage in a skeleton ambush) = 1 − (8/9)² = 17/81 ≈ 21 %. Frequent, but not guaranteed: which is why both witnesses can be right at once. And some groups never mix — the types whose entry points at themselves (bats, slimes, mongbats, dragons, corpsers, sand traps, gargoyles, guards): anyone who only ever fought those will never have seen it.

This is not a bug: it is an implemented, correct mechanic, and the port clones it (game/src/core/combat/encounters.ts, ENCOUNTER_FRIEND_TYPE dumped from the binary, and rollEncounterGroup). It is recorded here because it reached the table as a candidate — "the DOS build doesn't do it" — and the binary's answer is the opposite.

Method note, not promoted to a row: the cap at 0x6cc4-0x6cca (cmp 0x19 / mov 0x1a) has no live path: none of the 48 counts in DS 0x13c2 exceeds 16, so count never passes 25 and the mov never executes. It is one more dead branch, of §2.1's family, and it gets no row of its own because its effect would be nil even if reached.

5.9 The Ankh does not change the camping apparition's odds: it is a flat 25 %

refuted

A player suggests that equipping Ankhs raises the chance of the figure appearing while camping — the one that heals, wakes and levels you up — with a home test of 9 hits in 20 tries with 6 Ankhs (r/Ultima thread w2huwh). The gate is in CMDS.OVL, inside the results helper of camp() (0x0000-0x054e), and it reads three things, none of them inventory:

04e7: f6460882  test byte ptr [bp + 8], 0x82   ; camp flags
04eb: 7518      jne 0x505                      ; flag set → no apparition
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                      ; yes → no apparition
04fc: a07d58    mov al, byte ptr [g_month]     ; (dead byte — §4)
0502: e8d1ba    call 0xffffbfd6                ; the scene: OUTSUBS camp_results 0x0658

⇒ exactly 25 in 100, with no input beyond the context flag. No Ankh, no equipment, no karma, no attributes: the gate's body touches neither g_equip_qty (DS 0x57c0) nor any roster field. The camp loop does read equipment elsewhere and for something else — the Ring of Regeneration regenerator (index 0x2c), kernel 0x400c, called from CMDS.OVL:0x0207 twelve times an hour, derived in re/notes/camp-ambush-spec.md §0 — and that adds HP: it moves no probability.

What does modulate the observed frequency, and explains a 9/20 with no Ankhs involved: (a) the g_unk_588c cooldown, which the routine itself reloads to 14 (0505: c6068c580e) and which is decremented once per game hour (ULTIMA.EXE:0x4fd7 → saturating subtract 0x3f36), so the two header checks (0x03ea and 0x044c, cmp …,1 / jb) block the whole helper — partial healing included — until it expires; and (b) the ambush, which cuts the camp short before the gate (early return ax=1, re/notes/camp-ambush-spec.md §3). Over 20 tries, the deviation from a flat 25 % does not even leave the noise. It is neither a bug nor a hidden mechanic: it is a 25 % coin.

5.10 The two axes and the axe on the head: the player never picks the slot

refuted

Two community observations travel together (r/Ultima thread w2wfgu): a screenshot from the official book shows two magic axes equipped and the game prevents it ("it un-equips when you pick the stack"), and with a memory editor you can put an axe in the HEAD slot and attack three times. Both are true, neither is a bug, and the binary explains both with the same fact: the (R)eady routine is never given a slot.

try_equip_or_unequip (ZSTATS.OVL:0x0c5c, ret 4) takes only (equipId, charIdx) and derives the destination from a per-item CLASS table, DS 0x1a7e (= DATA.OVL fileoff 0x1a8e, 48 bytes), read at 0d94: 8a877e1a mov al, byte ptr [bx + 0x1a7e] and dispatched by six consecutive cmps (0d9a-0dc5). Dumped raw:

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
classcharacter-record slotoccupancy check
0x80 helm+0x19 (0x55c1)0dce: cmp byte [bx+0x55c1],0xff
0x40 armour+0x1a (0x55c2)0e53
0x20 one-hand+0x1b / +0x1c0e71: call 0x0c0a (free hands)
0x30 two-hand+0x1b, demands both0ea1: call 0x0c0a, cmp ax,2
0x04 amulet+0x1e (0x55c6)0ec3
0x02 ring+0x1d (0x55c5)0ee5
0x00 not equippable—silent reject at 0c82 (only ids 27 Arrows and 29 Quarrels)

⇒ "putting an axe on the head" is not rejected: it cannot even be proposed. Every weapon carries class 0x20 or 0x30, so its only possible destinations are the hands. That slot can only be changed by writing roster byte +0x19 by hand, exactly as the report says.

And the two axes need no anti-duplicate guard either, because there is none. Before any equip logic, 0cb4 calls is_equipped (ZSTATS.OVL:0x0518), which compares the item id against all six record slots (052d, 0537, 053f, 0547, 054f, 0557 = +0x19…+0x1e) and returns 1 on the first match; with that, 0cbf takes the un-equip branch (0cc7: call 0x8c80). Selecting an item you already wear always takes it off, whether you own one copy or ten ⇒ the same id can never occupy two slots. The book's screenshot is reachable only by editing memory.

★ And what the report reads as an exploit is the normal mechanic: the player's attack ALWAYS walks THREE slots. COMSUBS.OVL:0x0d96 has neither loop nor condition — three literal blocks in a row: 0df8: mov al,[si+0x55c1] (helm), 0e05: [si+0x55c3] (hand A) and 0e12: [si+0x55c4] (hand B), each with its call 0xd3c. Armour and ring/amulet are never read. Each slot's filter is 0d49: cmp byte [bx+0x15fc],0 / je 0xd91, i.e. "does that item have an attack value?". And with the attack table in hand (DS 0x15fc = DATA.OVL fileoff 0x160c) there is legitimate gear that attacks from the helm and from the shield: entry 3 = 0x04 and entry 6 = 0x06, which by the class table are helm (0x80) and one-hand (0x20). With the spiked helm, a weapon and the spiked shield you get three blows without touching a single save byte — and there is a witness of the conduct in the original: the banner "armed with Spiked Helm, Magic Axe" from a recorded playthrough, noted in re/notes/fenton-piloto.md. What the editor adds is not the three blows: it is which item sits in +0x19.

(The six reject strings are cited by offset and not verbatim, on purpose: they are EA's text and this register cites bytes.) The port mirrors all three — TYPE_TABLE dumped from the binary at game/src/core/equip.ts:48, the toggle-off by id, and ATTACK_SLOTS at :140 — so there is no divergence to declare either.

5.11 The unused combat map exists, it is BRIT.CBT record 9, and it cannot be opened

refuted

u/raldi reports (r/Ultima thread 13qddnq) an unused combat map in the game files, with a trigger on the WEST wall that opens a passage. It is there, and the description is exact.

BRIT.CBT is 5,632 B = 16 records of 352 (0x160, the same size ULTIMA.EXE:0x60ec loads with 60fc: mov ax,0x160). In record 9 — the one the port calls Basement — column x=3 is the west wall, 0x4F StoneBrickWall on all eleven rows except at (3,5), which is 0x4E StoneBrickWallSecret, the only one in the whole record. And all eight of its triggers have at = (3,5):

#stamped spritedest 1dest 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)

⇒ stepping on the plate carves a brick-floor corridor WESTWARD through columns 0-2 on rows 4-6 — until then 0xFF BlackSquare, void — and walls it off above and below with fresh stone. That is literally "a passage that opens".

It is also the only one of the sixteen carrying trigger data. The other fifteen have the block entirely zeroed (at = (0,0), sprite 0x00, destinations (0,0)) — stated with the predicate spelled out, because the naive one ("does the field exist?") gives 16 of 16 and the one that discriminates is "is the block non-zero?", which gives 1 of 16.

And it cannot fire, for three independent reasons, all three measured:

  1. Nobody loads record 9. The only two callers of 0x60ec are ULTIMA.EXE:0x633d — which passes it [bp-2], the arena index from enter_combat_vs_actor (0x6150) — and ULTIMA.EXE:0x6366, which passes a literal 0 (6363: sub ax,ax), the camp-ambush campfire. Censusing every write to [bp-2] in that function gives fourteen immediates and not one is 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). All paths converge on 633a: push word ptr [bp - 2], and those matching no tile fall through 6301 into the 2 branch or the 8 branch. ⇒ of the sixteen arenas, 9 is the only one no path can ask for.
  2. In Britannia arenas the trigger sweep is never even called. The gate is SJOG.OVL:0x1d35 test byte ptr [g_unk_58a1], 0x82 (and inside, COMBAT.OVL:0x1127/0x112e, bits 7 and 1). Censusing the five writers of 0x58a1 across the 28 .asm — DUNGEON.OVL:0x00bf = 0x82, DUNGEON.OVL:0x0c3e and 0x1d9a = 2, ULTIMA.EXE:0x3e65 = 6 (and that one hangs off 3e5e: cmp byte [g_location],0x20 / jbe, i.e. dungeon only) and ULTIMA.EXE:0x5f91, which copies argument [bp+8] of run_combat_encounter 0x5f86 — the two callers of that function pass 0 (the Britannia arena, 6340: sub ax,ax … 6347) and 4 (camping, 6369: mov ax,4 … 6373). 0 & 0x82 = 0 and 4 & 0x82 = 0 ⇒ triggers are a DUNGEON-ROOM mechanism, and in BRIT.CBT they never run. (Which is also why the fifteen zeroed blocks of the other arenas are inert and stamp nothing on corner (0,0): that corner is walkable in nine of the sixteen, and had the sweep run it would show.)
  3. The plate is not walkable. 0x4E StoneBrickWallSecret is impassable, and the only firing path is a successful move onto the cell (SJOG.OVL:0x1d11 → 0x1d42). Same class as §5.4.

⇒ cut content, not a bug: there is no oversight to fix and no dead branch to clone — the record is in the file and no index names it. The port inherits it as is (all 16 records are extracted whole into game/assets/maps/combatmaps.json, and CombatMapIndex.Basement exists in game/src/core/combat/encounters.ts with nothing selecting it), which is correct: a byte-exact extractor does not decide which data deserve to travel.


§6 «Status and pending work» is omitted: it is metadata about the document itself —which mirror overwrites which when the site is assembled— and describes no defect of the 1988 game. The complete register is in the repository.