ald-tao-gpu / track-map.js
TiGa-RCE's picture
Render native track curves and recorded poses only
ff2b518 verified
Raw History Blame
25.6 kB
/* Browser-only preview of the selected saved track. No upload for preview. */
(function(root,factory){
if(typeof module==='object'&&module.exports)module.exports=factory(root);
else root.TrackMap=factory(root);
})(typeof window==='undefined'?this:window,function(root){
const COLOURS=['#607e96','#b89561','#b9d9ed'];
const NAMES=['Asphalt','Dirt','Ice'];
const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
function vector(value){
const match=typeof value==='string'&&VECTOR.exec(value);
if(!match)throw Error('Track has invalid tile coordinates');
const x=Number(match[1]),y=Number(match[2]);
if(!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||Math.abs(x)>1000||Math.abs(y)>1000)
throw Error('Track coordinates exceed preview bounds');
return [x,y];
}
function connection(value,message='Track has an invalid piece connection'){
if(!value||typeof value!=='object'||Array.isArray(value)||
!Number.isInteger(value.side)||value.side<0||value.side>3||
!Number.isInteger(value.index)||value.index<0||value.index>2||
!Number.isInteger(value.type)||value.type<0)throw Error(message);
return {side:value.side,index:value.index,type:value.type};
}
function connectionPoint(value,x,y,size){
const {side,index}=connection(value);
// Native Connection.position uses offsets 128,384,640 in a 768-unit tile.
// Shared boundaries keep adjacent path endpoints and the saved start aligned.
const offset=[128,384,640][index]/768*size;
switch(side){
case 0:return [x+offset,y];
case 1:return [x+offset,y+size];
case 2:return [x,y+offset];
case 3:return [x+size,y+offset];
}
}
function connectionControl(own,other,point,target){
// Native TrackUtils.GetPathCurve (random_track.rs, b90b8af): each
// connection's inward normal sets its cubic handle, adjusted by port type.
// At the exit this is the next tile's negated entry normal, so the same
// per-tile construction also preserves the curve when endpoints reverse.
const [nx,ny]=[[0,1],[0,-1],[1,0],[-1,0]][own.side];
const dx=target[0]-point[0],dy=target[1]-point[1];
const angle=own.type===1?Math.PI/4:own.type===3?Math.atan2(128,384):own.type===4?Math.atan2(384,128):0;
const rotation=Math.sign(nx*dy-ny*dx)*angle;
const c=Math.cos(rotation),s=Math.sin(rotation);
const smoothness=[0,2,100].includes(own.type)&&[1,3,4].includes(other.type)?0.2:0.5;
const reach=Math.hypot(dx,dy)*smoothness;
return [point[0]+(nx*c-ny*s)*reach,point[1]+(nx*s+ny*c)*reach];
}
async function decode(file){
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks');
const reader=file.stream().pipeThrough(new DecompressionStream('gzip')).getReader();
const chunks=[];let total=0;
try{
while(true){
const {done,value}=await reader.read();if(done)break;
total+=value.byteLength;
if(total>2*1024*1024)throw Error('Track preview exceeds the 2 MB expanded limit');
chunks.push(value);
}
}finally{reader.releaseLock();}
const bytes=new Uint8Array(total);let at=0;
for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}
return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));
}
function model(saved,atlas){
const entries=Object.entries(saved?.Tiles??{});
if(entries.length<1||entries.length>256)throw Error('Track has no previewable tiles');
const tiles=entries.map(([position,item])=>{
const [x,y]=vector(position);
const id=item?.Block?.TileSetId;
const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`];
if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas');
if(!Array.isArray(block.connections)||block.connections.length!==2)throw Error('Track piece connections are unavailable');
return {x,y,surface:block.surface,connections:block.connections.map(value=>connection(value))};
});
if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile');
const start=vector(saved.StartTileCoords);
const startTile=tiles.find(tile=>tile.x===start[0]&&tile.y===start[1]);
if(!startTile)throw Error('Start tile is not present in this track');
const startConnection=connection({side:saved.StartTileConnection?.Side,index:saved.StartTileConnection?.Index,type:saved.StartTileConnection?.Type},'Track has an invalid or missing start connection');
if(!startTile.connections.some(item=>item.side===startConnection.side&&item.index===startConnection.index&&item.type===startConnection.type))
throw Error('Saved start connection does not match the start tile');
const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x));
const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y));
if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
}
function render(canvas,track){
const context=canvas.getContext('2d');
if(!context)throw Error('Canvas preview unavailable');
const w=canvas.width,h=canvas.height;
context.clearRect(0,0,w,h);
context.fillStyle='#0c1926';context.fillRect(0,0,w,h);
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
const size=Math.min(160,(w-70)/columns,(h-70)/rows);
const left=(w-columns*size)/2,top=(h-rows*size)/2;
for(const tile of track.tiles){
const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
context.fillStyle=COLOURS[tile.surface];
context.beginPath();context.roundRect(x+2,y+2,size-4,size-4,Math.max(4,size*.13));context.fill();
context.strokeStyle='#d7e8f2';context.lineWidth=Math.max(1,size*.018);
context.stroke();
if(Array.isArray(tile.connections)&&tile.connections.length>=2){
const [entry,exit]=tile.connections;
const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
context.beginPath();context.moveTo(...a);
context.bezierCurveTo(...p1,...p2,...z);
context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
context.lineCap='round';context.stroke();
}
}
const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
}
// Vehicle illustration uses only recorded position, heading, type and active state.
function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
ctx.save();
ctx.translate(x,y);
ctx.rotate(heading);
ctx.scale(scale,scale);
if(!active)ctx.globalAlpha=0.55;
const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
// Draw wheels / skis
ctx.fillStyle='#101820';
ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
ctx.lineWidth=showOutline?1.2:0.7;
if(vehicleType===2){
// Snowmobile twin front skis + rear track
for(const wy of [-5.2,5.2]){
ctx.save();ctx.translate(8.5,wy);ctx.rotate(wheelSteer);
ctx.beginPath();ctx.roundRect(-4.5,-1.3,9,2.6,1);ctx.fill();ctx.stroke();
ctx.restore();
}
ctx.beginPath();ctx.roundRect(-12,-4.2,13,8.4,1.5);ctx.fill();
}else{
// Rear wheels
const rwY=vehicleType===3?6.8:6.2;
for(const wy of [-rwY,rwY]){
ctx.beginPath();ctx.roundRect(-10.5,wy-1.8,6.5,3.6,1);ctx.fill();ctx.stroke();
}
// Steerable front wheels
const fwY=vehicleType===0?6.4:6.0;
for(const wy of [-fwY,fwY]){
ctx.save();ctx.translate(8.2,wy);ctx.rotate(wheelSteer);
ctx.beginPath();ctx.roundRect(-3.1,-1.6,6.2,3.2,1);ctx.fill();ctx.stroke();
ctx.restore();
}
}
// Chassis silhouette by VehicleType
ctx.fillStyle=color;
ctx.strokeStyle=showOutline?'#ffffff':'#0c1926';
ctx.lineWidth=showOutline?2.1:1.1;
ctx.beginPath();
if(vehicleType===0){
// Formula open-wheel: rear wing, sidepods, tapered nose, front wing
ctx.moveTo(-13.5,-6.2);ctx.lineTo(-9.5,-6.2);ctx.lineTo(-9.5,-3.6);
ctx.lineTo(-6.5,-5.5);ctx.lineTo(3.5,-4.8);ctx.lineTo(8.5,-2.0);
ctx.lineTo(13.5,-5.8);ctx.lineTo(15.2,-5.8);ctx.lineTo(15.2,5.8);
ctx.lineTo(13.5,5.8);ctx.lineTo(8.5,2.0);ctx.lineTo(3.5,4.8);
ctx.lineTo(-6.5,5.5);ctx.lineTo(-9.5,3.6);ctx.lineTo(-9.5,6.2);
ctx.lineTo(-13.5,6.2);ctx.closePath();
}else if(vehicleType===1){
// Rally car: wide-body hatchback
ctx.roundRect(-12.5,-6.3,25,12.6,3.4);
}else if(vehicleType===2){
// Snowmobile: sleek sled cowling
ctx.moveTo(-11.5,-4.6);ctx.lineTo(5.5,-5.0);ctx.lineTo(13.5,0);
ctx.lineTo(5.5,5.0);ctx.lineTo(-11.5,4.6);ctx.closePath();
}else{
// Truck: heavy cab + cargo bed
ctx.roundRect(-14,-7.0,28,14.0,2.4);
}
ctx.fill();ctx.stroke();
// Cockpit / roof / bed details
ctx.fillStyle='#0c1725';
if(vehicleType===0){
ctx.beginPath();ctx.ellipse(-0.5,0,4.2,2.3,0,0,Math.PI*2);ctx.fill();
// Halo bar
ctx.strokeStyle='#d7e8f2';ctx.lineWidth=1.1;
ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(3.4,0);ctx.stroke();
}else if(vehicleType===1){
// Windshield + roof scoop + rear spoiler
ctx.beginPath();ctx.roundRect(-4.5,-4.6,10.5,9.2,2);ctx.fill();
ctx.fillStyle='#ffffff';ctx.beginPath();ctx.arc(0.5,0,1.4,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#101d2c';ctx.fillRect(-12,-5.6,2.4,11.2);
}else if(vehicleType===2){
ctx.beginPath();ctx.roundRect(-4,-2.8,8,5.6,1.6);ctx.fill();
}else{
// Truck bed & cab windshield
ctx.fillRect(-12.2,-5.4,11,10.8);
ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
}
// The recorded active flag controls the inactive badge.
if(!active){
ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
}
ctx.restore();
}
function hash(value,label){
if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
return value.toLowerCase();
}
function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
if(!payload||payload.schema!=='ald.recorded-replay'||payload.version!==1||payload.kind!=='fresh_cpu_evaluation')
throw Error('This file is not a supported recorded CPU evaluation');
if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
throw Error('Recorded evaluation conditions are unsupported');
const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
throw Error('The recording does not match the selected track and network');
const ticks=payload.requested_ticks;
if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
const expectedTicks=[1];
for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
throw Error('Recording is missing its expected native samples');
const frames=payload.frames.map((frame,index)=>{
if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
const copy={tick:frame.tick,active:frame.active};
for(const key of ['x','y','rotation','vx','vy']){
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
copy[key]=frame[key];
}
for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
if(frame[key]===undefined)continue;
if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
copy[key]=frame[key];
}
return copy;
});
return {schema:payload.schema,version:1,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
}
function sampleRecording(recording,tick){
if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
const frames=recording.frames,last=frames.length-1;
const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
let lo=0,hi=last;
while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
const a=frames[lo],b=frames[Math.min(last,lo+1)];
const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
// Pose interpolation is only between adjacent recorded samples. HUD values
// remain the actual preceding sample, never reconstructed controls or rays.
return {tick:at,x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,rotation:a.rotation+angle*t,frame:{...a}};
}
function viewport(canvas,track){
const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
}
function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
function mount({document,fetch,picker}){
const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
if(!canvas||!status||!legend||!picker)return;
const element=id=>document.getElementById(id);
let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
const layers={outline:true,line:true,hud:true};
const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
function syncControls(reason){
const last=recording?.frames[recording.frames.length-1].tick;
const play=element('replay_play');
if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud']){
const node=element(id);if(node)node.disabled=!recording;
}
const scrub=element('replay_scrub');
if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
if(!recording){
setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
setText('replay_time_label','No recorded samples');
setText('replay_readout','Position, speed, controls and sensors are unavailable until a replay is recorded.');
}else{
const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${state}`);
}
}
function stopPlayback(){
if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
animId=null;playing=false;lastFrameTime=null;
}
function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);}
function draw(){
if(!currentTrack){renderEmpty();return;}
const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
const state=recording?sampleRecording(recording,cursor):null;
const position=state?worldPoint(view,state.x,state.y):null;
ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.save();
if(position&&cameraMode==='follow'){
ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
}
if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
if(recording){
if(layers.line){
ctx.beginPath();
recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
}
// Native rotation zero faces -Y; the vehicle illustration faces +X.
drawVehicle2D(ctx,...position,state.rotation-Math.PI/2,recording.vehicle_type,'#62dfb1',Math.max(0.55,Math.min(1.1,view.size/120)),layers.outline,state.frame.active);
}
ctx.restore();
if(state){
const frame=state.frame,speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Recorded sample ${frame.tick}`);
setText('replay_readout',`Recorded sample tick ${frame.tick} (${(frame.tick/60).toFixed(2)}s). Speed ${speed.toFixed(1)} km/h, derived from recorded velocity. Position (${frame.x.toFixed(2)}, ${frame.y.toFixed(2)}). Contacts ${contacts}. ${frame.active?'Active':'Inactive'} at this sample. Controls and sensors unavailable.`);
if(layers.hud){
ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText('Controls and sensors unavailable',24,77);ctx.restore();
}
}
}
function clearRecording(reason){
const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;
if(hadRecording)draw();syncControls(reason);
}
function step(now){
animId=null;if(!playing||!recording)return;
if(document.hidden){stopPlayback();syncControls();return;}
if(lastFrameTime===null)lastFrameTime=now;
const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
const last=recording.frames[recording.frames.length-1].tick;
cursor=Math.min(last,cursor+elapsed*60*playSpeed);
draw();
if(cursor>=last){stopPlayback();syncControls();return;}
syncControls();animId=root.requestAnimationFrame(step);
}
function bind(){
const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
on('replay_play','click',()=>{
if(!recording)return;
if(playing){stopPlayback();syncControls();return;}
if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
});
on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
on('replay_scrub','input',()=>{
if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
});
on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
for(const key of ['outline','line','hud']){
const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
on(id,'change',()=>{layers[key]=element(id).checked;draw();});
}
document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
}
async function refresh(){
const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;
const info=picker.info();
const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
status.textContent='Loading '+source+'…';
try{
atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
if(!r.ok)throw Error('Track atlas unavailable');return r.json();
});
const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
const hashPromise=root?.crypto?.subtle?file.arrayBuffer().then(bytes=>root.crypto.subtle.digest('SHA-256',bytes)).then(bytes=>Array.from(new Uint8Array(bytes),n=>n.toString(16).padStart(2,'0')).join('')):Promise.resolve(null);
const [saved,sha]=await Promise.all([decode(file),hashPromise]);
const track=model(saved,atlas);
if(current!==version)return;
currentTrack=track;currentTrackSha=sha;
render(canvas,track);
const off=document.createElement?.('canvas');
if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Orange line follows the track centerline. Collision shapes are not shown. Vehicle positions appear only from a recorded replay.';
}catch(error){
if(current!==version)return;
currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
}
}
const controller={
refresh,
setStagedNetwork(){clearRecording();},
setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
focusCandidate(){clearRecording();},
clearRecording,
setRecording(payload,expectedTrackSha,expectedNetworkSha){
clearRecording();
try{
const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
recording=next;cursor=next.frames[0].tick;draw();syncControls();
return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
}catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
},
};
if(typeof root==='object'&&root)root.TrackReplay=controller;
bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
return controller;
}
return {mount,model,decode,render,validateRecording,sampleRecording};
});