File size: 44,621 Bytes
3597f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
/* ============================================================================
   STRATEGIC ZOOM — TACTICAL TIER
   ----------------------------------------------------------------------------
   Decides, per entity, whether the camera is far enough out that a mesh has
   stopped being readable and a flat tactical icon says more.

   WHY SCREEN FOOTPRINT AND NOT orthoSpan
   The renderer already changes presentation at six different span constants
   (2250 far band, 2400 overview VFX, 2700 organic motion, 2550/2050 shadow
   stride, 760/720 badges). Every one of them demotes a Striker and a TITAN at
   the same moment, which is why zooming out turns infantry into unreadable
   mush long before an experimental needs help. A tier keyed off how many
   PIXELS the object actually covers demotes each class when that class stops
   reading, and nothing before.

   The unit is CSS pixels, matching the already-tuned health-bar conversion
   (render3d.js `hbPx = max(.24, orthoSpan/VH)`), so both systems agree about
   what "small on screen" means. Deliberately not a second unit.

   Measured crossings on a 915 px viewport (icon fully replaces mesh at
   MF_ICON_PX_OUT): Striker ~2196, Rhino ~2928, Goliath ~3843, TITAN ~8418,
   Carrier HQ ~10187. SPAN_MAX is 3400, so infantry and light armour become
   icons while experimentals and landmarks keep their silhouettes for the whole
   zoom range — the Supreme-Commander read.

   LIVE: render3d.js asks mfIconQ / mfCmdIconQ per entity and drops the mesh
   once q>=1. `?tiers=1` still prints the crossing table against the live camera.
   ============================================================================ */

/* The band where a mesh hands over to an icon, in CSS pixels of drawn size.
   24 -> the icon starts fading in; 15 -> the mesh is dropped entirely. Below
   ~15 px a unit mesh is a smear, so nothing is lost by then; above ~24 px the
   silhouette still carries role information that a symbol would throw away. */
const MF_ICON_PX_IN  = 24;
const MF_ICON_PX_OUT = 15;

/* World units per CSS pixel. Identical to render3d's hbPx so icons, health
   bars and any future screen-space UI share one definition of scale. */
let _mfWp=0.24,_mfWpSpan=-1,_mfWpH=-1;
function mfWorldPx(){
  if(_mfWpSpan===orthoSpan&&_mfWpH===VH) return _mfWp;
  _mfWpSpan=orthoSpan; _mfWpH=VH;
  return _mfWp=Math.max(.24, orthoSpan/Math.max(1,VH));
}

/* Drawn world diameter, approximated from the same numbers the renderer
   already uses: `T.size*(T.vscale||1)` is the health-bar width proxy, and the
   unit pass draws roughly 1.5x oversized for legibility. These only need to be
   cheap, stable and monotone in size — they are proxies, not mesh extents. */
function mfUnitSpan(T){
  return T ? T.size*(T.vscale||1)*3 : 36;
}
function mfBldSpan(B){
  return (B && B.size ? B.size : 18)*2.2;
}

/* 0 = mesh only, 1 = icon only, between = crossfading in.
   Clamped, monotone and continuous, so it can drive an alpha directly. */
function mfIconQ(worldSpan){
  const px = worldSpan/mfWorldPx();
  return clamp((MF_ICON_PX_IN - px)/(MF_ICON_PX_IN - MF_ICON_PX_OUT), 0, 1);
}

/* ---------------------------------------------------------------------------
   COMMANDERS
   The ramp above asks "is this too small to read?". That is the right question
   for a Striker and the wrong one for a commander, which is never too small —
   it is too IMPORTANT to hunt for. Asking the wrong question had a measurable
   cost: a commander is 96 world units, so it needs orthoSpan 3660 to begin
   iconising and SPAN_MAX is 3400. At full zoom-out it measured 25.8 px against
   a 24 px threshold and missed by 1.8, drawing no plate, no star and no ring,
   while a Striker beside it at 9.7 px drew all three. The single entity a
   player cannot afford to lose was the only one carrying no symbol. The same
   arithmetic excludes every other hero and the TITAN.

   So a commander does not measure itself. It measures the smallest thing on the
   field: once an ordinary unit has stopped being a mesh the view IS a symbol
   field, and the commander has to be the loudest symbol in it. MF_CMD_REF_SPAN
   is mfUnitSpan(Striker), which puts full opacity at orthoSpan 2196 — where
   ordinary units finish converting.

   Expressed as a call into mfIconQ rather than a second ramp, so there stays
   exactly one definition of scale, still clamped/monotone/continuous and still
   safe to drive an alpha with.
   --------------------------------------------------------------------------- */
const MF_CMD_REF_SPAN = 36;      // = mfUnitSpan(Striker) = 12*3
const MF_CMD_ICON_MUL = 1.55;    // a commander must out-read its neighbours
function mfIsCmdType(T){ return !!(T && T.cat==='hero'); }
function mfCmdIconQ(T){ return mfIsCmdType(T) ? mfIconQ(MF_CMD_REF_SPAN) : 0; }

/* Drawn diameter in world units — the expression render3d has always inlined,
   plus the commander boost. A commander that merely APPEARS at strategic zoom
   is not the fix; it has to dominate. */
function mfIconDpx(T){
  const base = clamp(18+mfUnitSpan(T)*0.12,22,40)*mfWorldPx();
  return mfIsCmdType(T) ? base*MF_CMD_ICON_MUL : base;
}

/* 0 culled | 1 icon | 2 far mesh | 3 near mesh.

   `band` is render3d's own renderBand() result, passed in rather than called:
   renderBand is a const declared INSIDE the render function, so it is not
   reachable from another file. Taking it as an argument also keeps renderBand
   byte-identical, which matters because its near/far split is span+radial and
   re-keying it to footprint would demote infantry MATERIALS at normal play
   zoom — a regression, not this feature. */
function mfViewTier(band, worldSpan){
  if(!band) return 0;
  if(mfIconQ(worldSpan) >= 1) return 1;
  return band===2 ? 3 : 2;
}

/* Span at which a given world size reaches a pixel threshold. Diagnostics and
   tests only — the render path never needs to invert the relation. */
function mfTierCrossSpan(worldSpan, px){
  return worldSpan*Math.max(1,VH)/px;
}

/* `?tiers=1` — prints where each reference class hands over, so the table in
   the header can be checked against the live camera instead of trusted. */
