# Assembled 3D city viewer

Update: [animated geometry and linked model states](model-animation-and-states.md)
now implements gate/fountain transforms, exports 27 additional linked definitions,
and adds state/animation controls. Earlier references below to those models being
unresolved describe the previous extraction pass.

2026-09-18. [Open the offline city explorer](../analysis/city/3d.html).
It is a single HTML file with embedded map/model/palette data and a WebGL
renderer, requiring no server, CDN, external JavaScript libraries or network.
The 2D map and model atlas also link to it.

## Exploration

- Select Delphi, Halon or one of the eight underground maps.
- Drag to orbit; wheel to zoom. Right-drag or Shift-drag pans.
- Fly mode preserves the current camera position and changes dragging to looking.
  With the viewport focused, WASD moves, Q/E lowers/raises the camera, and Shift
  increases speed. The wheel moves forwards/backwards in fly mode.
- Click a polygon or light disc to inspect its exact map cell and type. Picking
  uses an offscreen depth-tested cell-ID render, not a ground-plane guess.
- Choose a numeric type, then Next instance to visit successive occurrences.
  Highlighting and isolation help inspect repeating structures. Clear restores
  ordinary colours without moving away from the location.
- Click the minimap to visit a cell; Overview restores the whole-map view.
- Toggle game-palette vertex shading, flat colours, neutral geometry, approximate
  light discs, unresolved-model markers and the neutral inspection ground plane.

Coordinates in the controls are cells, not metres. A cell is 2,048 native model
units wide. The minimap follows resource row order; no compass registration is
claimed. The camera position is shown as column, row and height in cell units.

## Placement recovered from the executable

The key discovery is that type-record bytes +2 and +3 are **unsigned fractional
cell offsets**, not a rotation pair. `2DF8–2DFC` combines them with the selected
cell column and row:

```
AX = (column << 8) | record[2]
BX = (row    << 8) | record[3]
```

`2E21–2E4B` shifts these by two and subsequently doubles the horizontal terms.
The subtraction constants are camera positions. This makes each map cell
**2,048 units** wide in the same scale as the decoded model coordinates.
The row contribution is subtracted in the projection. The camera setup at
`1D63–1E76` installs local **B** with the column coefficient and local **A** with
the opposite row coefficient. The first viewer incorrectly paired A with the
column coefficient and B with the row coefficient. This swapped directional
pieces even though their cell origins were correct.

`2A24` reads the signed model-header word at +7 into DI. Unless type-record +4
is `FF`, `2A2E–2A30` subtracts that record byte shifted left by eight. This is a
vertical placement contribution; byte +4 has a rendering role in addition to
its previously noted collision/serialization uses.

For local model components A,B,C, the native assembled position is:

```
height_offset = signed_word(model_header + 7)
if record[4] != 0xFF:
    height_offset -= record[4] * 256

X = column + record[2]/256 + B/2048
Y = -(height_offset + C)/2048
Z = -row - record[3]/256 + A/2048
```

The 3D renderer then uses `X_display = 128 - X`, leaving Y and Z unchanged.
This corrects the east–west reflection observed in the viewer after local model
alignment was fixed. It reflects the **whole assembly**, including each model's
local vertices, lines, disc centres and unresolved markers, so adjoining pieces
remain aligned. Camera visit targets and selection outlines use the same transform.
The 2D map and minimap keep their existing orientation and raw cell IDs; minimap
camera markers and the coordinate readout use the inverse transform. This display
handedness correction is separate from the native placement checks below, which
do not establish geographical orientation.

The common `80 80` placement pair means the cell centre. Other values matter:
Halon types 20–23, for example, place a shared model at different quarter-cell
positions. Some Delphi structures and many tunnel types have nonzero vertical
placement. Directional geometry is already encoded in the model streams; the
viewer does not invent another per-cell rotation.

`tools/verify_city_placement.py` executes original `2DEF` and `2E21`, using three
independent mixed-axis projection rows. All **391 records × four map locations
× two camera positions = 3,128 cell origins** agree, including cells at coordinate
127 and nonzero vertical/fractional offsets. This complements the earlier
original-code verification of local model vertices, but **did not verify their
orientation relative to the map**. Its original broader wording was incorrect.
The test invalidates
Unicorn's translated-code cache after changing camera immediates, because the
original transform uses self-modifying code.

[Placement verification](../analysis/city/placement-verification.json)
contains the origin formula and results.

The added `tools/verify_city_orientation.py` runs the original camera setup
through `1E40`, checking the installed local/placement matrix relationship,
then projects 54,750 vertex events from 383 supported definitions through the
original FCxx interpreter under two cardinal camera configurations at fixed
depth. Projection agrees within one output pixel of the ideal matrix result
(fixed-point negation rounding). It independently checks 342 adjoining pairs
of wall types 2–5 in the actual Delphi map: the corrected long axes follow the
map runs and meet with zero seam gap. Browser checks and screenshots were
regenerated after the correction. See
[orientation-verification.json](../analysis/city/orientation-verification.json).

