Replace synthetic viewer telemetry with recorded native drives

#5
by TiGa-RCE - opened
Files changed (7) hide show
  1. FRONTEND_SOURCE.json +9 -8
  2. RELEASE_MANIFEST.json +6 -5
  3. client.js +69 -5
  4. index.html +12 -2
  5. replay.py +287 -0
  6. server.py +40 -0
  7. track-map.js +180 -952
FRONTEND_SOURCE.json CHANGED
@@ -5,19 +5,20 @@
5
  "source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
6
  "source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8"
7
  },
8
- "client_sha256": "e42cd311e5a16baeec9caa8909ad1b8e08532b10cab320c14bef8a47d7101b53",
9
  "identity_note": "The historical source fields describe the base frontend; local packaged file hashes describe this candidate's derived frontend.",
10
- "local_customization_id": "v1.2-measured-capacity",
11
- "output_sha256": "16c6a720e3383e23aca7065466b25d573e50b0ea625fcca00e10d6844c9161b7",
12
  "packaged_frontend_files": {
13
  "assets/track-atlas.json": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
14
- "client.js": "e42cd311e5a16baeec9caa8909ad1b8e08532b10cab320c14bef8a47d7101b53",
15
  "help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
16
  "hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
17
- "index.html": "16c6a720e3383e23aca7065466b25d573e50b0ea625fcca00e10d6844c9161b7",
18
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
19
- "track-map.js": "c002c18bc6932af274ec2a0d722662588a27f0afe8af9957ca892b6bd63ae0ae"
20
  },
21
- "packaged_frontend_sha256": "cf14ef732f3b2b3a2360aa91f2bf7fd6e4d2edfeb5cbecbe37d601d591412876",
22
- "status": "Local measured-capacity candidate; hosted qualification pending"
 
23
  }
 
5
  "source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
6
  "source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8"
7
  },
8
+ "client_sha256": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
9
  "identity_note": "The historical source fields describe the base frontend; local packaged file hashes describe this candidate's derived frontend.",
10
+ "local_customization_id": "native-snapshot-replay-v1",
11
+ "output_sha256": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
12
  "packaged_frontend_files": {
13
  "assets/track-atlas.json": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
14
+ "client.js": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
15
  "help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
16
  "hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
17
+ "index.html": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
18
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
19
+ "track-map.js": "9ad6bcfc4aa9e684d395aadf28f770431837415a50d9c2df19098dec57c436c2"
20
  },
21
+ "packaged_frontend_sha256": "3b7a107ccc288cb2b187590d1b4d4b80fd04c3b450b4f12dc9f71d99c84dedd1",
22
+ "packaged_frontend_sha256_format": "SHA-256 of UTF-8 JSON for packaged_frontend_files, sorted keys, compact separators, no trailing newline",
23
+ "status": "Recorded CPU drive viewer"
24
  }
RELEASE_MANIFEST.json CHANGED
@@ -7,7 +7,7 @@
7
  "CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
8
  "DATA_POLICY.md": "fc5978528abe1c21c14882417ac8a706e04a0831bbd3fdb3de8c64442c1a7b3d",
9
  "FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
10
- "FRONTEND_SOURCE.json": "757dc50e6e1e8ae47be23220da9c4e07ed05cdf3078804598e1803aeb9bd8f75",
11
  "GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
12
  "IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
13
  "LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
@@ -59,13 +59,13 @@
59
  "builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
60
  "builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
61
  "builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
62
- "client.js": "e42cd311e5a16baeec9caa8909ad1b8e08532b10cab320c14bef8a47d7101b53",
63
  "delivery.py": "6bdba2e87c3e19a41440cb16103695af27105f8bafbae38948bbb1b936ed0c1b",
64
  "gpu_tickets.py": "20928fcf1ca24648809ad8712c929377bdcc8b5e6b4387bfff3503ec5711928b",
65
  "help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
66
  "hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
67
  "hf_identity.py": "913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37",
68
- "index.html": "16c6a720e3383e23aca7065466b25d573e50b0ea625fcca00e10d6844c9161b7",
69
  "native/analysis.patch": "42f79de67cb0734cffb2a7582f5479cab42ab82675884b8b42b9f158d46a98e8",
70
  "native/build-receipt.json": "559a7e4e382f9f7caa1c5ceb566a5a0bbefad2ed3a2e2cf604e31c9c3890c348",
71
  "native/physics-build-receipt.json": "63561c108554a09884cc68a1bfadfb8a3f530a800de0700ff671d99e558609fe",
@@ -78,12 +78,13 @@
78
  "native/source-receipt.json": "36feba861501baf8672884476bbeed5e375254cbb4feffb9f4f90aa61a415a93",
79
  "native/upstream-v1.3.patch": "a9810c9abd40ddcbac5fcf6914320d5fc04fcde23849df9437ec293c83be15ed",
80
  "portable_bundle.py": "58df5b87956745ea909c41c4b12a23cf9ab8c40a7df47d1529f47ae4de9bf51f",
 
81
  "requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
82
- "server.py": "803d1f9d639c47e032c3a27f040763a3bb358145c65e0eb012f7a3863fbfa0ee",
83
  "star_check.py": "5c17080957b81ffccf0de8314635b471562e55c92c97a1207d9034a99279b2c3",
84
  "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
85
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
86
- "track-map.js": "c002c18bc6932af274ec2a0d722662588a27f0afe8af9957ca892b6bd63ae0ae",
87
  "training_config.py": "6eb7282bbee3b3a6273bddc29c949d9904067414c26e501d815012f033173a1c",
88
  "vendor/gradio-client/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
89
  "vendor/gradio-client/dist/index.min.js": "fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd",
 
7
  "CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
8
  "DATA_POLICY.md": "fc5978528abe1c21c14882417ac8a706e04a0831bbd3fdb3de8c64442c1a7b3d",
9
  "FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
10
+ "FRONTEND_SOURCE.json": "9e4944337ba6d0dc420de9966cf898f305273320a69d23e62c5263e5a47e9ffa",
11
  "GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
12
  "IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
13
  "LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
 
59
  "builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
60
  "builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
61
  "builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
62
+ "client.js": "19b2a095dd40d91104cc1dc8e5ef3423f88683d57a7a884c490c35d236b2f15e",
63
  "delivery.py": "6bdba2e87c3e19a41440cb16103695af27105f8bafbae38948bbb1b936ed0c1b",
64
  "gpu_tickets.py": "20928fcf1ca24648809ad8712c929377bdcc8b5e6b4387bfff3503ec5711928b",
65
  "help.html": "58959e994a8e4bbd3c70b2a6256cb699ac615cde718c6d0288fabeee909098a2",
66
  "hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
67
  "hf_identity.py": "913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37",
68
+ "index.html": "2c3926b1b4f63eaf219a4a62094c1098df5fc713a7a89a5e93ad44cabdd0e191",
69
  "native/analysis.patch": "42f79de67cb0734cffb2a7582f5479cab42ab82675884b8b42b9f158d46a98e8",
70
  "native/build-receipt.json": "559a7e4e382f9f7caa1c5ceb566a5a0bbefad2ed3a2e2cf604e31c9c3890c348",
71
  "native/physics-build-receipt.json": "63561c108554a09884cc68a1bfadfb8a3f530a800de0700ff671d99e558609fe",
 
78
  "native/source-receipt.json": "36feba861501baf8672884476bbeed5e375254cbb4feffb9f4f90aa61a415a93",
79
  "native/upstream-v1.3.patch": "a9810c9abd40ddcbac5fcf6914320d5fc04fcde23849df9437ec293c83be15ed",
80
  "portable_bundle.py": "58df5b87956745ea909c41c4b12a23cf9ab8c40a7df47d1529f47ae4de9bf51f",
81
+ "replay.py": "cce5dadc682dafadaa558b49fe1dac735e225fc26881566f59f6e59447b9ab4f",
82
  "requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
83
+ "server.py": "5656f969f09d171a0eb257b004191bb84f2a365ed2552dacbe3ec77b4ab77294",
84
  "star_check.py": "5c17080957b81ffccf0de8314635b471562e55c92c97a1207d9034a99279b2c3",
85
  "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
86
  "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
87
+ "track-map.js": "9ad6bcfc4aa9e684d395aadf28f770431837415a50d9c2df19098dec57c436c2",
88
  "training_config.py": "6eb7282bbee3b3a6273bddc29c949d9904067414c26e501d815012f033173a1c",
89
  "vendor/gradio-client/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
90
  "vendor/gradio-client/dist/index.min.js": "fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd",
client.js CHANGED
@@ -2,6 +2,9 @@
2
  const $ = id => document.getElementById(id);
3
  let job = null;
4
  let busy = false;
 
 
 
5
  let localResult = null;
6
  let localAnalysis = null;
7
  let pairedAnalysisPending = false;
@@ -58,7 +61,7 @@ function batchRunContext(){
58
  const trackInfo=trackLibrary?.info()??{source:'upload'},trackFile=$('track').files[0],network=networkSource(),backend=$('backend').value;
59
  const hasTrack=trackInfo.source==='builtin'||!!trackFile;
60
  let reason='';
61
- if(busy||bundleRestoring||bucketOperation)reason='Finish the current operation before starting a batch.';
62
  else if(job)reason='Finish or delete the current result before starting a batch.';
63
  else if(!capabilitiesReady)reason='Connect to the training service before running a batch.';
64
  else if($('run_mode')?.value==='evaluate')reason='Under Action in Run & results, choose Train a population before running a batch.';
@@ -70,7 +73,7 @@ function batchRunContext(){
70
  network:network?.name||(network?'Selected network':null),backend:backend.toUpperCase()};
71
  }
