File size: 54,032 Bytes
b51e03a
3597f5a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
fcce7e3
 
 
 
 
 
 
b51e03a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
fcce7e3
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
 
 
 
 
fcce7e3
3597f5a
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
fcce7e3
b51e03a
fcce7e3
 
b51e03a
 
 
 
 
 
fcce7e3
 
 
 
 
b51e03a
fcce7e3
b51e03a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
fcce7e3
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
b51e03a
3597f5a
 
 
 
fcce7e3
3597f5a
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
b51e03a
3597f5a
 
 
b51e03a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3597f5a
 
 
 
 
fcce7e3
3597f5a
 
 
 
 
 
 
fcce7e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
;
;
/* ============================================================================
   AD BOARDS β€” in-world video-texture billboards
   ----------------------------------------------------------------------------
   Ads are DIEGETIC PROPS here, not a UI overlay: a small number of billboards
   and jumbotron screens stand in the battlefield itself, beside the highway
   and around the derelict city districts, playing looping video like anything
   else the war left running. There is no ad network wired up yet β€” only
   bundled placeholder clips β€” but every path a real network would need
   (request a creative for a slot, report an impression, report a failure)
   already exists behind the AdProvider interface below. Turning on AdMob or a
   VAST tag later is meant to be AD_CONFIG.provider flipping from 'local' to
   'network', not a rewrite of the renderer.

   This file plugs into an already-running engine without editing any of it.
   Three existing global FUNCTIONS are wrapped (not replaced) at load time:
     setupDoodads  β€” so board placement is (re)computed right after the
                     terrain, road grid and city districts exist for whatever
                     map is current, exactly like rocks/trees/crystals are.
     begin3D       β€” the model-shader "frame prologue" render.js calls before
                     drawing any lit geometry. Hooking it is what lets the
                     board frames draw through the SAME lit/fogged/SSAO'd
                     pipeline every other structure uses, and gives a once-
                     per-frame timing point for the throttled video-texture
                     upload, all without a second render pass.
     renderSettings β€” appends one more row to #setList after the real
                     function has built the list, so the toggle lives in the
                     normal settings screen without touching meta.js.
   Every wrapper calls the ORIGINAL function first, wraps its own work in
   try/catch, and never lets a failure in here reach the caller β€” four other
   systems are mid-development in this same global scope and a bug in an ad
   board must never be able to take the render loop or the settings screen
   down with it.

   CRITICAL GL HYGIENE: the post-processing chain (SSAO/bloom/FXAA, in
   engine/mesh.js) owns texture units 4/5/6 and the model/terrain shaders own
   0/1/2/3. Unit 7 is ALSO the model detail atlas and unit 8 the fog map β€”
   ads borrow them for the screen draw, then put detail/fog/matTex back.
   Never bindTexture(null) on the active unit (that was unit 0 / the atlas).
   Restore BLEND / CULL_FACE / DEPTH_TEST / DEPTH_WRITEMASK / prog3D.
   ============================================================================ */

/* ============================================================================
   BILLBOARD PROP β€” geometry
   ----------------------------------------------------------------------------
   Authored the same way every other structure in engine/models.js is: welded
   from MeshBuilder primitives, feet at y=0, facing local +X, real world-scale
   units (an instance scale of ~1 is a real billboard). The screen itself is
   NOT part of this mesh β€” it can't be, its content changes every frame β€” this
   only builds the frame it sits in: footings, posts, a catwalk, hazard trim,
   a lighting boom and a backing panel sized to exactly match the video quad
   adScreenVerts() computes below. The two are kept in registration by sharing
   the same AD_* layout constants.
   ============================================================================ */
const AD_HALFW  = 14.2;   // screen half-width, local Z
const AD_SCR_H  = 16.0;   // screen height
const AD_BOT_Y  = 13.0;   // screen bottom, world units above ground
const AD_FACE_X = 1.02;   // local X of the screen plane β€” just proud of the backing panel

function mdlAdBoard(){
  const m = MB();
  const hw = AD_HALFW, topY = AD_BOT_Y + AD_SCR_H;
  // footings + support posts, one each side of the screen
  for (const s of [-1, 1]) {
    const pz = s * hw * 0.70;
    m.cyl(0.0, 0.0, pz, 2.5, 2.4, 1.0, 10, CONC);              // concrete footing
    m.cyl(0.0, 1.0, pz, 1.05, 0.90, AD_BOT_Y - 1.0, 10, MET_D); // support post
    m.cyl(0.0, AD_BOT_Y - 1.0, pz, 0.90, 0.80, 1.3, 10, MET_L); // collar where the catwalk lands
  }
  // horizontal cross braces tying the posts together
  m.box(0.0, 4.5, 0, 1.0, 0.9, hw * 1.42, DARK);
  m.box(0.0, 8.5, 0, 1.0, 0.9, hw * 1.42, DARK);
  // maintenance catwalk + a glowing guard-rail along its front edge
  m.bevelBox(0.20, AD_BOT_Y - 1.40, 0, 2.0, 0.5, hw * 1.70, 0.2, MET);
  glowStrip(m, 1.15, AD_BOT_Y - 0.50, 0, hw * 1.65, MET_L, Math.PI / 2);
  // hazard stripe band along the base β€” no existing palette constant maps to
  // MAT.WARN, so the material is set explicitly for this one primitive; every
  // primitive after it uses a recognised palette colour again and resets it
  m.mat(MAT.WARN);
  m.box(0.05, AD_BOT_Y - 2.30, 0, 0.9, 0.75, hw * 1.60, C(255, 255, 255));
  // backing panel β€” the screen quad sits flush against its front face
  m.bevelBox(0.40, AD_BOT_Y - 1.50, 0, 1.2, AD_SCR_H + 4.0, hw * 2 + 3.0, 0.4, DARKER);
  // lighting boom + spotlights angled down at the screen
  m.box(0.60, topY + 0.60, 0, 2.6, 0.5, hw * 1.70, MET_D);
  for (const t of [-0.75, -0.25, 0.25, 0.75]) {
    const lz = t * hw * 1.55;
    m.box(1.60, topY + 0.20, lz, 1.6, 0.6, 0.6, DARK);
    m.box(2.30, topY + 0.10, lz, 0.6, 0.5, 0.9, LAMP, -0.35);
  }
  // emissive trim along the top and bottom edges β€” reads at night even before
  // the screen itself is considered "glowing"
  glowStrip(m, AD_FACE_X, topY + 0.35, 0, hw * 2 + 0.6, ENERGY, Math.PI / 2);
  glowStrip(m, AD_FACE_X, AD_BOT_Y - 0.35, 0, hw * 2 + 0.6, ENERGY, Math.PI / 2);
  // antenna + a crate of gear at the base for clutter, same vocabulary every
  // other structure in the game is built from
  sensorMast(m, 0.40, topY + 1.10, hw * 0.95, 3.4, MET_L);
  kitBox(m, 0.30, 0.10, hw * 0.70 + 1.6, 1.8, 1.3, 1.6, MET_D, 0.25);
  return m.build();
}

