# Evo2 → MTM2 converted tracks — why the objects don't instance, and why they flicker

**For:** the author of the Evo2 → MTM2 track converter.
**From:** the MTM2 CP3 engine work, 2026-09-10.
**Converter build examined:** `2026-09-10.20-gate-height` (from each pod's `CONVERSION.LOG`).
**Tracks examined:** Snake River Canyon (`TDSNAKE.POD`) and Terramar Speedway (`TERRAMAR.POD`).

## The short version

- **Every converted face is written as a material face** (`MRGL_MATFACET`), and the materials carry
  flags that the engine's fast paths refuse. So **none of the scenery is hardware-instanced and none
  of it uses the resident mesh cache**: every prop, including roughly 4,000 trees per track, is rebuilt
  and re-uploaded every frame.
- **The models flicker** because most surfaces are stored twice, a front face and a back face, **and**
  drawn with backface culling switched off. Both copies land on the same plane and z-fight.
- **Both come from one root cause in the source meshes:** face normals and vertex order disagree, so
  the models can't be backface-culled as they are. The converter compensates by making everything
  two-sided, and in MTM2's format "two-sided" can only be said through a material.
- **Separately, Snake River's track files collide with MTM2's own Snake track.** The pod is named
  `TDSNAKE`, but every track file inside it uses the stem `SNAKE`, the same as an existing
  `SNAKE.POD` that mounts first. So the converted track loads the other track's terrain, tile grid,
  lighting, level file and palette, and only its objects come from the conversion (section 7).

None of this needs an engine change. It is all in what the converter writes, and section 5 lists
what would fix it.

---

## 1. How the engine chooses a draw path

Placed scenery takes one of three paths:

| path | what happens | cost |
|---|---|---|
| **instanced** | one draw call per model type covers every placement of it | cheapest |
| **resident cache** | the model's geometry is uploaded once and replayed | cheap |
| **per frame** | the model is walked and re-uploaded every frame, in every pass | most expensive |

**Instanced path:**

- Ordinary textured faces (`ZFACETTMAP`, `ZGFACETTMAP`, `FACETTMAP`, cutout `FACETTTMAP` …) need no
  material and are fine.
- A `MATFACET` model is accepted only if **every** `MRGL_MATERIAL` it uses has flags within
  `LIT 0x0001 · GOURAUD 0x0002 · ALPHATEST 0x0008 · TWOSIDED 0x0080 · TRANSLUCENT 0x1000`
  (mask `0x108b`), **and** `TWOSIDED` appears only together with `ALPHATEST`.
- Any other flag sends the model to the per-object path: `BLEND 0x0004`, `ADDITIVE 0x0010`,
  `REFLECT 0x0020`, `FRESNEL 0x0040`, `NOZWRITE 0x0100`, `EMISSIVE 0x0200`, `TINT 0x0400`,
  `ALPHAREF 0x0800`, `TEXSOLID 0x2000`.

The two rules that matter here:
- **Two-sided opaque** has no instanced variant; the instanced path always culls opaque geometry.
- **Blending** depends on draw order, and one instanced draw can't sort its own placements.

**Resident cache:** bakes ordinary textured faces. `MATFACET`, `UZFACETTTMAP` (0x33), `ZXFACETTMAP`
and flat-colour faces are always drawn per frame, because their state can't be frozen into a baked
mesh. In current builds a model can mix the two: its ordinary faces are cached and only the rest is
drawn per frame. A model made **only** of `MATFACET`s has nothing to cache.

---

## 2. What the converter writes today

| | Snake River Canyon | Terramar Speedway |
|---|---|---|
| models | 32 | 30 |
| faces | 6,410 | 4,093 |
| faces that are `MATFACET` | **100%** | **100%** |
| ordinary textured faces | 0 | 0 |

Every model has the same shape: `MAGNIFY`, `VLIST`, one `TEXTURE`, one `MATERIAL`, then only
`MATFACET`s. The materials carry nothing but flags: reflectivity 0, base alpha 1.0, emissive 0,
tint 1/1/1, spec power 32.

| flags | meaning | used for | instanced path |
|---|---|---|---|
| `0x0083` | LIT · GOURAUD · TWOSIDED | opaque scenery (35 records) | refused: two-sided opaque |
| `0x008f` | LIT · GOURAUD · BLEND · ALPHATEST · TWOSIDED | trees, cutouts, banners (29) | refused: BLEND |
| `0x00a3` | LIT · GOURAUD · REFLECT · TWOSIDED | FISHTRUCK, OOPPLANE | refused: REFLECT, set with reflectivity **0.00** |
| `0x00af` | LIT · GOURAUD · BLEND · ALPHATEST · REFLECT · TWOSIDED | OOPSCPBN | refused: BLEND, REFLECT |

The engine's own census agrees record for record. On Snake River **0 of 29** placed models instanced.
On Terramar it was 1 of 26, and that one turned out to be a same-named model from another pod that
mounts first, not the converted file. So in both tracks the converted models instance zero times.

The textures are **not** a factor: every one is a square power-of-two PNG, which is exactly what the
engine wants.

---

## 3. The root cause: face normals and vertex order disagree

MTM2 decides which side of a face is the front from its **vertex order** (winding), and lights it
from its **stored face normal**. For a model to draw correctly single-sided, the two have to agree.
In these models they don't. Split into connected pieces (faces sharing an edge):

| | pieces where every face agrees | pieces where every face disagrees | MIXED pieces | faces inside MIXED pieces |
|---|---|---|---|---|
| Snake River | 401 | 512 | 315 | 3,118 of 6,410 (**49%**) |
| Terramar | 395 | 445 | 264 | 1,267 of 4,093 (**31%**) |

(A model that is *all* "agree" or *all* "disagree" is fine: the engine detects the model's
convention and culls accordingly. It is the MIXED pieces, and models containing both kinds, that
can't be culled.)

Two different patterns show up:

- **Whole pieces wound the other way.** VULTURE is exactly two pieces, one each way, which is what a
  mirrored half that wasn't re-wound looks like.
- **Normals contradicting a correctly wound surface.** SNAKROC7 and SNAKROC11 are single pieces with
  no winding conflicts at all, yet their normals point both ways.

From the files alone we can't tell which of the two, normal or winding, is the authority in the Evo2
source; you will know how Evo2 renders SMF better than we do. Either way, single-sided culling would
hide visible faces, which is why making everything two-sided appears to work.

---

## 4. Why the models flicker

**Many faces sit exactly on top of another face**, mostly the opposite way round:

| | faces coincident with another | …of which exact same-way duplicates |
|---|---|---|
| Snake River | 2,472 of 6,410 (**39%**) | 176 |
| Terramar | 1,860 of 4,093 (**45%**) | 136 |

Worst cases: the trees are 70–79% doubled, DEATHSROCK 76%, ELDSKL 100%. Each copy has its own
vertices, which is why a simple index comparison doesn't find them; compare **positions**.

In other words, **the source already carries explicit back faces.** In a renderer with culling on,
only one of each pair shows from any viewpoint. With `TWOSIDED` set, culling is off and **both
copies draw on the same plane, with opposite normals — one lit, one dark — and they z-fight.** The
exact same-way duplicates z-fight even with culling on.

Likely a smaller contributor, not measured: `BLEND` on around 4,000 trees means they have to be
sorted against each other every frame, and blended trees can pop as their order changes.

---

## 5. What would fix it

1. **Make each face's vertex order agree with its stored normal.** Pick whichever the source treats
   as the authority, and flip the other where they disagree.
2. **Then keep either the explicit back faces or the `TWOSIDED` flag, never both.** Once winding and
   normals agree, the back faces the source already has give correct two-sided visuals with culling
   left on, and no material is needed for it. Drop the exact same-way duplicates either way.
3. **Write opaque scenery as ordinary textured faces with no `MATERIAL`**: `ZFACETTMAP` for lit,
   `ZGFACETTMAP` for shiny. Those instance, and they cache when a model can't instance.
4. **Drop `BLEND` from foliage and cutouts.** Alpha-test alone gives the cutout. If a surface
   genuinely needs both sides and has no back face, `LIT · ALPHATEST · TWOSIDED` (optionally
   `TRANSLUCENT`, which is the engine's own foliage set) is accepted by the instanced path.
   Ordinary cutout `FACETTTMAP` faces with back faces and no material also work.
5. **Only set `REFLECT` when reflectivity is above zero.** At 0.00 it does nothing except disqualify
   the model.

With 1–5 applied, every model the engine's census examined in these two tracks would pass the
instanced path's checks.

---

## 6. Reusing converted models in other tracks

Converted objects are meant to be reused in other tracks, which raises the stakes on two things:

- **The defects travel with the file.** A model that refuses instancing or flickers does so in every
  track that places it. Fixing it at conversion fixes it everywhere it is reused.
- **One source tree comes out at a different size in every track.** The source object is one size,
  and the converter writes it at one size: the plain `DESERT115.BIN` in both pods is byte-identical.
  The trees that are actually placed, though, use the converter's own **vegetation variants** (`V_*`),
  which are pre-scaled per vegetation-map slot. Placements carry no scale, so a slot's size can only be
  baked into the vertices. `CONVERSION.LOG` records each variant's scale (x / y / z):

  | variant | source | Snake River | Terramar |
  |---|---|---|---|
  | `V_DESERT115` | `DESERT115.SMF` | 0.3796 / 0.3478 / 0.3796 | 1.0845 / 1.1130 / 1.0845 |
  | `V_DESERT80` | `DESERT80.SMF` | 0.5090 / 0.3750 / 0.5090 | — |
  | `V_DESERT60` | `DESERT60.SMF` | 0.3664 / 0.5833 / 0.3664 | — |
  | `V_DESERT60_2` | `DESERT60.SMF` | 0.3297 / 0.3333 / 0.3297 | — |
  | `V_MAPLEMULTI` | `MAPLEMULTI.SMF` | — | 1.0991 / **1.6000** / 1.0991 |
  | `V_MAPLERED` | `MAPLERED.SMF` | — | 1.3572 / **1.6000** / 1.3572 |
  | `V_MAPLEYELLOW` | `MAPLEYELLOW.SMF` | — | 1.5589 / **1.6000** / 1.5589 |

  Measured from the vertices, Snake River's `V_DESERT115` is 0.350 × 0.3125 × 0.350 the size of
  Terramar's, which matches the log. Three things stand out:

  1. **The variant name doesn't carry the scale, so it collides across tracks.** Inside one track the
     converter already tells two sizes apart (`V_DESERT60` vs `V_DESERT60_2`). Across tracks it
     reuses the plain `V_<source>` name, so Snake River and Terramar ship two different
     `V_DESERT115.BIN` files. The engine serves one copy per filename (first mounted pod wins). In the
     install we tested `TDSNAKE.POD` mounts first, so **Terramar's desert trees are drawn at Snake
     River's scale, about a third of their intended size**, and any third track reusing
     `V_DESERT115` gets whichever copy the load order serves. Unlike the source model names, the
     `V_` name is the converter's own, so it is the converter's to make unique: include the track, or
     the scale, in it.
  2. **The scale is not uniform.** Height and width are scaled separately, so a variant is stretched
     or squashed compared with the source: `V_DESERT60` is 1.6× taller relative to its width,
     `V_DESERT80` is 0.74×. That may be exactly what the Evo slot asks for, but it is worth checking
     against how Evo draws the slot.
  3. **All three Terramar maples come out at height 1.6000 exactly**, while their widths all differ.
     That looks like a cap on the vertical scale. If so, those trees are shorter than the slot asks.

---

## 7. Snake River's track files collide with MTM2's own Snake track

The pod is named `TDSNAKE.POD`, but the track inside it uses the stem **`SNAKE`**: `DATA\SNAKE.*`,
`LEVELS\SNAKE.LVL`, `FOG\SNAKE.MAP`, `ART\SNAKE.ACT`, `WORLD\SNAKE.SI2`. An MTM2 `SNAKE.POD` already
exists and mounts earlier (line 16 of `POD.INI` in the install we tested, against line 163), and the
engine serves the first mounted copy of every filename. So **19 of the converted track's own files
never load, and 13 of those have different content:**

| file | what it is |
|---|---|
| `DATA\SNAKE.RAW` | the heightfield |
| `DATA\SNAKE.CLR` | the terrain tile grid |
| `DATA\SNAKE.TEX` | the terrain texture table |
| `DATA\SNAKE.LTE` | the terrain lighting |
| `DATA\SNAKE.RA0`, `.RA1`, `.CL0` | the ground-box grids |
| `LEVELS\SNAKE.LVL` | the level file |
| `FOG\SNAKE.MAP`, `ART\SNAKE.ACT` | the fog lookup and the palette |
| `DATA\SNAKE.ANI`, `.TTY`, `.TXT` | small per-track tables |

`.CL1`, `.CL2` and `.RA2`–`.RA5` are shadowed too, but they happen to be byte-identical.

Only the situation file gets through, because its extension differs: the original is
`WORLD\SNAKE.SIT` and the conversion is `WORLD\SNAKE.SI2`. So picking the converted track loads **its
objects and placements on top of the original track's terrain**: Evo objects positioned for Evo's
heights, standing on a different heightfield. On its own that makes objects look wrongly sized, or
float and sink, and it very likely accounts for at least some reports of converted models "not
scaling correctly".

What would avoid it: give the track's own files a stem no existing track uses. The converter already
picks a unique pod name, `TDSNAKE`, and using that same stem for the files inside the pod would do it.

Terramar has no such conflict, since its stem `TERRAMAR` is unique. (Both pods also ship
`DATA\ZERO.RAW`, which every track shares; the stock copy wins there, which is expected and harmless.)

---

## 8. Checking your own output

**Offline**, with the script handed over alongside this note:

```
py podmodels.py TDSNAKE.POD
```

Python 3, no packages, read-only. One line per model:

| column | what it shows |
|---|---|
| families | face opcode counts |
| material → instanced path | each material's flags, and whether the instanced path accepts them |
| pieces +/-/MIXED | the section 3 split |
| coincident (same-way) | the section 4 counts |

It ends with totals for the whole pod.

**In the engine**, in `system\monster.ini` under `[Graphics]`:

- **`instCensus=1`** writes `instdedup.txt` next to `monster.exe` at each level load: one line per
  placed model, `INSTANCED` or `PER-OBJECT` with the specific reason.
- **`objPathTint=1`** (DX11, current builds) colours each object by its draw path while you drive:
  instanced green, resident cache blue, per-frame unchanged.

Both are diagnostics, so switch them back to 0 afterwards.