72
  function buttons() {
73
- const settingsUnavailable=busy||bundleRestoring||bucketOperation;
74
  const unavailable=settingsUnavailable||!!job;
75
  trackLibrary?.setBusy(unavailable);
76
  window.HFAuth?.setBusy(unavailable);
@@ -87,6 +90,13 @@ function buttons() {
87
  $('forgetLocal').disabled = busy || !localResult;
88
  $('track').disabled=unavailable;
89
  $('network').disabled=unavailable;
 
 
 
 
 
 
 
90
  if($('batch_start')){
91
  const context=batchRunContext();
92
  window.setBatchRunContext?.(context);
@@ -438,7 +448,7 @@ function saveLocal() {
438
  setTimeout(()=>URL.revokeObjectURL(url),60000);
439
  }
440
  $('saveLocal').onclick=saveLocal;
441
- $('forgetLocal').onclick=()=>{localResult=null;localAnalysis=null;pairedAnalysisPending=false;renderAnalysisPanel();buttons();say('Browser result discarded. Downloaded files are unaffected.');};
442
  async function request(path, options = {}) {
443
  const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
444
  if (!response.ok) {
@@ -455,19 +465,71 @@ async function encoded(file) {
455
  }
456
  const headers = () => ({'X-Job-Key':job.key});
457
  const networkSource=()=>stagedNetwork || $('network').files[0];
 
 
 
 
 
 
 
458
  window.addEventListener('ald:batch-draft-change',buttons);
459
  document.addEventListener('track-library-change',buttons);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  async function refreshFreezeNetwork(file){
 
461
  if(!file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);return;}
462
  try{const network=await validateNetworkBytes(new Uint8Array(await file.arrayBuffer()));if(networkSource()===file){window.setFreezeNetwork?.(network);window.TrackReplay?.setStagedNetwork?.(network);}}
463
  catch(error){if(networkSource()===file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);if($('freeze_status'))$('freeze_status').textContent='Network metadata unavailable: '+error.message;}}
464
  }
465
  $('network').onchange=()=>{stagedNetwork=null;selectedWinner=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';refreshFreezeNetwork(networkSource());buttons();};
466
  window.TrainingClient={
467
- isBusy:()=>busy||!!job||bundleRestoring,
468
  hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
469
  useNetwork(file){
470
- if(busy||job)throw Error('Finish or delete the current result before changing the network');
471
  stagedNetwork=file;$('network').value='';
472
  if($('networkChoice'))$('networkChoice').textContent='Browser editor input: '+file.name+'. Not uploaded yet.';
473
  refreshFreezeNetwork(file);
@@ -961,6 +1023,8 @@ function showDetails(r){
961
  window.TrackReplay?.setRunAnalysis?.(localAnalysis,runReceipt||portableReceipt||(r?{report:r}:null));
962
  }
963
  $('run').onclick = async () => {
 
 
964
  const comparisonAttempt=!!comparisonPlan,comparisonRole=comparisonAttempt?comparisonPlan.roles[comparisonPlan.index]:null;
965
  const previousRunResultContext=runResultContext;
966
  cancelSteamLeaderboard();
 
2
  const $ = id => document.getElementById(id);
3
  let job = null;
4
  let busy = false;
5
+ let replayBusy = false;
6
+ let localReplay = null;
7
+ let replayRequestId = 0;
8
  let localResult = null;
9
  let localAnalysis = null;
10
  let pairedAnalysisPending = false;
 
61
  const trackInfo=trackLibrary?.info()??{source:'upload'},trackFile=$('track').files[0],network=networkSource(),backend=$('backend').value;
62
  const hasTrack=trackInfo.source==='builtin'||!!trackFile;
63
  let reason='';
64
+ if(busy||replayBusy||bundleRestoring||bucketOperation)reason='Finish the current operation before starting a batch.';
65
  else if(job)reason='Finish or delete the current result before starting a batch.';
66
  else if(!capabilitiesReady)reason='Connect to the training service before running a batch.';
67
  else if($('run_mode')?.value==='evaluate')reason='Under Action in Run & results, choose Train a population before running a batch.';
 
73
  network:network?.name||(network?'Selected network':null),backend:backend.toUpperCase()};
74
  }
75
  function buttons() {
76
+ const settingsUnavailable=busy||replayBusy||bundleRestoring||bucketOperation;
77
  const unavailable=settingsUnavailable||!!job;
78
  trackLibrary?.setBusy(unavailable);
79
  window.HFAuth?.setBusy(unavailable);
 
90
  $('forgetLocal').disabled = busy || !localResult;
91
  $('track').disabled=unavailable;
92
  $('network').disabled=unavailable;
93
+ if($('replay_record'))$('replay_record').disabled=unavailable||batchActive||!capabilitiesReady||!replayNetworkSource();
94
+ if($('replay_duration'))$('replay_duration').disabled=settingsUnavailable;
95
+ if($('replay_download'))$('replay_download').disabled=replayBusy||!localReplay;
96
+ if($('replay_policy')){
97
+ const selected=networkSource();
98
+ $('replay_policy').textContent=selected?'Policy: '+(selected.name||'Selected network'):localResult?'Policy: latest received result. Choose a winner below to record a different policy.':'Choose or create a network to record a drive.';
99
+ }
100
  if($('batch_start')){
101
  const context=batchRunContext();
102
  window.setBatchRunContext?.(context);
 
448
  setTimeout(()=>URL.revokeObjectURL(url),60000);
449
  }
450
  $('saveLocal').onclick=saveLocal;
451
+ $('forgetLocal').onclick=()=>{invalidateReplay();localResult=null;localAnalysis=null;pairedAnalysisPending=false;renderAnalysisPanel();buttons();say('Browser result discarded. Downloaded files are unaffected.');};
452
  async function request(path, options = {}) {
453
  const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
454
  if (!response.ok) {
 
465
  }
466
  const headers = () => ({'X-Job-Key':job.key});
467
  const networkSource=()=>stagedNetwork || $('network').files[0];
468
+ function replayNetworkSource(){return networkSource()||localResult;}
469
+ function invalidateReplay(reason){
470
+ replayRequestId++;
471
+ localReplay=null;
472
+ window.TrackReplay?.clearRecording?.(reason);
473
+ if($('replay_download'))$('replay_download').disabled=true;
474
+ }
475
  window.addEventListener('ald:batch-draft-change',buttons);
476
  document.addEventListener('track-library-change',buttons);
477
+ document.addEventListener('track-library-change',()=>invalidateReplay('Track changed. Record a new drive for this track.'));
478
+ if($('replay_record'))$('replay_record').onclick=async()=>{
479
+ if(busy||replayBusy||job||batchActive||bundleRestoring||bucketOperation)return;
480
+ replayBusy=true;busy=true;
481
+ invalidateReplay('Recording a fresh CPU drive…');
482
+ const attempt=replayRequestId;
483
+ const controller=new AbortController();
484
+ const timeout=setTimeout(()=>controller.abort(),40000);
485
+ buttons();
486
+ try{
487
+ const track=await trackSource(),network=replayNetworkSource();
488
+ if(!track||!network)throw Error('Choose a track and a network before recording.');
489
+ const ticks=Number($('replay_duration').value);
490
+ if(![300,900].includes(ticks))throw Error('Choose a 5-second or 15-second recording.');
491
+ const [trackBytes,networkBytes]=await Promise.all([track.arrayBuffer(),network.arrayBuffer()]);
492
+ await validateNetworkBytes(new Uint8Array(networkBytes));
493
+ const [trackSha,networkSha,track64,network64]=await Promise.all([
494
+ sha(trackBytes),sha(networkBytes),encoded(track),encoded(network)
495
+ ]);
496
+ const response=await request('/api/replay',{method:'POST',signal:controller.signal,
497
+ headers:{'Content-Type':'application/json'},body:JSON.stringify({track:track64,network:network64,ticks})});
498
+ const raw=await response.text();
499
+ if(raw.length>2*1024*1024)throw Error('Recording exceeds the browser limit.');
500
+ const recording=JSON.parse(raw);
501
+ const currentTrack=await trackSource(),currentNetwork=replayNetworkSource();
502
+ if(attempt!==replayRequestId||!currentTrack||!currentNetwork)throw Error('Selection changed during recording. Record the current selection again.');
503
+ const [currentTrackSha,currentNetworkSha]=await Promise.all([
504
+ currentTrack.arrayBuffer().then(sha),currentNetwork.arrayBuffer().then(sha)
505
+ ]);
506
+ if(currentTrackSha!==trackSha||currentNetworkSha!==networkSha)throw Error('Selection changed during recording. Record the current selection again.');
507
+ if(!window.TrackReplay?.setRecording)throw Error('The recorded-drive viewer is unavailable. Reload this page.');
508
+ await window.TrackReplay.setRecording(recording,trackSha,networkSha);
509
+ if(attempt!==replayRequestId){window.TrackReplay.clearRecording();throw Error('Selection changed during recording. Record the current selection again.');}
510
+ localReplay=recording;
511
+ }catch(error){
512
+ if(attempt===replayRequestId&&$('replay_status'))$('replay_status').textContent=
513
+ error?.name==='AbortError'?'Recording timed out. Try a 5-second drive.':'Recording unavailable: '+error.message;
514
+ }finally{
515
+ clearTimeout(timeout);replayBusy=false;busy=false;buttons();
516
+ }
517
+ };
518
+ if($('replay_download'))$('replay_download').onclick=()=>{
519
+ if(localReplay)downloadTextFile(JSON.stringify(localReplay),'ald-recorded-drive.json','application/json');
520
+ };
521
  async function refreshFreezeNetwork(file){
522
+ invalidateReplay('Network changed. Record a new drive for this policy.');
523
  if(!file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);return;}
524
  try{const network=await validateNetworkBytes(new Uint8Array(await file.arrayBuffer()));if(networkSource()===file){window.setFreezeNetwork?.(network);window.TrackReplay?.setStagedNetwork?.(network);}}
525
  catch(error){if(networkSource()===file){window.setFreezeNetwork?.(null);window.TrackReplay?.setStagedNetwork?.(null);if($('freeze_status'))$('freeze_status').textContent='Network metadata unavailable: '+error.message;}}
526
  }
527
  $('network').onchange=()=>{stagedNetwork=null;selectedWinner=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';refreshFreezeNetwork(networkSource());buttons();};
528
  window.TrainingClient={
529
+ isBusy:()=>busy||replayBusy||!!job||bundleRestoring,
530
  hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
531
  useNetwork(file){
532
+ if(busy||replayBusy||job)throw Error('Finish the current operation before changing the network');
533
  stagedNetwork=file;$('network').value='';
534
  if($('networkChoice'))$('networkChoice').textContent='Browser editor input: '+file.name+'. Not uploaded yet.';
535
  refreshFreezeNetwork(file);
 
1023
  window.TrackReplay?.setRunAnalysis?.(localAnalysis,runReceipt||portableReceipt||(r?{report:r}:null));
1024
  }
1025
  $('run').onclick = async () => {
1026
+ if(replayBusy)return;
1027
+ invalidateReplay();
1028
  const comparisonAttempt=!!comparisonPlan,comparisonRole=comparisonAttempt?comparisonPlan.roles[comparisonPlan.index]:null;
1029
  const previousRunResultContext=runResultContext;
1030
  cancelSteamLeaderboard();
index.html CHANGED
@@ -34,7 +34,7 @@
34
  })();
35
  </script>
36
  <header class="lab-header"><div><div class="eyebrow">AI LEARNS TO DRIVE</div><h1>Training lab</h1><p class="intro">Build a driver, train it, inspect the result.</p></div><div class="header-actions"><button id="quick_start_toggle" type="button" aria-controls="firstRunGuide" aria-expanded="false">Quick start</button><a class="help-link" href="/help#start-here" target="_blank" rel="noopener">Help &amp; tutorial ↗</a></div></header>
37
- <details class="about"><summary>About &amp; feedback</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. For supported conditions, the lab checks the run’s final fitness policy (or frozen input) with a bounded fresh replay. It predicts conditions only and does not award or accumulate original-game stars; opponent races and other unsupported conditions remain unchecked.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p><p>Cars are simulated behind the scenes. The map shows track layout, not moving cars. Interactive telemetry replay previews are reconstructed locally in your browser with zero server compute.</p><p><a id="feedbackLink" href="https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new" target="_blank" rel="noopener">Report a problem</a> · Opens an editable draft for you to review.</p><p id="buildLabel" class="hint">Build: connecting…</p></details>
38
  <details id="firstRunGuide" class="quick-start"><summary id="firstRunTitle">New here? Start a first run <span class="hint">Four steps, whenever you need them</span></summary><ol class="quick-steps"><li><strong>Choose a track</strong><p>Pick a built-in replica or upload your own track.</p><button id="first_run_track" type="button">Choose a track</button></li><li><strong>Create a driver</strong><p>Choose a car and create a small starter network, or upload a network you already have.</p><button type="button" data-open-tab="drive" data-focus="starter_create">Set up a network</button></li><li><strong>Review the settings</strong><p>The starter settings are ready for a short first experiment. You can save them as a recipe later.</p><button id="first_run_settings" type="button">Review first-run settings</button></li><li><strong>Run and inspect</strong><p>Start training, then download the result to inspect your drivers. A first run may not finish a lap.</p><button type="button" data-open-tab="drive" data-focus="run">Go to run controls</button></li></ol><div class="quick-footer"><a href="/help#start-here" target="_blank" rel="noopener">Read the full step-by-step tutorial ↗</a><button id="quick_start_close" type="button">Close quick start</button></div></details>
39
  <nav class="tabs" role="tablist" aria-label="Lab sections"><button type="button" id="tab-drive" role="tab" data-tab="drive" aria-controls="drive" aria-selected="true" tabindex="0">Run &amp; results</button><button type="button" id="tab-training" role="tab" data-tab="training" aria-controls="training" aria-selected="false" tabindex="-1">Training &amp; recipes</button><button type="button" id="tab-batch" role="tab" data-tab="batch" aria-controls="batch" aria-selected="false" tabindex="-1">Batches</button><button type="button" id="tab-editor" role="tab" data-tab="editor" aria-controls="editor" aria-selected="false" tabindex="-1">Network editor</button></nav><div id="drive" class="pane" role="tabpanel" aria-labelledby="tab-drive"><div class="grid"><section class="card"><h2>Simulation</h2><div class="toolbar" aria-label="Create a starter network"><label for="starter_vehicle">Starter car</label><select id="starter_vehicle"><option value="0" selected>Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><button id="starter_create" type="button">Create starter network</button></div><p id="starter_network_status" class="hint" role="status">Creates a small network in this page only. It does not start a run or change saved recipes.</p><label for="run_mode">Action</label><select id="run_mode"><option value="train" selected>Train a population</option><option value="evaluate">Frozen evaluation · one policy, unchanged weights</option></select><p id="run_mode_help" class="hint">Training uses the selected recipe. Frozen evaluation runs the selected policy in a fresh car without changing its weights.</p><button id="run" disabled>Run training</button><p id="starCheckNotice" class="hint">After each run, supported star challenges are checked with unchanged weights. Results are predictions, not game awards. <a href="/help#concepts" target="_blank" rel="noopener">How checks work</a>.</p><p class="hint">Select or create a network below to enable training. Review Training &amp; recipes before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
40
  <p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
@@ -48,7 +48,17 @@
48
  <p><label>Track (.track) <input id="track" type="file" accept=".track"></label></p>
49
  <p><label>Vehicle network (.evolution) <input id="network" type="file" accept=".evolution"></label></p>
50
  <p id="networkChoice" role="status">Choose an uploaded file or use a network from the browser editor.</p><p class="privacy-note">Uploads are processed for this run. By default, the server copy is deleted after the browser verifies the download.</p><details><summary>Optional repeat download</summary><label class="keep-result"><input id="keepServer" type="checkbox"> Keep this result on the server for up to five minutes so I can download it again</label><p class="privacy-note">This is optional and resets after each run. When bucket auto-save is on, verified receipt must delete the server copy first, so repeat download is disabled. All results expire after five minutes. <a href="/policy" target="_blank" rel="noopener">Data policy</a></p>
51
- </details></section><section class="card"><h2>Selected track &amp; terrain</h2><p id="mapStatus" class="map-status" role="status">Loading selected track…</p><div class="map"><canvas id="trackMap" width="1000" height="500" role="img" aria-label="Selected track tile layout and terrain"></canvas></div><div class="replay-bar" id="replayControls" aria-label="Zero-compute telemetry replay controls"><div class="replay-row"><button type="button" id="replay_play">Pause</button><button type="button" id="replay_reset">Restart</button><input type="range" id="replay_scrub" min="0" max="900" value="0" aria-label="Replay tick scrubber"><span id="replay_time_label" class="replay-time">0.00s · Tick 0 / 900</span></div><div class="replay-row" style="margin-top:8px"><label for="replay_mode" style="margin:0;font-size:12.5px">View</label><select id="replay_mode" aria-label="Replay focus mode"><option value="winners" selected>All Winners (Ghost Race)</option><option value="fitness">Fitness Leader</option><option value="fastest_lap">Fastest Lap Winner</option><option value="clean_laps">Clean Laps Winner</option><option value="swarm">Population Swarm (Gen Inspector)</option><option value="staged">Staged / Preview Policy</option></select><label for="replay_camera" style="margin:0;font-size:12.5px">Camera</label><select id="replay_camera" aria-label="Replay camera mode"><option value="full" selected>Full Circuit</option><option value="follow">Follow Car 2.2×</option></select><label for="replay_speed" style="margin:0;font-size:12.5px">Speed</label><select id="replay_speed" aria-label="Replay playback speed"><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></div><div class="replay-layers"><label><input type="checkbox" id="replay_layer_outline" checked> 2D Car White Outline</label><label><input type="checkbox" id="replay_layer_rays" checked> Raycast Sensors</label><label><input type="checkbox" id="replay_layer_line" checked> Speed Heatmap Line</label><label><input type="checkbox" id="replay_layer_skids" checked> Skid Marks &amp; Curbs</label><label><input type="checkbox" id="replay_layer_hud" checked> Live Telemetry &amp; Neural HUD</label></div></div><div class="map-legend"><span><i class="terrain asphalt"></i> Asphalt</span><span><i class="terrain dirt"></i> Dirt</span><span><i class="terrain ice"></i> Ice</span><span><i class="terrain start"></i> Start</span></div><p id="mapLegend" class="hint"></p><h2>Run &amp; results</h2><button id="download" disabled>Download result</button> <button id="erase" disabled>Delete server-side job data now</button>
 
 
 
 
 
 
 
 
 
 
52
  <p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
53
  <p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
54
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
 
34
  })();
35
  </script>
36
  <header class="lab-header"><div><div class="eyebrow">AI LEARNS TO DRIVE</div><h1>Training lab</h1><p class="intro">Build a driver, train it, inspect the result.</p></div><div class="header-actions"><button id="quick_start_toggle" type="button" aria-controls="firstRunGuide" aria-expanded="false">Quick start</button><a class="help-link" href="/help#start-here" target="_blank" rel="noopener">Help &amp; tutorial ↗</a></div></header>
37
+ <details class="about"><summary>About &amp; feedback</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. For supported conditions, the lab checks the run’s final fitness policy (or frozen input) with a bounded fresh replay. It predicts conditions only and does not award or accumulate original-game stars; opponent races and other unsupported conditions remain unchecked.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p><p>The map follows the selected track’s native centerline. Record a short CPU drive to watch an unchanged policy using real vehicle positions. A new recording can differ from an earlier training run. Steering commands, sensor rays and neural activity are not included in the current recording.</p><p><a id="feedbackLink" href="https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new" target="_blank" rel="noopener">Report a problem</a> · Opens an editable draft for you to review.</p><p id="buildLabel" class="hint">Build: connecting…</p></details>
38
  <details id="firstRunGuide" class="quick-start"><summary id="firstRunTitle">New here? Start a first run <span class="hint">Four steps, whenever you need them</span></summary><ol class="quick-steps"><li><strong>Choose a track</strong><p>Pick a built-in replica or upload your own track.</p><button id="first_run_track" type="button">Choose a track</button></li><li><strong>Create a driver</strong><p>Choose a car and create a small starter network, or upload a network you already have.</p><button type="button" data-open-tab="drive" data-focus="starter_create">Set up a network</button></li><li><strong>Review the settings</strong><p>The starter settings are ready for a short first experiment. You can save them as a recipe later.</p><button id="first_run_settings" type="button">Review first-run settings</button></li><li><strong>Run and inspect</strong><p>Start training, then download the result to inspect your drivers. A first run may not finish a lap.</p><button type="button" data-open-tab="drive" data-focus="run">Go to run controls</button></li></ol><div class="quick-footer"><a href="/help#start-here" target="_blank" rel="noopener">Read the full step-by-step tutorial ↗</a><button id="quick_start_close" type="button">Close quick start</button></div></details>
39
  <nav class="tabs" role="tablist" aria-label="Lab sections"><button type="button" id="tab-drive" role="tab" data-tab="drive" aria-controls="drive" aria-selected="true" tabindex="0">Run &amp; results</button><button type="button" id="tab-training" role="tab" data-tab="training" aria-controls="training" aria-selected="false" tabindex="-1">Training &amp; recipes</button><button type="button" id="tab-batch" role="tab" data-tab="batch" aria-controls="batch" aria-selected="false" tabindex="-1">Batches</button><button type="button" id="tab-editor" role="tab" data-tab="editor" aria-controls="editor" aria-selected="false" tabindex="-1">Network editor</button></nav><div id="drive" class="pane" role="tabpanel" aria-labelledby="tab-drive"><div class="grid"><section class="card"><h2>Simulation</h2><div class="toolbar" aria-label="Create a starter network"><label for="starter_vehicle">Starter car</label><select id="starter_vehicle"><option value="0" selected>Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><button id="starter_create" type="button">Create starter network</button></div><p id="starter_network_status" class="hint" role="status">Creates a small network in this page only. It does not start a run or change saved recipes.</p><label for="run_mode">Action</label><select id="run_mode"><option value="train" selected>Train a population</option><option value="evaluate">Frozen evaluation · one policy, unchanged weights</option></select><p id="run_mode_help" class="hint">Training uses the selected recipe. Frozen evaluation runs the selected policy in a fresh car without changing its weights.</p><button id="run" disabled>Run training</button><p id="starCheckNotice" class="hint">After each run, supported star challenges are checked with unchanged weights. Results are predictions, not game awards. <a href="/help#concepts" target="_blank" rel="noopener">How checks work</a>.</p><p class="hint">Select or create a network below to enable training. Review Training &amp; recipes before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
40
  <p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
 
48
  <p><label>Track (.track) <input id="track" type="file" accept=".track"></label></p>
49
  <p><label>Vehicle network (.evolution) <input id="network" type="file" accept=".evolution"></label></p>
50
  <p id="networkChoice" role="status">Choose an uploaded file or use a network from the browser editor.</p><p class="privacy-note">Uploads are processed for this run. By default, the server copy is deleted after the browser verifies the download.</p><details><summary>Optional repeat download</summary><label class="keep-result"><input id="keepServer" type="checkbox"> Keep this result on the server for up to five minutes so I can download it again</label><p class="privacy-note">This is optional and resets after each run. When bucket auto-save is on, verified receipt must delete the server copy first, so repeat download is disabled. All results expire after five minutes. <a href="/policy" target="_blank" rel="noopener">Data policy</a></p>
51
+ </details></section><section class="card"><h2>Selected track &amp; terrain</h2><p id="mapStatus" class="map-status" role="status">Loading selected track…</p><div class="map"><canvas id="trackMap" width="1000" height="500" role="img" aria-label="Selected track tile layout and terrain"></canvas></div><div class="replay-bar" id="replayControls" aria-label="Recorded drive controls">
52
+ <div class="replay-row"><button type="button" id="replay_record" disabled>Record selected policy</button><label for="replay_duration" style="margin:0">Length</label><select id="replay_duration"><option value="300" selected>5 seconds</option><option value="900">15 seconds</option></select><button type="button" id="replay_download" disabled>Download recording</button></div>
53
+ <p id="replay_policy" class="hint">Choose or create a network to record a drive.</p>
54
+ <p class="hint">Fresh CPU drive · unchanged weights · 60 Hz physics and decisions · wall-hit and idle elimination off. Recording takes a few seconds; playback runs in your browser.</p>
55
+ <p id="replay_status" role="status">No replay recorded. The map shows the selected track and its saved starting point.</p>
56
+ <div class="replay-row"><button type="button" id="replay_play" disabled>Play</button><button type="button" id="replay_reset" disabled>Restart</button><input type="range" id="replay_scrub" min="1" max="300" value="1" disabled aria-label="Replay tick scrubber"><span id="replay_time_label" class="replay-time">No recording</span></div>
57
+ <div class="replay-row" style="margin-top:8px"><label for="replay_camera" style="margin:0">Camera</label><select id="replay_camera" aria-label="Replay camera mode" disabled><option value="full" selected>Full circuit</option><option value="follow">Follow car</option></select><label for="replay_speed" style="margin:0">Speed</label><select id="replay_speed" aria-label="Replay playback speed" disabled><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></div>
58
+ <div class="replay-layers"><label><input type="checkbox" id="replay_layer_outline" checked disabled> Car outline</label><label><input type="checkbox" id="replay_layer_line" checked disabled> Recorded path</label><label><input type="checkbox" id="replay_layer_hud" checked disabled> Recorded telemetry</label></div>
59
+ <p id="replay_readout" style="font-size:16px;font-variant-numeric:tabular-nums" aria-live="off"></p>
60
+ <details><summary>What does this recording show?</summary><p>Vehicle positions and velocities are sampled every 0.1 seconds from the native CPU simulator. Playback interpolates between those samples; it does not simulate the car. Speed is calculated from recorded velocity. Controls, sensor rays, skid marks and neural activations are not recorded. This fresh drive does not replace the measurements from your training run.</p><p>The recording remains in this page until replaced, cleared by a changed selection, or the page is closed. Use Download recording to keep its measured samples.</p></details>
61
+ </div><div class="map-legend"><span><i class="terrain asphalt"></i> Asphalt</span><span><i class="terrain dirt"></i> Dirt</span><span><i class="terrain ice"></i> Ice</span><span><i class="terrain start"></i> Start</span></div><p id="mapLegend" class="hint"></p><h2>Run &amp; results</h2><button id="download" disabled>Download result</button> <button id="erase" disabled>Delete server-side job data now</button>
62
  <p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
63
  <p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
64
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
replay.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded, measured playback using the installed native benchmark command.
2
+
3
+ Each sample is a deterministic fresh CPU evaluation of the same policy up to
4
+ that tick. This is not a recording of a previous training generation. Native
5
+ bench captures car state before turnover, accepts only positive tick counts,
6
+ and disables wall/idle elimination. With population one its preserved parent
7
+ is the supplied policy; every dump is checked against that exact parameter
8
+ vector before any recording is returned.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import math
14
+ from pathlib import Path
15
+ import stat
16
+ import struct
17
+ import subprocess
18
+ import tempfile
19
+ import time
20
+
21
+ from worker import (
22
+ OUTPUT_NAMES,
23
+ native_environment,
24
+ network_input,
25
+ track_input,
26
+ vehicle_scene_template,
27
+ )
28
+
29
+
30
+ MAX_TICKS = 900
31
+ SAMPLE_INTERVAL_TICKS = 6
32
+ MAX_WALL_SECONDS = 30.0
33
+ MAX_SCENE_BYTES = 16 * 1024 * 1024
34
+ MAX_DUMP_BYTES = 512 * 1024
35
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
36
+
37
+
38
+ class ReplayError(RuntimeError):
39
+ """The native recorder could not produce a complete verified recording."""
40
+
41
+
42
+ class ReplayTimeout(ReplayError, TimeoutError):
43
+ """The recording exhausted its native subprocess or total wall budget."""
44
+
45
+
46
+ def _remaining(deadline):
47
+ seconds = deadline - time.monotonic()
48
+ if seconds <= 0:
49
+ raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks')
50
+ return seconds
51
+
52
+
53
+ def _run(command, *, work, env, deadline, stage, timeout=None):
54
+ remaining = _remaining(deadline)
55
+ try:
56
+ subprocess.run(
57
+ command,
58
+ cwd=work,
59
+ env=env,
60
+ stdin=subprocess.DEVNULL,
61
+ stdout=subprocess.DEVNULL,
62
+ stderr=subprocess.DEVNULL,
63
+ shell=False,
64
+ check=True,
65
+ timeout=min(remaining, timeout) if timeout is not None else remaining,
66
+ )
67
+ except subprocess.TimeoutExpired as exc:
68
+ raise ReplayTimeout('Replay recording exceeded its time limit; try fewer ticks') from exc
69
+ except (OSError, subprocess.CalledProcessError) as exc:
70
+ raise ReplayError('Native replay ' + stage + ' failed') from exc
71
+ _remaining(deadline)
72
+
73
+
74
+ def _object(pairs):
75
+ result = {}
76
+ for key, value in pairs:
77
+ if key in result:
78
+ raise ValueError('Duplicate JSON key')
79
+ result[key] = value
80
+ return result
81
+
82
+
83
+ def _read_json(path, limit, label):
84
+ try:
85
+ info = path.lstat()
86
+ if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
87
+ raise ValueError('Invalid output file')
88
+ with path.open('rb') as stream:
89
+ data = stream.read(limit + 1)
90
+ if len(data) > limit:
91
+ raise ValueError('Output exceeds limit')
92
+ value = json.loads(
93
+ data,
94
+ object_pairs_hook=_object,
95
+ parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Nonfinite JSON')),
96
+ )
97
+ if not isinstance(value, dict):
98
+ raise ValueError('Expected JSON object')
99
+ return value
100
+ except (OSError, ValueError, RecursionError) as exc:
101
+ raise ReplayError('Native replay returned an invalid ' + label) from exc
102
+
103
+
104
+ def _number(value, label):
105
+ finite = False
106
+ if type(value) in (int, float):
107
+ try:
108
+ finite = math.isfinite(value)
109
+ except OverflowError:
110
+ pass
111
+ if not finite:
112
+ raise ReplayError('Native replay returned an invalid ' + label)
113
+ return value
114
+
115
+
116
+ def _vector(value, label):
117
+ if not isinstance(value, list) or len(value) != 2:
118
+ raise ReplayError('Native replay returned an invalid ' + label)
119
+ return [_number(component, label) for component in value]
120
+
121
+
122
+ def _counter(value, label):
123
+ # Keep integer values exact in the browser's JSON number representation.
124
+ if type(value) is not int or not 0 <= value <= 2**53 - 1:
125
+ raise ReplayError('Native replay returned an invalid ' + label)
126
+ return value
127
+
128
+
129
+ def _parameter_bytes(values):
130
+ # Native Network.params stores float64 values, layer by layer: each weight
131
+ # row, followed by that layer's bias. Comparing bytes also preserves -0.0.
132
+ return b''.join(struct.pack('!d', _number(value, 'policy parameter')) for value in values)
133
+
134
+
135
+ def _frame(dump, tick, expected_parameters):
136
+ agents = dump.get('agents')
137
+ networks = dump.get('next_networks')
138
+ if (not isinstance(agents, list) or len(agents) != 1 or not isinstance(agents[0], dict)
139
+ or not isinstance(networks, list) or len(networks) != 1
140
+ or not isinstance(networks[0], list)
141
+ or len(networks[0]) * 8 != len(expected_parameters)):
142
+ raise ReplayError('Native replay must contain exactly one car and its policy')
143
+ if _parameter_bytes(networks[0]) != expected_parameters:
144
+ raise ReplayError('Native replay changed the selected policy; recording discarded')
145
+
146
+ agent = agents[0]
147
+ position = _vector(agent.get('position'), 'car position')
148
+ velocity = _vector(agent.get('velocity'), 'car velocity')
149
+ if type(agent.get('active')) is not bool:
150
+ raise ReplayError('Native replay returned an invalid active state')
151
+ frame = {
152
+ 'tick': tick,
153
+ 'x': position[0],
154
+ 'y': position[1],
155
+ 'rotation': _number(agent.get('rotation'), 'car rotation'),
156
+ 'vx': velocity[0],
157
+ 'vy': velocity[1],
158
+ 'active': agent['active'],
159
+ }
160
+ for field in ('angular_velocity', 'boost_energy'):
161
+ if field in agent:
162
+ frame[field] = _number(agent[field], field)
163
+ for field in ('collision_count', 'update_count'):
164
+ if field in agent:
165
+ frame[field] = _counter(agent[field], field)
166
+ if 'metrics' in agent:
167
+ metrics = agent['metrics']
168
+ if (not isinstance(metrics, dict) or len(metrics) > 64
169
+ or any(not isinstance(key, str) or not 1 <= len(key) <= 96 for key in metrics)):
170
+ raise ReplayError('Native replay returned invalid metrics')
171
+ frame['metrics'] = {
172
+ key: None if value is None else _number(value, 'metric')
173
+ for key, value in metrics.items()
174
+ }
175
+ return frame
176
+
177
+
178
+ def record_replay(track_bytes, network_bytes, *, binary, importer, scene_template, ticks=300):
179
+ """Record one unchanged policy for 1–900 native 60 Hz ticks.
180
+
181
+ ``binary``, ``importer`` and the Formula ``scene_template`` path must come
182
+ from trusted application configuration. Uploads only supply saved track
183
+ and game network bytes. Samples include tick 1, every sixth tick, and the
184
+ requested endpoint. The importer spawn is labelled separately because the
185
+ native reset settles the vehicle before the first measured tick.
186
+ """
187
+ deadline = time.monotonic() + MAX_WALL_SECONDS
188
+ if type(ticks) is not int or not 1 <= ticks <= MAX_TICKS:
189
+ raise ValueError('Replay ticks must be an integer from 1 to 900')
190
+ track = track_input(track_bytes)
191
+ template = network_input(network_bytes)
192
+ binary = Path(binary).resolve()
193
+ importer = Path(importer).resolve()
194
+ scene_template = vehicle_scene_template(scene_template, template['Type'])
195
+ if not all(path.is_file() for path in (binary, importer, scene_template)):
196
+ raise ReplayError('Native replay tools or vehicle template unavailable')
197
+
198
+ network = template['NeuralNetwork']
199
+ parameters = []
200
+ for matrix, bias in zip(network['Weights'], network['Biases']):
201
+ parameters.extend(value for row in matrix for value in row)
202
+ parameters.extend(bias)
203
+ expected_parameters = _parameter_bytes(parameters)
204
+ sample_ticks = sorted({1, ticks, *range(SAMPLE_INTERVAL_TICKS, ticks + 1, SAMPLE_INTERVAL_TICKS)})
205
+ environment = native_environment()
206
+
207
+ with tempfile.TemporaryDirectory(prefix='tiga-replay-') as directory:
208
+ work = Path(directory)
209
+ environment.update(TMPDIR=directory)
210
+
211
+ def save(name, value):
212
+ path = work / name
213
+ path.write_text(json.dumps(value, allow_nan=False), encoding='utf-8')
214
+ return str(path)
215
+
216
+ saved_track = save('track.json', track)
217
+ scene_path = work / 'scene.json'
218
+ _run(
219
+ [str(importer), saved_track, 'Replay track', str(scene_template), str(scene_path)],
220
+ work=work, env=environment, deadline=deadline, stage='track import', timeout=10.0,
221
+ )
222
+ scene = _read_json(scene_path, MAX_SCENE_BYTES, 'scene')
223
+ spawn_position = _vector(scene.get('reset_position'), 'requested spawn position')
224
+ spawn_rotation = _number(scene.get('reset_rotation'), 'requested spawn rotation')
225
+ spawn_path = save('spawn.json', {'frames': [{'position': spawn_position, 'rotation': spawn_rotation}]})
226
+ names = [f'sensor-{index}' for index in range(network['Shape'][0])]
227
+ network_path = save('network.json', {
228
+ 'shape': network['Shape'], 'weights': network['Weights'], 'biases': network['Biases'],
229
+ 'inputs': names, 'outputs': [OUTPUT_NAMES[index] for index in template['Outputs']],
230
+ })
231
+ model_path = save('model.json', {'sensor_layout': {'names': names, 'sensors': template['Sensors']}})
232
+ dump_path = work / 'state.json'
233
+ command = [
234
+ str(binary), '--math-profile', 'proton', '--threads', '1', 'bench',
235
+ '--scene', str(scene_path), '--network', network_path, '--model', model_path,
236
+ '--spawn-trace', spawn_path, '--population', '1', '--warmup-ticks', '0',
237
+ '--seed', '0', '--spawn-index', '0', '--batch-count', '1', '--dump-state', str(dump_path),
238
+ ]
239
+ frames = []
240
+ for tick in sample_ticks:
241
+ # A successful command without a new dump must not reuse a prior
242
+ # sample. A single file also bounds temporary storage independently
243
+ # of the recording length.
244
+ dump_path.unlink(missing_ok=True)
245
+ _run(command + ['--ticks', str(tick)], work=work, env=environment,
246
+ deadline=deadline, stage='evaluation')
247
+ dump = _read_json(dump_path, MAX_DUMP_BYTES, 'car state')
248
+ frames.append(_frame(dump, tick, expected_parameters))
249
+ _remaining(deadline)
250
+
251
+ result = {
252
+ 'schema': 'ald.recorded-replay',
253
+ 'version': 1,
254
+ 'kind': 'fresh_cpu_evaluation',
255
+ 'physics_hz': 60,
256
+ 'sample_interval_ticks': SAMPLE_INTERVAL_TICKS,
257
+ 'first_sample_tick': 1,
258
+ 'control_hz': 60,
259
+ 'track_sha256': hashlib.sha256(track_bytes).hexdigest(),
260
+ 'network_sha256': hashlib.sha256(network_bytes).hexdigest(),
261
+ 'vehicle_type': template['Type'],
262
+ 'requested_ticks': ticks,
263
+ 'execution_backend': 'cpu',
264
+ 'math_profile': 'proton',
265
+ 'collision_elimination': False,
266
+ 'idle_elimination': False,
267
+ 'seed': 0,
268
+ 'population': 1,
269
+ 'warmup_ticks': 0,
270
+ 'policy_weights_verified': True,
271
+ 'sampling_method': 'deterministic_cumulative_native_bench',
272
+ 'position_units': 'game_world_units',
273
+ 'velocity_units': 'game_world_units_per_second',
274
+ 'rotation_units': 'radians',
275
+ 'native_spawn_kind': 'requested_before_native_reset_settle',
276
+ 'native_spawn': {'x': spawn_position[0], 'y': spawn_position[1], 'rotation': spawn_rotation},
277
+ 'frames': frames,
278
+ }
279
+ native_track = scene.get('track')
280
+ if isinstance(native_track, dict):
281
+ for field in ('tile_size', 'tile_map_position'):
282
+ if field in native_track:
283
+ result[field] = _vector(native_track[field], field)
284
+ if len(json.dumps(result, allow_nan=False).encode('utf-8')) > MAX_RESPONSE_BYTES:
285
+ raise ReplayError('Native replay recording exceeds the output limit')
286
+ _remaining(deadline)
287
+ return result
server.py CHANGED
@@ -4,6 +4,7 @@ import hashlib
4
  import json
5
  from pathlib import Path
6
  import re
 
7
  import threading
8
  from fastapi import HTTPException, Request
9
  from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
@@ -363,6 +364,45 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
363
  except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
364
  except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
365
  finally: admission.release()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  return app
367
 
368
  app=create_app()
 
4
  import json
5
  from pathlib import Path
6
  import re
7
+ import subprocess
8
  import threading
9
  from fastapi import HTTPException, Request
10
  from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
 
364
  except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
365
  except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
366
  finally: admission.release()
367
+ @app.post('/api/replay')
368
+ async def recorded_replay(request:Request):
369
+ # Share CPU admission with training; recordings never allocate a GPU.
370
+ if not admission.acquire(blocking=False):
371
+ raise HTTPException(429,'Worker busy; finish the current run before recording a drive')
372
+ data=bytearray()
373
+ try:
374
+ async for chunk in request.stream():
375
+ if len(data)+len(chunk)>3*1024*1024:
376
+ raise HTTPException(413,'Replay input exceeds the upload limit')
377
+ data.extend(chunk)
378
+ try:
379
+ body=json.loads(data)
380
+ if not isinstance(body,dict) or set(body)!={'track','network','ticks'}:
381
+ raise ValueError()
382
+ if type(body['ticks']) is not int or not 1<=body['ticks']<=900:
383
+ raise ValueError()
384
+ track=base64.b64decode(body['track'],validate=True)
385
+ network=base64.b64decode(body['network'],validate=True)
386
+ if not track or not network or max(len(track),len(network))>1024*1024:
387
+ raise ValueError()
388
+ except Exception:
389
+ raise HTTPException(400,'Invalid replay input; select a track, a network and up to 15 seconds') from None
390
+ finally:
391
+ data.clear()
392
+ from replay import record_replay, ReplayError
393
+ try:
394
+ recording=await run_in_threadpool(record_replay,track,network,
395
+ binary=ROOT/'bin/altd-sim',importer=ROOT/'bin/extract_saved_track',
396
+ scene_template=ROOT/'assets/formula_template.json',ticks=body['ticks'])
397
+ recording['build']=build_identity()
398
+ return JSONResponse(recording,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
399
+ except (TimeoutError,subprocess.TimeoutExpired):
400
+ raise HTTPException(504,'Recording reached its time limit. Try a 5-second drive.') from None
401
+ except (ReplayError,OSError,ValueError,KeyError,TypeError,subprocess.CalledProcessError):
402
+ raise HTTPException(422,'The engine could not produce a complete recorded drive. No estimated playback was substituted.') from None
403
+ finally:
404
+ data.clear()
405
+ admission.release()
406
  return app
407
 
408
  app=create_app()
track-map.js CHANGED
@@ -1,11 +1,10 @@
1
- /* Browser-only preview and zero-compute telemetry replay of the selected saved track. No upload for preview. */
2
  (function(root,factory){
3
  if(typeof module==='object'&&module.exports)module.exports=factory(root);
4
  else root.TrackMap=factory(root);
5
  })(typeof window==='undefined'?this:window,function(root){
6
  const COLOURS=['#607e96','#b89561','#b9d9ed'];
7
  const NAMES=['Asphalt','Dirt','Ice'];
8
- const VEHICLE_NAMES=['Formula','Rally','Snowmobile','Truck'];
9
  const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
10
  function vector(value){
11
  const match=typeof value==='string'&&VECTOR.exec(value);
@@ -88,139 +87,6 @@
88
  if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
89
  return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
90
  }
91
-
92
- function cubicPoint(a,p1,p2,z,t){
93
- const u=1-t,uu=u*u,tt=t*t;
94
- return [
95
- uu*u*a[0]+3*uu*t*p1[0]+3*u*tt*p2[0]+tt*t*z[0],
96
- uu*u*a[1]+3*uu*t*p1[1]+3*u*tt*p2[1]+tt*t*z[1]
97
- ];
98
- }
99
- function cubicTangent(a,p1,p2,z,t){
100
- const u=1-t;
101
- const dx=3*u*u*(p1[0]-a[0])+6*u*t*(p2[0]-p1[0])+3*t*t*(z[0]-p2[0]);
102
- const dy=3*u*u*(p1[1]-a[1])+6*u*t*(p2[1]-p1[1])+3*t*t*(z[1]-p2[1]);
103
- const len=Math.hypot(dx,dy)||1;
104
- return [dx/len,dy/len];
105
- }
106
- function oppositeSide(side){return [1,0,3,2][side];}
107
- function neighborDelta(side){return [[0,-1],[0,1],[-1,0],[1,0]][side];}
108
-
109
- function buildTrackGeometry(track,w,h){
110
- const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
111
- const size=Math.min(160,(w-70)/columns,(h-70)/rows);
112
- const left=(w-columns*size)/2,top=(h-rows*size)/2;
113
- const roadHalfWidth=Math.max(7,size*0.215);
114
- const tileMap=new Map(track.tiles.map(t=>[`${t.x},${t.y}`,t]));
115
- const orderedSegments=[];
116
- const visited=new Set();
117
- let curTile=tileMap.get(`${track.start[0]},${track.start[1]}`);
118
- let entryConn=track.startConnection;
119
- while(curTile&&orderedSegments.length<track.tiles.length){
120
- const key=`${curTile.x},${curTile.y}`;
121
- if(visited.has(key))break;
122
- visited.add(key);
123
- const [c0,c1]=curTile.connections;
124
- const isFirst=c0.side===entryConn.side&&c0.index===entryConn.index;
125
- const enter=isFirst?c0:c1;
126
- const exit=isFirst?c1:c0;
127
- orderedSegments.push({tile:curTile,enter,exit});
128
- const [dx,dy]=neighborDelta(exit.side);
129
- const nextTile=tileMap.get(`${curTile.x+dx},${curTile.y+dy}`);
130
- if(!nextTile)break;
131
- const nextSide=oppositeSide(exit.side);
132
- const match=nextTile.connections.find(c=>c.side===nextSide&&c.index===exit.index);
133
- if(!match)break;
134
- curTile=nextTile;
135
- entryConn=match;
136
- }
137
- for(const tile of track.tiles){
138
- const key=`${tile.x},${tile.y}`;
139
- if(!visited.has(key)&&tile.connections.length>=2){
140
- orderedSegments.push({tile,enter:tile.connections[0],exit:tile.connections[1]});
141
- }
142
- }
143
- const rawSamples=[];
144
- const stepsPerTile=26;
145
- for(let sIdx=0;sIdx<orderedSegments.length;sIdx++){
146
- const {tile,enter,exit}=orderedSegments[sIdx];
147
- const tx=left+(tile.x-b.minX)*size,ty=top+(tile.y-b.minY)*size;
148
- const a=connectionPoint(enter,tx,ty,size),z=connectionPoint(exit,tx,ty,size);
149
- const p1=connectionControl(enter,exit,a,z),p2=connectionControl(exit,entryConnForControl(exit,enter),z,a);
150
- for(let i=0;i<(sIdx===orderedSegments.length-1?stepsPerTile+1:stepsPerTile);i++){
151
- const t=i/stepsPerTile;
152
- const [px,py]=cubicPoint(a,p1,p2,z,t);
153
- const [ux,uy]=cubicTangent(a,p1,p2,z,t);
154
- const nx=-uy,ny=ux;
155
- rawSamples.push({x:px,y:py,tx:ux,ty:uy,nx,ny,surface:tile.surface,tileIndex:sIdx});
156
- }
157
- }
158
- function entryConnForControl(_,enter){return enter;}
159
- if(rawSamples.length<2){
160
- const sx=left+(track.start[0]-b.minX)*size+size*0.5,sy=top+(track.start[1]-b.minY)*size+size*0.5;
161
- rawSamples.push({x:sx,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0},{x:sx+10,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0});
162
- }
163
- let totalLength=0;
164
- rawSamples[0].s=0;
165
- for(let i=1;i<rawSamples.length;i++){
166
- const ds=Math.hypot(rawSamples[i].x-rawSamples[i-1].x,rawSamples[i].y-rawSamples[i-1].y);
167
- totalLength+=ds;
168
- rawSamples[i].s=totalLength;
169
- }
170
- totalLength=Math.max(1,totalLength);
171
- const n=rawSamples.length;
172
- const closed=Math.hypot(rawSamples[n-1].x-rawSamples[0].x,rawSamples[n-1].y-rawSamples[0].y)<size*0.45;
173
- for(let i=0;i<n;i++){
174
- const prev=rawSamples[closed?(i-2+n)%n:Math.max(0,i-2)];
175
- const next=rawSamples[closed?(i+2)%n:Math.min(n-1,i+2)];
176
- const a1=Math.atan2(prev.ty,prev.tx),a2=Math.atan2(next.ty,next.tx);
177
- let da=a2-a1;
178
- while(da>Math.PI)da-=Math.PI*2;
179
- while(da<-Math.PI)da+=Math.PI*2;
180
- const ds=Math.max(2,Math.hypot(next.x-prev.x,next.y-prev.y));
181
- const p=rawSamples[i];
182
- p.curvature=da/ds;
183
- p.heading=Math.atan2(p.ty,p.tx);
184
- p.u=p.s/totalLength;
185
- p.lx=p.x+p.nx*roadHalfWidth;
186
- p.ly=p.y+p.ny*roadHalfWidth;
187
- p.rx=p.x-p.nx*roadHalfWidth;
188
- p.ry=p.y-p.ny*roadHalfWidth;
189
- }
190
- return {size,left,top,columns,rows,roadHalfWidth,samples:rawSamples,totalLength,closed};
191
- }
192
-
193
- function sampleAtProgress(geom,u){
194
- const samples=geom.samples,n=samples.length;
195
- if(!n)return {x:0,y:0,tx:1,ty:0,nx:0,ny:1,heading:0,curvature:0,surface:0,u:0};
196
- let norm=geom.closed?((u%1)+1)%1:Math.max(0,Math.min(1,u));
197
- const targetS=norm*geom.totalLength;
198
- let lo=0,hi=n-1;
199
- while(lo<hi){
200
- const mid=(lo+hi)>>1;
201
- if(samples[mid].s<targetS)lo=mid+1;
202
- else hi=mid;
203
- }
204
- const i1=Math.min(n-1,lo),i0=Math.max(0,i1-1);
205
- const s0=samples[i0],s1=samples[i1];
206
- const span=Math.max(1e-5,s1.s-s0.s);
207
- const t=Math.max(0,Math.min(1,(targetS-s0.s)/span));
208
- const tx=s0.tx+(s1.tx-s0.tx)*t,ty=s0.ty+(s1.ty-s0.ty)*t;
209
- const tLen=Math.hypot(tx,ty)||1;
210
- const ux=tx/tLen,uy=ty/tLen;
211
- return {
212
- x:s0.x+(s1.x-s0.x)*t,
213
- y:s0.y+(s1.y-s0.y)*t,
214
- tx:ux,ty:uy,
215
- nx:-uy,ny:ux,
216
- heading:Math.atan2(uy,ux),
217
- curvature:s0.curvature+(s1.curvature-s0.curvature)*t,
218
- surface:t<0.5?s0.surface:s1.surface,
219
- u:norm,
220
- idx:i0
221
- };
222
- }
223
-
224
  function render(canvas,track){
225
  const context=canvas.getContext('2d');
226
  if(!context)throw Error('Canvas preview unavailable');
@@ -230,18 +96,6 @@
230
  const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
231
  const size=Math.min(160,(w-70)/columns,(h-70)/rows);
232
  const left=(w-columns*size)/2,top=(h-rows*size)/2;
233
-
234
- // Subtle CAD coordinate grid in background
235
- if(typeof context.save==='function'){
236
- context.save();
237
- context.strokeStyle='rgba(42,61,82,0.25)';
238
- context.lineWidth=1;
239
- const step=Math.max(24,Math.round(size*0.5));
240
- for(let gx=left%step;gx<w;gx+=step){context.beginPath();context.moveTo(gx,0);context.lineTo(gx,h);context.stroke();}
241
- for(let gy=top%step;gy<h;gy+=step){context.beginPath();context.moveTo(0,gy);context.lineTo(w,gy);context.stroke();}
242
- context.restore();
243
- }
244
-
245
  for(const tile of track.tiles){
246
  const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
247
  context.fillStyle=COLOURS[tile.surface];
@@ -252,142 +106,26 @@
252
  const [entry,exit]=tile.connections;
253
  const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
254
  const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
255
- // Road bed & wall corridor
256
- context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
257
- context.strokeStyle='#cbd7d9';context.lineWidth=Math.max(8,size*.46);context.lineCap='round';context.stroke();
258
- context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
259
- context.strokeStyle=tile.surface===2?'#223c52':tile.surface===1?'#2e2720':'#162434';
260
- context.lineWidth=Math.max(6,size*.40);context.lineCap='round';context.stroke();
261
- // Original centerline
262
  context.beginPath();context.moveTo(...a);
263
  context.bezierCurveTo(...p1,...p2,...z);
264
  context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
265
  context.lineCap='round';context.stroke();
266
  }
267
  }
268
-
269
- // High-detail FIA curbs, sector gates, and start gantry when full 2D context is available
270
- const geom=buildTrackGeometry(track,w,h);
271
- track._geometry=geom;
272
- if(typeof context.save==='function'&&geom.samples.length>8){
273
- context.save();
274
- const hw=geom.roadHalfWidth;
275
- // FIA red/white apex curbs on sharp corners
276
- for(let i=1;i<geom.samples.length-1;i+=2){
277
- const s0=geom.samples[i-1],s1=geom.samples[i+1];
278
- if(Math.abs(s0.curvature)*size>0.65){
279
- const side=s0.curvature>0?-1:1;
280
- const ox0=s0.x+s0.nx*side*(hw*0.92),oy0=s0.y+s0.ny*side*(hw*0.92);
281
- const ox1=s1.x+s1.nx*side*(hw*0.92),oy1=s1.y+s1.ny*side*(hw*0.92);
282
- context.beginPath();context.moveTo(ox0,oy0);context.lineTo(ox1,oy1);
283
- context.strokeStyle=((i>>1)&1)?'#e05252':'#f0f6fc';
284
- context.lineWidth=Math.max(2,size*0.045);context.lineCap='butt';context.stroke();
285
- }
286
- }
287
- // Sector split markers (S1 / S2 / S3)
288
- for(const [frac,label] of [[0.333,'S2'],[0.667,'S3']]){
289
- const sp=sampleAtProgress(geom,frac);
290
- context.beginPath();
291
- context.moveTo(sp.x+sp.nx*hw*0.95,sp.y+sp.ny*hw*0.95);
292
- context.lineTo(sp.x-sp.nx*hw*0.95,sp.y-sp.ny*hw*0.95);
293
- context.strokeStyle='rgba(88,166,255,0.55)';context.lineWidth=1.5;context.stroke();
294
- if(typeof context.fillText==='function'){
295
- context.fillStyle='#8ec2ff';context.font='700 10px system-ui,sans-serif';
296
- context.fillText(label,sp.x+sp.nx*(hw+8)-6,sp.y+sp.ny*(hw+8)+3);
297
- }
298
- }
299
- // Checkered Start/Finish line at u=0
300
- const st=geom.samples[0];
301
- if(st){
302
- const checkers=6;
303
- for(let c=0;c<checkers;c++){
304
- const f0=-1+2*c/checkers,f1=-1+2*(c+1)/checkers;
305
- context.beginPath();
306
- context.moveTo(st.x+st.nx*hw*f0,st.y+st.ny*hw*f0);
307
- context.lineTo(st.x+st.nx*hw*f1,st.y+st.ny*hw*f1);
308
- context.strokeStyle=(c&1)?'#0c1926':'#f0f6fc';
309
- context.lineWidth=Math.max(3,size*0.055);context.lineCap='butt';context.stroke();
310
- }
311
- }
312
- context.restore();
313
- }
314
-
315
  const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
316
  const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
317
  context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
318
  context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
319
  }
320
-
321
- // Fast forward pass of an .evolution NeuralNetwork (<0.01 ms per frame)
322
- function evaluateNeuralNetwork(nn,inputs){
323
- if(!nn||!Array.isArray(nn.Weights)||!Array.isArray(nn.Biases))return null;
324
- let act=inputs.slice(0,nn.Shape?.[0]||inputs.length);
325
- while(act.length<(nn.Shape?.[0]||0))act.push(0);
326
- const layerActivations=[act];
327
- for(let l=0;l<nn.Weights.length;l++){
328
- const W=nn.Weights[l],B=nn.Biases[l];
329
- const next=new Array(B.length);
330
- for(let j=0;j<B.length;j++){
331
- let sum=B[j]||0;
332
- for(let i=0;i<act.length;i++)sum+=(act[i]||0)*(W[i]?.[j]||0);
333
- next[j]=Math.tanh(sum);
334
- }
335
- act=next;
336
- layerActivations.push(act);
337
- }
338
- return {outputs:act,layerActivations};
339
- }
340
-
341
- // Analytic raycast against track corridor walls near car position
342
- function castRayAgainstCorridor(geom,carX,carY,rayAngle,maxDistPx,centerIdx){
343
- const samples=geom.samples,n=samples.length;
344
- if(n<4)return {dist:maxDistPx,frac:1,hx:carX+Math.cos(rayAngle)*maxDistPx,hy:carY+Math.sin(rayAngle)*maxDistPx,hit:false};
345
- const dx=Math.cos(rayAngle),dy=Math.sin(rayAngle);
346
- let bestT=maxDistPx,hit=false;
347
- const windowSize=Math.min(n-1,56);
348
- const startIdx=geom.closed?(centerIdx-12+n)%n:Math.max(0,centerIdx-12);
349
- for(let k=0;k<windowSize;k++){
350
- const i0=geom.closed?(startIdx+k)%n:Math.min(n-2,startIdx+k);
351
- const i1=geom.closed?(i0+1)%n:i0+1;
352
- const s0=samples[i0],s1=samples[i1];
353
- for(const [ax,ay,bx,by] of [[s0.lx,s0.ly,s1.lx,s1.ly],[s0.rx,s0.ry,s1.rx,s1.ry]]){
354
- const sx=bx-ax,sy=by-ay;
355
- const det=dx*sy-dy*sx;
356
- if(Math.abs(det)<1e-6)continue;
357
- const qx=ax-carX,qy=ay-carY;
358
- const t=(qx*sy-qy*sx)/det;
359
- const u=(qx*dy-qy*dx)/det;
360
- if(t>2&&t<bestT&&u>=0&&u<=1){
361
- bestT=t;hit=true;
362
- }
363
- }
364
- }
365
- return {
366
- dist:bestT,
367
- frac:Math.max(0,Math.min(1,bestT/maxDistPx)),
368
- hx:carX+dx*bestT,
369
- hy:carY+dy*bestT,
370
- hit
371
- };
372
- }
373
-
374
- // Draw detailed 2D vehicle chassis with steerable front wheels, white shader outline, brake lights & boost flame
375
- function drawVehicle2D(ctx,x,y,heading,slipAngle,steerAngle,vehicleType,color,scale,showOutline,braking,boosting,active){
376
  ctx.save();
377
  ctx.translate(x,y);
378
- ctx.rotate(heading+slipAngle);
379
  ctx.scale(scale,scale);
380
  if(!active)ctx.globalAlpha=0.55;
381
 
382
- const wheelSteer=Math.max(-0.48,Math.min(0.48,steerAngle*0.42));
383
- // Boost exhaust flame
384
- if(boosting&&active){
385
- ctx.fillStyle='#ff9a3c';
386
- ctx.beginPath();ctx.moveTo(-14,-3);ctx.lineTo(-24,0);ctx.lineTo(-14,3);ctx.closePath();ctx.fill();
387
- ctx.fillStyle='#ffd073';
388
- ctx.beginPath();ctx.moveTo(-14,-1.6);ctx.lineTo(-19.5,0);ctx.lineTo(-14,1.6);ctx.closePath();ctx.fill();
389
- }
390
-
391
  // Draw wheels / skis
392
  ctx.fillStyle='#101820';
393
  ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
@@ -461,16 +199,7 @@
461
  ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
462
  }
463
 
464
- // Brake light glow
465
- if(braking&&active){
466
- ctx.fillStyle='#ff4d4d';
467
- ctx.shadowColor='#ff4d4d';ctx.shadowBlur=6;
468
- ctx.fillRect(-13.8,-5.2,1.8,3.2);
469
- ctx.fillRect(-13.8,2.0,1.8,3.2);
470
- ctx.shadowBlur=0;
471
- }
472
-
473
- // Eliminated / crashed X badge over car
474
  if(!active){
475
  ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
476
  ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
@@ -478,721 +207,220 @@
478
  ctx.restore();
479
  }
480
 
481
- function buildCandidateProfile(spec,geom,maxTicks){
482
- const ticks=Math.max(60,Math.min(18000,spec.ticksElapsed||maxTicks||900));
483
- const lapSec=spec.bestLapSeconds&&spec.bestLapSeconds>0?spec.bestLapSeconds:null;
484
- const laps=spec.forwardLaps||0;
485
- const raw=spec.rawMetrics||{};
486
- const driftFactor=raw.total_drifting_amount!=null?Math.min(1.4,Math.max(0.15,raw.total_drifting_amount/Math.max(1,ticks*0.25))):0.35;
487
- const apexEff=raw.total_racing_line_efficiency!=null?Math.min(1,Math.max(0.2,raw.total_racing_line_efficiency/Math.max(1,ticks*0.5))):0.68;
488
- const contacts=spec.physicalContacts??0;
489
- const active=spec.active!==false;
490
 
491
- // Determine how many laps/distance the car covers over maxTicks
492
- let totalProgress=0.82;
493
- if(lapSec){
494
- const lapTicks=Math.max(90,lapSec*60);
495
- totalProgress=Math.max(1.05,ticks/lapTicks);
496
- }else if(spec.totalDistance&&spec.totalDistance>0){
497
- totalProgress=Math.max(0.18,Math.min(3.5,spec.totalDistance/(geom.totalLength*3.1)));
498
- }else if(laps>0){
499
- totalProgress=Math.max(laps+0.15,1.1);
500
- }
501
- const phaseSeed=(spec.carIndex||0)*0.6180339887;
502
- return {
503
- id:spec.id||'primary',
504
- label:spec.label||'Active Driver',
505
- role:spec.role||'primary',
506
- color:spec.color||'#62dfb1',
507
- vehicleType:Number.isInteger(spec.vehicleType)?spec.vehicleType:0,
508
- network:spec.network||null,
509
- ticksElapsed:ticks,
510
- bestLapSeconds:lapSec,
511
- forwardLaps:laps,
512
- physicalContacts:contacts,
513
- active,
514
- driftFactor,
515
- apexEff,
516
- totalProgress,
517
- phaseSeed
518
- };
519
  }
520
-
521
- function evaluateCarAtTick(profile,geom,tick,maxTicks){
522
- const clampedTick=Math.min(tick,profile.ticksElapsed);
523
- const alive=tick<=profile.ticksElapsed||profile.active;
524
- const frac=clampedTick/Math.max(1,profile.ticksElapsed);
525
- // Modulate progress slightly by cornering slowdowns so cars brake into sharp turns and accelerate on straights
526
- const baseU=frac*profile.totalProgress;
527
- const sCenter=sampleAtProgress(geom,baseU);
528
- const nextCorner=sampleAtProgress(geom,baseU+0.032);
529
- const cornerSeverity=Math.min(1,Math.abs(sCenter.curvature)*geom.size*1.15);
530
- const approachSeverity=Math.min(1,Math.abs(nextCorner.curvature)*geom.size*1.25);
531
-
532
- // Apex cutting offset: shift toward inside of upcoming/current turn
533
- const turnSign=Math.sign(sCenter.curvature*0.65+nextCorner.curvature*0.35)||0;
534
- const lateralTarget=-turnSign*geom.roadHalfWidth*0.58*profile.apexEff*Math.min(1,(cornerSeverity+approachSeverity)*0.9);
535
- const wobble=Math.sin(baseU*Math.PI*6+profile.phaseSeed*6.28)*geom.roadHalfWidth*0.08*(1-profile.apexEff);
536
- const latOffset=Math.max(-geom.roadHalfWidth*0.76,Math.min(geom.roadHalfWidth*0.76,lateralTarget+wobble));
537
-
538
- const x=sCenter.x+sCenter.nx*latOffset;
539
- const y=sCenter.y+sCenter.ny*latOffset;
540
-
541
- // Speed & control state
542
- const surfaceGrip=sCenter.surface===2?0.62:sCenter.surface===1?0.78:1.0;
543
- const braking=approachSeverity>0.48&& approachSeverity>cornerSeverity*0.85;
544
- const speedNorm=Math.max(0.24,Math.min(1.0,1.0-approachSeverity*0.42-cornerSeverity*0.28*(2-surfaceGrip)));
545
- const speedKmh=alive?Math.round(75+speedNorm*185):0;
546
- const slipAngle=alive?(-turnSign*cornerSeverity*0.38*profile.driftFactor*(1.35-surfaceGrip*0.5)):0;
547
- const steerCmd=alive?Math.max(-1,Math.min(1,sCenter.curvature*geom.size*1.45)):0;
548
- const throttleCmd=alive?(braking?0.18:Math.min(1,0.55+speedNorm*0.45)):0;
549
- const brakeCmd=alive&&braking?Math.min(1,(approachSeverity-0.35)*1.4):0;
550
- const hbrakeCmd=alive&&Math.abs(slipAngle)>0.18?Math.min(1,Math.abs(slipAngle)*2.2):0;
551
- const boostCmd=alive&&!braking&&cornerSeverity<0.18&&speedNorm>0.82?0.9:0;
552
-
553
- return {
554
- x,y,
555
- heading:sCenter.heading,
556
- curvature:sCenter.curvature,
557
- surface:sCenter.surface,
558
- u:sCenter.u,
559
- lap:Math.floor(baseU)+1,
560
- idx:sCenter.idx||0,
561
- alive,
562
- speedNorm,
563
- speedKmh,
564
- slipAngle,
565
- braking,
566
- boosting:boostCmd>0.5,
567
- controls:{steer:steerCmd,throttle:throttleCmd,brake:brakeCmd,hbrake:hbrakeCmd,boost:boostCmd}
568
- };
569
  }
570
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  function mount({document,fetch,picker}){
572
- const canvas=document.getElementById('trackMap');
573
- const status=document.getElementById('mapStatus');
574
- const legend=document.getElementById('mapLegend');
575
- if(!canvas||!status||!picker)return;
576
- let version=0,atlasPromise=null;
577
- let currentTrack=null,currentSourceName='',staticLayer=null;
578
- let animId=null,playing=true,playSpeed=1,cameraMode='full',focusMode='winners';
579
- let currentTick=0,maxTicks=900,lastFrameTime=0;
580
- let stagedNet=null,analysisState=null,receiptState=null;
581
- let profiles=[],swarmProfiles=[],speedRibbon=[],skidSegments=[],contactMarkers=[];
582
- const layers={outline:true,rays:true,line:true,skids:true,hud:true};
583
-
584
- function createOffscreen(w,h){
585
- if(typeof document.createElement==='function'){
586
- const c=document.createElement('canvas');
587
- if(c&&typeof c.getContext==='function'){c.width=w;c.height=h;return c;}
588
- }
589
- return null;
590
- }
591
-
592
- function rebuildStaticLayer(){
593
- if(!currentTrack)return;
594
- const off=createOffscreen(canvas.width,canvas.height);
595
- if(off){
596
- render(off,currentTrack);
597
- staticLayer=off;
598
  }else{
599
- render(canvas,currentTrack);
600
- staticLayer=null;
601
  }
602
  }
603
-
604
- function rebuildProfiles(){
605
- if(!currentTrack||!currentTrack._geometry)return;
606
- const geom=currentTrack._geometry;
607
- const reqTicks=analysisState?.summary?.conditions?.requested_ticks||receiptState?.report?.requested_ticks||Number(document.getElementById('training_ticks')?.value)||900;
608
- maxTicks=Math.max(60,Math.min(18000,reqTicks));
609
- const scrub=document.getElementById('replay_scrub');
610
- if(scrub)scrub.max=String(maxTicks);
611
- if(currentTick>maxTicks)currentTick=0;
612
-
613
- const vType=analysisState?.summary?.conditions?.vehicle_type??receiptState?.report?.vehicle_type??stagedNet?.Type??Number(document.getElementById('starter_vehicle')?.value||0);
614
- profiles=[];
615
- swarmProfiles=[];
616
-
617
- if(analysisState?.summary?.mode==='train'&&analysisState.summary.winners){
618
- const roleMeta=[
619
- ['fitness','Fitness Leader','#62dfb1'],
620
- ['fastest_lap','Fastest Lap','#ffd073'],
621
- ['clean_laps','Clean Laps','#58a6ff']
622
- ];
623
- for(const [role,label,color] of roleMeta){
624
- const w=analysisState.summary.winners[role];
625
- if(w&&w.status==='available'){
626
- const row=analysisState.descriptorRows?.find(r=>r.candidate_id===w.candidate_id);
627
- profiles.push(buildCandidateProfile({
628
- id:w.candidate_id,
629
- role,
630
- label:`${label} (${w.candidate_id})`,
631
- color,
632
- vehicleType:vType,
633
- network:analysisState.winnerNetworks?.[role]?.decoded||stagedNet,
634
- ticksElapsed:row?.ticks_elapsed||maxTicks,
635
- bestLapSeconds:w.best_lap_seconds,
636
- forwardLaps:w.forward_laps,
637
- physicalContacts:w.physical_contacts,
638
- totalDistance:w.total_actual_distance,
639
- active:row?.objectives?.active??true,
640
- rawMetrics:row?.raw_metrics,
641
- carIndex:w.car
642
- },geom,maxTicks));
643
- }
644
- }
645
- // Build population swarm for the selected generation in descriptor_generation (up to 20 cars)
646
- if(Array.isArray(analysisState.descriptorRows)&&analysisState.descriptorRows.length){
647
- const targetGen=Number(document.getElementById('descriptor_generation')?.value)||analysisState.summary.observations?.generations||1;
648
- const genRows=analysisState.descriptorRows.filter(r=>r.generation===targetGen).slice(0,20);
649
- const palette=['#62dfb1','#ffd073','#58a6ff','#c792ea','#ff8b7b','#7ce4c3'];
650
- genRows.forEach((row,idx)=>{
651
- swarmProfiles.push(buildCandidateProfile({
652
- id:row.candidate_id,
653
- role:'swarm',
654
- label:`Gen ${row.generation} · Car #${row.car}`,
655
- color:palette[idx%palette.length],
656
- vehicleType:vType,
657
- network:stagedNet,
658
- ticksElapsed:row.ticks_elapsed||maxTicks,
659
- bestLapSeconds:row.objectives?.best_lap_seconds,
660
- forwardLaps:row.objectives?.forward_laps,
661
- physicalContacts:row.objectives?.physical_contacts,
662
- totalDistance:row.objectives?.total_actual_distance,
663
- active:row.objectives?.active,
664
- rawMetrics:row.raw_metrics,
665
- carIndex:row.car
666
- },geom,maxTicks));
667
- });
668
- }
669
- }else if(analysisState?.summary?.mode==='evaluate'&&analysisState.evaluation){
670
- const m=analysisState.evaluation.measurements;
671
- profiles.push(buildCandidateProfile({
672
- id:m.candidate_id||'eval',
673
- role:'evaluate',
674
- label:'Frozen Evaluation Policy',
675
- color:'#62dfb1',
676
- vehicleType:vType,
677
- network:stagedNet,
678
- ticksElapsed:m.ticks_elapsed||maxTicks,
679
- bestLapSeconds:m.objectives?.best_lap_seconds,
680
- forwardLaps:m.objectives?.forward_laps,
681
- physicalContacts:m.objectives?.physical_contacts,
682
- totalDistance:m.objectives?.total_actual_distance,
683
- active:m.objectives?.active,
684
- rawMetrics:m.raw_metrics,
685
- carIndex:0
686
- },geom,maxTicks));
687
- }else if(receiptState?.report){
688
- const r=receiptState.report;
689
- profiles.push(buildCandidateProfile({
690
- id:'receipt',
691
- role:'fitness',
692
- label:`Run Candidate (${VEHICLE_NAMES[vType]||'Formula'})`,
693
- color:'#62dfb1',
694
- vehicleType:vType,
695
- network:stagedNet,
696
- ticksElapsed:r.simulated_ticks||maxTicks,
697
- bestLapSeconds:r.best_lap_seconds,
698
- forwardLaps:r.best_lap_seconds?1:0,
699
- physicalContacts:0,
700
- totalDistance:null,
701
- active:true,
702
- carIndex:0
703
- },geom,maxTicks));
704
- }
705
-
706
- if(!profiles.length){
707
- profiles.push(buildCandidateProfile({
708
- id:'staged',
709
- role:'staged',
710
- label:stagedNet?.Name?`Staged · ${stagedNet.Name}`:`Preview · ${VEHICLE_NAMES[vType]||'Formula'} Starter`,
711
- color:'#62dfb1',
712
- vehicleType:vType,
713
- network:stagedNet,
714
- ticksElapsed:maxTicks,
715
- bestLapSeconds:11.8,
716
- forwardLaps:1,
717
- physicalContacts:0,
718
- active:true,
719
- carIndex:0
720
- },geom,maxTicks));
721
- }
722
-
723
- // Precompute speed-heatmap ribbon, skid segments, and wall contact markers for the lead profile (zero per-frame path math)
724
- const lead=getActiveProfiles()[0]||profiles[0];
725
- speedRibbon=[];
726
- skidSegments=[];
727
- contactMarkers=[];
728
- const ribbonSteps=140;
729
- for(let i=0;i<=ribbonSteps;i++){
730
- const st=evaluateCarAtTick(lead,geom,(i/ribbonSteps)*lead.ticksElapsed,maxTicks);
731
- const col=st.braking?'#ff6b6b':st.speedNorm>0.76?'#62dfb1':'#ffd073';
732
- speedRibbon.push({x:st.x,y:st.y,color:col,slip:st.slipAngle,heading:st.heading});
733
- if(Math.abs(st.slipAngle)>0.11&&i>0){
734
- const prev=speedRibbon[i-1];
735
- skidSegments.push({x0:prev.x,y0:prev.y,x1:st.x,y1:st.y,nx:-Math.sin(st.heading),ny:Math.cos(st.heading)});
736
- }
737
- }
738
- const totalContacts=lead.physicalContacts||(lead.active?0:1);
739
- if(totalContacts>0){
740
- // Place contact markers at sharpest corners or terminal point
741
- const sortedCorners=geom.samples.map((s,idx)=>({s,idx,k:Math.abs(s.curvature)})).sort((a,b)=>b.k-a.k);
742
- for(let c=0;c<Math.min(6,totalContacts);c++){
743
- const pick=sortedCorners[c*4]||sortedCorners[0];
744
- if(pick){
745
- const side=pick.s.curvature>0?1:-1;
746
- contactMarkers.push({
747
- x:pick.s.x+pick.s.nx*side*geom.roadHalfWidth*0.92,
748
- y:pick.s.y+pick.s.ny*side*geom.roadHalfWidth*0.92,
749
- label:`Contact #${c+1}`
750
- });
751
- }
752
- }
753
- }
754
- if(!lead.active){
755
- const term=evaluateCarAtTick(lead,geom,lead.ticksElapsed,maxTicks);
756
- contactMarkers.push({x:term.x,y:term.y,label:`Eliminated @ ${lead.ticksElapsed}t`,terminal:true});
757
- }
758
- }
759
-
760
- function getActiveProfiles(){
761
- if(focusMode==='swarm'&&swarmProfiles.length)return swarmProfiles;
762
- if(focusMode==='fitness'||focusMode==='fastest_lap'||focusMode==='clean_laps'){
763
- const found=profiles.find(p=>p.role===focusMode);
764
- if(found)return [found];
765
- }
766
- return profiles;
767
  }
768
-
769
- function drawFrame(){
770
- if(!currentTrack||!currentTrack._geometry)return;
771
- const ctx=canvas.getContext('2d');
772
- if(!ctx||typeof ctx.save!=='function')return;
773
- const w=canvas.width,h=canvas.height;
774
- const geom=currentTrack._geometry;
775
- const activeList=getActiveProfiles();
776
- const lead=activeList[0]||profiles[0];
777
- if(!lead)return;
778
- const leadState=evaluateCarAtTick(lead,geom,currentTick,maxTicks);
779
-
780
- ctx.clearRect(0,0,w,h);
781
- ctx.fillStyle='#0c1926';
782
- ctx.fillRect(0,0,w,h);
783
-
784
  ctx.save();
785
- if(cameraMode==='follow'){
786
- const zoom=2.15;
787
- const lookX=leadState.x+Math.cos(leadState.heading)*28;
788
- const lookY=leadState.y+Math.sin(leadState.heading)*28;
789
- ctx.translate(w*0.5,h*0.5);
790
- ctx.scale(zoom,zoom);
791
- ctx.translate(-lookX,-lookY);
792
- }
793
-
794
- if(staticLayer)ctx.drawImage(staticLayer,0,0);
795
- else render(canvas,currentTrack);
796
-
797
- // 1. Tire skid marks in high-slip drift zones
798
- if(layers.skids&&skidSegments.length){
799
- ctx.save();
800
- ctx.strokeStyle='rgba(12,18,26,0.58)';
801
- ctx.lineWidth=Math.max(1.4,geom.size*0.022);
802
- const trackHalf=Math.max(3,geom.size*0.055);
803
- for(const seg of skidSegments){
804
- for(const s of [-1,1]){
805
- ctx.beginPath();
806
- ctx.moveTo(seg.x0+seg.nx*s*trackHalf,seg.y0+seg.ny*s*trackHalf);
807
- ctx.lineTo(seg.x1+seg.nx*s*trackHalf,seg.y1+seg.ny*s*trackHalf);
808
- ctx.stroke();
809
- }
810
- }
811
- ctx.restore();
812
- }
813
-
814
- // 2. Speed-colored racing line heatmap
815
- if(layers.line&&speedRibbon.length>1){
816
- ctx.save();
817
- ctx.lineWidth=Math.max(2.6,geom.size*0.048);
818
- ctx.lineCap='round';
819
- ctx.globalAlpha=0.82;
820
- for(let i=1;i<speedRibbon.length;i++){
821
- const p0=speedRibbon[i-1],p1=speedRibbon[i];
822
- ctx.beginPath();ctx.moveTo(p0.x,p0.y);ctx.lineTo(p1.x,p1.y);
823
- ctx.strokeStyle=p1.color;ctx.stroke();
824
- }
825
- ctx.restore();
826
  }
827
-
828
- // 3. Collision & Elimination markers (matching r40 collision marker visual language)
829
- if(contactMarkers.length){
830
- ctx.save();
831
- for(const m of contactMarkers){
832
- ctx.beginPath();ctx.arc(m.x,m.y,m.terminal?9:6.5,0,Math.PI*2);
833
- ctx.fillStyle=m.terminal?'rgba(255,107,107,0.28)':'rgba(255,208,115,0.24)';
834
- ctx.fill();
835
- ctx.strokeStyle=m.terminal?'#ff6b6b':'#ffd073';
836
- ctx.lineWidth=2;
837
  ctx.beginPath();
838
- ctx.moveTo(m.x-4.5,m.y-4.5);ctx.lineTo(m.x+4.5,m.y+4.5);
839
- ctx.moveTo(m.x+4.5,m.y-4.5);ctx.lineTo(m.x-4.5,m.y+4.5);
840
- ctx.stroke();
841
- }
842
- ctx.restore();
843
- }
844
-
845
- // 4. Raycast Sensor Fan on the lead vehicle (using actual .evolution Sensors or default 7-ray fan)
846
- const netObj=lead.network||stagedNet;
847
- const sensorDefs=Array.isArray(netObj?.Sensors)&&netObj.Sensors.some(s=>s.$type==='raycast')
848
- ?netObj.Sensors
849
- :[
850
- {$type:'speed'},
851
- {$type:'raycast',Degrees:-75,Length:750},
852
- {$type:'raycast',Degrees:-42,Length:850},
853
- {$type:'raycast',Degrees:-18,Length:950},
854
- {$type:'raycast',Degrees:0,Length:1100},
855
- {$type:'raycast',Degrees:18,Length:950},
856
- {$type:'raycast',Degrees:42,Length:850},
857
- {$type:'raycast',Degrees:75,Length:750}
858
- ];
859
-
860
- const sensorInputs=[];
861
- const pxPerSimUnit=Math.max(0.08,geom.size/768);
862
- for(const s of sensorDefs){
863
- if(s.$type==='raycast'){
864
- const rad=leadState.heading+(s.Degrees||0)*Math.PI/180;
865
- const maxPx=Math.max(28,Math.min(260,(s.Length||800)*pxPerSimUnit*1.6));
866
- const hit=castRayAgainstCorridor(geom,leadState.x,leadState.y,rad,maxPx,leadState.idx);
867
- sensorInputs.push(hit.frac*2-1);
868
- if(layers.rays&&leadState.alive){
869
- ctx.save();
870
- const beamCol=hit.frac<0.32?'#ff6b6b':hit.frac<0.62?'#ffd073':'#62dfb1';
871
- ctx.strokeStyle=beamCol;
872
- ctx.globalAlpha=0.52;
873
- ctx.lineWidth=1.35;
874
- ctx.beginPath();ctx.moveTo(leadState.x,leadState.y);ctx.lineTo(hit.hx,hit.hy);ctx.stroke();
875
- ctx.globalAlpha=0.95;
876
- ctx.fillStyle=beamCol;
877
- ctx.beginPath();ctx.arc(hit.hx,hit.hy,2.6,0,Math.PI*2);ctx.fill();
878
- ctx.restore();
879
- }
880
- }else if(s.$type==='speed'){
881
- sensorInputs.push(leadState.speedNorm);
882
- }else if(s.$type==='velocitySide'||s.$type==='angularVelocity'){
883
- sensorInputs.push(leadState.slipAngle*2);
884
- }else if(s.$type==='trackCurvature'){
885
- sensorInputs.push(Math.max(-1,Math.min(1,leadState.curvature*geom.size)));
886
- }else{
887
- sensorInputs.push(leadState.speedNorm*0.7);
888
- }
889
- }
890
-
891
- // Evaluate actual neural network forward pass if available (<0.01ms)
892
- const nnEval=netObj?.NeuralNetwork?evaluateNeuralNetwork(netObj.NeuralNetwork,sensorInputs):null;
893
- if(nnEval&&Array.isArray(netObj?.Outputs)&&leadState.alive){
894
- const outIds=netObj.Outputs;
895
- const idxSteer=outIds.indexOf(0),idxAccel=outIds.indexOf(1),idxHbk=outIds.indexOf(2),idxBrk=outIds.indexOf(3),idxBst=outIds.indexOf(4);
896
- if(idxSteer>=0)leadState.controls.steer=leadState.controls.steer*0.55+nnEval.outputs[idxSteer]*0.45;
897
- if(idxAccel>=0)leadState.controls.throttle=Math.max(0,(nnEval.outputs[idxAccel]+1)*0.5);
898
- if(idxBrk>=0)leadState.controls.brake=Math.max(0,nnEval.outputs[idxBrk]);
899
- if(idxHbk>=0)leadState.controls.hbrake=Math.max(0,nnEval.outputs[idxHbk]);
900
- if(idxBst>=0)leadState.controls.boost=Math.max(0,nnEval.outputs[idxBst]);
901
- }
902
-
903
- // 5. Draw all active ghost/swarm cars back-to-front so lead car is on top
904
- const carScale=Math.max(0.68,Math.min(1.38,geom.size/64));
905
- for(let i=activeList.length-1;i>=0;i--){
906
- const prof=activeList[i];
907
- const st=i===0?leadState:evaluateCarAtTick(prof,geom,currentTick,maxTicks);
908
- drawVehicle2D(
909
- ctx,st.x,st.y,st.heading,st.slipAngle,st.controls.steer,
910
- prof.vehicleType,prof.color,carScale*(i===0?1.06:0.92),
911
- layers.outline,st.braking,st.boosting,st.alive
912
- );
913
- // Floating badge above ghost cars in multi-car race
914
- if(activeList.length>1&&activeList.length<=4&&typeof ctx.fillText==='function'){
915
- ctx.save();
916
- ctx.font='700 10px system-ui,sans-serif';
917
- ctx.fillStyle=prof.color;
918
- ctx.strokeStyle='#08111c';ctx.lineWidth=2.5;
919
- const tag=prof.role==='fitness'?'FIT':prof.role==='fastest_lap'?'LAP':prof.role==='clean_laps'?'CLN':`#${i+1}`;
920
- ctx.strokeText(tag,st.x-10,st.y-14*carScale);
921
- ctx.fillText(tag,st.x-10,st.y-14*carScale);
922
- ctx.restore();
923
  }
 
 
924
  }
925
  ctx.restore();
926
-
927
- // 6. Screen-Space Telemetry & Ghost Leaderboard HUD Overlay
928
- if(layers.hud&&typeof ctx.fillText==='function'){
929
- drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval);
930
- }
931
-
932
- // Sync timeline scrubber label
933
- const timeLabel=document.getElementById('replay_time_label');
934
- if(timeLabel){
935
- const sec=(currentTick/60).toFixed(2);
936
- const maxSec=(maxTicks/60).toFixed(1);
937
- timeLabel.textContent=`${sec}s / ${maxSec}s · Tick ${Math.round(currentTick)} / ${maxTicks}`;
938
- }
939
- const scrub=document.getElementById('replay_scrub');
940
- if(scrub&&document.activeElement!==scrub)scrub.value=String(Math.round(currentTick));
941
- }
942
-
943
- function drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval){
944
- ctx.save();
945
- // Top-left status pill
946
- ctx.fillStyle='rgba(9,17,27,0.84)';
947
- ctx.strokeStyle='rgba(73,98,124,0.65)';
948
- ctx.lineWidth=1;
949
- ctx.beginPath();ctx.roundRect(12,12,285,54,8);ctx.fill();ctx.stroke();
950
-
951
- ctx.fillStyle='#62dfb1';ctx.font='700 11px system-ui,sans-serif';
952
- const sector=leadState.u<0.333?'S1':leadState.u<0.667?'S2':'S3';
953
- const surfName=NAMES[leadState.surface]||'Asphalt';
954
- ctx.fillText(`${lead.label.slice(0,34).toUpperCase()}`,22,29);
955
- ctx.fillStyle='#c6d9e9';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
956
- const bestStr=lead.bestLapSeconds?`Best ${lead.bestLapSeconds.toFixed(3)}s`:`Contacts ${lead.physicalContacts}`;
957
- ctx.fillText(`LAP ${leadState.lap} · ${sector} · ${surfName.toUpperCase()} · ${bestStr}`,22,46);
958
- ctx.fillStyle='#82e6bb';ctx.font='600 9.5px system-ui,sans-serif';
959
- ctx.fillText('CLIENT TELEMETRY REPLAY · 0 SERVER COMPUTE',22,60);
960
-
961
- // Bottom-left Live Control Telemetry Bars + Speedometer
962
- const bx=12,by=h-94,bw=260,bh=82;
963
- ctx.fillStyle='rgba(9,17,27,0.86)';
964
- ctx.strokeStyle='rgba(73,98,124,0.65)';
965
- ctx.beginPath();ctx.roundRect(bx,by,bw,bh,8);ctx.fill();ctx.stroke();
966
-
967
- // Speed & Slip readout
968
- ctx.fillStyle='#e5edf5';ctx.font='800 18px ui-monospace,SFMono-Regular,monospace';
969
- ctx.fillText(`${String(leadState.speedKmh).padStart(3,' ')} km/h`,bx+12,by+24);
970
- ctx.fillStyle='#a2b4c8';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
971
- const slipDeg=(leadState.slipAngle*180/Math.PI).toFixed(1);
972
- ctx.fillText(`Slip ${slipDeg}°`,bx+12,by+42);
973
- ctx.fillText(`Steer ${(leadState.controls.steer*100).toFixed(0)}%`,bx+12,by+58);
974
- ctx.fillStyle=leadState.alive?'#62dfb1':'#ff6b6b';
975
- ctx.fillText(leadState.alive?'ACTIVE':'STOPPED',bx+12,by+74);
976
-
977
- // 5 Control bars (STR, THR, BRK, HBK, BST)
978
- const bars=[
979
- ['STR',(leadState.controls.steer+1)*0.5,'#58a6ff',true],
980
- ['THR',leadState.controls.throttle,'#62dfb1',false],
981
- ['BRK',leadState.controls.brake,'#ff6b6b',false],
982
- ['HBK',leadState.controls.hbrake,'#ffd073',false],
983
- ['BST',leadState.controls.boost,'#c792ea',false]
984
- ];
985
- bars.forEach(([name,val,col,bipolar],idx)=>{
986
- const ry=by+12+idx*13.5,rx=bx+142,rw=104,rh=8;
987
- ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px ui-monospace,SFMono-Regular,monospace';
988
- ctx.fillText(name,bx+112,ry+7.5);
989
- ctx.fillStyle='#152233';ctx.fillRect(rx,ry,rw,rh);
990
- ctx.fillStyle=col;
991
- if(bipolar){
992
- const mid=rx+rw*0.5,span=(val-0.5)*rw;
993
- ctx.fillRect(span>=0?mid:mid+span,ry,Math.abs(span),rh);
994
- }else{
995
- ctx.fillRect(rx,ry,Math.max(0,Math.min(1,val))*rw,rh);
996
- }
997
- });
998
-
999
- // Top-right Ghost Race Standings / Mini Neural Activations
1000
- if(activeList.length>1){
1001
- const gx=w-232,gy=12,gw=220,gh=18+Math.min(4,activeList.length)*18;
1002
- ctx.fillStyle='rgba(9,17,27,0.84)';
1003
- ctx.strokeStyle='rgba(73,98,124,0.65)';
1004
- ctx.beginPath();ctx.roundRect(gx,gy,gw,gh,8);ctx.fill();ctx.stroke();
1005
- ctx.fillStyle='#a2b4c8';ctx.font='700 10px system-ui,sans-serif';
1006
- ctx.fillText(focusMode==='swarm'?`POPULATION SWARM (${activeList.length} CARS)`:'GHOST RACE COMPARISON',gx+10,gy+13);
1007
- activeList.slice(0,4).forEach((p,idx)=>{
1008
- const py=gy+28+idx*16;
1009
- ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(gx+15,py-3.5,4,0,Math.PI*2);ctx.fill();
1010
- ctx.fillStyle='#e5edf5';ctx.font='600 10.5px system-ui,sans-serif';
1011
- const shortRole=p.role==='fitness'?'Fitness Leader':p.role==='fastest_lap'?'Fastest Lap':p.role==='clean_laps'?'Clean Laps':p.label.slice(0,18);
1012
- const stat=p.bestLapSeconds?`${p.bestLapSeconds.toFixed(2)}s`:`${p.ticksElapsed}t`;
1013
- ctx.fillText(`${shortRole} · ${stat}`,gx+24,py);
1014
- });
1015
- }
1016
-
1017
- // Mini live neural activation visualizer when network is present
1018
- if(nnEval&&nnEval.layerActivations.length>=2){
1019
- const nx=w-178,ny=h-84,nw=166,nh=72;
1020
- ctx.fillStyle='rgba(9,17,27,0.84)';
1021
- ctx.strokeStyle='rgba(73,98,124,0.65)';
1022
- ctx.beginPath();ctx.roundRect(nx,ny,nw,nh,8);ctx.fill();ctx.stroke();
1023
- ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px system-ui,sans-serif';
1024
- ctx.fillText(`LIVE POLICY (${nnEval.layerActivations.map(l=>l.length).join('→')})`,nx+8,ny+12);
1025
- const layersAct=nnEval.layerActivations;
1026
- const lCount=layersAct.length;
1027
- for(let l=0;l<lCount;l++){
1028
- const nodes=layersAct[l],shown=Math.min(8,nodes.length);
1029
- const lx=nx+14+l*(nw-28)/Math.max(1,lCount-1);
1030
- for(let i=0;i<shown;i++){
1031
- const ly=ny+20+(i+0.5)*(nh-26)/shown;
1032
- const v=nodes[i]||0;
1033
- ctx.fillStyle=v>=0?'#62dfb1':'#ff6b6b';
1034
- ctx.globalAlpha=0.3+Math.min(0.7,Math.abs(v)*0.7);
1035
- ctx.beginPath();ctx.arc(lx,ly,2.6,0,Math.PI*2);ctx.fill();
1036
- }
1037
  }
1038
- ctx.globalAlpha=1;
1039
  }
1040
- ctx.restore();
1041
  }
1042
-
1043
- function stepLoop(now){
1044
- animId=null;
1045
- if(!playing||!currentTrack||(typeof document.hidden==='boolean'&&document.hidden))return;
1046
- if(!lastFrameTime)lastFrameTime=now;
1047
- const dt=Math.min(0.1,(now-lastFrameTime)/1000);
1048
- lastFrameTime=now;
1049
- // Pause animation automatically while a server training job is actively running so 0% client CPU is used during runs
1050
- if(!root?.TrainingClient?.isBusy?.()){
1051
- currentTick+=dt*60*playSpeed;
1052
- if(currentTick>maxTicks)currentTick=0;
1053
- drawFrame();
1054
- }
1055
- if(typeof root?.requestAnimationFrame==='function')animId=root.requestAnimationFrame(stepLoop);
1056
- }
1057
-
1058
- function startLoop(){
1059
- if(animId||!playing||typeof root?.requestAnimationFrame!=='function')return;
1060
- lastFrameTime=0;
1061
- animId=root.requestAnimationFrame(stepLoop);
1062
- }
1063
-
1064
- function stopLoop(){
1065
- if(animId&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
1066
- animId=null;
1067
- }
1068
-
1069
- function bindReplayControls(){
1070
- const playBtn=document.getElementById('replay_play');
1071
- const resetBtn=document.getElementById('replay_reset');
1072
- const scrub=document.getElementById('replay_scrub');
1073
- const modeSel=document.getElementById('replay_mode');
1074
- const speedSel=document.getElementById('replay_speed');
1075
- const camSel=document.getElementById('replay_camera');
1076
- if(playBtn&&!playBtn.dataset.bound){
1077
- playBtn.dataset.bound='1';
1078
- playBtn.addEventListener('click',()=>{
1079
- playing=!playing;
1080
- playBtn.textContent=playing?'Pause':'Play';
1081
- if(playing)startLoop();else{stopLoop();drawFrame();}
1082
- });
1083
- }
1084
- if(resetBtn&&!resetBtn.dataset.bound){
1085
- resetBtn.dataset.bound='1';
1086
- resetBtn.addEventListener('click',()=>{currentTick=0;drawFrame();});
1087
- }
1088
- if(scrub&&!scrub.dataset.bound){
1089
- scrub.dataset.bound='1';
1090
- scrub.addEventListener('input',()=>{
1091
- currentTick=Number(scrub.value)||0;
1092
- drawFrame();
1093
- });
1094
- }
1095
- if(modeSel&&!modeSel.dataset.bound){
1096
- modeSel.dataset.bound='1';
1097
- modeSel.addEventListener('change',()=>{
1098
- focusMode=modeSel.value;
1099
- rebuildProfiles();
1100
- drawFrame();
1101
- });
1102
- }
1103
- if(speedSel&&!speedSel.dataset.bound){
1104
- speedSel.dataset.bound='1';
1105
- speedSel.addEventListener('change',()=>{playSpeed=Number(speedSel.value)||1;});
1106
- }
1107
- if(camSel&&!camSel.dataset.bound){
1108
- camSel.dataset.bound='1';
1109
- camSel.addEventListener('change',()=>{cameraMode=camSel.value;drawFrame();});
1110
- }
1111
- for(const [key,id] of [['outline','replay_layer_outline'],['rays','replay_layer_rays'],['line','replay_layer_line'],['skids','replay_layer_skids'],['hud','replay_layer_hud']]){
1112
- const el=document.getElementById(id);
1113
- if(el&&!el.dataset.bound){
1114
- el.dataset.bound='1';
1115
- el.addEventListener('change',()=>{layers[key]=el.checked;drawFrame();});
1116
- }
1117
- }
1118
- const starterVeh=document.getElementById('starter_vehicle');
1119
- if(starterVeh&&!starterVeh.dataset.replayBound){
1120
- starterVeh.dataset.replayBound='1';
1121
- starterVeh.addEventListener('change',()=>{rebuildProfiles();drawFrame();});
1122
  }
 
1123
  }
1124
-
1125
  async function refresh(){
1126
- const current=++version;
1127
- bindReplayControls();
1128
  const info=picker.info();
1129
- const source=info.source==='builtin'?info.display_name:document.getElementById('track')?.files?.[0]?.name;
1130
- if(!source){
1131
- currentTrack=null;staticLayer=null;stopLoop();
1132
- status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;
1133
- }
1134
  status.textContent='Loading '+source+'…';
1135
  try{
1136
  atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
1137
  if(!r.ok)throw Error('Track atlas unavailable');return r.json();
1138
  });
1139
  const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
1140
- const track=model(await decode(file),atlas);
 
 
 
1141
  if(current!==version)return;
1142
- currentTrack=track;
1143
- currentSourceName=source;
1144
  render(canvas,track);
1145
- rebuildStaticLayer();
1146
- rebuildProfiles();
1147
- drawFrame();
1148
- startLoop();
1149
  const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
1150
  status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
1151
- legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>{
1152
- const i=NAMES.indexOf(name);return name+' '+counts[i];
1153
- }).join(' · ')+' · Zero-compute telemetry visualizer: static circuit is cached once; ghost trajectories & sensor rays run locally in browser.';
1154
  }catch(error){
1155
  if(current!==version)return;
1156
- currentTrack=null;staticLayer=null;stopLoop();
1157
- renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
1158
  }
1159
  }
1160
- 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);}
1161
- document.addEventListener('track-library-change',refresh);
1162
- if(typeof document.addEventListener==='function'){
1163
- document.addEventListener('visibilitychange',()=>{if(document.hidden)stopLoop();else if(playing&&currentTrack)startLoop();});
1164
- }
1165
-
1166
  const controller={
1167
  refresh,
1168
- setStagedNetwork(net){
1169
- stagedNet=net||null;
1170
- if(currentTrack){rebuildProfiles();drawFrame();}
 
 
 
 
 
 
 
 
 
 
1171
  },
1172
- setRunAnalysis(analysis,receipt){
1173
- analysisState=analysis||null;
1174
- receiptState=receipt||null;
1175
- const modeSel=document.getElementById('replay_mode');
1176
- if(modeSel){
1177
- if(analysis?.summary?.mode==='train')focusMode=modeSel.value='winners';
1178
- else if(analysis?.summary?.mode==='evaluate')focusMode=modeSel.value='fitness';
1179
- }
1180
- currentTick=0;
1181
- if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
1182
- },
1183
- focusCandidate(roleOrGen){
1184
- const modeSel=document.getElementById('replay_mode');
1185
- if(roleOrGen==='swarm'){focusMode='swarm';if(modeSel)modeSel.value='swarm';}
1186
- else if(['winners','fitness','fastest_lap','clean_laps','staged'].includes(roleOrGen)){
1187
- focusMode=roleOrGen;if(modeSel)modeSel.value=roleOrGen;
1188
- }
1189
- currentTick=0;
1190
- if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
1191
- }
1192
  };
1193
  if(typeof root==='object'&&root)root.TrackReplay=controller;
1194
- refresh();
1195
  return controller;
1196
  }
1197
- return {mount,model,decode,render};
1198
  });
 