/* ============================================================================
   PLACEMENT β€” deterministic per map, seeded like every other doodad
   ----------------------------------------------------------------------------
   Runs from the setupDoodads() wrapper (see adInstallHooks), so ROADG,
   cityZones/cityPlan and the height field are already built for whatever map
   is current. Uses the SAME srand()/rnd()/rr() generator gl.js's own doodad
   placement uses, but reseeds it itself first β€” so this never depends on
   (or disturbs) whatever state that shared generator was left in β€” and never
   places on top of a spawn's safety ring, a resource deposit, water, or
   another board.
   ============================================================================ */
const AD_MAX = 10;
let adBoards = [];

function AdSlot(id, x, y, yaw, scale) {
  return {
    id, x, y, yaw, scale,
    placement: 'billboard',
    size: { w: AD_HALFW * 2 * scale, h: AD_SCR_H * scale },
    creative: null,           // filled in asynchronously by adAssignCreatives()
    _dwell: 0, _counted: false, _onscreen: false,
    // crossfade rotation state β€” second creative slot + blend progress
    creative2: null, _rotT: 0, _blend: 0,
  };
}

/* Beside the highway: walk the rasterised road grid looking for cells that
   are ON the road, then probe outward from each for the nearest clear
   shoulder β€” clear of the road itself, walkable, not water, not a cliff. */
function adScanRoadSpots(validSpot, tooClose) {
  const spots = [];
  if (typeof ROADG === 'undefined' || !ROADG) return spots;
  const cellW = MAP / PGS;
  const onRoad = (x, y) => !!ROADG[clamp(y / MAP * PGS | 0, 0, PGS - 1) * PGS + clamp(x / MAP * PGS | 0, 0, PGS - 1)];
  /* tooClose() alone only ever sees adBoards, and adBoards is still EMPTY for
     the whole duration of a scan β€” adPlaceBoards() doesn't push a scan's
     results into it until the scan has already returned in full. Two
     candidates accepted earlier in this SAME scan therefore never got
     checked against each other, so a run of unlucky road cells could place
     two boards on top of one another. tooCloseAny() closes that gap by also
     checking what this scan has already accepted. */
  const tooCloseAny = (x, y, d) => tooClose(x, y, d) || spots.some(s => dist2(x, y, s.x, s.y) < d * d);
  for (let gy = 3; gy < PGS - 3 && spots.length < 4; gy += 5) {
    for (let gx = 3; gx < PGS - 3 && spots.length < 4; gx += 5) {
      if (!ROADG[gy * PGS + gx]) continue;
      const rx = (gx + 0.5) * cellW, ry = (gy + 0.5) * cellW;
      if (tooCloseAny(rx, ry, 300)) continue;
      for (let t = 0; t < 10; t++) {
        const ang = rnd() * TAU, dist = 74 + rnd() * 56;
        const px = rx + Math.cos(ang) * dist, py = ry + Math.sin(ang) * dist;
        if (onRoad(px, py)) continue;
        if (!validSpot(px, py) || tooCloseAny(px, py, 300)) continue;
        spots.push({ x: px, y: py, yaw: ang + Math.PI });   // face back toward the highway
        break;
      }
    }
  }
  return spots;
}

/* In the city: one board per derelict/industrial district, planted just
   outside the block footprints on the district's rim, facing in. */
function adScanCitySpots(validSpot, tooClose) {
  const spots = [];
  if (typeof cityZones === 'undefined') return spots;
  const tooCloseAny = (x, y, d) => tooClose(x, y, d) || spots.some(s => dist2(x, y, s.x, s.y) < d * d);   // see adScanRoadSpots
  cityZones.forEach((Z, zi) => {
    for (let t = 0; t < 14; t++) {
      const ang = rnd() * TAU, rad = Z.r * (0.80 + rnd() * 0.35);
      const px = Z.x + Math.cos(ang) * rad, py = Z.y + Math.sin(ang) * rad;
      if (!validSpot(px, py) || tooCloseAny(px, py, 300)) continue;
      let blocked = false;
      if (typeof cityPlan !== 'undefined') {
        for (const P of cityPlan) {
          if (P.zone !== zi) continue;
          const r2 = Math.max(P.w, P.h) * 0.7 + 30;
          if (dist2(px, py, P.x, P.y) < r2 * r2) { blocked = true; break; }
        }
      }
      if (blocked) continue;
      spots.push({ x: px, y: py, yaw: ang + Math.PI });
      return;   // one per district β€” a skyline of billboards would defeat the point
    }
  });
  return spots;
}

function adPlaceBoards() {
  adBoards = [];
  if (typeof MAP === 'undefined' || typeof hAt !== 'function') return;   // engine not ready yet
  const MD = (typeof MAPDEFS !== 'undefined' && typeof curMap !== 'undefined' && MAPDEFS[curMap]) || null;
  srand(((MD && MD.seed || 1337) ^ 0xAD8081) | 1);
  const farFromSpawns = (x, y, d) => typeof farFromStartZones === 'function'
    ? farFromStartZones(x, y, d)
    : dist2(x, y, MAP * SP_LO, MAP * SP_HI) > d * d && dist2(x, y, MAP * SP_HI, MAP * SP_LO) > d * d;
  const validSpot = (x, y) => {
    if (x < 60 || y < 60 || x > MAP - 60 || y > MAP - 60) return false;
    if (!farFromSpawns(x, y, 340)) return false;
    if (typeof deposits !== 'undefined') for (const D of deposits) if (dist2(x, y, D.x, D.y) < 95 * 95) return false;
    if (typeof isWalkable === 'function' && !isWalkable(x, y)) return false;
    const h = hAt(x, y);
    return h >= 0.40 && h <= 0.75;
  };
  const tooClose = (x, y, d) => { for (const b of adBoards) if (dist2(x, y, b.x, b.y) < d * d) return true; return false; };

  let n = 0;
  for (const s of adScanRoadSpots(validSpot, tooClose)) {
    if (adBoards.length >= AD_MAX) break;
    adBoards.push(AdSlot('rd' + (n++), s.x, s.y, s.yaw, 0.88 + rnd() * 0.30));
  }
  n = 0;
  for (const s of adScanCitySpots(validSpot, tooClose)) {
    if (adBoards.length >= AD_MAX) break;
    adBoards.push(AdSlot('cz' + (n++), s.x, s.y, s.yaw, 1.00 + rnd() * 0.34));
  }
}

/* ============================================================================
   AD PROVIDER ADAPTER
   ----------------------------------------------------------------------------
   Every board asks THIS interface for what to play, and never touches a
   video element, a manifest file, or a network SDK directly. Today
   AD_CONFIG.provider is 'local' and LocalAdProvider serves the bundled clips
   in assets/ads/. The day a real network deal exists, AD_CONFIG.provider
   flips to 'network' and NetworkAdProvider β€” a documented stub below β€”
   starts answering the exact same three calls instead. Nothing in placement,
   rendering, throttling or the settings toggle has to change.
   ============================================================================ */
