File size: 43,495 Bytes
1096ae8
623f6f8
857589b
 
 
8994edc
623f6f8
 
 
 
 
 
 
 
 
 
 
f50e63e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7bf895a
 
 
 
 
 
 
 
 
 
 
 
 
 
623f6f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f50e63e
a948328
 
 
 
 
623f6f8
 
 
f50e63e
 
 
 
 
623f6f8
 
 
f50e63e
623f6f8
a948328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8994edc
a948328
 
 
 
 
 
 
 
 
623f6f8
 
 
 
 
 
 
24cc077
623f6f8
 
 
 
8994edc
 
 
a948328
 
 
 
 
8994edc
623f6f8
f50e63e
 
623f6f8
 
 
1096ae8
 
9c8994d
 
1096ae8
9c8994d
 
 
1096ae8
9c8994d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1096ae8
9c8994d
 
 
 
 
 
 
 
1096ae8
 
 
9c8994d
b10f424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1096ae8
b10f424
1096ae8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b10f424
1096ae8
 
 
 
 
 
 
 
 
 
 
 
 
b10f424
 
 
 
 
 
 
 
 
 
 
 
 
 
1096ae8
 
b10f424
 
 
 
 
1096ae8
 
9c8994d
1096ae8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623f6f8
1096ae8
 
 
 
a948328
1096ae8
 
 
 
b10f424
1096ae8
 
 
 
a948328
 
1096ae8
 
 
b10f424
1096ae8
 
b10f424
 
 
 
1096ae8
 
 
 
 
b10f424
9c8994d
1096ae8
b10f424
9c8994d
 
b10f424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1096ae8
 
 
9c8994d
1096ae8
 
 
 
b10f424
 
1096ae8
 
9c8994d
1096ae8
 
9c8994d
1096ae8
a948328
 
 
 
 
1096ae8
 
9c8994d
1096ae8
 
9c8994d
1096ae8
 
9c8994d
 
1096ae8
b10f424
1096ae8
a948328
8994edc
6556847
b10f424
1096ae8
 
 
 
b10f424
9c8994d
 
 
1096ae8
b10f424
a948328
1096ae8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a948328
 
8994edc
a948328
 
 
 
8994edc
a948328
 
 
 
 
 
 
8994edc
a948328
 
 
 
 
 
 
8994edc
 
a948328
 
 
1096ae8
 
 
 
 
 
 
 
 
 
 
b10f424
 
 
1096ae8
 
 
 
 
9c8994d
1096ae8
9c8994d
623f6f8
a948328
 
8994edc
623f6f8
1096ae8
 
623f6f8
 
 
 
 
 
1096ae8
 
 
 
623f6f8
1096ae8
623f6f8
1096ae8
 
623f6f8
f50e63e
8994edc
a948328
623f6f8
 
1096ae8
 
623f6f8
 
9c8994d
 
1096ae8
 
 
 
 
 
 
 
 
 
b10f424
1096ae8
 