function mfTierReport(){
  const rows=[];
  const push=(nm,span)=>rows.push({
    what:nm,
    worldSpan:+span.toFixed(1),
    pxAt2400:+(span/Math.max(.24,2400/Math.max(1,VH))).toFixed(1),
    fadeStart:Math.round(mfTierCrossSpan(span,MF_ICON_PX_IN)),
    iconAt:Math.round(mfTierCrossSpan(span,MF_ICON_PX_OUT)),
  });
  if(typeof TYPES!=='undefined'&&TYPES) TYPES.forEach((T,i)=>{
    /* TYPES rows do not all carry the same label field; fall back to the
       category and index so the table is still readable. */
    if(T&&T.size) push('unit '+(T.nm||T.name||T.key||((T.cat||'t')+'#'+i)), mfUnitSpan(T));
  });
  if(typeof BT!=='undefined'&&BT) for(const k in BT){
    if(BT[k]&&BT[k].size) push('bld '+k, mfBldSpan(BT[k]));
  }
  rows.sort((a,b)=>a.iconAt-b.iconAt);
  console.log('[tiers] VH='+VH+' orthoSpan='+orthoSpan+' worldPx='+mfWorldPx().toFixed(2)
    +' SPAN_MAX='+(typeof SPAN_MAX!=='undefined'?SPAN_MAX:'?')
    +'  (iconAt > SPAN_MAX means this class never becomes an icon)');
  if(console.table) console.table(rows); else console.log(rows);
  return rows;
}

/* ============================================================================
   ICON ATLAS
   ----------------------------------------------------------------------------
   A SEPARATE texture, not more cells in the shared sprite atlas. Two reasons:

   1. The shared atlas is exactly, completely full. gl.js sizes it ATLAS=2048 /
      CELL=256 -> an 8x8 grid = 64 cells, and buildAtlas() makes exactly 64
      defSprite() calls. Cell 65 would be written at y=2048, off-canvas and
      silently transparent — a bug that renders as "my icon is invisible".
   2. defSprite runs a SHADE material pass (grain, key light, occlusion, rim)
      that exists to make a flat vector shape look like a lit object. That is
      the opposite of what a tactical icon wants: at 20-30 px a symbol has to
      be flat, high-contrast and instantly separable from the terrain.

   PLATES are drawn WHITE with a baked near-black rim. Tinting a white body by
   TEAMC gives the faction colour, while the dark rim survives the multiply as
   a self-consistent border — so one instance produces a coloured icon with an
   outline, and no second outline pass is needed.

   ----------------------------------------------------------------------------
   THE TEXTURE IS 2048x1024, IN TWO HALVES, AND STILL ONE DRAW CALL

     x    0..1023   the procedural canvas below: 8x8 grid of 128 px cells —
                    faction plates, the ring, and the placeholder role glyphs.
     x 1024..2047   assets/textures/ui/tacticons-faction.png: the owner's
                    delivered faction art, 16x16 grid of 64 px cells, baked to
                    role order by tools/build-tacticon-sheet.cjs.

   Both halves live in ONE texture because the whole point of this tier is that
   render3d.js draws every icon on the map in a single bbIcon call
   (render3d.js:2371-2377). A second texture for the authored art would mean a
   second flush, and the number of flushes would then scale with how many
   factions are on screen — the exact cost the tier exists to avoid.

   The obvious alternative was one 2048x2048 atlas of 128 px cells. It is 16 MB
   before mipmaps and buys nothing: an icon is drawn at 15-24 CSS px, so even at
   DPR 3 the largest footprint sampled is ~72 device px. A 64 px cell is already
   at or above 1:1 for the authored art, and its 128 px mip would never be read.
   Halving the texture was free.
   ============================================================================ */
const MF_ICON_ATLAS=1024, MF_ICON_CELL=128, MF_ICON_INSET=5;
/* Combined texture size. The procedural canvas keeps its own 1024 geometry;
   only the UV divisors change, so nothing about the drawing code moves. */
const MF_ICON_TEX_W=2048, MF_ICON_TEX_H=1024;
let mfIcoCanvas=null, mfIcoCtx=null, mfIcoCell=0, mfIcoTex=null, bbIcon=null;
/* Instances submitted on the last frame. Sampled by the acceptance harness,
   which cannot read bbIcon.n because flush() zeroes it. */
let mfIconLast=0;
const MF_ICO={};

function mfDefIcon(name, fn){
  const cx=(mfIcoCell%8)*MF_ICON_CELL, cy=Math.floor(mfIcoCell/8)*MF_ICON_CELL;
  if(mfIcoCell>=64){ console.warn('[tacticons] atlas full, dropping',name); return; }
  mfIcoCell++;
  const c=mfIcoCtx;
  c.save(); c.translate(cx+MF_ICON_CELL/2, cy+MF_ICON_CELL/2);
  c.beginPath(); c.rect(-MF_ICON_CELL/2+1,-MF_ICON_CELL/2+1,MF_ICON_CELL-2,MF_ICON_CELL-2); c.clip();
  fn(c, MF_ICON_CELL/2-8);
  c.restore();
  const i=MF_ICON_INSET;
  MF_ICO[name]=[(cx+i)/MF_ICON_TEX_W,(cy+i)/MF_ICON_TEX_H,
                (cx+MF_ICON_CELL-i)/MF_ICON_TEX_W,(cy+MF_ICON_CELL-i)/MF_ICON_TEX_H];
}

/* Plate: white fill + baked dark rim. `path` receives (ctx,r) and must leave a
   closed path ready to fill/stroke. */
function mfPlate(path){
  return (c,r)=>{
    c.lineJoin='round';
    path(c,r); c.fillStyle='#fff'; c.fill();
    path(c,r); c.lineWidth=Math.max(6,r*0.20); c.strokeStyle='rgba(8,11,16,.92)'; c.stroke();
  };
}
/* Glyph: flat white on transparent; the caller tints it to the ink colour. */
function mfGlyph(draw){
  return (c,r)=>{ c.fillStyle='#fff'; c.strokeStyle='#fff';
    c.lineCap='round'; c.lineJoin='round'; c.lineWidth=Math.max(7,r*0.19); draw(c,r); };
}
function mfPoly(c,pts){ c.beginPath(); pts.forEach((p,i)=>i?c.lineTo(p[0],p[1]):c.moveTo(p[0],p[1])); c.closePath(); }