class AdProvider {
  /** Lazy, memoised, idempotent β€” call it as often as you like. */
  init() {
    if (!this._ready) this._ready = this._doInit().catch(e => { console.warn('adboards: provider init failed', e); });
    return this._ready;
  }
  async _doInit() {}
  /** slot: {id, size:{w,h}, placement:'billboard', x, y, yaw}.
   *  Resolves to {id, brand, accent, bg, poster, video} or null for "no
   *  fill" (the slot just keeps showing its neutral plate β€” exactly what an
   *  unfilled network request would look like too). Reject only for a real
   *  error; "nothing to show" is not one. */
  async loadCreative(slot) { return null; }
  /** Fired once per slot per viewing session (continuously on screen for at
   *  least AD_DWELL_S seconds) β€” the closest honest proxy for "impression"
   *  without a server round trip. */
  reportImpression(slot, creative) {}
  /** Playback/decoration failures the renderer couldn't route around. */
  reportError(slot, err) {}
}

class LocalAdProvider extends AdProvider {
  constructor() { super(); this.manifest = null; }
  async _doInit() {
    try {
      const res = await fetch('./assets/ads/manifest.json');
      if (!res.ok) throw new Error('http ' + res.status);
      this.manifest = await res.json();
    } catch (e) {
      console.warn('adboards: local ad manifest unavailable β€” boards will show a static plate only', e);
      this.manifest = null;
    }
  }
  async loadCreative(slot) {
    await this.init();
    const list = this.manifest && this.manifest.creatives;
    if (!list || !list.length) return null;
    const c = list[adHash(slot.id) % list.length];
    return {
      id: c.id, brand: c.brand, accent: c.accent, bg: c.bg,
      poster: './assets/ads/' + c.poster,
      video: c.video ? './assets/ads/' + c.video : null,
    };
  }
  reportImpression(slot, creative) {
    AD_STATS.total++;
    AD_STATS.impressions[creative.id] = (AD_STATS.impressions[creative.id] || 0) + 1;
    adStatsSave();
  }
  reportError(slot, err) { console.warn('adboards: slot error', slot && slot.id, err); }
}

/* ---- NetworkAdProvider β€” STUB ----------------------------------------------
   Not implemented on purpose: a live network integration needs a signed
   agreement, real IDs and β€” per store policy β€” a consent flow before it can
   request anything (see docs/ADS.md). This class exists so the SEAM is
   concrete rather than hypothetical. To go live:

     1. AD_CONFIG.provider = 'network' (below) β€” the entire call-site change.
     2. _doInit(): load the network SDK (e.g. Google Mobile Ads / AdMob, or a
        raw VAST/IMA tag), initialise it with real app/unit IDs, and gate the
        whole thing on the consent flow's result β€” do not request ads before
        consent is resolved.
     3. loadCreative(slot): request a video creative sized to slot.size (an
        AdMob rewarded/interstitial unit, or a VAST <MediaFile> parsed from
        the tag response) and resolve with the SAME shape LocalAdProvider
        resolves with: {id, brand, accent, bg, poster, video}. `video`/
        `poster` may be blob: or https: URLs β€” adDrawScreens() only ever
        consumes them as <video>/<img> sources, so it does not care.
     4. reportImpression()/reportError(): forward to the network SDK's own
        tracking callbacks instead of AD_STATS.
   Everything above this point β€” geometry, placement, the throttled texture
   upload, the settings toggle β€” is provider-agnostic and needs no changes. */
class NetworkAdProvider extends AdProvider {
  constructor(cfg) { super(); this.cfg = cfg || {}; }
  async _doInit() {
    throw new Error('NetworkAdProvider is a stub β€” see docs/ADS.md before setting AD_CONFIG.provider="network"');
  }
  async loadCreative(slot) { throw new Error('NetworkAdProvider.loadCreative is not implemented'); }
}

/* The entire integration surface for going live with a real network: swap
   this one field (plus the cfg block NetworkAdProvider would need) and every
   board in the game starts asking the network for fill instead. */
const AD_CONFIG = { provider: 'local' };
const AD_PROVIDER = AD_CONFIG.provider === 'network' ? new NetworkAdProvider({}) : new LocalAdProvider();

function adHash(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
  return Math.abs(h);
}

async function adAssignCreatives() {
  for (const slot of adBoards) {
    try {
      const desc = await AD_PROVIDER.loadCreative(slot);
      if (desc) { adRegisterCreative(desc); slot.creative = desc.id; }
    } catch (e) { console.warn('adboards: loadCreative failed', slot.id, e); }
  }
  // contextual content is assigned after sponsor creatives so it can override
  try { adAssignContextualCreatives(); } catch (e) {}
}

/* ============================================================================
   IMPRESSION COUNTING
   ============================================================================ */
const AD_STATS_KEY = 'massfront_ads_stats_v1';
const AD_DWELL_S = 1.5;     // seconds continuously on screen before it counts
const AD_ROTATE_S = 12;     // seconds between ad rotations
const AD_FADE_S = 1;        // crossfade duration in seconds
const AD_TEX_UNIT2 = 8;     // second texture unit for crossfade β€” 0-3 model, 4-6 post, 7 primary, 8 secondary
let AD_STATS = { total: 0, impressions: {} };

function adStatsLoad() {
  try {
    const s = localStorage.getItem(AD_STATS_KEY);
    if (s) AD_STATS = Object.assign({ total: 0, impressions: {} }, JSON.parse(s));
  } catch (e) {}
}
function adStatsSave() { try { localStorage.setItem(AD_STATS_KEY, JSON.stringify(AD_STATS)); } catch (e) {} }

function adUpdateImpressions(dt) {
  for (const b of adBoards) {
    if (b._onscreen && b.creative) {
      b._dwell += dt;
      if (!b._counted && b._dwell >= AD_DWELL_S) {
        b._counted = true;
        const c = AD_CREATIVES[b.creative];
        try { AD_PROVIDER.reportImpression(b, c || { id: b.creative }); } catch (e) {}
      }
    } else { b._dwell = 0; b._counted = false; }
  }
}

/* ============================================================================
   VIDEO TEXTURE MANAGEMENT
   ----------------------------------------------------------------------------
   One HTMLVideoElement per CREATIVE (not per board β€” several boards can and
   do share a clip), created lazily the first time a slot showing it is
   actually on screen. Every failure mode the brief calls out degrades to the
   poster texture, never to an unwritten (black) one:
     - not ready yet        -> readyState check before ever sampling the video
     - errored               -> 'error' event latches videoState permanently
     - autoplay blocked      -> play() rejection latches 'blocked'; retried on
                                 the next real user gesture (mobile browsers
                                 refuse unmuted autoplay, and this game's own
                                 videos are muted+playsinline for exactly that
                                 reason β€” but a stricter WebView can still say
                                 no, so the retry path is not optional)
     - tab hidden             -> paused outright, not just un-uploaded, so a
                                 backgrounded tab is not still paying decode
                                 cost
   ============================================================================ */
const AD_CREATIVES = {};
const AD_UPLOAD_MS = 1000 / 15;     // throttle GPU uploads to ~15fps, not 60
let adFallbackTex = null;

