# Handoff

You are picking up a browser-based Doom map editor. This document assumes no
prior context. Read it fully before changing anything — several of the design
decisions here look wrong until you know what they cost to learn.

---

## 0. How to work on this — read this section first

**Every defect in this project's history came from inventing a mechanism instead
of reading one.** Not from carelessness, and not from hard problems: from
plausible reasoning that happened to be wrong. This section exists so the next
person — or the next model — does not rediscover that the expensive way.

Reese's standing instruction, in their words: *"always do everything how udb
does it"*, and *"no skipping — we gotta port things exactly"*.

### The method

1. **Find the reference before writing the code.** UDB is the specification for
   editor behaviour, GZDoom/UZDoom for what the engine accepts, the `specs/*.txt`
   files for the format. `fetch-references.sh` brings all of it down. If you
   cannot point at a file and line for a behaviour, you are guessing.

2. **Read the WHOLE function.** Not the part that looks relevant. The single
   most expensive bug in this session came from reading 7 lines of a 10-line
   function (`Angle2D.Difference`, below).

3. **Port it line by line, including the parts that look arbitrary.** A
   0.1-unit tie-break bonus, a test on which way a neighbouring line turns, an
   inclusive rather than exclusive bounds check — those are the parts doing the
   work. A cleaner version that "does the same thing" reliably does not.

4. **Keep UDB's comments as citations.** Every ported function here carries the
   file and line it came from. That is what makes the next divergence findable
   instead of mysterious.

5. **Do not add guards the reference does not have.** Prudence is not free. A
   visited-set added to `findOuterLines` "for safety" terminated valid traces.

6. **A failing check is data.** When a test fails, the code is the likely
   culprit but not the certain one — twice in this session the TEST was wrong
   (a fixture wound anticlockwise; a height comparison against an unset
   default). Change an assertion only once you can say precisely why the old one
   was wrong, and write that reason into the test.

7. **Where a divergence is deliberate, write down which direction it runs.**
   See the `High/LowRequired` slope note and the CLASSIC-vs-REPLACE scope note
   in `stitch.js`. An undocumented divergence is drift wearing a hat.

8. **Prove a test can fail before trusting it.** Revert the fix and watch the
   test go red. A test that passes either way is not evidence, and this project
   has shipped one: `'a new sector inherits from the geometry it touches'`
   wrapped its whole body in `if (r)`, the trace it guarded on always returned
   null, and it reported ok for a phase while asserting nothing. Mutation
   testing found it — reverting each ported behaviour one at a time and
   checking something breaks. Of 12 reverts tried on `makesector.js`, 4 were
   caught by nothing. Do this for anything ported; it is cheap and it is the
   only way to tell a passing suite from a covering one.

9. **Cite precisely, and check the citations.** Every ported function carries
   the file and line it came from, and that only helps if the pointers are
   right — an off-by-forty citation costs the next person the search it was
   meant to save, and hints the behaviour was never really checked. `citecheck.js`
   verifies that every `File.cs:line` in the codebase resolves to a file
   `fetch-references.sh` actually fetches, and that a curated set of
   load-bearing claims still matches the text at the line cited. Its first run
   found nine wrong line numbers — every claim correct, every pointer off by
   one to forty lines — and one citation to a file that was never fetched at
   all, so nobody could have checked it.

10. **Mirror a table, mirror it with a checker.** `typeparity.js`,
   `specparity.js` and `dialogparity.js` rebuild their tables from the reference
   and fail on divergence. All three were written after a hand-copied table
   turned out wrong; all three found further bugs on their first run.

### The evidence, so this is not just an opinion

Every one of these was a reasonable-looking assumption that measurement
destroyed:

| assumed | actually | cost |
|---|---|---|
| Type behaviour follows the type names | `String`, `Color`, `AngleDegrees` are browseable; `Boolean` and `SectorTag` are enumerable; `EnumBits` is neither | whole table wrong |
| Dialog labels follow field names | `thing.height` is labelled **"Z"**; `special` is "Action" on a linedef but "Special" on a sector | UI/UX parity broken |
| Defaults follow the value's shape | `thing.scale*` default to **0 (ignored)**, not 1.0; three fields have CONDITIONAL defaults and must carry none | silent false negatives in the diff |
| A field's editor type follows its default | vertex `x`/`y` and `thing.height` are **Float** — `AllowDecimal`, not the value | wrong control rendered |
| Hole-cutting can be simplified sensibly | snapping the bridge to an endpoint instead of splitting the edge | 1.5–3% of sectors under-filled → **1 in 19,100** after the faithful port |
| `Angle2D.Difference` is directional | it FOLDS to [0, PI]; the sorter's side-of-line test exists to restore the direction | tracer walked **453 lines for a 4-sided sector** |
| The rightward scan can be summarised | straddle test is INCLUSIVE; `u > 0.00001` not `> 0`; `scanfront` is `< 0` (front is negative) | most traces returned null |
| A visited-set guard is harmless prudence | UDB loops `while(true)` and relies on the scan moving outward | valid traces terminated |
| A Set is equivalent to UDB's LinkedList | UDB holds one node PER ATTACHMENT, so a line can appear twice | flipping a line detached a live vertex |
| `MakeSector` was fully ported | it also calls `TakeSidedefDefaults`, and `RemoveUnneededTextures` on BOTH sides of every line it touches | new sectors had no wall texture; opened walls kept a middle texture |
| A new sector's brightness is the spec default 160 | reading an absent `lightlevel` gives 160, but UDB CREATES sectors at **192** | every drawn sector the wrong brightness |
| `linedef.id` absent means -1 everywhere | UDB's editor reads `id` with a default of **0**, so `id = 0` is untagged and `id = -1` is TAGGED | scripted geometry lost textures, plain geometry kept them |
| Clearing a texture means writing `"-"` | UDB stores `"-"` but its writer OMITS the field entirely | same map, different bytes — the one thing the span model exists to prevent |
| A green suite means the code is covered | 4 of 12 reverted behaviours broke no test at all | a phase reported done on a test whose body never ran |
| Nearest-line distance is distance-to-segment | UDB uses `SafeDistanceToSq`, which refuses to measure from a VERTEX so lines meeting at a corner do not all tie | "nearest" was a coin toss at exactly the corners stitching lands on |
| A 3-argument call to a 5-argument port is obviously wrong | it binds silently — `turnatends` became `endline`, every trace returned null, every `frontInterior` fell back to true | looked exactly like a winding bug for an hour; it was a typo |
| Disposing a line leaves its vertices alone | `Vertex.DetachLinedefP` sweeps a vertex whose LAST line just left | drawings left orphan vertices in the saved file |
| A citation's basename identifies one file | `refs/` carries **18** `BuilderPlug.cs`, one per plugin. `citecheck.js` kept the first the directory walk found and said in a comment that no citation was ambiguous — true when written, false the moment the menu work widened the fetch to the whole plugin tree | six preference claims resolved against 3DFloorMode's file; and three MORE (`MainForm.cs`, `MainForm.Designer.cs`, `Actions.cfg`) kept PASSING only because `Core` sorts before `Plugins` |
| A menu item that closes when clicked ran its command | the dropdown is a CHILD of its top-level element, so a row's mousedown bubbled to the toggle handler, which re-rendered and destroyed the row before its own click could fire | every menu item was inert, and the menu closing looked exactly like a command that had run |
| A field binds through `Fields.GetValue` | a `PairedFieldsControl` carries its two keys as DESIGNER properties (`Field1`/`Field2`) and the companion only says `pfc.ApplyTo(l.Front.Fields, ...)`; the texture pickers bind Sidedef PROPERTIES (`HighTexture`), which the writer maps to `texturetop` (UniversalStreamWriter.cs:255) | both sidedef panes derived EMPTY, and the sector's Surfaces groups silently lost texture panning and scale — four fields in each of its two most-used groups |
| A key identifies a field placement | the linedef's Front and Back tabs carry the SAME keys; the front/back difference is which Sidedef is written | a key→control map bound each key to the Front control and the Back tab derived empty |
| An empty caption means a spacer | `frontgroup` is captioned `"     "` and WRAPS the pane's boxes; `groupBox10` is captioned with spaces and holds `ceilingglowheight` directly. What a box CONTAINS decides it, not what it is called | the second kind was skipped and its field fell through to the Custom tab |
| A group's fields are its GroupBoxes' | the Front pane's right-hand column is three `TextureSelectorControl`s sitting loose in the spacer — the upper, middle and lower wall textures | the whole right column derived as nothing and the pane rendered half empty |
| Field order can be read off the geometry | `renderStyle` at (92,17) and `alpha` at (233,16) are side by side, one pixel apart vertically, so y-then-x reverses a row that reads left to right. TabIndex fixes that and breaks ` Texture offsets `, where "Sidedef offset:" is tabbed LAST and drawn FIRST | rows must be banded by vertical EXTENT then ordered across — which needs no invented tolerance, because the designer gives every control a Size |
| A label can be found by its `Tag` | most carry the field name, and `labelFrontTextureOffset` carries `""` while captioning "Sidedef offset:" | the one row that most needed a caption was the one that lost it; position has no such hole |
| A checker that compares sorted lists checks order | `--dialog` sorted both sides before comparing | the ` Texture scale ` group's top/bottom/mid ordering bug went straight through it |
| `IsImageLoaded` means the image is ready | here the two halves arrive separately — a PNG's SIZE comes back from its header while its PIXELS are still decoding — so the test that matters is a usable WIDTH, not `!pending`. A placeholder awaiting bytes is `width: 0`, and `offset % 0` is NaN |
| A guard that skips a write is the same as writing the default | `if(offset > 0)` KEEPS an existing offset, while `UniFields.SetFloat(…, 0)` DELETES the key (UniFields.cs:104). On a side that already carries one they are opposites — and the obvious fixture cannot tell them apart, because deleting a key that was never there reads exactly like not writing one |
| Opening an undo level before an edit records the state before it | this undo records a property change by capturing the value it is ABOUT TO OVERWRITE, and a drag ignores its own intermediate moves — so the level opened before the drag and closed EMPTY. Dragging geometry and pressing Ctrl+Z did nothing at all, silently, and had never worked. UDB moves everything back, resumes recording, opens the level, and only then moves to the final position (DragGeometryMode.cs:415-424) |
| An undo that restores a value restores the bytes | `restoreProps` marks the block dirty, so an undone property change comes back with the right values in CANONICAL formatting. Add/Rem stay byte-exact because they flag the block instead of rewriting it |
| The two drag modes are the same function on different lists | they snap from different points (`mousemappos` vs `anchorpos`) and read Ctrl differently (plain vs `^ AutoMerge`). Three rules of four match, which is what makes the other one easy to unify by accident |
| A sector's floor is a height | UDMF gives FOUR ways to say where it is — a plane equation, per-vertex heights on a three-sidedef sector, a Plane_Align line, and `heightfloor` last. Comparing two sectors by height alone is wrong wherever either is sloped |
| `!IsNaN(offset / normal.z)` rejects degenerate slopes | it rejects NaN, and only NaN. A flat normal with a NON-zero offset divides to Infinity, which UDB accepts — every point in the sector then reports an infinite height |
| One resolution of a sector's plane serves everything | `HighRequired` reads the RAW slope: no NaN guard, no Plane_Align. A sector tilted only by a Plane_Align line compares as FLAT when deciding whether a wall texture is required, while `GetCeilingPlane` calls it sloped. Unifying them changes which textures UDB asks for |
| A drag moves geometry, so everything on it moves too | a sector's SLOPE is a plane in world space and stays where it was unless re-anchored — the sector slides out from under its own floor. And only a sector the drag TRANSLATES may be re-anchored: one with a single edge in the selection is being deformed, and its plane must not slide |
| UDB computes a bounding box because it needs one | the re-anchoring's centre terms cancel and reduce to `d -= dot2(normal, offset)`, so any point gives the same plane. Kept anyway, computed UDB's way, because the choice moves the last bits and the value goes on disk |
| A matrix library agrees with itself | UDB's `Matrix` does NOT: `Translation` and `LookAt` are ROW-vector, `PerspectiveFov` is written transposed. It is not a bug — the two reach the shader as separate uniforms, so each need only match how the shader reads it. Read the projection the view's way and the near plane lands at NDC 0.00067 instead of -1 |
| A comment describes what the code does | `CreateProjection` says the FOV is held over X so the horizontal field is what the user set. It passes a HALF angle where a full one is expected, so the field is halved AND drifts with the aspect — 40° at square, 45° at 4:1, against a flat 80° if the angle went in whole. The first test written for it asserted the comment and failed |
| Three sibling functions share a rule | the middle and lower wall pegs fire when their flag is SET; the UPPER fires when it is CLEAR (VisualUpper.cs:157). A fixture that never sets the flag cannot tell the two readings apart |
| An empty `grep` result means the symbol is absent | several files in `refs/` are CRLF + ISO-8859 and grep treats them as BINARY, printing nothing at all rather than saying so. `GetRotated` looked missing from `Vector2D.cs` until `grep -a` |
| A mutation harness that reports "caught" is working | the first one detected failure with `'FAIL' in line`, and one of the suite's own test names ends in the word FAILS — so it reported caught for every mutation, a no-op included. Every such harness needs a CONTROL mutation that must come back uncaught |

### What grounding looks like in practice

Good: *"`singlesidedflag = "blocking"` — resolved from the game configuration
(`Includes/Common.cfg`), not assumed."*

Also good: *"there is no `defaultfloorheight` key in the .cfg tree; these are
UDB application settings, so they are options here."* — saying what you could
NOT ground is as useful as what you could.

Bad: any constant, flag name, default or ordering that appears in this codebase
without a citation next to it.

---

## 1. The goal

Reese needs a **feature-for-feature, UI/UX-compatible replacement for Ultimate
Doom Builder that runs in a browser**, for their own use. Their Windows laptop
is dying and VMs are not an option. They map in **UDMF** for **UZDoom**, a
personal GZDoom fork used for the game project F-State 2.

As of 2026-09-10 the shipping form is a **standalone Electron app** rather than
a hosted page (see the Electron decision below) — but "browser engine, no native
UI toolkit" is unchanged and still shapes every constraint in this document.

"For their own use" does not mean a reduced scope. Parity is the requirement,
including **visual (3D) mode**, which is UDB's main strength and is explicitly
not to be deferred. MODELDEF support is in scope. Dialogs are a priority, not an
afterthought — they scaffold the field model everything else needs.

### Decisions already made — do not relitigate without new information

- **Vanilla JS, ES modules, no build step.** This is a standing preference, not
  incidental. No bundler, no TypeScript, no transpiler.
- **Not a port of UDB.** UDB is .NET Framework 4.7.2 + WinForms + a native
  OpenGL renderer over P/Invoke. WinForms does not exist in a browser at all.
  Porting means deleting the entire UI layer and every editing mode's
  interaction code, which is most of what UDB is. Two projects have compiled
  WinForms to the browser (WasmWinforms, ClassicForms); both are dead, and
  neither can bind a GL context to a control, so neither can give you the
  viewport — the one part you need.
- **Not built on deotg.** biwa's abandoned TypeScript sketch: 1,857 lines, no
  UDMF at all (its 432-line `MapIO.ts` is binary Doom/Hexen `getInt16`
  readers), `Tools.ts` is ten lines, and its triangulation call is commented
  out so sector fills never render. It was measured, not assumed. Salvage taken:
  poly2tri as the triangulation library, and the outer-CW / holes-CCW contour
  convention for sectors with islands. Nothing else.
- **Not jzbuilder or wad-together.** Both exist specifically to redesign the
  workflow; jzbuilder's data model is deliberately decoupled from Doom's and it
  has no license.
- **UDB is the specification, not the base.** It is GPL, so its source is
  readable and reimplementable provided this stays GPL. Its `.cfg`
  configuration tree is reusable as *data* — that is where the decades of
  accumulated human knowledge actually live.
- **Electron is the shipping host** (decided 2026-09-10, `wwwdb-4vr`). WWWDB
  ships as a standalone desktop app the user launches, not a hosted website.
  The browser engine is the cross-platform vehicle: the dev team runs Windows,
  macOS and Linux, and a bundled Chromium is *one* pinned WebGL / WASM
  substrate tested once, rather than one system webview per OS. System-webview
  runtimes (Tauri, Wails) were considered and rejected — `render3d.js` and
  `acs/acc.wasm` need a known engine, and Safari / WKWebView lacks the File
  System Access API `view.html` already uses. The same ES modules run
  unmodified in both hosts and `view.html` is **not** forked; the desktop host
  is reached by feature-detecting `window.electronAPI` with the existing FS
  Access API / download fallback. A thin build hosted in a plain browser
  webview is a possible future, explicitly not current scope. This turns the
  5c rows gated on *"a browser cannot spawn a process / cannot touch the
  filesystem"* from blocked-on-the-platform into **owed**: `wwwdb-ag1`,
  `wwwdb-138`, `wwwdb-bax`, `wwwdb-ri2`, and the real-path resource-path row.
  `electron-app/` still owes its section 2 entry and 5c line (`wwwdb-mat`).

### Build order

1. ~~UDMF read/write + correctness harness~~ **done**
2. ~~Config parser for UDB's `.cfg` tree~~ **done**
3. ~~**Dialogs**~~ **done.** `fieldmodel.js` turns a resolved config into the
   descriptor list; `editstate.js` holds the multi-selection semantics;
   `controls.js` draws one renderer per UDB control class; `dialogs.js`
   assembles the tabs, groups and catalogue browsers. `demo.html` is a working
   page — load a wad, select one or many blocks, edit, see the byte diff, save.
   What is NOT done: the texture and flat browsers list lump names rather than
   rendering thumbnails, since nothing decodes graphics yet.
4. 2D view and draw mode — the genuinely hard part. Sector splitting on draw,
   drag-merge, vertex dragging through existing geometry, sidedef reassignment.
   Twenty years of edge cases live here.
5. Visual mode — geometry build, ray picking, texture ops, height dragging,
   auto-align. Flat-lit is acceptable; shader-accurate preview is a bonus.
6. Actors — sprites first, MODELDEF layered over.

---

## 2. What exists, and its verified state

Eleven modules, no dependencies, no build step. `udmf.js`, `wad.js`, `diff.js`,
`defaults.js`, `dbconfig.js` and `fieldmodel.js` run unmodified in a browser;
only `harness.js`, `conformance.js`, `wadcheck.js` and `fieldcoverage.js` use
Node.

| file | role |
|---|---|
| `udmf.js` | TEXTMAP lexer, parser, lossless serializer, style detection |
| `wad.js` | WAD container read/write, map group detection, alignment |
| `defaults.js` | UDMF field defaults, for omitted-equals-default comparison |
| `diff.js` | structural validation, semantic diff, Myers byte diff |
| `dbconfig.js` | UDB `.cfg` parser with include resolution |
| `fieldmodel.js` | dialog field descriptors built from the resolved config |
| `harness.js` | CLI: validate / roundtrip / mutate / compare |
| `conformance.js` | parses and resolves UDB's whole config tree |
| `wadcheck.js` | WAD container audit |
| `fieldcoverage.js` | checks descriptors against the fields real maps carry |
| `typeparity.js` | re-derives the type table from UDB's handlers and diffs |
| `specparity.js` | re-derives `defaults.js` from the UDMF spec and diffs |
| `dialogparity.js` | re-derives the control table from UDB's forms and diffs; `--layout` the group geometry, `--dialog` the whole tab/box/row/field structure, including which block each tab edits |
| `citecheck.js` | every `File.cs:line` citation resolves, and the load-bearing ones say what they claim |
| `graphics.js` | palette, flats, pictures, and TEXTUREx composition |
| `resources.js` | the resource manager: name → lump or file → decoded image, cached; wad, directory and PK3-structured containers |
| `rescheck.js` | drives the resource manager over real wads and directories, end to end |
| `gfx.html` | decodes a wad's graphics and shows them |
| `editstate.js` | multi-selection edit state and relative numeric entry |
| `controls.js` | one DOM renderer per UDB control class |
| `dialogs.js` | dialog assembly, tabs and the catalogue browsers |
| `configjson.js` | bake/revive a resolved config for the browser |
| `exportconfig.js` | CLI that writes that JSON |
| `demo.html` | a working editor page over a real wad |
| `triangulate.js` | sector triangulation by ear clipping (trace/cut/clip) |
| `topology.js` | the map graph over the document: adjacency, angles, mutation |
| `trace.js` | FindPotentialSectorAt — the region enclosing a point |
| `makesector.js` | MakeSector — turn a traced region into a sector |
| `stitch.js` | splitting, merging and welding drawn geometry into a map |
| `drawlines.js` | Tools.DrawLines — the draw operation, all ten phases, and the texture auto-alignment that finishes it |
| `splitsectors.js` | SplitOuterSectors — cutting a multipart sector in two |
| `stackcheck.js` | drives every layer over a real corpus, end to end |
| `crosscheck.js` | the topology's triangulation against the document's |
| `render2d.js` | the 2D view: transform, layers, grid, surfaces, wireframe |
| `snapping.js` | `GetCurrentPosition` — the whole snap cascade |
| `drawmode.js` | draw mode: points, mouse/key actions, the overlay |
| `selection.js` | highlighting, click select, the marquee and its modes |
| `dragmode.js` | dragging a selection of GEOMETRY, with the snap cascade, the drop, and the UDMF slope / texture-offset tail |
| `dragthings.js` | dragging a selection of THINGS — `DragThingsMode`, its own mode in UDB and its own module here |
| `planes.js` | a sector's floor and ceiling as PLANES — UDMF slopes, vertex heights, Plane_Align, flat — and the dialog's rotation / pitch / offset view of one |
| `visual.js` | visual (3D) mode's camera and matrices — `VisualCamera` entire, `LookAt` / `PerspectiveFov`, `CreateProjection`, and `sceneOptions()`, which is what finally carries `planeAlignActions` |
| `visualgeometry.js` | a sector and its sidedefs as textured surfaces — floors, ceilings and the three wall parts, with UDB's texture-coordinate planes and Doom wall shading |
| `render3d.js` | the WebGL renderer. The one place the MECHANISM diverges from UDB and cannot be ported — everything deciding what the picture looks like is in the two modules above |
| `visual.html` | the visual-mode bench: a built-in fixture map, WASD + mouse look, and a resource folder picker |
| `sidedefedit.js` | the linedef dialog's Front/Back side checkbox and Sector index box, performed against the graph |
| `undo.js` | undo / redo — progressive command recording |
| `view.html` | a working 2D view over a real wad |
| `dialogs.css` | dialog presentation, shared by `demo.html` and `view.html` |
| `menus.js` | ALL FIVE of UDB's chrome tables and their widgets: the seven menus and the runtime Mode menu (`MenuBar`), the editing-mode strip (`MODE_TOOLBAR` / `ModeToolbar`), the main toolbar (`TOOLBAR` / `Toolbar` / `applyToolbarState`) and the status bar (`STATUS_BAR` / `StatusBar`), plus the label parser, the shortcut formatter and the menu state pass |
| `menuparity.js` | re-derives all FIVE chrome tables from `MainForm.Designer.cs`, `Actions.cfg` and the `[EditMode]` attributes, and diffs; `--dump` prints them |
| `mkicons.js` | pulls UDB's 98 button images out of `refs/` into `icons/`, through the three ways UDB names one; `--check` verifies the bytes |
| `info.js` | UDB's `panelinfo` — the derived layout of its five panels, a port of each `ShowInfo`, and the widget |
| `infoparity.js` | re-derives those five layouts, and the four draw modes' OPTION panels (`SHAPE_OPTIONS`), from UDB's own designer files and companions, and diffs; `--dump` prints them |
| `dockers.js` | UDB's `dockerspanel` — the docker frame, its tabs and collapsing, the Undo / Redo docker and the four shape modes' option dockers |
| `linedefsmode.js` | LinedefsMode's own actions over a selection — Flip Linedefs and Flip Sidedefs so far |
| `shapemodes.js` | the four SHAPE draw modes as pure generators — rectangle, ellipse, grid and curve, with UDB's own options and limits |
| `panes.js` | **5d, an ADDITION** — the 1 / 2 / 4 pane layout, its shared-edge splitters and the per-pane dirty flags. Cites nothing, because there is nothing to cite |
| `elevation.js` | **5d, an ADDITION** — the XZ / YZ elevation panes: the whole map in grey with the selection highlighted, sloped sectors as trapezoids, the 2D view's own grid, and the height/slope drags. The gestures are invented; every edit they make goes through ported semantics |
| `visualpane.js` | visual mode in a PANE — `visual.html`'s assembly as a class. The mode is parity, the pane is 5d |
| `editselection.js` | `EditSelectionMode` — the bounding-box mode: the transform over a frozen snapshot, the eight grips, and `AdjustSectorHeight`. PARITY, and 5d's keystone |
| `pick.js` | `VisualMode.PickObject` — the camera ray against flats, walls and things, in UDB's three passes. PARITY; the blockmap that narrows the gather is not built, which costs time and changes no answer |
| `visualedit.js` | the visual-mode editing ACTIONS over a picked target — `ChangeHeight` for both surfaces with its slope and vertex arms, and `OnChangeTextureOffset`. PARITY |
| `visualselect.js` | the visual-mode SELECTION and `BaseVisualMode`'s action protocol — the toggle, the highlight fallback, `GetSelectedObjects` / `Sectors` / `Linedefs`, and the `PreAction` / `CreateUndo` / `PostAction` machinery that makes a multi-surface edit ONE undo level. PARITY |
| `scripting.js` | `ScriptConfiguration` over `ZDoom_ACS.cfg` — 444 keywords, 34 properties, 925 constants READ rather than typed, plus the ACS lexer and acc's error parser. PARITY |
| `scripteditor.js` | the SCRIPTS editor window. An ADDITION (UDB's is Scintilla in a docked tab); the highlighting tables and the compiler are ported |
| `acs.js` | `compileAcs` over `acs/acc.wasm` — ZDoom's own `acc`, unmodified |
| `texturebrowser.js` | the texture browser's LOGIC — `ImageBrowserControl`'s filters and groups, and `DataManager.UpdateUsedTextures`. PARITY, including the "Used textures" folder |
| `texturebrowserui.js` | its WINDOW. An ADDITION: `TextureBrowserForm` is a WinForms dialog with an owner-drawn virtual list, none of which transfers. What IS taken is behaviour — the groups, the title, the orange used items, double-click to accept |
| `serve.py` | the dev server, with `Cache-Control: no-store`. Use it instead of `python3 -m http.server` |
| `visualblockmap.js` | `VisualBlockMap` — the QUADTREE the pick gathers through. Parity, and the ledger row `pick.js` was written around. 33-60x on realistic map sizes, with the results proven identical to the brute-force walk |
| `visualflood.js` | FLOOD SELECT — shift/ctrl-click to take a surface's matching neighbours. The sidedef walk along the lines and the two flat walks across sectors. PARITY, with one named divergence: `usebuggyfloodselect` is not built |
| `icons/` | those images and `index.json`, GENERATED — vendored so the editor runs without a reference tree |
| `menus.css` | menu bar and modes-toolbar presentation, in the same variables `dialogs.css` uses |
| `mksynthetic.js` | regenerates `view.html`'s fixture map through `drawLines` |
| `selftest.js` | 605 checks, no external files needed |
| `electron-app/` | **the host shell, and the shipping target** (§1). `main.js`: a `BrowserWindow` loading `app://editor/view.html` — a custom scheme mapped to the repo root — with Electron's stock menu and `save-file` / `load-file` IPC that `preload.js` exposes as `window.electronAPI`. `package.json` builds a macOS dmg through electron-builder. Requires no `child_process`; spawns nothing. **The IPC bridge is unused today** — nothing calls `window.electronAPI`, so the packaged app saves through the same File System Access API path as the browser. See *The host shell* below. Owed work is filed: `wwwdb-mat`, `wwwdb-bax`, `wwwdb-ri2`, `wwwdb-ag1`, `wwwdb-138` |

**Current verified state:**

- 652/652 self-tests
- 124 menu, 34 mode, 32 mode-toolbar, 59 toolbar and 39 status-bar entries checked against UDB's own form
- 658 source citations resolve, and 279 load-bearing claims still say what
  they are cited for
- 170/170 UDB config files parsed, 51/51 game configurations resolved, 0
  discrepancies
- Both Freedoom IWADs (27.5 MB, 3,163 and 3,610 lumps) rebuild with zero lump
  mismatches
- 68 converted Freedoom maps round-trip byte-identically
- **22 real ZDoom maps** round-trip **and regenerate byte-identically from
  scratch**, with all **346,979** of their float values reproduced exactly
- every field across those 22 maps is covered by a dialog descriptor, bar one
  known stray (`thing.flags`, below)

### The one architectural idea that matters

`udmf.js` is **lossless by construction**. Values and blocks store *spans into
the source*, not extracted strings. On write, anything untouched is emitted
verbatim from the original bytes; only dirty items are re-serialized.

Consequences, both load-bearing:

- Opening and saving an unedited map is byte-identical.
- Editing one field re-serializes exactly one block.

This is what prevents the classic editor failure: round-tripping through a typed
model silently eats `user_*` fields the editor has never heard of. This one
cannot, because it never re-types what it did not touch. **Do not replace the
span model with eager parsing.** It also happens to be why memory is bearable —
most fields on a large map are never read.

### The second one, from the dialogs phase

**A field has two different defaults and they must not be merged.**

- The **UI default** is what UDB's dialog pre-fills. It lives in the `.cfg`, as
  `universalfields.<block>.<field>.default`.
- The **semantic default** is what an *absent* field means to the engine. It
  lives in GZDoom's source, and `defaults.js` mirrors it.

They are usually equal, which is why it is easy to miss that they are different
things. One real case proves they are not: UDB declares
`universalfields.sector.fogdensity` as `default = 0`, while GZDoom initialises
<!-- @cite-scope udmf.cpp -->

`int fogdensity = -1` (`udmf.cpp:1642`) and *branches* on `-1` to decide whether
to build a colormap at all (`:2291`). So an absent `fogdensity` and an explicit
`fogdensity = 0` are **different maps**.

Use the UI default to populate a dialog. Use the semantic default to decide
whether two files describe the same map. Collapsing them would make a real
difference compare equal — the one failure direction `defaults.js` exists to
prevent. `fieldmodel.js` carries both as `uiDefault` and `semanticDefault`, and
a self-test pins them apart.

### The host shell (`electron-app/`)

Electron is the shipping host (the decision is in section 1). `electron-app/`
is the wrapper that makes the same ES modules a standalone desktop app. It was
built before that decision and existed undocumented until `wwwdb-mat`; this is
its state as read, not a design.

**What is there.** Four files plus a build output:
<!-- @cite-scope none — this walkthrough cites electron-app/'s OWN files, not UDB's. -->

- `main.js` (66 lines). Registers a privileged `app://` scheme
  (`main.js:5`), opens one 1200×800 `BrowserWindow` with `contextIsolation`
  on and `nodeIntegration` off (`:10`–`:18`), and points it at
  `app://editor/view.html` (`:19`). The `app://` handler
  (`:49`–`:57`) joins the request path onto the repo root (`:51`) and serves
  it with `net.fetch('file://…')`; the `editor` host segment is ignored, only
  the path is used. Two IPC handlers: `save-file` (`:24`–`:34`) runs
  `dialog.showSaveDialog` then `fs.writeFileSync(filePath, content)` (`:30`);
  `load-file` (`:36`–`:46`) runs `dialog.showOpenDialog` then
  `fs.readFileSync` (`:42`), returning a Node `Buffer`.
  `setMenuBarVisibility(true)` (`:21`) leaves Electron's stock menu in place —
  there is no custom `Menu`.
- `preload.js` (6 lines). A `contextBridge` surface of exactly two functions,
  `electronAPI.saveFile` / `.loadFile`, over those two channels.
- `package.json`. `productName "WWWDB Alpha"`, `appId com.wwwdb.alpha`,
  `electron ^31` + `electron-builder ^24.13.3`, `mac.target ["dmg"]`.
  `build.files` is `["**/*", "../**/*"]` — it packs the **entire parent
  repo** into the app.
- `dist/WWWDB Alpha-0.1.0-arm64.dmg` — a real 90 MB build, arm64 only.

**What it does not do, and what that means.**

- **The IPC bridge is dead code.** No module references `window.electronAPI`
  (`grep` is clean). `view.html` saves through `window.showSaveFilePicker` /
  `createWritable` with an `<a download>` fallback, and Chromium 31 supports
  that API, so the packaged app today behaves exactly like the browser minus
  the address bar. `save-file` / `load-file` are wired but unreached.
- **It loads only the 2D view.** `app://editor/view.html`, not a combined
  multi-pane page and not `visual.html`.
- **`save-file` is a bare write** — no `.backup1..3` rotation
  (`wwwdb-bax`), no read-only / lock check (`wwwdb-ri2`). `load-file` hands
  back a `Buffer` a renderer would have to convert.
- **Nothing spawns.** The test-engine launch (`wwwdb-ag1`) and a nodebuilder
  subprocess (`wwwdb-138`) are the reason the decision mattered; neither is
  built.
- **`build.files: ["../**/*"]`** is why the dmg is 90 MB — it includes
  `refs/`, the beads db, tooling `node_modules`, everything. A real packaging
  step needs an explicit allowlist (`*.js` / `*.html` / `*.css` / `icons/` /
  `acs/`).
- **Security posture is correct but thin.** `contextIsolation` on,
  `nodeIntegration` off, no `webSecurity: false`, a two-function bridge. Not
  yet present: a `Content-Security-Policy`, `sandbox: true` on the renderer,
  any validation of the `save-file` path, any bound on what `load-file` may
  read.

**The shape the real shell should take** (not started): feature-detect
`window.electronAPI` and fall back to the FS Access API / download already in
`view.html`, so one codebase serves both hosts and `view.html` is never
forked; add IPC for spawn + filesystem; harden the `app://` handler and the
`webPreferences`; and configure Windows and Linux targets, which the shipping
decision now requires and the current `package.json` does not have.

---

## 3. How to verify

```
node selftest.js                                    # fast gate, no external files
node dialogrender.js                                # createDialog's DOM assembly, under a shim
node harness.js roundtrip map.wad [MAP01]
node harness.js mutate    map.wad --set 'linedef[47].special=80'
node harness.js compare   ours.wad theirs.wad
node wadcheck.js  file.wad
node conformance.js   <UDB>/Assets/Common/Configurations
node fieldcoverage.js <UDB>/.../UZDoom_DoomUDMF.cfg map.wad [map.wad ...]
node stackcheck.js    <dir-of-wads>   # every layer, end to end
node crosscheck.js    <dir-of-wads>   # both triangulation paths agree
node rescheck.js      <iwad> [pwad ...] [--dir <d>[=<prefix>]] [--maps <dir-of-wads>]
node dialogparity.js  <UDB>/Source/Core/Windows --layout   # dialog group geometry
node dialogparity.js  <UDB>/Source/Core/Windows --dialog   # tabs, groups, field placement
node menuparity.js    <UDB>/Source                        # menu bar + mode menu + MODE_INFO
node configparity.js  <UDB>/Source/Core                   # Tools > Game Configurations
node infoparity.js    <UDB>/Source/Core/Controls          # info panels + shape option panels
node typeparity.js    refs/udb                            # the type handler table
node citecheck.js                    # every source citation resolves
node mkicons.js [--check]            # UDB's button images, refs/ -> icons/
```

`dialogparity.js --dialog --dump` prints the WHOLE dialog structure for pasting
into `fieldmodel.js`, and `--layout --dump` prints the older group table, for pasting into
`fieldmodel.js`, the way `--dump` does for the control table. Type either by
hand and it will be wrong; the first hand-written layout table had 10 of 27
placements incorrect.

`menuparity.js --dump` does the same for the menu bar: it prints `MENU_LAYOUT`
and `MODE_MENU` for pasting into `menus.js`. Same rule — re-dump, never
hand-edit. It reads the seven menus out of `MainForm.Designer.cs` and builds
the Mode menu, which the designer leaves EMPTY, from the `[EditMode]`
attributes of the plugins `Builder.sln` actually ships.

The 2D view needs a server, because ES modules do not load over `file://`:

```
python3 -m http.server 8777 -d udmf-editor-foundation
# then open http://localhost:8777/view.html and pick a wad,
#   or visual.html for the 3D bench (it comes up on a fixture map)
```

`.claude/launch.json` carries the same thing for the editor's preview pane.

The view has two tools. In **select**: left click toggles whatever is under the
cursor, dragging is a marquee, and Shift / Ctrl / Ctrl+Shift while dragging
make it add / subtract / intersect. The mode dropdown switches between
vertices, linedefs and sectors.

In **draw**: **left** draws a point, **right** or **Esc** cancels the drawing,
**middle** or **space-drag** pans, **wheel** scrolls and **Alt-wheel** or a
**trackpad pinch** zooms, **Backspace** drops the last
point and **Ctrl-Backspace** the first, **Enter** finishes. **Shift** turns grid
snap off, **Ctrl** turns geometry snap off, **Shift+Alt** is cardinal. Only
Ctrl-Backspace is a grounded binding (Actions.cfg gives `removefirstpoint`
131080); UDB ships no default for `drawpoint`, `removepoint`, `finishdraw` or
`pan_view`, so the rest are chosen here. What IS grounded is that `drawpoint`
sets `disregardshift`/`control`/`alt` — the modifiers deliberately do not
change which action fires, which is what frees them to be snap modifiers.

**A documented divergence: what the wheel does, and which way it runs.** UDB
binds the wheel itself to zoom — ClassicMode's zoom actions sit on
`MScrollUp`/`MScrollDown` (Actions/SpecialKeys.cs:25 gives the wheel its own key
codes), a fixed 1.1 per notch, and there is no pan-by-wheel gesture at all
because panning is `pan_view`, a held button. Here the **wheel scrolls the view
up/down/left/right and Alt-wheel — or a trackpad pinch — zooms about the
cursor.** The direction is towards the trackpad: the plain two-finger gesture
carries an x AND a y component, and spending it on a one-axis zoom throws half
of it away while leaving the pane's most natural gesture unbound. UDB is a
mouse-wheel program and the reference has no answer for this hardware.

Two mechanics are forced by the browser rather than chosen, and both are in
`wheelGesture` (`render2d.js`), which is the ONLY place the mapping lives —
`view.html` and the elevation panes both call it, so the two panes cannot drift
apart. A trackpad pinch reaches a page as a wheel event with `ctrlKey` set and
no key held, which is why Ctrl-wheel zooms too and is indistinguishable from a
pinch by design. And the step is CONTINUOUS rather than the reference's fixed
notch, because a pinch emits many small deltas and 1.1 apiece would cross the
whole scale range in one gesture: `1.1 ** (-dy / WHEEL_NOTCH)` keeps UDB's
number exactly where it is observable — a mouse notch is 100px of `deltaY`, so
it still zooms by 1.1 — and interpolates between notches for everything else.
`deltaMode` is normalised first, because Firefox reports a mouse wheel in LINES
and the deltas are not comparable until it is.

A pinch runs on its OWN gain, ten times the notch's (`WHEEL_PINCH`), because
the two gestures do not emit comparable numbers: a pinch reports the fingers'
movement directly, a few units per event, and at the notch gain a whole swipe
of the trackpad barely moves the zoom. That puts it at ~1% per unit, which is
what every other pinch-zoom on the platform does. **Alt-wheel deliberately
keeps the notch gain** — it is a keyboard modifier on a scroll, so what is
being scaled is a scroll's deltas, not a pinch's, and Reese confirmed that half
already feels right. Telling a pinch from a Ctrl-held mouse wheel is a
heuristic and is admitted as one, since the browser reports both identically:
the signal is magnitude, a notch being 100 (or 120) units where a pinch is
single digits. Guessing wrong costs one event that zooms 10x too fast or too
slow, never worse, and the per-event clamp bounds even that.

**Why a large map now moves smoothly.** Three things were wrong, none of them
in the transform. (1) A pan `mousemove` did not return: it fell through into
the highlight pick and ended in `repaint()`, the post-EDIT path, which rebuilds
the elevation panes' grey layer, the 3D pane's geometry and the menu bar's
enablement on every mouse move — for a view move that changes none of them.
(2) Both the pan and the wheel redrew SYNCHRONOUSLY inside the event, and a
trackpad emits events far faster than the display refreshes, so the browser
never got a frame to composite until the burst ended — which is exactly the "it
only updates when I let go" symptom: the work was being done, several times
over, with nothing shown. `queueViewRedraw` (and `_queueChange` on the
elevation panes) is `DelayedRedraw` (MainForm.cs:1128-1131) for that, at most @cite-scope MainForm.cs
one redraw per frame, the same timer `Renderer2D.queueRepaint` already used for
arriving images. (3) `redrawSurface` re-pathed EVERY sector in the file per
frame, most of them off screen; each cache entry now carries the map-space
bounding box computed with its triangulation, and `_visibleSurfaces` culls
against `view.viewport`. That third one is not an optimisation invented here —
it is the missing half of the port, since UDB's `SurfaceManager` only ever
hands the plotter the surfaces it has decided are visible. The `viewport` pair
is display-to-map of the two window corners, so `top` is the LARGER y: get that
round the wrong way and the cull throws the whole map away.

The page comes up on a synthetic map — two rooms sharing a wall, each with an
island — so it renders without one of Reese's wads. That fixture is NOT
hand-written: `mksynthetic.js` generates it by running `drawLines` over four
closed loops, so its windings, sidedef sectors and hole topology are the ones
`drawlines.js` is already tested to produce rather than something derived by
hand for a demo. `node mksynthetic.js --check` verifies it; without the flag it
prints the map to paste back into `view.html`. Re-run it if the draw path
changes.

Exit codes: `0` clean, `1` real failure, `2` usage error, `3` (conformance only)
checks pass but discrepancies are tracked. All CI-usable.

Run `fetch-references.sh` to rebuild the external verification environment (UDB
config tree, GZDoom reader sources, Freedoom, and the 68-map converted corpus).
It needs network access to `github.com`, `raw.githubusercontent.com` and
`pypi.org`.

**The reference maps are not in this archive, but they ARE on disk** at
`~/Desktop/Work/rhythm doom_orig/firstwad/maps/` — **22 WADs, 21 non-empty**,
not the four (`MAP01`, `MAP11`, `MAP21`, `MAP22`) an earlier version of this
document named. Do not ask Reese to re-upload them; point the corpus tools at
that path. They are excluded from the archive because they are Reese's own work,
not because they are lost.

They are the only artifacts that can verify the serializer, and they are what
caught two bugs no amount of source reading found.

---

## 4. Anti-drift discipline — non-negotiable

Reese's words: *"drift is death for this project."* These rules are earned, each
from a specific mistake made in the session that produced this code.

1. **Survey before implementing.** Both parsers were written against measured
   token shapes from real files, never from a recalled spec. Every format
   construct listed in the README was found by scanning, not by memory.

2. **Read the reference implementation for behaviour, not just its data.**
   "UDB ships a file that omits the comma" is compatible with both "tolerated"
   and "silently broken". Only `ParseFunction` distinguishes them. Shipped data
   can never settle a behavioural question.

3. **A failing check is data.** When the `null`-deletion check first failed, the
   first two attempts changed the assertion. Only the third was right — the
   resolver was genuinely broken and was resurrecting 35 thing types. Change an
   assertion only once you can say precisely why the old one was wrong.

4. **Never loosen to quiet a diff, in either direction.** Stricter than the
   reference rejects valid files; more permissive accepts invalid ones and hides
   real errors. Both are drift.

5. **Where divergence is deliberate, write down why and which direction it
   runs.** An undocumented divergence is drift wearing a hat.

6. **Every fix becomes a regression test** that runs without external files, so
   `selftest.js` stays the fast gate.

7. **Where UDB has an algorithm, port it line by line.** Not a cleaner version
   that does the same thing — "the same thing" is exactly what a simplification
   turns out not to do. This is Reese's standing instruction and it is measured:
   a reasonable simplification of `SplitOuterWithInner` failed on 1.5-3% of
   sectors, the faithful port on 1 in 19,100. The parts that read as arbitrary
   (a 0.1-unit bonus; a test on which way a neighbouring line turns) are the
   parts doing the work. Keep UDB's comments as citations.

7. **A mirrored table and its checker must not share a parser.** `dialogparity`
   validated `UDB_CONTROL` using its own copy of the designer parse, and both
   read only `private X y;` declarations — so both missed the three group boxes
   `SectorEditFormUDMF` builds as LOCALS, and the checker reported PASS on five
   entries that named a TAB where a group belonged. Giving the checker its own
   independent reading of the source is the point of having one; giving it the
   same reading makes it agree with the bug.

7. **A table copied from a reference must be re-derivable from it.** Hand-copied
   tables drift the moment the reference moves, and no amount of care prevents
   it. Where a table mirrors UDB or the spec, there is a checker that rebuilds
   it from source and fails on divergence: `typeparity.js` for the
   `UniversalType` table, `specparity.js` for `defaults.js`, `dialogparity.js`
   for the control table. All three were written after a table turned out to be
   wrong, and all three found further bugs on their first run — `dialogparity`
   found 27, because most of the positions in the table were placeholders typed
   rather than read. Use its `--dump` mode to copy the table out of the source
   instead of typing it. If you add a fourth mirrored table, add its checker at
   the same time.

8. **Every descriptor states where its wording came from.** `titleSource` is
   `udb-dialog` (a designer file), `udb-group` (the caption of the GroupBox a
   self-labelling picker sits in), `udb-name` (a universal field's raw key, as
   UDB's Custom tab shows it), `udb-source` (a rule in UDB's own code),
   `config` (the `.cfg`), `null` (UDB draws no control), or `derived` (a
   guess). A self-test asserts `derived` never appears except for a spec flag
   the loaded config leaves undeclared. If you add a title, add its source.

### Two failure modes that recurred — check these first

**Being stricter than the reference.** This happened four times: rejecting
digit-leading UDMF keys (`3dfloor` is legal to GZDoom), bounds-checking zero-size
lump offsets (`WAD.cs` does no bounds checking at all), assuming config
`ValidateKey` validated identifier characters (it only rejects empty keys and
keys containing a space), and requiring the `include()` comma. The instinct to
assume a mature codebase is strict is wrong; UDB and GZDoom are both far more
permissive than they look.

**Imposing a house style on a file that had its own.** This happened three
times, each caught only by foreign data: CRLF line endings, then brace
placement, then absent indentation and `=` spacing. The fix is always the same —
detect and preserve. The reason it kept recurring is that every fixture written
by hand looked like what was expected, so only real third-party files exposed
it. **When adding a serializer feature, test it against a file you did not
write.**

A third, mechanical, and it has now happened TWICE: **a patch that did not apply
was read as applied, because something else in the same command printed
success.** The second time, an edit was chained behind a `grep` that found
nothing, the `&&` short-circuited, the whole edit silently never ran — and the
test suite that ran afterwards printed `197/197 passed` for the UNMODIFIED code,
which read as confirmation.

Two rules, both cheap:
- Every patch asserts its target exists (`assert s.count(find) == 1`) and prints
  a distinctive line when it applies.
- After the patch, verify the CHANGE, not the suite — grep for the new symbol,
  or check the file's mtime. A green suite proves nothing about an edit that
  never happened.

---

## 5. Everything already verified against source

Do not re-derive these. Line numbers are from the versions fetched by
`fetch-references.sh`.

### UDB — `Source/Core/IO/Configuration.cs`

- `ParseFunction` accumulates arguments by encountering value tokens; the comma
  branch is an **empty block**. `include("a.cfg" "b")` and
  `include("a.cfg", "b")` are identical. UDB's own `MBF21_common.cfg` omits it.
- `FunctionInclude` path logic is one line:
  `Path.GetDirectoryName(file) + Path.DirectorySeparatorChar + args[0]`.
  **No fallback, no search path.**
- `ValidateKey` rejects only empty keys and keys containing a space —
  `space` is literally `new[] { ' ' }`.

### UDB — `Source/Core/General/General.cs`

- Line 376: `Directory.GetFiles(configspath, "*.cfg", SearchOption.TopDirectoryOnly)`.
  Configs are enumerated **non-recursively**. The 30 configs under
  `Other Games/` are never loaded; they are a staging area meant to be copied up
  into `Configurations/`, which is why their relative includes assume they sit
  at the root.

### UDB — `Source/Core/IO/UniversalParser.cs`

- `OutputConfiguration()` → `OutputStructure(root, 0, "\r\n", true)`.
- `OutputStructure`: one tab of indent per level, spaces around `=`, a blank
  line per block, and a **tab** before the `// index` comment.
- `EscapedString` order: `\\`, `\n`, `\r`, `\t`, `"` — backslash first.
- Floats print `"0.000"`; doubles print `"0.0##############"`.
- `KEY_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_"`, checked per
  character after lowercasing, `strictchecking` on by default. **No
  first-character rule**, so `3dfloor` is a legal key.
- UDMF keys are lowercased on read (`ToLowerInvariant`).

### UDB — the editing dialogs (`Source/Core/Windows`)

Read for the field model. These are the only place UDB's on-screen wording
exists, so for a UI/UX-compatible replacement they are specification, not
decoration.

- `VertexEditForm` — `positionx`/`positiony` are "X:"/"Y:", and `zfloor` /
  `zceiling` are **"Absolute floor height:"** / **"Absolute ceiling height:"**.
- `ThingEditFormUDMF` — position controls are `posX`/`posY`/`posZ`, labelled
  "X:"/"Y:"/**"Z:"**. The UDMF key behind `posZ` is `height`, so an editor
  showing "Height" there is not UI-compatible.
- `SectorEditFormUDMF` — `special` is **"Special:"**, sitting next to the
  `effect` browser. The form's other "Type:" belongs to the Sector damage
  group and is not the sector special.
- `LinedefEditFormUDMF` — `special` is **"Action:"**. The form has **zero**
  references to vertices; `v1`/`v2` are never user-editable.
- Tags go through the shared `Controls/TagsSelector`, which labels itself
  "Tags:" / "Tag 1:".
- Every form has a **Custom** tab backed by `Controls/FieldsEditorControl`, and
  the thing form offers "Show user-added custom fields only". Unrecognised
  fields are a first-class UI concept in UDB, which is what `describe()`'s
  `unknown` list maps onto.

### UDB — other

- `UniversalStreamWriter.cs:160` calls `textmap.OutputConfiguration()`, and @cite-scope UniversalStreamWriter.cs
  writes **vertices first**.
- `Geometry/Vector2D.cs:36-37` — `x` and `y` are `double`, so coordinates use @cite-scope Vector2D.cs
  the double format.
- `WAD.cs` does **no lump padding** and **no bounds checking** on lump offsets.

### GZDoom — `src/maploader/udmf.cpp`

The engine is the consumer; the failure that costs a session is writing
something UDB accepts and the engine rejects.

- `CheckCoordinate` rejects any coordinate outside `[-32768 .. 32768]`
  (inclusive bounds) and sets `BadCoordinates`. At line 2629:
  `if (BadCoordinates) I_Error("Map has out of range coordinates");` — **fatal,
  the map does not load**. Guards nine fields, enumerated from the call sites
  rather than recalled: `thing.x/y/height` (533/537/541),
  `sector.heightfloor/heightceiling` (1705/1709), `vertex.x/y` (2350/2354) and
  **`vertex.zfloor/zceiling`** (2363/2358). The vertex slope heights are the
  easy ones to miss — they are checked exactly like `x`/`y` and set the same
  fatal flag, but only appear on maps using vertex slopes. UDB will happily
  save a map that trips any of these, so `validate()` reports it.
- A **missing namespace is not an error** — it prints a message and falls back
  to game defaults. An unknown namespace likewise warns and falls back.
- `ParseKey` reads `+`/`-` as a **separate token** before the number, so
  `x = - 64.0;` parses in GZDoom.

UZDoom is a GZDoom fork. All of the above holds wherever the fork has not
diverged in the map loader — worth a diff of `src/maploader/udmf.cpp` on
Reese's side.

**UDB master now ships UZDoom configurations** (`UZDoom_DoomUDMF.cfg`,
`UZDoom_HereticUDMF.cfg`, `UZDoom_HexenUDMF.cfg`); this post-dates the original
handoff. They are currently a rebrand, not a divergence — the entire diff
against the GZDoom equivalents is the resource filename:

```
< filename = "gzdoom.pk3";
> filename = "uzdoom.pk3";
```

The catalogue is identical (346 thing types, 221 linedef types, 3,606
structures, 10,094 values either way), and `engine` is still `"gzdoom"`.
Practically: building against GZDoom's catalogue *is* building against
UZDoom's, so there is no fork-specific catalogue work to schedule. Anything
Reese's UZDoom adds beyond stock GZDoom is not in UDB's tree and has to come
from the fork itself.

### Format constructs in UDB's `.cfg` tree, found by surveying

- `1.0f` — C# float suffixes, 54 uses.
- `null` / `NULL` is a **deletion marker, not a value**. `9001 = null;` after an
  include *removes* what the include brought in; it is how UDB resolves
  conflicts between overlapping catalogues.
- Bare flag entries: `defaultthingflags { 1; 2; 4; }`.
- Numeric block names are the majority — 4,775 numeric vs 4,437 identifiers.

### Calibrated only by real maps

Two things no amount of source reading revealed:

- **`formatDouble` must round to 15 *significant* digits, not 15 decimal
  places.** .NET rounds doubles to 15 significant digits for custom format
  strings, so `5746.179` prints as `5746.179`. `toFixed(15)` printed the binary
  expansion, `5746.179000000000087`.
- **Blank lines trail blocks, they do not lead them.** Both placements emit
  identical bytes *between* blocks and differ only at the two edges.

Also settled: **every** float field in the reference maps uses the double
format. None uses `"0.000"`. `formatFloat` is retained but currently unused.

---

## 5b. Phase 4 research — the 2D view

Done before writing any 2D code, because this is the phase the whole document
warns about. Sources: UDB master, the older builders' repos, SLADE, UZDoom, the
Doom wiki and the ZDoom/Doomworld forums.

### The lineage, and why it matters

Doom Builder 2 (Pascal vd Heiden / CodeImp, 2009, last release June 2012) →
GZDoom Builder (MaxED, 2012, discontinued 2017) → GZDoom Builder-Bugfix (ZZYZX,
January 2017) → **Ultimate Doom Builder** (renamed December 2019, now largely
Boris Iwanski, Cacoward 2022). Every one of these is on GitHub, which makes
"what did the older versions do?" an answerable question rather than a
speculation. It answered one below.

### SETTLED: which tool wrote the reference maps

It is **not any Doom Builder, and not SLADE**. The evidence:

- **Block order.** All four builders in the lineage — `anotak/doombuilderx`
  (DB2), `m-x-d/GZDoom-Builder`, `jewalky/UltimateDoomBuilder`, UDB master —
  call `WriteVertices` first and `WriteThings` last. The maps are thing-first.
- **SLADE** *does* write things first, but prepends `// Written by SLADE3` and
  emits `namespace="zdoom";` with no spaces. The maps have no header and
  `namespace = "zdoom";` with spaces.
- **The formatting is unreachable.** `UniversalParser.OutputStructure` has ONE
  `whitespace` flag that controls three things together: indentation,
  the spaces around `=`, and the separator before the `// index` comment. The
  maps need `=` spacing (whitespace **true**), no indentation (**false**), and a
  *space* before `// 0` (true gives a tab, false gives nothing). No value of
  that flag produces these files.
- **But the field order is exactly UDB's.** A reference thing block runs
  `x, y, angle, scalex, scaley, type` — precisely `WriteThings`'s
  `id, x, y, height, angle, pitch, roll, scalex, scaley, type, special, args…`
  with the absent fields dropped. The `// N` index comments are the same
  hallmark.

So: something read a Doom Builder-written map — inheriting its field order and
index comments — and **re-serialized it** with its own block order, line endings
and indentation. A post-processing step in the F-State 2 toolchain is the
obvious candidate; Reese will know.

**Consequence: do not rewrite `UDB_STYLE` to match these artifacts.** They are
not UDB output, so calibrating to them would encode a third-party tool's
formatting as though it were the reference implementation's. The provisional
decision to leave `UDB_STYLE` alone was right, and is now settled rather than
pending.

### UZDoom's map loader, diffed against GZDoom

`UZDoom/UZDoom` (default branch **`trunk`**, not `master`) is a fork of GZDoom
created in October 2025; most of the GZDoom team moved to it after 4.14.2. It
is actively developed and keeps reverse compatibility with pre-fork GZDoom. Note
it relicensed `udmf.cpp` to **GPL-3.0-or-later** (GZDoom's was BSD-3-Clause,
retained for pre-2026 code) — which is one more reason this project stays GPL.

Diffing `src/maploader/udmf.cpp` against GZDoom's, ignoring whitespace and the
licence header, the **entire** substantive difference is:

- an include-block reshuffle,
- `sd->ClearAlpha()` added to the sidedef initialiser,
- **`sidedef.alpha`** — `case NAME_Alpha: sd->SetAlpha(CheckFloat(key));`
- **`sidedef.blockrendering`** — `Flag(sd->Flags, WALLF_BLOCKRENDERING, key);`

Everything this project depends on is byte-identical in substance: the
`[-32768 .. 32768]` range check, the fatal `I_Error("Map has out of range
coordinates")`, and **nine** `CheckCoordinate` call sites — matching the nine
`diff.js` now guards, vertex slope heights included.

**Actionable:** `sidedef.alpha` and `sidedef.blockrendering` are UZDoom-only.
`blockrendering` appears in **zero** of UDB's 170 config files and neither is in
`defaults.js`, so today they land in `describe()`'s `unknown` list — safe, but
invisible in a dialog. Adding them needs a UZDoom `.cfg` that does not exist
upstream yet.

### UDB's 2D architecture, as read

**`Tools.DrawLines`** (`Geometry/Tools.cs:964-1761`, ~800 lines) is draw mode.
Its phases, in order:

1. Build a blockmap for the spatial queries.
2. Make a vertex per drawn point and a line between consecutive points.
3. **Split existing lines at intersections** — but skip an intersection when
   both ends of one line are within a threshold of the other, "to avoid
   creating unexpected splits when drawing on top of non-cardinal lines".
4. Merge overlapping drawn vertices.
5. **Closure.** A closed polygon is preferred because the interior is then
   determinable. If the drawing is open, first check whether the new lines are
   inside existing sectors — if so this is a *sector split* and no closing path
   is attempted. Otherwise stitch: find where each end lands (on a linedef, or
   on a vertex, taking the two linedefs at adjacent angles), then find the
   shortest closest path between them.
6. **Determine the interior side** of every new line — the user may have drawn
   clockwise, counter-clockwise or something worse. Trace from the front, build
   the polygon, and if the front is outside, trace from the back.
7. Stitch the new geometry into the map by marked vertices, then re-find the new
   lines (merging may have replaced them).
8. **Interior:** make sectors. "When none of the linedef sides exist yet, this
   is a true new sector created out of the void" — suppressed in split-only mode.
9. **Exterior:** join existing sectors. Note the comment: *"We only want to
   modify our new lines when joining a sector (or it may break nearby
   self-referencing sectors)."* Self-referencing sectors — both sides of a line
   pointing at the same sector, the classic vanilla trick behind invisible
   bridges and deep water — are load-bearing enough to constrain the algorithm.
10. Fix backward linedefs, drop lines that ended up with no sides, apply texture
    overrides, auto-align.

**`Tools.FindPotentialSectorAt`** is the trace: find the outer polygon by
angle-walking, then find holes by looking for the right-most vertex inside the
polygon and taking the attached linedef with the smallest angle to the right,
repeating until no more holes appear. Islands fall out of this naturally.

**Triangulation is ear clipping**, not poly2tri — `Triangulation.cs` says so in
its class comment, with `EarClipPolygon`/`EarClipVertex` beside it. **Decided:
we ear-clip too.** `triangulate.js` implements UDB's three phases (trace, cut,
clip); poly2tri is dropped, and with it the last would-be dependency.

Measured across the whole 21-map corpus: **19,100 sectors, 154,398 triangles,
2 empty and 1 area-mismatched** — and all three of those are in CIRQUE.wad, the
only map with sectors that trace no contour at all. A whole map triangulates in
1-44 ms (MAP01's 2,986 sectors in 41 ms).

Getting there took one lesson worth keeping. `cutHoleInto` was first written as
a sensible simplification of UDB's `SplitOuterWithInner` and failed on 1.5-3% of
sectors. Porting that function line by line — the 0.1-unit tie-break bonus, the
direction tests, splitting the outer edge at the true intersection rather than
snapping to an endpoint — took it to 1 sector in 19,100.

Two things were found the hard way and are worth keeping: tracing must continue
along the **smallest delta angle** at a vertex shared by more than two of a
sector's sides (UDB says so explicitly), and ring nesting is by **containment
depth**, not one-outer-plus-holes — a sector may have disjoint parts, and two
on MAP21 do.

**Rendering is layered render targets**, composited by a `Presentation`:
Background → Surface (textured sector fills) → Things → Grid → Geometry
(the wireframe, via a software `Plotter`) → Overlay. Each is a separate target
with its own blend mode, which is why UDB can redraw the overlay on every mouse
move without touching the geometry.

**Snap priority in draw mode** (`DrawGeometryMode.GetCurrentPosition`), in order:
cardinal-direction constraint first if enabled; then nearest *drawn* point;
then nearest *vertex* via the blockmap; then nearest *linedef* — and if grid
snap is also on, the nearest **grid intersection along that linedef**; then
plain grid. Shift toggles grid snap.

### The gap between our model and UDB's

This is the one architectural thing to settle before writing code.

`udmf.js` is a **document**: blocks holding spans, with references stored as
integers (`linedef.v1 = 0`). That is exactly what makes it lossless, and it must
not change.

UDB's `MapSet` is a **topological graph**: `Linedef.Start`/`End` are `Vertex`
objects, `Linedef.Front`/`Back` are `Sidedef` objects, and — critically —
`Vertex.Linedefs` is a `LinkedList<Linedef>` maintained incrementally, with
`DetachVertexP` unhooking on dispose. Every algorithm above depends on that
reverse adjacency: "which lines touch this vertex" is the inner loop of tracing,
dragging and stitching.

We have no reverse adjacency at all. A vertex does not know its linedefs; a
sector does not know its sidedefs.

So phase 4 needs a **topology layer over the document, not instead of it**: an
index built from the blocks that answers adjacency queries, mutates through
`blk.set()`, and can be rebuilt or incrementally maintained. The span model stays
underneath and keeps doing what it is good at. Getting that boundary right is
the whole design; everything else is geometry with known answers.

**Built — `topology.js`.** Nodes for vertices, linedefs, sidedefs and sectors;
integer references resolved to objects once, at build; `Vertex.linedefs` and
`Sector.sidedefs` as Sets (UDB uses a LinkedList plus a node handle for O(1)
detach — a Set gives the same complexity, and UDB never relies on that list's
order, sorting explicitly with LinedefAngleSorter where it matters).

Ported from UDB alongside it: `Vector2D.GetAngle`, `Angle2D.Normalized`,
`Angle2D.Difference`, `Line2D.GetSideOfLine`, and `LinedefAngleSorter` itself —
the last decides which way a trace turns at a shared vertex, and the corpus has
vertices with up to **8** lines on them, so it is not a corner case.

Note the angle convention, which is not the obvious one: **south is 0**, and
angles increase anticlockwise (east PI/2, north PI, west 3PI/2). A first draft
of the test assumed north was 0.

`verify()` is first-class rather than test-only: it re-checks the graph against
the blocks it was built from, because the entire design rests on those two not
drifting apart, and a mutation path that forgot to write through would
otherwise only surface as a corrupted save. Across the 21-map corpus: **0
integrity problems**, built in 0-30 ms per map.

---

## 5c. The parity ledger — now in beads

**The ledger moved.** Every UDB feature this project still owes, with what
blocks each, is filed in `bd` (issue prefix `wwwdb`) as a dependency DAG rather
than as a table here. A prose table cannot say *"this is blocked on that"*, and
the blocking is the half that decides what to work on.

```
bd ready        # what is workable right now
bd blocked      # what is gated, and on what
bd show <id>    # one row, with its citations and its blocker
```

**The rule that produced the old table is unchanged and still binds:**

> *"There is no X yet"* is a missing feature, not a justification for skipping a
> step, and a deferral buried in a code comment is invisible. When a port has to
> stop at a dependency, **file the stopping point as a BEAD.**

Two things to know when reading one:

- **A bead's description is a POINTER TO THE SOURCE, not a specification.** Four
  of the first five ledger rows ever closed were described *wrongly*, and every
  correction came from reading UDB's designer rather than from the row. Where a
  bead and the source disagree, the source wins and the bead gets corrected.
- **The phase log in section 8 still says "still owed" in places.** Those are
  history — what a phase knew at the time — and every one of them is filed. Do
  not read them as the list.

**One category in the DAG changed on 2026-09-10.** Rows that read *"a browser
cannot spawn a process"* or *"a browser cannot touch the filesystem"* used to
be gated on a platform question. Electron is now the shipping host (section 1),
so they are ordinary owed work against the host shell — `wwwdb-ag1` (launch the
test engine), `wwwdb-138` (nodebuilder subprocess), `wwwdb-bax` (backup
rotation), `wwwdb-ri2` (read-only / file-lock). The shell itself is documented
in section 2, *The host shell (`electron-app/`)*, and `wwwdb-mat` closes with
that entry.

The table this section used to hold is recoverable: `git log -- HANDOFF.md`.

## 5d. Additions beyond UDB — the multi-pane layout

**Nothing in this section is parity.** Section 5c is what UDB has and this
project owes; this is the opposite list, and the two must not be confused. An
entry here has **no UDB source to cite**, so `citecheck.js` must not be pointed
at it and `menuparity.js` must not be made to accept it. That separation is the
whole reason this section exists as its own heading.

The rule from section 0 still applies with full force. *"There is no spec"* is
not a licence to design freehand — it is a reason to keep the invented surface
as **small as it can possibly be**, to write down exactly where it starts and
stops, and to leave everything on either side of it untouched.

### What Reese asked for

A **Hammer-style viewport layout**: a 3D view and orthographic elevation views
always visible alongside the existing 2D view, in panes. Explicitly **not**
brush-based construction, not a CSG compiler, not a second map model. The data
model, the document, the topology and the modes are unchanged. This is a
**presentation and layout** change with one small new interaction vocabulary
attached to it.

### Why it is cheap where it is cheap

<!-- @cite-scope none — this paragraph cites render2d.js's / view.html's OWN lines, not UDB's. -->

`render2d.js` has **no module-level mutable state**, and both `View` (:178) and
`Renderer2D` (:470) are classes. `View` owns its own offset, scale, window size
and viewport rectangle; `mapToDisplay` / `displayToMap` are instance methods
over instance fields. So *N* renderers over one document is already supported by
the library as written — this is inherited from UDB, whose `Renderer2D` is an
object too.

Every singleton assumption is in **`view.html`**, the harness, not in the
library. `let tool = 'select'` (:153) and the hand-wired mode switching (:1624)
are the harness's, and they are what a pane host would replace.

### The three panes, and which of them is parity

| pane | what it is | parity status |
|---|---|---|
| **top (XY)** | the existing 2D view, **verbatim** — every mode, every behaviour, driven exactly as it is today | **parity, untouched** |
| **elevation (XZ / YZ)** | new content layers over `View`'s existing transform, pan/zoom and grid chrome | **addition** |
| **3D** | visual mode, drawn into a pane rather than taking the screen | **parity (the mode), addition (the pane)** |

The top pane staying verbatim is the load-bearing part of this design. Parity is
measured against a thing that is still there and still driven the same way, so
the layout cannot make a parity claim false.

An elevation pane is **not an axis swap** of the 2D view. The transform, the
viewport rectangle, the zoom-anchor maths and the grid all transfer. The
*content* does not: `LAYER_ORDER`'s surface and wireframe layers draw footprints,
and an elevation draws height bars. Reusing the chrome and writing new layers is
the correct split; parameterising `Renderer2D` by axis is not.

### What an elevation pane draws — Reese's answer

The projection problem is real and has to be answered before anything is built.
A Doom sector is a footprint plus a floor and a ceiling height, so projecting a
whole map onto XZ makes every sector a rectangle spanning
`[min_x, max_x] × [floor, ceiling]`. Doom maps are wide and flat, and a full-map
elevation of an F-State map is unreadable overlap.

**Reese's decision: draw the WHOLE MAP IN GREY, and highlight the active
selection.** The context stays on screen as a ghost so you can see where you are
in the level; the selection is what reads. This is the answer to the mush
problem — not by hiding geometry, but by making everything that is not selected
recede.

Consequences to respect when building it:

- The grey context layer is **static per viewport** — it changes only on a
  geometry edit or a pan/zoom, never on a selection change. Cache it and
  repaint only the highlight layer as the selection moves, or elevation panes
  will cost a full-map redraw on every mouse move.
- **Draw order is grey first, highlight second, always.** A selected sector
  behind a grey one must still read as selected.
- Slopes ARE visible here, unlike in the 2D view, and this is the first place
  outside visual mode where `planes.js` has anything to draw. A sloped sector's
  bar is a trapezoid, not a rectangle. `floorPlaneOf` / `ceilingPlaneOf` give
  the height at each end of the span.

A **section line** — a user-placed cut in the top view, with the elevation
drawing a true architectural section along it — was considered and is the
natural graduation from this, particularly for corridors and stairwells. It is
**not** being built first. Ship the grey-plus-highlight version, use it, and let
the section line be decided by what that teaches.

### The limited vocabulary — agreed, and this is the boundary

Modes stay bound to the **top pane**. Drawing lines in an elevation is
meaningless.

An elevation pane gets **exactly this and nothing else**:

- drag a floor plane
- drag a ceiling plane
- drag a slope handle
- **drag the whole selection up and down in Z**, floor and ceiling locked
- **resize the selection**, from bounding-box grips

**Do not grow this list by accretion.** Anything that feels like it belongs here
— nudging, snapping to a neighbour's height, multi-sector height relationships,
texture work — is a new entry in this section with its own justification, not a
natural extension of a drag that already exists. The moment an elevation pane
starts to feel like a general editing surface, this feature has stopped being a
layout change.

### `EditSelectionMode` is the keystone, and it carries most of this

<!-- @cite-scope EditSelectionMode.cs -->

An earlier draft of this section called the whole vocabulary invented. **That was
wrong, and the correction matters** because it changes what gets built and how
much of it has a source. `EditSelectionMode.cs` — already owed in 5c, already
blocking the slope adjustment — turns out to specify most of what Reese asked
for. Read it before building any of this.

**Vertical movement has a ported primitive.**
`AdjustSectorHeight(Sector s, int flooroffset, int ceiloffset)`
(`EditSelectionMode.cs:1213-1280`) takes a floor delta and a ceiling delta and
does all three things correctly:

- `s.FloorHeight += flooroffset` / `s.CeilHeight += ceiloffset`;
- **the UDMF slope arm** — `s.FloorSlopeOffset -= flooroffset *
  Math.Sin(s.FloorSlope.GetAngleZ())` (:1226, and :1259 for the ceiling), guarded
  by `GetLengthSq() > 0` and a `NaN` check on `SlopeOffset / Slope.z`;
- **the vertex-height arm** — when the slope arm does not apply and
  `s.Sidedefs.Count == 3`, it collects the sector's vertices into a `HashSet` and
  offsets each `v.ZFloor` / `v.ZCeiling` that is not `NaN` (:1230-1246).

Those are exactly the two hard cases — a sloped sector and a three-sided
vertex-height sector — and both have a ported answer. This is the same family as
`dragmode.js`'s UDMF tail, and it is the primitive the vertical drag calls.

**Resizing has ported machinery too.** `Grip.SizeN/SizeE/SizeS/SizeW` and
`Grip.RotateLT/RT/RB/LB` (:60-71), `GRIP_SIZE = 9.0f` (:141), the
`resizegrips[4]` / `rotategrips[4]` rectangles in top/right/bottom/left order
(:212-213), `CheckMouseGrip` (:678), and the scale maths at :596-621 —
`resizeaxis.GetNearestOnLine`, and the `resizefilter` blend
`newsize = (basesize * resizefilter) * newscale + size * (1 - resizefilter)`
that confines a grip to its own axis. All of it is UDB's, all of it is 2D (XY),
and an elevation pane is the same machinery presented on a different axis pair.

**So the split is:**

| part | status |
|---|---|
| what a height delta DOES to a sector, slopes and vertex heights included | **ported** — `:1213-1280` |
| grips, hit-testing, axis filtering, scale maths | **ported** — `:60-71, :212-213, :596-621, :678` |
| a mouse drag in an ELEVATION producing that delta | **invented** — the driver only |
| presenting the grips on an XZ / YZ axis pair | **invented** — the projection only |

The invented surface is the *driver and the projection*, not the semantics. That
is a much smaller and much safer thing to be inventing, and it is why
`EditSelectionMode` should be built as parity work **before** any of this, not
alongside it.

**One thing UDB does NOT give us.** UDB never drives `AdjustSectorHeight` from a
free vertical drag. `AdjustSectorsHeight` (:1176) fires when a selection is
dropped into a **new surrounding sector**, and the delta comes from
`GetOutsideHeights` (:1142) — the floor and ceiling of the neighbouring sectors
outside the selection, and `int.MinValue` when they disagree — under a
`HeightAdjustMode` of `ADJUST_FLOORS`, `ADJUST_CEILINGS` or `ADJUST_BOTH`. It is
"conform to where you dropped it", not "move by this much". Reusing the
primitive with a mouse-derived delta is legitimate and is the whole point; do not
describe it as ported, and do not expect `HeightAdjustMode` to mean anything in
an elevation pane.

### The sloped-tunnel modifier — deferred, and it is the real invention

Reese raised a modifier on the vertical drag: connected tunnels either become
**sloped** to meet the moved sector, or stay **orthogonal** and take the height
change as a step.

**Orthogonal is UDB's behaviour and costs nothing.** `GetOutsideHeights` only
ever *reads* the neighbouring sectors; nothing in `EditSelectionMode` writes to a
sector outside the selection. Leave the neighbours alone, the shared two-sided
line becomes a step, and its wall textures show the difference — which is what
Doom does and what every existing tool here already produces. **This is the
default, and it should ship alone.**

**Sloping the neighbour is the single largest invention in this feature**, and it
is deferred for four reasons, not one:

1. It **edits unselected geometry** — the only operation discussed anywhere in
   this section that does. Everything else in 5d stays inside the selection.
2. It needs a slope **authoring** path, and 5c records that there is none:
   *"nothing here writes `floorplane_*` deliberately"*. `planes.js` reads planes;
   `dragmode.js`'s tail and `AdjustSectorHeight` **preserve** a plane that
   already exists. Fitting a new plane to a neighbour is a different operation.
3. The design questions are genuinely open, and none has a source to settle it:
   which neighbours slope; how far back the slope runs (to the neighbour's far
   edge, or to its next boundary); what happens when the neighbour **already**
   has a slope; what happens when it is a three-sidedef vertex-height sector
   instead; what happens when two selected sectors border the same neighbour and
   demand different planes.
4. It is naturally sequenced after the slope-editing UI, which 5c already owes.

So: **build the orthogonal drag, use it, and let the modifier be decided by what
that teaches.** If it is built later it gets its own entry here with those five
questions answered explicitly, in writing, before any code.

### Open — things carried by a vertical move

Unresolved, and it decides whether things ride along for free. In
`refs/gzdoom/udmf.cpp:540-541` a thing's `height` is read straight into @cite-scope udmf.cpp
`th->pos.Z`; whether that is then treated as an offset from the floor happens
later, in `P_SpawnMapThing`, which is **not in the reference copy on disk**. The
classic Doom rule is floor-relative except with `SPAWNCEILING`, which would mean
a raised sector carries its things with no editor work at all — but that is
recollection, not a citation, and section 0 does not accept recollection.

**Verify against the spawner before writing any thing-moving code.** If it turns
out things are floor-relative, do nothing; if they are absolute, moving a sector
vertically without its things is a data-loss-shaped bug.

### Two collisions with the checked tables

Both are structural, both are known now rather than discovered when a checker
goes red:

1. **The menu bar.** `menuparity.js` re-derives 124 entries from
   `MainForm.Designer.cs` and diffs; UDB's View menu has **no pane-layout
   items**. Adding one fails that check *by design*, and the check is correct to
   fail. Layout controls therefore live **outside the derived table** — a
   separate menu the parity checker does not cover, or a splitter/toolbar
   affordance with no menu entry at all. Do not relax `menuparity.js` to admit
   them; the table's completeness is what parity is measured against.

2. **`citecheck.js`.** Every `File.cs:line` citation in this project resolves and
   says what it claims. Code written for this section has nothing to cite.
   Comments in it must not invent a citation to look like the rest of the
   codebase, and must say plainly that they are an addition, pointing here.

### Layout and performance pragmatics

- ~~Four equal quadrants is a bad default for Doom's aspect ratio~~ —
  **REVERSED by Reese, 2026-09-07**: *"when hammer view starts each pane should
  be equal size, including on first load if hammer view is enabled in cfg."*
  The fractions are 0.5 / 0.5. The old reasoning — elevations are short and
  wide, the top view wants the height — was mine, and it was a guess written as
  a requirement; the splitters are draggable, so a user who wants a taller top
  view drags one. There was a TEST enforcing the old default, which is exactly
  what a written requirement should produce, and it now enforces the new one.
- Ship a pane-count toggle (1 / 2 / 4) with draggable splitters, **defaulting to
  today's single 2D view**, so nothing regresses and the layout is opt-in.
- **Per-pane dirty flags are mandatory.** A mouse-move in the top pane must not
  repaint the 3D view. The hook exists: `setResources` / `queueRepaint` /
  `onRepaint`, built for the asynchronous texture path.
- **Unmeasured, and the thing to measure first:** four live panes plus
  OTEX-scale texture decode on a real F-State map. 1,201 Freedoom images decode
  in 140 ms in total, which is why background loading has not bitten yet — that
  number is not evidence about this case.

---

### Save Map Into — the repoint half, ported; ReloadResources, not (2026-09-11)

<!-- @cite-scope MapManager.cs -->

**wwwdb-ccm.** Confirmed against MapManager.cs: `SaveMap`'s end block
(:1122-1153) has exactly ONE IntoFile-specific skip, at :1015 (don't delete
the target file). Everything past that — the `filepathname`/`filetitle`
repoint, `ReloadResources`, the `changed`/`scriptschanged` clears — runs for
IntoFile the same as Normal and AsNewFile. **UDB's "Save Map Into" relocates
the editor to the target file; it is not an export.** The prior entry's
divergence comment (wwwdb-k89) had this right in outline but left both halves
undone; this bead ports the half that's actually portable.

`actionSaveMapInto` now does `mapHandle = handle; markMapSaved(bytes)` on a
successful write — the identical repoint `actionSaveMapAs` already makes for
a different target, ported from the same :1125-1129/:1151-1152 lines. Status
message changed to match: `"Map saved in " + fileTitle()` (:1690), not the
invented "Map saved into X." Saving again (Ctrl+S) after an Into now writes
the TARGET, matching UDB.

**Still not ported: `ReloadResources(true, false)` at :1131.** Grounded this
time rather than asserted: it tears down and rebuilds `DataManager` entirely,
and critically feeds it `DataLocation(RESOURCE_WAD, filepathname, ...)`
(:2453-2456) — **the currently open map's own wad is itself a resource
location** in UDB, on every load, reload and relocate, not just IntoFile.
This port has no such thing for ANY open map: `resourceMounts` are built only
from explicit `#resdir`/`#reswad`/`?res=` picks in `view.html`, never from
`mapHandle`. So this was not a "port ReloadResources for the IntoFile case"
job — it needed a load-bearing capability the whole editor is missing, and
inventing a narrower one just for this call site would have been exactly the
kind of divergence section 0 warns against. Filed properly, and correctly
scoped this time: **wwwdb-8cx.4**, under the resources epic, not this row.

Gates: selftest 960/960 (unchanged — no pure function touched; this is a
three-line call-site change reusing `markMapSaved`, already exercised by
`actionSaveMapAs`), citecheck, dialogrender, all parity/type/icon/stack gates
green. **Not mutation-tested against an automated fixture** — same gap as
wwwdb-k89 (wwwdb-ng7), and the browser pane stayed `visibilityState: 'hidden'`
again this session (confirmed with a 12s poll, not just one failed check), so
no E2E run either. Verified by equivalence trace against the already-reasoned
`actionSaveMapAs` tail (identical shape, same three statements, same order)
and a grep of every `mapHandle` read site to confirm nothing downstream
assumed IntoFile left it alone — `%F`/test-engine parameter substitution
(view.html, the ConvertParameters block) reads `mapHandle` live and is
correctly stale-free either way.

---

### Un-factoring performSave — three save tails, not one (2026-09-10)

<!-- @cite-scope General.cs -->

**wwwdb-k89, superseding wwwdb-2xf.** `performSave` was an invented shared
tail for `actionSaveMap` / `actionSaveMapAs` — no UDB counterpart.
`General.SaveMap` (General.cs:1463), `SaveMapAs` (:1542) and `SaveMapInto`
(:1639) are three near-parallel ~60-line methods, each carrying its OWN copy
of the tail (status Busy → `OnMapSaveBegin` → `map.SaveMap(path, purpose)` →
`AddRecentFile` → `OnMapSaveEnd` → `UpdateInterface` → status). UDB does not
factor it out. `performSave` did, so `actionSaveMapInto` — whose tail
genuinely differs — bypassed it wholesale, and that bypass is exactly where
wwwdb-2xf's missing `scriptschanged = false` lived. The abstraction created
the gap it was then filed against.

**Fix: deleted `performSave`.** `actionSaveMap` / `actionSaveMapAs` /
`actionSaveMapInto` each now carry their own explicit tail, cited line by line
against their `General.cs` method. What IS still shared — `buildSaveBytes`
(the transform core of `MapManager.SaveMap`: compile scripts, run
`savemap.js`'s `saveMap`) and `markMapSaved` (its :1150-1157 end-of-save flag
clears) — is shared because each is genuinely ONE place in UDB, not three.

**A misread citation caught in the process.** The old `actionSaveMapInto`
comment claimed "MapManager.cs:1122-1136 skips the filename swap for @cite-scope MapManager.cs
IntoFile." False: the only IntoFile-specific skip is :1015 (don't delete the
target file). The end block at :1122-1153 — the `filepathname`/`filetitle`
repoint, `ReloadResources`, and the `changed`/`scriptschanged` clears — runs
for IntoFile exactly as it does for Normal and AsNewFile. So in UDB, Save Map
Into actually RELOCATES the editor to the target file; it is not a pure
export. This port does not do the relocate (no resource-reload-on-move path
exists), and — this is the point of wwwdb-2xf's two options — it now says so
explicitly rather than implying a citation that didn't hold up, and clears
neither flag rather than clearing one half of a pair that only makes sense
together. Filed to close the gap properly: **wwwdb-ccm**.

Also written down as a deliberate divergence (method rule 9), in savemap.js's
own header: `saveMap()` is pure bytes-in/bytes-out where `MapManager.SaveMap`
is `bool SaveMap(string path)` with side effects — forced by the browser
having no `System.IO`, but worth stating outright rather than leaving implied.

Gates: selftest 960/960 (unchanged — this bead moves no pure logic; savemap.js
gained ten lines of comment, nothing else), citecheck, dialogrender, and all
ten parity/type/icon/stack gates green. **Not mutation-tested against an
automated fixture** — there isn't one for this code (wwwdb-ng7 already tracks
that gap) — verified instead by tracing every branch of the old
`performSave`-based code against its replacement and confirming behavioural
equivalence, plus one live run in the browser confirming the module still
loads and bootstraps with no console errors. Full click-through E2E (a real
save round trip through the three actions) was not possible this session: the
preview pane's document stayed `visibilityState: 'hidden'`, and the app's own
bootstrap gates on a `ResizeObserver` seeing a laid-out, non-zero `#host` —
which a hidden document never delivers here.

---

### AskSaveScriptChanges — the scripts half of AskSaveMap (2026-09-10)

**wwwdb-akn.** `AskSaveMap` had its map half but ignored scripts, because
before 2026-09-08 there was no script editor to have unsaved changes in. Now
there is, so the three `AskSaveScriptChanges` call sites (General.cs:1728,
:1744, :1750) are wired, and `IsChanged` is `changed | CheckScriptChanged()`
(MapManager.cs:111) — the map-save prompt now opens on a script-only edit too.

**`scriptschanged` is a page variable now** (`scriptsChanged` in `view.html`,
MapManager.cs:65): set when the editor's OK actually changes the SCRIPTS text, @cite-scope MapManager.cs
cleared on load and on a successful save (:1152, beside `changed`).
`CheckScriptChanged` (:2093-2102) is ported into `savemap.js` next to
`MapSaveRequired`, which gained its `scriptschanged || CheckScriptChanged()`
term (:1164) — without it, answering Yes to the prompt after a script-only edit
reported "up to date" and never wrote BEHAVIOR.

**Three MapManager methods it leans on are NOT vendored.** `AskSaveAll`,
`Editor.CheckImplicitChanges`, `Editor.ImplicitSave` live in
`ScriptEditorForm`/`ScriptEditorPanel`, which `fetch-references.sh` does not
pull. They are stand-ins, declared as additions the same way `scripteditor.js`'s
window already is: `checkImplicitChanges()` is "textarea text ≠ last-synced
text", `implicitSave()` writes it back and advances the sync point, and
`askSaveScriptChanges`'s Yes/No/Cancel is rebuilt on `confirmThree`.
`scriptSaveOptions` does the pre-compile `ImplicitSave` at MapManager.cs:830-831 @cite-scope MapManager.cs
so the Yes path does not double-prompt. **Owed: wwwdb-ud7** (re-derive against a
vendored form), **wwwdb-ng7** (a DOM gate for this path — `selftest.js` covers
only the two pure predicates), **wwwdb-2xf** (`SaveMapInto` bookkeeping).

`savemap.js` was added to `citecheck.js`'s MODULES in the same change — it had
been unscanned, so its citations were never verified.

---

### The script editor and compile-on-save (2026-09-08)

**829 self-tests, 897 citations, 425 claims.**

**Compiling SCRIPTS writes BEHAVIOR — a DIFFERENT lump.** `resultlump =
"BEHAVIOR"` (ZDoom_ACS.cfg), which is why the map lump table has SCRIPTS as
`scriptbuild` and BEHAVIOR as `blindcopy`: one is a source, the other an
artefact. `compileScriptLumps` in `savemap.js` is `MapManager`'s pass
(:2110-2135), including ":2118 — but only if it's required or exists", so a map
with no SCRIPTS compiles nothing rather than inventing an empty BEHAVIOR. With
no compiler the pass is inert and an existing BEHAVIOR is carried through,
which is UDB's behaviour when no script configuration is found. A failed
compile REFUSES the save rather than writing a broken lump.

**The async boundary is the page's.** `saveMap` is synchronous and `compileAcs`
is not (the wasm module instantiates), so `view.html` compiles first and hands
the save a finished result; the ported unit keeps UDB's synchronous
`CompileLump` shape.

**The keyword tables are READ, not typed.** `ZDoom_ACS.cfg` parses with the
same `dbconfig.js` the game configuration uses: 444 keywords, 34 properties,
925 constants, `casesensitive = false`, `extrawordchars = "#"` — which is what
makes `#include` one token rather than a symbol and a word. Typing any of that
out would be the same mistake as hand-copying a menu layout.

**A bug the screenshot caught.** acc writes its BANNER and statistics to
stderr beside any real errors, so the first `parseCompilerErrors` — "match the
`file:line:` shape, otherwise keep the line as a message" — filled the error
panel with nine lines of credits on a SUCCESSFUL compile. It is match-or-DROP
now, with the raw transcript still available from `compileAcs`. Worth
remembering that the fault was visible in the UI and invisible to the tests I
had written.

Verified end to end: F-State's `driftskid.acs` compiles to a 348-byte BEHAVIOR
that is byte-identical to native acc's, lands in the saved wad beside SCRIPTS,
and a deliberate syntax error both refuses the save and reports
`line 4: Missing semicolon.` as a clickable row.

Still owed: multiple script tabs (UDB edits every script lump and loose files),
snippets, find/replace, and the keyword call-tip — the argument signature is
already in the table beside each keyword, just not shown.

---

### ACS compilation — the data layer, verified (2026-09-08)

**Decision (Reese): use ACC, not BCC.** The reason that holds is parity —
ACC is the reference implementation and what UDB invokes, and BCC being a
strict SUPERSET means it can only approximate ACC, never verify it. Same
argument as porting UDB rather than a cleaner equivalent.

**And it is not shipped, so the licence question is much smaller than I made
it.** This is a personal tool; with no distribution the distribution right is
not engaged and GPL obligations do not attach. Building `acc` locally is what
every ZDoom mapper already does. The boundary that still matters is the one
already recorded: **anything that ends up inside F-State 2.**

**Compiled, not rewritten.** `acs/acc.wasm` is the ZDoom `acc` sources built
with Emscripten, unmodified — 8 C files, no dependencies. A JS rewrite was
considered and rejected on engineering grounds, not legal ones: it must be
bug-for-bug exact in the PCODE it emits, and the only way to verify that is
differential testing against ACC, so you need working ACC either way. 10,350
lines including a 4,961-line parser, for nothing.

**Verified byte for byte** against a native build of the same commit:
F-State's own `firstwad/ACS/driftskid.acs` (348 bytes) and a synthetic script
(192 bytes), both IDENTICAL. Bad source returns `ok: false` with acc's own
messages captured.

`acs.js` is the wrapper: a fresh module per compile (acc calls `exit()`), the
four `.acs` headers embedded in the wasm so `#include "zcommon.acs"` resolves
with no host files, and an `ACS` magic check because acc writes nothing on a
hard error. **`acs/` is never redistributed** — acc has no LICENSE file at all;
its only rights statement is the banner, "Copyright (c) 1995-2023 Raven
Software, Corp." README has the rebuild-and-verify recipe.

Still owed: **there is no script editor at all**, and the compile has to hook
into the save path (UDB's `CompileLump`, MapManager.cs:2119) for BEHAVIOR to
land in the wad.

---

### The texture browser, texture copy/paste, and live 3D (2026-09-08)

**825 self-tests, 892 citations, 421 claims.** The open items, closed.

<!-- @cite-scope DataManager.cs -->

**THE TEXTURE BROWSER — and the headline is that it is a PORT.** Reese asked
for "a smart folder for textures used in the map" and it was very nearly built
as an invention. UDB already has exactly that: `RefillList` builds an
`ImageBrowserItemGroup("Used textures")` (ImageBrowserControl.cs:521), the
toggle that reveals it ships FALSE (:143), used items draw ORANGE against the
system text colour (`ImageBrowserItem.cs:96`, `:121`), and the set comes from
`DataManager.UpdateUsedTextures` (:3205). **`refs/` did not carry any of it** —
`fetch-references.sh` was widened and the four files fetched.

Rules worth keeping, all of which a "cleaner" version would have erased:

- **`MixTexturesFlats` decides which dictionary the flats land in.** Mixed, they
  go into `usedtextures` alongside the wall textures; unmixed, into `usedflats`.
- **A sidedef texture is only counted when it is not the empty name (:3216);
  a sector flat is counted UNCONDITIONALLY (:3293).** So a sector with no flat
  marks "-" as used. Asymmetric, and ported as written.
- **Every name is also looked up in the short-to-full table** — mxd's "long name
  support shennanigans". A sidedef stores a short name and the browser holds
  the long one; without the lookup nothing is ever marked used, and the failure
  is an EMPTY folder rather than an error.
- **The list walks BACKWARDS** (:540), which decides which of a duplicate name
  survives — biwa's comment says it decides "whether a floor or a wall ... come
  first". A fixture without duplicates cannot tell the two directions apart,
  which is how a forward-walking mutation survived the first pass.

**Texture select / copy / paste in visual mode.** `OnSelectTexture` opens the
browser; the clipboard is TWO slots (`CopiedTexture` and `CopiedFlat`), and
copying fills both when the namespaces mix (:1334-1335). The undo descriptions
are per-surface and not symmetrical — a sidedef says `Change texture X` with no
quotes, a sector `Change flat "X"` with them (:738) — kept exactly.

**Live 3D during a drag.** Reese: *"3D needs to update live with elevation and
angle changes ... not just after they release the mouse button."* Two gestures
deliberately skipped `repaint()`, which is what rebuilds the 3D and elevation
panes: the Edit Selection transform (move / ROTATE / resize) and the elevation
drags. `queueLiveRebuild()` now drags the other views along, coalesced to one
frame and kept separate from `repaintFrame` so it does not pull the 2D redraw,
overlay and status bar into a gesture that has already drawn them. The
elevation path is gated on an `elevationEditing` flag, because its `onChange`
also fires for pans and zooms where nothing has moved.

**EQUAL Hammer panes, reversing a requirement of mine.** Reese: *"when hammer
view starts each pane should be equal size, including on first load if hammer
view is enabled in cfg."* The fractions were 0.62 / 0.66 on a rationale I had
written into 5d as a rule — "elevations are short and wide, the top view wants
the height" — and there was a TEST enforcing it, which is exactly what a
written requirement should produce. Both are reversed. Nothing persists the
fractions, so the default is what every session starts at, a saved Hammer
layout included. **The lesson is about the shape of the mistake, not the
numbers: a guess of mine had been promoted to a specification with a test
guarding it, which made it look like Reese's decision.**

---

### Visual MODE, right-click properties, and four smaller asks (2026-09-07)

**779 self-tests, 812 citations, 370 claims.** A batch from Reese, in order:

- **FoV steps faster** — `FOV_STEP` 2 -> 5.
- **The elevation panes lost their labels**, and the 3D pane's is now
  lowercase "camera". `.pane-label` also lost its `text-transform: uppercase`,
  which would have shouted it back.
- **The HIGHLIGHT only draws in the live pane.** `isActive()` — the pane holds
  the pointer lock, the pointer is over it, or it is the sole view. UDB needs
  no such test because its visual mode owns the window; a gold surface pulsing
  in the corner while you work in the 2D view is, in Reese's words, just
  distracting. The SELECTION still always draws: it is state the user set on
  purpose.
- **Classic VISUAL MODE, on Q.** `gzdbvisualmode` is UDB's action and Q is its
  shipped key; `menus.js` has carried the menu item since the mode tables were
  derived and it answered "not implemented yet" until now. It is a toggle over
  the pane host, deliberately NOT a Hammer layout — `SLOT_PANES` says every
  layout shows the XY view, which is a 5d decision, and visual mode is the
  other axis. **Q does nothing in the Hammer panes** (Reese's rule), which also
  settles a real collision: Q is "move down" in the 3D pane, so `visualpane.js`
  declines to consume it exactly when it is the sole view.
- **Right-click opens the properties dialog.** `visualedit` ->
  `EndEdit` -> `OnEditEnd`: a flat edits the SECTORS
  (BaseVisualGeometrySector.cs:774), a wall the LINEDEFS
  (BaseVisualGeometrySidedef.cs:1429), both through the selection with its
  highlight fallback, and `GetTargetEventReceiver(false)` — so aiming at
  something unselected while a selection stands edits the SELECTION. The
  pointer lock is dropped first, because a dialog needs the cursor. Only the
  LEFT button captures the mouse now; right-click has to reach the dialog
  whether or not the pane is captured.

**Three mistakes of mine worth keeping.**

1. **A `replace(..., 1)` put the Q handler in the wrong function.** The guard
   string `if (visualPane && visualPane.hasFocus()) return;` appears twice, and
   the first is the MOUSEMOVE handler. Moving it then split a comment in half.
   Both were caught by `node --check` on the extracted module rather than by
   reading — worth doing routinely on `view.html`, which has no other syntax
   gate.
2. **Hiding the 2D view to show visual mode starved the startup.** `#view` is
   what the first `ResizeObserver` measures, so `display: none` on it meant
   `clientWidth` stayed 0, `synthetic()` never ran, and entering visual mode
   before the first paint left the editor with **no map at all**. It is an
   OVERLAY now (`z-index`, opaque pane), which also makes exiting instant.
3. **The status bar advertised the S key I had removed.** Static HTML, so no
   search for `synthetic()` would have found it. It now names Q instead.

**A new dev server, because the cache trap keeps costing time.** `serve.py`
sends `Cache-Control: no-store`. `python3 -m http.server` sends none, and a
page built from ES modules then loads its module graph out of the HTTP cache —
where `fetch(url, {cache: 'reload'})` from a console does NOT reliably reach
it. **And in the Claude Code browser pane even that is not enough: a tab keeps
a module cache across navigations that no cache-buster clears.** The symptom is
brutal — the page fails to initialise, and the console shows a stale
`SyntaxError` naming an export that plainly exists on disk. **Open a NEW TAB.**
That is what finally proved the code was fine.

Also: **do not poll in the pane.** A hidden Browser pane throttles timers, so a
`while (!window.topo) await sleep(100)` loop hangs the tool call and keeps
running in the page afterwards — which silently fired a queued keypress and
made the next test read backwards.

---

### The `VisualBlockMap` — done (`visualblockmap.js`, 2026-09-07)

**779 self-tests, 807 citations, 367 claims.** The 5c row `pick.js` was written
around: its gather walked every linedef because there was no blockmap. Reese
hit the cost directly on a real map, so here it is.

**It is a QUADTREE, and the name is the only thing that says otherwise.** Doom's
own BLOCKMAP is a uniform 128-unit grid and UDB has one of those too; this is a
different structure sharing the family name — a tree over the map's configured
boundaries, at most 8 levels deep (:171).

**The rule that makes it work is `GetEntry` (:294-315):** an item descends only
while a CHILD contains it whole, and otherwise stops at the level it straddles.
So a linedef crossing the middle of the map lives at the root and is returned
by every query — correct, if unselective — while a short line in a corner sinks
to level 8. Nothing is ever stored twice, which is why no query de-duplicates
within a block. Its other half is that `GetBlocks` adds a node's own block
BEFORE testing any child (:282); miss that and the straddling items vanish.

**Three different rectangle tests, and they disagree at their edges.**
`Contains(Rectangle)` lets the far edges touch; `Contains(Point)` is half-open,
so the right and bottom edges are OUTSIDE (which is what stops a point on a
seam landing in two siblings); `IntersectsWith` is half-open both ways, so
rectangles that merely touch do not intersect. Collapsing them into one test is
how a query starts missing geometry on a block boundary, so all three are
written out and pinned separately.

**Measured, on generated grid maps, after warm-up:**

    1,600 linedefs   brute  5.6 ms   blockmap  0.2 ms   33x
    6,400 linedefs   brute 24.7 ms   blockmap  0.4 ms   60x
   14,400 linedefs   brute 49.6 ms   blockmap  1.2 ms   40x

...for 200 ray gathers each, **with identical results every time**. Building the
tree for 1,600 linedefs costs 2.6 ms, and it is rebuilt only when the geometry
is (`FillBlockMap`, VisualMode.cs:1295-1297, where UDB refills it).

**The equivalence is a TEST, not a hope** — over 400 rays across a 36-room map,
`linesAlong` with and without a blockmap must return the same set, and
`pickObject` must return the same pick. If that ever fails the blockmap is
dropping geometry and picking is silently wrong, which is far worse than slow.

**Two mutations survived the first pass and both were real test gaps.**
Splitting the children by `round` instead of C#'s truncating integer division
still tiles the map and still answers every query correctly — it changes only
the tree's SHAPE, so it needed asserting directly (the root is 65535 wide, an
odd extent, so the split lands on -1 and the halves are 32767 / 32768). And
`addThing` ignoring the radius: the deepest node is 255 units across, so my
first probe pair — 50 units apart — sat in the same block and passed either
way. It had to be placed on a real boundary at x = 255. **Measuring the tree
beat guessing at it**, and that is the general lesson: when a mutation
survives, check whether the fixture can even express the difference.

The mutation runner now has the **watchdog** the last session said it needed —
a 45-second kill, and a hang counts as caught rather than hanging the run.

---

### Field of view on the wheel — an ADDITION (5d)

Reese asked for scroll-to-change-FoV and middle-click-to-reset. The split is
worth stating precisely: **`visualfov` is UDB's own setting and ships at 80**
(ProgramConfiguration.cs:336), so the value and the default are parity.
Reaching it is not — there is **no FOV action in `Actions.cfg` at all** and
nothing bound to the wheel in visual mode, so the controls are this pane's
convention. The RANGE (20-140) is invented too, because `visualfov` is
config-file-only in UDB and there is no control to borrow bounds from; 140 is
short of where `tan(fov/2)` runs away at 180.

Middle-click is tested BEFORE the pointer-lock check, so it works whether or
not the pane holds the mouse — needing to click in first to undo an accidental
zoom would be the wrong way round.

Verified live: the wheel moves it 80 -> 90 -> 70, clamps at 20 and 140, the
middle button returns it to 80, the status line reports each change, and the
3D pane visibly widens and narrows.

---

### The 3D pane and the 2D view were fighting over the mouse and keyboard

**770 self-tests, 801 citations, 355 claims.** Reese: *"rotating is quite
laggy, and I'm still getting random position resets."* Two more bugs, and both
are the same shape — **the 2D view kept acting on input that belonged to the 3D
pane.** `visualpane.js` gates its own WASD on `hasFocus()`; the other half of
that bargain was never written in `view.html`.

**1. The lag was the 2D view repainting behind the 3D pane.** Under a pointer
lock the cursor does not move, so `clientX`/`clientY` are FROZEN — and
`view.html`'s `mousemove` handler ran anyway, calling `findHighlight` over the
whole map and queuing a 2D redraw for every event, at raw mouse rate. It
computed the identical answer hundreds of times a second while the 3D pane was
trying to hold a frame budget. Measured on the four-sector synthetic map: 500
mouse moves cost **121 ms** before the guard and **2.1 ms** after — **57x** —
and `findHighlight` scales with map size, so on an F-State map it is far worse.

**2. The "random position resets" were the letter S.** `S` is *move backward*
in the 3D pane and was also the bootstrap hotkey for *reload the synthetic
map*. Nothing gated the 2D keyboard while the pane held the lock, so flying
backwards rebuilt the document, handed `setMap` a genuinely new topology, and
respawned the camera. Random is exactly how that feels: one of six movement
keys, and only that one. **On a loaded map it would have discarded the map**,
with no confirmation.

Proven rather than assumed, before the fix: park the camera at (500, 600, 88),
press S, and it lands at (-240, 0, 41) with `placements` going 1 -> 2. With the
guard, unchanged.

**The hotkey is now REMOVED** (Reese: *"we're past that point"*). `synthetic()`
survives as what the first render loads; it simply has no key. The keyboard
guard stays regardless — every other single-letter shortcut (F and Shift+F for
the flips, the mode keys) would have fired the same way.

**3. A latent double-loop, fixed while in there.** `start()` guarded only on a
`running` flag, so `stop()` followed by `start()` **inside one frame** left the
already-pending callback alive: it saw `running === true`, rendered, and
scheduled a third. The loop doubles, and each doubling steps the camera again
per frame and picks again — twice the speed, twice the cost, compounding. A
generation token now means only the newest chain survives. Verified: eight
stop/start cycles in a row still tick once per frame.

**A diagnostic, because "random" needs evidence.** `window.visualPane.placements`
counts camera placements. It should read 1 per document opened; if it climbs
while you fly, something is handing the pane a new document and that is the
bug — which is exactly how this one was caught.

---

### Two 3D-pane bugs Reese found by flying around (2026-09-07)

**770 self-tests, 801 citations, 355 claims.** Both reported in one sentence —
*"I keep getting snapped into the spawn position, and the view doesn't rotate
properly"* — and they are unrelated.

**1. The mouse-look was FORTY TIMES too slow, because a step of the port was
missing.** `ANGLE_FROM_MOUSE` is 0.0001 radians per unit (VisualCamera.cs:17),
which reads like a typo until you find what feeds it: `MouseInput.ProcessInput`
scales the raw pixel delta first (MouseInput.cs:117-118) —

    changex = msX * VisualMouseSensX * MouseSpeed * 0.01
            = msX * 40 * 100 * 0.01
            = msX * 40

`visualmousesensx`/`y` ship at **40** (ProgramConfiguration.cs:337-338) and
`mousespeed` at **100** (:351). Both the pane and the bench were handing the
browser's `movementX` straight to `processMouseInput`, so 100 px of mouse
turned **0.57°** instead of 22.9°. `mouseSensitivity` in `visual.js` is now
that scaling, cited, and both callers go through it.

The lesson is one this project keeps relearning: **a constant that looks
self-contained usually is not, and the caller is part of the port.**
`processMouseInput` was a faithful line-for-line port and was still wrong in
use, because the function above it had never been read.

**2. `setMap` re-placed the camera every time, and `setPaneLayout` calls
`setMap`.** So switching between one, two and four panes — or toggling the
Hammer panes at all — threw away where you were standing *and* which way you
were facing, and dropped you at the first sector's centroid. `placeCamera` now
runs only for a document the pane has not seen, or the first time. Reloading
the same document is a VIEW operation, not a navigation one; UDB has no
equivalent because its visual mode is a place you enter once, so the rule is
this pane's and says so.

**A test expectation of mine was wrong again, in the same direction.** The
sensitivity test asserted a 22.918° turn and got 337.08 — because
`processMouseInput` subtracts and then normalizes into [0, 2PI), so a
right-turn from zero comes back as 337, not -23. The code was right; the test
measured the angle the long way round. That is now four times a test of mine
has asserted against correct code, and every one was an expectation I wrote
from memory rather than derived.

Six mutations on the new scaling, all caught; the control missed.

**Verified in the running editor:** park the camera at (333, 444, 99), change
the pane layout through the View menu, and it is still at (333, 444, 99);
hand the pane a genuinely new document and it respawns at that map's centroid
with UDB's eye height.

**Still NOT built, and Reese asked for it:** right-click on a wall to open its
properties. That is UDB's `visualedit` action (`BeginEdit`/`EndEdit` ->
`OnEditEnd`), which opens the sector or linedef dialog and releases the mouse
capture. No right-click handling exists in the pane at all — the button is
ignored. It is part of the 5c row "the rest of the visual actions" and is
cheaper than it looks, because the sector and linedef dialogs are already
ported; what is missing is the action, the dispatch over the selection, and
dropping the pointer lock as the dialog opens.

---

### Mouse capture in the 3D pane — the environment, plus two real defects

Reese could not get cursor capture in the 3D pane and asked whether that was
the Claude Code browser pane or a bug. **It is the pane**, and it is not
ambiguous:

    document.featurePolicy.allowsFeature('pointer-lock')  ->  false
    canvas.requestPointerLock()  ->  SecurityError (code 18),
        "The root document of this element is not valid for pointer lock."
    console: "Unrecognized feature: 'pointer-lock'."

The page is TOP-LEVEL there (`window.self === window.top`), on localhost, in a
secure context, and `pointer-lock` is simply absent from the document's
`allowedFeatures()`. Nothing the page does can obtain the lock. **Pointer lock
cannot be tested in the Claude Code browser pane. Test look controls in a real
browser** — everything else in the 3D pane works there, because keyboard
movement and every editing action are gated on `hasFocus()` only for the
keyboard, not for the render or the pick.

**But the diagnosis exposed two defects that are mine**, both fixed:

1. **The promise was never caught.** Chrome has returned a promise from
   `requestPointerLock` since 113, and ordinary clicking filled the console
   with unhandled rejections — nine of them before anyone looked. `pointerlockerror`
   is handled too, for Firefox and pre-113 Chrome, which report only that way.
2. **The failure was SILENT.** The pane looked broken rather than blocked.
   It now says so on the status line, with the reason's name:
   *"3D: the browser would not give this page the mouse (WrongDocumentError).
   Look controls need pointer lock; the keys still work."*

Both are worth having regardless of this environment, because a real browser
refuses too: a document that is not focused, and Chrome's cooldown after the
user pressed Escape to leave a previous lock. The handling is this module's
own and says so — UDB captures the mouse through the OS and has nothing that
can refuse.

Verified: three clicks on the pane now produce **zero** unhandled rejections
and the explanatory status line.

---

### Flood select — done (`visualflood.js`, 2026-09-07), minus the buggy path

**769 self-tests, 791 citations, 351 claims.** Shift-click to take a surface's
neighbours with the same TEXTURE, ctrl-click for the same HEIGHT, alt to stop
at anything already in the state being carried. Walls walk along the lines;
floors and ceilings walk across sectors.

**THE DIVERGENCE, and it is a decision rather than an omission.** `EndSelect`
forks (:2677-2688): the default floods from the TARGET, and the other path —
behind a preference literally captioned *"Use buggy flood select in Visual
Mode"* (PreferencesForm.Designer.cs:744) — iterates the whole selection and
roots a flood at every member. **Only the default is built.** Reese's call,
2026-09-07, with both paths' behaviour on the table. What the editor therefore
cannot do:

- flood from every already-selected surface rather than from the one clicked,
  so a selection spread across a map grows in every room at once;
- and, because that path hands the target's post-toggle state to floods rooted
  elsewhere (`obj.SelectNeighbours(target.Selected, …)`, :2687) while
  `SelectNeighbours` forces its own object to that state, **wipe an entire
  selection on one shift-click that happens to deselect the target.** That is
  the half that destroys work, and it is why the preference is named as it is.

The preference ships `false` (BuilderPlug.cs:325), so this is the behaviour
Reese has today. UDB itself treats the other path as an escape hatch: the
fork's condition is `AltState || !UseBuggyFloodSelect`, so holding ALT leaves
it even when the preference is on — because the buggy branch hardcodes
`stopatselected: false` and has nowhere to put the flag.

**Two asymmetries that are UDB's, found by reading rather than by testing, and
ported as-is.** Neither is the bug the preference names:

1. **`stopatselected` is DEAD for flats.** `VisualFloor.SelectNeighbours`
   takes the flag, threads it through all four recursive calls (:556, :569,
   :580, :591) and tests it nowhere; `VisualCeiling` likewise. Its own guard,
   `select != vs.Floor.Selected` (:568), is exactly what `stopatselected`
   means for a sidedef (:687). **So Alt does nothing when you flood a floor**,
   and walls and flats disagree with nothing in UDB saying so.
2. **Half the sidedef guard is dead code.** :687 tests
   `!visualside.Sidedef.Marked`, but the flood zeroes every sidedef mark at
   :613-614 and only ever sets the LINEDEF's (:617) — "clear" meaning "set to
   the argument" (MapSet.cs:1638-1647). The live guard is `side.Line.Marked`
   at :664.

**Three predicates that are each two tests, and every one needed its own
geometry before a mutation would bite.** The wall texture match is
`Texture == Texture && r.IntersectsWith(sourcerect)` (:691) — same texture AND
vertical overlap; the wall height match is `Height == Height && Y == Y` (:692)
— same height AND same top; and `GetSidedefPartSize` is `else if`
(BuilderModesTools.cs:126), which is a latent bug: a vertex that lowers the
minimum never gets to raise the maximum, so four STRICTLY DESCENDING heights
leave `maxy` at negative infinity. Unreachable on a normally-wound quad,
reachable on a wall inverted at one end. Ported as written and pinned.

**The fixture had to grow a fourth room to catch two of those.** Rooms 2 and 3
are the same wall HEIGHT at a different TOP, and their BRICK2 walls abut
exactly — which `IntersectsWith` calls no overlap, because it is half-open.
Without room 3, dropping either second test changed no result. That is the
fifth time a fixture has hidden a real mutation by making two different things
equal, and the shape is always the same.

**Twenty-four mutations caught; two survivors, recorded as survivors rather
than claimed equivalent.** Swapping the two `at()` calls in the backward branch
(:628-633) changes no result across six roots — set *and* insertion order — but
order is not provably irrelevant: a part is matched against its immediate
predecessor and `Line.Marked` closes a line as soon as any part of it floods,
so arriving from the other end first can change a verdict. A distinguishing
case needs a part reachable from both ends of a backward-walked line with
different predecessors; not built. The other is the `Sidedef.Sector == null`
guard (:594), which every caller already filters (:654) and only a root call
could reach. Three genuine equivalents are recorded with their proofs beside
the tests, including both halves of `EndSelect`'s gate — they are cost, not
behaviour, because `SelectNeighbours` opens with its own no-modifier return.

**A harness lesson.** Two mutations (never marking the line, clearing the marks
on every recursive call) send the walk into unbounded recursion, and the
mutation runner has no timeout — it hung, and interrupting it left the module
mid-mutation on disk. Restored from the backup and re-verified by diff before
trusting a green run. **If a mutation can loop, the runner needs a timeout;
until it has one, diff the file against its backup before believing the suite.**

**Verified in the running editor:** ctrl-clicking one ceiling in the synthetic
map selects all four — "4 ceilings selected." — and draws them in the selection
colour with the aimed wall still gold; ctrl-clicking the same ceiling again
deselects the whole run and blanks the line.

---

### Visual-mode SELECTION — done (`visualselect.js`, 2026-09-07)

**760 self-tests, 782 citations, 332 claims.** Click a surface in the 3D pane
to select it, click again to deselect, and every action already ported now runs
over the selection instead of over the one thing under the crosshair. The
highlight and the selection are drawn, in UDB's own two colours.

**The part to understand before changing anything here is `PreAction` /
`CreateUndo` / `PostAction`** (BaseVisualMode.cs:239-341). It is not what it
looks like — three flags decide how many undo levels an action makes:

- `singleselection` — nothing is selected, so the action runs on the TARGET as
  a temporary selection. **UDB never actually adds it to the list**;
  `GetSelectedObjects` falls back to the highlight when the list is empty
  (:2221-2229), so the temporary selection is *virtual* and `PostAction`'s
  clear has nothing to clear. The undo level keeps the ACTION's group, so
  holding a key collapses into one level per sector.
- `undocreated` — set by the FIRST object to ask for a level, so five selected
  floors raised together make ONE level, not five.
- `lastundogroup` + `selectionchanged` — repeating the same action with the
  same selection makes **no new level at all** (:263-275); the changes combine
  into the one already open. Get this wrong and it shows up as undo doing
  nothing, not as a visible bug.

And the multi-selection level is created with `UndoGroup.None` (:338), **not**
the action's group — because the merging is already being done by `undocreated`
and a group would merge levels that must stay apart.

**`VisualModeClearSelection` ships FALSE** (BuilderPlug.cs:308). So clicking a
new surface while others are selected does not clear them, and an action aimed
at an *unselected* surface while a selection stands runs on the SELECTION
(`GetTargetEventReceiver` answers `selectedobjects[0]`, :2495). It surprises
people; it is UDB's shipped behaviour, and both arms are tested.

**Two smaller rules that look like bugs and are not.** Clearing the selection
EMPTIES the status line rather than recomputing it (:2649) — so a *partial*
clear says nothing is selected while something still is. And selection is a
toggle on RELEASE, guarded by `if(uvdragging)`, so a drag that moved a texture
does not also toggle; UV dragging is not ported, so `dragged` is always false
here, and the guard is carried rather than dropped because it is the line that
will matter the day dragging arrives.

**Identity had to be invented, carefully.** UDB needs none: its
`IVisualEventReceiver`s are long-lived objects on the `BaseVisualSector`, so
`Selected` is a bool on the geometry itself. A pick here allocates a fresh
result every time, so `objectKey` spells identity out — the map element plus
which SURFACE of it, which is exactly what UDB's separate `VisualFloor` /
`VisualCeiling` / `VisualUpper` *types* encode. The same function answers for a
built surface (`surfaceKey`), which is the point: what can be picked and what
is drawn selected cannot disagree.

**The overlay is ported; only how it reaches the pixels is not.**
`CalculateHighlightColor` (Renderer3D.cs:1831-1836) over the glow at :382-391:
SELECTION wins the colour when a surface is both, HIGHLIGHTED wins the alpha
and takes the *inverse* glow, so a highlighted surface pulses in antiphase with
a merely selected one. Neither flag gives a transparent colour, which is what
lets the base pass run the same mix with an alpha of zero rather than branch.
UDB sets a uniform per drawn geometry; `render3d.js` batches by texture, so a
flagged surface is drawn a SECOND time over itself — identical vertices
interpolate to an identical depth, `LEQUAL` passes, and the overlay lands with
no offset and no z-fighting. That mechanism is this module's own and is named
as such, like the buffer layout and the shaders.

**A bug the browser caught that the tests could not.** The multi-selection
undo level passes UDB's literal `0` as its group tag (:338). I passed "no tag",
which is the same thing right up until the page turns a tag into a fixed index:
`fixedIndexOf(null)` threw before a single height had moved. The fix is in both
places — the literal zero here, and `view.html` no longer assuming every tag is
a map element — and there is now a test on the zero.

**One mutation went uncaught because the FIXTURE agreed with the bug** — again.
`PICK_ROOM`'s sidedefs all belong to sector 0, so keying a wall by its sector
instead of by itself reads identically on sidedef 0. The test now uses sidedef
**2**. That is the fourth time a fixture has hidden a real mutation; the
pattern is "the fixture makes two different things equal", and it is worth
looking for deliberately rather than waiting for the harness.

**Named deferrals, each blocked rather than skipped:** FLOOD SELECT
(`SelectNeighbours`, plus the `UseBuggyFloodSelect` fork at :2673-2688 that
keeps the old behaviour on purpose) — three implementations, and none of the
"same texture / same height" neighbour walks exist here; PAINT SELECT in the 3D
pane; and the VERTEX and SLOPE-HANDLE categories, which have no visual geometry
here at all. All three are carried through the filter and clear arguments, so
adding them later is adding a case rather than reshaping the module.

**Verified in the running editor:** clicking a wall reports "1 sidedef
selected." and the target stays locked while the button is held; two floors
selected and raised by three presses of +8 move together to 24 and come back to
0 in **one** undo (the redo stack holds exactly one level afterwards); the 3D
pane draws the aimed wall gold and the selected floor orange-red at once; and
clearing blanks both the overlay and the status line.

---

### The first visual-mode ACTIONS — done (`visualedit.js`, 2026-09-06)

**750 self-tests, 758 citations, 313 claims.** Raise or lower either surface,
and nudge a wall's texture offsets, over whatever `PickObject` is aiming at.
They could not exist before it; they are the first thing to use it.

**A CORRECTION this port forced, and it is the best kind.** The elevation
panes' `translateSurface` — `d -= delta * normal.z` — was written and
documented as a **divergence**, on the grounds that `AdjustSectorHeight`'s
slope arm (`d -= delta * sin(angleZ)`) under-shoots a drag. The reasoning was
right; the conclusion was wrong. UDB has BOTH rules, for two different
operations, and the one for a user raising a surface is
`VisualFloor.ChangeHeight` (VisualFloor.cs:356) —
`FloorSlopeOffset -= FloorSlope.z * amount`, the same expression. **The
elevation drag was parity all along and simply had no citation yet.** The
divergence note is gone and the comment now points at the source.

Worth generalising: *deriving the right answer from first principles does not
make it an invention.* The honest label needed one more search, not a different
implementation — and the search only happened because a later port walked into
the same function from the other side.

**The two VERTEX-HEIGHT rules are not the same either.**
`AdjustSectorHeight` moves each vertex whose height is not NaN, individually.
`ChangeVertexHeight` (:379-393) returns early unless **all three** have
heights — "Do this only if all 3 verts have offsets" — because a user raising a
surface must not turn a flat sector into a half-sloped one by moving the single
vertex that happens to carry a height. Reading either as the other loses a rule.

**Pointing at a WALL and changing height does nothing, and that is correct.**
`OnChangeTargetHeight` on a sidedef changes the SECTOR's height, and which
surface is a preference — `changeheightbysidedef`, "0 = nothing, 1 = ceiling,
2 = floor" (BuilderPlug.cs:108) — which ships at **0**
(PreferencesForm.cs:45). It looks like a bug and is not.

**`GetRoundedTextureOffset` has three oddities and all three are kept**
(:576-584): a whole tile of offset is a no-op, the result wraps into one tile,
and an offset that rounds back to where it started is nudged one unit further —
the source's own comment on that line is *"biwa. Why?"*. A consequence worth
knowing: **with no texture loaded UDB passes a size of -1, every whole-number
offset is a multiple of -1, and the offset cannot be nudged at all.** Confirmed
in the browser — the action runs, records its level, reports "Changed texture
offsets to 0, 0." and moves nothing. A test asserted it grew; the code was
right and the test was wrong.

**The undo level is opened BEFORE the write** (VisualFloor.cs:354 then :356),
and that took a browser probe to notice: the height changed, the status line
was right, and the undo stack stayed empty. Under progressive recording a
command captures the value it is about to overwrite, so a level created
afterwards records the new value as the old one. The description differs per
branch, so the callback fires inside each branch rather than once at the top.

**Two things I got wrong that the SOURCE settled**, both of which I had
asserted the other way in a test first: UDB writes BOTH offset fields every
time, including the axis that did not move; and `UndoGroup` was already ported
in `undo.js`, so naming a second copy here made the module fail to load.

**Verified in the running editor:** aim down, three presses of +8 raise the
floor to 24 as ONE grouped undo level, and one undo returns it to 0. Aim at a
wall and the offset action runs, records, and correctly moves nothing with no
resources loaded.

---

### `PickObject` — done (`pick.js`, 2026-09-06)

**743 self-tests, 741 citations, 304 claims.** The 5c row that has said "next"
since the 3D foundation landed: nothing in the 3D view can be highlighted,
selected or edited until the camera ray can say what it is aiming at.

**Three passes, and the reason is cost** (VisualMode.cs:841-1046): gather the
geometry the ray could reach, cheaply reject most of it, then test the rest
accurately and keep the CLOSEST hit.

Things that had to be read rather than guessed, each with a test and a
mutation:

- **`Plane.GetIntersection`'s sign convention** (Plane.cs:116-129) — `w` is the
  dot with `from - to`, not `to - from`, and the numerator is `offset + v`
  rather than a difference. Written the obvious way round every parameter comes
  out negated and nothing is ever pickable.
- **BOTH sectors of a crossed line are gathered**, whichever side the ray is
  on, and UDB says why (:898-901): aiming at a sector that is just within range
  would otherwise miss, because the far line of that sector may not be in the
  loop at all. The case is a ray that ENDS inside the far sector.
- **A sidedef is offered only from the side it faces** (:931, :977). Offering
  both puts the far room's wall in the candidate set at the same distance, and
  which wins becomes an accident of iteration order.
- **The bounding box is not the sector.** `VisualFloor.PickFastReject` tests
  the box, and `PickAccurate` then asks whether the hit is on the inside of the
  nearest sidedef (:452-457) — without which the floor of an L-shaped room is
  pickable through its own notch.
- **A thing you stand inside is picked at its FAR wall** — `tnear` when the ray
  starts outside, `tfar` when inside (BaseVisualThing.cs:643). And the slab
  swap (:584) is not decoration: along a negative axis the two parameters come
  out reversed and every hit from that side reads as a miss.
- **`hitpos` is `from + to * u`** (:1035). That is `to`, not `to - from`, so
  the point is not on the ray unless the camera sits at the origin. It is a bug
  in UDB, it is ported as written, nothing consumes it, and the corrected value
  is offered BESIDE it as `hitpointOnRay` so a future caller chooses knowingly
  rather than inheriting it.

**`wallPlanes` was pulled out of `buildWall`** so picking and building bound a
wall part the same way. A picker that decided those bounds for itself could
disagree with what was drawn — you would click a wall that is not there, or
miss one that is.

**Two fixture lessons, both earned here.** The shared `ROOM` fixture is wound
counter-clockwise, so its interior is on the BACK of every line while every
line carries only a front sidedef — its walls face OUT. That is invisible to
the tests that use it and fatal to picking, which is entirely about facing; a
correctly wound `PICK_ROOM` was added rather than changing a fixture a dozen
other tests depend on. And the thing tests only ever fired rays along +x, which
is exactly the direction the slab swap does not matter in.

**A bug found on the way, older than this work.** `sectorAt` — the containment
lookup the info panel uses for a thing's sector — was written out in
`view.html` and copied into `visualpane.js`, and both copies were wrong TWICE:
`triangulateSectorNode` returns `{ islands, triangles }`, so `tris.length` was
`undefined` and the loop never ran; and each entry of `triangles` is a whole
TRIANGLE, not three loose points, so it was misread even had it run. It failed
SILENTLY, because "no sector here" is a legitimate answer over void. It is one
shared `sectorAtPoint` in `triangulate.js` now, next to the function whose
shape it has to know.

**Verified in the running editor**, camera inside a room: level north picks a
middle wall at 256 units, straight down the floor at 41, straight up the
ceiling at 87. The pane names its target in the status bar, which is both the
first use of picking and the way to see it working.

---

### The corner that went down when the cursor went up (2026-09-06)

**719 self-tests.** One symptom, two causes, and both came from the drag
rebuilding the plane out of the plane it had just written.

**It COMPOUNDED.** `want` is measured from the press, so adding it to a plane
that already carried the previous frame's `want` doubled the movement and ran
away from the pointer.

**And it FLIPPED SIDES mid-drag.** `edgeWOf` picks the end of the sector the
silhouette is drawn from, and which end that is depends on which way the
surface tilts. A drag large enough to carry the surface THROUGH FLAT therefore
swapped the reference to the opposite side of the sector, and the corner jumped
the other way — "goes down when I move the cursor up", exactly.

The fix is structural rather than a correction: `cornerSlopeBasis` captures
everything the drag needs ONCE at the press — both ends' heights, the reference
edge, and the cross-axis tilt — and `slopeFromBasis(basis, want)` is then a
**pure function of one scalar**. The same `want` always produces the same
plane, so neither failure is expressible.

**Two of the four mutations against this went uncaught at first, and the
fixture was why.** The monotonicity test measures against the basis, so a wrong
basis is self-consistent with it; a second test had to tie the basis to the
sector's drawn geometry. And that test's first fixture sloped its ceiling
DOWNWARD across the hidden axis, which puts the silhouette edge at the
sector's minimum — the same end a wrong rule picks. Sloping it upward instead
put the drawn edge at the maximum, where only the right rule finds it.
**A fixture that agrees with the bug proves nothing.**

Verified in the browser with a continuous eleven-move drag sweeping up to +224
and back down through flat to -160: the corner tracked every step
monotonically, and the whole drag was ONE undo level that reverts.

---

### The handles that appeared "outside edit mode" (2026-09-06)

**717 self-tests.** The report was two orange squares in an elevation pane
while not in edit mode. They were the RESIZE PIPS, and three things had to be
true at once for them to look like that.

**1. The mode was still engaged, invisibly.** Nothing on screen said so:
`currentEditModeClass()` never returned `EditSelectionMode`, so the Mode menu
and the modes strip went on showing Sectors while Edit Selection was running.
The only evidence the mode was engaged was the handles its panes drew — which
therefore read as handles appearing for no reason. It now returns the mode's
own class name (EditSelectionMode.cs:48) and the menu checks it.

**2. Nothing made the mode LET GO.** Enter and Escape were the only exits, so
switching editing mode or tool left it engaged behind an unrelated mode. Both
switches now leave it, which is what UDB's `ChangeMode` does to the mode it
replaces.

**3. The pips fell back to Z = 0.** Their vertical position is the middle of
the geometry at their end of the selection; with nothing reaching that end
there is no such middle, and the fallback was zero — which for a sector with a
floor at 0 is exactly the floor line. So the fallback did not look like a
fallback, it looked like two stray grab handles sitting on the floor. **A
control with nothing to control is now absent rather than parked at the
origin.**

Worth keeping as a pattern: **an invisible mode is a bug even when its
behaviour is correct.** Two of these three fixes are about making the state
legible rather than about changing what it does, and the third only became
confusing because the first two hid its cause.

---

### Two bugs I introduced, and how (2026-09-06)

**716 self-tests.** Both were caused by the previous pass, and both are the
same shape: a change that was right in itself broke something it now sat
inside.

**1. Entering Edit Selection mode stopped undo recording — because editing was
moved INSIDE it.** `EditSelectionMode` sets `IgnorePropChanges` while engaged
(EditSelectionMode.cs:1348), and it is right to: the mode moves geometry
continuously and only its accept path opens a level. The pass before had gated
every elevation gesture behind that mode. So every elevation write happened
with recording off, and nothing an elevation did was undoable.

**And the second-order failure was worse than the first.** A cancelled drag
calls `WithdrawUndo`, which performs an undo — against an EMPTY level, so it
undid whatever unrelated edit came before it. A gate that looked like a safety
improvement turned Escape into a data-loss button.

The fix is small and belongs to the caller: an elevation drag turns recording
back on for its own transaction and puts the flag back exactly as it found it,
so the mode's own behaviour is untouched. The general lesson: **a global flag
owned by one mode becomes everyone's problem the moment anything else runs
inside that mode.**

**2. The dragged corners lagged the cursor, and it was a disagreement between
what is DRAWN and what is COMPUTED.** The handle sits on the silhouette — the
lowest floor, the HIGHEST ceiling — while the plane was read at the sector's
minimum on the axis the pane cannot see. For a surface tilted across that axis
those are opposite ends of the sector, so the corner moved to
`plane(min) + delta` while the pointer was at `drawn + delta`: a constant
offset, which is exactly what lag feels like.

`edgeWOf` now picks the end the silhouette is actually drawn from — an extreme
of a linear function over the sector's range, so it is always one end or the
other — and both `planeZAt` and `slopeFromCorner` use it. A corner drag also
references the CORNER's height rather than the height at the press position,
so a press anywhere inside the handle square moves it by the same amount.

Verified in the browser: a drag of ~62 units on a ceiling tilted across Y moved
the drawn corner from 176 to 224 — on the grid, far end pinned, one undo level
called "Slope ceiling", and undo returns it.

---

### The elevation panes, fourth pass — tracking, gating, edges (2026-09-06)

**714 self-tests.** Reese's list, and the interesting one is the third.

**Everything in a pane is now behind EDIT SELECTION MODE**, not just the slope
handles — floor, ceiling, body and corner alike. Reese asked for the slope
handles first and then flatly for the ceiling edge, and the general rule is the
better one: a pane you are only looking at cannot change the map, which also
makes the panes safe to leave on while drawing in the top view. The handles are
not drawn outside the mode either, because an affordance offering a gesture
that will be refused is worse than no affordance. The WIREFRAME is not gated —
seeing a shape is not editing it.

**The drag now tracks the pointer, and it took two separate fixes.**

*First*, the reference was the sector's NOMINAL height. On a sloped sector —
or at any position along a sloped edge — `heightceiling` is not the height of
the line under the cursor, so the first movement jumped by the difference and
everything after it was offset by a constant. It is now the height of the EDGE
THAT WAS GRABBED at the position it was grabbed, so the arithmetic is
`snap(grabbed + mouseDelta) - grabbed`: the line under the cursor lands on a
grid line AND follows the mouse. Both only hold when the reference is the thing
being dragged.

*Second, and this is the one worth remembering:* **`AdjustSectorHeight`'s slope
arm is the wrong operation for a drag, and it is still right for what UDB uses
it for.** It moves `floorplane_d` by `delta * sin(angleZ)` — designed to keep a
slope's ANGLE while the sector's nominal height conforms to its neighbours, and
called there with a delta derived from the neighbouring sectors, never from a
pointer. Driven by a drag it under-shoots: a 64-unit pull moved the visible
ceiling **47.1**, which is exactly `64 * sin(angleZ)` and exactly the
"over/underdrag" reported. Dragging a drawn edge means TRANSLATE, so
`translateSurface` does `d -= delta * c` for a plane `z = (-d - ax - by) / c`,
and falls through to the ported function for everything else — which keeps the
three-sidedef vertex arm where it belongs. **`adjustSectorHeight` is untouched:
it is parity and `EditSelectionMode` calls it.** This is a divergence with a
reason, not a correction of UDB.

**The real EDGES of every sector are drawn**, not only the selected ones, and
in the CACHED context layer because they are a function of the geometry alone.
The silhouette is an outline — lowest floor, highest ceiling — and a trapezoid
could be several shapes; the edges say which. The selection's own wireframe is
the same path drawn brighter, so the two cannot disagree.

**The resize pips sit on their own line**, each centred on the vertical extent
at ITS end rather than on the selection's bounding box. On a stair or a sloped
room a box-centred pip ends up nowhere near the line it moves.

**"Center in 3D View"** puts the 3D pane's camera on the selection — an
addition, in the View menu beside the other pane items, and outside the derived
table like them. The camera is pulled back along its CURRENT heading rather
than moved to a canned viewpoint, so the view swings to the selection without
throwing away which way you were facing; the distance is the selection's own
size.

**A verification trap that cost several probes:** a stale module. The dev
server sends no `Cache-Control`, so `location.reload()` alone can leave a
module cached while a freshly `import()`ed copy in the console is new — the two
then disagree and the page looks like it is ignoring the fix. `fetch(url,
{cache: 'reload'})` for **every** module the page imports, before reloading.

---

### Snapping in the elevation panes — third pass (2026-09-06)

**711 self-tests.** Two snapping defects, both found by Reese using it.

**A CORNER drag was snapping against the wrong number.** `snapHeightDelta`
measures a drag against a reference, and for a corner that reference has to be
**the corner's own height, read off the surface's plane** — not
`heightfloor`/`heightceiling`. The moment a surface is already sloped the two
are different numbers, so the corner came to rest between grid lines while the
sector's nominal height was the thing that lined up. Both the snap and the
plane construction now go through one `planeZAt`, so they cannot disagree about
which number they mean. Verified: a 77-unit pull on a 32 grid put the corner on
192.

**The horizontal RESIZE was not snapping at all** — the pip drag called
`update` with no options. It now passes `resizeSnapOptions(gridSize)`, which is
UDB's own rule and the reason Reese's "same rules as other resize" is a rule
rather than a preference: **the MOUSE POSITION is put on the grid and the scale
is computed from it** (:585-589), rather than the resulting size being rounded
afterwards. So the dragged EDGE lands on a grid line. Verified: a deliberately
awkward 101-unit drag landed the east edge on -32, with the west edge stuck.

**Two mutations went uncaught because they lived in DOM-driven paths**, which
`selftest.js` cannot reach. Both were extracted into pure functions —
`planeZAt` and `resizeSnapOptions` — and then bit. That is the same move
`canSlopeOn` needed a pass earlier, and it is worth generalising: **if a rule
cannot be reached without a browser, the rule is in the wrong place.**

---

### The elevation panes, second pass — Reese's six corrections (2026-09-06)

**707 self-tests.** All six were real; three were defects rather than gaps.

**1. Undo could not get back to the original state — and the cause was EMPTY
LEVELS.** The undo machinery was fine (a Node harness confirmed both a height
change and a slope record and replay correctly). The page was opening a level
on every MOUSEDOWN, so a press that never moved far enough to change anything
left an empty level behind; a stack of those makes undo look broken, because
each press consumes one and nothing on screen moves. **The level is now opened
lazily, on the first write** — the same rule `dragmode.js` already followed and
the reason its comment says the level is opened by `drop`, not by the press.

**2. Escape now cancels a drag in progress**, restoring a snapshot of
everything the drag can touch — both heights, all eight plane fields, and the
vertex heights `adjustSectorHeight`'s second arm moves. A key that was ABSENT
goes back to absent rather than to zero: the lossless writer would otherwise
leave a field in the file the map never had. If a level was opened it is
`WithdrawUndo`n, so a cancelled edit leaves nothing to step through. (A test
asserting `heightfloor === 0` after a cancel FAILED, correctly — the room never
had the key, and the drag had created it.)

**3. A surface can now slope in TWO directions.** Each corner drag used to
build a plane that was flat across the axis its pane could not see, so sloping
in XZ and then in YZ threw the first slope away. The cross-axis tilt is now
carried over from the plane the surface already has, and the two ends are
measured on that plane at a common reference position rather than off the
silhouette — the silhouette reports the lowest floor and highest ceiling ACROSS
the sector, which is the right thing to draw and the wrong thing to build a
plane from once the surface is tilted. Verified in the browser: after an X drag
and then a Y drag the ceiling runs 256→160 along x AND 240→176 along y.

**4. The real EDGES and VERTICES are drawn from the side.** The grey bars are a
silhouette, which is right for reading a crowded map and tells you nothing
about the shape inside it. Each editable sector now also draws its linedefs at
floor and ceiling height, the wall at each end, and a dot per vertex per plane
— in `COLORS.linedefs` and `COLORS.vertices`, the top pane's own colours for
the same two things. A slope now reads as a slope.

**5. Sloping is offered only on the ONE sector being edited.** A plane belongs
to one sector, so with several selected a corner handle would have to guess
which to tilt, and guessing silently is worse than not offering the handle. The
flat height drags still act on the whole selection.

**6. Horizontal RESIZE, one pip per vertical side, only in Edit Selection
mode** — and it contains no resize logic. Reese's instruction was explicit:
*"no need to change drag logic, just treat drags from a certain direction as
drags from the top center/bottom/left/right center edges as applicable."* So a
pip NAMES a grip and `EditSelectionMode.beginGrip` runs the ported gesture,
axis filter and stuck corner and all. `beginAt` is now that function with the
grip hit-tested, so there is one path, not two. Which grip depends on the pane:
UDB's box is built on `baseoffset = (min x, min y)`, so N is the min-Y edge and
W the min-X one — **the XZ pane's left pip is `SizeW` and the YZ pane's left
pip is `SizeN`**, which looks like an inconsistency and is not. Verified: an
east-pip drag moved the east edge and left the west edge and both Y edges
exactly where they were.

**A bug the browser found before Reese could.** The corner handles are drawn
CENTRED on the ends of a bar, so half of each square hangs outside the span —
and the hit test rejected anything outside the span, so the outer half of every
corner handle silently did nothing. The span is widened by the grab range and
the position clamped back into it. The lesson generalises: **anything drawn as
grabbable must be grabbable everywhere it is drawn.**

---

### Editing in the elevation panes — done (`elevation.js`, 2026-09-06)

Four gestures, and **every one of them ends in ported semantics** — which is
the whole reason `EditSelectionMode` was built first. **702 self-tests, 685
citations, 292 claims.**

| gesture | what it does | what does it |
|---|---|---|
| drag a floor edge | moves the floor | `adjustSectorHeight(delta, null)` |
| drag a ceiling edge | moves the ceiling | `adjustSectorHeight(null, delta)` |
| drag the body | moves both, locked | `adjustSectorHeight(delta, delta)` |
| drag a CORNER handle | **slopes** that surface | `Plane.fromPoints`, written as the sector dialog writes it |

**Two decisions were Reese's, and UDB has no answer to either** — it never
drives a height change from a free vertical drag, so there is nothing to port:
a drag snaps to the **status bar's grid size**, and it acts on the
**selection, or the highlight standing in for it**.

**The grid is drawn in the elevations too**, from the same `gridLines` the top
view calls and in the same two colours, so the lines land on the same
coordinates and what you snap to is what you can see. Nothing here is a second
grid setting: the size is pushed in from the status bar.

**The affordances are the overhead view's.** Reese asked for them to match, so
the handles are `GRIP_SIZE` squares in `COLORS.highlight` — literally
`EditSelectionMode`'s own constant and UDB's own colour — at both ends and the
middle of each edge, with the hovered one filled and its edge line thickened.
Three per edge rather than one because a bar is wide and flat and a single
centre handle on a 1,024-unit span reads as a speck.

**The corner drag is the first thing in this project that AUTHORS a slope.**
5c has said throughout that nothing here writes `floorplane_*` deliberately —
`planes.js` reads planes, `AdjustSectorHeight` and the drag tail PRESERVE one.
This writes one. What it writes is not invented: `Plane.fromPoints` is UDB's
own constructor, which forces the normal up for a floor and down for a ceiling,
and the four fields are written exactly as `editstate.js` writes them through
`controlToSlope` — `_a`/`_b`/`_c` from the normal, `_d` from the offset, as
floats. A second spelling of those four fields is how two writers drift apart.

**A slope from an elevation can only tilt the axis the pane shows.** The fixed
end contributes TWO points spanning the sector's whole width on the other axis,
so the plane is flat across it. An elevation shows one axis, and a gesture in
it must not silently tilt the one it does not show. (An equivalent mutation is
recorded in place: sliding the third point along that same axis changes
nothing, with the cross-product shown.)

**A bug the browser found, and it was a real one.** The corner handles are
drawn CENTRED on the ends of the bar, so half of each square hangs outside the
span — and `partAt` rejected anything outside the span, so the outer half of
every corner handle silently did nothing. The span is widened by the grab range
and the position clamped back into it before the edge heights are interpolated;
without the clamp a steep slope extrapolates the floor far below where it is
and the handle you are standing on reports the body instead. Both halves have
tests, because the first mutation of the fix went uncaught.

**Verified in the browser**: hover shows a crosshair on a corner and
`ns-resize` on an edge; a ceiling drag of 96 units snapped to 224 on a 32 grid;
a corner drag left the fixed end at 128, put the dragged end at 224, came out
flat across y, and recorded one undo level called "Slope ceiling"; and the 3D
pane rebuilt to match.

---

### `EditSelectionMode` — done (`editselection.js`, 2026-09-06)

Reese's instruction when the pane-editing design came up: **"parity first,
prioritize parity before inventions."** So the mode was built before any
elevation gesture, which is what 5d asks for anyway — it calls this mode the
keystone, and porting it converts the invention in the panes from *semantics
plus driver* down to *driver only*. **695 self-tests, 684 citations, 292
claims.**

**The mode is a TRANSFORM OVER A FROZEN SNAPSHOT**, and that is the design
worth carrying. `OnEngage` records every selected vertex's and thing's original
position and the selection's bounding box; every gesture then edits four
numbers — `offset`, `size`, `rotation` and the derived `scale` — and
`UpdateGeometry` re-derives every position from the ORIGINALS. Nothing
accumulates, which is why a resize then a rotate then a drag is exact rather
than drifting, and why cancelling is just replaying the snapshot.

Things that had to be read rather than guessed, each with a test:

- **`Angle2D.DoomToReal` is not a degree-to-radian conversion.** It carries a
  quarter turn — `DegToRad(doomangle + 90)`, and `RealToDoom` subtracts a right
  angle back (Angle2D.cs:42, :48). Reading it as a plain conversion is a
  90-degree error on every rotated thing, and only a map with things in it
  would show it.
- **The two thing-flip cascades are DIFFERENT functions** (:766-799), not one
  with the axes swapped, and each is four quadrant branches whose arithmetic is
  UDB's own. Both are involutions; folding them together is the obvious tidy-up
  and is wrong.
- **There are FOUR transform functions, not one**, and UDB says why: "0.0
  rotation and 1.0 scale may still give slight inaccuracies". A selection
  dragged with no rotation must land on exactly the integers it started on.
- **Each resize grip sticks a DIFFERENT corner** — N sticks 2, S and E stick 0,
  W sticks 1 (:2151-2224) — and the stuck corner is put back by moving the
  whole box (:614-616). **A test that only drags EAST passes with that
  correction deleted**, because east sticks the corner the scale already pivots
  about. The north drag is the one that catches it.
- **The flip test is XOR, not OR** (:860): one axis reversed turns the windings
  inside out and the lines must follow; both axes is a 180-degree rotation and
  they must not. It is compared against a flag so the flip happens once rather
  than on every mouse move.
- **A selection squashed exactly onto a line is REFUSED** (:601-603) rather than
  clamped — `size / basesize` would be zero in all four transforms and dragging
  back could not recover the geometry.
- **The box is the LAST grip tested** (:688). Grips overlap at the corners of a
  small selection, and testing the box first swallows all eight.
- **The undo level is created with the geometry back HOME** (:1624), the same
  dance `dragmode.js` performs: a property command captures the value it is
  about to overwrite, so a level created after the move records the new
  position as the old one and undo does nothing.

**`adjustSectorHeight` is ported in full** (:1213-1280) — it is the primitive
the elevation panes need. Both arms and both guards: a UDMF sector slope has
its offset moved by `delta * sin(angleZ)`; otherwise, and **only on a sector
with exactly three sidedefs**, the per-vertex heights move instead (three
points determine a plane, four need not). A vertex with NO height is left
alone, and `int.MinValue` — ported as `null` — means leave that plane alone
rather than add zero, which on a sector with no `heightceiling` would otherwise
INVENT the key in the saved file.

**On Reese's own corpus neither arm usually fires**: zero UDMF sector slopes,
slopes through Plane_Align lines whose plane re-derives from the sector height
for free, and 37 vertex heights on three-sidedef sectors. The slope arm is
correct, ported, and mostly inert here — worth knowing before someone decides
it is dead code.

Two bugs found in the browser, both mine:

- **`setViewScale` before `engage`.** The grips are sized from the bounding
  box, which does not exist until the snapshot is taken, so it read an
  undefined `baseoffset`. Guarded on `engaged` now.
- **`COLORS` was never imported into `view.html`.** The overlay threw halfway
  through, which left a partially stroked path on screen AND left
  `ignorePropChanges` true — so the editor silently stopped recording undo for
  everything, not just this mode. A throw between setting a flag and clearing
  it is worse than the drawing bug that caused it.

**Wired and verified end to end**: Mode menu ▸ Edit Selection Mode with a
selection made, drag to move, grips to resize and rotate, Enter accepts and
Escape cancels, the Edit menu reads "Undo Edit selection", and undo puts the
geometry back.

---

### What is BUILT of 5d — the layout, the elevations and the 3D pane (2026-09-06)

`panes.js`, `elevation.js` and `visualpane.js`. Reese asked for the Hammer
layout after the option dockers, for the quadrants to be "edge drag resizable,
sort of like blender subviews", for the toggle to live in the View menu, for
their own quadrant arrangement, for pan and zoom in the elevations, and for
visual mode to be in this project rather than on a bench page. All of that is
in. **660 self-tests, 666 citations, 281 claims.**

**The layout** (`panes.js`) is 1 / 2 / 4 panes, defaulting to `single` — which
is today's editor, unchanged — so nothing regresses and the layout is opt-in.
The splitters are SHARED EDGES: one vertical fraction and one horizontal
fraction for the whole grid, so dragging a divider moves both panes either side
of it and the rows stay aligned, and dragging the CROSS moves both at once.
Per-pane widths would let the grid go ragged, which is not what the reference
feels like. The quadrants ARE equal (0.5 / 0.5) — Reese's instruction of
2026-09-07, reversing a default of mine; see the bullet in "Layout and
performance pragmatics" above. Nothing persists the fractions, so the default
is what every session starts at, including one coming up straight into a saved
Hammer layout.

**The arrangement is Reese's**, given a second time after the first was built:
3D top left, the XY view top right, the two elevations along the bottom. That
is why a pane is addressed by NAME (`SLOT_PANES`) rather than by position —
the first version made slot 0 the XY view by construction and could not be
rearranged without touching the host.

**The elevations** (`elevation.js`) draw the whole map in GREY with the
selection highlighted, which is 5d's answer to the projection problem. The grey
layer is on its OWN CANVAS and is repainted only on a geometry change or a view
change; a selection change repaints the highlight alone. That is the caching
requirement made structural rather than remembered.

A sector's bar is its SILHOUETTE, sampled at the sector's own vertices: the
lowest floor and the highest ceiling at each position along the pane's axis.
For a flat sector that collapses to exactly the rectangle it should be; for a
sloped one it is the trapezoid 5d asks for, and it is right for a slope that
runs across the pane's axis rather than along it, where two ends alone would be
wrong. The sloped case has its own test with the floor and the ceiling tilted
in OPPOSITE directions, because a fixture flat in either half leaves a
min-versus-max mutation uncaught — both were, until it existed.

Pan and zoom are the 2D view's own gestures (wheel to scroll, Alt-wheel or a
trackpad pinch to zoom about the cursor, middle-drag or space-drag to pan) over
`View`'s ported maths. What is invented is which gesture reaches it; the
transform is not.

**The 3D pane** (`visualpane.js`) is `visual.html`'s wiring as a class. It cost
so little because of the property section 5d told us not to regress:
`VisualRenderer` takes a canvas, `VisualCamera` is parameterised by viewport
size, and `visualgeometry.js` is pure functions of the document. It renders
only while the layout shows it, takes pointer lock on a click inside itself,
and gates WASD on holding that lock so the keyboard is not fought over.

**What it does NOT do is edit**, and neither do the elevations. 5d's elevation
vocabulary — drag a floor plane, drag a ceiling plane, drag a slope handle,
move the selection in Z, resize from grips — rests on `EditSelectionMode`,
which 5c owes and 5d calls the keystone: `AdjustSectorHeight` is what a
vertical drag must call and its grips are what a resize must present. Building
the drags first would mean inventing the semantics as well as the driver, which
is the trade 5d says not to make. The 3D pane picks nothing for the same
reason — `PickObject` does not exist yet.

**The View menu collision, resolved without relaxing anything.** Reese asked
for the toggle to be in the View menu. UDB's View menu has no pane items and
`menuparity.js` diffs 124 derived entries against the designer, so the items
are passed to `MenuBar` as ADDITIONS and appended to the INSTANTIATED tree —
`MENU_LAYOUT` is untouched, `menuparity` still passes unchanged, and the rows
are marked in the DOM and in CSS so nobody reading the bar mistakes one for
parity. Their action tags carry a `wwwdb_` prefix rather than `builder_` for
the same reason.

Two bugs worth carrying, both found in the browser:

- **`Number('xz')` is NaN.** Naming the panes turned the slot keys from indices
  into strings and one `Number(slot)` survived, so every pane lookup came back
  `undefined` and nothing sized or drew. It threw in a handler, which is why
  the page looked merely blank rather than broken.
- **Fit AFTER sizing, never before.** `centerOnArea` computes its scale from
  the view's window size, so fitting a pane that has not been measured fits it
  to the placeholder canvas and the scale clamps at `SCALE_MIN`. The map came
  out as a strip pinned near an edge.

---

### citecheck learns the shorthand — and the pages it never read (2026-09-11)

**wwwdb-awi.** Two gaps in citecheck.js itself, both drift-in-the-checker
rather than drift-in-the-port: `view.html`, `visual.html`, `demo.html` and
`gfx.html` were never in `MODULES` — 87 explicit citations in `view.html`
alone had never been checked — and the shorthand `:NNN` form, used everywhere
once a nearby line names the source file, was invisible to the structural
scan entirely. Roughly half the tree's citations, by the bug's own count.

**The pages: a one-line fix, done first.** Added all four to `MODULES`. All
87 newly-scanned citations already resolved; nothing to correct, just
nothing left unguarded.

**The shorthand took a false start, and the false start is worth keeping
here because it explains the design.** The obvious algorithm — anchor a bare
`:NNN` to the nearest PRECEDING explicit `File.ext:NNN` citation, reading the
module top to bottom — is exactly how a person resolves one, and it is
RIGHT for a file shaped like `savemap.js`: each function's shorthand run
sits directly under the citation that introduces it. It is WRONG for a file
like `editselection.js`: a single one-line aside — "`Thing.Angle` is
`anglerad` (Thing.cs:111, :229)" — inside a file about `EditSelectionMode.cs`
LEAKED, becoming the anchor for every bare citation after it until the next
explicit citation happened to correct it. 23 citations resolved against the
wrong file, and 18 of them still passed the range check — silently right for
the wrong reason, purely because `Thing.cs` and `Angle2D.cs` happened to
both be long enough. That is the exact failure `DISAMBIGUATE`'s own comment
already warns about for duplicate basenames, now shown to apply to *any*
citation an aside can shadow. `render2d.js` had the same shape: a two-item
parenthetical (`Thing.cs:125, :641`) hijacked the anchor for a `RedrawSurface`
citation that belonged to `Renderer2D.cs`.

**The fix: only a directive moves the anchor.** `@cite-default` (once, near
a header) and `@cite-scope` (inline, wherever the subject changes) are the
same directive under two names; an ordinary explicit citation is still
checked structurally but never changes what a *following* bare citation
resolves against. That is a deliberate, visible act instead of an inferred
one, and it is what makes the checker's silence trustworthy: no anchor in
effect is now a FAILURE, not a fallback to a guess.

**The rollout.** Every module in `MODULES` with a bare citation now carries
the directives it needs — 1540 bare citations resolve through an explicit
anchor, 0 with none in effect, 0 out of range. Most files needed one
`@cite-default` near the header; the genuinely multi-source ones
(`visualedit.js`, `visualselect.js`, `render2d.js`, `pick.js`, `dockers.js`,
among others) needed `@cite-scope` at each real change of subject, sometimes
mid-paragraph where a single sentence names two classes. `@cite-scope none`
is the one other value a directive carries, for the rare paragraph that
cites THIS repo's own files rather than UDB's (`electron-app/main.js`'s
line numbers in section 2, and the self-referential `render2d.js` /
`view.html` line numbers in this section's own "why it is cheap" note) — a
different kind of pointer citecheck was never built to verify, said so
rather than left silently unscanned.

**Not extended to `.cfg`.** `Actions.cfg` citations (`BuilderModes' Actions.cfg
:717-737`, seen in `visualpane.js`) are still outside the structural scan —
the explicit-citation regex only ever matched `.cs`/`.cpp`/`.h`, and widening
it to data files is a different, separable change from teaching the checker
the shorthand. Marked `@cite-scope none` at the one spot it came up rather
than guessed at.

**Verification.** Mutation-tested twice: a corrupted bare citation
(`:9999-9999` swapped in for a real one) goes red with the correct file and
line; a corrupted `@cite-default` pointed at a nonexistent file goes red at
the directive itself and at every bare citation depending on it, both
reverted after confirming. All 13 gates green, `citecheck.js` with **no**
argument (`./refs`, both `refs/udb` and `refs/gzdoom`) — the `refs/udb`-only
invocation used while this bead was in progress showed the `udmf.cpp` /
`maploader.cpp` citations as unresolvable the whole time; they were never a
real gap, just the wrong refs directory.

---

### zscript.js — a second tokeniser, because UDB brought a second tokeniser (2026-09-11)

**wwwdb-kdv.2.** DECORATE and ZScript share nothing at the tokeniser level in
UDB — `ZScriptTokenizer` is not a `ZDTextParser` subclass, it is a wholly
separate token-typed scanner (`ZScriptTokenType`: `Identifier` / `Integer` /
`Double` / `String` / `Name` / comments / 43 named punctuation and operator
tokens) that `ZScriptParser` drives instead of the char-level
`SkipWhitespace`/`ReadToken` `decorate.js` reuses from `zdtextures.js`. So
`zscript.js` ports a second tokeniser rather than stretching the first one
over syntax it was never shaped for — matching, not fighting, the reference.

**The two-phase parse.** DECORATE resolves `: base` in the SAME pass it reads
a class body, because nothing in DECORATE can be forward-referenced.
ZScript can — `class Foo : Bar` where `Bar` is declared later in the same
file — so `ZScriptParser.Parse` only collects a `ZScriptClassStructure` per
class (name, parent, replaces, and the STREAM POSITION where its body
starts), and `Finalize` revisits every one of them afterward, building the
real `ActorStructure` and resolving `baseclass` only then. Ported as two
passes for the same reason: `class`-header parsing (`parseClassOrStruct`)
skips the body by brace-matching and remembers where it started;
`ZScriptClassStructure.process()` (called from `Finalize`) rewinds there and
constructs the real `ZScriptActorStructure`.

**One real bug the two-phase design cost the port an hour on.** The whole
tree shares ONE `pos`/`s` pair on the parser (a deliberate simplification —
UDB's tokeniser is stateless enough that "a new one per construction" and
"one long-lived one" are the same thing, see zscript.js's own header note) —
EXCEPT across an `#include` boundary, where the included lump is a genuinely
different buffer. `ZScriptClassStructure` originally snapshotted only
`pos`; `Finalize` revisiting an included file's class reseeked into the
OUTER lump's text at that position and read garbage. Fixed by snapshotting
`{s, len, pos, sourcename}` together, mirroring UDB's own per-class
`Stream`/`Position`/`SourceName` capture (`ZScriptParser.cs:30-48`) more
literally than the position-only version did. Caught by an actual
`#include`-through-`Finalize` test, not by inspection — the kind of bug this
project's method exists to catch AFTER a design shortcut, not instead of one.

**Three genuine findings in UDB's OWN shipped code, not divergences of this
port's making — each pinned by a test and a `citecheck.js` claim so nobody
"fixes" them later into drift:**

  - `goto Super::State` inside a ZScript actor's `states{}` never actually
    resolves "super" — `ZScriptStateGoto` runs during `Process()`, before
    `Finalize` ever sets `actor.baseclass` (:1323 comes after :129 in
    execution order despite the file's own line order suggesting otherwise).
    `classname` stays the literal string `"super"`; nothing resolves it; the
    `goto` silently falls back to the state's own first frame.
  - `ZScriptParser.Finalize` returns `true` even down the path where a `user_`
    variable shadows a parent's and it calls `ReportError` — the `goto
    stopValidatingCompletely` lands on the SAME `return true;` every other
    exit does. A caller has to check `HasError` after `finalize()`, not trust
    the return value.
  - `ApplyMixin` (the `mixin Foo;` merge) copies exactly `height` / `radius`
    props, `spawnceiling` / `solid` flags, the `spawn` state, and `user_`
    variables — nothing else. A mixin's `+BRIGHT` or any other flag/property
    is silently NOT inherited by the class that mixes it in. Confirmed with a
    fixture and pinned so "obviously it should copy everything" doesn't
    quietly widen it later.

**Deferred, each a bead, none silently dropped:** the
`things = General.Map.Config.GetThingTypes()` block inside `Finalize`
(`:1339-1387`) — seeding state/flags/props/args from a `.cfg`-matched
`ThingTypeInfo` when a class's parent names a game-configured thing rather
than another parsed class — is the same shape of DataManager glue
`decorate.js` already defers, folded into **wwwdb-kdv.3**.
`ParseCustomArguments` (**wwwdb-kdv.4**, unchanged from decorate.js's own
deferral). Script-resource tracking (`AddTextResource`), same as
`zdtextures.js` and `decorate.js` already carry. And a NEW one, found only by
grepping the reference rather than assuming: **no ZScript actor ever gets a
`doomednum`** — `ActorStructure.cs`'s field is never once written by
`ZScriptActorStructure.cs` or `ZScriptParser.cs`. A pure-ZScript actor's
editor number has to come from GZDoom's MAPINFO `DoomEdNums` table, a wholly
separate subsystem this bead's file list never included — filed as
**wwwdb-8kt** rather than assumed to be someone else's problem already
covered.

**Verification.** 19 new `t()` blocks in `selftest.js`, real ZScript source
as fixtures throughout (a `class Actor native {}` stub stands in for the
engine's own bundled declaration, the same way DECORATE tests need no `.cfg`
— `checkActorSupported`'s "no map open" path). Five mutations tried against
the actual source (not reimplemented in the test): the mixin over-copy, the
leading-dot tokeniser quirk, the scale→xscale/yscale fold, `Finalize`'s
always-`true` return, and the `unique` argument to `getArchivedActorByName`
— all four of the first caught cleanly; the fifth (`unique`) didn't go red,
it HUNG — an actor replaced by itself created a self-referential `baseclass`
chain and the shadow-validation `while` loop spun forever, confirmed by
timeout and killed rather than mistaken for a pass. All reverted after
confirming. `citecheck.js`: `zscript.js` added to `MODULES`, 7 curated
`CLAIMS` (559 total, up from 552), 0 unresolvable / ambiguous / out-of-range
across both explicit and bare citations. `fetch-references.sh`'s
`REQUIRED_UDB` now lists the five ZScript `.cs` files. `actors.js` gained one
field, `uservar_defaults` (`ActorStructure.cs:57`, an empty Map until
wwwdb-kdv.4) — ZScript's own `$UserDefaultValue` needed it; DECORATE's `var`
never sets a default so `decorate.js` never needed the container. All 13
gates green. Left uncommitted, per this session's instructions — another
agent was concurrently working `citecheck.js` / `selftest.js` for wwwdb-5qh
and wwwdb-2ym; every edit here landed at a spot their diffs don't touch.

---

## 6. Questions — settled, and where the open ones live

**The open questions are beads**, in the `ver-*` family: the two UZDoom-only
sidedef fields (`sidedef.alpha`, `sidedef.blockrendering`), MAP22's duplicated
`BEHAVIOR` lump, `user_*` custom fields still being synthetic-only, config key
case sensitivity and `ValidateKeyword`. Several need an answer from Reese
rather than code. `bd list --json` finds them.

**What is settled is kept here**, because a settled question that is not
written down gets asked again:

- ~~**Which tool wrote the reference maps.**~~ **Settled — see 5b.** Not any
  Doom Builder (all four write vertices first), not SLADE (header and no `=`
  spacing). The formatting is unreachable from `OutputStructure`'s single
  `whitespace` flag, yet the field order is exactly UDB's. It is a
  re-serializer downstream of a Doom Builder save. `UDB_STYLE` stays as the
  master source specifies.

- ~~**Per-field float vs double typing.**~~ **Settled.** Regenerating 22 maps
  from the model with `formatDouble` for every float reproduces all **346,979**
  values byte-exactly. If any field were `float`-typed (`"0.000"`), at least one
  of those would have differed. `formatFloat` stays for UDB output that might
  use it, but nothing in this corpus does.

- **A stray `thing.flags` in `map31.wad`.** One thing carries `flags = 7`, the
  *binary* format's flags word, alongside the proper named UDMF flags. GZDoom's
  thing parser has no `NAME_Flags` case and UDB does not declare it, so nothing
  reads it — a converter artifact. Harmless, preserved on save, and reported by
  `fieldcoverage.js` rather than silently dropped. Worth knowing only because it
  is the single uncovered field in 375,201 blocks.

- ~~**UDB's exact wording for the core spec fields.**~~ **Read.**
  `Source/Core/Windows/*EditFormUDMF.Designer.cs` now ships in
  `fetch-references.sh`, and the labels are lifted rather than guessed. Three
  corrections came out of it: `thing.height` is labelled **"Z"**, not "Height";
  `special` is **"Action"** on a linedef and a thing but **"Special"** on a
  sector, so titles are per-block; and the linedef dialog contains **no
  reference to `v1`/`v2` at all** — those are now flagged `structural: true`,
  meaning real data that no dialog draws a control for. What is still derived:
  `texturefloor`, `textureceiling`, `type`, and the `arg0..arg4` placeholders
  (whose real titles come from `argsFor()` per action). Descriptors carry
  `titleSource` as `'udb-dialog'` / `'config'` / `'derived'` so a guess can
  never be mistaken for UDB's wording.

---

## 7. Where the remaining knowledge lives

For the next phase (dialogs), everything needed is already loading:

- `dbconfig.js` resolves UDB's tree into 15,424 thing-type entries and 12,689
  linedef-type entries across 51 configurations, each with human-written titles,
  argument names, types and enum references. From `GZDoom_DoomUDMF.cfg` alone:
  346 thing types, 221 linedef types, 688 typed arguments, 266 referencing one
  of 48 named enums.
- `flattenCatalogue()` pulls the numeric entries out of a resolved section.
- `fieldmodel.js` turns that tree into the descriptor list a dialog renders.
  `fieldsFor(blockType)` gives core spec fields, flags, activations and
  `universalfields` in dialog order; `argsFor(type, id)` gives the five args
  for the current action, which is the one genuinely dynamic part of the
  surface; `describe(block)` binds a parsed block to all of it and reports
  any field no descriptor covers rather than dropping it. For
  `UZDoom_DoomUDMF.cfg` that is 4 / 61 / 62 / 85 / 70 descriptors for
  vertex / linedef / sidedef / sector / thing.
- `defaults.js` holds the base UDMF 1.1 field set plus a substantial ZDoom set.
  It is deliberately incomplete: an unknown field has no default and is reported
  as a difference, which is a false positive you can look at rather than a false
  negative that hides corruption. **Add entries as you meet them; never remove
  one to quiet a diff.**

### Mutation and sector making — done

`topology.js` gained UDB's creation primitives (`MapSet.CreateVertex`,
`CreateLinedef`, `CreateSidedef`, `CreateSector`, `Linedef.Split`) plus the
marking DrawLines uses to find its own geometry again after stitching.

**New blocks are APPENDED**, and that is load-bearing rather than incidental:
appending shifts no index, so every untouched block still emits from its
original span and the lossless guarantee survives a geometry edit. Removal is
the opposite — it renumbers everything after it and rewrites every reference —
which is why it is deliberately not implemented yet rather than half-done.

`makesector.js` ports `Tools.MakeSector`, `Tools.JoinSector` and the helpers
under them (`TakeSidedefSettings`, `TakeSidedefDefaults`,
`ApplyDefaultsToSidedef`, `ApplyOverridesToSidedef`,
`Sidedef.High/Middle/LowRequired`, `Sidedef.RemoveUnneededTextures`,
`Linedef.ApplySidedFlags`).

**Re-reading `MakeSector` end to end found four things the first port missed**,
which is the whole of section 0 in one function. The first port stopped at the
sidedef loop and never reached the tail:

- **`TakeSidedefDefaults` (Tools.cs:599) was not called at all.** It is the last
  resort in the texture search — the map's `Default{Top,Wall,Bottom}Texture`.
  Without it a sector drawn in the void came out with no middle texture.
- **`RemoveUnneededTextures` was never called.** UDB calls it on BOTH sides of
  every line it touched, `(wassinglesided, false, wassinglesided)` — so a solid
  wall that had just become an opening kept the middle texture that no longer
  belonged on it.
- **Brightness was 160, and UDB uses 192.** 160 is what an ABSENT `lightlevel`
  means (UniversalStreamReader.cs:397); 192 is what UDB puts on a sector it
  CREATES (ProgramConfiguration.cs:443). The two-defaults trap from section 2,
  hit again in a different place.
- **The `useOverrides` branch was missing** — only its `else` (mxd's
  avoid-invalid-height guard) had been ported.

`JoinSector` is the counterpart: instead of creating a sector for the traced
region, an existing one absorbs it. Its asymmetries with `MakeSector` are UDB's
and are not tidied here — `ApplySidedFlags` fires on every side that had to be
CREATED rather than only when the double-sidedness changed, and
`RemoveUnneededTextures` runs on the OPPOSITE side with `force = true` rather
than on both sides with the was-single-sided flags. With `trace.js`
that is a working editor operation: point at a closed region with no sector and
get one, inheriting its look from whatever it touches. The inheritance search
order is UDB's and matters — sides facing the region first, then sides facing
away, then the nearest line outside, then defaults.

**A texture is cleared by DELETING the key, not by writing `"-"`.**
`Sidedef.SetTexture*` stores `"-"` for an empty name (Sidedef.cs:715) and
`UniversalStreamWriter.cs:255-257` then omits the field, so UDB's round trip for @cite-scope UniversalStreamWriter.cs
a cleared sidedef texture is an absent key. Writing an explicit `"-"` would be
the same map in different bytes — exactly what the span model exists to avoid.
Sector textures are the opposite: `texturefloor`/`textureceiling` are always
written (`:283-284`).

**`linedef.id` has two different defaults and this bit once already.** UDB's
editor reads `id` with a default of **0** (UniversalStreamReader.cs:257, and the
same for sectors at `:395` and things at `:188`) and tests `Tag == 0`. So an
explicit `id = 0` is UNTAGGED and an explicit `id = -1` is TAGGED — while
`defaults.js` correctly carries `-1` as the linedef's *semantic* default. An
earlier `RemoveUnneededTextures` read it with -1 and got both edge cases
backwards, stripping textures from scripted geometry and preserving them on
plain geometry.

Two grounded values worth recording: `singlesidedflag = "blocking"` and
`doublesidedflag = "twosided"` come from the game configuration
(`Includes/Common.cfg`), not from an assumption. The default floor/ceiling
heights do NOT — there is no such key in the .cfg tree; they are UDB
application settings, so they are options here.

**Documented divergence:** `HighRequired`/`LowRequired` implement UDB's final
rule (required when the opposite ceiling is lower / floor is higher) but not its
slope and per-vertex-height branches, because sector planes are not modelled
yet. The divergence runs towards requiring FEWER textures on sloped geometry —
a missing texture rather than a spurious one. Restore the branch when planes
exist.

### Stitching — done (`stitch.js`)

`SplitLinesByVertices`, `RemoveLoopedLinedefs`, `JoinOverlappingLines`,
`Linedef.Join` with `JoinChangeSidedefs`, `FlipVertices`, `FlipSidedefs`,
`Sidedef.AddTexturesTo` and `RemoveUnneededTextures` — the pieces
`MapSet.StitchGeometry` is assembled from.

The payoff case works: two single-sided lines back to back merge into ONE
two-sided line with a different sector on each side. That is how a shared wall
comes into being when you draw a room against an existing one.

**Scope, stated rather than implied:** this is `MergeGeometryMode.CLASSIC`,
which is what `StitchGeometry()` defaults to and what DrawLines uses. The
REPLACE branches are not ported and are not quietly folded in — REPLACE deletes
lines lying inside changed sectors, which is a different operation rather than a
refinement of this one.

Three details worth keeping:

- **The 0.001 "already uses this vertex" test is not the split distance.** It
  compares COORDINATES, not identity, so a line ending a hair away from a vertex
  is not split at it.
- **`AddTexturesTo` weights its slots**: middle counts 2, upper and lower count
  1 each, and the base `offsetx`/`offsety` travel only when the total reaches 2.
  A middle alone brings them; an upper alone does not.
- **`RemoveUnneededTextures` refuses to act on tagged or actioned geometry.**
  Anything scripted might need a texture the current geometry does not justify.

### Splitting — done (`splitsectors.js`)

`Tools.SplitOuterSectors` (Tools.cs:2253) and `Tools.SectorWasInvalid`
(Tools.cs:2338). This is the phase that runs after `DrawLines`, on the marked
lines, and it only ever looks at **multipart** sectors — ones whose
triangulation yields more than one disjoint island. Drawing does not split a
sector by itself; both halves keep referencing the one sector until this gives
one of them its own.

`triangulate.js` gained `sectorEdgesOf()` and `triangulateSectorNode()` for it:
the same answer as `triangulateSector()` but read from a live `SectorNode`'s own
sidedefs rather than from the document, which is both what `Sector.Triangles` is
in UDB and O(sides) instead of O(all linedefs). `crosscheck.js` runs the two
paths against each other over the corpus — **19,100 sectors, 0 disagreements**,
and it reports **10 genuinely multipart sectors**, so the filter is not
hypothetical.

Three things worth keeping:

- **`SectorWasInvalid` is not a predicate.** It reads as a question and it
  performs the removal, which is why `SplitOuterSectors` calls it from inside
  the condition of its first loop. Ported in place rather than hoisted.
- **The islands filter is the only thing protecting self-referencing sectors.**
  For a room with a divider carrying the same sector on both sides, the
  region-size test and the drawn-side test both pass and the split would fire;
  only "add only multipart sectors" stops it. Since the triangulator drops
  self-referencing sides, such a room is one island. Pinned by a test.
- **Removal had two parity gaps that this depends on.** `Sidedef.Dispose` nulls
  the sidedef's `sector` and `linedef` (Sidedef.cs:144-150) — without that,
  `SplitOuterSectors`' "sector was already split?" guard never fires and a side
  gets processed twice, making a duplicate sector on top of the one just
  created. And `Sector.Dispose` honours `AutoRemove` (Sector.cs:187-190),
  disposing its sidedefs only when it is on. Both fixed in `topology.js`.

### Drawing — done (`drawlines.js`)

`Tools.DrawLines` (Tools.cs:964-1626), all ten phases. This is the operation the
editor exists for, and it works end to end:

| drawing | result |
|---|---|
| closed square in the void | 4 lines, 1 sector, 64x64 of floor, a loadable map |
| the same square anticlockwise | identical — the lines get flipped round |
| a square against an existing room | 6 vertices, 7 lines, **one two-sided shared wall** |
| an open line wall to wall | the room becomes two sectors joined by that line |
| a line drawn straight through the room | walls cut at both crossings, the outside pieces discarded |

Ported alongside it, because DrawLines needs them: `Linedef.SafeDistanceToSq`,
`MapSet.NearestLinedef`/`NearestLinedefRange`/`NearestVertexSquareRange`,
`EarClipPolygon.CalculateArea`, and `Tools.FindClosestPath`'s 3-argument
overload.

Four things worth keeping:

- **`SafeDistanceToSq` is not `DistanceToSq`.** A plain clamp lets the parameter
  reach 0 or 1, which measures from a VERTEX — and then every line meeting at
  that corner is equally far away and "nearest" is a coin toss. UDB pulls the
  clamp one map unit in from each end, with a separate branch for lines shorter
  than 1 mu (jewalky/GZDoom-Builder-Bugfix#307). DrawLines asks "which line is
  nearest?" about endpoints that are usually sitting exactly on a corner, so
  this is the common case, not the edge case. `MakeSector` was using the plain
  version too; both now go through the safe one.
- **`FindClosestPath` has a 3-argument overload and it is a trap.** UDB's
  `FindClosestPath(line, front, turnatends)` just calls the 5-argument form with
  the line as its own endpoint. Calling our 5-argument version with three
  arguments silently binds `turnatends` to `endline`, the trace returns null for
  every line, and every `frontInterior` falls back to `true` — which looked
  exactly like a winding bug and was not. `findClosestPathFrom()` now exists so
  it cannot happen again.
- **Removing a line must sweep the vertices it orphans.** `Linedef.Dispose`
  detaches from both ends and `Vertex.DetachLinedefP` disposes a vertex whose
  last line just left (Vertex.cs:186-189). `removeLinedef` was deleting straight
  out of the vertex's set and skipping that, so a drawing whose ends fell outside
  the map left loose dots behind in the saved file.
- **Auto-align is deferred, with the reason stated.** `AutoAlignLinedefStrip`
  needs the texture's pixel width to turn a strip length into an offset, and
  nothing decodes graphics yet. The strip BUILDING is pure topology, so it is
  ported and the strips are returned; a caller with textures can pass
  `opts.alignStrip`. No offsets are invented in the meantime.

**Coverage, stated rather than implied.** Twelve behaviours were
mutation-tested; eight are pinned by tests and four are not — the
`splittingonly` detection, the void-sector guard, the strict `u` bounds on an
intersection, and the second `SplitLinesByVertices` pass. Five different drawing
shapes give byte-identical results with each of those reverted. For the first
the reason is visible: a closing path is traced along lines that already exist,
so what it adds is duplicates that stitching merges straight back out. The
comment block at the top of `drawlines.js` says all of this too. It is not
licence to simplify them — it records that their evidence is UDB's source rather
than this suite.

### A place where a Set is NOT a LinkedList

`Vertex.Linedefs` is a LinkedList in UDB holding one node PER ATTACHMENT, so a
line appears twice when both its ends are the same vertex, and detaching the
end's handle leaves the start's alone. A Set cannot express that.

The bug it caused: flipping a line detached the vertex its start had just been
pointed at, and the graph lost an edge the document still recorded. The fix is
to detach only when the line no longer touches that vertex through EITHER end.
If another operation ever needs to distinguish the two attachments, the Set is
the thing that has to change.

### Joining and removal — done

`Vertex.Join` (Vertex.cs:307) and `MapSet.JoinVertices` (MapSet.cs:2916) are
ported, with `STITCH_DISTANCE = 0.005` — UDB's value, carrying its own comment
that "0.001f is not enough when drawing very long lines".

Two details that only reading gets you:

- **`DetachLinedefP` disposes a vertex when its last line leaves**
  (Vertex.cs:185-190, under `autoremove`). That is what lets `Join` move lines
  across without cleaning up after itself. It fires on DETACH only, so a vertex
  created and not yet connected is never swept away.
- **JoinVertices restarts after every single join.** Tempting to flatten into
  one pass; that would iterate the sets while mutating them, which is precisely
  what the restart exists to avoid.

Removal cascades as UDB's `Dispose` does (`autoremove` is true by default,
MapSet.cs:201): a vertex takes its linedefs, a linedef takes its sidedefs, a @cite-scope MapSet.cs
sector takes its sidedefs.

**Removal is where the span model gives something up**, and the size of it is
worth stating. UDMF references are positional, so deleting block *k* shifts
every later block of that type and invalidates every reference above it. What is
implemented dirties only the blocks whose index ACTUALLY changed — so deleting
the last vertex is byte-identical (there is a test), while deleting an early one
rewrites most of the file. Stitching mostly deletes geometry it just created,
which lives at the cheap end. And UDB rewrites the whole file every save
regardless, so the worst case here is no worse than the reference.

### Tracing — done (`trace.js`)

`Tools.FindPotentialSectorAt` and everything under it: `FindClosestPath`,
`FindOuterLines`, `FindInnerLines`, `LinedefTracePath.MakePolygon`,
`EarClipPolygon.Intersect`, `Line2D.GetIntersection`, `Linedef.DistanceToSq`,
`Angle2D.GetAngle`, `Tools.GetRelativeAngle`.

Across the corpus: **19,098 of 19,100 sectors traced, and every one of those
encloses the side point it started from** — the function's actual contract. The
2 failures are CIRQUE's, the same two sectors the triangulator cannot contour.
14 ms for MAP21's 453 sectors, 195 ms for MAP45's 4,189.

Two bugs got there, and both were the same mistake:

**`Angle2D.Difference` has TWO corrections, not one.** After wrapping negatives
it also folds: `if (d > PI) d = PI2 - d`. It returns an UNSIGNED angle in
[0, PI]. Reading only the first correction made it directional, which made
`LinedefAngleSorter` rank every junction wrongly, and the tracer walked **453
lines for a four-sided sector**. The sorter's side-of-line test exists purely to
put back the direction this fold removes — so the two must be ported together
or neither works.

**`FindOuterLines`' rightward scan was summarised rather than ported.** Four
things in it are load-bearing and none is guessable: the y-straddle test is
INCLUSIVE (so lines merely touching the scan row count), the x test asks about
either endpoint rather than the intersection, `u` must exceed 0.00001 rather
than 0, and `scanfront` is `SideOfLine(...) < 0` — negative is the FRONT side.

Also worth knowing: a guard *added* out of prudence — a visited (line, side)
set that UDB does not have — terminated valid traces. UDB loops `while(true)`
and relies on the scan moving outward. Only a generous iteration ceiling
remains.

UDB's editing-mode sources are the behavioural spec for phases 4 and 5 —
`Tools.cs`, `DrawGeometryMode.cs`, `SectorsMode.cs`, and
`BuilderModes/VisualModes/`. Read them as a specification and reimplement;
the result stays GPL.

---

## 8. Resume here

State at the end of the phase-4 groundwork session. Everything below is
verified; `node stackcheck.js <dir-of-wads>` reproduces the numbers.

### What is done

| layer | file | state |
|---|---|---|
| UDMF read/write, lossless spans | `udmf.js` | 21/21 maps round-trip AND regenerate byte-identically |
| WAD container | `wad.js` | 21/21 rebuild |
| validation / diff | `diff.js` | 9 engine-fatal coordinate fields guarded |
| UDB config tree | `dbconfig.js` | 170/170 files, 51/51 configs, 0 discrepancies |
| dialog field model | `fieldmodel.js` | 282 descriptors, 0 with a guessed title |
| dialogs (DOM) | `controls.js`, `dialogs.js`, `editstate.js` | working; `demo.html` drives a real wad |
| triangulation | `triangulate.js` | 154,398 triangles, 1 mismatch in 19,100 sectors |
| map graph | `topology.js` | 21/21 verify; create, split, move, join, remove |
| sector tracing | `trace.js` | 19,098/19,100 traced, all enclosing their start point |
| make / join sector | `makesector.js` | void region → new sector, or absorbed by a neighbour |
| split sectors | `splitsectors.js` | multipart sector → cut in two; 0 disagreements over 19,100 sectors |
| **draw lines** | `drawlines.js` | **the draw operation, end to end** |
| stitching pieces | `stitch.js` | split / loop-removal / overlap-merge / line join |
| **2D view** | `render2d.js` | **transform, six layers, grid, wireframe, all four ViewModes, textured sector fills, and THINGS with their sprites** |
| **things** | `things.js`, `thingsmode.js` | **types from the catalogue, 8-way sprite rotations, select / marquee / place / delete, undoable** |
| **windows** | `windows.js` | **floating windows that stack, cascade and come to the front** |
| **snapping** | `snapping.js` | **the GetCurrentPosition cascade, whole** |
| **draw mode** | `drawmode.js` | **click to draw, with the overlay** |
| **selection** | `selection.js` | **highlight, click select, marquee, order** |
| **dragging** | `dragmode.js` | **move a selection, snap, stitch on drop** |
| **undo / redo** | `undo.js` | **byte-exact on real maps, with grouping** |
| **editing** | `dialogs.js` in `view.html` | **right-click a selection to edit it** |
| **graphics** | `graphics.js` | **palette, flats, pictures, TEXTUREx — 1,764 textures composed, 0 failures** |
| **resource manager** | `resources.js` | **name → bytes → pixels, cached; wads AND unpacked directories; 200/201 of Reese's wall textures resolve, 119/119 flats** |
| **visual (3D) mode** | `visual.js`, `visualgeometry.js`, `render3d.js` | **camera, matrices, textured surfaces and a WebGL renderer — the first consumer of a resolved sector PLANE** |
| **the sidedef checkbox** | `sidedefedit.js` | **add / remove / repoint a sidedef from the linedef dialog, undoable** |
| **the chrome** | `menus.js`, `menuparity.js`, `menus.css`, `mkicons.js` | **UDB's menu bar, modes strip, main toolbar and status bar — five derived tables, all checked by menuparity, all mounted in `view.html`, with UDB's own 98 button images** |

Gates: `selftest.js` (652, no external files), `citecheck.js`, `mkicons.js --check`,
`conformance.js`, `typeparity.js`, `specparity.js`, `dialogparity.js` (and
`--layout`, `--dialog`), `fieldcoverage.js`, `wadcheck.js`, `stackcheck.js`,
`crosscheck.js`, `rescheck.js`.

### How each phase landed, oldest first

This section grew by accretion: each phase appended what it learned. It is a
LOG of finished work, not a plan. **For what to do next, run `bd ready`**; for
how to work, section 0.

**`MapSet.StitchGeometry` is DONE** (`stitch.js`, CLASSIC path), and it works
end to end: mark a drawn room's geometry, stitch, and the duplicate corners weld
while the overlapping walls merge into a single two-sided line with a room on
each side. 8 vertices to 6, 8 lines to 7, zero validation errors. That is the
core operation of a map editor.

One thing that reading saved: **`SplitLinesByLines` does nothing in CLASSIC**.
Its first line is `if(... || mergemode == MergeGeometryMode.CLASSIC) return
true;`. Sixty lines that never execute on this path — porting them would have
been implementing REPLACE behaviour under a CLASSIC name.

**`Tools.JoinSector` (Tools.cs:681) is DONE**, and porting it paid for itself
twice over: reading it surfaced `TakeSidedefDefaults`, which led back to
`MakeSector` and the four omissions listed in section 7. Re-reading a function
you already ported is worth doing when a neighbouring one calls into the same
helpers.

Every ported behaviour in `makesector.js` has been mutation-tested — revert it, watch a test go red. That exercise found
two coverage holes and one test that had never run at all.

**`Tools.SplitOuterSectors` (Tools.cs:2253) is DONE** (`splitsectors.js`),
along with `SectorWasInvalid`. Porting it forced two removal-parity fixes in
`topology.js` that nothing had needed until now — see section 7.

**`Tools.DrawLines` is DONE** (`drawlines.js`) — see section 7. Phase 4's core
is complete: a user can draw a room and get a room, stitched to what was already
there, with sectors on the inside and the neighbours extended on the outside.

**The RENDERER is done** (`render2d.js`), and `view.html` drives it over a real
wad — pan, zoom, grid size, sector fills, wireframe. What is ported, with
citations in the file: `UpdateTransformations` and the Vector2D transforms,
`PositionView` / `ScaleView`, `ClassicMode.ZoomBy` (including zoom-at-cursor and
`ClampViewOffset`) and `CenterOnArea`, `RenderGrid` and `RenderBackgroundGrid`,
`GridSetup.SnappedToGrid`, `PlotLinedef` with both culling thresholds and the
normal indicator, `PlotVertex`, `DetermineLinedefColor`, `CalculateBrightness`,
and `Presentation.Initialize`'s six-layer order.

Four things from that port are worth carrying forward:

- **.NET's `Math.Round` is banker's rounding and JavaScript's is not.**
  `SnappedToGrid` rounds a coordinate over the grid size, so an exact midpoint
  snaps to DIFFERENT grid lines under the two rules — on a 32 grid, x = 16 goes
  to 0 in UDB and to 32 under `Math.round`. `roundHalfToEven` exists for this
  and every snap must go through it.
- **The two linedef culling thresholds compare a SQUARED screen length against
  an unsquared bound.** Dimensionally wrong, deliberately kept: the constants
  are tuned against the numbers that comparison produces, so "fixing" the units
  changes the zoom at which geometry starts to vanish. Pinned by a test.
- **The grid rounds a horizontal line's y (+0.49999) and does NOT round a
  vertical line's x.** The asymmetry is in `RenderGrid` at :1041 vs :1058.
  Making it consistent is a divergence, not a tidy-up. Both halves are pinned.
- **`CenterOnArea` took a `RectangleF`, which cannot have a negative Height.**
  Ours could, and map bounds are naturally Y-up, so passing them straight in
  produced a negative scale that clamped to `SCALE_MIN` and showed the whole
  map as a dot — failing silently rather than loudly. It now normalises.

Every one of those, plus the layer order, the view transform, the doom light
curve and the vertexsize clamp, was **mutation-tested**: fifteen reverts, all
fifteen caught by `selftest.js`. Four of them were caught by nothing on the
first pass and needed a test written specifically to reach them — the
unsquared threshold, the vertexsize clamp, the grid's y rounding, and the
boundary clamp, whose effect is only ever ONE map unit and so is invisible
unless the view is zoomed in on the map's edge.

**The INPUT half is done too.** `snapping.js` is
`DrawGeometryMode.GetCurrentPosition` (:343-556) entire, and `drawmode.js` is
the mode around it — point list, the mouse and key actions, and the overlay.
`view.html` draws: left click places a point, closing on the first finishes and
calls `drawLines`, and the result is a valid map.

The cascade, in the order that IS the specification: cardinal projection first
(it replaces the mouse position outright), then nearest drawn point, then
nearest vertex, then nearest linedef — and on a linedef with grid snap on, the
nearest grid intersection ALONG that line, which is what keeps a point both on
a wall and on the grid. Then boundary clipping, then the grid or plain rounding.

Six things from this port are worth carrying forward:

- **Both snap toggles ship ON**, so the modifiers turn them OFF.
  `buttonsnaptogrid` and `buttonautomerge` are `Checked = true`
  (MainForm.Designer.cs:2008, :2021) and :146-148 XORs them with Shift and
  Ctrl. Reading "Shift toggles grid snap" the other way round gives an editor
  where nothing snaps until you hold a key. Cardinal (Shift+Alt) also **forces
  grid snap on**, ORed in rather than being a third independent flag.
- **`GetHigher(0)` returns 0, not one cell up.** It rounds
  `(offset + half a cell)`, which at an exact multiple is a midpoint, and
  .NET's banker's rounding sends it down. Every grid-intersection scan depends
  on this, and it is the third place that rounding rule decides behaviour after
  grid snapping and the off-grid tail.
- **`Line2D`'s `u_ray` / `u_line` are named the opposite way round** from what
  you would guess — `u_line` is the parameter along the FIRST argument. The
  boundary clip and `GetIntersectionPoint` both feed one straight back into
  `GetCoordinatesAt`, so renaming them to read naturally silently changes which
  line the answer is measured on. Kept as UDB has them.
- **A cardinal run is grid-snapped around `gridoffset`**, the amount by which
  the previous point misses the grid (:372-373, :541). Without it a cardinal
  line starting off-grid steps off its own axis at the first snap, which
  defeats the point of cardinal snap entirely.
- **The grid tail clamps only RIGHT and BOTTOM** (:543-545), not left and top.
  Asymmetric in the source; tidying it is a divergence.
- **The `points.Count == 2` cancel branch (:605-609) is unreachable** by
  drawing. To reach it the new point must differ from `points[last]` — or the
  zero-length guard at :581 drops it — while equalling `points[0]`, and with
  two points those are the same point at the same 0.001 tolerance. Ported,
  untestable through the public path, and recorded here rather than left
  looking like a coverage hole.

**One real bug this found, and how.** The draw overlay's direction indicator
was derived in screen space rather than transformed from UDB's map-space
expression, and came out NEGATED — every tick on the wrong side of its line.
Nothing failed; it looked plausible. It was caught by *looking at the picture*
and checking a tick against the front side by hand. The fix makes it the same
expression the wireframe's normal indicator uses, and
`directionIndicatorGeometry` is now a pure function asserted against
`getSideOfLine` AND against the wireframe, so the two can no longer disagree.

Mutation-tested: 22 reverts across `snapping.js` and `drawmode.js`, all 22
caught. Four needed better fixtures first — a vertex placed ON a grid multiple,
a boundary clip run straight along an axis, a `removePoint` list with a
repeated x, and a left-boundary position that snaps to itself all produced the
SAME answer whether the behaviour was there or not.

**Selection is done too** (`selection.js`): highlighting, click select, and the
marquee with all four of its modes, for vertices, linedefs and sectors.
`view.html` has a tool switch (select / draw) and a mode switch, and renders
the result the way UDB does — a selected sector fills in the SELECTION colour
at alpha 64 and the highlighted one in HIGHLIGHT, both on the overlay
(SectorsMode.cs:201, :207), so hovering never invalidates the triangulated
surface.

Five things from that port:

- **The marquee modifiers are not in `ClassicMode`.** Its
  `GetMultiSelectionMode` (:916-919) returns SELECT unconditionally, which
  makes every mode's ADD / SUBTRACT / INTERSECT arm look like dead code.
  `BaseClassicMode` OVERRIDES it (:136-148) and that is where Ctrl and Shift
  are read — Ctrl+Shift tested FIRST so it cannot fall through to SUBTRACT,
  and Shift XORed with `AdditiveSelect`. Reading only the obvious file gets
  this wrong twice over.
- **The three modes find their highlight three different ways.** Vertices and
  linedefs use a bounded `HighlightRange / scale`; sectors use
  `NearestLinedef` with NO range at all and then take the sector on the
  mouse's side (SectorsMode.cs:1246). That is why a sector highlights from the
  middle of a big room, and it is not an optimisation you can add later — it
  is the behaviour.
- **Marquee membership differs per mode and none is a rounding of the others.**
  A vertex by its position, a linedef by BOTH endpoints (`&&` at
  LinedefsMode.cs:322 — the `||` is the `MarqueSelectTouching` variant, which @cite-scope LinedefsMode.cs
  ships off), a sector by its whole BOUNDING BOX (SectorsMode.cs:670). @cite-scope SectorsMode.cs
- **`autoclearselection` ships FALSE** (BuilderPlug.cs:307), so clicking empty
  space does NOT clear the selection. People expect the opposite; this is
  UDB's, and a click on a highlighted item FLIPS rather than sets.
- **`MouseSelectionThreshold` is 2 SCREEN pixels and is not divided by the
  scale** (VerticesMode.cs:458-461). It is a question about the mouse, not
  about the map, which is why it is the one range here that does not scale.

Mutation-tested: 16 reverts, all 16 caught. Two needed better fixtures — a
sector marquee whose centre is inside while its bbox is not, and an INTERSECT
assertion on WHICH items survive rather than how many, since dropping the
inside set leaves the same count.

**Dragging is done** (`dragmode.js`): `DragGeometryMode.StartDrag` and
`MoveGeometryRelative` in full, plus the drop. Press on a selected item and
drag to move it; press on empty space and drag to marquee. Vertices, linedefs
and sectors all work, because in UDB they are one algorithm — the three
`Drag*Mode` subclasses do nothing but MARK the right vertices, and `StartDrag`
partitions from there.

Six things worth carrying:

- **The anchor is a VERTEX, not the mouse** (:150) — the selected vertex
  nearest the grab point. Every snap is computed for that one vertex. Snapping
  the mouse instead puts the geometry wherever the cursor happens to sit
  relative to whatever was grabbed.
- **A drag is absolute, not accumulated.** Every move works from
  `oldpositions` (:161), never from where the vertices currently are, which is
  what stops the geometry creeping as snapping rewrites the offset mid-drag.
- **A grid-snapped drag STEPS** (:340): nothing moves until the anchor lands
  on a new grid position. Drop that condition and every mouse pixel mutates
  the map.
- **Snap targets exclude the drag itself.** The weld searches only UNSELECTED
  vertices (:249), and the snap lines are those with NEITHER end marked
  (:156) — a line with one end in the drag would have the selection chasing
  its own geometry.
- **Cardinal dragging is a THIRD angle rule.** `+ 44` then `/ 90 * 90` gives
  the axes, where draw mode's four-way gives the DIAGONALS (`/ 90 * 90 + 45`)
  and its eight-way uses `+ 22` with `/ 45 * 45`. Three rules that look like
  they should be one. Cardinal also forces grid-increment on (:226) so the
  grid snap cannot pull the drag off its own axis.
- **Dropping stitches only when snap-to-nearest is on** (:424). Drag with Ctrl
  held and overlapping geometry stays overlapping — two vertices in the same
  place, a valid map but rarely what was meant. That is UDB's rule.

Mutation-tested: 18 reverts, all 18 caught. Five needed better fixtures — a
weld with another SELECTED vertex in range, a snap line with one end in the
drag, a 45-degree cardinal offset (every shallower angle gives the same answer
under both rules), an off-grid cardinal drag, and a `cancel` mutation that
actually changed behaviour.

**Undo / redo is done** (`undo.js`), and it is byte-exact: three different
edits to MAP01, map30 and SPIRAL, then three undos, reproduce the original file
to the byte — 4,431,642 of them for MAP01 — and three redos reproduce the
edited one.

The architecture is not the obvious one and `WithdrawUndo`'s own comment
(:580-585) explains why: UDB *used* to snapshot the whole map per level and
does not any more. It now RECORDS, as the edit happens, the commands needed to
put things back — `RecAddVertex` so undo can remove it, `RecRemVertex` with
index, properties and graph references so undo can rebuild it, `RecPrpVertex`
with the whole property set — and plays them back in REVERSE.

Five things worth carrying:

- **The hook has to sit on the BLOCK.** UDB records property changes because
  every element setter calls `MapElement.BeforePropsChange` (:143). A great
  deal of code here writes fields directly through `blk.set(...)`, so the
  equivalent went into `UdmfBlock.set`/`delete` via `setPropsObserver`. A hook
  anywhere else can be bypassed without anyone noticing.
- **`BeforePropsChange` guards with `if(map == General.Map.Map)`**
  (Vertex.cs:165) and that guard is load-bearing: every create writes its
  fields BEFORE the element joins the map, so without it each create emits a
  spurious property command for something that had no previous state.
- **Only the FIRST property change per element per level is recorded** (:896).
  The command holds the whole property set, so recording again would store the
  same "before" twice and undo would land on the second-to-last state.
- **Playback runs backwards with `AutoRemove` OFF** (:389, :398). Forward
  order undoes a create before the changes that depended on it; leaving
  autoRemove on cascades deletions the recording never asked for.
- **The recorded Rem command carries the BLOCK, not a copy of its
  properties** — a deliberate divergence, and the reason undo is byte-exact.
  UDB has to rebuild from bytes; here removal only FLAGS a block and it stays
  in the document's emit plan, so reviving the original keeps its span and the
  original bytes come back. Rebuilding it would re-serialise that block: the
  same map in different bytes, which is the one failure the span model exists
  to prevent.

Mutation-tested: 17 reverts, all 17 caught. Three needed tests written
specifically to reach them — a restored linedef's graph edges (the byte output
alone does not notice), a reference change with no property change behind it,
and a sidedef repointed at another sector.

`ignorePropChanges` is wired into the drag, so a drag's live preview records
nothing and only the drop becomes a level — which is exactly what
`DragGeometryMode.StartDrag:131` uses it for.

**Selection order is done** (`selection.js`). It is two mechanisms, not one,
and they are easy to conflate:

- **Insertion order.** `MapSet` keeps its selections as LinkedLists (:92-94),
  not sets, and `DoSelect` appends with `AddLast` (Linedef.cs:807). A JS `Set`
  iterates in insertion order and re-adding an existing member does not move
  it — which matches `SelectableElement.Selected` only firing `DoSelect` on a
  false-to-true transition. So `Selection.items` being a Set is the port, not
  a convenience.
- **Marquee order.** `GetOrderedSelection` sorts what the rectangle caught by
  distance from where the drag STARTED, then selects in that sequence.

Four things worth carrying:

- **Only SELECT and ADD sort.** SUBTRACT and INTERSECT carry the explicit
  comment "Selection order doesn't matter here" (:1188, :1200) — removing
  items cannot disturb the order of the survivors.
- **ADD deselects the caught items and then reselects them** (:1173-1179).
  That looks redundant and is not: anything already selected is MOVED to the
  end in proximity order, while items outside the rectangle keep their places.
- **Vertices are not ordered at all.** `VerticesMode` has no
  `GetOrderedSelection`; its marquee just assigns `Selected` while walking the
  map. The asymmetry with the other two modes is UDB's.
- **The comparator returns only -1, 0 or 1** — biwa's fix for UDB issue #1053,
  because the gap between two squared distances exceeds int capacity and made
  .NET's sort throw. Coordinates reach 32767, so the gap really does reach
  ~8.2e9; there is a test that checks the fixture actually crosses 2^31.

One measurement worth keeping. Sector ordering uses each side's LEADING vertex
(`side.IsFront ? Start : End`, SectorsMode.cs:716), and that choice is
**unobservable on valid geometry**: across the corpus the leading and trailing
vertex sets are identical for **19,095 of 19,100 sectors**, because a closed
sector's sides visit every vertex exactly once in each direction. The five
exceptions are malformed — three in CIRQUE.wad, two in MAP22.wad — so the only
fixture that can pin the reading is a sector that does not close, which is what
the test uses.

A stated divergence: .NET's `List.Sort` is introsort and UNSTABLE; JavaScript's
`Array.sort` has been stable since ES2019. Equal distances therefore keep
document order here and may not in UDB. That runs towards determinism, and
.NET's instability is what made the -1/0/1 clamp necessary in the first place.

Mutation-tested: 11 reverts, all 11 caught. Four needed work first — three
because the assertions used the very distance function under test and so agreed
with any mutation of it (they now compute the expected order inline), and the
ADD case because a rectangle over the LAST room passes whether or not ADD
moves anything.

**Undo grouping is done.** `createUndo(description, {source, groupId,
groupTag})` collapses consecutive same-kind edits on the same selection into
one level — twenty taps of a raise-floor key are one undo, which is the whole
point of the feature.

Four things worth carrying:

- **`groupId` 0 is a sentinel, not an id.** :527 tests `groupid == 0` and
  `lastgroupid == 0` separately from equality, so two consecutive `None` calls
  do NOT group even though their ids match.
- **The tag is a checksum of the SELECTION** (`CreateSelectionCRC`,
  SectorsMode.cs:151) — count plus each element's **FixedIndex**, not its @cite-scope SectorsMode.cs
  positional index. That distinction matters: positional indices renumber on
  every removal, so a tag built from them would break groups at random and
  could group two selections that renumbered into each other. `fixedIndexOf`
  is a WeakMap-backed stable id, which is what `FixedIndex` is.
- **Undo and redo reset the chain** (:693, :836). Without it an edit made
  after an undo would keep recording into a level just played back.
- **Two of UDB's seven clauses are provably redundant** — `p == null` is
  implied by the two that follow it, and re-storing the group state on a
  grouped call is a no-op because those values are equal by definition. Both
  are ported anyway. A 324-case truth table in `selftest.js` checks the
  condition against an independent transcription of UDB's and asserts that the
  redundant clause never decides an outcome, so the claim is checkable rather
  than merely argued.

Mutation-tested: 12 reverts, 9 caught outright. The other three were the two
redundant clauses and the tag's redundant count prefix — all three provably
cannot change behaviour, which the truth table now demonstrates rather than
asserts.

**The dialogs are wired to the selection.** Right-click a highlighted item and
its editor opens — `dialogs.js` was already built and driven by `demo.html`;
what it needed was a selection to bind to.

Wiring it corrected a binding I had got wrong. **`classicedit` is one action
that does two things**, and dragging is one of them:

|  | released in place | moved first |
|---|---|---|
| `classicselect` | toggle what is under the cursor | marquee (VerticesMode:456) |
| `classicedit` | open the edit dialog (OnEditEnd:838) | DRAG the selection (OnDragStart:1069) |

So geometry dragging lives on the EDIT button, not the select button, gated on
`CheckActionActive("classicedit")`. That structure is grounded; the physical
button is not — Actions.cfg ships no default for either action, so
left = select / right = edit is the Doom Builder convention chosen here.

Both paths share one rule, from OnEditBegin:790-804 and OnDragStart:1082-1093:
**an unselected highlight becomes the only thing acted on** (the selection is
cleared first), while a selected one means the whole selection. That is why
selection order had to exist first — `GetSelectedLinedefs(true)` hands the
dialog a SEQUENCE, and a multi-item dialog shows the first item's values
wherever they differ.

Three things to know:

- **`demo-config.json` is generated, not shipped.** It is the baked
  configuration the field model needs, derived from UDB's tree, and it is
  excluded from every archive for the same licensing reason as `refs/`.
  Rebuild it with
  `node exportconfig.js refs/udb/Assets/Common/Configurations/UZDoom_DoomUDMF.cfg demo-config.json`.
  Without it the view still works and only the dialogs are unavailable, which
  is why its absence is a soft failure.
- **The dialog runs inside one undo level**, opened before any field is
  written. Cancel leaves the level open and empty, which costs nothing: a
  zero-command snapshot is never pushed onto the stack.
- **`dialogs.css` is now a shared stylesheet.** Those rules used to live
  inline in `demo.html`; `view.html` needed the same ones, and two copies of a
  stylesheet drift exactly the way two copies of a table do. Both pages link
  it and supply their own palette through the CSS variables it expects
  (`--panel`, `--line`, `--ink`, `--dim`, `--accent`, `--rel`, `--warn`) —
  demo.html light, view.html dark.

**The dialogs are laid out on UDB's own grid.** Its forms are absolutely
positioned WinForms, and several tabs put groups SIDE BY SIDE — the sector
Colors and Slopes/Portals tabs are two columns, the thing Properties tab is
three (Thing | Flags | a stack of Roll/Pitch/Angle). Stacking every group in
one column is a different dialog.

`fieldmodel.js` now carries `GROUP_LAYOUT`: each group's span on a twelve-column
grid, plus a start column and the designer's own reading order. It is derived
from the `Location`/`Size` coordinates rather than typed — **`dialogparity.js
--layout` re-derives it and fails on divergence, and `--layout --dump` prints
it for pasting**, which is the same treatment `typeparity`, `specparity` and the
control table get, and for the same reason: the first hand-typed version of
this table had **10 of 27 placements wrong**, and the checker found them
immediately.

The `order` field is the part that is easy to miss. The field model groups by
the `.cfg`, whose order has nothing to do with the dialog's, so on a
multi-column tab the right spans in the wrong sequence still put groups in the
wrong cells. Groups the designer does not place — the `.cfg`'s own catch-alls,
Custom and Comment — take full width and sort last, which is where they sit in
UDB too.

**Modals do not leak events to the map.** The dialog and the texture/action
browsers are siblings of the view, not children, so they sit OVER it while the
view's own handlers are on `window`. Without guards, hovering a dialog moves
the map's highlight underneath it, releasing a button over one commits a
marquee behind it, and — the one that eats work — typing in a field runs the
view's shortcuts, so `s` reloads the map, Backspace deletes a draw point and
Ctrl+Z undoes the MAP instead of the text. `modalOpen()` and `typingInField()`
gate every mouse and key handler; Escape closes the dialog and goes no further.
Both overlays are covered, since `makeBrowser` mounts `.udmf-modalback` on the
body.

NOT ported: UDB's live `OnEditFormValuesChanged` (:851), which repaints the map
as fields change rather than on OK. That needs the dialog to stream its
changes; `dialogs.js` applies on OK, so the map updates then.

**Graphics decoding is done** (`graphics.js`) — the thing the ledger has been
waiting on. Ported from `Playpal.cs`, `ImageDataFormat.cs`,
`DoomPictureReader.cs` and `DoomFlatReader.cs`:

- **PLAYPAL**, whose 768 bytes are the first of FOURTEEN palettes in the lump;
  UDB reads only the first, because the rest are runtime screen tints rather
  than other colourings of the map.
- **Flats**, which have no header at all — the size comes from the LUMP
  LENGTH. A perfect square is that square, over 4096 is 64x64 with the tail
  ignored, anything else is not a flat. The middle rule reads like a bug and
  is how UDB copes with trailing junk.
- **Pictures** — the column/post format behind patches and sprites, including
  the two pad bytes per post that are read and discarded, and the tall-patch
  rule at `DoomPictureReader.cs:207`. That last one is section 0 in miniature: @cite-scope DoomPictureReader.cs
  `if(read_y < y || (height > 256 && read_y == y)) y += read_y; else y = read_y;`
  makes topdeltas RELATIVE once they stop increasing, because one byte cannot
  address a row above 254. A plain `y = read_y` renders every tall sky with
  its lower half missing.
- **Transparency by omission.** The buffer starts zeroed and posts paint the
  opaque runs over it, so a patch's transparent pixels are the ones no post
  covers. There is no transparent palette index.

Decoders return `{ width, height, offsetx, offsety, rgba }` with `rgba` in
`ImageData`'s layout, so an image goes onto a canvas with no repacking.

Measured against both Freedoom IWADs: **240 flats and 1,047 patches in
freedoom1, 240 and 1,052 in freedoom2, zero failures**, 853 and 1,350 sprites
all carrying the offsets things need for placement, and TITLEPIC decoding to a
fully opaque 320x200. `gfx.html` shows any wad's palette, flats, patches and
sprites; the sprites render over a checkerboard so the transparency is visible
rather than asserted.

Mutation-tested: 15 reverts, all 15 caught. Two needed work first — one target
was not unique so the mutation never applied, and the `height > 256` half of
the tall-patch rule needed its own fixture, because a BACKWARDS delta is
relative either way and every earlier test passed with that clause deleted.

**TEXTUREx composition is done too.** A wall texture is not a lump — it is a
recipe. TEXTURE1/TEXTURE2 name a size and a list of patches with offsets, and
PNAMES turns each patch index into a lump name; flats are whole images and wall
textures are assembled, which is why `texturefloor` and `texturetop` resolve
through completely different paths.

Four things from that port:

<!-- @cite-scope WADReader.cs -->

- **The Strife/Doom detection is a heuristic on a zero** (WADReader.cs:580-592).
  Doom's entry carries a 4-byte `columndirectory` between the height and the
  patch count; Strife's does not. UDB reads an int16 where Doom has the low
  half of that always-zero field: zero means Doom, so skip two more bytes and
  read the real count; anything else means Strife and the value already IS the
  count.
- **The validation condition has an operator-precedence bug and is ported
  anyway** (:596). `(a && b && c && d) || (scaley != 0)` — and scaley is
  almost never zero, so the guard passes whatever the width, height and patch
  count are. Writing what someone plainly MEANT would reject textures UDB
  accepts, which is drift in the stricter direction. Flagged in the code so
  the next reader knows it was read rather than copied by accident.
- **The scale byte is in eighths and the scale is its RECIPROCAL** — 8 means
  1.0, 16 means 0.5, and 0 means "use the config default" rather than "scale
  by zero".
- **Only pixels above half alpha are copied** (TextureImage.cs:216). That is
  the whole mechanism behind overlay textures — the blood-splattered crates in
  Freedoom are a masked patch drawn over a base at an offset.

The vanilla negative-offset bug emulation is ported too, gated on
`compatibility.fixnegativepatchoffsets` and `fixmaskedpatchoffsets`.
UZDoom_DoomUDMF resolves BOTH to true, so Reese's engine takes the simple path;
the emulation exists because a configuration that wants it is one file away and
a texture drawn the wrong way is a silent difference rather than an error.

Measured across both Freedoom IWADs: **801 and 963 texture definitions, all
composed, zero failures**, 312 and 409 of them genuine multi-patch composites,
every one exactly its declared size. `gfx.html` shows them.

Mutation-tested: 14 reverts, all 14 caught. Four needed better fixtures —
a Strife set with only ONE patch cannot notice a record read four bytes too
long, scale byte 8 gives 1.0 under both the reciprocal and a plain divide, a
PNAMES count under 65536 reads the same as a uint16, and — the interesting one
— a patch overhanging the BOTTOM disappears on its own because a write past a
typed array's end is silently dropped, while one overhanging the RIGHT wraps
onto the next row. Only the second is a real bug, and only a right-edge
fixture finds it.

**The RESOURCE MANAGER is done** (`resources.js`) — `WadResourceReader` for
`WADReader`, `ResourceManager` for the resolving half of `DataManager`, plus
`MurmurHash2` and `Lump.MakeLongName`, the hash every lookup is keyed by.
`rescheck.js` drives it over real wads: **1,201 textures and 1,201 flats
resolved and decoded per Freedoom IWAD, zero failures**, the whole load in
3-8 ms and every image decoded in 140 ms.

Seven things from that port are worth carrying forward:

- **The override rule is written TWICE, in opposite directions.** Loading walks
  the containers FORWARD and assigns (`list[img.LongName] = img`, :960), so a
  later container replaces an earlier one; fetching walks them BACKWARD and
  takes the first hit (:987). Same outcome, opposite loop, and they are not
  interchangeable — the load pass must visit every container to build the list
  while the fetch pass must stop at the first.
- **PNAMES is deliberately NOT reset between containers** (:945-948, and UDB
  says so in a comment). A PWAD that ships TEXTURE1 without its own PNAMES
  resolves its patches through the IWAD's table. A fresh table per container
  reads like hygiene and breaks the common case.
- **The first TEXTURE1 entry is thrown away** (:437) — the slot vanilla
  reserves, AASTINKY in Doom and AASHITTY in Doom II. From TEXTURE1 only,
  never TEXTURE2, so a fixture with one texture set cannot tell that rule
  apart from "drop the first texture loaded".
- **The patch search has three tiers and the middle one is the surprise**
  (:679-710): the patch ranges strictly first, then everywhere EXCEPT the flat
  ranges — UDB's comment says "the way it's done in ZDoom" — and only then
  inside them. Collapsing the last two into one scan of the whole wad reverses
  the priority for any name that appears on both sides.
- **`GetTextureImage` and `GetFlatImage` are NOT the same shape** (:1034 vs
  :1236). The flat lookup gates its direct hit on the image being a TEXTURES or
  HiRes flat, so a short→full translation wins first; the texture lookup has no
  such gate and the direct hit wins. Two functions that read as duplicates and
  resolve differently.
- **"The same name can legally be both" is real data, not a hypothetical.**
  `STEP1` and `STEP2` are a wall texture AND a flat in both Freedoom IWADs. It
  is `mixtexturesflats` that makes each resolvable as either, and that key is
  **true** for every ZDoom configuration including Reese's.
- **A patch is retried as a flat only when mixing is on** (TextureImage.cs:123).
  The gate is one line and easy to drop, and dropping it makes a flat usable as
  a patch under a configuration where UDB says it is not.

Mutation-tested: 34 reverts, 31 caught. The three uncaught are all provably
incapable of changing behaviour, and rather than being argued they are
CHECKED — `getTextureEntry`'s duplicated third clause and `loadFlats`' <2-lump
range guard each have an exhaustive equivalence test, and `FindRanges`
searching from `range.end` rather than `end + 1` was settled by surveying all
**618 range definitions across all 51 configurations**: none has the same name
for its start and end marker, so the two cannot differ. Four fixtures could not
discriminate on the first pass and were rebuilt: the >8-character lump guard
needed a wad that actually CONTAINS a nine-character lump (asking for an absent
long name returns -1 either way), the patch-search order needed the same name
on both sides of the flat markers in ONE wad, the ASCII coercion needed two
names differing only above codepoint 127, and the murmur sweep generated only
printable ASCII and so agreed with or without that coercion.

Two bugs in existing code came out of this, both of the "degrades quietly"
kind this document keeps warning about:

- **`dbconfig.js`'s `joinPath` dropped the leading `/` of an absolute path.**
  Splitting on `/` puts an empty string first and the loop discarded it with
  the other empties, turning `/a/b` into `a/b` — which then resolved against
  the process's working directory. Every include failed to load and
  `resolveConfig` returned the root file alone, so
  `node exportconfig.js "$HOME/.../UZDoom_DoomUDMF.cfg" demo-config.json` wrote
  a config with **1 file instead of 22** and still exited 0. Fixed, and pinned
  by a test that resolves an absolute root through a synthetic loader.
- **`fetch-references.sh` never topped up an existing `refs/`.** Its guard was
  `if [ ! -d udb ]`, so once the tree existed it never changed again and every
  file added to the extraction list afterwards was fetched for a fresh clone
  and silently missing for everyone else. `Source/Core/Data` held 13 of its 32
  files while the glob already said `Data/*.cs`. The guard now checks a
  MANIFEST of files this codebase actually cites and refetches when any is
  absent, and the post-extraction check verifies the same manifest instead of
  trusting a nonzero file count.

`citecheck.js` also had `graphics.js` and `resources.js` missing from its
`MODULES` list, so neither module's citations were being checked at all — the
first run after adding them found two wrong line numbers among the 21 new
claims. It now covers 219 citations and 52 claims.

**The DIRECTORY / PK3 READER is done** (`DirectoryResourceReader`) —
`PK3StructuredReader` plus `DirectoryReader`, which is what Reese's own
resources actually need: OTEX and the F-State textures are unpacked trees of
PNGs, not wads. Over the real stack: **5,186 textures and 5,186 flats indexed
in 47 ms, 3,986 of them long-named, and 200 of 201 wall texture names and
119 of 119 flat names the 22-map corpus references now resolve** — up from 33
and 18 against the IWAD alone.

Nine things from that port:

- **The namespace is the DIRECTORY NAME.** `textures/`, `flats/`, `patches/`,
  `sprites/`, `hires/`, `colormaps/`, `graphics/`, `voxels/`
  (PK3StructuredReader.cs:33-40) do what P_/F_/S_ markers do in a wad.
- **The two override rules run OPPOSITE ways, and both are right.** Across
  containers `DataManager` assigns and the LAST wins (:960); inside one
  resource `AddImagesToList` skips a key it already has (:861) and the FIRST
  wins. Flattening them into one rule silently reverses one of the two.
- **`FileImage.SetName` is the load-bearing function** (FileImage.cs:106-140).
  A file in the resource ROOT gets a classic 8-character name even with long
  names ON — UDB's comment: zdoom "doesn't recognize long texture names in a
  root folder / pk3 root". Anywhere deeper it gets the whole relative path,
  **keeping its extension and its original case**, which is why Reese's maps
  contain `textures/wall/Subway/SUBWALL.png` rather than a bare name.
- **A long-named file ALSO registers a short name**, the stem uppercased and
  cut to 8, through `noteNameTranslation`. That is how a map naming plain
  `SUBWALL` finds a file three directories down, and it is why the
  short↔full tables — dead on a WAD-only path — carry 3,979 entries here.
- **The ZDoom file-title rule is 8 characters on BOTH sides**
  (DirectoryFileEntry.cs:100): the entry's title AND the query are truncated.
  `SUBWALL_LAMP` and `SUBWALL_LIGHT` therefore collide on `SUBWALL_`, which
  is a real collision in Reese's tree and is UDB's.
- **A directory path keeps a trailing separator** (DirectoryFilesList.cs:394)
  so `textures/` cannot match `textures2/`. Dropping it loads a neighbouring
  directory's files as textures.
- **`PATCH_LOCATIONS` order is the specification** (:60) — patches, textures,
  flats, sprites, graphics, "Because ZDoom looks for patches and sprites in
  this order" — and searching all five is gated on `MixTexturesFlats`.
- **The wad reader drops TEXTURE1's first entry and the directory reader does
  NOT.** `WADReader.LoadTextures` removes it (:437); `PK3StructuredReader`
  calls the same shared `LoadTextureSet` and never touches the result
  (:216-224). Porting the removal into both would lose a texture from every
  PK3.
- **A directory image carries its OWN bytes.** `FileImage.LocalLoadImage`
  (:142-172) reads its file, where `FlatImage` asks the manager for a lump by
  name — and `PK3StructuredReader.GetFlatData` (:395-411) searches only the
  NESTED wads, so a flat that did not carry its bytes would resolve and then
  decode to nothing.

**PNG is split in half, deliberately.** UDB hands PNG to a framework decoder
and gets a sized Bitmap back; a browser's `createImageBitmap` is ASYNC, and
every caller here is synchronous. So `readPngInfo` reads IHDR — required by
the spec to be the first chunk, so no decompression is involved — and the
image reports its real size immediately with `pending: true` and no `rgba`.
Pixels arrive through `setExternalDecoder`, filling the same cached object.
A caller that checks `rgba` draws nothing rather than something wrong; one
that wants a width gets it now. `grAb` is read too — not a PNG standard
chunk, but the one Doom source ports adopted to carry a patch's offsets, and
without it every PNG sprite hangs off the wrong point.

Mutation-tested: 24 reverts, all 24 caught. Six fixtures could not
discriminate first time and were rebuilt — a `findFirstFile` query of exactly
8 characters cannot see that the query is truncated too, one nested wad cannot
see the direction of a backward walk, a name in only one of TEXTURE1 and
`textures/` cannot see which wins, a non-recursive flats scan is invisible
without a flat in a subdirectory, a LONG-named texture hides an entry that
ignores its own bytes (because `GetTextureData` accepts an absolute path where
`GetFlatData` does not, so only a FLAT exposes it), and every PNG fixture
began with a valid IHDR so the IHDR guard decided nothing.

One test was also fixed rather than trusted: the external-decoder test
asserted inside a `.then()`, which `t()` never awaits — it would have passed
whatever it asserted, the same failure this project shipped once before.
`_requestExternal` now applies a synchronous decoder synchronously, so the
assertion is real.

**TEXTURED SECTOR SURFACES are done** (`render2d.js`), on the ASYNCHRONOUS
path. `view.html` gains a four-way `view` dropdown and a `res` folder picker,
and pointing it at OTEX renders Reese's own flats.

<!-- @cite-scope Renderer2D.cs -->

`ViewMode` is ported whole (ViewMode.cs:22-28) and the first thing to know is
that **`Normal` draws no sector fill at all**: `RedrawSurface`'s switch
(:1638-1656) has cases for Brightness, FloorTextures and CeilingTextures and no
default. It reads like the mode that would show textures and it is the one that
shows nothing.

Five things from the port:

- **The texture coordinates are WORLD space** — `flatvertices[i].u = x;
  v = y` (Sector.cs:397-398). One texel per map unit, tiled from the world
  origin rather than from the sector, which is why two sectors sharing a flat
  line up across their wall. A per-sector origin looks plausible and is wrong
  everywhere. In canvas terms the pattern transform is EXACTLY the
  world-to-display matrix, y flip included, so `_worldToDisplayMatrix` is
  handed straight to `pattern.setTransform` and a test checks it agrees with
  `mapToDisplay` point for point.
- **Brightness mode is the textured path with a white flat.**
  `RenderSectorBrightness` passes `0` as the texture name (SurfaceManager.cs:562)
  and `longimagename == 0` resolves to WhiteTexture (:581). So the three
  drawing modes are one code path with a different name lookup, not three.
- **An image still loading draws as WHITE** (:601, `!img.IsImageLoaded ||
  img.LoadFailed`). That is the placeholder, and white is the right one
  because the brightness multiply still applies — the sector reads at its true
  light level while the pixels are on their way. An ABSENT name is also white
  (:581), while a name that resolves to nothing is UnknownTexture3D (:596):
  two fallbacks that are not interchangeable.
- **The shading is a real multiply, not an approximation.** UDB gives each
  flat vertex `c = brightint` and the shader multiplies. Canvas has no
  multiply on a plain fill, but black at alpha `(1 - b)` over an opaque
  destination IS multiplication by `b`.
- **Surfaces are batched by image**, as UDB batches them by `ImageData`
  (SurfaceManager.cs:610). There it saves a texture bind; here it saves
  rebuilding a `CanvasPattern` per sector.

**The async design, which is UDB's.** A background thread loads each image and
hands it to the UI thread, which updates the sectors naming it and calls
`DelayedRedraw()` — a timer with `Interval = 1` (MainForm.Designer.cs:2718), so
a hundred images finishing at once cost ONE repaint. `requestAnimationFrame` is
that timer: it coalesces to the next frame and does not run while the tab is
hidden. The sector-walk half is not ported, because nothing here holds
per-sector vertex buffers to update — the fill is drawn from the triangulation
each frame, so an arriving image needs only the repaint. A repaint drops the
PATTERN cache and deliberately keeps the TRIANGULATION: no geometry moved, and
re-triangulating per texture would cost the whole map per image.

**Laziness runs all the way down, and that is UDB's too.**
`FileImage.LocalLoadImage` (FileImage.cs:152) reads its file when the image
loads, not when the resource is indexed — which is why opening a 90 MB texture
pack in UDB is instant. A browser cannot read a `File` synchronously, so a
directory entry may carry `read()` instead of `data`, and the whole load
becomes async rather than only the PNG decode. Both arrive through one
`pending` flag. OTEX plus the F-State tree is ~240 MB on disk and a map uses a
few hundred of its 8,000 files, so eager reading would cost most of that for
nothing.

One consequence, stated because it is a real limit rather than a bug: the
byte-level accessors (`getPatchData` and friends) are synchronous by contract,
so a lazily-supplied entry reads as ABSENT to them. A TEXTURE1 composite whose
patches live in a lazy directory will not assemble. Supply `data` for a
resource whose patches a TEXTUREx lump references.

Mutation-tested: 14 reverts, all 14 caught. One fixture could not
discriminate first time — every surface test set `lightlevel` to 255, where
the brightness multiply is skipped and its absence is invisible, so a DARK
sector had to be added to see it.

**One bug the browser caught that no test would have.** The
`createImageBitmap` decoder called `bmp.close()` before
`getImageData(0, 0, bmp.width, bmp.height)` — and `close()` zeroes the
bitmap's width, so every PNG failed with `IndexSizeError: The source width is
0`. The size has to be read BEFORE the close. It was in the example in
`resources.js` as well as in `view.html`, so it would have been copied
forward. Found by driving the real page and asking why `pending` had gone
false with no pixels; the self-tests use a stub decoder and could not have
seen it.

**THINGS are done** — `things.js` (types and sprites), `thingsmode.js` (the
editing mode), and the Things layer in `render2d.js`. Measured over the corpus:
**3,436 things across 22 maps, 63 distinct types**, and of the 47 the config
knows, **every sprite that can resolve does** — the rest are 16 `internal:`
icons for types with no game sprite. The other 16 types are F-State's own
ZScript actors, which the `.cfg` does not carry.

Seven things from the port:

- **The radius comes from the config key `width`**, not `radius`
  (ThingTypeInfo.cs:229), and every property falls back to the CATEGORY's
  value. Reading only the type's own entry gives a teleport no size at all.
- **A sprite name is NNNNFA or NNNNFAFA**, and the second pair is the SAME
  image serving another facing MIRRORED — which is how five lumps cover eight
  rotations. `PLAYA2A8` resolves to `PLAYA1, PLAYA2A8, PLAYA3A7, PLAYA4A6,
  PLAYA5` and then the last three again, mirrored. A fixture with eight
  separate lumps cannot see that arm at all.
- **Angle 0 means NO rotations** — one frame whichever way the thing faces —
  while 1-8 means all eight must be found. A partial set FAILS rather than
  leaving a gap.
- **The frame letter class is `[A-Za-z\[\]\\]`.** Doom frame letters run past
  Z into `[`, `\` and `]` for actors with more than 26 frames; writing
  `[A-Za-z]` looks like a tidy-up and drops those sprites.
- **The pick is MANHATTAN distance, ties to the SMALLER thing**
  (MapSet.cs:3736-3737), and the range is expanded by each thing's own
  displayed size. The tie-break is what makes a teleport destination standing
  inside a teleporter selectable at all.
- **A sprite under 8 pixels is dropped AND the arrow gets bigger** — one
  decision, not two. UDB's comment calls it "a hackish way to tell arrow
  rendering code to draw bigger arrow"; splitting them leaves a directional
  thing with no visible facing at the zoom where that matters most.
- **The draw order is boxes, then sprites, then ARROWS** (:1262, :1345,
  :1492). The arrow is last and therefore on top of the sprite, which is the
  point: a directional thing has to show its facing even when a sprite covers
  its box. Drawing it before the sprite hides it exactly where it is wanted —
  which is what the first version did, and it was obvious on screen.

**Things is a MODE, not a tool.** The mode says WHAT is being edited (vertices,
linedefs, sectors, things) and the tool says HOW (select, draw), so both tools
work in things mode: select picks and marquees, draw places. Placing by click
is this editor's own binding — UDB inserts through the `insertitem` action, and
the Insert key does that here too.

**Placing and deleting are UNDOABLE, and that needed new commands.** UDB has
`RecAddThing` / `RecRemThing` alongside the geometry recorders; without them
the level opened, recorded nothing, was discarded, and a delete was simply
gone. They are handled differently from the geometry commands and the
difference is the point: a vertex removal rebuilds graph references and
renumbers everything pointing at it, while NOTHING points at a thing, so its
undo is only "put the block back at its old index". Removal flags the block
rather than splicing it, so undoing a placement is byte-identical to the file
that was read.

Mutation-tested: 29 reverts, 28 caught. The one uncaught is the `internal:`
prefix guard, which is provably redundant — "internal:" is nine characters and
a sprite name must be 5, 6 or 8, so the length check rejects those anyway.
Both guards are ported because both are there, and a test shows the first
cannot decide an answer rather than arguing it. Three fixtures could not
discriminate first time: a wrong-length name that is merely ABSENT returns null
either way, a marquee too short to start cannot reach the zero-area guard, and
the `internal:` case above.

**FLOATING WINDOWS** (`windows.js`). The popups were each pinned to a fixed
z-index — browsers at 10, the dialog at 20 — so a browser always opened BEHIND
the dialog that spawned it, two browsers landed exactly on top of one another,
and neither could be raised. There is no UDB reference for this: WinForms gets
it from the OS window manager. Now one monotonic z counter, a cascade so a new
window does not perfectly cover its predecessor, and a press anywhere raising
it — on the CAPTURE phase, because a window's buttons stop propagation and a
bubble listener would never see a press that landed on one.

One real bug fixed alongside: **`.udmf-item` had no `color`**. It is a
`<button>`, and a button does not inherit `color` from its container — it takes
the user agent's black. On `demo.html`'s light palette that looked right; on
`view.html`'s dark one it was black text on a #2b2b2b panel. Every other
control in `dialogs.css` sets its colour explicitly and this was the single
omission.

**THE DIALOG STRUCTURE is done**, and the first attempt at it was WRONG in
three ways that a passing checker hid. Read this before touching the derivation
again.

**1. A GroupBox is not always a field.** `SectorEditFormUDMF` builds three of
its group boxes as LOCAL variables inside `InitializeComponent`:

```
groupfloorceiling = new System.Windows.Forms.GroupBox();
...
this.tabproperties.Controls.Add(groupfloorceiling);
```

no `this.`, no `private ... ;` line. Parsing only field declarations lost
` Heights `, ` Effects ` and ` Identification ` — **the entire left column of
the sector Properties tab**. Their fields fell through to a generic pane and
the tab rendered with a hole beside ` Sector damage `. A control's type has to
come from where it is CONSTRUCTED.

**2. The checker agreed because it shared the blind spot.** `parseForm` built
its own control map the same wrong way, so `dialogparity` validated
`UDB_CONTROL` against the same missing groups and reported PASS. Fixing the
parse immediately failed FIVE entries: `heightceiling`, `heightfloor`,
`special`, `lightlevel` and `id` all recorded `"Properties"`, which is the TAB
name, not a group. A mirrored table and its checker sharing a parser is a
checker that cannot see the bug it exists to catch.

**3. Twelve columns was too coarse.** UDB's forms are absolutely positioned, so
a coarse grid rounds real geometry into gaps that are not there: ` Heights `
(254px) and ` Sector damage ` (297px) sit **6px** apart in a 570px tab, and at
twelve columns they came out 5@1 and 6@7 — a whole empty column, eight per cent
of the width for a one per cent gap. The grid is now **24 columns**, where they
are 11@1 and 13@12: adjacent, as they are on screen. The tab's own `Size` is
used for the width too, not the widest group on it.

A fourth, found by looking at the rendered dialog rather than the numbers:
`hidden` hides through the user agent's `display: none`, and an author rule
beats the UA sheet — so the moment a pane also carried `.udmf-grid` every tab
rendered at once, stacked down the dialog. `.udmf-pane[hidden] { display: none }`
restates it.

With those fixed it is read out of UDB rather than derived. `dialogparity.js` gained `deriveDialogLayout(designerSrc,
companionSrc)`, `--dialog --dump` prints the table, `fieldmodel.js` carries it
as `DIALOG_LAYOUT` with `tabsFor()` placing fields onto it, `dialogs.js`
renders it, and `--dialog` checks **17 tabs, 36 groups and 30 field
placements** against the forms.

**The bug was not the spans. It was the tabs.** `deriveGroupLayout` flattened
every tab into one object keyed by group caption, so nothing recorded that
`Ceiling slope` and `Flags` are different PAGES. Everything the designer puts
on a tab we did not render collapsed into one Properties pane and took the
span-12 fallback — which is why the multi-column tabs looked single-column
while `--layout` reported 27/27 correct placements. Both were true at once.

What UDB actually has:

| form | tabs |
|---|---|
| sector | Properties, Colors, Surfaces, Slopes / Portals, Comment, Custom |
| linedef | Properties, Front, Back, Comment, Custom |
| thing | Properties, Action / Tag / Misc., Comment, Custom |

Six things from the port:

- **Field membership is in the `.cs` companion, not the designer.** The
  designer says where a CONTROL sits; the companion says which UDMF key it
  edits: `ceilBrightness.Text = sc.Fields.GetValue("lightceiling", 0)`, and
  `UniFields.SetInteger(s.Fields, "damageamount", damage...)` on the write
  side. Both forms are read; 26 of 26 sector keys resolve to a captioned group.
- **The left-hand side must be a declared CONTROL.** The same file assigns to
  a snapshot class in the same `x.y = …GetValue("key")` shape (`sectorprops`,
  `value`, and PascalCase properties), and those lines come BEFORE the control
  bindings. Binding is first-wins, so without the filter the snapshot claims
  the key, resolves to no group, and the field is never placed at all.
- **A GroupBox captioned with only spaces is a SPACER.** UDB uses them as
  layout padding — the linedef form nests its texture pickers in one captioned
  `"     "`. Treating it as a group invents a fieldset with a blank legend and
  misfiles the controls inside it.
- **The column width is per TAB, not per form.** Each tab's content width is
  the widest right edge on that tab; using one width for the whole form makes
  every span on the narrower tabs wrong.
- **A declared group with no fields is KEPT.** Ceiling/Floor slope, the colour
  pickers and the thing form's Roll/Pitch/Rotation have no modelled controls
  yet, but their geometry is real and dropping them lets the tab reflow around
  a gap UDB does not have. A whole TAB that is empty is skipped instead —
  which is the linedef's Front and Back, whose fields belong to the sidedef.
- **`groupOf` stops at the TabPage**, so a loose control on a tab has no
  group. That guard cannot change the output, because the group listing only
  considers boxes whose own tab is that tab and a box wrapping the tab control
  has no tab — both are kept and a test shows the second already suffices.

Mutation-tested: 13 reverts, 12 caught, the uncaught one being that redundant
guard. Three fixtures could not discriminate first time and each needed the
REAL failure shape: the non-control binding needed a `x.y = …` line that comes
BEFORE the control's, a commented-out binding needed to name a real control,
and the tab-width mutation had a target that appeared twice so it proved
nothing until it was made unique.

One thing the CLI needed: `dialogparity.js` ran its argument handling and its
whole report at import time, so importing `deriveDialogLayout` from a test
exited the process with code 2 before an assertion ran. The CLI body is now
behind a `RUN_AS_CLI` guard.

**Sector planes** (slopes, and the High/LowRequired branches that need them)
remain the other unstarted phase.

Two things the renderer does NOT do, stated rather than left to be discovered:
**things are not drawn** (the layer exists and composites in the right place),
and **sector fills use `ViewMode.Brightness`, not `ViewMode.Normal`** — a real
UDB view mode rather than a stand-in, and the only one of the four needing no
textures. Neither is blocked any more: `resources.js` answers
`flatImage(name)` and `spriteImage(name)`, so both are wiring. The same goes
for `Tools.AutoAlignTexturesOnSides`, which needed a texture's pixel width.

Mutation-test what you port. It has now found, in three modules: four unpinned
conditions in `splitsectors.js`, a test whose body had never run, and a
signature slip in `drawlines.js` that made every trace return null. The fixtures
that pin a condition are often not the obvious ones — a donut does NOT
distinguish the islands filter, a self-referencing room does.

### The menu bar — done (`menus.js`, `menuparity.js`)

The last mirrored table, and it landed the same way the dialog one did: dumped
from UDB, never typed, and checked by a script that re-derives it from the
source rather than re-reading the table.

`menuparity.js` reads three things and joins them. The seven menus, their
items, separators, submenus and Tags come from `MainForm.Designer.cs`. The
shortcut TEXT is `Actions.cfg`'s `default` put through a port of
`ApplyDefaultShortcutKeys` (ActionManager.cs:437) and `GetShortcutKeyDesc`
(Action.cs:126). The Mode menu is not in the form at all — the designer leaves
it holding two separators — so it is built from the `[EditMode]` attributes of
the plugins `Builder.sln` actually ships, sorted by `ButtonOrder` the way
`EditModeInfo.CompareTo` does.

Four things had to be read rather than reasoned about:

- **`Builder.sln` decides which plugins exist.** `ImageDrawingExample`,
  `WadAuthorMode` and `USDF` are in the repository and NOT in the build, and
  the first two each declare an `[EditMode]` with a button image. Derive the
  Mode menu from the source tree alone and it grows "Image Example" and
  "WadAuthor Mode" — two entries no UDB user has ever seen, in the middle of a
  list that is otherwise right.
- **A mode Tag is prefixed by the plugin's ASSEMBLY name**, from its `.csproj`.
  `3DFloorMode` builds `ThreeDFloorMode.dll`, so its modes are
  `threedfloormode_*`. Every other shipped plugin's assembly happens to match
  its directory, which is what makes the guess so attractive and so wrong.
- **`GetFullActionName` has three branches** (`library`, `baseaction`,
  assembly) and only the third can fire for a mode switch action, because
  `EditModeInfo` builds it with the one-argument `BeginActionAttribute`
  (EditModeInfo.cs:82) whose constructor defaults the other two off
  (ActionAttribute.cs:63). Narrowed deliberately, with the reason written down.
- **One action table, not one per assembly.** `ApplyDefaultShortcutKeys` skips
  a default that any other action anywhere already holds, so the core's
  `Actions.cfg` and every shipped plugin's are loaded into a single map keyed
  by full name. Resolve them separately and two menu items can claim one key.

The shipped form's own oddities are ported rather than corrected:
`itemgridinc` carries the Tag `builder_griddec` and `itemgriddec` carries
`builder_gridinc` (MainForm.Designer.cs:700, :709) — `Actions.cfg:459` says
the names "were incorrectly swapped before" and the swap is still there. Two
Help items carry `Tag = ""`, which is not the same as no Tag, so the dumper
emits an empty string rather than dropping the key.

`view.html` mounts the bar and wires the fifteen actions this editor has.
Everything else keeps its real caption and shortcut and is greyed with "not
implemented yet" — the table stays whole, because the table is what parity is
measured against, and section 5c is the same list in prose.

**Two defects came out of actually wiring it**, neither visible from the unit
tests:

- Every menu item was **inert**. The dropdown is a child of its top-level
  element, so a row's mousedown bubbled to that element's toggle handler,
  which set `open = null` and re-rendered — destroying the row before its own
  click event could reach it. The menu closed on the way down, which is
  indistinguishable from a command that ran. Fixed by stopping mousedown at
  the panel.
- The menus did not follow an edit, because nothing called the state pass
  after one. `repaint()` — this editor's post-edit trigger — now drives it,
  skipped while a menu is open so the bar is not rebuilt under the cursor.

### The linedef Front and Back panes — done

The two panes now render UDB's own layout, and getting there turned up two
sector fields that had been missing since the dialog phase.

**They edit a SIDEDEF, and that is derived, not assumed.** Every control on
them writes to `l.Front.Fields` or `l.Back.Fields`, so `dialogparity` reads a
`side` off those accessors and the table records it; `tabsFor('linedef')` then
looks those keys up among the SIDEDEF's descriptors and stamps each with its
side, which is what routes a write to the right block. The caption "Front" is
a label — the accessor is the binding. A tab is only a sidedef tab when EVERY
bound control on it agrees, so one stray `l.Front.Fields` read cannot retype
the Properties tab.

**Three binding sources, where there had been one.** The companion's
`Fields.GetValue` is what the sector form uses and it finds nothing here:

  * `PairedFieldsControl` carries its two keys as DESIGNER properties —
    `pfcFrontOffsetTop.Field1 = "offsetx_top"`, `.Field2 = "offsety_top"` —
    because the companion just says `pfc.ApplyTo(l.Front.Fields, ...)` and the
    control supplies them. All twelve offset and scale fields are only here.
  * The texture pickers and the whole-sidedef offset bind Sidedef PROPERTIES:
    `fronthigh.TextureName = fl.Front.HighTexture`. The property→key mapping is
    the writer's, `UniversalStreamWriter.cs:253-258`, not a guess. @cite-scope UniversalStreamWriter.cs

That second source is what recovered **the sector's texture panning and
scale** — `ceilOffsets`/`ceilScale` are paired controls too, and
` Ceiling ` and ` Floor ` had been rendering six of their ten fields since the
dialog phase without anything noticing.

**A key is not a placement.** Front and Back carry the SAME keys, so bindings
are (key, control) PAIRS; a key→control map bound each to the Front control and
derived the Back tab empty. Groups are matched on the tab as well as the
caption, since both panes hold a ` Texture offsets `.

**What counts as a box.** An empty caption does not mean spacer: `frontgroup`
is captioned `"     "` and wraps the pane's boxes, while `groupBox10` on the
sector Colors tab is captioned with spaces and holds `ceilingglowheight`
directly. What a box CONTAINS decides it — a GroupBox holding another GroupBox
is padding, one holding only controls is a box, titled or not. That recovered
`ceilingglowheight` and `floorglowheight`, which had been falling through to
the Custom tab. And a bound control in no box at all is its own box, which is
the pane's entire right-hand column: three texture pickers sitting loose in the
spacer.

**Order, after two wrong answers.** Declaration order renders the middle
texture's scale under the lower one's. TabIndex — UDB's own traversal order —
is right almost everywhere and wrong in ` Texture offsets `, where
"Sidedef offset:" is tabbed 41 against the paired controls' 35-37 and drawn
ABOVE them. Plain y-then-x reverses ` Settings `, where `renderStyle` (92,17)
and `alpha` (233,16) sit side by side one pixel apart. What works is banding by
vertical EXTENT and then ordering across, and it needs no invented tolerance
because the designer gives every one of these controls a `Size`.

**Rows are UDB's rows.** ` Texture offsets ` is four rows of two boxes under
four captions, not eight rows named after their keys — a paired control is one
row, and so is "Brightness: [ ] ☐ Absolute", whose two controls sit five pixels
apart. A row IS a band, so the ordering above already computed it. Captions
come from the Label sharing the row, matched by POSITION: matching on `Tag` was
tried and fails on exactly the row that needs it, since
`labelFrontTextureOffset` captions "Sidedef offset:" with `Tag = ""`.

`fields` is kept as the flat projection of `rows` so the two cannot disagree,
and `--dialog` now compares rows AND compares field lists IN ORDER. It used to
sort both sides before comparing, which is how the scale group's top/bottom/mid
bug passed it.

**Not done, and in section 5c:** the "Front side" checkbox reports the truth
and is disabled, because checking it CREATES a sidedef (`MapSet.CreateSidedef`)
and unchecking it removes one; UDB's two-column ` Effects ` and ` Behaviour `
groups still render as one column; and `lightabsolute` shows under its key,
because that checkbox captions itself rather than having a Label beside it.

**One thing that is not a bug in this code:** `python3 -m http.server` sends no
`Cache-Control`, so a browser can serve a stale module from heuristic freshness
and a correct change looks broken — here, a `SyntaxError` about an export that
was demonstrably in the file. `fetch(url, {cache: 'reload'})` for the changed
modules, then reload.

### The sector dialog's slope controls — done

<!-- @cite-scope SectorEditFormUDMF.cs -->

SectorEditFormUDMF's slope utilities, :1719-1868. The first thing in the editor
that both READS and WRITES a plane, and the reason the Slopes / Portals tab had
two empty groups.

**The dialog does not show `floorplane_a/b/c/d`.** UDB fills each slope group
with a single `SectorSlopeControl` showing a ROTATION, a SLOPE ANGLE and a
HEIGHT OFFSET, and converts both ways. So these are DERIVED descriptors rather
than UDMF keys — they carry a `\u0001` key prefix that cannot collide with a
real one — and `editstate.js` reads them through the conversion and writes all
four plane keys from all three values at once, because they are three views of
ONE plane. UDB writes them in a single handler for the same reason.

**The quarter turns differ between floor and ceiling and are not a sign flip.**
Reading, the floor is `-(RadToDeg(GetAngleZ()) - 90)` and the ceiling
`-(270 - RadToDeg(GetAngleZ()))`; writing, the floor adds 90 to the rotation
and the ceiling 270. The tell that they are right is that the two normals come
out with the SAME x and y and only z mirrored — change the 270 to 90 and the
horizontal parts negate, which is a ceiling sloping the wrong way across the
room. Comparing the planes for mere inequality does not catch that.

**The offset is not `floorplane_d`.** It is the plane's height at the sector's
bounding-box centre (`GetVirtualSlopeOffset`, :1764) — a number that means
something to a mapper, where `_d` generally is not even close. Which point is
the `SlopePivotMode`: LOCAL is the sector's own centre and the form's default,
ORIGIN is the map origin, GLOBAL is the mean over the selection. Switching mode
re-reads the same plane through a different point and changes only the number
shown, which is why `OnPivotModeChanged` just calls Setup*Slope again.

**Rounding happens BEFORE the arithmetic, not after.** `Math.Round(..., 1)` is
applied to the degrees, and then `ClampAngle` and the `anglexy -= 180`
normalisation run on the rounded value without re-rounding — so a displayed
angle can carry float residue like 33.30000000000001 in C# exactly as it does
here. What must not happen is 33.333333.

**A backwards slope is normalised** (:1727): a rotation at or past 180 with a
negative pitch is the same plane as its opposite with a positive one, and the
dialog always shows the positive form.

Two things worth knowing about how it is wired. `EditState` needed a
`nodeOf(block)` resolver, because the offset needs the sector's bounding box
and a bounding box needs its sidedefs, which only the graph has — without one
it falls back to the ORIGIN pivot, which is a real UDB mode rather than an
invention. And a surface with nothing touched is not written at all, so opening
the dialog on a flat sector and pressing OK does not give it a slope.

**Verified end to end in the browser**: typed Rotation 45, Slope angle 25,
Height offset 64 into the Floor slope group, pressed OK, reopened the dialog and
read back 45 / 25 / 64 — with the plane's real height at the sector centre
exactly 64.

### Dragging a sloped sector — done (`dragmode.js`'s UDMF tail)

DragGeometryMode.OnDisengage :432-557, the part that was losing work: before
this, dragging a sloped sector left its plane where it was and the sector slid
out from under its own floor.

**Two updates, two gates, and the gates are not the same shape.** Texture
offsets slide the flat under a moved sector so the pattern stays put on the
ground, and are gated on `locktextureoffsets`, which ships FALSE
(BuilderPlug.cs:331) — so by default a dragged sector's flat travels with it
and nothing is written. Slopes have no setting at all: they are re-anchored on
every drag.

**Only a sector the drag TRANSLATES is touched.** `selectedsectors` is built at
drag start from the sectors whose every sidedef line is being dragged
(:177-206). One with a single edge in the selection is being deformed, and
sliding its plane along would be wrong — the ground under it changed shape
rather than moving.

**The offset is the ANCHOR's real movement** (`dragitem.Position -
dragitemposition`, :435), read after the snap. With grid snapping a 40-unit
drag moves the sector 32, and the plane has to follow the 32; every un-snapped
fixture agrees with the mouse delta and cannot see the difference.

**3D floors.** A linedef with action 160 makes its front sector the control
sector for everything tagged with its first argument, and that control sector
is where the 3D floor's own planes live — so dragging a tagged sector has to
bring them along. Two details: a tag of 0 controls nothing (":454 — 0 is not a
valid value"), and the slope gate is an XOR
(`!((controlsectors ^ addcontrolsectors) && draggedsectors)`, :476) which means
dragging a control sector ITSELF does not move its own planes. Odd, and ported
as written.

**The bounding box is computed for nothing, and is kept anyway.** UDB anchors
the re-anchoring on the sector's bbox centre — but the centre terms cancel and
the whole thing reduces to `d -= dot2(normal, offset)`, so any point on the
plane gives the same answer. It is kept, and computed the same way UDB computes
it (float-cast rectangle included), because the choice moves the last bits: the
candidates differ by about 1e-14, which is a different `floorplane_d` on disk.

Smaller things ported rather than tidied: the pan negates x and not y; the
flat's rotation is TRUNCATED to a whole degree before becoming radians, so
30.9 pans as 30; the wrap is by the scaled texture size with banker's rounding;
and a zero texture scale is skipped rather than divided by.

**Three fixtures could not discriminate.** Un-snapped drags cannot tell the
anchor movement from the mouse delta. A single dragged VERTEX marks no whole
line, so the partly-dragged filter is never asked — it needs a dragged EDGE.
And the sector-tag leading-zero rule cannot be observed here at all, because
the 3D-floor sweep already skips a tag of 0; it is ported for the reader's rule
rather than for this caller, and recorded as such.

**Verified end to end in the browser**, not only in fixtures: a sloped sector
dragged 96 units through the real handlers came out with `floorplane_d`
0 → −85.8624 and the same height under its own centre, −448 before and after.

### Sector planes — modelled (`planes.js`)

A sector's floor is not a number. UDMF gives four ways to say where it is and
`heightfloor` is the last of them, so `Sector.GetFloorPlane` /
`GetCeilingPlane` (Sector.cs:663-822) is a four-branch cascade:

  1. the sector's own SLOPE, `floorplane_a/b/c/d` — the plane equation outright;
  2. per-VERTEX heights, `zfloor` / `zceiling`, but only on a sector with
     exactly three sidedefs;
  3. a Plane_Align linedef, which tilts the sector towards its neighbour;
  4. flat, at `heightfloor`.

**The three-sidedef restriction is not a simplification to remove.** Three
points determine a plane and four corners need not lie on one, so a square with
`zfloor` on a corner does not slope — in UDB either.

**The slope guard is not what it looks like.**
`!double.IsNaN(s.FloorSlopeOffset / s.FloorSlope.z)` (:668) reads as "reject a
degenerate slope" and is not that. NaN comes from `_d` being absent, and from
0/0 — a flat normal with a zero offset. A flat normal with a NON-zero offset
divides to INFINITY, which is not NaN, so UDB accepts it and every point in the
sector reports an infinite height. Ported as written, and pinned by a test.

**The texture rules use a DIFFERENT, narrower resolution, and that is
deliberate.** `HighRequired` reads `this.sector.CeilSlope` directly
(Sidedef.cs:416) — no `_d`-is-NaN guard and NO Plane_Align branch — so a
sector tilted only by a Plane_Align line still compares as flat when deciding
whether a wall texture is required, even though `GetCeilingPlane` reports it as
sloped. Routing the rule through the full plane resolution is the obvious
tidy-up and would change which textures UDB asks for. Two functions, on
purpose.

The offset there defaults to **NaN**, not 0 (UniversalStreamReader.cs:418), so
a slope with a, b and c but no `_d` yields NaN at both ends, every comparison
against NaN is false, and the rule falls through to the flat heights. An
earlier draft of this port defaulted it to 0 and invented a plane UDB never
builds — one that would have demanded an upper texture UDB does not.

**Two of UDB's lines here provably do nothing**, and are kept rather than
quietly dropped:

  * the Plane_Align branch picks its winding from `SideOfLine` and inverts the
    test between floor and ceiling (:733 against :814). Swapping the first two
    points negates the cross product exactly — `cross(-a, b-a) = -cross(a, b)`
    — and the `up` flag then forces the normal's sign back, while the offset
    comes from p3, which does not move. Both orderings give the identical
    plane, verified numerically for both values of `up`.
  * `GetFloorPlane` takes a front side's END vertex and a back side's START
    (:681). Which end is unobservable in the only branch that uses it, because
    a three-sidedef sector's three lines' ends and starts are the same three
    vertices in a different rotation, and the plane does not depend on their
    order.

Both are recorded as equivalent mutations rather than left to look like gaps in
the fixtures.

**Measured against the 22-map corpus**, which says which branch is
load-bearing for the actual work:

| | |
|---|---|
| sectors | 19,100 |
| UDMF sector slopes (`floorplane_*`) | **0** |
| sectors touching a Plane_Align line | 1,179 |
| vertices with `zfloor` / `zceiling` | 37, ALL on three-sidedef sectors, across 10 maps |
| floor planes that resolve as sloped | 738 |
| ceiling planes that resolve as sloped | 170 |
| planes that resolve non-finite | 0 |

So Reese's maps slope through **Plane_Align and vertex heights, never through
the UDMF slope fields** — which is worth knowing before optimising the branch
that never runs.

And the honest result for the texture rules: over 164,692 two-sided sidedefs
the slope-aware `HighRequired`/`LowRequired` ask for **exactly the same
textures** as the flat-only rule did — 29,636 uppers and 45,274 lowers, a
difference of zero. That is not the branch failing to work; it is the RAW-slope
narrowness above doing what it says. The rule ignores Plane_Align, and the
corpus has no `floorplane_*` at all, so the only live input is 37 vertex
heights and none of them changes a decision. The branch is correct, tested, and
currently inert on this corpus — which is the sort of thing that looks like a
bug later if it is not written down now.

**Three fixtures could not discriminate first time** — the failure the method
section keeps warning about. A four-sided sector's outline is walked as
v1, v2, v3, v0 and only the first three would build a plane, so a `zfloor` on
v0 comes out flat whether or not the three-sidedef check is there. A
Plane_Align case tested only from the front side cannot tell that `args[0]`
is read at all. And the raw-versus-resolved distinction cannot be shown on a
single shared line at all, because Plane_Align makes the two sectors meet
exactly there — it needs the tilt to come from a different line.

### The Plane_Align resolver that matched nothing (`planes.js`)

Found by asking a plain question — *"do the slopes in my maps actually work?"* —
and measuring the answer instead of reading the recorded one.

`planeAlignActions(catalogue)` turns the game config into the set of action
numbers whose `id` is `plane_align`, which is how `hasPlaneAlign` stays a set
lookup. Against the real config it returned **the empty set**, so every one of
the corpus's **1,036 Plane_Align linedefs** resolved as no-slope and
`floorPlaneOf`/`ceilingPlaneOf` reported **0 sloped floors and 0 sloped
ceilings** where there are **738 and 170**.

**The cause is a name collision between two different `id`s.** UDB compares
`GetLinedefActionInfo(Action).Id?.ToLowerInvariant()` against `"plane_align"`
(Linedef.cs:825), and that `Id` is the .cfg's own `<action>.id` STRING, read
with a default of `string.Empty` (LinedefActionInfo.cs:107, exposed at :78).
But `FieldModel._catalogue` builds each entry with `id: parseInt(k, 10)` — the
action NUMBER — and keeps the .cfg's string on the entry's own config Map. The
function read `info.id`, got `181` where it wanted `"Plane_Align"`, and its
`typeof id === 'string'` guard then silently discarded every entry.

**The test passed the whole time, and that is the part worth carrying.** Its
fixture was `new Map([[181, { id: 'Plane_Align' }]])` — hand-written, and a
shape `_catalogue` never produces. It asserted the right behaviour against the
wrong world. The handoff's own 738/170 figure could not catch it either,
because the measuring script passed `new Set([181])` in by hand and so never
went through the resolver at all. Two independent green signals, neither of
them touching the broken path.

The fixture is now built through `buildFieldModel` from a small `.cfg`, so the
catalogue comes out of the real `_catalogue`, and it asserts the SHAPE before
the behaviour — `cat.get(181).entry.get('id') === 'Plane_Align'` and
`cat.get(181).id === 181` — so a future change to the catalogue fails here
loudly rather than re-hiding this. Mutation-tested three ways: the original
`info.id` read, dropping `toLowerCase` (UDB's `ToLowerInvariant`), and dropping
the `typeof` guard (UDB's `?.`, which exists because `GetLinedefActionInfo`
answers None, generalized and unknown actions with a constructed info whose id
is unset, GameConfiguration.cs:1347-1360). All three go red on all three tests. @cite-scope GameConfiguration.cs

**The wiring is still owed** and is now a row in section 5c: the resolver is
correct, but no caller passes `opts.planeAlignActions`, so `hasPlaneAlign` is
still false everywhere in the running editor. Nothing is wrong today, because
the app's only plane consumers deliberately use the raw slope — but **visual
mode is the first caller that must pass it**, and 738 floors and 170 ceilings
of Reese's own maps ride on it.

### Thing dragging — done (`dragthings.js`), and the undo bug it found

`DragThingsMode` is its own mode in UDB rather than a subclass of
`DragGeometryMode`, and it is its own module here for the same reason: dragging
geometry moves VERTICES and works out which lines and sectors come with them,
while dragging a thing moves a point nothing is attached to. They share their
snapping vocabulary and almost nothing else.

**Where the two modes differ, they differ in UDB.** Three of the four modifier
rules match and two things do not, and both are the kind that get unified by
accident:

  * snapping to the nearest item measures from the MOUSE (:213) where the
    geometry drag measures from the ANCHOR (DragGeometryMode.cs:249). With the
    grab point away from the anchor the two pick different targets.
  * `snaptonearest` is plain `CtrlState` (:433) where the geometry drag is
    `CtrlState ^ AutoMerge` (:592). So with "snap to geometry" ON, dragging a
    thing does not snap and dragging a vertex does, and Ctrl means opposite
    things in the two modes.

Ported as-is: the cardinal rule's `+ 44` band, the bounding box with its
truncation-only-inside-the-loop quirk, the boundary clamp, and the "did the
anchor actually move" test that stops a grid-snapped drag repainting per pixel.

**NEITHER DRAG WAS UNDOABLE, and that is the real find.** This undo records a
property change by capturing the value it is about to overwrite, and a drag sets
`ignorePropChanges` while it runs so its intermediate moves are not recorded. The
host opened the undo level BEFORE the drag — so the level closed with no commands
and was discarded. Dragging geometry and pressing Ctrl+Z did nothing whatsoever,
and had never worked.

UDB's order is explicit about why, and puts the line that matters in the middle:

```
MoveGeometryRelative(new Vector2D(0f, 0f), false, false, false, false);   // :415
General.Map.UndoRedo.IgnorePropChanges = false;                          // :418
General.Map.UndoRedo.CreateUndo(undodescription);                        // :421
MoveGeometryRelative(mousemappos - dragstartmappos, ...);                // :424
```

Move everything back, THEN resume recording, THEN open the level, THEN move to
the final position — so the first thing recorded is the position the drag
started from. `drop` now does that in both modules, and the host's `createUndo`
callback fires in the gap, which is exactly where recording should resume.

**A third command was missing too.** `TYPES` in `undo.js` listed vertex,
linedef, sidedef and sector, so `_onPropsChange` dropped every thing block on
the floor. Placing and deleting a thing were undoable through explicit
`AddThing`/`RemThing` commands; CHANGING one was not — in the dialog as much as
in a drag, and silently. `RecPrpThing` is the third of the three in UDB
(UndoManager.cs:1317), guarded by the same `map == General.Map.Map` test as a
vertex (Thing.cs:180); the equivalent here is whether the document still holds
that block at that index, since there is no thing node array to check against.
`PrpThing` carries the BLOCK rather than an index, as Add/RemThing already do.

An undone property change comes back with the right values in canonical
formatting rather than the original bytes — `restoreProps` marks the block
dirty on purpose. That is how vertices and sectors already behaved; things now
join them.

**Two fixtures could not discriminate and had to be rebuilt.** Landing the
mouse exactly on the snap target proves nothing, because snapping and not
snapping give the same answer — the drag has to end three units short. And a
cardinal drag of 100 units lands on 96, because cardinal FORCES grid-increment
snapping (:189); the first version of that test asserted the full length and
was simply wrong about the rule.

### Texture auto-alignment — done (`drawlines.js`)

`Tools.AutoAlignLinedefStrip` and its two halves, Tools.cs:1632-1749. When a @cite-scope Tools.cs
drawing produces a run of connected lines, each one's texture is slid along so
the run reads as one continuous wall: the offset is how far along the strip the
line starts, wrapped by the texture's pixel WIDTH. That width is the whole
reason this waited for the resource manager.

The strips were already being built — `drawlines.js` has assembled and returned
them since the draw phase, with the deferral written into the code where the
alignment call belonged. What was missing was something to measure.

**Two functions, not one, and the difference is not cosmetic.**
`localsidedeftextureoffsets` (GameConfiguration.cs:302) chooses:

  * the plain path sets the sidedef's single `offsetx`, and ONE texture decides
    it — middle, else upper, else lower, an if/else-if chain (:1657);
  * the UDMF path sets `offsetx_top`, `offsetx_mid` and `offsetx_bottom`
    INDEPENDENTLY, three separate `if`s, so a step with both an upper and a
    lower gets both, each wrapped by its own texture's width.

UZDoom's UDMF config sets the flag true, so this project takes the second. Both
are carried because the flag is real and configs differ on it.

**Three details that a summary would have lost:**

  * `(int)Math.Round` is banker's rounding — already in section 0's table for
    grid snapping, and it decides this too. A line starting 2.5 units along
    gets 2, not 3.
  * The modulo is conditional; the WRITE is not. `if(texture.IsImageLoaded)
    offset %= texture.Width;` (:1700) leaves an unmeasurable texture with the
    raw distance along the strip and writes it anyway. The plain path does the
    opposite and guards the whole assignment (:1665), so it writes nothing.
  * `if(offset > 0)` (:1701) is a guard on the write, not a way of spelling
    "write zero" — `UniFields.SetFloat` REMOVES a key equal to its 0.0 default
    (UniFields.cs:104). On a side that already carries an offset the two are
    opposites, and the obvious fixture cannot tell them apart, because deleting
    a key that was never there reads exactly like not writing one. That
    mutation went uncaught until the fixture started with the key present.

`IsImageLoaded` maps to a USABLE WIDTH here rather than `!pending`, because the
two halves of an image arrive separately: a PNG's size comes back synchronously
from its header while its pixels are still decoding, and `resources.js` says in
as many words that the header size is all this function needs. A placeholder
still waiting for its bytes is `width: 0`, and `offset % 0` is NaN.

**Two things fixed at the call site.** `DrawGeometryMode.cs:832` calls @cite-scope DrawGeometryMode.cs
`Tools.DrawLines(points, true, BuilderPlug.Me.AutoAlignTextureOffsetsOnCreate)`
— `useOverrides` is unconditionally TRUE for a user drawing, and `drawmode.js`
had been leaving it at the two-argument overload's false. The align flag is a
preference that ships FALSE (BuilderPlug.cs:316), so `view.html`'s toggle
starts unchecked. Its options are read at DRAW time rather than when the mode
is built, or a resource folder picked after the map opened would not count.

<!-- @cite-scope Tools.cs -->

Auto-alignment sits inside `if(sidescreated)` (:1549), which is worth knowing
before writing a fixture: an open run drawn in the void aligns nothing, because
it makes no sector, so its lines get no sidedefs, so there is no texture to
slide. The integration test draws a closed rectangle for that reason.

### The dialog's four smallest parity gaps — done (2026-09-06)

Four ledger rows closed, and three of the four turned out to be described
WRONGLY in the ledger. Each correction came from reading the designer rather
than from the row.

**A control that captions ITSELF.** A row's caption comes from the Label beside
it; a CheckBox has none, because WinForms draws its own `Text`. `dialogparity`
now derives a per-field `captions` off the control's own text, and `tabsFor`
puts it on a COPY of the descriptor — `fieldsFor` memoises its list, and UDB
shows the raw key for these on the Custom tab, so writing the caption onto the
shared object would rename the field everywhere.

The type test is the load-bearing half and UDB's own forms CANNOT pin it:
measured across all four, every bound control is a `ButtonsNumericTextbox`,
`ComboBox`, `PairedFieldsControl` or `CheckBox`, and only the CheckBoxes carry
a `Text` at all. So "has a Text" derives the same four captions and passes
`--dialog`. It takes a designer where a value-bearing control carries one — a
pre-filled TextBox — to tell the rules apart, which is why that fixture is
synthetic. `--dialog` also now reports any bound control of another class that
carries a Text, rather than quietly reading it as a value.

**The ledger said one field. It is four:** `lightabsolute` on Front and Back
<!-- @cite-scope LinedefEditFormUDMF.Designer.cs -->
(LinedefEditFormUDMF.Designer.cs:920, :1123) and `lightfloorabsolute` /
`lightceilingabsolute` on the sector's Surfaces tab
<!-- @cite-scope SectorEditFormUDMF.Designer.cs -->
(SectorEditFormUDMF.Designer.cs:1329, :1642). The row was written from the
linedef panes alone.

**Two-column groups, and this was not a shape problem.** A band drawn across a
columned group holds one row from EACH column, so reading a band as a row put
the left column's label on the right column's field. Five fields carried the
wrong caption: `alpha` read "Render style:", `soundsequence` read
"Brightness:", `fogdensity` and `conversation` read "Gravity:",
`floatbobphase` read "Score:".

A CELL is one Label and the controls between it and the next Label along the
band. Two traps in deciding which COLUMN a cell is in:

* **Exact control-x splits ` Ceiling ` wrongly.** It stacks its controls at
  x=110 and x=113 — three pixels apart, one column — while ` Effects ` puts
  real columns at 89 and 350. A tolerance would be invented, so the columns
  come from the bands that actually SPLIT: their cells give the origins
  outright and every other cell takes the origin at or to its left.
* **A cell's ordinal within its band is not its column.** ` Effects `' Sound
  sequence band has ONE cell because the Brightness beside it is a core field
  this model places elsewhere — and that cell belongs in column 1.

**THREE groups, not the two the ledger named** — the linedef's ` Settings `
(Render style | Alpha | Lock number) was never counted. Re-deriving changed
those three and left all 43 other groups byte-identical, which is the check
that the cell split disturbs nothing.

**The label column was an invented constant.** `.udmf-row` was
`grid-template-columns: 190px 1fr`, chosen here, and in a narrow group it
swallowed the whole cell: the thing's ` Position ` (span 7 of 24) drew X / Y /
Z as **12-pixel** inputs, values correct and invisible. UDB has no such
constant — in every group on the four forms the labels share a RIGHT EDGE and
vary on the left (` Ceiling `'s seven at x 26/32/37/63/47/56/44 all end at 106,
` Position `'s end at 26, ` Sector damage `'s at 84), so the column is as wide
as the widest label and the labels are right-aligned into it. That is
`max-content` over a column the whole group shares, which is what
`.udmf-row { display: contents }` gives. Position went 12px to 153px.

**Paint-select, in all FOUR modes** (`selection.js`, `thingsmode.js`) — the
ledger listed it as a things item and it is `VerticesMode.cs:671-700` and the @cite-scope VerticesMode.cs
same block in the other three. Two things had to be read:

* **Every mode's paint pick is RANGED, sectors included.** SectorsMode.cs:1196 @cite-scope SectorsMode.cs
  uses `NearestLinedefRange` where the sector HIGHLIGHT at :1246 uses an
  unranged `NearestLinedef` — so a sector highlights from the middle of a room
  and only paints within HighlightRange of a wall. Reusing `findHighlight`
  would paint every sector the mouse crossed.
* **Things pick over `HighlightThingsRange` (10), not `HighlightRange` (20).**

`t != highlighted` is the memo that stops a stationary mouse flipping an item
on every event, and leaving the item's range clears it so waving back paints
again. Two differences among UDB's four copies are equivalent rather than real
and are recorded as such: VerticesMode assigns `highlighted = null` before a
`Highlight(null)` that assigns it anyway, and SectorsMode sets `highlighted`
before toggling and then reads `!highlighted.Selected`, which is the same
object it just assigned.

`classicpaintselect` ships NO default key and sets `disregardshift`,
`disregardcontrol` and `disregardalt`, so alt+drag is this page's convention —
chosen the way left=select / right=edit was, and the only invented part.

**Nineteen mutations, all caught.** Three fixtures could not discriminate first
time: the caption type test (no real form can pin it), `tabsFor` mutating the
shared descriptor rather than copying (invisible on a side pane, which copies
anyway — only a SECTOR field sees it), and the things paint range (the first
attempt stood 15 units away and passed under either constant; radius 16 puts
the cutoff at 26 against 36, so it has to stand at 30).

**Two hours went into a bug that was not one.** Paint-select looked inert in
the browser: the page had a STALE `selection.js` with no `paintSelect` on it at
all. `python3 -m http.server` sends no `Cache-Control` and this document warns
about it in the linedef-panes entry; reach for `fetch(url, {cache:'reload'})`
early. Worth knowing too: the preview automation does not propagate modifier
keys through a drag and fires two mousemoves for a whole one, so paint-select
has to be driven with a dispatched event stream into the real handlers.

**Three ledger rows were corrected rather than closed**, each because the row
asserted something UDB does not do — see section 5c for all three: there is no
classic-mode auto-align to build (`AutoAlignLinedefStrip` is `private static`
with ONE caller, inside `DrawLines`, and all six texture auto-align actions are
`category = "visual"`); adding a sidedef is not blocked on `CreateSidedef` but
on the `frontsector` control beside the checkbox, whose whole design is now
written out there; and six sidedef fields (`light_top` and friends) are drawn
nowhere at all, because `SidedefPartLightControl` carries its own keys.

### The shape modes' option dockers — done (`infoparity.js`, `dockers.js`, 2026-09-06)

The four panels UDB edits its draw-shape settings in, docked with their modes.
**A seventh mirrored table** — `SHAPE_OPTIONS`, 4 panels and 33 controls,
derived by `infoparity.js`'s `deriveOptionPanel` from
`Source/Plugins/BuilderModes/Interface/Draw*OptionsPanel.Designer.cs` and
diffed by it. Never hand-edit it; re-dump it.

**The panels are two different SHAPES of form and needed two readers.**
Rectangle, ellipse and curve are ToolStrips: the controls are in
`Items.AddRange` order and a control's caption is the `ToolStripLabel` in front
of it, so reading order is the designer's own. The grid panel is an ordinary
form of three GroupBoxes, where order is GEOMETRY — group, then absolute Y,
then X — and a caption is the nearest Label to the LEFT on the same row. The
two rules cannot be merged: applying the ToolStrip rule to the grid panel
scrambles it, and applying the geometric rule to a ToolStrip invents rows.

**Two corrections the browser caught, both in the grid panel:**

1. *A self-captioned control must never borrow a label.* A CheckBox or Button
   draws its own `Text`, so the nearest-label rule handed `triangulate` and
   `reset` the caption of whatever sat to their left — `reset` came out
   labelled "Vertical". Gated on the control class, as the dialog's caption
   rule already is.
2. *`reset` is keyed by NAME, not by control class.* Three panels give it a
   `ToolStripButton` captioned "Reset"; the grid panel uses a plain `Button`
   with an IMAGE and no `Text` at all (DrawGridOptionsPanel.Designer.cs:114),
   and it lives INSIDE groupBox1 (:63) rather than beside the panel — a 24x45
   button standing to the right of the two slice-count boxes. Keying on the
   class rendered it as a number box labelled "reset", sitting outside its
   group.

**The numeric RANGES are deliberately not derived.** Every one of these panels
has its limits overwritten by the mode that builds it —
`panel.MaxSubdivisions = maxsubdivisions` (DrawEllipseMode.cs:61) — so the
designer's numbers are placeholders no user can ever reach, and checking them
would be checking dead values. The ranges come from the modes, as
`shapeLimits` in `view.html`.

**`SHAPE_OPTION_KEY` bridges the two vocabularies, and the bridge is checked.**
UDB's control names and its modes' field names are not the same word: the
ellipse's `subdivs` control sets `subdivisions`, and its `spikiness` control
sets the very field the rectangle's panel calls "Bevel Radius". That bridge
lives in `shapemodes.js` beside the defaults it names — not in the page — so
`selftest.js` can check it is TOTAL in both directions: every derived control
reaches a real settings field, is a declared unbuilt stub, is the Reset button
or is a separator; and every key in the bridge names a control some panel
actually has. Without it a renamed field leaves a control reading and writing
`undefined`, which looks right and does nothing. Four mutations confirm it
bites (rename a target, drop a mapping, un-declare a stub, add a key naming no
control), and the control mutation — deleting `ELLIPSE_DEFAULTS.bevelWidth` —
fails the geometry test as well as this one.

**Verified in the browser.** The grid panel renders UDB's three groups in
order, `Horizontal / Reset / Vertical` inside "Number of slices:", and setting
Horizontal to 5 then drawing a grid produces **15 sectors** — the panel drives
the generator, not just itself.

**Still unbuilt inside these panels**, greyed the way stubbed menu items are:
`continuousdrawing`, `showguidelines`, `radialdrawing`,
`placethingsatvertices`, `autoclosedrawing` and `relativeinterpolation`.

### The four shape draw modes — done (`shapemodes.js`, 2026-09-06)

`DrawRectangleMode`, `DrawEllipseMode`, `DrawGridMode` and `DrawCurveMode`. All
four derive from `DrawGeometryMode`, which is already here as `drawmode.js` +
`snapping.js`, and they differ in exactly ONE thing: what the clicked points
turn into before `Tools.DrawLines` gets them. So this is four shape generators
and their options, not four modes — the drawing, snapping, stitching, undo and
overlay are the existing path untouched.

**The routing was the one real bug, and it is UDB's own answer.** The first
version handed the generated points straight to `drawLines`, and an
eight-sided ellipse came out with NINE lines. UDB does not do that: it clears
the two clicked points and feeds the shape's points back through the BASE
class's `DrawPointAt` (:401), which carries two guards a generated ring needs —
a point identical to the previous one is dropped, and a point landing on the
FIRST one CLOSES the drawing instead of being added again. The closing point of
a ring is exactly that second case. Building the list directly skips both.

Five things per mode that had to be read:

- **A rectangle degenerates three ways** (:242-260). Two identical points are
  NO shape, two sharing an axis are a LINE — the mode still draws, it just
  draws a segment — and only then is it a box. An unbevelled ring is five
  points with the last repeating the first.
- **A negative bevel is not a mirrored positive one.** The arc's centre is the
  corner ITSELF when the bevel is negative and the offset point when it is
  positive (:298), so a positive bevel rounds the corner off and a negative one
  bites a concave notch out of it. Measured: at bevel 16 on a 64-unit box the
  positive arc comes within 8.28 units of the corner and the negative one sits
  at exactly 16 — its own radius. The first test asserted the notch would touch
  the corner and failed; the corner is the centre, not a point on the arc.
- **The ellipse is FITTED back into its box** (:182), and that is not optional:
  an N-gon inscribed in a circle touches the box only at its vertices, so at
  the default eight subdivisions the raw polygon is visibly smaller than what
  was dragged. UDB scales it about the centre to match the extents, then
  offsets its top-left onto the box's. Both passes, in that order. Its bevel is
  also forced off below six subdivisions, with mxd's reason in the source:
  "Works strange otherwise".
- **A flat grid box is a SEGMENTED LINE, not nothing** (:382) — evenly spaced
  vertices along a straight wall, which is easy to read as a degenerate case to
  bail out of. The slice counts can also come from the editor's GRID instead of
  the option, per axis, and the triangulation's starting parity comes from the
  box's own position on that grid (:458), so two grids drawn side by side
  continue each other's diagonal pattern.
- **The curve only curves with MORE than two points and a positive segment
  length** (CurveTools.cs:19); both fall through to the same polyline branch,
  so a curve mode with the option turned down draws what the freehand mode
  would. A CLOSED curve wraps for its neighbours (:39) so it is smooth across
  the join rather than cornering there, the shorter of the two adjacent
  segments scales the other before the tangent is taken (:82), and UDB checks
  and SWAPS the two control points when they come out reversed — its own
  comment says "Haven't quite worked out how this happens" (:138).

**The curve is a shape mode that is also freehand**, which needed its own path:
rectangle, ellipse and grid fire on the SECOND click, while the curve collects
points until you finish, like the base mode, and converts then.

**Verified in the browser**, drawn in empty space so the counts are the shapes'
own rather than stitching's: rectangle **4v 4l 1s**, ellipse **8v 8l 1s**, a 3x3
grid **16v 24l 9s**, and an open curve **17v 16l 0s**. Drawn inside the existing
map the ellipse gains a line, which is the stitcher splitting what it lands on —
correct, and the reason the clean-space measurement is the one to trust.

**Each mode's OPTIONS DOCKER is built** — bevel and subdivisions for rectangle
and ellipse, slice counts and interpolation for grid, segment length for curve.
The defaults and limits below are what those panels drive; the panels
themselves are the section above.

### Flipping a linedef — done (`linedefsmode.js`, 2026-09-06)

`LinedefsMode.FlipLinedefs` and `FlipSidedefs`, and a new home for the mode's
own actions: `selection.js` is `BaseClassicMode`'s half — highlight, click,
marquee — and this is the half that does things to what is selected.

**Neither action ships a default key**, so F and Shift+F are this page's
convention, on the same footing as alt+drag for paint-select. Everything else
is UDB's, and four things in it are not guessable:

- **Flipping a linedef is TWO operations.** `FlipVertices` swaps the ends and
  `FlipSidedefs` swaps front and back BACK again, so the line reverses while
  each side goes on facing the sector it faced. Doing only the first turns the
  sectors inside out.
- **The two filters are opposite shapes.** FlipLinedefs keeps a line when
  `l.Back != null || l.Front == null` — which keeps a two-sided line, keeps a
  line with NO sides at all, and drops exactly the ordinary one-sided case,
  because flipping that would put its only side on the outside. FlipSidedefs
  requires `Front != null && Back != null`. Reading the first as "skip lines
  with no back" also drops the malformed no-front case, which UDB flips.
- **Only one of them gives the highlight back.** FlipLinedefs borrows an
  unselected highlight and clears the selection afterwards — on the path that
  does nothing, too, because lines may have been filtered out. FlipSidedefs
  adds the highlight and never clears it. Ported as written.
- **The messages and the undo descriptions are UDB's**, singular and plural,
  including "Selected linedef already points in the right direction!" for the
  refused case — which is the only thing that tells you why nothing happened.

**Not cited by line, deliberately.** Those two actions live in BuilderModes' own
`Actions.cfg`, and `refs/` holds eighteen files with that basename; a
`Actions.cfg:302` here resolves against the CORE one and would be wrong while
passing `citecheck.js` — the exact failure that checker was rewritten to catch.
The module says so where the citation would have been.

### The dockers panel — the frame, and one docker (`dockers.js`, 2026-09-06)

The last piece of UDB's window furniture. `dockerspanel` is a `DockersControl`
holding `Docker`s, and a Docker is nothing more than a name, a title and a
control (Docker.cs:48).

**Three preferences decide how it behaves, and all three were read rather than
chosen.** `dockersposition` defaults to **1** — dockers RIGHT, modes strip left
(ProgramConfiguration.cs:361), which is the layout Reese described before
either was built. `collapsedockers` defaults to **false**, so the panel does
NOT auto-collapse; and `dockerswidth` is 300.

**Collapsing remembers what it undoes** (DockersControl.cs:144-145): the width
and the selected tab go into `expandedwidth` / `expandedtab` on the way down
and come back on the way up, so a reopened panel lands on the tab you left
rather than the first. The tab strip sits on the panel's INNER edge — the
alignment flips with the side (:120-128) — so the tabs always face the map.

**The Undo / Redo docker is ported whole**, and its list is not the obvious
one. `UpdateList` builds ONE list out of both stacks: the undo levels REVERSED
so the oldest is at the top, then the redo levels appended
(UndoRedoPanel.cs:80). The first row is a `begindescription` — "New Map" — and
it is the SELECTED row when there is nothing to undo, because it is the state
before anything happened. Everything past the current position is the redo side
and is greyed. And **clicking a row moves by the DELTA, not to the index**
(:282): rows above the current one are `PerformUndo(delta)`, rows below it
`PerformRedo(-delta)`. It looks like a jump and is a walk. `MAX_DISPLAY_LEVELS`
is 400 with an ellipsis row at either end, ported because a long session
reaches it.

**What is NOT built**, and why it is four rows rather than four stubs: BuilderModes
also contributes *Drawing Overrides*, and *Tag Explorer*, *Comments* and
*UDBScript* are each their own plugin panel. An empty tab claiming to be the
Comments panel is worse than no tab, so they are absent and listed in 5c.
Two smaller divergences are stated: the auto-collapse TIMER is a `mouseleave`
here rather than UDB's cursor poll (a poll exists because WinForms gives no
reliable leave event over child controls; the DOM does), and the drag-to-resize
splitter is not built.

**Verified in the browser:** the tab shows "Undo / Redo", three thing
insertions produce three rows with the current one highlighted, and clicking
the top row walks back through them — leaving the ones below greyed as the redo
side, which is the delta behaviour rather than a jump.

### The info panel — done (`info.js`, `infoparity.js`, 2026-09-06)

`panelinfo`, docked along the bottom: the five UserControls that tell you what
is under the cursor. **A sixth mirrored table** — 5 panels, 21 groups, 83 rows,
derived by `infoparity.js` from UDB's designer files and diffed by it.

**The derivation took four attempts, and each failure was informative.** The
problem is that a caption and a value are BOTH plain Labels on these forms, so
telling them apart needed a rule, and every simple rule was wrong:

1. *"a control the companion writes is a value."* Wrong: `arglbl1.Visible` and
   `labelclass.Enabled` are written all over these companions, so every
   argument caption came out as a value. Restricting to `.Text`/`.Image`/
   `.Angle` fixed that half.
2. *"a Label the companion never writes is a caption."* Necessary but not
   sufficient — `arglbl1` IS written, because the argument captions become the
   action's own argument names.
3. *"...or its designer Text ends with a colon."* Wrong on `arg1`, whose
   designer PLACEHOLDER is also "Arg 1:", copied from its own caption. It takes
   the colon AND `TextAlign = ...Right`; and right-alignment alone is wrong
   too, because `StatisticsControl` lays its row out backwards — the count on
   the left, right-aligned, with its caption after it. Both halves of the
   clause are load-bearing and each was found by the rule failing without it.
4. And the values still had to be FOUND, which needed three passes over the
   companion rather than one: a direct `x.Text =`, an ARRAY
   (`Label[] args = { arg1, ... }; args[i].Text = ...`), and a HELPER the
   control is passed to — `SetArgumentText(info, arg2, value)`, whose own
   parameter is written one level deeper still. So the analysis finds which
   methods write which of their PARAMETERS, to a fixpoint. Without the array
   pass, four of five argument rows derived with no value; without the helper
   pass, all six texture panes showed the designer's "128x128" placeholder as
   a CAPTION. `arg1` was right the whole time by accident, because one line
   happens to write it by name as well.

**Banding is seeded from the CAPTIONS**, not from every control's extent, which
is where the dialog panes' rule had to be changed rather than reused: the sector
panel's ` Floor ` group has a 64px texture preview beside four one-line rows,
and banding on full extent merged Offset, Angle, Scale and Light into a single
row. Each row also records its BAND, because a band can hold several cells
ACROSS — the linedef panel is three columns, which is why UDB's strip is about
a hundred pixels tall and not thirteen rows deep.

**Four things the readers had to get from UDB rather than invent:**

- **A thing's Z is three branches** (ThingInfoPanel.cs): absolute for an
  `AbsoluteZ` type or a thing in no sector, the gap down from the CEILING for
  one that hangs, and the height above the FLOOR otherwise — both through the
  sector's resolved PLANE. That makes the info panel the fourth consumer of
  `planes.js` and the first outside visual mode, and on Reese's Plane_Align
  maps the number moves as the thing crosses a sloped floor.
- **A thing's sector is found by CONTAINMENT** — `GetSectorByCoordinates`
  (MapSet.cs:4088) walks every sector and asks `s.Intersect(pos)`. The
  nearest-line rule the sector HIGHLIGHT uses is a different question and would
  pick a different sector, hence a different floor.
- **"0 - None" is UDB's, before the config is consulted.**
  `GetLinedefActionInfo(0)` returns "None" (:1350) and an unrecognised action
  "Unknown" (:1360) — so a .cfg that declares its own name at 0 does not change
  what the panel says. Reading the config first gets both ends wrong.
- **A long texture name is NOT upper-cased** (SectorInfoPanel.cs:79). That is
  not cosmetic here: Reese's maps carry `textures/wall/Subway/SUBWALL.png`, and
  upper-casing one breaks the lookup the name exists for.

**Two naming corrections came from Reese's "name everything as udb has it".**
"Argument 1:" turned out to be UDB's own default (ArgumentInfo.cs:403) and was
already right; the action and effect wording was not, and now goes through
UDB's own three branches instead of an approximation.

**What is NOT built inside it**, each showing one marker rather than its rows so
the group keeps its place: the linedef panel's six texture panes and flags, the
sector panel's colour and offset/angle/scale/light readouts, the thing panel's
sprite preview and flags. `console` and `heightpanel1` are not ported at all,
and a GENERALIZED action reads "Unknown" where UDB names its category.

**Verified in the browser**: hovering a linedef gives " Linedef 3 " with Action,
Lock, Length 512, Angle 180°, both sidedef offsets and five argument rows in
three columns at 124px tall; a sector gives " Sector 2 (4 sidedefs) " with its
heights and brightness; a vertex gives its position and both slope offsets as
UDB's own "--"; and with nothing highlighted the strip falls back to the
statistics, which is `HideInfo`'s behaviour rather than a blank bar.

### UDB's own button images — vendored (`mkicons.js`, 2026-09-06)

The icon debt every chrome row carried, closed. **98 images, 64 KB**, copied
into `icons/` and loaded by all three widgets, so the toolbar is one 25px row
of 23px buttons and the modes strip is 30px wide — UDB's own geometry, with
UDB's own pictures in it.

**They were already in the tarball `fetch-references.sh` downloads.** The
extraction is a pattern list and the PNGs simply were not on it, so this is
four added patterns rather than a new mechanism.

**UDB names an image three different ways, and only the first is obvious.**
`mkicons.js` handles all three because guessing one of them is wrong:

  1. A MODE icon names its FILE — `ButtonImage = "VerticesMode.png"` in the
     `[EditMode]` attribute — and the file is in that plugin's own
     `Resources/`. No indirection.
  2. A CHROME icon names a RESOURCE — `Image = Resources.Script2` — and
     `Source/Core/Properties/Resources.resx` maps it to a file. **`Script2`
     resolves to `Script.png`, and there is no `Script2.png` anywhere.**
     Appending ".png" to the name resolves 69 of the 72 chrome names this
     project uses; going through the resx resolves 71. That file is the
     authority and it is now in the MANIFEST.
  3. **One image is not a file at all.** `thingfilters.Image` is base64 inside
     `Source/Core/Windows/MainForm.resx` — the designer says
     `resources.GetObject("thingfilters.Image")` rather than naming a resource
     — so it is decoded out rather than copied.

**Generated, not authored, and gated as such.** `mkicons.js` reads the image
names out of the five chrome tables rather than carrying a list, so a table
re-dumped from a newer UDB brings its own names and this follows without being
edited. `--check` compares the BYTES on disk against what `refs/` produces and
fails on any drift — a stale `icons/` is exactly as wrong as a stale table.
`selftest.js` additionally checks the INDEX (it runs without external files),
and reports SOFTLY when `icons/` is absent, the way a missing
`demo-config.json` does: a fresh checkout that has not run the generator draws
captions and works.

**Vendored rather than read out of `refs/` at runtime**, which is a deliberate
divergence from this project's usual rule that `refs/` is never redistributed.
The reason is that parity is the requirement and `view.html` has to work on a
machine that has never run `fetch-references.sh`. It is legal because UDB ships
a plain GPL-3.0 `LICENSE.txt` with no asset carve-out and this project is
GPL-3.0 for exactly this reason. **One thing NOT verified:** whether every one
of the 98 is UDB's own artwork rather than inherited from Doom Builder 2 or a
third-party icon set. The licence names no exception, but absence of a note is
not provenance, and nobody has traced it per file.

**Loading is async and the fallback is the old behaviour.** `icons/index.json`
is fetched, so the widgets draw captions first and swap when it arrives — the
same shape as an arriving texture, which is why `setIcons` exists rather than a
constructor argument alone. If it 404s the captions stay and a console warning
says to run the generator. `layoutChrome()` MEASURES the modes strip rather
than repeating its width, so the view widens by itself when the strip narrows
from 172px to 30px.

**One thing the icons BROKE, and it is the sort that hides.** The "not
implemented yet" affordance is `color: var(--dim)`, which greys a caption and
does **nothing at all** to an image — so the moment UDB's pictures went in, the
21 unbuilt modes and 27 unbuilt toolbar buttons became indistinguishable from
the live ones, and the parity ledger stopped being visible in the UI it is the
ledger for. Greyscale plus a drop in opacity is an image's equivalent, applied
to the same items by the same class so the two cannot disagree. UDB's own
DISABLED state — the two radio sets outside a classic mode — is a separate
class and gets the same treatment for the same reason. Reese spotted this on
the first screenshot.

**Verified in the browser:** 64 images on screen, none broken; 21 of 26 mode
buttons and 27 of 36 toolbar buttons greyscaled with every live one in colour;
and every interaction still works in icon mode — the modes strip switches and
marks, the toolbar's view-mode radio moves, the snap buttons flip and report,
and the status bar's grid dropdown sets 128 mp.

### The main toolbar and the status bar — done (2026-09-06)

The rest of UDB's chrome, and it turned the two menu tables into **five**, all
derived by `menuparity` and all diffed against the designer: MENU_LAYOUT,
MODE_MENU, MODE_TOOLBAR, TOOLBAR and STATUS_BAR — **124 + 34 + 32 + 59 + 39
entries checked**.

**The menu bar and the toolbar are the same construct**, so they are read by
one function. `deriveMenuBar` and `deriveToolbar` are now both
`deriveItemTree(src, root)`; what differs is the root and which item CLASSES
turn up under it. Walking from a root rather than listing every `AddRange` is
still what keeps them apart — `dynamiclightmode` and `modelrendermode` mirror
two View submenus item for item, and taking every AddRange gives the menu bar
two menus twice over.

**Four item classes, and they are not interchangeable.** A
`ToolStripActionButton` carries its action in `Tag` and its TOOLTIP in `Text`,
because every one of them is `DisplayStyle = Image`. A
`ToolStripDropDownButton` — `thingfilters`, `linedefcolorpresets` — is
`DisplayStyle = Text`, and its Text is the **current value** ("(show all)"),
so drawing it as a tooltip loses the only place the active things filter is
shown. A `ToolStripSplitButton` is a button carrying its own action PLUS a
dropdown whose items hold integers rather than action names. And a separator is
a section break the toolbar's own context menu shows and hides.

**Counting by eye gave 51 items; the derivation says 52** (39 buttons, 13
separators, 7 nested). The number in the first draft of this document was the
guess, and it is the smallest possible version of the lesson.

**`UpdateToolbar` is almost entirely VISIBILITY** (MainForm.cs:2157-2208), and
every section past the file one is `Setting && maploaded` — so with no map open
UDB's toolbar genuinely has three buttons on it rather than fifty greyed ones,
the same shape as the File menu losing items. Three gates are more than a
section flag: the script button needs the configuration to declare script lumps
(:2160), and comments and visual vertices each need a UDMF map (:2178, :2207).

**Two buttons ARE the state.** `MainForm.SnapToGrid` and `AutoMerge` are
properties that read `buttonsnaptogrid.Checked` and `buttonautomerge.Checked`
(MainForm.cs:176-177) — there is no setting behind either. That is why the
designer ships both Checked and why Shift and Ctrl turn snapping OFF rather
than on, and it is the same pair `snapping.js` already XORs the modifiers
against. So `view.html` now passes those buttons in as `snapFlags`' `settings`,
and `applyToolbarState` deliberately never touches their `Checked` — a state
pass that "helpfully" reset them would undo the user's toggle on every repaint.
Pinned by a test.

**One button where the designer and the setting DISAGREE.**
`buttontogglefixedthingsscale` ships `Checked = true` and `fixedthingsscale`
defaults FALSE (ProgramConfiguration.cs:430), so UpdateToolbar's first run
clears it. Reading the designer alone draws it pressed. That is the
two-defaults trap this project already has in `fogdensity` and in a new
sector's brightness, met a third time.

**The STATUS BAR is where the grid size actually lives**, and porting it is
what stopped that being invented somewhere else. `statusbar`
(MainForm.Designer.cs:2249-2269) holds the message line (`Spring = true`, so it
takes the slack), the configuration's name, the grid size and its dropdown of
**thirteen designer-listed values**, the zoom and its eight, the mouse position
and a warnings counter. Two things in it are easy to miss: each dropdown item
carries its VALUE in its own Tag and the last entry of each list has none —
"Customize..." and "Fit to screen" are a different kind of thing — and all
three position labels carry `Tag = builder_centeroncoordinates` with
Ctrl+Shift+G, so clicking the coordinates is an action.

**The page's own top bar is gone**, at Reese's instruction, and each control
went to the place UDB keeps it: the wad picker to `File > Open Map` (a
browser opens a file dialog only inside a user gesture, and the menu item IS
one, so the hidden input is an implementation detail and the flow is UDB's);
tool and mode to the modes strip; the grid to `buttongrid`; the view mode to
the toolbar's radio set; the message to `statuslabel`. What is left is a small
strip explicitly marked **page-only**: a resource-folder picker and the
auto-align toggle, neither of which has any on-screen home in UDB. The old HUD
keeps only what the status bar has no slot for — the snap flags, the selection
and the undo/redo descriptions — which is `panelinfo`'s job in UDB and is a 5c
row rather than an invention.

**A file was damaged and reconstructed, and it is flagged in place.** Pasting
the toolbar table over its placeholder used an over-matching regex that also
swallowed `menuLabel`. No other copy of `menus.js` existed, so it was rebuilt
from its contract, its four self-tests and the `_label` renderer that consumes
it. Its behaviour is pinned by those tests; **its wording and internals may
differ from what stood there before**, and a note in the function says so.

**Fifteen mutations, fourteen caught; the control uncaught.** Two fixtures could
not discriminate first time: a radio set that only ever SETS the current member
agrees with a real one until the pass runs TWICE, and the "absent value leaves
its label alone" rule has to be asserted on `configlabel`, because the grid and
zoom are guarded at their call sites and survive either way.

**Verified in the browser:** the grid dropdown lists UDB's thirteen sizes and
setting 64 moves both the label and the renderer; the zoom dropdown moves the
view to 400% and "Fit to screen" back to 55%; the view-mode buttons behave as a
radio set with exactly one pressed; and the two snap buttons flip, report UDB's
own wording ("Snap to grid is ENABLED"), and change what the draw and drag
modes actually snap to.

### The modes toolbar — done (`menus.js`, `menuparity.js`, 2026-09-06)

UDB's `modestoolbar`: a strip of editing-mode buttons docked down one side,
30 pixels wide, captioned "Editing Modes"
(MainForm.Designer.cs:2763-2773). The first piece of the CHROME phase, and the
cheapest, because the table it needs was already derived.

**It is the Mode menu's list, and that is not an observation — it is one loop.**
`EditingManager` walks its groups and calls `AddEditModeSeperator` then
`AddEditModeButton` (EditingManager.cs:272-281), and each of those adds to the
toolbar and to the menu in the same breath (MainForm.cs:2308/2316,
:2334/:2342). So `MODE_TOOLBAR` is `buildModeMenu` again with ONE argument
changed: no designer separators, because `RemoveEditModeButtons` does
`modestoolbar.Items.Clear()` (:2296) where the menu keeps the two the designer
put in it and inserts after them (:2312). That difference is not cosmetic —
`updateToolStripSeparators` decides each separator's visibility from what
precedes it, so the two lists hide a different leading run.

Three things came out of reading rather than assuming:

- **The active button is found by the mode's CLASS name**, not its switch
  action — `(item.Tag as EditModeInfo).Type.Name == modeclassname`
  (MainForm.cs:2276). The two differ, and Visual Mode is the proof: its action
  is `gzdbvisualmode` and its class is **`BaseVisualMode`** (BaseVisualMode.cs:41),
  which no reading of the action, the display name or the image would produce.
  The first version of the test asserted `VisualMode` and failed, which is how
  it was found. `menuparity` now reads the class off the declaration each
  `[EditMode]` sits on — the FIRST one after the attribute, because several
  attributes can stack on one class.
- **`CheckEditModeButton` walks `editmodeitems` and nothing else** — a list only
  the two Add functions ever append to. That scope is load-bearing rather than
  incidental: the same menus carry check marks `applyMenuState` owns, and a
  pass that cleared every unmatched item would wipe the view-mode radio on
  every mode change. `checkEditModeButton` here touches only items carrying a
  `mode`, and a test asserts the view-mode mark survives.
- **Which side is a PREFERENCE.** `DockersPosition == 0` docks this strip RIGHT
  and the dockers left; anything else is the other way round (MainForm.cs:370,
  :379). `ModeToolbar` takes `side` rather than hard-coding one, and
  `view.html` takes the left, which is UDB's own default.

**A stated divergence, and which way it runs.** UDB's buttons are
`DisplayStyle = ToolStripItemDisplayStyle.Image` (MainForm.cs:2329) — icons and
nothing else — and its PNGs live in its repository, which
`fetch-references.sh` does not extract. Rather than draw twenty-six blank
squares, the strip renders each mode's own NAME and is therefore wider than
UDB's 30px. The divergence is confined to this one widget and runs towards
legibility; it closes in one line, because `ModeToolbar` takes an `icons` map
keyed by the `image` name the table already carries (read from each attribute),
and switches to 30px image buttons the moment one is supplied. **Whether to
pull UDB's icons into this project is Reese's call** — it is a redistribution
question, not a technical one, and the answer is a `fetch-references.sh`
pattern plus that map.

`view.html` mounts it beside the view and hands it the SAME `MENU_ACTIONS` map
the menu bar uses, so the five modes this editor has are live and the other
twenty-one carry their real names, greyed "not implemented yet" — the parity
ledger, rendered, exactly as the menu already does it.

**Eight mutations, six caught, one equivalent recorded** (`|| ''` on an empty
mode name, which cannot decide anything because every mode item carries a
string), and the control mutation came back uncaught.

**Verified in the browser:** 26 buttons, 4 visible separators — the two leading
ones correctly hidden — Linedefs Mode marked on load; clicking Sectors Mode
moved the mark and the dropdown, clicking Draw Lines Mode switched the tool,
clicking Bridge Mode reported "not implemented yet" and moved nothing, and
changing the dropdown moved the mark back. The Mode MENU showed the same single
check mark throughout, which is the one-list-one-function property working.

### The sidedef checkbox — done (`sidedefedit.js`, 2026-09-06)

The row section 9 named as next, and the ledger's own description of it was
wrong in the way that matters: it said the Sector index box needs a DERIVED
descriptor with a `\u0001` key, "exactly as the sector dialog's three slope
fields do", because `deriveDialogLayout` cannot see a control that binds no
`Fields.GetValue`.

**It binds a Sidedef PROPERTY, and that is a binding source this project already
had.** `frontsector.Text = fl.Front.Sector.Index.ToString()`
(LinedefEditFormUDMF.cs:322) is the same shape as
`fronthigh.TextureName = fl.Front.HighTexture`, which is how the Front and Back
panes recovered their whole right-hand column. And `Sidedef.Sector` is not a
derived quantity at all: the writer emits it as the UDMF key `sector`
(UniversalStreamWriter.cs:258), which every sidedef block in every map already
carries.

So the fix was one entry in `dialogparity`'s `PROPERTY_KEY`. The box then
derives into the untitled top box of each pane, one row ABOVE Brightness,
captioned **"Sector index:"** from `label11` — and `--dialog` went from 83 field
placements to 83 with two new rows, failing on exactly those four lines until
`DIALOG_LAYOUT` was re-dumped. Nothing was typed.

**The lookahead that keeps `SetSector` out.** `\.\s*Sector(?=\s*\.\s*Index)` —
the Apply branch says `l.Front.SetSector(s)` (:784) and the comparison beside it
says `l.Front.Sector != s`. Neither can bind today, because the outer match
requires a DECLARED control at the head of the line and `l` is not one. The
lookahead is pinned by a fixture that puts one there
(`frontsector.Enabled = (l.Front.Sector != null)`), because that is the shape
that would start binding the moment someone widened the outer match.

**A key is not always a field.** `sector` is a graph REFERENCE. Writing it the
way the pane writes `offsetx_top` — `blk.set('sector', n)` — would leave the
sidedef node in the old sector's `sidedefs` set while the document said
otherwise, which is the drift `verify()` exists to catch, arrived at through
the dialog. So `tabsFor` stamps the placed copy `sidedefSector`, the pane's
EditState never sees it, and the dialog REPORTS what the user asked for.
`sidedefedit.js` performs it in `view.html`'s undo level.

Four things had to be read rather than reasoned about:

- **The two write branches are `if` / `else if` (:764, :769).** A line that has
  the side and is unchecked is only ever disposed.
- **An INDETERMINATE box reaches neither branch**, and UDB freezes it —
  `AutoCheck = false` at :449, alongside the three-state. So a mixed selection
  of one-sided and two-sided lines cannot be flattened by one click, and the
  third state is something you can be left in but never choose. The pane stays
  ENABLED, because :858 tests against Unchecked and not against Checked, and
  UDB's own comment there says so.
- **An out-of-range index does nothing at all** (:775). It is not clamped and it
  is not an error, and `Sectors.Count` is read live.
- **`GetResult` on an empty box returns the original** (NumericTextbox.cs:389),
  read per line at :773 — which is what stops a mixed selection being flattened
  onto the first line's sector.

**One consequence had to be fixed rather than ported.** A side pane with no
sidedef used to fall back to the LINEDEF's EditState, which was safe only
because the pane was permanently disabled. Making the checkbox live makes that
pane reachable, and typing into it would have staged `offsetx_top` onto the
linedef block. UDB has the same situation and answers it the same way — every
live handler on those panes is guarded by `if(l.Front != null)`, so the value is
simply lost — so the pane now renders over an inert state.

**Eleven mutations, all eleven caught; four equivalents, recorded in the module
with their proofs** (the `else if` continue, the original index while
`AllowRelative` is false, the unreachable `?? CreateSector()`, and re-reading
`l.Front` after the create). Both control mutations came back UNCAUGHT, which is
the check the visual-mode phase had to add after a harness reported "caught" for
a no-op.

**Three fixtures could not discriminate first time**, each in the usual shape: a
typed `-1` never arrives as -1 because `AllowNegative = false` makes GetResult
return the original, so the `index > -1` half needs a BLANK box on a one-sided
line; `++1` from sector 0 is 1 under both the literal and the relative reading,
so the fixture has to start in sector 1 with a sector 2 to reach; and a blank
box on a mixed selection agrees with a broken indeterminate guard, so the box
has to carry a real index.

**Verified in the browser**, on `view.html`'s own fixture map, through the real
handlers: unticking Back side on the shared wall removed the sidedef and Ctrl+Z
brought it back in its own sector; ticking Back side on an outer wall with the
index box BLANK created nothing ("nothing changed", which is UDB's answer);
typing 2 created it in sector 2 and Ctrl+Z removed it; and retyping the Front
index moved the side to sector 2 and back. A two-line selection with one side
each way renders the box indeterminate, frozen, over an enabled pane, and asks
the host for nothing.

### The checker that was right by luck (`citecheck.js`)

Widening `fetch-references.sh` to pull the whole plugin tree — which the menu
bar needed, for the `.csproj` files and each plugin's `Actions.cfg` — broke
`citecheck.js` without touching a single citation.

It indexed reference files by BASENAME and kept the first the directory walk
found, with a comment saying no citation in this codebase was ambiguous. That
was true when written. Afterwards `refs/` held **18** `BuilderPlug.cs`, and the
six `BuilderPlug.cs:3xx` preference claims — `highlightrange`, `stitchrange`, @cite-scope BuilderPlug.cs
`additiveselect`, `autoclearselection`, `mouseselectionthreshold`,
`highlightthingsrange` — resolved against `3DFloorMode`'s file and failed.

The failures were the good half. `MainForm.cs`, `MainForm.Designer.cs` and
`Actions.cfg` are duplicated too, and kept PASSING for one reason: `readdir`
yields `Core` before `Plugins`. A checker that is right by accident reports the
same green as one that is right.

So the index keeps every path, a small `DISAMBIGUATE` table names the intended
file for each ambiguous basename that is cited, and an ambiguous basename cited
WITHOUT an entry is a failure rather than a coin toss — a future refetch that
adds a second copy of some cited file now breaks the build loudly instead of
silently re-pointing a citation. Mutation-tested three ways: dropping the
`BuilderPlug.cs` entry, dropping the `MainForm.Designer.cs` entry (the one that
had been right by luck), and pointing `BuilderPlug.cs` at the wrong plugin.

Two other things fell out of it:

- **`DoomPictureReader.cs` and `DoomFlatReader.cs` were never fetched.**
  `graphics.js` was ported from them and cites them throughout, but
  `Core/IO` is extracted file by file and neither was named — so every pointer
  into them was unverifiable. Both are now in the manifest and in the tar
  patterns. The tall-patch topdelta rule turned out to be at
  `DoomPictureReader.cs:207`, not `:212`; the claim was verbatim correct and @cite-scope DoomPictureReader.cs
  the pointer was five lines off, which is the usual shape.
- **`typeparity.js` told you to look in `refs/udb/types`**, a path
  `fetch-references.sh` has never created. The gate passes; only its error
  message was wrong. `node typeparity.js refs/udb` works.

### Visual (3D) mode — the foundation (`visual.js`, `visualgeometry.js`, `render3d.js`)

The phase the plane work was waiting for. `planes.js` has been able to answer
*"where is this sector's floor at this point"* since the slope phase and
**nothing consumed it** — the 2D view deliberately draws no slopes, and the
three app consumers that touch a plane all use the RAW slope on purpose. Visual
mode is the first caller that resolves a plane properly.

**What that wiring was worth, measured rather than asserted.** `sceneOptions()`
carries `planeAlignActions` and every surface build takes it. Across the 22-map
corpus, turning it on moves **738 sectors' floor geometry** — 6,374 vertices, by
up to **2,112 map units** — which is exactly the 738 sloped floors the plane
phase counted. On the bench fixture **52.3% of the rendered frame changes**.
Without it the view still renders; it renders level where Reese's maps slope,
which is why the ledger row existed.

**UDB's `Matrix` struct is inconsistent with itself, and it is not a bug.**
`projection`, `view` and `world` go to the shader as three separate uniforms
(Renderer3D.cs:377-379), so each only has to agree with how the shader reads it:

  * `Translation` puts the offset in M41/M42/M43 (Matrix.cs:63-69) and
    `Vector3D.Transform` reads it back as `x = M11*vx + M21*vy + M31*vz + M41`
    (Vector3D.cs:241) — a ROW-vector transform. The view is unambiguous.
  * `PerspectiveFov` (Matrix.cs:295-306) sets `M34` and `M43` the other way
    round. Under the row-vector reading the near plane comes out at NDC 0.00067
    instead of -1 and the far plane at 0.5 instead of 1; read TRANSPOSED both
    land exactly on -1 and +1, which is GL's depth range.

Both constructors are ported element for element so every number is checkable
against the source, and the ONE decision this port makes — that applying the
projection uses the transposed reading — is shown with its arithmetic and
pinned by a test at both planes. The practical consequence is two upload
functions: `viewToGL` writes the C# field order verbatim, `projToGL` transposes.

**The FOV setting reaches the matrix HALVED, and that breaks the invariant the
function exists for.** `CreateProjection` derives `fovy = Math.Atan(1.0f /
reversefovy)` (Renderer3D.cs:280) — an `atan`, not `2 * atan` — and @cite-scope Renderer3D.cs
`PerspectiveFov` halves its parameter again. Its comment says the FOV is
specified over X and converted through the aspect so the horizontal field is
what the user set; that holds only if the value passed is the full vertical
angle. Measured:

| aspect | horizontal FOV, as shipped | if the angle were passed whole |
|---|---|---|
| 1.000 | 40.000° | 80.000° |
| 1.333 | 42.077° | 80.000° |
| 2.667 | 44.553° | 80.000° |
| 4.000 | 45.080° | 80.000° |

So a `visualfov` of 80 renders as 40 on a square viewport and widens slowly as
the window does. Ported as written — it is what UDB's users' muscle memory is
calibrated to — and the test asserts UDB's numbers. **The first version of that
test asserted the INTENT and failed, which is how this was found.**

**Other things that had to be read rather than reasoned about:**

  * `FromAngleXYZ` (Vector3D.cs:227-235) scales the HORIZONTAL part by
    `cos(anglez)`, so at the resting pitch of PI it comes out NEGATED relative
    to `FromAngleXY` — a camera at anglexy 0 looks NORTH. Building it as
    "FromAngleXY plus a pitch" points the camera backwards at every angle.
  * The camera's pitch is NORMALIZED to [0, 2PI) and only THEN clamped
    (VisualCamera.cs:76-82). Clamping first sends a look-up straight back
    through the floor, because a pitch 91° below zero normalises to 269° — the
    opposite clamp. The two orders disagree by 178°, not by a rounding.
  * The clamps are 91° and 269°, not 90 and 270, and `LookAt` negates the X and
    Z axes but not Y — two axes flipped, not the usual one.
  * `verts[i].z = level.plane.GetZ(...)` for a flat, and a wall's four corners
    are the floor and ceiling planes evaluated at EACH END of the line. That is
    why a sloped sector's wall is a trapezoid, and why the texture coordinates
    come from `TexturePlane.GetTextureCoordsAt` (a barycentric interpolation
    over the wall's plane) rather than a lerp down the quad — a lerp shears.
  * **The UPPER's peg rule is INVERTED relative to its two siblings.** Middle
    and lower fire when their flag is SET; `VisualUpper.cs:157` fires when @cite-scope VisualUpper.cs
    `UpperUnpeggedFlag` is CLEAR. A fixture that never sets the flag cannot see
    this, and the first version of these tests did not.
  * `Math.Round(Sidedef.Line.Length)` — (G)ZDoom snaps texture coordinates to an
    INTEGRAL linedef length, so a 90.51-unit diagonal tiles as 91.
  * A ceiling's triangles are SWAPPED and a floor's are not, and the two
    conditions are exact opposites in the single-level case.
  * Wall shading is `vertwallshade = 16` and `horizwallshade = -16`
    (MapInfo.cs:96-97) — the opposite sign from the guess — UDB labels 90°/270°
    "Horizontal" and 0°/180° "Vertical", and the switch is on an integer
    TRUNCATION of the degrees, so 90.9° is shaded and 89.9° is not.

**An equivalent mutation, recorded rather than left looking like a gap.** A
wall's BOTTOM height cannot move its texture coordinates. The texture plane's
vertical gradient is `((topZ-bottomZ)/tsz.y) / (topZ-bottomZ) = 1/tsz.y` for any
`bottomZ`, and the anchor sits at `vlt.z = topZ`, so `bottomZ` cancels out
entirely. Its only real job is the one UDB's own comment gives it — keeping the
two apart, because at zero height the barycentric divide is 1/0 and every
coordinate comes back NaN. That is what the `bias` is for, and a test
demonstrates both halves.

**The renderer is the one place the MECHANISM diverges and cannot be ported.**
UDB draws through BuilderNative, a C++ layer over D3D/OpenGL whose shaders are
not in the C# source at all. `render3d.js` is WebGL2. What is ported is
everything that decides what the picture looks like — the matrices, the
surfaces, the texture coordinates, the brightness, batching by image
(SurfaceManager.cs:610) and the white placeholder for an image still loading
(SurfaceManager.cs:601, the same one the 2D view uses). What is this module's
own is buffer layout and two shaders, and they are marked as invented rather
than dressed in a citation.

**Verified in the browser, not only in fixtures.** The bench renders its
fixture in 1 ms — 2 sectors, 10 surfaces, 5 texture batches — and reading the
framebuffer back gives exactly the shades the ported brightness predicts: 192
for a `lightlevel` 192 flat, 144 for a `lightlevel` 160 flat (192 - 32*1.5,
the Doom curve), 168 for its vertical walls (160+16 through the curve) and 120
for its horizontal ones (160-16 through the curve). MAP01's 2,986 sectors build
19,439 surfaces and 170,898 vertices in 305 ms with zero non-finite values.

Mutation-tested: **16 reverts on `visual.js`, all 16 caught; 15 on
`visualgeometry.js`, 13 caught** — the two uncaught being a deliberate control
no-op and the `bottomZ` equivalence proved above. Both harnesses carry a
control mutation, which is how the first one was caught being **right by
luck**: it detected failure with `'FAIL' in line`, and one of the suite's own
test names ends in the word FAILS, so it reported "caught" for every mutation
including a no-op. It reads the exit code now.

**Three fixtures could not discriminate first time**, each in the shape section
0 keeps warning about: an upper/lower pair on a neighbour NESTED inside this
sector's heights builds nothing at all from the back side (and one both taller
AND deeper builds both from that side — only taller-but-shallower gives each
side one step); a texture-size fallback compared at x = 0, where `u` is zero
under every width; and the peg rules with the flag never set, where `!absent`
and `absent` agree.

**`Source/Core/VisualModes/` was not in `refs/`** — the whole base framework,
1,399 lines of `VisualMode.cs` included. It is in `fetch-references.sh` now,
along with `Renderer.cs` (the shared `CalculateBrightness`) and
`GZBuilder/Data/MapInfo.cs` (the wall shades). It was fetched WITHOUT letting
the staleness guard `rm -rf udb`, because two other sessions were running gates
against that tree at the time.

**Pointing the bench at a REAL map and Reese's own textures**, without a file
dialog. `visual.html` takes three query parameters:

```
visual.html?map=<wad url>[&mapname=MAP01]&res=<manifest url>&iwad=<wad url>
```

`res` is a JSON manifest `{ mounts: [{url, prefix, files:[relpath]}] }`, because
a browser cannot walk a directory it was not handed through a picker. Each
entry stays LAZY — `read()` is an HTTP fetch that happens only for an image
something asks to draw, which is the same laziness the on-disk path has and
UDB's own (FileImage.cs:152). `local-resources.json` is a GENERATED, local-only
file (like `demo-config.json`) and **must not be archived** — it indexes paths
under Reese's own trees. Regenerate it with the walk in this section's phase
log, and serve `~/Desktop/Work` so both the maps and the texture trees are
reachable: `.claude/launch.json` has `wwwdb-work-8801` for exactly that.

**The two mounts are NOT symmetric, and this is the same asymmetry
`rescheck.js` spells `--dir ...=textures`.** OTEX's paths are relative to its
own root (`Textures/OMRBLA01.png`), because `Textures/`, `Flats/` and
`Patches/` are the namespace directories. The F-State tree IS the `textures/`
namespace directory, so its paths carry that prefix
(`textures/wall/Subway/SUBWALL.png`). Mount OTEX with its own folder name in
the path and nothing in it is a texture at all.

Measured end to end on MAP01 through the browser: **2,986 sectors, 19,439
surfaces, 83 texture batches, 285 ms**, and **75 of its 82 distinct textures**
resolve against OTEX + F-State + freedoom2. The shortfall is the known
`textures/wall/SUBWALL.png` reference and the undecodable `.psd` files, both
already recorded.

**A timer, not `requestAnimationFrame`, for the texture sweep.** `render2d.js`
coalesces REPAINTS with rAF and is right to — a hidden tab genuinely does not
need repainting, and the handoff calls rAF "the browser's DelayedRedraw". But
this sweep coalesces texture UPLOADS, and a hidden tab does need those: with
rAF the bench sat at 1 of 82 textures forever and looked like a broken resource
path. `setTimeout(sweep, 1)` is also the more literal port — `DelayedRedraw` is
a Timer with `Interval = 1` (MainForm.Designer.cs:2718), not a frame callback.

**One reference-tree gotcha worth knowing:** several files under `refs/` are
CRLF + ISO-8859 and `grep` treats them as BINARY, printing nothing at all
rather than saying so. `Vector2D.cs` is one — `grep -n GetRotated` on it
returns silence, and `grep -an` returns the answer. A silent empty result reads
exactly like "this function does not exist", which is how `GetRotated` looked
missing for a minute.

### Two things to know before touching removal

- Removal RENUMBERS. UDMF references are positional, so deleting block *k*
  shifts everything after it. Only blocks whose index actually changed are
  dirtied — deleting the last vertex is byte-identical, deleting an early one
  rewrites most of the file. Stitching mostly deletes what it just created,
  which lives at the cheap end.
- `autoRemove` cascades deletion (vertex → lines → sidedefs) and must be turned
  OFF around a vertex swap, exactly as `Linedef.FlipVertices` does.

### Known, tracked, not bugs in this code

- `CIRQUE.wad` has **4 linedefs with no `sidefront`** — invalid UDMF. Those
  orphans are why 2 of its sectors will not triangulate or trace. A defect in
  the map.
- `map31.wad` carries a stray `thing.flags = 7`, a binary-format artifact no
  engine reads. Preserved on save, reported by `fieldcoverage.js`.
- `sidedef.alpha` and `sidedef.blockrendering` are UZDoom-only and appear in no
  UDB config, so they land in `describe()`'s `unknown` list.

---

### Saving and loading — done (`savemap.js`, 2026-09-06)

Reese asked whether save and load worked. Load did — `File > Open Map` has
parsed a wad and shown a map since the beginning. Save did not exist at all:
the three File items and the toolbar button were in the derived tables and had
no handler, and nothing in the tree called `writeTextmap`.

The port is `savemap.js`, the byte-producing core of `MapManager.SaveMap`
(MapManager.cs:797-1157), with `General.SaveMap` / `SaveMapAs` / `SaveMapInto` /
`AskSaveMap` (General.cs:1462-1745) wired into `view.html`. **MapManager.cs is
newly vendored** and added to `fetch-references.sh`; it was not in the tree.

**The thing that made this small: the document was already right.** The span
model means an untouched map re-emits verbatim, `topology.js` writes every
mutation back through `block.set()`, and `syncIndices` renumbers references
after a removal. So the save had nothing to serialize — it only had to decide
which lumps go where. That is the payoff the "lossless by construction" note at
the top of `udmf.js` was written for, and it is worth knowing it collected.

Four things in the port are worth reading before touching it:

<!-- @cite-scope MapManager.cs -->

- **`FindAndRemoveMap` does NOT search by name** (:1545-1652). It finds every
  lump carrying the map's name and scores the RUN after each one against the
  config's `maplumpnames`, because a patch or a texture may legally be called
  MAP01 — several megawads' level-select screens are exactly that. The obvious
  version of this function ("remove TEXTMAP, insert the new one") passes every
  test in the file and eats somebody's texture. There is a test for it.
- **`CopyLumpsByType` walks the CONFIG**, not a hardcoded list (:1690-1735), so
  what a save copies is `required` / `blindcopy` / `nodebuild` / `script` off
  the game configuration. `mapLumpInfos` derives it; nothing is transcribed.
- **`replacetargetmap` inverts on `deleteold`** (:1684). When the old map has
  already been deleted wholesale there is nothing to replace lump by lump, so
  the copy appends in order instead of searching for each lump's old position.
  Both arms are live: `SaveMap` passes `deleteold: true`.
- **The rebuild-from-scratch is deliberate** (:1036-1056, citing issue #531):
  UDB stopped patching the target file because a wad whose directory is not at
  the bottom breaks when patched. `Wad.build` rebuilds unconditionally, so this
  fell out rather than being arranged for.

`wad.js` gained `insert` / `removeAt` (WAD.cs:460-505) and the **official-IWAD
guard** — WAD.cs's 58 IWAD SHA-1s, verbatim, with SHA-1 written out rather than
taken from SubtleCrypto because that API is Promise-only and `Wad.parse` sets
the flag synchronously exactly where WAD.cs:359-371 does. Verified against @cite-scope WAD.cs
`shasum -a 1` on the 19 MB freedoom1.wad and the two FIPS 180-4 vectors.

<!-- @cite-scope UndoManager.cs -->

`undo.js` gained an `onchanged` callback at the three sites UDB writes
`General.Map.IsChanged = true` (UndoManager.cs:563, :722, :865). Note the
second and third: undo and redo BOTH mark the map changed, and they do not
cancel out — a map undone back to how it was on disk is still one UDB offers
to save.

<!-- @cite-scope General.cs -->

**What the browser supplies for `filepathname`:** a File System Access handle.
That is what makes Save a save rather than a second download, and it is why
`SaveMapAs` can port the "some muppets use Save As even when saving to the same
file" check at :1574-1579 — `isSameEntry` is the handle-level form of UDB's
full-path comparison. Without the API (any browser but Chrome's family) the
bytes go out as a download, Save behaves as Save As, and `Save Map Into` says
it needs the API rather than pretending.

Ctrl+S and Shift+Ctrl+S are **this page's**, not UDB's: `savemap` ships no
`default` in Actions.cfg (:94-101), the same footing as the F/Shift+F flip keys.
The menu tables were not touched.

**The one thing to know before trusting a saved map: it has no NODES.** There
is no nodebuilder, so `copynodebuild` is false and ZNODES/BLOCKMAP/REJECT are
dropped. The map reopens here perfectly and will not run in GZDoom. That, and
ten other owed pieces of `SaveMap`, are in 5c under "Saving".

Twenty tests, `selftest.js` 675 → 695.

### The toolbar's context menu — done (`menus.js`, `menuparity.js`, 2026-09-06)

The last unbuilt piece named in 5c's "main TOOLBAR" row. `toolbar.ContextMenuStrip
= toolbarContextMenu` (MainForm.Designer.cs:1354): a right-click anywhere on the
main toolbar raises a ten-item menu that shows and hides the toolbar's ten
sections. The state machinery was already here — `applyToolbarState`
(`UpdateToolbar`) has read the ten `General.Settings.Toolbar*` flags since the
toolbar phase — so what was missing was only the way to change them.

**The table.** `TOOLBAR_CONTEXT` is `menuparity.js`'s sixth derived table:
`deriveToolbarContext` reads `toolbarContextMenu.Items.AddRange`
(MainForm.Designer.cs:1416-1426) with the same `deriveItemTree` the other five
trees use, and `menuparity.js`'s walk diffs all ten rows against the designer so
the captions cannot drift. The designer gives each row only its `Text`; the
check marks and the behaviour are ported separately, from `MainForm.cs`.

**The row → setting map.** `TOOLBAR_CONTEXT_SETTING` (in `menus.js`, cited to
the `toggle*_Click` handlers, MainForm.cs:2436-2525). Nine rows flip a @cite-scope MainForm.cs
`Toolbar<Section>` flag; the tenth, **Rendering**, flips `GZToolbarGZDoom` — the
GZDoom-features section's flag, the one not named after its menu row
(ProgramConfiguration.cs:212 vs :265). A directory-name-style guess
(`ToolbarRendering`) would sit in the map looking right and toggle nothing; a
self-test toggles each flag and asserts `applyToolbarState` hides at least one
item, which fails on that guess.

**The behaviour** (`ToolbarContextMenu` in `menus.js`, from MainForm.cs:2401-2525):

* `toolbarContextMenu_Opening` (:2401-2419) — `if (General.Map == null) e.Cancel
  = true`: with no map open the menu does not appear. Otherwise every row is
  ticked from its flag. Here `canOpen : () => !!topo`.
* `toggle*_Click` (:2436-2525) — flip the one flag, then `UpdateToolbar()`.
  Ported as `onToggle(key)` in `view.html`, which flips `toolbarSections[key]`
  and calls `refreshMenus()`, whose `toolbar.setState` runs `applyToolbarState`.
* `toolbarContextMenu_Closing` (:2421-2424) — `e.Cancel = (reason ==
  ItemClicked && shiftPressed)`: a click on a row closes the menu UNLESS Shift
  is held, so several sections can be switched in one opening. A click outside,
  or Escape, always closes. While the menu stays open the clicked row's tick is
  refreshed in place (:2438-2441).
* `toolbarContextMenu_KeyDown` / `KeyUp` (:2426-2434) — the Shift latch the
  Closing rule reads. Ported verbatim, **including** the KeyUp arm
  `shiftPressed = (e.KeyCode != Keys.ShiftKey)` that sets it TRUE on any
  non-Shift key release. That reads like a bug; it is UDB's own line and
  section 0 forbids "fixing" a ported one, so it is kept and flagged in a
  comment.

**Where the state lives.** In `view.html`, `toolbarSections` — an in-memory
object of the ten flags, seeded all-`true` (ProgramConfiguration.cs:365-373,
:413), passed into every `toolbar.setState`. UDB persists these through
`ProgramConfiguration`; this project ports **no** settings-persistence layer,
which is the same reason `refreshMenus` hands the menu bar `settings: {}` and
the dockers a literal position. So a toggle holds for the session and resets on
reload. This is now the first place that gap bites — a change is *made* here and
then forgotten — so 5c gained a row for it: it is owed as one piece, a browser
`Builder.cfg`, not a per-feature bolt-on.

The popup reuses the menu bar's `.udmf-menu-panel` / `.udmf-menu-item`
rendering, fixed-positioned at the cursor with a viewport clamp, and stops
mousedown on the panel so a press on a row does not reach the document handler
that closes it — the same leak `menus.js`'s `_dropdown` documents.

Two tests, `selftest.js` +2 (719 → 721 as seen here; the suite count has
drifted upward from other work in the tree since the 675 → 695 line above, so
the delta is what this entry claims, not the absolute). `node menuparity.js
refs/udb/Source` reports "10 toolbar context entries" and passes; `node
citecheck.js` passes with the new `MainForm.cs` citations resolved.

### The settings persistence layer — done (`settings.js`, `dbconfig.js`, 2026-09-06)

**Goal.** Port `ProgramConfiguration` (`Source/Core/Config/ProgramConfiguration.cs`)
so an application setting survives a reload, the way `Builder.cfg` makes it
survive a UDB restart. Before this, every setting took its UDB default forever:
`refreshMenus` handed the menu bar `settings: {}`, the dockers a literal
`{ side: 'right', width: 300 }`, and `toolbarSections` was an in-memory object.
The toolbar context menu (entry above) was the first thing that WROTE a setting,
so it was the first place the gap bit.

**What landed:** `settings.js` (the store), a Configuration SERIALISER in
`dbconfig.js` (`writeConfig` / `readConfigSettings` / `escapedString`), the
toolbar context menu's ten flags migrated onto the store, and File > Export /
Import Settings actions for a portable `wwwdb-settings.cfg`. Details and the
follow-ups still open are below.

**Design (agreed with Reese — do not relitigate):**

1. **Primary store = `localStorage`, one JSON blob under one key.** Chosen over
   IndexedDB (async — `ReadSetting` is synchronous in UDB and the chrome reads
   settings during synchronous widget construction) and cookies (wrong tool —
   4KB, sent per request, string only). It is already the codebase pattern:
   `view.html` uses `localStorage` for the keys-legend and dockers collapse
   state, with a try/catch-both-ends idiom that falls back to defaults when
   storage throws (private window, blocked site data). `ProgramConfiguration`
   is a flat key -> scalar store; `localStorage` is the exact analogue.

2. **`readSetting(key, dflt)` mirrors `ReadSetting`'s default-arg semantics
   EXACTLY** — an absent key returns `dflt`, `key in store` is the test. This is
   load-bearing: HANDOFF already records `fixedthingsscale` where the designer
   ships the control CHECKED and the setting defaults FALSE, so "absent means
   default" and "default is not the control's state" must both hold.

3. **Write-on-change, not write-on-exit.** UDB calls `ProgramConfiguration.Save()`
   from form close (`MainForm`), but the browser has no reliable exit hook
   (`beforeunload` / `pagehide` are flaky). Writing immediately is the
   adaptation and is strictly safer — nothing is lost to a crash. **Documented
   divergence, direction: towards not losing data.**

4. **No schema / migration system.** `Builder.cfg` tolerates missing keys (the
   default arg is the whole mechanism), so a new key reads its default against
   an old blob and a removed key sits harmlessly. Do not build versioning.

5. **Keys = the PascalCase names the JS already uses** (`ToolbarFile`,
   `GZToolbarGZDoom`, `DockersPosition`), i.e. UDB's `General.Settings.<Property>`
   spelling, NOT `Builder.cfg`'s lowercase-dotted keys (`toolbarscript`).
   Nothing reads a real `Builder.cfg`. The mapping (property <-> cfg key) lives
   in `ProgramConfiguration.cs` if a true bridge is ever wanted; that is a
   separate, larger job (full key map + the structured sections: recent files,
   per-plugin blocks, colours as ARGB ints).

6. **Optional `.cfg` EXPORT / IMPORT, opt-in via menu actions.** Not auto-sync
   to disk (File System Access needs a gesture, re-prompts each session, is
   Chrome-only — it can only ever mirror `localStorage`, not replace it).
   - Import is nearly free: `dbconfig.js`'s `parseConfig` already reads UDB
     Configuration format. Read text -> parse -> merge -> persist -> `refreshMenus`.
   - Export needs a Configuration SERIALISER, which does not exist
     (`configjson.js` / `exportconfig.js` go config -> JSON, not back). Match
     `dbconfig.js` on round-trip. Ground the output rules in UDB's
     `Configuration` writer (`Source/Core/Config/Configuration.cs`,
     `OutputConfiguration` / `WriteConfiguration`).
   - The emitted file is `wwwdb-settings.cfg`, clearly this app's own — NOT
     named `Builder.cfg`, which it is not loadable as.

**Build order & STATUS:**

- [x] **`settings.js`** — DONE. `readSetting(key, dflt)` (absent → default,
      Configuration.cs:356-403), `writeSetting` / `writeSettings` (persist now, @cite-scope Configuration.cs
      not at close — divergence #3), `allSettings`, `replaceSettings`,
      `clearSettings`, `_reload` (test seam). Store is one JSON blob under
      `wwwdb.settings`, `load()` / `persist()` both try/catch → `{}` on any
      failure. `SETTING_DEFAULTS` = the 10 toolbar flags + `RenderGrid`, each
      cited to its `ReadSetting` line (ProgramConfiguration.cs:365-373, :413,
      :431). Header comment carries the whole design rationale.
- [x] **selftest.js coverage** — DONE, 5 tests (`selftest.js` 721 → 726):
      absent-vs-stored-`false`, persist+reload, batch write, throwing
      `localStorage` degrades silently, corrupt JSON → empty, `SETTING_DEFAULTS`
      covers every `TOOLBAR_CONTEXT_SETTING` flag, replace/clear. `withLocalStorage`
      installs a Map-backed shim on `globalThis`. Mutation-checked: truthiness
      instead of key-presence FAILS the absent-key test (the `!== undefined`
      variant is equivalent for a JSON store and does not, correctly). Added
      `settings.js` to `citecheck.js`'s `MODULES` (706 → 713 citations, all
      resolve).
- [x] **migrate `view.html`** — the toolbar context menu: `toolbarSections` is
      now `readSetting(key, SETTING_DEFAULTS[key])` per flag and `onToggle`
      calls `writeSetting`. The other consumers (dockers literal, menu bar's
      `settings: {}`, and — correctly — never `snapSettings`) are in the
      "follow-ups left open" list at the end of this entry.
- [x] **Configuration serialiser** — DONE, in `dbconfig.js` (kept with the
      parser; already scanned by citecheck and imported by selftest).
      `writeConfig(obj|Map, {newline='\r\n', whitespace=true})` is
      `Configuration.OutputStructure` (IO/Configuration.cs:1178-1260) called as
      `SaveConfiguration` does (:1474): per key in INSERTION ORDER — `null` /
      `DELETE` → `<tabs><key>;`, nested dict → blank line then `key` `{` recurse
      `}`, `bool` → `true;`/`false;`, non-integer number → `<n>f;`, integer →
      `<n>;`, else quoted+`escapedString`. `escapedString` is `EscapedString`
      (:413-424): `\` FIRST, then `\n \r \t "`. `readConfigSettings(src)` is the
      import side — `parseConfig` kept to top-level `assign` / `flag`. **The one
      documented loss:** JS has no float type, so a value UDB wrote as `40f`
      round-trips as the integer `40` (Configuration.cs:1240 vs :1247) — inert
      for the bool/int/string this project stores. 4 selftests, incl. a
      literal-bytes format check and a flat round trip; mutation-checked.
- [x] **export / import actions** — DONE. App-only `wwwdb_exportsettings` /
      `wwwdb_importsettings` in `MENU_ACTIONS`, surfaced as **File > Export /
      Import Settings...** via a `SETTINGS_MENU` ADDITION on `menufile` (the
      same mechanism as `PANE_MENU` on `menuview` — `MENU_LAYOUT` and
      `menuparity` untouched). Export: `writeConfig(allSettings())` →
      `showSaveFilePicker` → `writeHandle`, download fallback, name
      `wwwdb-settings.cfg` (NOT `Builder.cfg`). Import: `showOpenFilePicker` or a
      detached `<input type=file>` → `readConfigSettings` → drop `DELETE`
      values → `replaceSettings` → rebuild `toolbarSections` → `refreshMenus`.
      Reuses `savemap.js`'s `writeHandle` and download pattern. No new tests —
      the actions are glue over `writeConfig` / `readConfigSettings` /
      `replaceSettings`, which are all covered; `view.html` module parses.
- [x] **HANDOFF** — this entry is the "done" writeup. 5c row updated below.

**Verified (final):** `node selftest.js` **729/729** (+8: 5 for `settings.js`,
3 for `writeConfig`), `node menuparity.js refs/udb/Source` OK, `node
citecheck.js` PASS (**720 citations**, `settings.js` added to its `MODULES`),
`view.html` main module parses (`node --check` on the extracted `<script
type=module>`). Not exercised in a real browser — no agent here can; the File
System Access / `<input>` paths and the `localStorage` round trip are the parts
a human should click through once.

**Follow-ups:**

- [~] Migrate the **dockers** literal `{ side, width }` onto the store — IN
  PROGRESS (2026-09-06). Grounding: `SetupInterface` (MainForm.cs:344-391) reads
  `DockersPosition` (0 = panel LEFT / modes strip right; 1 = panel RIGHT / modes
  strip left, the default; **2 = dockers HIDDEN** — `!= 2` gates the whole
  setup, and this project has no hidden mode, so 2 maps to "shown right" and
  that gap is noted), `DockersWidth` (300, floored at `GetCollapsedWidth()` — a
  runtime measurement not reproduced here), `CollapseDockers` (false → the
  `autoCollapse` arg). Properties at ProgramConfiguration.cs:201-203, cfg reads @cite-scope ProgramConfiguration.cs
  at :361-363. WRITE-BACK is not owed yet: the resize splitter and the pin
  button are both unbuilt (5c dockers row), so nothing changes these at
  runtime.
- [ ] Pass **`allSettings()`** to the menu bar instead of `settings: {}` — NOT
  STARTED. Needs `SETTING_DEFAULTS` widened to the keys `applyMenuState` reads
  (`SplitJoinedSectors` :381, `RenderComments` :429, `FixedThingsScale` :430,
  `DynamicGridSize` :432, `AutoClearSideTextures` :426, plus the `GZ*` ones),
  each cited. NOTE a pre-existing bug to weigh: `applyMenuState` (menus.js) reads
  `s.AutoClearSidedefTextures` where `applyToolbarState` and UDB's property are
  `AutoClearSideTextures` — a key that can never match. Fixing it is a one-liner
  but check `selftest.js` first.
- `snapSettings` deliberately stays OFF the store — `SnapToGrid` / `AutoMerge`
  have no `General.Settings` backing (MainForm.cs:176-177 read the buttons).
- A true **`Builder.cfg`** bridge (lowercase-dotted keys + the structured
  sections) remains a separate, larger job and is NOT this.

### Textures and flats in both views — three bugs, not a feature (2026-09-06)

Reese asked to "add flats and textures to 3D view and top-down view". Both were
already BUILT — `render2d.js`'s textured view modes and `visualpane.js`'s
texture sweep — and neither could ever show a texture in `view.html`. Three
independent defects, all on the path between the resource picker and the two
renderers, and none of them in the rendering code.

**1. The resource tree mounted one level too deep, so nothing was indexed.**
`loadResources` built its entries from `webkitRelativePath`, which includes the
PICKED FOLDER'S OWN NAME, and a comment argued this was "exactly what the
reader wants". It is not. Every path a directory resource answers is relative
to the resource root — `PK3StructuredReader`'s `textures/`, `flats/`,
`patches/` and `sprites/` are all root-relative, and the directory you added is
not part of any of them. So OTEX mounted as `Otex/Textures/...`, which matches
no namespace at all. Measured, against `local-resources.json`'s own file list:

| mount | textures | flats |
|---|---|---|
| the picked folder's name kept (what shipped) | **0** | **0** |
| the root stripped (UDB, and `rescheck --dir`) | **2,624** | **1,309** |

Zero. Every surface in both views drew the white placeholder, correctly, for a
container set that contained nothing. `addDirectoryResource` strips the root
now, which also makes `firstwad/` mount as the resource root it actually is —
it has `textures/`, `sprites/`, `hires/` and a `PLAYPAL`, so it is a resource
directory in exactly UDB's sense. `rescheck` agrees to the name:

```
node rescheck.js refs/freedoom2.wad \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/Otex" \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/firstwad" \
  --maps "$HOME/Desktop/Work/rhythm doom_orig/firstwad/maps/"
```

gives **200/201 wall textures and 119/119 flats**, the same as the older
`firstwad/textures=textures` spelling and with 12 more uses resolved.

**2. `onImageLoaded` was one assignable slot that two views fought over.**
`Renderer2D.setResources` did `resources.onImageLoaded = () => this.queueRepaint()`
and `VisualPane.setManager` did `this.manager.onImageLoaded = () => ...sweep()`,
on the SAME manager, and `loadResources` called them in that order — so the 3D
pane's sweep silently threw the 2D view's repaint away. Late-arriving flats
could then never appear in the overhead view: the pixels landed, and nothing
asked the canvas to redraw.

UDB's shape is not a slot. `DelayedRedraw` (MainForm.cs:1128-1131) is one timer
on the MAIN FORM, armed by the data manager and observed by whatever is on
screen; the notification belongs to no single view. `ResourceManager` now has
`addImageLoadedObserver`, every firing site goes through `_notifyImageLoaded`,
an observer that throws cannot stop the others, and the `onImageLoaded`
property still works because `visual.html` assigns it directly.

**3. `loadResources` called `status(...)`, which is not a function.** Bare
`status` resolves to `window.status`, a STRING, so the call threw — one line
before its `repaint()`, which therefore never ran. The helper is `setStatus`.

**Also added, so this is verifiable at all:** `view.html` now takes the same
`?res=` / `?iwad=` / `?map=` URL parameters `visual.html` already had. The
manifest is not a convenience — the editor needs THREE containers mounted at
once (the IWAD for 33 wall and 18 flat names, OTEX, and the F-State tree) and a
`webkitdirectory` picker hands over one directory per gesture. So resources are
now a LIST that grows rather than a pick that replaces, in UDB's own order
(`DataManager` walks it forward and assigns, so the LAST wins), the bar has
`res dir` and `res wad` because `WADReader` and `DirectoryReader` are peers
under one container list, and a wad found INSIDE a picked tree is nested into
that reader rather than hoisted beside it (PK3StructuredReader.cs:79-88).

Verified in the browser on two maps: Freedoom's `FD1_E1M1` against
`freedoom2.wad` alone (flats drawn in the overhead view, 182 sectors), and
Reese's `CIRQUE` against the IWAD + OTEX + `firstwad` stack — 5,186 textures
and 5,186 flats indexed in the page, `FLOOR0_1` resolving to a real 64x64
image, and the 3D pane drawing 1,654 surfaces in 148 batches with its walls and
floors textured. **Note for the next person: CIRQUE is 30,000 map units wide,
so it opens at 5% zoom, where a 64-pixel flat is three pixels and the fill
reads as flat colour. It looks untextured and is not.** Zoom in before
concluding anything about the surface layer.

Not touched, and still true: the 2D textured modes and the 3D pane were
correct all along. 750 self-tests, `citecheck` clean.

### The info panel's texture previews — done (`info.js`, 2026-09-06)

Reese: "show flats/textures in the lower chrome section on hover, as UDB does
(currently 'not implemented yet')". Eight of the panel's picture boxes were the
`unbuilt` markers section 5c listed. They are drawn now.

**The spec is four lines of C# and every one of them matters.**
`LinedefInfoPanel.DisplayTextureImage` (:725-753), which
`SectorInfoPanel.ShowInfo` (:104-127) repeats twice inline for its two flats:

- a "none" name (empty, or `-`) shows NO image and hides the size label. WHICH
  no-image depends on `required`: a side that must have this texture gets
  `MissingTexture`, one that need not gets a blank box. That is the difference
  between "this wall has a hole in it" and "this side has no upper", and it is
  not guessable — `required` is `l.Front.HighRequired()` / `MiddleRequired()` /
  `LowRequired()` (:406-421), which `makesector.js` already carries with their
  slope branches. The SECTOR panel has no such parameter at all: an empty
  `LongFloorTexture` is always `MissingTexture` (:105-109), because a sector
  always has a floor and a ceiling;
- otherwise the image is the texture's preview and the size label reads `WxH`
  — but ONLY when `ShowTextureSizes` is on, the image is Ready, and it is not
  an `UnknownImage`. A pending texture shows its placeholder and no size,
  because a size it does not know yet would be a lie.

`ShowTextureSizes` is a real persisted setting defaulting TRUE
(ProgramConfiguration.cs:376) and is now in `SETTING_DEFAULTS`, read through
`readSetting` rather than hardcoded.

**The preview geometry is the designer's, not chosen here.**
`MakeImagePreview` (ImageData.cs:619-674) caps an image at `MAX_PREVIEW_SIZE`
= 256 on its longer side with `InterpolationMode.NearestNeighbor` — Doom art is
never resampled — and the box is 64x64 with `PictureBoxSizeMode.Zoom` and
`BackColor = AppWorkspace` (SectorInfoPanel.Designer.cs:474-484,
LinedefInfoPanel.Designer.cs:499-509). The two scalings compose, so a 128x32 @cite-scope LinedefInfoPanel.Designer.cs
texture draws 64x16 centred with grey above and below.

**Three things the FLATTENED derived table forced a decision about.** The
`INFO_PANELS` table records a box's rows and not its parent, so UDB's nesting
arrives flat. Each was resolved by reading the designer rather than the row:

1. `frontpanel` is the GroupBox captioned " Front ", it contains
   `flowLayoutPanelFront`, and THAT contains `panelFrontTop`/`Mid`/`Low` side
   by side. The three-pane strip is therefore drawn on the CONTAINER row, and
   the six flattened child boxes are hidden — otherwise each texture is drawn
   twice, once in its real place and once in the flattened one.
2. `labelFloorTextureSize` and its five siblings are parented ONTO their
   picture box at (1,1) with a half-transparent back colour
   (SectorInfoPanel.cs:50-55, LinedefInfoPanel.cs:54-64). They are an OVERLAY
   on the image, not a row. The derivation put them beside "Offset:" only
   because it groups by absolute Y and the label sits at the top of the box.
3. The name label goes under the image (`UpdateTexturePanel` :634-637). The
   sector panel already has `floorname` / `ceilingname` as their own derived
   rows, so its panes must NOT draw a second caption; the linedef panel's six
   name rows are hidden with their boxes, so there the pane carries it.

**Two rendering rules in `show()` changed, and both fix latent bugs.** A row
whose cells are ALL hidden now goes with them, caption and all — which is what
`panelOffsets.Visible = General.Map.UDMF` and the UDMF activation row already
wanted, and a non-UDMF vertex used to show a bare "Z Ceiling:" with nothing
beside it. And a box left with nothing but empty captions is dropped, because
the derived table carries rows with an EMPTY `values` list (a label the
derivation found with no value control beside it, such as `panelBackMid`'s
"Offset:"), which are harmless beside real rows and absurd alone.

**Stated divergences, the same ones already on the record for the map view.**
`MissingTexture`, `Hourglass` and `Failed` are UDB resource PNGs
(ImageData.cs:808-830) — art assets rather than behaviour, not reimplementable
from source and not ours to redistribute. The stand-ins are a magenta/black
check for missing, an empty box for "none and none required", and the box left
grey while an image is on its way. And UDB measures `ScaledWidth`/`ScaledHeight`
— the size after a texture's own `xscale`/`yscale`; this editor's images carry
no scale yet, so the two are the same number here and the line that has to
change when scaling arrives is named in `texturePane` rather than silently
equated.

A pending preview repaints on arrival for free: `repaint()` already ends in
`refreshInfo()`, and the 2D renderer's image observer already calls it.

Verified in the browser on Freedoom's `FD1_E1M1` (sector 38's NUKAGE1 and
F_SKY1 flats at 64x64; linedef 510's front strip showing STEP4 32x16 and
AQRUST10 64x128 with a blank, not-required lower) and on Reese's `CIRQUE`
against the OTEX + `firstwad` + IWAD stack (linedef 542's REPLACEW at 64x128,
and NO " Back " box at all, which is `frontpanel.Visible = false` for a side
that does not exist). 750 self-tests, `infoparity`, `menuparity`,
`dialogparity` and `citecheck` all clean.

**Still `unbuilt` in these two panels, and still 5c rows:** the linedef and
sector FLAG lists, the sector's offset/angle/scale/light rows and its two
colour panels, and the per-part UDMF offset and scale readouts — those last
ones no longer say "not implemented yet" once per box, because saying it six
times underneath a strip that works reads as the strip being broken.

**A trap worth writing down.** Half an hour went into "the previews do not
render" when the code was right the whole time: the browser was serving a
CACHED `info.js`. A cache-buster on `view.html` does not bust its module
imports. Restart the server on a new port, or check
`(await import('./info.js')).MAX_PREVIEW_SIZE` before suspecting the code.

### Phase log — TOOLS > GAME CONFIGURATIONS (2026-09-07)

**`ConfigForm` is ported, so the engine executable, the IWAD and the asset
directories are set from inside the editor.** `configform.js` (the dialog),
`configinfo.js` (`ConfigurationInfo` + `EngineInfo`), `datalocation.js`
(`DataLocation` + `DataLocationList`), `launcher.js` (`ConvertParameters`),
`handles.js` (the browser mechanism), `configparity.js` (the EIGHTH derived
table). **815 self-tests, 907 citations, 407 claims.**

Reese asked for "an actual config window where I can set executable for
UZDoom, set my base Iwads and asset directories". **That is not
`builder_preferences`.** UDB puts every one of those in Tools > Game
Configurations (`ConfigForm`, five tabs: Resources / Nodebuilder / Testing /
Textures / Modes), and `PreferencesForm` — Interface, Controls, Appearance,
Script Editor, Pasting, Recovery, Toasts, 146 KB of designer — sets no path at
all. Scoped with Reese before building: **ConfigForm alone, ported whole.**

`CONFIG_FORM` is derived from `ConfigForm.Designer.cs` and
`ResourceOptionsForm.Designer.cs` and diffed by
`node configparity.js refs/udb/Source/Core` — 2 forms, 10 tabs, 38 rows, 57
control placements. `MODE_INFO` is a SEVENTH table out of `menuparity.js` (37
edit-mode records), because the Modes tab needs the `[EditMode]` ATTRIBUTES —
`Optional`, `SafeStartMode`, `UseByDefault`, the map formats and features —
which `MODE_MENU` does not carry.

Things that had to be read, each pinned by a claim:

- **A caption is a colon-Label, and the two forms are shaped differently.**
  `ConfigForm` captions to the LEFT ("Engine:" at x=36 beside its combo at
  x=87), `ResourceOptionsForm` ABOVE ("WAD File Resource:" at y=20 over its box
  at y=37). One rule reads both: *the next value in reading order.* The colon
  has to be the test rather than "a Label the companion never writes to",
  because `noresultlabel` is a Label the companion only sets `.Visible` on and
  it sits BETWEEN `labelresult` and the box it captions (x=84 against x=86) —
  read as a caption it steals `testresult`'s.
- **Three of the six paragraphs are in the RESX**, not the designer:
  `label3.Text = resources.GetString("label3.Text")`. A literal-only reader
  reports them EMPTY rather than missing — the same shape as an empty grep
  result not being proof of absence.
- **A paragraph in the derived table is not always static text.**
  `noresultlabel` is a live readout, so the Testing tab builds it itself; the
  generic paragraph pass drew a SECOND one that nothing owned and never hid, and
  the tab claimed a result could not be displayed while displaying one.
- **The reorder is in `ResourceListView`, not `ResourceListEditor`.** The
  editor's own `DragDrop` only accepts a FILESYSTEM drop, so reading the obvious
  file makes reordering look absent while `label5` promises it. The rule is
  `if(dropindex > dragitems[0].Index) dropindex++` — without the increment a
  downward drag lands one place short.
- **The two engine-index clamps DISAGREE**: the constructor clamps an overrun
  to 0 (:169), `Apply` clamps it to `Count - 1` (:423). They look like one guard.
- **Selecting an engine RESETS the parameters** from the game configuration
  unless `customparameters` is set (:818) — the box is not the user's value at
  all while that box is clear.
- **`%L1`/`%L2` are replaced BEFORE `%L`.** Reversing those three lines turns
  `%L1` into `MAP011`, and every test that only uses `%L` passes either way.
- **`GetShortFilePath` is the identity off Windows** — its whole body is inside
  `#if NO_WIN32`. The checkbox stores, exports and changes nothing, exactly as
  for every Linux UDB user.
- **`testmonsters` ships TRUE**, so `%NM` is EMPTY by default.
- **`enabled` is saved before the `changed` guard**, so ticking a
  configuration persists on its own while editing its resources does not unless
  a handler set the flag.
- **A nodebuilder throws unless its compiler is registered**
  (NodebuilderInfo.cs:83), and this editor registers none — so both combo boxes
  hold only the empty entry UDB itself adds first, which is what UDB shows on an
  install with no compilers. The tab is drawn dimmed for that reason.

**Four browser divergences, all narrow and all visible to the user:**

1. **A path is not enough.** `location` is stored and exported exactly as UDB
   stores it, and a `FileSystemDirectoryHandle` is kept beside it in IndexedDB
   (`handles.js`) so the row can be read. A row has FIVE states where UDB has
   two — `unchecked`, `none`, `prompt`, `granted`, `missing` — and only
   `missing` is UDB's MistyRose. **Only `missing` blocks OK**: feeding
   `existsPredicate` straight into the faithful `listIsValid` made the button
   refuse to save any configuration whose folders had not been granted yet,
   which is a gate UDB does not have.
2. **`prompt` is the state of EVERY handle after a page reload** — a stored
   handle survives, its permission does not — so each such row carries a Grant
   button, because `requestPermission` outside a user gesture is rejected in a
   way indistinguishable from a refusal.
3. **The browse buttons cannot produce a path.** A browser never discloses one;
   `showDirectoryPicker` hands back a handle whose `name` is the last segment.
   So the picker fills the box with that name and stores the handle, and the box
   stays editable so a real path can be pasted for the `.cfg` export and for
   `%AP`. UDB's location box is editable too.
4. **Testing cannot launch.** Everything on the tab is stored and exported and
   `testresult` shows the converted command line — which is UDB's OWN label
   (ConfigForm.cs:377), not an addition. What is missing is the `Process.Start`,
   and that is a 5c row.

**Three bugs this phase found in code that already existed:**

- **A `Map` cannot live in the settings store.** `JSON.stringify(new Map(...))`
  is `{}` — silently. Every structured writer builds Maps because that is what
  `dbconfig.js` speaks, so `saveSettings` appeared to work, wrote nothing, and
  the resources came back empty. Flattened at the store boundary now, which is
  safe for ORDER because the keys are `resource0`, `engine0`, `set0` — non-numeric
  strings.
- **`readConfigSettings` SKIPPED nested blocks.** True enough when the only
  nested settings were two nobody read; with the game configurations it made
  Export-then-Import succeed and silently drop every resource path and engine.
- **A settings key containing a DOT is a path, not a name.** `WWWDB.PaneLayout`
  round-trips through `writeSetting`/`readSetting` (they agree with each other)
  and is invisible to anything expecting the flat name — so the preference
  looked like it simply did not stick. Additions use `wwwdb_panelayout`.

**Also done:** the **pane layout is a saved preference** (Reese's ask
mid-phase) — `wwwdb_panelayout` and `wwwdb_panelayoutlast`, restored at startup
after `refreshMenus`, so the Hammer arrangement comes back on reload and the
toggle returns to the multi-pane layout you were last in.

**`exportconfig.js` now records the source `.cfg` name** as `__source` beside
`__map`, because `ConfigurationInfo`'s settings key is the FILE NAME lowercased
and nothing inside a resolved tree says what file it came from. A
`demo-config.json` baked before this change stored everything under the EMPTY
key — which worked perfectly and would have looked like data loss on the next
re-bake. Re-bake it; a missing `__source` now warns and falls back to a named
key rather than to the empty one.

**Verified in the browser, not only in fixtures:** the dialog opens from Tools,
a directory resource is added and shows "path only — no folder picked", an
engine path is typed and its name derived (UDB's rule takes the last DIRECTORY,
so `/Applications/UZDoom.app/Contents/MacOS/uzdoom` is named "MacOS"), OK
stores under `configurations.uzdoom_doomudmf`, everything survives a reload, the
`.cfg` export carries the real paths, and the four-pane layout is restored on
startup.

## 9. Read this first — state, corrections and standing gotchas

This is context, not a plan: what landed, what was corrected, and what must not
be regressed. **For what to work on, run `bd ready`** — section 5c says why.

A note on reading what follows, and the phase log in section 8 with it:
mentions of *"a 5c row"* are **historical**. They record what a phase knew at
the time and are kept for their grounding, not as a work list. Every item they
name is filed as a bead; where a mention and a bead disagree, the bead is
current and the source beats both.

### Read this first (2026-09-07)

**TOOLS > GAME CONFIGURATIONS IS IN** (`configform.js`, `configinfo.js`,
`datalocation.js`, `launcher.js`, `handles.js`, `configparity.js`) — the engine
executable, the IWAD and the asset directories are set from inside the editor
now, stored under `configurations.<key>.*` and exported in a `.cfg` UDB could
read. **815 self-tests, 907 citations, 407 claims, TEN gates green.** See the
phase log at the end of section 8, and note the one thing that will otherwise be
re-derived from scratch: **the executable and the resources are NOT in
Preferences.** `builder_preferences` is a different, much larger dialog that
sets no path at all, and it is still owed — see 5c.

`CONFIG_FORM` is the eighth derived table (`node configparity.js
refs/udb/Source/Core`) and `MODE_INFO` the seventh out of `menuparity.js`.

### Read this first (2026-09-06)

**More than one agent works in this tree at once.** `selftest.js`, this file
and the session memory all changed underneath an in-progress session, and the
self-test count moved twice for reasons unrelated to the work in hand. So:
re-read a file immediately before patching it, keep edits surgical, never
restore a whole file from a backup without diffing it against disk first, and
**do not read a changed test count as evidence about your own change** — diff
the test NAMES. Ask which phase is yours before starting a big one.

**The four smallest dialog gaps are closed** — see the phase log entry at the
end of section 8. Three of the four were described WRONGLY in the ledger, and
the corrections all came from reading the designer rather than the row, so
treat a 5c row as a pointer to the source, not as a specification.

**THE CHROME IS DONE, BAR THE ICONS AND TWO PANELS.** UDB's menu bar, editing-
mode strip, main toolbar and status bar are all ported, all five tables derived
by `menuparity` and diffed against the designer, and all mounted in
`view.html`. See the two phase-log entries at the end of section 8.

**The ICONS are in** — 98 of them, `icons/`, generated from `refs/` by
`mkicons.js` and vendored so the page works without a reference tree. Both
strips now run at UDB's own geometry. See the phase log. The one thing not
verified is per-file PROVENANCE: the licence names no asset exception, but
nobody has traced whether every image is UDB's own artwork.

**THE INFO PANEL IS DONE TOO** (`info.js`, `infoparity.js`) — a sixth derived
table, 5 panels / 21 groups / 83 rows, with each `ShowInfo` ported. **Its eight
TEXTURE PREVIEWS are drawn now** (2026-09-06) — the linedef's six and the
sector's two, with `DisplayTextureImage`'s required / pending / size rules and
UDB's own 64x64 Zoom box; see the phase log at the end of section 8. The flags
lists and some readouts inside it are still not built and say so; see the 5c
row.

**THE TOOLBAR'S CONTEXT MENU IS DONE** (`menus.js`, `menuparity.js`,
2026-09-06) — right-click the main toolbar to show or hide any of its ten
sections. `TOOLBAR_CONTEXT` is `menuparity`'s SIXTH derived table (ten rows,
checked), and `ToolbarContextMenu` ports MainForm.cs:2401-2525: the no-map @cite-scope MainForm.cs
cancel, the Shift-latch that keeps the menu open across clicks, and each row
flipping one `General.Settings.Toolbar*` flag then re-running `UpdateToolbar`.
It is also the first thing in the editor that WRITES an application setting.
See the phase log at the end of section 8.

**SETTINGS NOW PERSIST** (`settings.js`, `dbconfig.js`, 2026-09-06) —
`ProgramConfiguration` ported onto a `localStorage` blob (`readSetting` /
`writeSetting`, absent-key semantics from Configuration.cs:356-403, @cite-scope Configuration.cs
write-on-change instead of `Save()`-on-close). `dbconfig.js` gained the
Configuration writer (`writeConfig` = `OutputStructure`), and **File > Export /
Import Settings...** move a portable `wwwdb-settings.cfg`. The toolbar context
menu's ten flags are migrated onto it; the dockers position and the menu bar's
`settings: {}` are the small follow-ups named in the phase log and the 5c row.

**THE CHROME IS DONE.** Menu bar, modes strip, main toolbar (with its
right-click context menu), status bar, info panel and dockers — EIGHT derived
tables, all checked, all mounted, with UDB's own icons. What is missing INSIDE
those pieces is listed in 5c: four of the five general dockers, three groups of
the info panel, and two remaining `General.Settings` consumers not yet moved
onto the now-built persistence layer.

**FLIPPING A LINEDEF IS DONE** (`linedefsmode.js`) — `LinedefsMode.FlipLinedefs`
and `FlipSidedefs`, on the selection or on the highlight standing in for it,
bound to **F** and **Shift+F** in `view.html`. Neither action ships a default
key in UDB, so the keys are this page's; everything else is UDB's, including
two filters that read as if they should be the same test and are not.

**THE FOUR SHAPE DRAW MODES ARE DONE** (`shapemodes.js`) — rectangle, ellipse,
grid and curve, wired to their own buttons on the modes strip, **each with its
OPTIONS DOCKER** — a seventh derived table (`SHAPE_OPTIONS`, 4 panels / 33
controls), rendered by `dockers.js` and arriving and leaving with its mode. See
the phase log for the two panel shapes it took to derive and what is still
greyed inside them.

**THE HAMMER MULTI-PANE LAYOUT IS IN** (`panes.js`, `elevation.js`,
`visualpane.js`) — section 5d, the one ADDITION. 1 / 2 / 4 panes with
shared-edge splitters, defaulting to today's single view; the two elevations
drawing the whole map in grey with the selection highlighted and slopes as
trapezoids; and visual mode in a pane rather than on a bench page. The View
menu carries the toggle as an ADDITION appended to the instantiated tree —
`MENU_LAYOUT` is untouched and `menuparity.js` was not relaxed. See the record
at the end of section 5d.

**SAVE AND LOAD BOTH WORK NOW** (`savemap.js`, 2026-09-06) — `MapManager`'s
save half ported, File System Access handles standing in for `filepathname`,
and all three of Save / Save As / Save Into wired. **A saved map has no nodes**
(there is no nodebuilder), so it reopens here and will not run in a source port;
that row and ten others are in 5c under "Saving". See the phase log.

**`EditSelectionMode` IS DONE** (`editselection.js`) — built next on Reese's
instruction, "parity first, prioritize parity before inventions", and it is
5d's keystone. The transform, the eight grips, move / resize / rotate / flip,
the accept-cancel dance and `AdjustSectorHeight` in full; wired to the Mode
menu with Enter to accept and Escape to cancel, and undoable. See the phase
log.

**EDITING IN THE ELEVATION PANES IS DONE TOO** — floor, ceiling and locked
whole-sector drags through `adjustSectorHeight`, and a CORNER drag that slopes
a surface, which is the first thing here that authors a slope. The grid is
drawn in the elevations and the handles are the overhead view's own grips. See
the phase log.

**`PICKOBJECT` IS DONE** (`pick.js`) — the camera ray against flats, walls and
things, in UDB's three passes, wired into the 3D pane and naming its target in
the status bar. See the phase log.

**THE FIRST VISUAL ACTIONS ARE IN TOO** (`visualedit.js`) — raise/lower either
surface with its slope and vertex arms, and nudge a wall's texture offsets,
undoable and grouped. See the phase log, including the correction it forced to
the elevation panes' "divergence".

**VISUAL SELECTION IS DONE** (`visualselect.js`, 2026-09-07) — click to select
a surface in the 3D pane, click again to deselect, with UDB's highlight and
selection colours drawn over the geometry and every ported action now running
over `GetSelectedObjects`. It brings `BaseVisualMode`'s whole action protocol
with it: `PreAction` / `CreateUndo` / `PostAction`, which is what makes five
selected floors raised together ONE undo level. See the phase log — including
the two rules that read as bugs and are not, and the bug of my own that only
the running editor could catch.

**FLOOD SELECT IS DONE** (`visualflood.js`, 2026-09-07) — shift for the same
texture, ctrl for the same height, alt to stop at what is already selected.
**The `usebuggyfloodselect` path is deliberately NOT built** and is the one
open divergence in visual mode; see the phase log for exactly what it does and
why Reese chose to leave it out.

**A CORRECTION that changes the order (2026-09-07).** The 5c row for the
NODEBUILDER claimed a saved map "will NOT run in a source port" and called it
"the single largest thing between here and a usable editor". Both are wrong for
UZDoom: a ZDoom-family port builds nodes, BLOCKMAP and REJECT at load when they
are missing <!-- @cite-scope maploader.cpp --> — measured against
`refs/gzdoom/maploader.cpp:3106-3147`, `:2604` and `:2815`. It costs load time,
not correctness. The nodebuilder is a
performance row, and the thing actually between here and a usable editor is the
3D editing loop.

**THE SIDEDEF CHECKBOX IS DONE** (`sidedefedit.js`) — see the phase log at the
end of section 8. The dialog reports an intent per side and `view.html` performs
it inside the undo level it already opens, because removal RENUMBERS. And the
ledger row describing it was WRONG in the same way three of the four dialog-gap
rows were: it said the Sector index box needs a derived `\u0001` descriptor,
where in fact it binds a Sidedef PROPERTY that maps to the real UDMF key
`sector`. One entry in `dialogparity`'s `PROPERTY_KEY` and the row derives
itself. **Treat a 5c row as a pointer to the source, not as a specification** —
that is now four rows out of five.

Both of the smaller ones named beside it have since moved on. **Six sidedef
fields (`light_top` and friends) drawn nowhere at all** was the same shape
again — `SidedefPartLightControl` carries its own keys the way
`PairedFieldsControl` does — and it is **DONE** (2026-09-09): `dialogparity`'s
`deriveSidedefPartLight` reads the `Setup()` switch, `DIALOG_LAYOUT` was
re-dumped, and `--dialog` checks 95 placements where it checked 83. The thing
and linedef **ARGUMENTS are still drawn twice**, and that is a bead.


**The RESOURCE MANAGER is done** (`resources.js`) — see section 8. It turns
*"this sidedef says STONE2"* into pixels: `WadResourceReader` is `WADReader`
(lump ranges from the config, the patch/flat/texture/sprite searches) and
`ResourceManager` is the resolving half of `DataManager` (container override
order, PNAMES carry-over, the flat/texture mixing rule, decode and cache).

**The DIRECTORY READER is done too** (`DirectoryResourceReader`), because
Reese's resources are **not in a WAD at all**. The unpacked F-State repo is at
`~/Desktop/Work/rhythm doom_orig/firstwad/`, and the textures it uses live in
two unpacked DIRECTORY trees of **PNG** files:

| where | what |
|---|---|
| `~/Desktop/Work/rhythm doom_orig/Otex/` | **OTEX** — `Textures/` 2,624, `Flats/` 1,309, `Patches/` 4,042 PNGs |
| `.../firstwad/textures/` | F-State's own — plus `flats/`, `wall/`, `sky/` subdirectories |
| `.../miracle/build/uzdoom.pk3` | the engine's own resources, a real PK3 |

Resolving every texture and flat name the 22-map corpus references against
that stack gives:

| | distinct names | uses |
|---|---|---|
| wall textures | **199/201 (99.0%)** | 138,534/138,596 (100.0%) |
| sector flats | **119/119 (100%)** | 38,053/38,053 (100%) |

split as **33 wall / 18 flat from the IWAD, 156 / 98 by directory basename,
and 10 / 3 by a LONG PATH** — names like `textures/wall/Subway/SUBWALL.png`
and `Textures/OMRBLA01.png` written into the map. So `longtexturenames = true`
is not a formality: the short↔full name translation that `resources.js` builds
and that stays empty on a WAD-only path is **live in Reese's maps**, and the
`hasLongName` arms of `noteNameTranslation` — along with `getFlatEntry`'s
ordering asymmetry — start deciding real lookups the moment a directory reader
exists.

With the directory reader and the PNG header path in, the same measurement
through the real code is:

```
node rescheck.js refs/freedoom2.wad \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/Otex" \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/firstwad/textures=textures" \
  --maps "$HOME/Desktop/Work/rhythm doom_orig/firstwad/maps/"
```

**wall textures 200/201 (99.5%), sector flats 119/119 (100%), 5,186 textures
and 5,186 flats indexed in 47 ms, 3,986 of them long-named.** The single miss
is Reese's own broken reference, below. Note the `=textures` suffix: F-State's
tree IS the `textures/` namespace directory, so it has to be mounted under that
prefix or nothing in it is a texture.

**TEXTURED SECTOR SURFACES are wired in too**, on the asynchronous path —
placeholder and repaint, which is what UDB itself does. See the phase log in
section 8. `view.html` has a `view` dropdown for the four modes and a `res`
folder picker; pick the OTEX folder or the F-State `textures` folder and switch
to *floor textures*.

**THINGS are done** — drawn with their sprites, and a things MODE that selects,
marquees, places and deletes them, all undoable. See section 8.

**THE DIALOG STRUCTURE IS DONE.** `dialogparity.js --dialog --dump` reads it out
of UDB's designer files, the table is baked into `fieldmodel.js` as
`DIALOG_LAYOUT`, `dialogs.js` renders it, and `--dialog` checks 17 tabs, 36
groups and 33 field placements so it cannot drift. The sector dialog now has
its six tabs, the thing form is genuinely three columns wide, and every field
the designer places is on the page UDB puts it on.

**THE MENU BAR IS DONE.** `menuparity.js --dump` reads UDB's seven menus out of
`MainForm.Designer.cs` and builds the Mode menu — which the designer leaves
empty — from the `[EditMode]` attributes of the plugins `Builder.sln` ships;
`menus.js` carries both tables and the `MenuBar` widget; `view.html` mounts it.
`node menuparity.js refs/udb/Source` checks 124 menu entries and 34 mode
entries so neither table can drift.

Three things about it are worth knowing before touching it, each of which cost
a measurement:

* **A mode item's Tag is prefixed with the plugin's ASSEMBLY name**, read from
  its `.csproj` — not its directory. `3DFloorMode`'s assembly is
  `ThreeDFloorMode`, so its three modes are `threedfloormode_*`; a
  directory-name guess gets exactly that one wrong, and it is the kind of
  wrong that sits in a checked table looking right.
* **`GetFullActionName` has three branches and only the third is ported.** The
  `library` and `baseaction` overrides cannot fire for a mode switch action,
  because `EditModeInfo` builds it with the one-argument
  `BeginActionAttribute`, whose constructor defaults both off. That reasoning
  is written out in `menuparity.js` rather than left as a silent narrowing.
* **The core's and every plugin's `Actions.cfg` are loaded into ONE table**,
  keyed by full name, because `ApplyDefaultShortcutKeys`' collision rule is
  global — resolve them separately and two menu items can be handed the same
  default key.

**THE LINEDEF FRONT AND BACK PANES ARE DONE**, in UDB's own layout — see the
phase log in section 8. They edit a SIDEDEF, and which one is derived from the
companion's `l.Front.Fields` accessors rather than taken from the tab caption.
Doing them turned up two things that had been wrong since the dialog phase: the
sector's ` Ceiling ` and ` Floor ` groups were missing texture panning and
scale, and `ceilingglowheight`/`floorglowheight` were falling through to the
Custom tab.

**TEXTURE AUTO-ALIGN IS DONE** — see the phase log in section 8. Both of UDB's
paths are ported, `localsidedeftextureoffsets` chooses between them, and
`view.html` has an *align* toggle that ships OFF exactly as UDB's preference
does. It needs a resource folder picked: the offset is a distance wrapped by a
texture's pixel width, and with nothing to measure UDB's own `IsImageLoaded`
guard leaves the offsets alone.

**THING DRAGGING IS DONE** (`dragthings.js`) — and doing it turned up that
NEITHER drag was undoable. See the phase log in section 8.

**SECTOR PLANES ARE MODELLED** (`planes.js`) — see the phase log in section 8.
All four of UDB's branches, and `High/LowRequired` now carry the slope
comparison they were deliberately missing, so drawing on sloped geometry asks
for the right wall textures.

**THE DRAG-TIME SLOPE UPDATE IS DONE TOO** — `dragmode.js`'s UDMF tail, the
one that was losing work. A sloped sector dragged whole now arrives with its
plane, and a 3D-floor control sector's planes follow the sector it controls.

**There is no sloped rendering to do in the 2D view.** UDB's does not draw
slopes: `Renderer2D.cs` and `SurfaceManager.cs` mention neither `Slope` nor
`Plane`, and never read a sector height at all — a 2D surface is a footprint
with flat texture coordinates, so a sloped sector looks exactly like a level
one. Every slope in the rendering layer belongs to `Renderer3D`. Building a
2D slope indicator would be inventing a feature, which section 0 forbids.

**THE SECTOR DIALOG'S SLOPE CONTROLS ARE DONE** — the Slopes / Portals tab now
edits slopes, which is the first thing in the editor that both reads and writes
a plane. See the phase log in section 8.

**VISUAL (3D) MODE IS OPEN, and its foundation is in** — `visual.js` (the
camera and the matrices), `visualgeometry.js` (sectors and sidedefs as textured
surfaces) and `render3d.js` (a WebGL renderer). `visual.html` is its bench, the
way `demo.html` and `gfx.html` are: a built-in fixture map, WASD and mouse look,
and a resource folder picker. See the phase log in section 8 — in particular
UDB's two disagreeing matrix conventions, and that `visualfov` reaches the
matrix HALVED.

This is where `floorPlaneOf` / `ceilingPlaneOf` finally earn their keep, and
the `planeAlignActions` wiring the ledger recorded as owed is closed: turning it
on moves 738 sectors' floor geometry across the corpus, by up to 2,112 units.

### Two things about the 3D work that section 5d depends on — do not regress them

The multi-pane layout needs the 3D view to be embeddable as a PANE rather than a
fullscreen takeover, and needs the camera to be an object rather than mode
state. **Both are already true**, and that was not guaranteed — UDB's visual
mode is a mode you enter that owns the screen. As built here:

* `VisualRenderer` takes a **canvas in its constructor** (`render3d.js:65-67`)
  and reads its size from `this.canvas.width/height` (:154-155). No `document.`
  or `window.` reference anywhere in the module — it draws where it is told.
* `VisualCamera` is a **first-class class in its own module**, with
  `createProjection(width, height, opts)` and `perspectiveFov(fov, aspect, ...)`
  parameterised by viewport size rather than by the window.
* `visualgeometry.js` builds geometry as **pure functions of the document** —
  `buildSector`, `buildWall`, `buildFlat` take a sector or sidedef plus `opts`,
  and are not owned by the mode.

So an N-pane host is a matter of N canvases and N cameras, and `view.html` is
the only thing holding a singleton assumption. **Keep it that way.** If a later
change moves sizing, input or camera state into the mode, section 5d gets much
more expensive and the cost will not be obvious at the time.

---

The grounding, kept because it is what the implementation was built from —
`dialogparity.js` exports `deriveDialogLayout(designerSrc, companionSrc)`,
which reads the whole thing out of UDB rather than deriving anything. Nothing
below is inferred; it is what the designer files say.

**UDB's forms have TABS, and we render three where it has up to six:**

| form | tabs |
|---|---|
| sector | Properties, Colors, Surfaces, Slopes / Portals, Comment, Custom |
| linedef | Properties, Front, Back, Comment, Custom |
| thing | Properties, Action / Tag / Misc., Comment, Custom |

And the groups on each, with their spans, come out exactly:

```
sector  [Properties]        Flags span 12 | Sector damage span 6 @7
        [Colors]            Global sector colors span 6 | Doom 64-like span 6 @7
        [Surfaces]          Ceiling span 12 | Floor span 12
        [Slopes / Portals]  Ceiling slope 6 | Ceiling portal 6 @7
                            Floor slope 6   | Floor portal 6 @7
thing   [Properties]        Thing 4 | Flags 6 @6 | Roll/Pitch/Angle 2 @11
                            Position 3 @6 | Rotation 4 @9
        [Action/Tag/Misc.]  Rendering 5 | Behaviour 6 @7 | Action 12 | Identification 12
linedef [Properties]        Flags, Settings, Action, Activation, Identification — all span 12
```

So the linedef Properties tab really IS one column; the multi-column tabs are
the ones we were not rendering at all.

**Field membership comes from the form's `.cs` companion**, because that is
where the binding is:

```
ceilBrightness.Text = sc.Fields.GetValue("lightceiling", 0).ToString();
UniFields.SetInteger(s.Fields, "damageamount", damageamount...);
```

The left side is the control, the string is the UDMF key, and `groupOf` walks
the control up to its GroupBox. **26 of 26 sector keys bind to a captioned
group this way.** The one filter that makes it reliable: the control must
EXIST in the designer — the same file also assigns to a snapshot class with
PascalCase properties (`CeilOffsetX`, `LightColor`) that are not controls, and
without the filter those come through as bogus names.

**The MENUS extracted just as cleanly** from `MainForm.Designer.cs`: seven
top-level menus (File, Edit, View, Zoom is inside View, Mode, Prefabs, Tools,
Help), with their items, separators, submenus and shortcut display strings.
The **Mode menu is EMPTY in the designer** — it holds two separators and is
filled at runtime from the registered editing modes, so it could not be read
from the form and comes from the `[EditMode]` attributes instead. All of that
is now built and gated; see the top of this section.

**Do not hand-type any of it.** The first hand-written layout table had 10 of
27 placements wrong.

---

The original diagnosis, kept because it explains WHY this was invisible:

  * the group SPANS are correct and checked — `dialogparity --layout` verifies
    all 27 placements against the designer geometry, and the sector table
    really does declare two columns for the colour groups and the slope and
    portal groups;
  * but `fieldmodel.js` groups fields by the **`.cfg`**, and the `.cfg`'s
    grouping is not the designer's. For a sector it yields
    `Flags / Ceiling / Floor / Properties / Comment` — 5 groups where the
    designer has 10. Everything the designer puts in `Sector damage`,
    `Global sector colors`, `Doom 64-like sector colors`, `Ceiling slope`,
    `Ceiling portal`, `Floor slope` and `Floor portal` lands in `Properties`
    instead, misses the layout table, and takes the span-12 fallback;
  * so the multi-column tabs render as one column, which is what Reese sees.

The fix is the same shape as `GROUP_LAYOUT` itself, and most of the parts
exist. `UDB_CONTROL` already carries a designer `group` for the CORE spec
fields, and `dialogparity.js`'s `groupOf()` already walks a control up to its
enclosing GroupBox caption. What is missing is that mapping for UNIVERSAL
fields: a derived key→group table, dumped from the designer rather than typed,
with `dialogparity` checking it — the same treatment, for the same reason, as
the four other mirrored tables. Do NOT hand-write it; the first hand-typed
layout table had 10 of 27 placements wrong.

**One broken reference, in `MAP40.wad` MAP01**, and it is Reese's to decide
about rather than anything to fix here: `textures/wall/SUBWALL.png` on 50
sidedefs, where the file is actually at `textures/wall/Subway/SUBWALL.png` —
the `Subway/` segment is missing. Every other name in the corpus resolves.

(`SUBWALL_` on 12 more sidedefs looked broken too and is not: it resolves to
`SUBWALL_LIGHT.png` through the ZDoom 8-character file-title rule, since
`SUBWALL_LAMP` and `SUBWALL_LIGHT` both truncate to `SUBWALL_`. Which of the
two wins is a genuine collision, and it is UDB's.)

Also reported, not failed: **seven `.psd` files** in the texture tree are
indexed as images and cannot be decoded. `LoadDirectoryImages`
(PK3StructuredReader.cs:808) makes an image for every named file it finds and
UDB logs an error for each — so Reese keeping Photoshop sources next to the
exports produces exactly this in UDB too.

**`rescheck.js` is how you check a resource stack before blaming the
renderer.** Pointed at only the IWAD it resolves 33 of 202 wall names, which is
a statement about which resources were loaded rather than about this code —
add the `--dir` mounts above and it is 200 of 201. A drop in that number means
a missing or moved resource to ask Reese about, not a bug to hunt.

Run the gates before starting anything. They take about a minute and are the
fastest way to find out whether the tree is where you left it:

```
node selftest.js
node dialogrender.js
node citecheck.js
node conformance.js   refs/udb/Assets/Common/Configurations
node dialogparity.js  refs/udb/Source/Core/Windows --layout
node dialogparity.js  refs/udb/Source/Core/Windows --dialog
node menuparity.js    refs/udb/Source
node configparity.js  refs/udb/Source/Core
node infoparity.js    refs/udb/Source/Core/Controls
node mkicons.js       --check
node typeparity.js    refs/udb
node stackcheck.js    "$HOME/Desktop/Work/rhythm doom_orig/firstwad/maps/"
node rescheck.js      refs/freedoom1.wad refs/freedoom2.wad
node rescheck.js      refs/freedoom2.wad \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/Otex" \
  --dir "$HOME/Desktop/Work/rhythm doom_orig/firstwad/textures=textures" \
  --maps "$HOME/Desktop/Work/rhythm doom_orig/firstwad/maps/"
```

If `refs/` is missing, `./fetch-references.sh` rebuilds it; if
`demo-config.json` is missing, `view.html`'s dialogs are unavailable until you
regenerate it (the command is in section 3).
