Assembled 3D city viewer
Update: animated geometry and linked model states 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. 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 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.
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.
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, Delphi overview, and Halon overview.
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.
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.
Printed location names
The optional named-location overlay 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.