function adMakeTex(seedRGBA) {
  const t = gl.createTexture();
  const was = gl.getParameter(gl.ACTIVE_TEXTURE);
  gl.activeTexture(gl.TEXTURE7);
  const prev = gl.getParameter(gl.TEXTURE_BINDING_2D);
  gl.bindTexture(gl.TEXTURE_2D, t);
  // no mipmaps: the video texture is rewritten every throttle tick, and
  // regenerating mips on that cadence would be the "stalled GPU upload"
  // this whole system exists to avoid. LINEAR/LINEAR keeps it complete
  // without them β€” the default min filter needs mips and samples as solid
  // black on a texture that never gets any, which is precisely the bug this
  // file's brief called out by name.
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.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);
  // seeded immediately with a solid plate colour: a texture object that has
  // never been written samples as opaque black, i.e. exactly the "black
  // rectangle" failure mode this system must never show.
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, seedRGBA || new Uint8Array([16, 20, 26, 255]));
  gl.bindTexture(gl.TEXTURE_2D, prev);
  gl.activeTexture(was);
  return t;
}

function adRegisterCreative(desc) {
  if (!desc || !desc.id) return null;
  let c = AD_CREATIVES[desc.id];
  if (c) return c;
  const bg = desc.bg || [16, 20, 26];
  c = AD_CREATIVES[desc.id] = {
    id: desc.id, brand: desc.brand || desc.id, accent: desc.accent || [190, 220, 255],
    poster: desc.poster || null, video: desc.video || null,
    posterTex: adMakeTex(new Uint8Array([bg[0], bg[1], bg[2], 255])),
    videoTex: adMakeTex(new Uint8Array([bg[0], bg[1], bg[2], 255])),
    posterLoaded: false, videoTexPrimed: false,
    videoEl: null, videoState: 'init', lastUpload: 0,
  };
  if (c.poster) adLoadPoster(c);
  return c;
}

function adLoadPoster(c) {
  const img = new Image();
  img.onload = () => {
    try {
      const was = gl.getParameter(gl.ACTIVE_TEXTURE);
      gl.activeTexture(gl.TEXTURE7);
      const prev = gl.getParameter(gl.TEXTURE_BINDING_2D);
      gl.bindTexture(gl.TEXTURE_2D, c.posterTex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, img);
      gl.bindTexture(gl.TEXTURE_2D, prev);
      gl.activeTexture(was);
      c.posterLoaded = true;
    } catch (e) { console.warn('adboards: poster upload failed', c.id, e); }
  };
  img.onerror = () => { console.warn('adboards: poster failed to load', c.id, c.poster); };
  img.src = c.poster;
}

function adMakeVideo(c) {
  if (!c.video || c.videoEl) return;
  const v = document.createElement('video');
  v.muted = true; v.defaultMuted = true; v.volume = 0;
  v.loop = true; v.autoplay = true; v.playsInline = true;
  v.setAttribute('playsinline', '');            // legacy iOS Safari form
  v.setAttribute('webkit-playsinline', '');
  v.preload = 'auto';
  v.src = c.video;
  v.addEventListener('error', () => { c.videoState = 'error'; try { AD_PROVIDER.reportError(null, { creative: c.id, type: 'video-error' }); } catch (e) {} });
  c.videoEl = v;
  adTryPlay(c);
}

function adTryPlay(c) {
  const v = c.videoEl; if (!v) return;
  let p;
  try { p = v.play(); } catch (e) { c.videoState = 'blocked'; return; }
  if (p && typeof p.then === 'function') {
    p.then(() => { if (c.videoState !== 'error') c.videoState = 'ready'; })
     .catch(() => { if (c.videoState !== 'error') c.videoState = 'blocked'; });   // autoplay refused
  } else {
    c.videoState = 'ready';
  }
}

let _adGestureWired = false;
function adWireGestureRetry() {
  if (_adGestureWired) return; _adGestureWired = true;
  const retry = () => { for (const id in AD_CREATIVES) { const c = AD_CREATIVES[id]; if (c.videoState === 'blocked') adTryPlay(c); } };
  document.addEventListener('pointerdown', retry, { passive: true });
  document.addEventListener('touchstart', retry, { passive: true });
  document.addEventListener('keydown', retry, { passive: true });
}

function adPauseAll() {
  for (const id in AD_CREATIVES) { const c = AD_CREATIVES[id]; if (c.videoEl && !c.videoEl.paused) c.videoEl.pause(); }
}

/* Throttled to AD_UPLOAD_MS by the caller. `needed` is the set of creative
   ids at least one ON-SCREEN board is currently showing β€” everything else is
   paused outright rather than merely skipped, so an off-screen billboard's
   clip is not still decoding somewhere off camera. */
function adUpdateCreatives(now, needed) {
  const perfOk = !(typeof perfScale !== 'undefined' && perfScale < 0.5) &&
                 !(typeof META !== 'undefined' && META.settings && META.settings.perf === 'low');
  const adsOn = !(typeof META !== 'undefined' && META.settings && META.settings.ads === false);
  const canPlay = perfOk && adsOn && !document.hidden;
  for (const id in AD_CREATIVES) {
    const c = AD_CREATIVES[id];
    const want = canPlay && needed.has(id) && !!c.video;
    if (want && !c.videoEl) adMakeVideo(c);
    if (!c.videoEl) continue;
    if (!want) { if (!c.videoEl.paused) c.videoEl.pause(); continue; }
    if (c.videoEl.paused && c.videoState !== 'error') adTryPlay(c);
    if (c.videoState !== 'ready' || c.videoEl.readyState < 2) continue;   // HAVE_CURRENT_DATA
    if (now - c.lastUpload < AD_UPLOAD_MS) continue;
    c.lastUpload = now;
    try {
      /* MUST NOT use the active unit. begin3D leaves TEXTURE0 = matTex, then
         re-enters 2-3 times per frame. Uploading here used to bind the video
         onto unit 0 and then bindTexture(null) β€” every ~15Hz that landed on
         the begin3D AFTER shadows, every model sampled an empty atlas and
         the whole army strobed. Unit 7 is the ad scratch unit; restore
         whatever was there (detail / fog / video) before returning. */
      const was = gl.getParameter(gl.ACTIVE_TEXTURE);
      gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT);
      const prev = gl.getParameter(gl.TEXTURE_BINDING_2D);
      gl.bindTexture(gl.TEXTURE_2D, c.videoTex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, c.videoEl);
      gl.bindTexture(gl.TEXTURE_2D, prev);
      gl.activeTexture(was);
      c.videoTexPrimed = true;
    } catch (e) { /* a mid-decode frame can throw on some mobile browsers β€” skip this tick, try again next */ }
  }
}

/* ============================================================================
   SCREEN QUAD β€” its own tiny GLSL program, its own texture unit
   ----------------------------------------------------------------------------
   The billboard's FRAME is ordinary geometry through the shared model shader
   (see adFlushFrames). The SCREEN can't be: it samples a texture that changes
   every frame and must read as self-lit regardless of the sun, which the
   shared material-atlas shader has no notion of. So it is one small unlit
   program of its own, drawing a handful of world-space quads computed on the
   CPU from the same AD_* layout constants the frame mesh uses, which is what
   keeps the video locked exactly into its bezel at any scale or yaw.
   ============================================================================ */
let adProg = null, AD_U = {}, adVAO = null, adVBO = null;
const AD_TEX_UNIT = 7;    // deliberately unused everywhere else β€” 0-3 are the model/terrain
                           // shaders' own samplers, 4/5/6 are the AO/bloom post chain
