# Model colours, Gouraud shading and destruction links

[Browse all recovered 256-entry palettes](../analysis/media/palettes.html),
including source RGB values and entries retained from earlier palette loads.

2026-09-18. [Open the coloured atlas](../analysis/models/index.html).
The viewer offers stored game colours with vertex shading, the flat-colour
fallback, and neutral geometry inspection. Hover the colour swatches for the
face byte and base palette index. OBJ downloads now have companion bank MTL
files; keep both in the same directory. MTL exports use flat fallback colours.

## Collision-volume inspection

The [model viewer](../analysis/models/index.html) has a **Collision volumes**
checkbox, off by default, for retail and demo city types. It overlays translucent boxes
and outlines, visible through the model so recessed vulnerable areas remain
inspectable. Cyan denotes category 0 (ordinary ground-target surface), red 1
(protected), yellow 2 (general breakable), and purple 3 (Diffuser surface).
These are response categories, not a claim that every coloured surface is
currently destructible; [weapon eligibility still applies](weapon-behaviour.md#brent-ground-vulnerable-building-regions).

Volumes follow the selected base, alternate or damaged model. They are the
unexpanded native boxes; player/projectile collision radii enlarge them at
runtime. Gate/fountain animation sliders do not deform these static boxes.
The **Collision OBJ** link beside **OBJ + MTL** exports each box as a named object with
its category in the name, in the same coordinate system as the model OBJ.
OBJ geometry has no transparency material; the translucency is a viewer feature.

`tools/model_collision.py` decodes the selected header's collision pointer and
world-data stream. Native `2E21` maps map offsets to model axes as
`A=-row*8`, `B=column*8`; export is `A,-C,B`. Vertical bounds include the
selected model header's height, with descriptor base elevation cancelling
against model placement. Upper horizontal bounds include the native +1.
Non-colliding descriptors expose no volumes. The 1993 and 1995 demo city banks use the same decoded collision-stream
encoding; their overlays are built directly from the unpacked demo resources,
including each selected linked state. Special-object collision systems remain
separate and are not included in this overlay.

## Tile placement and screen-space discs

The **Placement** checkbox (off by default) draws the map tile's square outline
at ground level. It uses the type directory's column/row fractions and the
selected model's header height and base elevation, rather than centring a
square on the mesh bounding box. It is available for retail and demo city types;
free-moving special objects have no fixed tile placement.

The model preview now draws stored screen-space discs as camera-facing circles,
including the beacon light and fountain spheres. Their centres follow recovered
vertex animation and share the preview's depth sorting with polygon faces.
The original radius is `numerator/projected_depth`; this orthographic inspection
view uses `numerator/128` model units, scaled with the model, matching the existing
city inspection convention. This is not a reproduction of DOS projection,
clipping or pixel rounding. Dynamic colours use inspection palette values, with
a bright value for the lit tower state. Discs contribute to preview framing,
including models with no polygon faces. They remain excluded from polygon OBJ
exports. See [component definitions](model-components-and-aliases.md) for native
centres, colours and radius numerators.

## View-dependent drawing rules

**View-dependent rules** is enabled by default in the model viewer. The drawing
bytecode is executed again as the view rotates, including calls, branches,
vertex-slot changes and gate/fountain parameters:

- `16` tests three projected vertices and skips the following byte range if
  their screen-space winding is back-facing. A test may guard several faces,
  not necessarily just the triangle used to establish the facing direction.
- `1A` skips a byte range when the wrapped high-byte distance comparison
  `(distance_byte - threshold) & 80h` is nonzero. The **Distance byte** control
  supplies this input independently of automatic preview framing.

These primarily control hidden-surface/detail selection. They can avoid drawing
unwanted overlapping geometry, but are not a conventional Z-buffer depth-fighting
fix: the original renderer uses ordered software drawing.

**Conditional face colours** replaces colours on guarded polygons with the
normal of the innermost facing test, mapped from [-1,1] to RGB [0,255]. It is
not necessarily that polygon's own normal. Distance-only groups are amber;
unconditional faces retain their ordinary colours. Disable view-dependent rules
to inspect guarded faces that would otherwise be skipped. The status line
reports tests reached and their rejection results at the selected view.

Facing is evaluated in the viewer's orthographic projection, without native
integer rounding or near-plane clipping. **Authored drawing order** now preserves the executed face/disc order and
rejects back-facing polygons; disable it to compare with the former inspection
depth sort. In the assembled fountain, components are sorted by their origins,
then each component retains its own command order. This is still an inspection
approximation to whole-scene DOS ordering. OBJ files remain flattened inspection meshes;
these dynamic view decisions do not rewrite exported assets.

`tools/model_visibility.js` handles retail and mapped demo opcodes from the
unpacked geometry pools. Browser verification compares flattened output with
existing geometry for 10,182 supported model/state combinations, and executes
30,546 rotated rule-enabled views without decoder failures. Those checks verify
geometry preservation and branch execution, not pixel-exact DOS rendering.
Run `NODE_PATH=/tmp/riotfire-browser/node_modules node tools/verify_model_visibility_browser.js`.

## Face colour encoding

Every supported filled polygon has a colour byte in its command stream.
The flat renderer's `3383–3398` splits it into:

```text
range = colour & E0h        // one of eight 32-entry ranges
shade = colour & 1Fh
palette_index = range + shade_lookup[shade]
```

`2CFC–2D04` selects the lookup according to distance. `B73C–B77F` generates the
lookup tables; the first table is the identity mapping for shades 0–27. The
viewer uses this base-distance case rather than guessing a camera distance.
This preserves the model author's face shading instead of applying a new
normal/light calculation.

Low-five-bit values 28–31 take a separate path (`3399–33B2`), using a runtime
parameter at `339A`. The viewer fixes that parameter to zero, which gives shade
16 with the identity table. Those special faces are not faithfully animated.

## Correction: vertex shades were mistaken for texture coordinates

The previous report labelled opcodes `0E`, `0F`, `11` as textured polygons.
That interpretation was wrong. They carry **(vertex index, shade)** pairs.

The path at `30EB` preserves the face's upper colour-range bits. `A99D–A9F7`
looks up each vertex's shade and constructs a fixed-point palette value:

```text
((range + shade_lookup[vertex_shade]) << 8)
    + shade_lookup[vertex_shade] + 80h
```

The clipping/raster code carries and interpolates this third attribute alongside
screen X/Y. This is **Gouraud shading in palette-index space**, not a UV pair or
a bitmap texture lookup. F9 changes this path to `312A`, which skips the shade
bytes and uses the face colour instead. The clipped path has the corresponding
switch at `30A1/30BD`.

Playtesting confirms the visible F9 mode toggle. The bytecode investigation refines
what it does technically; no claim is made that every surface which looks
textured to a player is backed by an image texture. No bitmap texture mapping
has been established in these model commands.

Among the successfully parsed type streams (including aliases):

| Bank | Flat polygons | Gouraud polygons |
| --- | ---: | ---: |
| 30 | 4,144 | 567 |
| 31 | 2,602 | 114 |
| 32 | 11,548 | 617 |

All observed extra shade bytes are in 0–27. These are counts over type records,
not unique polygons across the whole bank. The city streams do contain shaded
polygons, even though the buildings have no identified bitmap textures.

The viewer triangulates faces and interpolates palette indices using the stored
vertex shades. It is a visual preview, not a reproduction of DOS scan conversion,
rounding, clipping or painter ordering. Conditional visibility remains flattened.

## Palette resources

The palette is loaded before the corresponding cockpit/image payload:

| Bank | Palette source |
| --- | --- |
| 30, Delphi | Resource 00/16 |
| 31, Halon | Resource 00/17 (00/18 is the other Halon configuration, with an identical palette) |
| 32, underground | Resource 00/16, followed by overlay 00/19 |

`BC69–BC77` selects the craft image; `4149` calls palette loader `4185`.
Underground mode additionally loads resource 00/19 at `BC8E–BC91`.
The loader stores RGB triples at `world_segment:A900`:

- An **even first byte** starts an RGB triple. It is retained as red; the next
  two bytes are green and blue.
- An **odd byte** skips `(byte >> 1) + 1` palette entries, preserving their old
  contents. It is not a colour triple.
- Decoding ends after 256 entries, leaving the following image data untouched.

The stored RGB values are 8-bit source values. Runtime colour ramps, fades,
lighting options and VGA DAC conversion are separate (`AFAD–B04E`). The viewer
shows source RGB, not a claim of screenshot-exact DAC output. Unspecified entries
are retained as null in `palettes.json`; none of the currently exported face
materials requires an unspecified entry after special-shade resolution.

## Destruction: confirmed state write and replacement model

The observation of intact/destroyed pairs is supported directly by the
model headers and collision code:

1. `CDB9` resolves the struck cell's current model.
2. `CDCD` requires a nonzero first model-header word, then further collision and
   weapon tests determine whether the path reaches `CE1A`.
3. `CE1A` jumps to `67C1`; `67C7–67CA` adds **20h to the state byte**, preserving
   the map's type byte.
4. The renderer follows the model header's **relative link at +0** according to
   that stage. The link is relative to the current model, not a new type ID.

Crucially, `BCB6–BCBC` patches the selector mask using the bank table at `BDEC`:

| Bank | State mask | Number of link traversals |
| --- | --- | --- |
| 30 | `20` | `(state & 20h) >> 5` |
| 31/32 | `60` | `(state & 60h) >> 5` |

Before these traversals, state bit `80` can select the separate relative link
at header `+2`. That is a distinct branch; it should not be renamed the generic
“destroyed bit.” Bit 0 also remains transient renderer state.

Examples in Halon bank 31 are **4 → 5**, **6 → 7**, **38 → 39**, **41 → 42**.
The viewer links these exported pairs. Twenty-two Halon type records have a
nonzero first damage link; aliases mean this is not 22 unique intact buildings.
All those base chains reach a terminal model after one distinct replacement.
The data also supports variants not listed as separate city type records.

The two underground type records **111 and 112** have nontrivial link chains,
with up to three distinct models. Their exact gameplay role remains unidentified.
All chains and any corresponding directory types are in `damage-links.json`.

## Beacon outages and streetlights

**Delphi streetlights go dark automatically with their regional energy beacon;
there is no separate lamp on/off toggle involved.** The renderer at `2D85`
looks up the state byte at the nearest nine-cell lattice position, using
`9*floor((coordinate+4)/9)` independently for each map axis. This gives each
beacon an effectively 9×9-cell lighting region rather than a circular radius.
Unlike player charging, the rendering lookup does not check the sampled cell's
type.

The sampled strength affects ordinary building shading and special luminous
colours, including the streetlights. `2CE4–2D04` adds `16*(255-strength)` to the
model's shading depth and supplies `F0h | (strength >> 4)` for special colour
codes 28–31. Intermediate strengths therefore dim the region during beacon
fades, rather than simply switching every surface on or off. Halon's `2DAC`
uses constant strength 255 instead.

This regional lighting is separate from **destroying a lamp**: its own cell's
damage state selects the broken model, and restoring beacon power does not
repair it. Nor does the visual relationship prove that streetlights supply
player energy; [charging uses a separate calculation](flight-physics-and-charging.md).
[Saved beacon codes](save-file-format.md#city-editing) restore off/full endpoints,
while mission scripts can subsequently fade or restore the beacon strength.

## Validation

`tools/verify_model_colours.py` executes original x86 code in Unicorn:

- Four palette streams match the game's loader byte-for-byte, including skip
  preservation and exact end position.
- 8,192 colour cases match `3383` with identity lookup, covering all colour bytes
  and all 32 values of the dynamic parameter.
- 28 Gouraud setup probes confirm the extra bytes become fixed-point palette
  shades alongside screen coordinates, not texture coordinates.
- 3,128 selector cases cover all 391 type records and eight high-bit state
  combinations with each bank's patched mask.
- Six destruction-write probes confirm `+20h`, preservation of low state bits,
  and preservation of the cell's type byte. Full weapon eligibility/effect
  spawning is not emulated by those six probes.

The browser viewer was checked in palette, flat and neutral modes; screenshots
include `colours-delphi.png`, `colours-halon.png` and `colours-destroyed.png`.
The previous vertex-interpreter validation remains applicable; geometry
coordinate decoding has not changed.

```sh
python3 tools/extract_models.py
PYTHONPATH=/path/to/unicorn python3 tools/verify_model_colours.py
python3 tools/model_viewer.py
```
