OPENU5 Home ES

These pages are generated from the project's internal documentation. References to source files and binary offsets are kept as provenance — so you can see where each claim comes from — but they are not followable yet: the engine is not published. Once it is, they will become links.

1 · Image improvements

Everything that changes what you see: the smoothing, the two skins, sprite and tile transparency, typography, and lines and outlines.

Tags: 🎯 FIDELITY (the "before" was our own bug) · ✨ ADDED (the original did not do it) · ⚖️ DECLARED DIVERGENCE · 🗑️ REMOVED. See the overview.


1 · The two skins: "1988" and "smooth"

ADDED · Code: game/src/skin/fiel/skin.ts, game/src/skin/shader/skin.ts, game/src/skin/manager.ts · Switched live with F9 or from ⚙ → Video.

Before

The original has a single presentation: 16-colour EGA, 320×200, fat pixels. There is nothing to choose.

Now

There are two skins, interchangeable at runtime, and one is a replica of the other:

Why it matters

That the shader hosts the faithful skin instead of duplicating it is the decision holding up everything else: the animation clock, the combat overlays, the moongate, the earthquake, the Ztats modal and the audio remain the faithful skin's. When a defect is fixed in the faithful skin, the shader inherits it without a line being touched. The 1988 replica never forks.

And filtering only the viewport is not laziness: text and chrome live outside that rectangle, so the filter cannot touch them. An upscaler run over the 8×8 font would have turned it to mush.

Trade-off


2 · The smoothing: xBR on the GPU

ADDED · Code: game/src/skin/shader/xbr-gl.ts, upscaler.ts

Before

Nearest neighbour. One pixel of the original is an N×N square of screen pixels. It is what the "1988" skin does, and it is the right thing for it.

Now

The viewport goes through xBR level 1 with Hyllian's edge-detection rule (weighted difference in YUV, weights 48/7/6), ported to GLSL and run on WebGL1. The pipeline chains two 2× passes (= 4× internally) and leaves the 4→6 stretch to the bilinear blit.

Why it matters

An edge filter does not "blur": it reconstructs the diagonals that the 16-pixel grid could not represent. Measurement over the split layout gave 12.8× more distinct colours in the viewport than the faithful skin (2026-07-28) — the filter is working, not decorating.

Trade-offs, and there are several

  1. Licence: the kernel is the project's own port of extractor/src/upscale/xbr.ts, not the GPL .glsl from RetroArch that the spec recommended. The deviation is declared in the header of xbr-gl.ts and was reported to the publication lane.
  2. No WebGL, no filter: there is a NearestUpscaler fallback that scales to 6× by nearest neighbour. The shader skin stays mounted and switchable, but it looks identical to the faithful one. It is a silent degradation: nothing warns the player.
  3. What gets cut out loses the smoothing. Sprites recomposed from the EGA atlas (§3 and §4) are blitted with nearest, so the cut-out sprite does not carry xBR. It is recorded as an inherited caveat, not as a regression.
  4. Collapsed backbuffer if the host measures 0×0. Hosted inside another layout with no size, the shader computes ceil(320/320) = 1 and stays at 320×200: no smoothing, no error. Diagnosed and resolved later. It is the most treacherous failure mode of all, because the game works perfectly and the only thing wrong is the single reason anyone chose this skin.

3 · Transparency I — the body at alpha .55

ADDED · Shader skin · Kill switch ?transp=off

Before

Nothing in the port applied alpha < 1 to the body of a tile or an actor. Actors already had their black background cut out, but the body was solid: a ghost hid the floor just like a troll.

The census first, the wiring after

Before a pixel was touched, a census of candidates was made over the 529 tiles of TileData.json, with impact / coherence / cost and — the column that decides — whether the original reference already hints at it (DOS) or it is purely aesthetic (Licence). The A/B mock-up was rendered from the real HD atlas before choosing:

Transparency candidates — A opaque / B alpha .55 / C glass-tint

The user chose column B (body at alpha .55), and that is what is wired.

Now — first wave

FamilyTilesRoute
Force fields (Poison/Magic/Fire/Electric)488-491terrain: synthesised floor + body at .55
Ghosts412-415actor: ActorTransparency.bodyAlpha
Wisps468-471actor
Shadowlords508-511actor

And in the second wave, the shadowlord boundary (112-127, as "mist") and the blue flame (222).