const _adVerts = new Float32Array(4 * 5);

function adInitScreenProgram() {
  const VS = `#version 300 es
layout(location=0) in vec3 aPos;
layout(location=1) in vec2 aUV;
uniform mat4 uVP;
out vec2 vUV;
void main(){
  vUV = aUV;
  gl_Position = uVP * vec4(aPos, 1.0);
}`;
  const FS = `#version 300 es
precision highp float;
in vec2 vUV;
uniform sampler2D uTex;
uniform sampler2D uTex2;
uniform float uBoost;
uniform float uMix;
out vec4 o;
void main(){
  // crossfade between two textures: when uMix==0 only uTex shows,
  // when uMix==1 only uTex2 shows. Both are self-lit screens β€”
  // no lighting, straight gamma through.
  vec3 a = texture(uTex, vUV).rgb;
  vec3 b = texture(uTex2, vUV).rgb;
  vec3 c = mix(a, b, uMix) * uBoost;
  o = vec4(c, 1.0);
}`;
  adProg = mkProg(VS, FS);
  AD_U.uVP = gl.getUniformLocation(adProg, 'uVP');
  AD_U.uTex = gl.getUniformLocation(adProg, 'uTex');
  AD_U.uTex2 = gl.getUniformLocation(adProg, 'uTex2');
  AD_U.uBoost = gl.getUniformLocation(adProg, 'uBoost');
  AD_U.uMix = gl.getUniformLocation(adProg, 'uMix');
  adVAO = gl.createVertexArray();
  gl.bindVertexArray(adVAO);
  adVBO = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, adVBO);
  gl.bufferData(gl.ARRAY_BUFFER, _adVerts.byteLength, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0);
  gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12);
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

/* World-space corners of one board's screen face, derived with EXACTLY the
   transform the instanced model shader applies (see VS3D in engine/mesh.js):
     world = R(yaw) * (local * scale) + (worldX, height, worldY)
   computed on the CPU because there are only ever a handful of these and a
   whole instancing path would be more machinery than the job needs. Corner
   order matches the engine's own quad convention (BL,BR,TL,TR as a
   TRIANGLE_STRIP β€” see quadB in engine/gl.js); v=0 is the TOP of the source
   image, matching how every canvas-sourced texture in this engine is already
   uploaded with no Y-flip (see the sprite atlas in engine/mesh.js). */
function adScreenVerts(b) {
  const c = Math.cos(b.yaw), s = Math.sin(b.yaw);
  const gx = b.x, gz = b.y, gy = terrainH(b.x, b.y);
  const lx = AD_FACE_X * b.scale;
  const corners = [
    [-AD_HALFW, AD_BOT_Y],              // BL
    [ AD_HALFW, AD_BOT_Y],              // BR
    [-AD_HALFW, AD_BOT_Y + AD_SCR_H],   // TL
    [ AD_HALFW, AD_BOT_Y + AD_SCR_H],   // TR
  ];
  const uvs = [[0, 1], [1, 1], [0, 0], [1, 0]];
  for (let k = 0; k < 4; k++) {
    const lz = corners[k][0] * b.scale, ly = corners[k][1] * b.scale;
    const o = k * 5;
    _adVerts[o]     = gx + (lx * c - lz * s);
    _adVerts[o + 1] = gy + ly;
    _adVerts[o + 2] = gz + (lx * s + lz * c);
    _adVerts[o + 3] = uvs[k][0];
    _adVerts[o + 4] = uvs[k][1];
  }
  return _adVerts;
}

/* Draws the video/poster quad for every ON-SCREEN board, plus a soft additive
   halo (piggy-backed on the engine's existing bbAdd billboard-sprite batch β€”
   no extra shader for that part) so the screen reads as a light source once
   night falls, not just a texture. Saves and restores every piece of GL
   state the brief calls out, and β€” the one that matters most β€” leaves prog3D
   bound again before returning, because render3d.js's begin3D() (which this
   is called from) is what every OTHER model draw this frame assumes is still
   current. */
function adDrawScreens(list) {
  if (!adProg || !list.length) return;
  const wasBlend = gl.getParameter(gl.BLEND);
  const wasCull  = gl.getParameter(gl.CULL_FACE);
  const wasDepth = gl.getParameter(gl.DEPTH_TEST);
  const wasMask  = gl.getParameter(gl.DEPTH_WRITEMASK);

  gl.useProgram(adProg);
  gl.uniformMatrix4fv(AD_U.uVP, false, matVP);
  gl.uniform1i(AD_U.uTex, AD_TEX_UNIT);
  gl.uniform1i(AD_U.uTex2, AD_TEX_UNIT2);
  const na = (typeof nightAmt === 'function') ? nightAmt() : 0;
  gl.uniform1f(AD_U.uBoost, 0.95 + na * 0.85);   // self-lit; brighter once the sun's down
  gl.disable(gl.BLEND);
  gl.disable(gl.CULL_FACE);       // the quad's winding isn't worth chasing for ~10 draws/frame
  gl.enable(gl.DEPTH_TEST);
  gl.depthMask(true);
  gl.bindVertexArray(adVAO);
  gl.bindBuffer(gl.ARRAY_BUFFER, adVBO);

  for (const b of list) {
    const c = AD_CREATIVES[b.creative];
    const c2 = b.creative2 ? AD_CREATIVES[b.creative2] : null;
    // contextual texture overrides sponsor creative when available
    const ctxTex = b._contextual ? adMakeContextualTex(b) : null;
    // primary texture: contextual canvas, then video if playing, else poster, else fallback
    let tex1 = ctxTex || adFallbackTex;
    if (!ctxTex && c) tex1 = (c.videoTexPrimed && c.videoState === 'ready') ? c.videoTex : (c.posterTex || adFallbackTex);
    // secondary texture: during crossfade it's the incoming creative
    let tex2 = tex1;
    let mix = 0;
    if (b._blend > 0 && c2) {
      tex2 = (c2.videoTexPrimed && c2.videoState === 'ready') ? c2.videoTex : (c2.posterTex || adFallbackTex);
      mix = b._blend;
    }
    // bind both texture units up front β€” unit 7 for primary, unit 8 for secondary
    gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT);
    gl.bindTexture(gl.TEXTURE_2D, tex1);
    gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT2);
    gl.bindTexture(gl.TEXTURE_2D, tex2);
    gl.uniform1f(AD_U.uMix, mix);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, adScreenVerts(b));
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    if (typeof bbAdd !== 'undefined' && typeof sprites !== 'undefined' && sprites.glow) {
      const chx = b.x + Math.cos(b.yaw) * AD_FACE_X * b.scale;
      const chz = b.y + Math.sin(b.yaw) * AD_FACE_X * b.scale;
      const chy = terrainH(b.x, b.y) + (AD_BOT_Y + AD_SCR_H * 0.5) * b.scale;
      const ac = (c && c.accent) || [190, 220, 255];
      bbAdd.add(sprites.glow, chx, chz, chy, AD_HALFW * b.scale * 1.15, 0, ac[0], ac[1], ac[2], Math.round(46 + na * 130));
    }
  }

  gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT2);
  if (typeof fogTex !== 'undefined' && fogTex) gl.bindTexture(gl.TEXTURE_2D, fogTex);
  else if (typeof matTex !== 'undefined' && matTex) gl.bindTexture(gl.TEXTURE_2D, matTex);
  gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT);
  if (typeof matDetailTex !== 'undefined' && matDetailTex) gl.bindTexture(gl.TEXTURE_2D, matDetailTex);
  gl.activeTexture(gl.TEXTURE0);
  if (typeof matTex !== 'undefined' && matTex) gl.bindTexture(gl.TEXTURE_2D, matTex);
  if (wasCull) gl.enable(gl.CULL_FACE); else gl.disable(gl.CULL_FACE);
  if (wasBlend) gl.enable(gl.BLEND); else gl.disable(gl.BLEND);
  if (wasDepth) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST);
  gl.depthMask(wasMask);
  gl.useProgram(prog3D);    // MUST leave the model program bound β€” see file header
}