9c8994d
 
 
1096ae8
9c8994d
623f6f8
a948328
623f6f8
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
/* Browser-only preview of the selected saved track. No upload for preview. */
(function(root,factory){
  if(typeof module==='object'&&module.exports)module.exports=factory(root);
  else root.TrackMap=factory(root);
})(typeof window==='undefined'?this:window,function(root){
  const maskPlanner=typeof module==='object'&&module.exports?require('./track-mask.js'):root.TrackMask;
  const COLOURS=['#607e96','#b89561','#b9d9ed'];
  const NAMES=['Asphalt','Dirt','Ice'];
  const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
  function vector(value){
    const match=typeof value==='string'&&VECTOR.exec(value);
    if(!match)throw Error('Track has invalid tile coordinates');
    const x=Number(match[1]),y=Number(match[2]);
    if(!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||Math.abs(x)>1000||Math.abs(y)>1000)
      throw Error('Track coordinates exceed preview bounds');
    return [x,y];
  }
  function connection(value,message='Track has an invalid piece connection'){
    if(!value||typeof value!=='object'||Array.isArray(value)||
       !Number.isInteger(value.side)||value.side<0||value.side>3||
       !Number.isInteger(value.index)||value.index<0||value.index>2||
       !Number.isInteger(value.type)||value.type<0)throw Error(message);
    return {side:value.side,index:value.index,type:value.type};
  }
  function connectionPoint(value,x,y,size){
    const {side,index}=connection(value);
    // Native Connection.position uses offsets 128,384,640 in a 768-unit tile.
    // Shared boundaries keep adjacent path endpoints and the saved start aligned.
    const offset=[128,384,640][index]/768*size;
    switch(side){
      case 0:return [x+offset,y];
      case 1:return [x+offset,y+size];
      case 2:return [x,y+offset];
      case 3:return [x+size,y+offset];
    }
  }
  function connectionControl(own,other,point,target){
    // Native TrackUtils.GetPathCurve (random_track.rs, b90b8af): each
    // connection's inward normal sets its cubic handle, adjusted by port type.
    // At the exit this is the next tile's negated entry normal, so the same
    // per-tile construction also preserves the curve when endpoints reverse.
    const [nx,ny]=[[0,1],[0,-1],[1,0],[-1,0]][own.side];
    const dx=target[0]-point[0],dy=target[1]-point[1];
    const angle=own.type===1?Math.PI/4:own.type===3?Math.atan2(128,384):own.type===4?Math.atan2(384,128):0;
    const rotation=Math.sign(nx*dy-ny*dx)*angle;
    const c=Math.cos(rotation),s=Math.sin(rotation);
    const smoothness=[0,2,100].includes(own.type)&&[1,3,4].includes(other.type)?0.2:0.5;
    const reach=Math.hypot(dx,dy)*smoothness;
    return [point[0]+(nx*c-ny*s)*reach,point[1]+(nx*s+ny*c)*reach];
  }
  async function decode(file){
    if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
    if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks');
    const reader=file.stream().pipeThrough(new DecompressionStream('gzip')).getReader();
    const chunks=[];let total=0;
    try{
      while(true){
        const {done,value}=await reader.read();if(done)break;
        total+=value.byteLength;
        if(total>2*1024*1024)throw Error('Track preview exceeds the 2 MB expanded limit');
        chunks.push(value);
      }
    }finally{reader.releaseLock();}
    const bytes=new Uint8Array(total);let at=0;
    for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}
    return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));
  }
  function model(saved,atlas){
    const entries=Object.entries(saved?.Tiles??{});
    if(entries.length<1||entries.length>256)throw Error('Track has no previewable tiles');
    const tiles=entries.map(([position,item])=>{
      const [x,y]=vector(position);
      const id=item?.Block?.TileSetId;
      const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`];
      if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas');
      if(!Array.isArray(block.connections)||block.connections.length!==2)throw Error('Track piece connections are unavailable');
      const polygons=block.polygons;
      if(polygons!==undefined&&(!Array.isArray(polygons)||polygons.length>32||polygons.some(p=>!Array.isArray(p)||p.length<3||p.length>128||p.some(v=>!Array.isArray(v)||v.length!==2||v.some(n=>!Number.isFinite(n)||Math.abs(n)>384)))))
        throw Error('Track wall geometry is invalid');
      return {x,y,surface:block.surface,connections:block.connections.map(value=>connection(value)),
        polygons:polygons?.map(p=>p.map(([px,py])=>[px+x*768+384,py+y*768+384]))};
    });
    if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
    const start=vector(saved.StartTileCoords);
    const startTile=tiles.find(tile=>tile.x===start[0]&&tile.y===start[1]);
    if(!startTile)throw Error('Start tile is not present in this track');
    const startConnection=connection({side:saved.StartTileConnection?.Side,index:saved.StartTileConnection?.Index,type:saved.StartTileConnection?.Type},'Track has an invalid or missing start connection');
    if(!startTile.connections.some(item=>item.side===startConnection.side&&item.index===startConnection.index&&item.type===startConnection.type))
      throw Error('Saved start connection does not match the start tile');
    const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x));
    const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y));
    if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
    return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
  }
  const distance=(a,b)=>Math.hypot(a[0]-b[0],a[1]-b[1]);
  function projectPoint(p,a,b){
    const dx=b[0]-a[0],dy=b[1]-a[1],length=dx*dx+dy*dy;
    const t=length?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/length)):0;
    return [a[0]+t*dx,a[1]+t*dy];
  }
  function insidePolygon(point,polygon){
    let inside=false;
    for(let i=0,j=polygon.length-1;i<polygon.length;j=i++){
      const a=polygon[i],b=polygon[j];
      if((a[1]>point[1])!==(b[1]>point[1])&&point[0]<(b[0]-a[0])*(point[1]-a[1])/(b[1]-a[1])+a[0])inside=!inside;
    }
    return inside;
  }
  function segmentDistance(a,b,c,d){
    const cross=(p,q,r)=>(q[0]-p[0])*(r[1]-p[1])-(q[1]-p[1])*(r[0]-p[0]);
    const ac=cross(a,b,c),ad=cross(a,b,d),ca=cross(c,d,a),cb=cross(c,d,b);
    if(ac*ad<0&&ca*cb<0)return 0;
    return Math.min(distance(a,projectPoint(a,c,d)),distance(b,projectPoint(b,c,d)),distance(c,projectPoint(c,a,b)),distance(d,projectPoint(d,a,b)));
  }
  function routePoints(track){
    const byTile=new Map(track.tiles.map(t=>[`${t.x},${t.y}`,t]));
    const equal=(a,b)=>a.side===b.side&&a.index===b.index&&a.type===b.type;
    let tile=byTile.get(track.start.join(',')),incoming=track.startConnection;
    const visited=new Set(),points=[],neighbors=[[0,-1],[0,1],[-1,0],[1,0]];
    while(tile&&!visited.has(`${tile.x},${tile.y}`)){
      visited.add(`${tile.x},${tile.y}`);
      if(!tile.connections.some(c=>equal(c,incoming)))throw Error('The saved route has an incompatible connection');
      const outgoing=tile.connections.find(c=>!equal(c,incoming));
      if(!outgoing)throw Error('The saved route has no exit');
      const a=connectionPoint(incoming,tile.x*768,tile.y*768,768),b=connectionPoint(outgoing,tile.x*768,tile.y*768,768);
      const p=connectionControl(incoming,outgoing,a,b),q=connectionControl(outgoing,incoming,b,a);
      for(let i=0;i<32;i++){
        const t=i/32,s=1-t;
        points.push([s*s*s*a[0]+3*s*s*t*p[0]+3*s*t*t*q[0]+t*t*t*b[0],s*s*s*a[1]+3*s*s*t*p[1]+3*s*t*t*q[1]+t*t*t*b[1]]);
      }
      const step=neighbors[outgoing.side];
      tile=byTile.get(`${tile.x+step[0]},${tile.y+step[1]}`);
      incoming={...outgoing,side:outgoing.side^1};
    }
    if(!tile||tile.x!==track.start[0]||tile.y!==track.start[1]||!equal(incoming,track.startConnection))throw Error('The saved route does not close at its starting connection');
    points.push(points[0]);
    const lengths=[0];
    for(let i=1;i<points.length;i++)lengths.push(lengths[i-1]+distance(points[i-1],points[i]));
    const length=lengths[lengths.length-1];
    if(!Number.isFinite(length)||length<100)throw Error('The saved route has no usable length');
    const count=Math.min(2048,Math.max(32,Math.ceil(length/64))),sampled=[];
    let at=0;
    for(let i=0;i<count;i++){
      const target=length*i/count;
      while(at+1<lengths.length-1&&lengths[at+1]<target)at++;
      const t=(target-lengths[at])/(lengths[at+1]-lengths[at]);
      sampled.push(points[at].map((v,axis)=>v+t*(points[at+1][axis]-v)));
    }
    return {points:sampled,route_tiles:visited.size};
  }
  function referenceLine(track){if(!maskPlanner)throw Error('Road-mask planner unavailable');return maskPlanner.line(track);}
  function referenceDistance(point,line){
    if(!line?.points?.length)return null;
    let best=null;
    line.points.forEach((a,i)=>{
      const projected=projectPoint(point,a,line.points[(i+1)%line.points.length]),d=distance(point,projected);
      if(!best||d<best.distance)best={distance:d,point:projected};
    });
    return best;
  }
  function render(canvas,track){
    const context=canvas.getContext('2d');
    if(!context)throw Error('Canvas preview unavailable');
    const w=canvas.width,h=canvas.height;
    context.clearRect(0,0,w,h);
    context.fillStyle='#0c1926';context.fillRect(0,0,w,h);
    const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
    const size=Math.min(160,(w-70)/columns,(h-70)/rows);
    const left=(w-columns*size)/2,top=(h-rows*size)/2;
    for(const tile of track.tiles){
      const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
      context.fillStyle=COLOURS[tile.surface];
      // Physical part mask: a full tile with each native wall polygon removed.
      // No guessed rounded pavement or artificial gaps at tile joins.
      context.fillRect(x,y,size,size);
      for(const polygon of tile.polygons??[]){
        context.beginPath();
        polygon.forEach(([px,py],i)=>{const p=[x+(px-tile.x*768)/768*size,y+(py-tile.y*768)/768*size];if(i)context.lineTo(...p);else context.moveTo(...p);});
        context.closePath();context.fillStyle='#253443';context.fill();
      }

    }
    const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
    const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
    context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
    context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
  }
  // Vehicle illustration uses only recorded position, heading, type and active state.
  function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
    ctx.save();
    ctx.translate(x,y);
    ctx.rotate(heading);
    ctx.scale(scale,scale);
    if(!active)ctx.globalAlpha=0.55;

    const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
    // Draw wheels / skis
    ctx.fillStyle='#101820';
    ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
    ctx.lineWidth=showOutline?1.2:0.7;
    if(vehicleType===2){
      // Snowmobile twin front skis + rear track
      for(const wy of [-5.2,5.2]){
        ctx.save();ctx.translate(8.5,wy);ctx.rotate(wheelSteer);
        ctx.beginPath();ctx.roundRect(-4.5,-1.3,9,2.6,1);ctx.fill();ctx.stroke();
        ctx.restore();
      }
      ctx.beginPath();ctx.roundRect(-12,-4.2,13,8.4,1.5);ctx.fill();
    }else{
      // Rear wheels
      const rwY=vehicleType===3?6.8:6.2;
      for(const wy of [-rwY,rwY]){
        ctx.beginPath();ctx.roundRect(-10.5,wy-1.8,6.5,3.6,1);ctx.fill();ctx.stroke();
      }
      // Steerable front wheels
      const fwY=vehicleType===0?6.4:6.0;
      for(const wy of [-fwY,fwY]){
        ctx.save();ctx.translate(8.2,wy);ctx.rotate(wheelSteer);
        ctx.beginPath();ctx.roundRect(-3.1,-1.6,6.2,3.2,1);ctx.fill();ctx.stroke();
        ctx.restore();
      }
    }

    // Chassis silhouette by VehicleType
    ctx.fillStyle=color;
    ctx.strokeStyle=showOutline?'#ffffff':'#0c1926';
    ctx.lineWidth=showOutline?2.1:1.1;
    ctx.beginPath();
    if(vehicleType===0){
      // Formula open-wheel: rear wing, sidepods, tapered nose, front wing
      ctx.moveTo(-13.5,-6.2);ctx.lineTo(-9.5,-6.2);ctx.lineTo(-9.5,-3.6);
      ctx.lineTo(-6.5,-5.5);ctx.lineTo(3.5,-4.8);ctx.lineTo(8.5,-2.0);
      ctx.lineTo(13.5,-5.8);ctx.lineTo(15.2,-5.8);ctx.lineTo(15.2,5.8);
      ctx.lineTo(13.5,5.8);ctx.lineTo(8.5,2.0);ctx.lineTo(3.5,4.8);
      ctx.lineTo(-6.5,5.5);ctx.lineTo(-9.5,3.6);ctx.lineTo(-9.5,6.2);
      ctx.lineTo(-13.5,6.2);ctx.closePath();
    }else if(vehicleType===1){
      // Rally car: wide-body hatchback
      ctx.roundRect(-12.5,-6.3,25,12.6,3.4);
    }else if(vehicleType===2){
      // Snowmobile: sleek sled cowling
      ctx.moveTo(-11.5,-4.6);ctx.lineTo(5.5,-5.0);ctx.lineTo(13.5,0);
      ctx.lineTo(5.5,5.0);ctx.lineTo(-11.5,4.6);ctx.closePath();
    }else{
      // Truck: heavy cab + cargo bed
      ctx.roundRect(-14,-7.0,28,14.0,2.4);
    }
    ctx.fill();ctx.stroke();

    // Cockpit / roof / bed details
    ctx.fillStyle='#0c1725';
    if(vehicleType===0){
      ctx.beginPath();ctx.ellipse(-0.5,0,4.2,2.3,0,0,Math.PI*2);ctx.fill();
      // Halo bar
      ctx.strokeStyle='#d7e8f2';ctx.lineWidth=1.1;
      ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(3.4,0);ctx.stroke();
    }else if(vehicleType===1){
      // Windshield + roof scoop + rear spoiler
      ctx.beginPath();ctx.roundRect(-4.5,-4.6,10.5,9.2,2);ctx.fill();
      ctx.fillStyle='#ffffff';ctx.beginPath();ctx.arc(0.5,0,1.4,0,Math.PI*2);ctx.fill();
      ctx.fillStyle='#101d2c';ctx.fillRect(-12,-5.6,2.4,11.2);
    }else if(vehicleType===2){
      ctx.beginPath();ctx.roundRect(-4,-2.8,8,5.6,1.6);ctx.fill();
    }else{
      // Truck bed & cab windshield
      ctx.fillRect(-12.2,-5.4,11,10.8);
      ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
    }

    // The recorded active flag controls the inactive badge.
    if(!active){
      ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
      ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
    }
    ctx.restore();
  }


  function hash(value,label){
    if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
    return value.toLowerCase();
  }
  const CONTROL_KEYS=['steering','acceleration','handbrake','brake','boost'];
  const SCALAR_SENSORS=['speed','velocityFront','velocitySide','angularVelocity','wheelAngle','boostCapacity','correctDirection'];
  function exactKeys(value,keys,label){
    if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Invalid '+label);
  }
  function finiteValue(value,label,limit=1e12){if(typeof value!=='number'||!Number.isFinite(value)||Math.abs(value)>limit)throw Error('Invalid '+label);return value;}
  function sensorDescriptor(sensor){
    const kind=sensor?.$type,keys=SCALAR_SENSORS.includes(kind)?[]:({raycast:['Degrees','Length'],accelerationFront:['MaxAcceleration'],accelerationSide:['MaxAcceleration'],distanceFromWall:['MaxDistance'],trackCurvature:['MinLookaheadDistance','MaxLookaheadDistance'],grip:['PositionOffset']})[kind];
    if(!keys)throw Error('Unsupported recorded sensor');exactKeys(sensor,['$type',...keys],'sensor descriptor');
    const clean={$type:kind};
    for(const key of keys){
      if(key==='PositionOffset'){exactKeys(sensor[key],['X','Y'],'grip offset');clean[key]={X:finiteValue(sensor[key].X,'grip X',1e6),Y:finiteValue(sensor[key].Y,'grip Y',1e6)};}
      else clean[key]=finiteValue(sensor[key],'sensor '+key,1e6);
    }
    if(kind==='raycast'&&(Math.abs(clean.Degrees)>360||clean.Length<1||clean.Length>10000))throw Error('Invalid ray parameters');
    if(keys.includes('MaxAcceleration')&&clean.MaxAcceleration<=0||kind==='distanceFromWall'&&clean.MaxDistance<=0)throw Error('Invalid sensor normalization');
    return clean;
  }
  function recordingTelemetry(payload){
    if(payload.version===1)return null;
    if(payload.policy_weights_verified!==true||payload.sampling_method!=='single_native_bench_trace'||payload.population!==1||payload.warmup_ticks!==0||payload.seed!==0)throw Error('Unsupported native recording conditions');
    const value=payload.telemetry;exactKeys(value,['source','state_phase','decision_phase','sensors','output_ids','controls'],'telemetry');
    if(value.source!=='native_cached_inference'||value.state_phase!=='post_physics'||value.decision_phase!=='pre_physics_at_decision_tick'||value.controls!=='raw_network_commands_and_native_normalized_controls')throw Error('Unsupported telemetry phases');
    if(!Array.isArray(value.sensors)||!value.sensors.length||value.sensors.length>20)throw Error('Invalid recorded sensor count');
    const sensors=value.sensors.map(sensorDescriptor),ids=value.output_ids;
    if(sensors.filter(x=>x.$type==='raycast').length>13||sensors.filter(x=>x.$type!=='raycast').length>7)throw Error('Invalid sensor layout');
    if(!Array.isArray(ids)||!ids.length||ids.length>5||new Set(ids).size!==ids.length||ids.some(x=>!Number.isInteger(x)||x<0||x>4)||!ids.includes(0)||!ids.includes(1)||payload.vehicle_type===2&&ids.includes(2))throw Error('Invalid recorded output slots');
    return {...value,sensors,output_ids:[...ids]};
  }
  function recordedControls(value,label){
    exactKeys(value,CONTROL_KEYS,label);return Object.fromEntries(CONTROL_KEYS.map(key=>[key,finiteValue(value[key],label,1)]));
  }
  function recordingEvents(recording){
    const result=[];
    recording.frames.forEach((frame,index)=>{
      const previous=recording.frames[index-1],from=previous?.tick??frame.tick;
      if(frame.collision_count!==undefined){
        const count=previous?.collision_count===undefined?frame.collision_count:frame.collision_count-previous.collision_count;
        if(count>0)result.push({from_tick:from,to_tick:frame.tick,kind:'contacts',count,label:previous?`${count} contact(s) recorded in (${from}, ${frame.tick}]`:`${count} contact(s) at first sample ${frame.tick}`});
      }
      if(previous&&previous.active!==frame.active)result.push({from_tick:from,to_tick:frame.tick,kind:'active',label:`${frame.active?'Active':'Inactive'} by sample ${frame.tick}; changed in (${from}, ${frame.tick}]`});
    });return result;
  }
  function adjacentSampleTick(recording,tick,direction){
    if(direction!==-1&&direction!==1||!Number.isFinite(tick))throw Error('Invalid sample step');
    const frames=recording.frames;
    return direction>0?(frames.find(frame=>frame.tick>tick)?.tick??frames[frames.length-1].tick):([...frames].reverse().find(frame=>frame.tick<tick)?.tick??frames[0].tick);
  }
  function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
    if(!payload||payload.schema!=='ald.recorded-replay'||![1,2].includes(payload.version)||payload.kind!=='fresh_cpu_evaluation')
      throw Error('This file is not a supported recorded CPU evaluation');
    if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
       payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
      throw Error('Recorded evaluation conditions are unsupported');
    const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
    if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
      throw Error('The recording does not match the selected track and network');
    const ticks=payload.requested_ticks;
    if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
    if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
    const expectedTicks=[1];
    for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
    if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
    if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
      throw Error('Recording is missing its expected native samples');
    const telemetry=recordingTelemetry(payload);
    const frames=payload.frames.map((frame,index)=>{
      if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
      const copy={tick:frame.tick,active:frame.active};
      for(const key of ['x','y','rotation','vx','vy']){
        if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
        copy[key]=frame[key];
      }
      for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
        if(frame[key]===undefined)continue;
        if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
        if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
        copy[key]=frame[key];
      }
      if(frame.metrics!==undefined){
        if(!frame.metrics||typeof frame.metrics!=='object'||Array.isArray(frame.metrics)||Object.keys(frame.metrics).length>64)throw Error('Invalid recorded metrics');
        copy.metrics=Object.fromEntries(Object.entries(frame.metrics).map(([key,value])=>{if(!key.length||key.length>96)throw Error('Invalid metric name');return [key,value===null?null:finiteValue(value,'metric')];}));
      }
      if(telemetry){
        copy.controls=recordedControls(frame.controls,'control commands');copy.applied_controls=recordedControls(frame.applied_controls,'applied controls');
        for(const key of CONTROL_KEYS){const expected=['boost','handbrake'].includes(key)?Math.max(0,copy.controls[key]):copy.controls[key];if(copy.applied_controls[key]!==expected)throw Error('Control normalization differs');}
        if(frame.decision===null)copy.decision=null;
        else{
          exactKeys(frame.decision,['tick','inputs'],'recorded decision');
          if(!Number.isInteger(frame.decision.tick)||frame.decision.tick<1||frame.decision.tick>frame.tick||!Array.isArray(frame.decision.inputs)||frame.decision.inputs.length!==telemetry.sensors.length)throw Error('Invalid decision tick or sensor vector');
          copy.decision={tick:frame.decision.tick,inputs:frame.decision.inputs.map(value=>finiteValue(value,'sensor input'))};
        }
      }
      return copy;
    });
    for(let i=1;i<frames.length;i++){
      if(frames[i].collision_count!==undefined&&frames[i-1].collision_count!==undefined&&frames[i].collision_count<frames[i-1].collision_count)throw Error('Contact counter moved backwards');
      if(telemetry&&frames[i-1].decision&&(frames[i].decision===null||frames[i].decision.tick<frames[i-1].decision.tick))throw Error('Decision clock moved backwards');
    }
    return {schema:payload.schema,version:payload.version,telemetry,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
      first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
      requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
  }
  function sampleRecording(recording,tick){
    if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
    const frames=recording.frames,last=frames.length-1;
    const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
    let lo=0,hi=last;
    while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
    const a=frames[lo],b=frames[Math.min(last,lo+1)];
    const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
    const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
    // Pose interpolation is only between adjacent recorded samples. HUD values
    // remain the actual preceding sample, never reconstructed controls or rays.
    return {tick:at,x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,rotation:a.rotation+angle*t,frame:{...a}};
  }
  function viewport(canvas,track){
    const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
    const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
    return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
  }
  function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
  function mount({document,fetch,picker}){
    const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
    if(!canvas||!status||!legend||!picker)return;
    const element=id=>document.getElementById(id);
    let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
    let reference=null,referenceBusy=false,referenceWorker=null;
    let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
    let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
    let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
    const layers={outline:true,line:true,hud:true};
    let smoothPose=false,blackboxTick=null;
    const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
    const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
    function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
    function syncControls(reason){
      if(element('reference_calculate'))element('reference_calculate').disabled=!currentTrack||referenceBusy;
      if(element('reference_show'))element('reference_show').disabled=!reference;
      const last=recording?.frames[recording.frames.length-1].tick;
      const play=element('replay_play');
      if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
      for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud','replay_interpolate','replay_prev','replay_next','replay_event']){
        const node=element(id);if(node)node.disabled=!recording;
      }
      if(element('replay_prev'))element('replay_prev').disabled=!recording||cursor<=recording.frames[0].tick;
      if(element('replay_next'))element('replay_next').disabled=!recording||cursor>=last;
      if(element('replay_event'))element('replay_event').disabled=!recording||!recordingEvents(recording).length;
      if(element('replay_blackbox'))element('replay_blackbox').hidden=!recording;
      const scrub=element('replay_scrub');
      if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
      if(!recording){
        setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
        setText('replay_time_label','No recorded samples');
        setText('replay_readout','Record a drive to inspect position and speed. Native controls and sensor inputs appear after a version 2 recording is loaded.');
      }else{
        const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
        setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${smoothPose?'Pose interpolation on.':'Measured sample poses.'} ${state}`);
      }
    }
    function rows(id,entries){
      const body=element(id);if(!body)return;
      const nodes=entries.map(cells=>{const row=document.createElement('tr');cells.forEach((value,index)=>{const cell=document.createElement(index===0?'th':'td');cell.textContent=String(value);row.append(cell);});return row;});body.replaceChildren(...nodes);
    }
    function blackbox(frame){
      if(blackboxTick===frame.tick)return;blackboxTick=frame.tick;
      const number=value=>value===undefined||value===null?'Unavailable':typeof value==='number'?Number(value.toPrecision(8)).toString():String(value);
      const decision=frame.decision;
      setText('blackbox_phase',`Post-physics snapshot: tick ${frame.tick}. ${decision?`Native decision: tick ${decision.tick}, pre-physics${decision.tick<frame.tick?' (held from an earlier tick)':''}.`:recording.telemetry?'No inference captured before this snapshot.':'Legacy recording: controls and sensors unavailable.'}`);
      rows('blackbox_state',[
        ['Snapshot time',(frame.tick/60).toFixed(3)+' s'],['Position X / Y',number(frame.x)+' / '+number(frame.y)+' game units'],
        ['Velocity X / Y',number(frame.vx)+' / '+number(frame.vy)+' game units/s'],['Speed',number(Math.hypot(frame.vx,frame.vy))+' game units/s'],
        ['Heading',number(frame.rotation)+' rad'],['Angular velocity',number(frame.angular_velocity)+' rad/s'],['Boost energy',number(frame.boost_energy)],
        ['Physical contacts',number(frame.collision_count)],['Active',frame.active?'Yes':'No'],['Statistics updates',number(frame.update_count)]
      ]);
      rows('blackbox_controls',recording.telemetry?CONTROL_KEYS.map((key,index)=>[key,number(frame.controls[key]),number(frame.applied_controls[key]),recording.telemetry.output_ids.includes(index)?'Policy output':'Native default (no output slot)']):[['Unavailable','Not captured in this recording','Unavailable','Unavailable']]);
      rows('blackbox_inputs',recording.telemetry?recording.telemetry.sensors.map((sensor,index)=>[index,sensor.$type,JSON.stringify(Object.fromEntries(Object.entries(sensor).filter(([key])=>key!=='$type'))),decision?number(decision.inputs[index]):'Not captured yet']):[['Unavailable','Legacy recording','No input capture','Unavailable']]);
      rows('blackbox_metrics',Object.entries(frame.metrics??{}).map(([key,value])=>[key,number(value)]));
    }
    function eventOptions(){
      const select=element('replay_event');if(!select)return;select.replaceChildren();
      const empty=document.createElement('option');empty.value='';empty.textContent=recording?'Choose contact / active-state interval':'No recording';select.append(empty);
      if(recording)recordingEvents(recording).forEach(event=>{const option=document.createElement('option');option.value=String(event.to_tick);option.textContent=event.label;select.append(option);});
    }
    function stopPlayback(){
      if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
      animId=null;playing=false;lastFrameTime=null;
    }
    function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
    function draw(){
      if(!currentTrack){renderEmpty();return;}
      const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
      let state=recording?sampleRecording(recording,cursor):null;
      if(state&&!smoothPose)state={...state,x:state.frame.x,y:state.frame.y,rotation:state.frame.rotation};
      const position=state?worldPoint(view,state.x,state.y):null;
      ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
      ctx.save();
      if(position&&cameraMode==='follow'){
        ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
      }
      if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
      if(reference&&element('reference_show')?.checked){
        ctx.beginPath();
        reference.points.forEach((p,i)=>{const xy=worldPoint(view,...p);if(i)ctx.lineTo(...xy);else ctx.moveTo(...xy);});
        ctx.closePath();ctx.strokeStyle='#ffd073';ctx.lineWidth=2.5;ctx.setLineDash([7,5]);ctx.stroke();ctx.setLineDash([]);
      }
      if(recording){
        if(layers.line){
          ctx.beginPath();
          recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
          ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
        }
        // Native rotation zero faces -Y; the vehicle illustration faces +X.
        drawVehicle2D(ctx,...position,state.rotation-Math.PI/2,recording.vehicle_type,'#62dfb1',Math.max(0.55,Math.min(1.1,view.size/120)),layers.outline,state.frame.active);
      }
      ctx.restore();
      if(state){
        const frame=state.frame;blackbox(frame);const speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
        const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
        const deviation=referenceDistance([frame.x,frame.y],reference);
        setText('reference_deviation',deviation?`Measured position is ${(deviation.distance/15).toFixed(2)} m from the nearest suggested-route segment. Distance alone does not measure driving quality.`:'');
        setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Measured tick ${frame.tick}`);
        setText('replay_readout',`Recorded sample tick ${frame.tick} (${(frame.tick/60).toFixed(2)}s). Speed ${speed.toFixed(1)} km/h, derived from recorded velocity. Position (${frame.x.toFixed(2)}, ${frame.y.toFixed(2)}). Contacts ${contacts}. ${frame.active?'Active':'Inactive'} at this sample. ${frame.decision?'Native controls and inputs captured at tick '+frame.decision.tick+'.':'No captured decision at this sample.'}`);
        if(layers.hud){
          ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
          ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
          ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
          ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText(frame.decision?'Decision tick '+frame.decision.tick+' · native controls + inputs':'No captured decision at this sample',24,77);ctx.restore();
        }
      }
    }
    function clearRecording(reason){
      const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;blackboxTick=null;eventOptions();
      setText('reference_deviation','');
      if(hadRecording)draw();syncControls(reason);
    }
    function step(now){
      animId=null;if(!playing||!recording)return;
      if(document.hidden){stopPlayback();syncControls();return;}
      if(lastFrameTime===null)lastFrameTime=now;
      const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
      const last=recording.frames[recording.frames.length-1].tick;
      cursor=Math.min(last,cursor+elapsed*60*playSpeed);
      draw();
      if(cursor>=last){stopPlayback();syncControls();return;}
      syncControls();animId=root.requestAnimationFrame(step);
    }
    function bind(){
      const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
      on('reference_calculate','click',async()=>{
        if(!currentTrack||referenceBusy)return;
        const track=currentTrack,request=version;referenceBusy=true;syncControls();setText('reference_status','Calculating suggested route…');
        await new Promise(resolve=>typeof root?.setTimeout==='function'?root.setTimeout(resolve,0):resolve());
        if(request!==version)return;
        try{
          if(typeof root?.Worker==='function'&&typeof root?.URL?.createObjectURL==='function'){
            const source=`const MaskPlanner=${maskPlanner.workerSource()};onmessage=event=>{try{postMessage({line:MaskPlanner.line(event.data)});}catch(error){postMessage({error:error.message});}};`;
            const url=root.URL.createObjectURL(new Blob([source],{type:'application/javascript'}));
            let worker;
            try{
              worker=new root.Worker(url);
              const line=await new Promise((resolve,reject)=>{
                referenceWorker={worker,cancel:()=>reject(Error('Track changed'))};
                worker.onmessage=event=>event.data.error?reject(Error(event.data.error)):resolve(event.data.line);
                worker.onerror=()=>reject(Error('Browser could not calculate the suggested route'));
                worker.postMessage(track);
              });
              if(request!==version)return;
              reference=line;
            }finally{worker?.terminate();root.URL.revokeObjectURL(url);if(referenceWorker?.worker===worker)referenceWorker=null;}
          }else reference=referenceLine(track);
          if(request!==version)return;
          setText('reference_status',`Suggested route ready · ${reference.route_tiles} connected pieces. A geometric guide for orientation; vehicle dynamics are not modeled.`);
        }catch(error){if(request===version){reference=null;setText('reference_status','Suggested route unavailable: '+error.message);}}
        finally{if(request===version){referenceBusy=false;draw();syncControls();}}
      });
      on('reference_show','change',draw);
      on('replay_play','click',()=>{
        if(!recording)return;
        if(playing){stopPlayback();syncControls();return;}
        if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
        playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
      });
      on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
      on('replay_scrub','input',()=>{
        if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
        stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
      });
      for(const [id,direction]of [['replay_prev',-1],['replay_next',1]])on(id,'click',()=>{if(!recording)return;stopPlayback();cursor=adjacentSampleTick(recording,cursor,direction);draw();syncControls();});
      on('replay_event','change',()=>{if(!recording||!element('replay_event').value)return;stopPlayback();cursor=Number(element('replay_event').value);draw();syncControls();});
      on('replay_interpolate','change',()=>{smoothPose=element('replay_interpolate').checked;draw();syncControls();});
      on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
      on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
      for(const key of ['outline','line','hud']){
        const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
        on(id,'change',()=>{layers[key]=element(id).checked;draw();});
      }
      document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
    }
    async function refresh(){
      if(referenceWorker){referenceWorker.worker.terminate();referenceWorker.cancel();referenceWorker=null;}
      const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;reference=null;referenceBusy=false;
      setText('reference_status','Calculate an optional route for general orientation, then compare it with the recorded car path.');setText('reference_deviation','');syncControls();
      const info=picker.info();
      const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
      if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
      status.textContent='Loading '+source+'…';
      try{
        atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
          if(!r.ok)throw Error('Track atlas unavailable');return r.json();
        });
        const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
        if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
        const hashPromise=root?.crypto?.subtle?file.arrayBuffer().then(bytes=>root.crypto.subtle.digest('SHA-256',bytes)).then(bytes=>Array.from(new Uint8Array(bytes),n=>n.toString(16).padStart(2,'0')).join('')):Promise.resolve(null);
        const [saved,sha]=await Promise.all([decode(file),hashPromise]);
        const track=model(saved,atlas);
        if(current!==version)return;
        currentTrack=track;currentTrackSha=sha;
        render(canvas,track);
        const off=document.createElement?.('canvas');
        if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
        const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
        status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
        legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Dark shapes show static walls. Road shape follows native part geometry. Dashed yellow: mask-derived suggestion. Green: recorded drive. Vehicle positions appear only from a recorded replay.';
        syncControls();
      }catch(error){
        if(current!==version)return;
        currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
        status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
      }
    }
    const controller={
      refresh,
      setStagedNetwork(){clearRecording();},
      setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
      focusCandidate(){clearRecording();},
      clearRecording,
      setRecording(payload,expectedTrackSha,expectedNetworkSha){
        clearRecording();
        try{
          const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
          if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
          if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
          recording=next;blackboxTick=null;cursor=next.frames[0].tick;eventOptions();draw();syncControls();
          return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
        }catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
      },
    };
    if(typeof root==='object'&&root)root.TrackReplay=controller;
    bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
    return controller;
  }
  return {mount,model,decode,render,referenceLine,referenceDistance,validateRecording,sampleRecording,recordingEvents,adjacentSampleTick};
});