🗑️ What this part used to show, and why it no longer does

There was an A/B comparison of the translucent moongate here. It has been removed, because the effect no longer exists.

The moongate was wired in the first wave and the user ordered it taken out on 2026-07-22 ("the moongate must not have transparency inside", with two screenshots). It is in the code, at the exact spot where it used to be painted:

// MOONGATE: RETIRADA del censo de translucidez por VEREDICTO DEL USUARIO

The gate keeps the opaque painting of the faithful layer. Tile 220 is still in isCensusTerrainTile, but only to exclude it as a floor donor for neighbouring fields.

How it was caught, which is the reusable part: by capturing both branches of the A/B — default and ?transp=off — and comparing the sha. They came out byte-identical. A kill switch that does not change a single byte means the thing it switches off is gone. Reading the code was not needed to suspect it; only to confirm it.

The original screenshots are still in their verdict folder: they are true as of their date, and they are the record of a work lane. What cannot be done is cite them in the present tense.

Why it matters

It is the canonical use of translucency: a ghost that looks solid is a ghost that is not frightening. But the relevant decision is not that one, it is the one next to it —

Trade-offs and rejections


4 · Transparency II — the outline cut-out

ADDED · Shader skin · Kill switch ?contourTransp=off

This is the other treatment, and it is not the same as the previous one. Here the body's alpha is not lowered: the outer black pixels of the sprite are replaced by transparency (the ones connected to the cell edge, by flood fill), and the inner blacks are kept. The sprite stays opaque; what disappears is its background square.

Before / after — the fountain in Lord British's castle

?contourTransp=offdefault
fountain with its black squarecut-out fountain

On the left, the grid floor is cut off in black at the corners of the tile. On the right, the grid continues through the corners and only the water and the basin remain.

The underlying problem, and its solution

The fountain is baked by the faithful skin with no per-tile hook: there is nowhere to attach. The way out was to synthesise the floor underneath with dominantFloorNeighbor — of the four orthogonal neighbours, the floor tile that appears most, copying its already-rendered (xBR-exact) pixels before blitting the cut-out.

The regression that appeared, and why it is instructive

On wiring the cut-out, the fountain stopped animating. terrainWindow carries the raw map tile (fixed base frame, 0xd8); the faithful skin animates the fountain at draw time, with animatedFrame(tile, phase, groups). The cut-out blitted the raw tile, so it covered the animated fountain of the world layer with a static frame. A visual fix that freezes what was alive.

The repair: the cut-out derives the live frame using the same phase as the faithful skin (FaithfulSkin.animPhase). The four frames, cycling with the cut-out active:

Four frames of the fountain cycling

And a measured regression guard was left behind (SAD between distinct frames = 133 454, i.e. ≠ 0) in game/tests/contour-transp.test.ts.

Extension to the fires, which do not animate the same way

Braziers (0xb2), torches (0xb0/0xb1), bonfires (0xb3), hearths (0xbc–0xbf) and the blue flame (0xde) do not cycle their id: their flicker is procedural fn32 noise on a live canvas. The cut-out treats them differently — it synthesises the floor, and then cuts the live flame canvas by the tile's static silhouette (destination-in), so the flicker survives and the background is removed.

?contourTransp=offdefault
brazier with black squarecut-out brazier

What is NOT touched: its contribution to lighting (lightLevel / visMask). Only the painting. A presentation fix that had moved the lighting would have changed the game.

Trade-offs


5 · The rule that cost two user screenshots: "never a wall as background"

ADDED (correction to an addition) · Code: game/src/render/floor-underlay.ts

The "dominant neighbour" of §4 had a defect you only see by looking: if the cell's neighbours were walls, it synthesised wall as floor, and the cut-out painted brick where the player sees stone.

Two rules, each with a visual witness from the user behind it:

  1. Walls are not candidates for floor. isFloorUnderlayCandidate: a candidate is floor if it is walkable on foot or navigable (grass, brick, wood, carpet, bridge, stairs, lava, water). Walls, dry stone, doors and windows are out. With three walls and one floor around, the floor wins: the walls do not even count. All walls ⇒ null ⇒ cell untouched. Never a wall as background.
  2. Wall-mounted fires are not cut out. The left/right sconces (0xb0/0xb1) and the hearth (0xbc) already carry the wall in their graphic; cutting them out painted brick behind. They are left with their whole cell baked, and the faithful skin keeps supplying the flicker. Floor fires (brazier 0xb2, bonfire 0xb3, lamp post 0xbd, blue flame 0xde) are cut out. The ambiguous ones (0xbe candle on a table, 0xbf cooking fire) were excluded on the same principle: the background must be what is actually behind.