/* ============================================================================
   FRAME MESH β€” the physical prop, through the shared lit/instanced pipeline
   ============================================================================ */
let adFrameMesh = null;
function adFlushFrames() {
  if (!adFrameMesh || !adBoards.length) return;
  for (const b of adBoards) adFrameMesh.add(b.x, b.y, terrainH(b.x, b.y), b.scale, b.yaw, 232, 232, 232, 255);
  adFrameMesh.flush(gl);
}

/* ============================================================================
   PER-FRAME HOOK β€” installed on begin3D (see adInstallHooks)
   ----------------------------------------------------------------------------
   begin3D() is NOT called once per rendered frame β€” render3d.js's render()
   re-enters it two or three times in the course of a single frame (once
   before terrain/shadows draw, once again immediately after to restore the
   lit model program, and once more after the AO resolve on the branch where
   AO is active). The throttled upload/impression logic below already
   self-gates on wall-clock time so re-entrancy there is harmless, but the
   DRAW calls are not idempotent the same way: adFlushFrames() redrawing the
   same opaque instances 2-3x/frame is merely wasted GPU time (depth-tested
   away, so at least nothing looks wrong), but adDrawScreens() also queues
   the night-glow halo onto the engine's shared ADDITIVE bbAdd batch, and
   that batch is only ever cleared by its own single flush() call late in
   the frame β€” so calling it more than once per frame stacks the halo 2-3x
   brighter than intended instead of drawing it once. adFrameTick() is an
   independent requestAnimationFrame ticker that exists purely to answer
   "has a new real frame started?" without needing a fourth hook into
   render3d.js: main.js's own loop is also driven by rAF, so both callbacks
   fire once per browser animation frame and _adFrameId is guaranteed stable
   across begin3D's entire synchronous burst of re-entries for that frame. */
let _adFrameId = 0;
(function adFrameTick(){ _adFrameId++; requestAnimationFrame(adFrameTick); })();
let _adDrawnFrame = -1;
let _adLastTick = 0;

function adVis(cb, x, y, pad) { return x >= cb.x0 - pad && x <= cb.x1 + pad && y >= cb.y0 - pad && y <= cb.y1 + pad; }
function adCamBoundsSafe() { try { return (typeof camBounds === 'function') ? camBounds() : null; } catch (e) { return null; } }

/* ============================================================================
   CONTENT ROTATION β€” 12s cycle with ~1s crossfade
   ============================================================================
   Each board tracks its own rotation timer. When the timer expires, the
   current creative becomes the "old" one (still displayed via tex unit 7)
   and a new creative is picked for the "incoming" slot (tex unit 8). The
   blend uniform lerps from 0 to 1 over AD_FADE_S seconds, then the old
   creative is dropped and the new one becomes primary. */
function adUpdateRotation(dt) {
  const ids = Object.keys(AD_CREATIVES);
  if (ids.length < 2) return;   // nothing to rotate to
  for (const b of adBoards) {
    if (!b.creative) continue;
    b._rotT += dt;
    if (b._blend > 0) {
      // mid-crossfade: advance blend
      b._blend = Math.min(1, b._blend + dt / AD_FADE_S);
      if (b._blend >= 1) {
        // crossfade complete β€” new creative becomes primary
        b.creative = b.creative2;
        b.creative2 = null;
        b._blend = 0;
        b._rotT = 0;
      }
    } else if (b._rotT >= AD_ROTATE_S) {
      // time to rotate β€” pick a different creative than current
      let next = b.creative;
      for (let tries = 0; tries < 6; tries++) {
        const pick = ids[(adHash(b.id + ':' + b._rotT + ':' + tries)) % ids.length];
        if (pick !== b.creative) { next = pick; break; }
      }
      if (next !== b.creative) {
        b.creative2 = next;
        b._blend = 0.001;   // start the crossfade β€” 0 would skip it
      } else {
        b._rotT = 0;        // only one creative available, just reset
      }
    }
  }
}

/* ============================================================================
   CONTEXTUAL AD CONTENT β€” faction lore, unit tips, sponsor branding
   ============================================================================
   During a match, billboards can cycle through contextual content: faction
   lore tied to who you're fighting, unit/structure tips, and the bundled
   sponsor brands. This is a content selector that assigns a _contentType
   to each board so the renderer can pick the right creative pool.

   Content types: 'lore', 'tip', 'sponsor' β€” rotated in that order per board
   so every board shows all three kinds over the course of a match. */
const AD_CONTENT_TYPES = ['sponsor', 'lore', 'tip'];

// faction lore lines β€” shown on boards during the match, tied to the enemy
const AD_LORE = {
  legion: [
    'The Ascendancy does not retreat. It reloads.',
    'Iron speaks louder than doctrine.',
    'Lord Vex commands. The column advances.',
    'Every shell finds its mark. Every fortress falls.',
  ],
  syndicate: [
    'The Coalition trades in futures β€” yours.',
    'Air superiority is not a luxury. It is a verdict.',
    'Broker Renn sends her regards. And a strike wing.',
    'Speed is the only armor that never jams.',
  ],
  horde: [
    'The Brood does not negotiate. It assimilates.',
    'You hear the swarm before you see it. Then you don\'t hear anything.',
    'The Sovereign remembers. The Sovereign sends more.',
    'Organic. Relentless. Hungry.',
  ],
};

// unit/structure tips β€” generic gameplay advice shown on billboards
const AD_TIPS = [
  'MASS EXTRACTORS win wars. Build early, build often.',
  'SCOUT before you commit. Knowledge is the best armor.',
  'RECLAIM wrecks. Free mass is winning mass.',
  'TERRAIN matters. High ground grants vision and range.',
  'TITANS change the game. Rush one, or prepare for one.',
  'SHIELDS absorb the first hit. Position matters.',
  'ARTILLERY outranges everything. Protect the battery.',
  'ENERGY MANAGEMENT β€” stall and your production halts.',
  'FLANKING deals bonus damage. Surround, don\'t face-tank.',
  'RADAR gives you seconds. Seconds win engagements.',
];

function adPickContextualType(board) {
  // cycle sponsor -> lore -> tip based on board index
  return AD_CONTENT_TYPES[board.id.charCodeAt(2) % 3] || 'sponsor';
}