function buildIconAtlas(){
  mfIcoCanvas=document.createElement('canvas');
  mfIcoCanvas.width=mfIcoCanvas.height=MF_ICON_ATLAS;
  mfIcoCtx=mfIcoCanvas.getContext('2d'); mfIcoCell=0;

  /* ---- faction plates (4 factions x 4 domains, + neutral) ----
     The FRAME carries allegiance, the glyph carries role. A player should know
     whose army that is from the silhouette alone, before the livery colour
     registers — the same job the unit meshes already do in 3D, and the reason
     these are not generic NATO boxes. Language per faction, brutish and
     angular in the Supreme Commander 2 / Tiberium Wars register:

       nova      precise chamfered hex, engineered, clean cut corners
       legion    heavy slab, hard 45-degree bevels, siege mass
       syndicate skewed shard, asymmetric machine geometry
       horde     chitin carapace, spurred, grown rather than built

     Domain deforms the same silhouette instead of replacing it: air rises to a
     peak, naval drops a keel, structures sit on a flat anchored base. */
  const FACS=['nova','legion','syndicate','horde'];
  const DOMS=['gnd','air','nav','str'];
  /* Vertical bias per domain: [topStretch, bottomStretch, squareness] */
  const DOMK={ gnd:[1,1,0], air:[1.16,.86,0], nav:[.84,1.12,0], str:[.94,.98,1] };

  /* Each plate is a reduction of that faction's CREST, so the tactical layer
     and the brand are one design scheme rather than two. Read at 26 px the
     crest detail is gone, but its defining gesture survives:

       nova      winged chevron  — swept shoulders rising to a spear point
       legion    inverted spearhead — flat crown, barbs, driven to a point DOWN
       syndicate nested triangle — apex up, flat base, geometric
       horde     clawed radial   — curved spurs around an organic core

     Opposed gestures on purpose: nova points up and outward, legion points
     down, syndicate is a stable triangle, horde is radial. Those four read
     apart at a glance even before colour registers, which is the whole job. */
  function facePts(fac,dom,r){
    const k=DOMK[dom]||DOMK.gnd, up=k[0], dn=k[1], sq=k[2];
    const R=r*.95;
    if(fac==='nova'){
      /* WINGED CHEVRON: spear point at the crown, wings swept back and up,
         body tapering to a keel. Interior stays open for the role glyph. */
      const w=R*(sq?1.02:.98), h=R;
      return [[0,-h*up],                     // spear point
              [w*.40,-h*up*.50],             // inner shoulder
              [w,-h*up*.58],                 // wing tip (raised, swept out)
              [w*.80,-h*up*.06],             // wing underside
              [w*.46, h*dn*.46],             // body taper
              [0, h*dn],                     // keel
              [-w*.46, h*dn*.46],
              [-w*.80,-h*up*.06],
              [-w,-h*up*.58],
              [-w*.40,-h*up*.50]];
    }
    if(fac==='legion'){
      /* INVERTED SPEARHEAD: broad flat crown, barbed shoulders, mass driven
         downward to a single point — the red crest's aggression, upside down
         from Nova on purpose. */
      const w=R*(sq?1.00:.96), h=R;
      return [[-w*.62,-h*up],[w*.62,-h*up],  // flat crown
              [w,-h*up*.34],                 // barb out
              [w*.56,-h*up*.02],             // notch in
              [w*.72, h*dn*.34],             // lower barb
              [0, h*dn],                     // driven point
              [-w*.72, h*dn*.34],
              [-w*.56,-h*up*.02],
              [-w,-h*up*.34]];
    }
    if(fac==='syndicate'){
      /* NESTED TRIANGLE: apex up, flat base, corners clipped so it still
         holds a glyph. Stable and geometric where the others are aggressive. */
      const w=R*1.02, h=R, c=R*.20;
      return [[0,-h*up],                     // apex
              [w-c*.4, h*dn*.72-c*.2],
              [w-c*1.2, h*dn*.86],
              [-(w-c*1.2), h*dn*.86],
              [-(w-c*.4), h*dn*.72-c*.2]];
    }
    /* HORDE — CLAWED RADIAL. Curved spurs sweeping off a central mass, the
       purple crest reduced to its gesture. Mirrored so it reads as an organism
       rather than a random polygon. */
    const h=R, w=R*.98;
    const half=[
      [w*.44,-h*up*.74],    // inner claw root
      [w*.92,-h*up*.34],    // upper claw tip
      [w*.50,-h*up*.02],    // notch
      [w*1.00, h*dn*.34],   // lower claw tip
      [w*.42, h*dn*.52],    // notch
      [w*.60, h*dn*.88],    // trailing spur
    ];
    const p=[[0,-h*up]];
    for(const q of half) p.push(q);
    p.push([0,h*dn*.72]);
    for(let i=half.length-1;i>=0;i--) p.push([-half[i][0],half[i][1]]);
    return p;
  }
  for(const f of FACS) for(const d of DOMS)
    mfDefIcon('pl_'+f+'_'+d, mfPlate((c,r)=>mfPoly(c,facePts(f,d,r))));
  /* Neutral/unaligned fallback: no faction claim, so no faction language. */
  mfDefIcon('pl_neutral', mfPlate((c,r)=>{ const k=r*.84,rr=r*.30; c.beginPath();
    c.moveTo(-k+rr,-k); c.lineTo(k-rr,-k); c.quadraticCurveTo(k,-k,k,-k+rr);
    c.lineTo(k,k-rr); c.quadraticCurveTo(k,k,k-rr,k); c.lineTo(-k+rr,k);
    c.quadraticCurveTo(-k,k,-k,k-rr); c.lineTo(-k,-k+rr); c.quadraticCurveTo(-k,-k,-k+rr,-k); c.closePath(); }));
  /* Selection/hero ring, drawn over the plate in the bright accent. */
  mfDefIcon('pl_ring', (c,r)=>{ c.strokeStyle='#fff'; c.lineWidth=Math.max(7,r*0.17);
    const p=[]; for(let i=0;i<6;i++){ const a=Math.PI/6+i*Math.PI/3; p.push([Math.cos(a)*r*.95,Math.sin(a)*r*.95]); }
    mfPoly(c,p); c.stroke(); });

  /* ---- unit role glyphs (11, keys mirror UCAT) ---- */
  mfDefIcon('u_inf', mfGlyph((c,r)=>{ c.beginPath(); c.arc(0,-r*.34,r*.24,0,Math.PI*2); c.fill();
    c.beginPath(); c.moveTo(0,-r*.06); c.lineTo(0,r*.40); c.moveTo(-r*.36,r*.62); c.lineTo(0,r*.40); c.lineTo(r*.36,r*.62); c.stroke(); }));
  mfDefIcon('u_veh', mfGlyph((c,r)=>{ c.fillRect(-r*.70,-r*.26,r*1.40,r*.44);
    c.beginPath(); c.arc(-r*.42,r*.40,r*.20,0,Math.PI*2); c.arc(0,r*.40,r*.20,0,Math.PI*2); c.arc(r*.42,r*.40,r*.20,0,Math.PI*2); c.fill(); }));
  mfDefIcon('u_at', mfGlyph((c,r)=>{ mfPoly(c,[[0,-r*.78],[r*.34,-r*.10],[r*.13,-r*.10],[r*.13,r*.72],[-r*.13,r*.72],[-r*.13,-r*.10],[-r*.34,-r*.10]]); c.fill(); }));
  mfDefIcon('u_aoe', mfGlyph((c,r)=>{ for(let i=0;i<8;i++){ const a=i*Math.PI/4;
    c.beginPath(); c.moveTo(Math.cos(a)*r*.24,Math.sin(a)*r*.24); c.lineTo(Math.cos(a)*r*.78,Math.sin(a)*r*.78); c.stroke(); }
    c.beginPath(); c.arc(0,0,r*.20,0,Math.PI*2); c.fill(); }));
  mfDefIcon('u_art', mfGlyph((c,r)=>{ c.beginPath(); c.arc(0,r*.46,r*.80,Math.PI*1.15,Math.PI*1.85); c.stroke();
    c.beginPath(); c.arc(r*.52,-r*.28,r*.20,0,Math.PI*2); c.fill(); }));
  mfDefIcon('u_aa', mfGlyph((c,r)=>{ for(let i=0;i<2;i++){ const y=r*.30*i+r*.06;
    c.beginPath(); c.moveTo(-r*.62,y); c.lineTo(0,y-r*.52); c.lineTo(r*.62,y); c.stroke(); } }));
  mfDefIcon('u_air', mfGlyph((c,r)=>{ mfPoly(c,[[0,-r*.76],[r*.80,r*.46],[0,r*.16],[-r*.80,r*.46]]); c.fill(); }));
  mfDefIcon('u_nav', mfGlyph((c,r)=>{ c.beginPath(); c.moveTo(-r*.78,-r*.10); c.lineTo(r*.78,-r*.10);
    c.lineTo(r*.44,r*.52); c.lineTo(-r*.44,r*.52); c.closePath(); c.fill();
    c.beginPath(); c.moveTo(0,-r*.10); c.lineTo(0,-r*.70); c.stroke(); }));
  mfDefIcon('u_sup', mfGlyph((c,r)=>{ c.fillRect(-r*.18,-r*.72,r*.36,r*1.44); c.fillRect(-r*.72,-r*.18,r*1.44,r*.36); }));
  mfDefIcon('u_exp', mfGlyph((c,r)=>{ for(let i=0;i<3;i++){ const a=-Math.PI/2+i*Math.PI*2/3;
    c.beginPath(); c.moveTo(0,0); c.arc(0,0,r*.80,a-0.42,a+0.42); c.closePath(); c.fill(); }
    c.save(); c.globalCompositeOperation='destination-out'; c.beginPath(); c.arc(0,0,r*.22,0,Math.PI*2); c.fill(); c.restore(); }));
  mfDefIcon('u_hero', mfGlyph((c,r)=>{ const p=[]; for(let i=0;i<10;i++){ const a=-Math.PI/2+i*Math.PI/5, k=i%2?r*.36:r*.86;
    p.push([Math.cos(a)*k,Math.sin(a)*k]); } mfPoly(c,p); c.fill(); }));

  /* ---- building role glyphs (8, keys mirror BCAT) ---- */
  mfDefIcon('b_eco', mfGlyph((c,r)=>{ mfPoly(c,[[0,-r*.76],[r*.62,0],[0,r*.76],[-r*.62,0]]); c.fill(); }));
  mfDefIcon('b_prod', mfGlyph((c,r)=>{ c.beginPath(); c.moveTo(-r*.76,r*.56); c.lineTo(-r*.76,-r*.16);
    c.lineTo(-r*.16,r*.16); c.lineTo(-r*.16,-r*.16); c.lineTo(r*.44,r*.16); c.lineTo(r*.44,-r*.56);
    c.lineTo(r*.76,-r*.56); c.lineTo(r*.76,r*.56); c.closePath(); c.fill(); }));
  mfDefIcon('b_nav', mfGlyph((c,r)=>{ c.beginPath(); c.arc(0,-r*.52,r*.18,0,Math.PI*2); c.fill();
    c.beginPath(); c.moveTo(0,-r*.32); c.lineTo(0,r*.66); c.moveTo(-r*.44,-r*.20); c.lineTo(r*.44,-r*.20); c.stroke();
    c.beginPath(); c.arc(0,r*.20,r*.58,Math.PI*0.18,Math.PI*0.82); c.stroke(); }));
  mfDefIcon('b_def', mfGlyph((c,r)=>{ c.beginPath(); c.moveTo(0,-r*.80); c.lineTo(r*.66,-r*.44);
    c.lineTo(r*.66,r*.18); c.quadraticCurveTo(r*.66,r*.66,0,r*.84);
    c.quadraticCurveTo(-r*.66,r*.66,-r*.66,r*.18); c.lineTo(-r*.66,-r*.44); c.closePath(); c.fill(); }));
  mfDefIcon('b_tech', mfGlyph((c,r)=>{ c.beginPath(); c.arc(0,0,r*.26,0,Math.PI*2); c.fill();
    for(let i=0;i<3;i++){ c.save(); c.rotate(i*Math.PI/3); c.beginPath();
      c.ellipse(0,0,r*.82,r*.32,0,0,Math.PI*2); c.stroke(); c.restore(); } }));
  mfDefIcon('b_wall', mfGlyph((c,r)=>{ const h=r*.36,w=r*.78;
    for(let row=0;row<3;row++){ const y=-r*.60+row*h*1.06, off=(row%2)?-w*.5:0;
      for(let i=-1;i<=1;i++) c.fillRect(off+i*w*1.04-w*.5, y, w*.96, h*.86); } }));
  mfDefIcon('b_sup', mfGlyph((c,r)=>{ c.beginPath(); c.arc(0,r*.20,r*.72,Math.PI*1.10,Math.PI*1.90); c.closePath(); c.fill();
    c.beginPath(); c.moveTo(0,-r*.30); c.lineTo(0,r*.72); c.stroke(); }));
  mfDefIcon('b_sup2', mfGlyph((c,r)=>{ for(let i=0;i<4;i++){ const a=i*Math.PI/4;
    c.save(); c.rotate(a); c.beginPath(); c.moveTo(0,-r*.86); c.lineTo(r*.16,0); c.lineTo(0,r*.86); c.lineTo(-r*.16,0);
    c.closePath(); c.fill(); c.restore(); }
    c.beginPath(); c.arc(0,0,r*.24,0,Math.PI*2); c.fill(); }));

  return mfIcoCanvas;
}