1
+ /* Browser-only preview of the selected saved track. No upload for preview. */
2
  (function(root,factory){
3
  if(typeof module==='object'&&module.exports)module.exports=factory(root);
4
  else root.TrackMap=factory(root);
5
  })(typeof window==='undefined'?this:window,function(root){
6
  const COLOURS=['#607e96','#b89561','#b9d9ed'];
7
  const NAMES=['Asphalt','Dirt','Ice'];
 
8
  const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
9
  function vector(value){
10
  const match=typeof value==='string'&&VECTOR.exec(value);
 
87
  if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
88
  return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
89
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  function render(canvas,track){
91
  const context=canvas.getContext('2d');
92
  if(!context)throw Error('Canvas preview unavailable');
 
96
  const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
97
  const size=Math.min(160,(w-70)/columns,(h-70)/rows);
98
  const left=(w-columns*size)/2,top=(h-rows*size)/2;
 
 
 
 
 
 
 
 
 
 
 
 
99
  for(const tile of track.tiles){
100
  const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
101
  context.fillStyle=COLOURS[tile.surface];
 
106
  const [entry,exit]=tile.connections;
107
  const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
108
  const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
 
 
 
 
 
 
 
109
  context.beginPath();context.moveTo(...a);
110
  context.bezierCurveTo(...p1,...p2,...z);
111
  context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
112
  context.lineCap='round';context.stroke();
113
  }
114
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
116
  const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
117
  context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
118
  context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
119
  }
120
+ // Vehicle illustration uses only recorded position, heading, type and active state.
121
+ function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  ctx.save();
123
  ctx.translate(x,y);
124
+ ctx.rotate(heading);
125
  ctx.scale(scale,scale);
126
  if(!active)ctx.globalAlpha=0.55;
127
 
128
+ const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
 
 
 
 
 
 
 
 
129
  // Draw wheels / skis
130
  ctx.fillStyle='#101820';
131
  ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
 
199
  ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
200
  }
201
 
202
+ // The recorded active flag controls the inactive badge.
 
 
 
 
 
 
 
 
 
203
  if(!active){
204
  ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
205
  ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
 
207
  ctx.restore();
208
  }
209
 
 
 
 
 
 
 
 
 
 
210
 
211
+ function hash(value,label){
212
+ if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
213
+ return value.toLowerCase();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  }
215
+ function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
216
+ if(!payload||payload.schema!=='ald.recorded-replay'||payload.version!==1||payload.kind!=='fresh_cpu_evaluation')
217
+ throw Error('This file is not a supported recorded CPU evaluation');
218
+ if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
219
+ payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
220
+ throw Error('Recorded evaluation conditions are unsupported');
221
+ const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
222
+ if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
223
+ throw Error('The recording does not match the selected track and network');
224
+ const ticks=payload.requested_ticks;
225
+ if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
226
+ if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
227
+ const expectedTicks=[1];
228
+ for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
229
+ if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
230
+ if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
231
+ throw Error('Recording is missing its expected native samples');
232
+ const frames=payload.frames.map((frame,index)=>{
233
+ if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
234
+ const copy={tick:frame.tick,active:frame.active};
235
+ for(const key of ['x','y','rotation','vx','vy']){
236
+ if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
237
+ copy[key]=frame[key];
238
+ }
239
+ for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
240
+ if(frame[key]===undefined)continue;
241
+ if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
242
+ if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
243
+ copy[key]=frame[key];
244
+ }
245
+ return copy;
246
+ });
247
+ return {schema:payload.schema,version:1,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
248
+ first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
249
+ requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  }
251
+ function sampleRecording(recording,tick){
252
+ if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
253
+ const frames=recording.frames,last=frames.length-1;
254
+ const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
255
+ let lo=0,hi=last;
256
+ while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
257
+ const a=frames[lo],b=frames[Math.min(last,lo+1)];
258
+ const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
259
+ const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
260
+ // Pose interpolation is only between adjacent recorded samples. HUD values
261
+ // remain the actual preceding sample, never reconstructed controls or rays.
262
+ 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}};
263
+ }
264
+ function viewport(canvas,track){
265
+ const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
266
+ const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
267
+ return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
268
+ }
269
+ function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
270
  function mount({document,fetch,picker}){
271
+ const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
272
+ if(!canvas||!status||!legend||!picker)return;
273
+ const element=id=>document.getElementById(id);
274
+ let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
275
+ let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
276
+ let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
277
+ let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
278
+ const layers={outline:true,line:true,hud:true};
279
+ const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
280
+ const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
281
+ function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
282
+ function syncControls(reason){
283
+ const last=recording?.frames[recording.frames.length-1].tick;
284
+ const play=element('replay_play');
285
+ if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
286
+ for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud']){
287
+ const node=element(id);if(node)node.disabled=!recording;
288
+ }
289
+ const scrub=element('replay_scrub');
290
+ if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
291
+ if(!recording){
292
+ setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
293
+ setText('replay_time_label','No recorded samples');
294
+ setText('replay_readout','Position, speed, controls and sensors are unavailable until a replay is recorded.');
 
 
295
  }else{
296
+ const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
297
+ setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${state}`);
298
  }
299
  }
300
+ function stopPlayback(){
301
+ if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
302
+ animId=null;playing=false;lastFrameTime=null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  }
304
+ 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);}
305
+ function draw(){
306
+ if(!currentTrack){renderEmpty();return;}
307
+ const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
308
+ const state=recording?sampleRecording(recording,cursor):null;
309
+ const position=state?worldPoint(view,state.x,state.y):null;
310
+ ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
 
 
 
 
 
 
 
 
 
311
  ctx.save();
312
+ if(position&&cameraMode==='follow'){
313
+ ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
+ if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
316
+ if(recording){
317
+ if(layers.line){
 
 
 
 
 
 
 
318
  ctx.beginPath();
319
+ recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
320
+ ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
+ // Native rotation zero faces -Y; the vehicle illustration faces +X.
323
+ 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);
324
  }
325
  ctx.restore();
326
+ if(state){
327
+ const frame=state.frame,speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
328
+ const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
329
+ setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Recorded sample ${frame.tick}`);
330
+ 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. Controls and sensors unavailable.`);
331
+ if(layers.hud){
332
+ ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
333
+ ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
334
+ ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
335
+ ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText('Controls and sensors unavailable',24,77);ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
 
337
  }
 
338
  }
339
+ function clearRecording(reason){
340
+ const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;
341
+ if(hadRecording)draw();syncControls(reason);
342
+ }
343
+ function step(now){
344
+ animId=null;if(!playing||!recording)return;
345
+ if(document.hidden){stopPlayback();syncControls();return;}
346
+ if(lastFrameTime===null)lastFrameTime=now;
347
+ const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
348
+ const last=recording.frames[recording.frames.length-1].tick;
349
+ cursor=Math.min(last,cursor+elapsed*60*playSpeed);
350
+ draw();
351
+ if(cursor>=last){stopPlayback();syncControls();return;}
352
+ syncControls();animId=root.requestAnimationFrame(step);
353
+ }
354
+ function bind(){
355
+ const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
356
+ on('replay_play','click',()=>{
357
+ if(!recording)return;
358
+ if(playing){stopPlayback();syncControls();return;}
359
+ if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
360
+ playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
361
+ });
362
+ on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
363
+ on('replay_scrub','input',()=>{
364
+ if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
365
+ stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
366
+ });
367
+ on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
368
+ on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
369
+ for(const key of ['outline','line','hud']){
370
+ const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
371
+ on(id,'change',()=>{layers[key]=element(id).checked;draw();});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  }
373
+ document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
374
  }
 
375
  async function refresh(){
376
+ const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;
 
377
  const info=picker.info();
378
+ const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
379
+ if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
 
 
 
380
  status.textContent='Loading '+source+'…';
381
  try{
382
  atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
383
  if(!r.ok)throw Error('Track atlas unavailable');return r.json();
384
  });
385
  const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
386
+ if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
387
+ 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);
388
+ const [saved,sha]=await Promise.all([decode(file),hashPromise]);
389
+ const track=model(saved,atlas);
390
  if(current!==version)return;
391
+ currentTrack=track;currentTrackSha=sha;
 
392
  render(canvas,track);
393
+ const off=document.createElement?.('canvas');
394
+ if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
 
 
395
  const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
396
  status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
397
+ legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Orange line follows the track centerline. Collision shapes are not shown. Vehicle positions appear only from a recorded replay.';
 
 
398
  }catch(error){
399
  if(current!==version)return;
400
+ currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
401
+ status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
402
  }
403
  }
 
 
 
 
 
 
404
  const controller={
405
  refresh,
406
+ setStagedNetwork(){clearRecording();},
407
+ setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
408
+ focusCandidate(){clearRecording();},
409
+ clearRecording,
410
+ setRecording(payload,expectedTrackSha,expectedNetworkSha){
411
+ clearRecording();
412
+ try{
413
+ const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
414
+ if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
415
+ if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
416
+ recording=next;cursor=next.frames[0].tick;draw();syncControls();
417
+ return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
418
+ }catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
419
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  };
421
  if(typeof root==='object'&&root)root.TrackReplay=controller;
422
+ bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
423
  return controller;
424
  }
425
+ return {mount,model,decode,render,validateRecording,sampleRecording};
426
  });