function adAssignContextualCreatives() {
  if (typeof AI === 'undefined') return;  // not in a match
  const fac = (AI && AI.fac) || 'legion';
  const loreLines = AD_LORE[fac] || AD_LORE.legion;
  for (const b of adBoards) {
    const type = adPickContextualType(b);
    if (type === 'lore') {
      // pick a lore line for this faction, hashed to the board id
      const line = loreLines[adHash(b.id + ':lore') % loreLines.length];
      b._contextual = { type: 'lore', text: line, fac: fac };
    } else if (type === 'tip') {
      const tip = AD_TIPS[adHash(b.id + ':tip') % AD_TIPS.length];
      b._contextual = { type: 'tip', text: tip };
    } else {
      b._contextual = null;  // use the normal sponsor creative
    }
  }
}

/* Canvas-generated texture for contextual content β€” renders text onto a
   poster-sized canvas and uploads it as a GL texture. Created once per board
   per match start (not per frame). The canvas is reused and the texture is
   updated via texSubImage2D when content changes. */
const _adCtxCanvas = document.createElement('canvas');
_adCtxCanvas.width = 480; _adCtxCanvas.height = 270;
const _adCtx2d = _adCtxCanvas.getContext('2d');
const AD_CTX_TEX_CACHE = {};  // boardId -> {tex, lastType, lastText}

function adMakeContextualTex(board) {
  const ctx = _adCtx2d;
  const c = board._contextual;
  if (!c) return null;
  let cached = AD_CTX_TEX_CACHE[board.id];
  if (cached && cached.lastType === c.type && cached.lastText === c.text) return cached.tex;

  // draw the contextual content
  const facCol = c.type === 'lore' && typeof FACTIONS !== 'undefined' && FACTIONS[c.fac]
    ? FACTIONS[c.fac].col : [110, 190, 255];
  const accent = 'rgb(' + facCol[0] + ',' + facCol[1] + ',' + facCol[2] + ')';

  ctx.fillStyle = c.type === 'lore' ? 'rgba(12,20,34,0.96)' : 'rgba(18,28,18,0.96)';
  ctx.fillRect(0, 0, 480, 270);

  // border glow
  ctx.strokeStyle = accent;
  ctx.lineWidth = 3;
  ctx.strokeRect(6, 6, 468, 258);

  // header
  ctx.fillStyle = accent;
  ctx.font = '700 14px monospace';
  ctx.textAlign = 'center';
  if (c.type === 'lore') {
    ctx.fillText('FIELD BROADCAST', 240, 36);
  } else {
    ctx.fillText('TACTICAL ADVISORY', 240, 36);
  }

  // divider
  ctx.strokeStyle = 'rgba(' + facCol[0] + ',' + facCol[1] + ',' + facCol[2] + ',0.3)';
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(40, 48); ctx.lineTo(440, 48); ctx.stroke();

  // main text β€” word-wrap
  ctx.fillStyle = '#d0e8ff';
  ctx.font = '600 18px monospace';
  const words = c.text.split(' ');
  let line = '', y = 90;
  for (const w of words) {
    const test = line + (line ? ' ' : '') + w;
    if (ctx.measureText(test).width > 400 && line) {
      ctx.fillText(line, 240, y);
      line = w; y += 28;
    } else {
      line = test;
    }
  }
  if (line) ctx.fillText(line, 240, y);

  // footer tag
  ctx.fillStyle = 'rgba(' + facCol[0] + ',' + facCol[1] + ',' + facCol[2] + ',0.5)';
  ctx.font = '700 10px monospace';
  ctx.fillText(c.type === 'lore' ? 'MASSFRONT FIELD NETWORK' : 'COMMAND ADVISORY SYSTEM', 240, 250);

  // upload to GL texture
  if (!cached) {
    cached = AD_CTX_TEX_CACHE[board.id] = { tex: adMakeTex(), lastType: '', lastText: '' };
  }
  const was = gl.getParameter(gl.ACTIVE_TEXTURE);
  gl.activeTexture(gl.TEXTURE0 + AD_TEX_UNIT);
  const prev = gl.getParameter(gl.TEXTURE_BINDING_2D);
  gl.bindTexture(gl.TEXTURE_2D, cached.tex);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, _adCtxCanvas);
  gl.bindTexture(gl.TEXTURE_2D, prev);
  gl.activeTexture(was);
  cached.lastType = c.type;
  cached.lastText = c.text;
  return cached.tex;
}

/* ============================================================================
   POST-MATCH AD SLOT β€” 5s card on end-game screen, skippable after 3s
   ============================================================================
   Inserted into #gameOver before #goRewards. Shows a sponsor creative with
   a countdown timer. Touch/click dismisses after 3s. Auto-dismisses at 5s.
   The card is pure DOM, not a GL draw β€” the game renderer is paused. */
const AD_POSTMATCH_S = 5;
const AD_POSTMATCH_SKIP_S = 3;
let _adPostMatchTimer = null;
let _adPostMatchEl = null;

function adShowPostMatchAd(win) {
  adClearPostMatchAd();
  // pick a creative to feature
  const ids = Object.keys(AD_CREATIVES);
  const pick = ids.length ? AD_CREATIVES[ids[adHash('postmatch' + Date.now()) % ids.length]] : null;
  const brand = pick ? pick.brand : 'MASSFRONT';
  const accent = pick ? 'rgb(' + pick.accent.join(',') + ')' : '#6ec8ff';
  const tagline = (pick && pick.poster) ? '' : 'Tactical superiority, delivered.';

  const go = document.getElementById('gameOver');
  if (!go) return;
  const scroll = go.querySelector('.goResultScroll');
  if (!scroll) return;

  // inject CSS once
  if (!document.getElementById('adPostMatchCSS')) {
    const st = document.createElement('style');
    st.id = 'adPostMatchCSS';
    st.textContent =
      '.adPostMatch{margin:12px auto 4px;max-width:400px;border-radius:12px;overflow:hidden;' +
      'background:linear-gradient(160deg,rgba(20,32,50,.96),rgba(8,14,26,.98));' +
      'border:1px solid rgba(110,180,240,.28);cursor:pointer;position:relative}' +
      '.adPostMatchImg{width:100%;display:block;aspect-ratio:16/9;object-fit:cover}' +
      '.adPostMatchBody{padding:10px 14px 12px;text-align:center}' +
      '.adPostMatchBrand{font:800 11px var(--fT);letter-spacing:.16em;color:' + accent + '}' +
      '.adPostMatchTag{margin-top:4px;font:600 10px var(--fU);color:#7a9cb8}' +
      '.adPostMatchBar{height:3px;background:rgba(110,180,240,.15);position:relative;overflow:hidden}' +
      '.adPostMatchBar>i{display:block;height:100%;background:' + accent + ';width:100%;' +
      'transition:width .1s linear}' +
      '.adPostMatchSkip{position:absolute;bottom:6px;right:10px;font:700 9px var(--fT);' +
      'letter-spacing:.12em;color:rgba(160,200,230,.6);opacity:0;transition:opacity .3s}' +
      '.adPostMatchSkip.show{opacity:1}';
    document.head.appendChild(st);
  }

  const el = document.createElement('div');
  el.className = 'adPostMatch';
  el.innerHTML =
    (pick && pick.poster
      ? '<img class="adPostMatchImg" src="' + pick.poster + '" alt="' + brand + '" onerror="this.style.display=\'none\'">'
      : '') +
    '<div class="adPostMatchBody"><div class="adPostMatchBrand">' + brand.toUpperCase() + '</div>' +
    (tagline ? '<div class="adPostMatchTag">' + tagline + '</div>' : '') + '</div>' +
    '<div class="adPostMatchBar"><i id="adPostMatchProg"></i></div>' +
    '<div class="adPostMatchSkip" id="adPostMatchSkip">TAP TO SKIP</div>';

  // insert before rewards
  const rewards = document.getElementById('goRewards');
  if (rewards) {
    scroll.insertBefore(el, rewards);
  } else {
    scroll.appendChild(el);
  }
  _adPostMatchEl = el;

  // track impression
  if (pick) {
    AD_STATS.total++;
    AD_STATS.impressions[pick.id] = (AD_STATS.impressions[pick.id] || 0) + 1;
    adStatsSave();
  }

  // timer β€” 100ms ticks, progress bar, skip reveal
  let elapsed = 0;
  const skipEl = el.querySelector('#adPostMatchSkip');
  const progEl = el.querySelector('#adPostMatchProg');

  const tick = () => {
    elapsed += 0.1;
    if (progEl) progEl.style.width = Math.max(0, (1 - elapsed / AD_POSTMATCH_S) * 100) + '%';
    if (elapsed >= AD_POSTMATCH_SKIP_S && skipEl) skipEl.classList.add('show');
    if (elapsed >= AD_POSTMATCH_S) { adClearPostMatchAd(); return; }
    _adPostMatchTimer = setTimeout(tick, 100);
  };
  _adPostMatchTimer = setTimeout(tick, 100);

  // touch/click to dismiss after skip window
  el.addEventListener('pointerdown', () => {
    if (elapsed >= AD_POSTMATCH_SKIP_S) adClearPostMatchAd();
  });
}