/* ============================================================================
   THE DELIVERED FACTION ART — RIGHT HALF OF THE TEXTURE
   ----------------------------------------------------------------------------
   tools/build-tacticon-sheet.cjs bakes the four delivered 1024/8x8/128 sheets
   into ONE 1024/16x16/64 sheet laid out in ROLE order:

       cell = factionIndex*MF_ICON_FAC_STRIDE + roleIndex

   so resolving a glyph is a multiply-add. The pack's own label vocabulary
   (icon-index.json: "main_battle_tank", "spawn_pit", "black_market", and no two
   factions agreeing) is translated at bake time and never enters the engine —
   the two arrays below are the whole contract, and they are duplicated verbatim
   in the tool.

   Faction index comes from the SAME kit key the 3D path resolves at the call
   site (render3d.js:1175 for units, bldFactionKey for structures), so an icon
   and its mesh can never disagree about whose army that is.
   ============================================================================ */
const MF_ICON_FAC_SHEET_URL='assets/textures/ui/tacticons-faction.png';
const MF_ICON_FAC_CELL=64, MF_ICON_FAC_GRID=16, MF_ICON_FAC_STRIDE=24, MF_ICON_FAC_INSET=3;
const MF_ICON_FAC_ORDER=['nova','legion','syndicate','horde'];
const MF_ICON_ROLE_ORDER=[
  'u_inf','u_veh','u_at','u_aoe','u_art','u_aa','u_air','u_nav','u_sup','u_exp','u_hero',
  'b_eco','b_prod','b_nav','b_def','b_tech','b_wall','b_sup','b_sup2'
];
const MF_ICON_ROLE_IX={}; MF_ICON_ROLE_ORDER.forEach((r,i)=>{MF_ICON_ROLE_IX[r]=i;});