The standalone OBJ/model-atlas convention remains `(A,-C,B)`; city assembly
converts the native coordinates to `(B,-C,A)` before adding each cell's origin.

## Coverage and rendering

| Layout | Decoded model cells | Unsupported cells | Preview triangles |
| --- | ---: | ---: | ---: |
| Delphi, 68 | 5,433 | 21 | 266,846 |
| Halon, 69 | 2,645 | 0 | 193,723 |
| Underground 70–77 | 518–922 per map | 0 | 57,425–95,166 per map |

Counts refer to placed cells and triangulated inspection geometry, not original
DOS draw counts. The known empty type definitions are absent from these stored
maps. Zero cells intentionally select no model. The full inventory is
[scene-summary.json](../analysis/city/scene-summary.json).

Delphi's 21 unresolved cells are 17 gates (types 17/21) and four probable fountain
effect cells (120–123). Orange wire markers make these omissions visible; the
viewer never reports partial geometry as a successfully decoded building.
There are 466 decoded disc commands across the placed Delphi definitions,
including tower and fountain discs.

The renderer uploads combined triangle buffers for the current map; models and
maps are stored once in the HTML. Per-vertex shades interpolate a palette index,
with nearest indexed palette lookup in the fragment shader. Flat mode uses the
same fallback colour as the model atlas. Rendering uses depth testing and both
face orientations; this is not the original painter/clipping pipeline.

Disc centres use the verified placement transform. The preview uses camera-facing
circles with approximate model-space radius `numerator / 128`, relating the
stored radius numerator to the nominal projection scale, then applies the modern
camera projection. This is explicitly an approximation: the original screen
radius division, integer rounding, 256-screen-unit cap and changing projection
configuration are not emulated. These circles are not represented as spheres.

The ground plane is an inspection aid, not an extracted terrain model. Its
blue-grey fill contrasts with the dark background; the checkbox hides this floor. There is
no added directional lighting, shadow system, sky or texture invention. Neutral
mode uses a uniform neutral surface colour for geometry inspection.

Other limits:

- Stored base layouts only: mission scripts, saved destruction state, moving
  vehicles, gate motion and fountain animation are not applied.
- Distance/backface conditions were flattened by the existing model exporter;
  mutually exclusive surfaces can overlap. Polygon fan triangulation and WebGL
  interpolation are preview approximations.
- Runtime palette fades, distance shading and animated shade values are absent.
- Underground meshes include their original surfaces; use fly mode to look
  inside. There is no automatic ceiling cutaway or collision.

## Fountain identification

Visual inspection identifies 101–104 as fountain-pool portions and suggests 120–123 as
its special effects. Their recovered placement supports this directly:

| Types | Main-map cells (column,row) |
| --- | --- |
| 101 | (58,66), (57,67) |
| 102 | (59,66), (60,67) |
| 103 | (60,68), (59,69) |
| 104 | (57,68), (58,69) |
| 120 | (58,67) |
| 121 | (59,67) |
| 122 | (59,68) |
| 123 | (58,68) |

Thus the four sine-parameter-dependent definitions occupy the centre of the
assembled fountain, surrounded by eight instances of the four pool types.
The fountain identity is based on gameplay observations and spatially supported; the precise
animated water/effect geometry remains undecoded.

See [fountain screenshot](../analysis/city/3d-fountain.png),
[Delphi overview](../analysis/city/3d-delphi.png), and
[Halon overview](../analysis/city/3d-halon.png).

## Reproduction and checks

```
python3 tools/city_scene.py
PYTHONPATH=/tmp/darker-python python3 tools/verify_city_placement.py
PYTHONPATH=/tmp/darker-python python3 tools/verify_city_orientation.py
NODE_PATH=/tmp/darker-browser/node_modules node tools/verify_city_browser.js
```

The builder uses the standard library and existing parsers. Placement verification
requires Unicorn. Browser checks require Playwright and Chromium; the test uses
ANGLE SwiftShader for repeatable software WebGL in this environment.

All ten map selections, depth-tested picking, type/instance selection, isolation,
colour modes, clear, fly movement, minimap navigation, overview and responsive
resizing passed with no JavaScript or WebGL errors. See
[3d-browser-check.json](../analysis/city/3d-browser-check.json).

The default now lights Delphi energy beacons using their alternate definition
and bright dynamic disc shade; other structures retain base states. Select
“All base states (beacons off)” to inspect the off version. See the
[in-game state identifications](model-animation-and-states.md#in-game-identifications-and-default-beacon-display).

## Printed location names

The optional [named-location overlay](named-locations.md) places all 69 reference-map names approximately in Delphi, with camera navigation from a selector. These are printed-map area labels, not verified building identities.
