Model colours, Gouraud shading and destruction links
Browse all recovered 256-entry palettes, including source RGB values and entries retained from earlier palette loads.
2026-09-18. Open the coloured atlas. 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 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.
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 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:
16tests 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.1Askips a byte range when the wrapped high-byte distance comparison(distance_byte - threshold) & 80his 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:
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:
((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) + 1palette 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:
CDB9resolves the struck cell's current model.CDCDrequires a nonzero first model-header word, then further collision and weapon tests determine whether the path reachesCE1A.CE1Ajumps to67C1;67C7–67CAadds 20h to the state byte, preserving the map's type byte.- 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. Saved beacon codes 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
3383with 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.
python3 tools/extract_models.py
PYTHONPATH=/path/to/unicorn python3 tools/verify_model_colours.py
python3 tools/model_viewer.py