/* Pure geometry — computed once at parse time, needs no image and no GL. If the
   sheet never loads these are simply never consulted. */
const MF_ICO_FAC=(function(){
  const out=[], i=MF_ICON_FAC_INSET, C=MF_ICON_FAC_CELL;
  for(let c=0;c<MF_ICON_FAC_ORDER.length*MF_ICON_FAC_STRIDE;c++){
    const cx=MF_ICON_ATLAS+(c%MF_ICON_FAC_GRID)*C, cy=Math.floor(c/MF_ICON_FAC_GRID)*C;
    out.push([(cx+i)/MF_ICON_TEX_W,(cy+i)/MF_ICON_TEX_H,
              (cx+C-i)/MF_ICON_TEX_W,(cy+C-i)/MF_ICON_TEX_H]);
  }
  return out;
})();

/* WHICH cells actually carry art is read off the decoded sheet, not declared.
   Two roles are procedural on purpose (no faction in the pack ships a naval
   glyph, and Legion has no wall or support-structure emblem), and a future
   pack may fill or drop others. Asking the image means the engine cannot drift
   from the bake, a partially-baked sheet degrades cell by cell instead of
   wholesale, and a tainted or undecodable canvas simply leaves every cell
   procedural — the same non-fatal contract as a missing sheet, at cell
   granularity.

   Cheap: the sheet is drawn once into a 16x-smaller canvas, so a 64 px cell
   becomes 4x4 samples and the whole scan is one 256x256 readback. */
let mfIcoFacImg=null, mfIcoFacTried=false, mfIcoFacHas=null, mfIcoFacReject=null;
function mfIconFacScan(img){
  const G=MF_ICON_FAC_GRID*4;                    // 4 samples per cell per axis
  const c=document.createElement('canvas'); c.width=c.height=G;
  const x=c.getContext('2d',{willReadFrequently:true});
  x.drawImage(img,0,0,G,G);
  const d=x.getImageData(0,0,G,G).data;
  const has=new Uint8Array(MF_ICON_FAC_ORDER.length*MF_ICON_FAC_STRIDE);
  for(let i=0;i<has.length;i++){
    const cx=(i%MF_ICON_FAC_GRID)*4, cy=Math.floor(i/MF_ICON_FAC_GRID)*4;
    let a=0;
    for(let y=0;y<4;y++)for(let k=0;k<4;k++) a+=d[((cy+y)*G+(cx+k))*4+3];
    has[i]=a>120?1:0;                            // a blank cell reads exactly 0
  }
  return has;
}
function mfIconFacLoad(){
  if(mfIcoFacTried) return; mfIcoFacTried=true;
  try{
    const img=new Image();
    img.onload=()=>{
      /* GEOMETRY GATE. onerror catches a sheet that cannot decode, but a VALID
         png of the WRONG SIZE decodes cleanly and never reaches it. The scan
         below is resolution-independent (drawImage rescales), so a 512px sheet
         would report all 66 cells present, while texSubImage2D uploads it at
         native size into a region whose UVs address 1024 — every glyph then
         samples empty texture and the icons render as BLANK PLATES, which is
         worse than the placeholder it is supposed to fall back to. Verified on
         a real device before this guard existed. Size is part of the contract,
         so check it rather than trust the filename. */
      const need=MF_ICON_FAC_GRID*MF_ICON_FAC_CELL;
      if(img.naturalWidth!==need||img.naturalHeight!==need){
        mfIcoFacReject='size';
        mfIcoFacHas=null; mfIcoFacImg=null; return;      // placeholders stand
      }
      try{ mfIcoFacHas=mfIconFacScan(img); mfIcoFacImg=img; }
      catch(e){ mfIcoFacReject='scan'; mfIcoFacHas=null; mfIcoFacImg=null; }
      mfIconInvalidate();                               // next icon frame re-uploads
    };
    img.onerror=()=>{ mfIcoFacReject='decode'; };        // 404, truncated, undecodable
    img.src=(typeof mf2AssetURL==='function')?mf2AssetURL(MF_ICON_FAC_SHEET_URL)
                                             :('./'+MF_ICON_FAC_SHEET_URL);
  }catch(e){ mfIcoFacReject=mfIcoFacReject||'decode'; }
}
/* Runtime kit keys and doctrinal names are not the same set. playerFaction
   is nova/ascendancy/syndicate/horde; FACTIONS[] is legion/syndicate/horde;
   FACART.ascendancy.key is legion. indexOf('ascendancy') was -1, so a Dominion
   or Brood army sampled the procedural half and wore the NEUTRAL plate — the
   missing-faction-tacticon bug. Resolve through factionKitKey + facArt, then
   the four short aliases those two still miss (plain 'ascendancy', 'brood'). */
function mfIconKit(raw){
  let k=raw;
  if(typeof factionKitKey==='function'&&k!=null&&k!=='') k=factionKitKey(k);
  k=String(k==null?'':k).toLowerCase();
  if(k==='nova'||k==='legion'||k==='syndicate'||k==='horde') return k;
  if(typeof facArt==='function'){
    const A=facArt(k);
    if(A&&A.key&&(A.key==='nova'||A.key==='legion'||A.key==='syndicate'||A.key==='horde')) return A.key;
  }
  if(k==='ascendancy'||k==='dominion') return 'legion';
  if(k==='brood'||k==='swarm'||k==='infestation') return 'horde';
  if(k==='coalition'||k==='machine') return 'syndicate';
  if(k==='federation'||k==='frontline'||k==='terran') return 'nova';
  return k;
}
function mfIconKitForTeam(team){
  if(team===2) return 'horde';
  if(team===0){
    /* Prefer playerFaction over playerKitKey: the latter returns 'nova' when
       FACTIONS[] has no 'ascendancy' row, which is how Dominion plates vanished. */
    const pf=(typeof playerFaction!=='undefined'&&playerFaction)||null;
    if(pf){ const k=mfIconKit(pf); if(k==='nova'||k==='legion'||k==='syndicate'||k==='horde') return k; }
    return mfIconKit((typeof playerKitKey==='function')?playerKitKey():'nova');
  }
  const f=(typeof AI!=='undefined'&&AI&&AI.fac)||'legion';
  if(typeof FACTIONS!=='undefined'&&FACTIONS[f]&&FACTIONS[f].kit) return mfIconKit(FACTIONS[f].kit);
  return mfIconKit(f);
}
function mfIconFacIdx(kit){
  let k=mfIconKit(kit);
  /* render3d passes playerKitKey(), which collapses ascendancy→nova because
     FACTIONS[] has no such row. Enemy kits never arrive as nova. */
  if(k==='nova'&&typeof playerFaction!=='undefined'&&playerFaction&&playerFaction!=='nova'){
    const p=mfIconKit(playerFaction);
    if(p==='legion'||p==='syndicate'||p==='horde') k=p;
  }
  return MF_ICON_FAC_ORDER.indexOf(k);
}