Verification — inside the keep of "Calma"

?contourTransp=offwith the fix
Calma, outline offCalma, outline fixed

The brazier blends with the brick (not with the white stone of the border) and the sconces stay identical between the two screenshots, because they are excluded from the cut-out.

Why it matters beyond its own lane

dominantFloorNeighbor is a shared helper used by the fountain, braziers, fields, moongate and boundary. Fixing it here also corrected what had already landed from the previous lane. A bug in a shared function has no single owner: it has all of them.

Trade-off: the improvement is deliberately UNEVEN

The price of rule 2 is that two adjacent fires look different. In a room with a floor brazier and a wall sconce, the brazier blends with the brick and the sconce keeps its baked square. To someone who does not know why, it looks as if the effect "fails sometimes".

It was accepted knowingly, and it is the right choice: the alternative — cutting out the wall fires too — was worse and also false, because it painted brick where the player sees stone. Between a uniform effect that lies and an uneven one that tells the truth about what is behind, the second was chosen. When the real background is the wall, the correct answer is not to cut out.


6 · Tiles: the corpse that was desert

🎯 FIDELITY · Code: game/src/skin/coreview.ts (arenaLoot)

Before

On the cell of a troll just killed on a bridge, no corpse appeared but "a mix of grass with transparency": green and yellow flecks over the planks.

The cause

COMBAT:0x1574 stores in the arena's object table the low byte of the remains: corpse 0x1E, blood 0x1F, chest 0x01. The renderer blits them from the high bank, with +0x100. arenaLoot painted the raw byte, without that offset — so out came their low-bank twins:

bytelow bank (the bug)high bank (the faithful one)
0x1ELeftDesert2 (desert)0x11E DeadBody (corpse)
0x1FRightDesert2 (desert)0x11F Splat (blood)
0x01Water1 (water)0x101 Chest (chest)

Desert tiles — green flecks on black — painted as an entity with a transparent background over the planks. Exactly what the user described.

Before / after

Low bank vs high bank

Top row, the low bank (the bug); bottom row, the high bank.

Why it matters

It is the kind of defect that does not look like a defect: it looks like a strange effect. The witness did not say "the corpse is missing", they said "a desert with transparency comes up" — and that is precisely the symptom a lost 0x100 offset produces. Without the user's literal description, the search would have started in the wrong place.

Trade-off: the fix is three bytes, not the family

lootTiles() keeps its low-byte contract because the tests and open_chest consume it: the +0x100 is added only at the painting site. That is right — changing the contract would have broken the other consumers — but it leaves the asymmetry alive: there are two bank conventions in play and you have to know which one you are in.

And the fix does not cover the whole family: the gargoyle (0x4C) is still deferred, because it is another layer (map terrain, not an object in the arena table) and is not solved by the same offset. It is recorded as deferred, not as resolved.


7 · Typography I — the equipment icons came from the wrong font

🎯 FIDELITY · Code: game/src/skin/fiel/skin.ts (drawReadyPicker)

The report

"the font icons in the template are NOT the game's, we need to look at that properly, we have some error"

What was NOT broken (ruled out byte by byte)

Before touching anything, the obvious was checked, and the obvious was fine:

The real bug

The original prints each equipped item's class glyph as an attribute character from the runic font, not from IBM. The control band of RUNES.CH carries equipment dingbats (helmet, shield, cuirass, weapons, ring); the one in IBM.CH carries triangles and box pieces. The port wrote the code into a cell that the skin rasterises with IBM ⇒ all 48/48 items rendered a different glyph from the one DOS shows.

The corollary confirms it: 0x09 (Thrwng Axe) is an empty glyph in IBM.CH — hence the "BLANK!" that had puzzled an earlier lane — but has a real glyph in RUNES.CH. No code in the table is empty in the correct font.

And what stays in IBM, verified