function adClearPostMatchAd() {
  if (_adPostMatchTimer) { clearTimeout(_adPostMatchTimer); _adPostMatchTimer = null; }
  if (_adPostMatchEl && _adPostMatchEl.parentNode) {
    _adPostMatchEl.parentNode.removeChild(_adPostMatchEl);
  }
  _adPostMatchEl = null;
}

/* ============================================================================
   PER-FRAME HOOK β€” installed on begin3D (see adInstallHooks)
   ============================================================================ */
function adFrameHook() {
  if (!adBoards.length) return;
  const freshFrame = _adDrawnFrame !== _adFrameId;
  if (freshFrame) adFlushFrames();                     // cheap, real geometry β€” draw once per frame

  const cb = adCamBoundsSafe();
  if (!cb) return;
  const visible = [];
  const needed = new Set();
  for (const b of adBoards) {
    b._onscreen = adVis(cb, b.x, b.y, 70);
    if (b._onscreen && b.creative) { visible.push(b); needed.add(b.creative); }
  }

  const now = (typeof performance !== 'undefined') ? performance.now() : Date.now();
  /* Video upload used to run on EVERY begin3D once the 15Hz timer fired.
     render() re-enters begin3D after shadows, immediately before models.
     That path unbound the material atlas on unit 0 and every hull strobed.
     Uploads and screen draws are once per rAF, on the first begin3D, which
     is before terrain rebinds unit 0. */
  if (freshFrame) {
    if (now - _adLastTick >= AD_UPLOAD_MS) {
      const dt = Math.min(0.5, (now - _adLastTick) / 1000);
      _adLastTick = now;
      adUpdateCreatives(now, needed);
      adUpdateImpressions(dt);
      adUpdateRotation(dt);
    }
    if (visible.length) adDrawScreens(visible);
    _adDrawnFrame = _adFrameId;
  }
}

/* ============================================================================
   SETTINGS ROW β€” appended to #setList after renderSettings() runs
   ============================================================================ */
function adRenderSettingsRow() {
  const list = document.getElementById('setList');
  if (!list) return;
  const on = !(typeof META !== 'undefined' && META.settings && META.settings.ads === false);
  const row = document.createElement('div');
  row.className = 'sItem setRow adsRow';
  row.innerHTML =
    '<div class="sTx"><b>\u{1F4FA} In-World Ad Boards</b>' +
    '<div class="sDs">Billboards play looping video on the battlefield β€” off shows static art</div></div>' +
    '<div class="sBuy togB' + (on ? ' onT' : '') + '">' + (on ? 'ON' : 'OFF') + '</div>';
  row.addEventListener('pointerdown', () => {
    if (typeof META === 'undefined' || !META.settings) return;
    META.settings.ads = (META.settings.ads === false);   // toggle, defaulting true -> false -> true
    if (typeof metaSave === 'function') metaSave();
    if (typeof sfx === 'function') sfx('ui');
    renderSettings();   // rebuilds the list; the wrap below re-appends this row
  });
  list.appendChild(row);
}

/* ============================================================================
   HOOK INSTALLATION β€” wrap, never replace
   ============================================================================ */
function adInstallHooks() {
  if (window.__adboardsHooked) return;
  window.__adboardsHooked = true;

  if (typeof setupDoodads === 'function') {
    const orig = setupDoodads;
    setupDoodads = function () {
      const r = orig.apply(this, arguments);
      try { adPlaceBoards(); adAssignCreatives(); } catch (e) { console.error('adboards: placement failed', e); }
      return r;
    };
  }
  if (typeof begin3D === 'function') {
    const orig = begin3D;
    begin3D = function (nA) {
      const r = orig.apply(this, arguments);
      try { adFrameHook(); } catch (e) { console.error('adboards: draw hook failed', e); }
      return r;
    };
  }
  if (typeof renderSettings === 'function') {
    const orig = renderSettings;
    renderSettings = function () {
      const r = orig.apply(this, arguments);
      try { adRenderSettingsRow(); } catch (e) { console.error('adboards: settings row failed', e); }
      return r;
    };
  }
}

/* ============================================================================
   ENTRY POINT
   ----------------------------------------------------------------------------
   main.js calls initAdBoards() near the end of boot() if it exists β€” but by
   then boot() has ALREADY called setupDoodads() once (for the attract-mode
   menu diorama), so the hook install below also runs itself immediately, at
   script-load time, well before boot() runs at all. main.js's own call
   later is a harmless no-op thanks to the __adboardsInit guard. This is the
   one function in this file that touches the DOM/GL eagerly rather than
   lazily, and it is safe to do so here because gl.js/mesh.js/models.js have
   already run by adboards.js's turn in the manifest.
   ============================================================================ */
function initAdBoards() {
  if (window.__adboardsInit) return;
  window.__adboardsInit = true;
  adStatsLoad();
  adInstallHooks();
  adInitScreenProgram();
  adFallbackTex = adMakeTex();
  adFrameMesh = new InstMesh(gl, mdlAdBoard(), AD_MAX + 4);
  adWireGestureRetry();
  document.addEventListener('visibilitychange', () => { if (document.hidden) adPauseAll(); });
  AD_PROVIDER.init();   // fire-and-forget; loadCreative() awaits it itself if it's still pending
}
initAdBoards();