/* Category -> cell. UCAT/BCAT are always populated at runtime (classify() and
   classifyBld() backfill every row that omits one), so these are plain lookups
   with a defensive fallback rather than a branchy resolution.

   `kit` is optional and its absence is not an error — it resolves to the
   procedural glyph, which is what every caller got before the art landed. */
function mfIconRoleCell(role,kit,dflt){
  if(mfIcoFacHas){
    const f=mfIconFacIdx(kit), ri=MF_ICON_ROLE_IX[role];
    if(f>=0&&ri!==undefined){
      const c=f*MF_ICON_FAC_STRIDE+ri;
      if(mfIcoFacHas[c]) return MF_ICO_FAC[c];
    }
  }
  return MF_ICO[role]||dflt;
}
function mfIconCellForUnit(T,kit){
  const cat=(T&&T.cat)||'veh';
  return mfIconRoleCell('u_'+cat,kit,null)||mfIconRoleCell('u_veh',kit,MF_ICO.u_veh);
}
function mfIconCellForBld(B,kit){
  const cat=(B&&B.bcat)||'prod';
  return mfIconRoleCell('b_'+cat,kit,null)||mfIconRoleCell('b_prod',kit,MF_ICO.b_prod);
}
/* Drawn diameter for a structure, the sibling of mfIconDpx. Structures read a
   step larger than units at the same span because they are the thing you are
   navigating BY; the floor is above the unit floor for the same reason. */
function mfIconDpxBld(B){
  return clamp(20+mfBldSpan(B)*0.12,26,46)*mfWorldPx();
}
/* Allegiance comes from the same kit key the 3D path resolves, so an icon and
   its mesh always claim the same faction. Team 2 is organic when the Brood is
   fielding it and unaligned wildlife otherwise, which is exactly the
   distinction ai.js already encodes in TEAMC[2]. */
function mfIconDomain(T){
  if(!T) return 'gnd';
  if(T.air) return 'air';
  if(T.naval) return 'nav';
  return 'gnd';
}
function mfIconPlateFor(kit,T,dom){
  const f=MF_ICON_FAC_ORDER[mfIconFacIdx(kit)];
  if(!f) return MF_ICO.pl_neutral;
  return MF_ICO['pl_'+f+'_'+(dom||mfIconDomain(T))]||MF_ICO.pl_neutral;
}

/* Livery body + a legible ink. Read from TEAMC/TEAMB at draw time, never from
   COLORS[META.color]: applyColor() has already folded in the azure->faction
   fallback, the same-faction contrast guard and Brood-vs-wildlife team 2, and
   those globals are rewritten mid-match when the player buys a colour. */
const MF_INK_FALLBACK=[200,200,200], MF_INK_DARK=[10,14,20], MF_INK_LIGHT=[238,246,255];
function mfIconBody(team){ return (typeof TEAMC!=='undefined'&&(TEAMC[team]||TEAMC[2]))||MF_INK_FALLBACK; }
function mfIconInk(team){
  const c=mfIconBody(team);
  const l=(c[0]*0.299+c[1]*0.587+c[2]*0.114)/255;
  return l>0.52?MF_INK_DARK:MF_INK_LIGHT;
}

/* ---------------------------------------------------------------------------
   AUTHORED SHEET OVERRIDE
   The procedural cells above are PLACEHOLDERS. When an authored sheet exists at
   assets/textures/ui/tacticons.png it replaces them wholesale — same 8x8 / 128px
   grid, same cell order (MF_ICON_ORDER), so no code changes when the art lands.
   The load is async and non-blocking: the placeholder sheet renders until the
   real one decodes, then one flag swap re-uploads. A missing or broken file is
   not an error, it just means placeholders stay.
   Contract for the artist: docs/TACTICON_ART_SPEC.md
   --------------------------------------------------------------------------- */
const MF_ICON_SHEET_URL='assets/textures/ui/tacticons.png';
let mfIcoAuthored=null, mfIcoAuthoredTried=false;
/* Drop the uploaded texture so the next icon frame rebuilds it with whatever
   has decoded since. Deletes rather than orphans: two sheets arrive
   asynchronously, so without this a session could leak two 8 MB textures. */
function mfIconInvalidate(){
  try{ if(mfIcoTex&&typeof gl!=='undefined'&&gl) gl.deleteTexture(mfIcoTex); }catch(e){}
  mfIcoTex=null;
}
function mfIconLoadAuthored(){
  if(mfIcoAuthoredTried) return; mfIcoAuthoredTried=true;
  try{
    const img=new Image();
    img.onload=()=>{
      /* Same trap as the faction half: a valid PNG of the wrong size decodes
         and never hits onerror. texSubImage2D would then write the image's
         native pixels into the left 1024² of a 2048x1024 texture — overflow
         or a smeared atlas, both worse than keeping the procedural cells. */
      if(img.naturalWidth!==MF_ICON_ATLAS||img.naturalHeight!==MF_ICON_ATLAS) return;
      mfIcoAuthored=img; mfIconInvalidate();
    };
    img.onerror=()=>{};                                        // placeholders stand
    img.src=(typeof mf2AssetURL==='function')?mf2AssetURL(MF_ICON_SHEET_URL):('./'+MF_ICON_SHEET_URL);
  }catch(e){}
}

/* LAZY. Rasterising a 1024 sheet, uploading it and generating mipmaps is real
   work, and the tier that needs it only engages past ~1800 span. FAR zoom now
   reaches that band on every theatre (Compact used to stop short). Doing it
   during boot also pushed buildTerrain() later, which is enough to lose a
   pre-existing race in tools/test-fog-pickups (it waits only for function
   declarations, which hoist, and then calls resetWorld() while heightF is
   still null). Nothing on the boot path should pay for a far-zoom feature. */