The picker's scroll arrows (0x18/0x19/0x12) really are IBM in real DOS: in the reference it is a clean ↓ = IBM.CH[0x19]. The scroll frame and the row text too. The fix uses the highlightCols that layoutReadyPicker was already computing and ignoring, to draw only the class-glyph cells from the runic atlas.

Trade-off: under the smooth skin these glyphs are not smoothed

The shader skin inherits the fix through the nearest blit of the faithful base, not by repainting it. That is: the equipment icons come out correct, but unvectorised — unlike the scroll arrows of §11, which are repainted vectorially. On the same screen a smooth element and a hard-pixel one coexist.

It is the cheap choice and a defensible one (a hand-vectorised 8×8 dingbat for 30 codes is a lot of work for a tiny icon), but it is a real unevenness of the smooth skin, not a coincidence.

🔴 A note on the state of the material

The record cited an image (red column = IBM, green = RUNES) that was never committed: the folder contains only the record itself. The adjudication does not depend on it — it rests on the byte-level comparison above — but the reference was broken. It is now annotated in the record itself (2026-08-04) so nobody looks for it again.


8 · Typography II — the body of signs: Latin script or runes

🎯 FIDELITY · Code: game/src/skin/fiel/sign-box.ts (layoutSignBox, runicBodyCells)

Before (Latin)Now, by default (runes)
sign in Latin scriptsign in runes

Real DOS paints sign bodies in runes, with the digraphs collapsed (TH=0x5b, EA=0x5e, ST=0x5f, NG=0x5d, EE=0x5c) — which is why "NORTH", "EAST" and "TRINSIC" come out more compact than in Latin script. The port used to do it in Latin; now it does it in runes.

It was decided in favour of fidelity (verdict #25): layoutSignBox carries const runicBody = opts?.runicBody !== false — that is, runic unless you opt out, and the Latin form survives only for the tests of the legible variant.

What saves legibility

The console log keeps the Latin script. The box is faithful in runes and the text is still readable in the history. Maximum fidelity where you look, legibility where you read — which is why the decision cost nothing that hurt.

A method detail worth keeping: the box is sized to the already composed body and capped to the panel width (interior 14, derived from SIGNS.DAT). Since runes collapse digraphs, the same text takes less room: "PRIVATE ISLAND" fills the panel exactly, with no air.

🔴 This section said the opposite until 2026-08-04. It claimed the runic body was an "unlanded mock-up" behind a ?signRunic=1 flag, and that the port "stays in Latin script by default". Both were false: the flag does not exist in the tree and runic is the default. It was not a stale figure — it was the inverted outcome of a decision that was actually taken. The census of this document against the code exposed it.

9 · Typography III — the panel font, two rejected mock-ups

Mock-ups, not landed

The idea of a "transparent font" for the panel under the shader skin allowed two readings, and both were mocked up as reversible flags before deciding:

Today's state?mockFont=nobg?mockFont=alpha
opaque glyphs on a black panelglyph with no cell backgroundtranslucent glyphs

Switched off, the render is byte-identical to today's (verified no-op). The record notes that in C the wind band ("Calm Winds") is painted by another route and stays opaque in the mock-up: if this path were chosen, it would have to be unified when wiring it for real.


10 · Lines and outlines I — the scroll band brackets

🎯 FIDELITY

When a Ready picker list overflows its 7 visible rows, the original paints a ► arrow ◄ band below the scroll. In the port, the brackets were separated from the blue band by a black line of 1 logical pixel.

beforeafter
bracket separated by a black linebracket flush with the band

And the ×8 proof of the joint, which is where it is actually adjudicated:

Band–chevron joint at ×8

Two different causes for the same symptom

This is what makes the entry interesting: the same defect had a different cause in each skin, and fixing one did not fix the other.

The faithful skin's result is byte-identical to the DOS reference crop.


11 · Lines and outlines II — the arrow that was a split diamond

🎯 FIDELITY · Code: game/src/skin/shader/skyband.ts

The shader skin repaints the chrome vectorially, and its scroll arrow drew filled triangles ▲/▼. For the "there is more above and below" state (↕) it painted two triangles with a gap between them, which read as a split diamond.

before / after, faithful skinbefore / after, shader skin
faithful before and aftershader before and after

The DOS glyph is not a triangle: it is a thin arrow with a shaft (↑ 0x18 / ↓ 0x19 / ↕ 0x12 of IBM.CH). The fix vectorises that shape by measuring the real bitmap of font-ibm.png — head of half-width 3 (cols 1..7), shaft of half-width 1 (cols 3..5) — and the ↕ becomes one continuous silhouette of a double point joined by the shaft, not two pieces.

The guard was written in the term that matters: the test shader-skyband.test.ts asserts that the ↕ is 1 fill + 1 stroke, not 2+2. A test that counts drawing operations, because the defect was precisely that there were two pieces where there should be one.

The census that followed

The same indicator was missing from the Ztats lists, and there the finding was one of method: the band is not drawn by each list, it is a single kernel (0x6c0a) with no coordinates — i.e. a band fixed on screen — that several commands invoke. The derivations had been done per command, vertically; nobody swept the shared consumer. Ready implemented it when its turn came and there it stayed.

Of the three list scroll bands in the binary, the port covers two. The third is the blacksmith's wares list, and it does not apply: the port serves shops as a console flow, not as the original's framed scroll. That is a recorded presentation divergence, not a missing arrow.

🔴 And there is a fourth place where that band appears without belonging there

In the port, the Ready picker has one more client: the Mix reagent selector, which reuses drawReadyPicker with variant:"mix". So the fix above reached it for free. And with it, something that should not have:

Mix reagent picker

7 reagents and a . The game has 8: Mandrake Root is missing.

In the binary, the Mix reagent panel is not even the Ready picker: it is a routine of its own in another overlay, CMDS.OVL @0x18be, and it paginates nothing.

The port's 7 comes from skin/fiel/ready.ts:56READY_VISIBLE_ROWS, derived from Ready's window (draw_list_frame(8) @ZSTATS 0x12ee) and applied to Mix purely by sharing a renderer.

The same band is the FIX in Ztats and Ready, and the DEFECT in Mix. And the mechanism is not "a constant was inherited": it is that the port made Mix a client of the Ready picker, and the binary keeps them in two separate overlays. Reusing the renderer carried its geometry along — the page cap, and the band that announces that cap — into a case that has neither. A shared widget does not share only the drawing: it shares its assumptions, and nobody decides whether those hold for the new client.

Still open: Mix's window is 9 rows, so with 8 rows of content it has to be repositioned, and the renderer is Ready's — blast radius. Detail in docs/verdicts/mix-flow/README.md.

Corollary, and it qualifies the sentence above: of the three list bands in the binary the port covers two and the third does not apply. But "covered" is not "faithful" — and there is a fourth place, Mix, where the port paints a band that the binary does not paint. The census counted the bands that were MISSING; nobody counted the ones that are IN EXCESS.

statescreenshot
7 items (just fits) — no arrowno arrow
8 items, at the top — only ▼arrow down
8 items, scrolled — ▲arrow up
shader skin — vectorialvector arrow

12 · The transposed zodiac

🎯 FIDELITY

The zodiac plate (the starry sky with the signs) came out with the stars in the wrong positions:

beforeafter
transposed zodiacfaithful zodiac

And the zoomed detail of one sign, which is where the difference in shape shows:

beforeafter
sign, beforesign, after

The folder has no record, so it was measured

That folder carries no write-up. All there was, was a file name asserting "transposed", and a file name is not a derivation. Rather than repeat it as if it were one, the hypothesis was tested against the two images (2026-08-04).

Method: both plates are 528×528 and ~98% black, so comparing pixel by pixel gives 99% agreement with any transformation — the background dominates. Only the lit pixels are compared, by intersection over union (IoU), against the eight symmetries of the square:

Hypothesis: AFTER = … of BEFOREIoU
transposed (aᵀ)0.4897
identity (no change)0.0242
90° rotation0.0160
180° rotation0.0059
vertical mirror0.0040
270° rotation0.0020
horizontal mirror0.0000

The transposition wins by 20× over the next candidate. The file name's hypothesis is corroborated: what was wrong was swapping the two indices when reading the plate.

What this measurement does NOT establish

The IoU is 0.49, not 1.0, and that has to be said: the transposition explains the bulk of the difference, but not all of it. AFTER has more lit pixels than BEFORE (2 394 against 2 178), so the fix was not a pure transposition — there was something else, or the star sprites are not symmetric and transposing them does not map them exactly.

Which of the two, I do not know and will not invent: it would take the lane's code. What remains here is a measured hypothesis with its residual declared, which is rather more than there was.


What this document does not cover