function mfIconEnsure(){
  if(mfIcoTex) return true;
  if(typeof gl==='undefined'||!gl) return false;
  mfIconLoadAuthored();
  mfIconFacLoad();
  if(!mfIcoCanvas) buildIconAtlas();
  const t=gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D,t);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);
  /* Allocated empty at the full 2048x1024, then filled in two sub-uploads. No
     compositing canvas: a 2048x1024 scratch canvas would be 8 MB of CPU memory
     held for one frame, and drawImage of a 1024 source into it would resample
     art that is already the right size. Both halves go up at native 1:1. */
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,MF_ICON_TEX_W,MF_ICON_TEX_H,0,gl.RGBA,gl.UNSIGNED_BYTE,null);
  gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,gl.RGBA,gl.UNSIGNED_BYTE,mfIcoAuthored||mfIcoCanvas);
  /* Absent, undecodable or unreadable: the right half stays transparent and
     mfIcoFacHas stays null, so every role resolves procedural. Nothing here is
     allowed to be fatal. */
  if(mfIcoFacImg&&mfIcoFacHas){
    try{ gl.texSubImage2D(gl.TEXTURE_2D,0,MF_ICON_ATLAS,0,gl.RGBA,gl.UNSIGNED_BYTE,mfIcoFacImg); }
    catch(e){ mfIcoFacReject='upload'; mfIcoFacHas=null; mfIcoFacImg=null; }
  }
  gl.generateMipmap(gl.TEXTURE_2D);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
  mfIcoTex=t;
  /* HIGH keeps 12k for a full-map icon field. MEDIUM stacks earlier and
     never fills that; grow() covers a freak overflow. */
  if(!bbIcon&&typeof BBBatch!=='undefined'){
    const q=typeof qualityKey==='function'?qualityKey():'high';
    bbIcon=new BBBatch(gl,q==='low'?3000:q==='medium'?5000:12000);
  }
  return !!bbIcon;
}
/* Called at boot and on context loss: forget the GPU objects so the next icon
   frame rebuilds them. Deliberately does no GL work of its own. */
function mfIconInitGL(){
  mfIcoTex=null; bbIcon=null;
  return true;
}

/* ============================================================================
   ICON STACKING
   ----------------------------------------------------------------------------
   Zoom-out spam is a DISTANCE problem: 110 infantry in one pad paint 110
   plates. Clustering by team alone would fuse a whole army into one blob
   across the map. Cell = max(56, 0.085×orthoSpan) so nearby bodies merge and
   far ones stay apart. Team is only a discriminator (player/enemy in the same
   cell stay two stacks). Commanders never enter a stack.

   On from orthoSpan>680 (tactical→strategic). Command zoom rings stay
   commander-only in render3d; this covers the band where icons AND leftover
   rings still carpet.
   ============================================================================ */
let _stkOn=false, _stkCell=56, _stkHN=0, _stkCycle=-1, _stkQ='high';
let _stkLead=new Int32Array(4096), _stkNext=new Int32Array(4096);
let _stkCnt=new Uint16Array(4096), _stkSel=new Uint8Array(4096);
let _stkHeads=new Int32Array(512);
let _stkMap=new Map(), _stkC=[0,0,0], _stkRingOut=[];
function _mfIcoQ(){
  if(typeof qualityKey==='function') return qualityKey();
  const q=typeof META!=='undefined'&&META.settings&&META.settings.quality;
  return q==='low'||q==='medium'||q==='cinematic'?q:'high';
}
function mfIconStackOn(){
  /* MEDIUM stacks at tactical zoom so 110 infantry become one plate before
     they are a fillrate carpet. HIGH still waits for the 680 strategic cut. */
  const cut=_mfIcoQ()==='low'?420:_mfIcoQ()==='medium'?520:680;
  return typeof orthoSpan==='number'&&orthoSpan>cut;
}
function mfIconStackCell(){
  const q=_mfIcoQ();
  const k=q==='low'?0.12:q==='medium'?0.105:0.085;
  return Math.max(56, (typeof orthoSpan==='number'?orthoSpan:900)*k);
}
function mfIconStackGrow(n){
  if(_stkLead.length>=n) return;
  _stkLead=new Int32Array(n); _stkNext=new Int32Array(n);
  _stkCnt=new Uint16Array(n); _stkSel=new Uint8Array(n);
}
function mfIconStackRebuild(vis,isCmd){
  _stkQ=_mfIcoQ();
  _stkOn=mfIconStackOn(); _stkHN=0; _stkCell=mfIconStackCell();
  _stkMap.clear();
  if(!_stkOn||typeof unitHigh!=='number') return;
  const n=unitHigh; mfIconStackGrow(n);
  _stkLead.fill(-1,0,n); _stkNext.fill(-1,0,n); _stkCnt.fill(0,0,n); _stkSel.fill(0,0,n);
  const cell=_stkCell;
  for(let i=0;i<n;i++){
    if(!ualive[i]) continue;
    if(isCmd&&isCmd(i)) continue;
    if(vis&&!vis(ux[i],uy[i],40)) continue;
    if(typeof fogEntityVisible==='function'&&!fogEntityVisible(uteam[i],ux[i],uy[i])) continue;
    /* Numeric key, not 't,x,y' — that string was one alloc per visible unit
       every frame and the Map itself was new each rebuild. */
    const key=(uteam[i]<<22)|((((ux[i]/cell)|0)&0x7ff)<<11)|(((uy[i]/cell)|0)&0x7ff);
    const head=_stkMap.get(key);
    if(head==null){
      _stkMap.set(key,i);
      _stkLead[i]=i; _stkCnt[i]=1; if(usel[i]) _stkSel[i]=1;
      if(_stkHN>=_stkHeads.length){
        const nx=new Int32Array(_stkHeads.length*2); nx.set(_stkHeads); _stkHeads=nx;
      }
      _stkHeads[_stkHN++]=i;
    } else {
      _stkLead[i]=head; _stkNext[i]=_stkNext[head]; _stkNext[head]=i;
      _stkCnt[head]++; if(usel[i]) _stkSel[head]=1;
    }
  }
}
function mfIconStackSkip(i){
  if(!_stkOn||i<0||i>=_stkLead.length) return false;
  const h=_stkLead[i]; return h>=0&&_stkCnt[h]>1;
}
function mfIconStackCentroid(head){
  let x=ux[head],y=uy[head],n=1;
  for(let j=_stkNext[head];j>=0;j=_stkNext[j]){ x+=ux[j]; y+=uy[j]; n++; }
  _stkC[0]=x/n; _stkC[1]=y/n; _stkC[2]=n;
  return _stkC;
}
function mfIconStackDraw(gh){
  if(!_stkOn||!mfIconEnsure()||!bbIcon) return;
  for(let h=0;h<_stkHN;h++){
    const lead=_stkHeads[h], c=_stkCnt[lead];
    if(c<2) continue;
    const T=TYPES[utype[lead]];
    const uIcon=(typeof mfIconQ==='function')?mfIconQ(mfUnitSpan(T)):0;
    const uCmdQ=(typeof mfCmdIconQ==='function')?mfCmdIconQ(T):0;
    /* Tactical band: plates replace the 110-mesh carpet even before mfIconQ
       would drop a lone Striker. Commanders stay meshes via isCmd. */
    const uMark=Math.max(uIcon,uCmdQ,_stkOn?0.92:0);
    if(uMark<=0) continue;
    const C=mfIconStackCentroid(lead), X=C[0], Y=C[1];
    const iKit=mfIconKitForTeam(uteam[lead]);
    const ih=(T.naval?1.5:gh(X,Y)+(T.air?58:0))+2;
    const body=mfIconBody(uteam[lead]), ink=mfIconInk(uteam[lead]);
    const dpx=((typeof mfIconDpx==='function')?mfIconDpx(T)
      :clamp(18+mfUnitSpan(T)*0.12,22,40)*mfWorldPx())*(1+Math.min(0.35,Math.log(c)*0.12));
    const ia=255*uMark;
    /* HIGH keeps the offset copies so a blob reads as a stack. MEDIUM already
       merged the cell — extra plates are pure overdraw. */
    const copies=(_stkQ==='high'||_stkQ==='cinematic')?(c>=8?3:2):1;
    const plate=mfIconPlateFor(iKit,T), glyph=mfIconCellForUnit(T,iKit);
    for(let k=copies-1;k>=1;k--){
      const ox=(k-0.5)*dpx*0.16, oy=k*dpx*0.10;
      bbIcon.add(plate,X+ox,Y+oy,ih,dpx*0.88,0,body[0],body[1],body[2],ia*0.72);
    }
    bbIcon.add(plate,X,Y,ih,dpx,0,body[0],body[1],body[2],ia);
    bbIcon.add(glyph,X,Y,ih,dpx*0.60,0,ink[0],ink[1],ink[2],ia);
    if(_stkSel[lead]){
      const br=(typeof TEAMB!=='undefined'&&TEAMB[uteam[lead]])||body;
      bbIcon.add(MF_ICO.pl_ring,X,Y,ih,dpx*1.26,0,br[0],br[1],br[2],ia);
    }
  }
}
function mfIconStackRingLeads(vis,isCmd){
  if(!_stkOn) return null;
  _stkRingOut.length=0;
  for(let h=0;h<_stkHN;h++){
    const lead=_stkHeads[h];
    if(_stkCnt[lead]<2||!_stkSel[lead]) continue;
    if(vis&&!vis(ux[lead],uy[lead],40)) continue;
    _stkRingOut.push(lead);
  }
  return _stkRingOut;
}
function mfIconStackPick(wx,wy,team){
  if(!_stkOn) return -1;
  const r=_stkCell*0.7, r2=r*r;
  let best=-1, bd=r2;
  for(let h=0;h<_stkHN;h++){
    const lead=_stkHeads[h];
    if(_stkCnt[lead]<2) continue;
    if(team!=null&&uteam[lead]!==team) continue;
    const C=mfIconStackCentroid(lead), d=dist2(wx,wy,C[0],C[1]);
    if(d<bd){ bd=d; best=lead; }
  }
  return best;
}
function mfIconStackMembers(lead){
  const out=[lead];
  if(lead<0) return out;
  for(let j=_stkNext[lead];j>=0;j=_stkNext[j]) out.push(j);
  return out;
}
function mfIconStackSelect(i){
  const lead=(i>=0&&i<_stkLead.length&&_stkLead[i]>=0)?_stkLead[i]:i;
  if(lead<0||uteam[lead]!==0) return false;
  const mem=mfIconStackMembers(lead).filter(j=>ualive[j]&&uteam[j]===0);
  if(!mem.length) return false;
  const allOn=mem.every(i=>usel[i]);
  if(typeof clearSel==='function') clearSel();
  if(allOn&&mem.length>1){
    let k=mem.indexOf(_stkCycle)+1; if(k>=mem.length) k=0;
    usel[mem[k]]=1; _stkCycle=mem[k];
    if(typeof toast==='function') toast('STACK · unit '+(k+1)+' / '+mem.length);
  } else {
    for(const i of mem) usel[i]=1;
    _stkCycle=mem[0];
    if(typeof toast==='function') toast('STACK · '+mem.length+' units');
  }
  if(typeof updateSelInfo==='function') updateSelInfo();
  return true;
}

if(typeof window!=='undefined'){
  window.MFTiers={
    atlas:()=>({cells:mfIcoCell,names:Object.keys(MF_ICO),tex:!!mfIcoTex,batch:bbIcon?bbIcon.cap:0}),
    /* Acceptance hooks. `fac()` answers "did the owner's art actually land, and
       for which roles", which counts alone cannot: a sheet that 404s, decodes
       blank or fails the readback all leave the game looking correct because
       the placeholders are still standing. */
    fac:()=>({
      loaded:!!mfIcoFacImg, scanned:!!mfIcoFacHas,
      tried:!!mfIcoFacTried, reject:mfIcoFacReject,
      authored:mfIcoFacHas?Array.from(mfIcoFacHas).reduce((a,b)=>a+b,0):0,
      roles:MF_ICON_ROLE_ORDER, order:MF_ICON_FAC_ORDER,
      byFaction:MF_ICON_FAC_ORDER.map((k,f)=>({kit:k,
        procedural:MF_ICON_ROLE_ORDER.filter((r,i)=>!(mfIcoFacHas&&mfIcoFacHas[f*MF_ICON_FAC_STRIDE+i]))}))
    }),
    /* Resolved UV for a role+kit. Identical to the array the render path
       submits, so a test can assert the faction quadrant is being sampled
       rather than infer it from a screenshot. */
    cell:(role,kit)=>mfIconRoleCell(role,kit,null),
    kit:(raw)=>mfIconKit(raw),
    kitTeam:(t)=>mfIconKitForTeam(t),
    last:()=>mfIconLast,
    ink:(t)=>mfIconInk(t),
    body:(t)=>mfIconBody(t),
    report:mfTierReport,
    q:(w)=>mfIconQ(w),
    tier:(b,w)=>mfViewTier(b,w),
    unitSpan:(T)=>mfUnitSpan(T),
    bldSpan:(B)=>mfBldSpan(B),
    px:()=>mfWorldPx(),
    consts:()=>({MF_ICON_PX_IN,MF_ICON_PX_OUT}),
  };
  try{
    if(new URLSearchParams(location.search).get('tiers')==='1'){
      /* TYPES/BT live in sim.js, which loads after the engine files, so the
         report cannot run at parse time. */
      addEventListener('load',()=>setTimeout(()=>{ try{ mfTierReport(); }catch(e){ console.warn('[tiers]',e); } },1200));
    }
  }catch(e){}
}