{"context":{"domain":"lucca-playground"},"layers":{"ApplicationLayer":{"reference":"ApplicationLayer","availableInstances":[{"reference":"ComponentsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"SystemsDataSet","params":{"filter":{"domain":"data2"},"pagination":{"size":1},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSource"}}}},{"reference":"ExternalComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"SystemsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5e3b796d6228227ca637d0f4","name":"data2","domain":"data2","organization":"62292184611f480b3191c37c","visibility":"public","updatedAt":"2026-07-27T14:19:36.810Z"}]}},{"reference":"OrganizationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"DataComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"OneFileComponentInstancesDataSet","params":{"pagination":{"size":1},"master":{"dataset":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSet"}},"localField":"system","masterField":"_id"},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"FileComponentInstancesDataSource"}}}},{"reference":"ApplicationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ServiceComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@Data2TableDataSource","reference":"FileComponentInstancesDataSource","params":{"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}}]}},{"component":"data2-core@MemoryDataSource","reference":"EmptyDataSource","params":{"initialData":[]}},{"reference":"ComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"UserComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"DataComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ServiceComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ExternalComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ApplicationsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}}],"processesParams":{}},"HostApplicationLayer":{"reference":"HostApplicationLayer","availableInstances":[{"_id":"5f15ff190528b92601890f71","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"SystemsTableDataSource"}},"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"domain"}}},"pk":"_id","fields":[{"component":"data2-core-types@FieldReference","params":{"key":"_id"}},{"component":"data2-core-types@FieldReference","params":{"key":"name"}},{"component":"data2-core-types@FieldReference","params":{"key":"domain"}},{"component":"data2-core-types@FieldReference","params":{"key":"visibility"}},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization._id"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.name"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.billingStatus"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.publicTrial.active"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"settings.defaultAccessPolicy"}}},"break":true}],"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"staticPagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"policies":[{"component":"data2-core-auth@Policy","params":{"allow":{"params":{"actions":[{"params":{"components":[{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"_id"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"domain"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"}]},"component":"data2-core-logical-operators@Or"}]},"component":"data2-core-flow@Series"}}}]},"reference":"SystemsDataSet","updatedAt":"2026-08-07T21:51:24.037Z","createdAt":"2020-07-20T20:31:21.838Z"},{"_id":"5f1601ce0528b92601890f7c","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":1000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"sort":{"params.package":1,"params.component":1},"pk":"_id","autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"staticFilter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}},"filter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}}},"reference":"ComponentsDataSet","updatedAt":"2025-11-05T19:37:01.935Z","createdAt":"2020-07-20T20:42:54.898Z"},{"_id":"5f9041dbc8da12be8b94aea4","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":2000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"UserComponentsDataSet","updatedAt":"2025-11-06T21:06:58.095Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"65d8c4fabc9dbb8f94de8d53","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ExternalComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ExternalComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:23.742Z","createdAt":"2024-02-23T16:17:01.816Z"},{"reference":"SystemsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6172b957976026c073ab9058","name":"Lucca`s Playground","domain":"lucca-playground","visibility":"public","settings":{"defaultAccessPolicy":"private"},"updatedAt":"2026-08-14T19:26:29.927Z"}]}},{"reference":"ComponentsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5f52a278a575399a45d239b4","component":"data2-core-types@Component","params":{"package":"data2-core-primitives","component":"Router","interactions":{"component":"data2-core-types@FieldSet","params":{"fields":{"goBack":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.goBack","args":[]}}}},"icon":{"component":"data2-core-primitives@String","params":{"content":"arrow-left"}},"visibility":"public"}},"goTo":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"component":"data2-core-types@Reference","params":{"reference":"url"}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}}}]}},"parameters":[{"component":"data2-core-primitives@String","params":{"content":"url"}},{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}]},"break":true},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Path to route"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Path to redirect user"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Boolean"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Preserve Query"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Preserve query string parameters  preserve query parameters on navigating."}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"}]}},"open":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"pathname":{"component":"data2-core-types@Reference","params":{"reference":"value"}}}]}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}]}},"close":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":"close"}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"times"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}],"description":{"component":"data2-core-primitives@String","params":{"content":"Closes a window that was opened via router.open."}}}}}}}}},{"_id":"620d28658271aa4129919d89","component":"data2-core-types@Component","params":{"package":"data2-interface-services","component":"DeviceController","interactions":{"params":{"fields":{"postMessage":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@String","title":{"component":"data2-core-primitives@String","params":{"content":"data"}}}}]},"component":"data2-core-types@ComponentParameter"},"print":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"zoom":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Number"}},"title":{"component":"data2-core-primitives@String","params":{"content":"zoom"}},"visibility":"public"}}},"visibility":"public"},"component":"data2-core-types@ComponentParameter"}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"share":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}}},"component":"data2-core-types@ComponentParameter"},"clipboard.copy":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"clipboard.paste":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"download":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}]},"component":"data2-core-types@ComponentParameter"},"downloadUrl":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Url"}}}}]},"component":"data2-core-types@ComponentParameter"},"promptFiles":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"params":{"fields":{"multiple":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Multiple"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Whether or not to allow selecting multiple files"}},"component":"data2-core-primitives@Boolean"}},"acceptedFormats":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Accepted Formats"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Accepted file formats"}},"component":"data2-core-primitives@String","list":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}},"timeout":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Timeout"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Max milliseconds to wait for file upload"}},"component":"data2-core-primitives@Number"}}}},"component":"data2-core-types@FieldSet"}}}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"meetingRecord":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","definition":{"params":{"actions":[{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"meeting"}},"script":{"component":"data2-core-primitives@String","params":{"content":"(function() {\n  let mediaRecorder;\n  let audioChunks = [];\n  let combinedStream;\n  let audioContext;\n  \n  window.meetingCapture = async function() {\n    try {\n      // Solicitar acesso ao áudio da guia (e vídeo, que será ignorado depois)\n      const tabStream = await navigator.mediaDevices.getDisplayMedia({ \n        video: true, // Necessário para alguns navegadores\n        audio: true\n      });\n      \n      // Remover a trilha de vídeo\n      const tabAudioTrack = tabStream.getAudioTracks()[0];\n      \n      // Solicitar acesso ao microfone\n      const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      const micAudioTrack = micStream.getAudioTracks()[0];\n      \n      // Criar um novo MediaStream com ambas as trilhas de áudio\n      combinedStream = new MediaStream([tabAudioTrack, micAudioTrack]);\n      \n      // Criar um contexto de áudio para misturar os streams\n      audioContext = new AudioContext();\n      const destination = audioContext.createMediaStreamDestination();\n      \n      // Conectar a trilha de áudio da guia\n      const tabSource = audioContext.createMediaStreamSource(new MediaStream([tabAudioTrack]));\n      tabSource.connect(destination);\n      \n      // Conectar a trilha de áudio do microfone\n      const micSource = audioContext.createMediaStreamSource(new MediaStream([micAudioTrack]));\n      micSource.connect(destination);\n      \n      // Iniciar gravação com o stream misturado\n      mediaRecorder = new MediaRecorder(destination.stream);\n      audioChunks = [];\n      mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);\n      mediaRecorder.start();\n      \n      console.log('Gravação iniciada. Chame meetingCaptureStop() para finalizar.');\n    } catch (error) {\n      console.error('Erro ao iniciar a captura:', error);\n    }\n  };\n  \n  window.meetingCaptureStop = function() {\n    return new Promise((resolve, reject) => {\n      if (!mediaRecorder) {\n        reject('Nenhuma gravação em andamento.');\n        return;\n      }\n      \n      mediaRecorder.onstop = () => {\n        const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });\n        combinedStream.getTracks().forEach(track => track.stop());\n        audioContext.close();\n        \n        resolve(audioBlob);\n        console.log('Gravação finalizada. O arquivo de áudio está pronto para upload.');\n      };\n      \n      mediaRecorder.stop();\n    });\n  };\n})();"}}},"component":"data2-core-web@Script"},{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCapture"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"meetingRecordStop":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}],"definition":{"params":{"actions":[{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCaptureStop"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"notify":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"private","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","parameters":{"title":{"params":{"component":"data2-core-primitives@String","index":{"component":"data2-core-primitives@Number","params":{"content":0}},"title":{"component":"data2-core-primitives@String","params":{"content":"title"}}},"component":"data2-core-types@ComponentParameter"},"sound":{"params":{"component":"data2-core-types@FileComponentUrlReference","index":{"component":"data2-core-primitives@Number","params":{"content":1}},"title":{"component":"data2-core-primitives@String","params":{"content":"sound"}}},"component":"data2-core-types@ComponentParameter"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}}]},"component":"data2-core-types@ComponentParameter"}}},"component":"data2-core-types@FieldSet"}}},{"_id":"6a73ee571204b3d36b63499e","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"pixeldoomEditor","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const CELL = 32\nconst TOOLS = [\n  { id: '0', label: 'Vazio', sw: '#2f3542' },\n  { id: '1', label: 'Parede 1', sw: '#b33939' },\n  { id: '2', label: 'Parede 2', sw: '#4a69bd' },\n  { id: '3', label: 'Parede 3', sw: '#38ada9' },\n  { id: '4', label: 'Parede 4', sw: '#e58e26' },\n  { id: '5', label: 'Saída', sw: '#f6e58d' },\n  { id: 'player', label: 'Início Jogador', sw: '#00e6e6' },\n  { id: 'enemy', label: 'Inimigo', sw: '#e84118' },\n]\nconst SWATCH = { '0': '#2f3542', '1': '#b33939', '2': '#4a69bd', '3': '#38ada9', '4': '#e58e26', '5': '#f6e58d' }\n\nfunction esc(v) {\n  return String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;')\n}\nfunction blankGrid(w, h) { return new Array(w * h).fill(0) }\nfunction resizeGrid(oldGrid, oldW, oldH, newW, newH) {\n  const g = new Array(newW * newH).fill(0)\n  const mh = Math.min(oldH, newH), mw = Math.min(oldW, newW)\n  for (let y = 0; y < mh; y++) {\n    for (let x = 0; x < mw; x++) g[y * newW + x] = oldGrid[y * oldW + x] || 0\n  }\n  return g\n}\nfunction drawGrid(cv, s, draftGrid) {\n  if (!cv || !s || !s.width) return\n  const w = s.width, h = s.height\n  const grid = draftGrid || s.grid || []\n  const dpr = window.devicePixelRatio || 1\n  const cssW = w * CELL, cssH = h * CELL\n  const needW = Math.round(cssW * dpr), needH = Math.round(cssH * dpr)\n  if (cv.width !== needW || cv.height !== needH) { cv.width = needW; cv.height = needH }\n  const ctx = cv.getContext('2d')\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n  for (let y = 0; y < h; y++) {\n    for (let x = 0; x < w; x++) {\n      const v = grid[y * w + x] || 0\n      ctx.fillStyle = SWATCH[String(v)] || '#2f3542'\n      ctx.fillRect(x * CELL, y * CELL, CELL, CELL)\n      ctx.strokeStyle = 'rgba(0,0,0,0.25)'\n      ctx.strokeRect(x * CELL + 0.5, y * CELL + 0.5, CELL - 1, CELL - 1)\n    }\n  }\n  const things = s.things || []\n  ctx.font = `${Math.floor(CELL * 0.7)}px sans-serif`\n  ctx.textAlign = 'center'\n  ctx.textBaseline = 'middle'\n  things.forEach((t) => { if (t.type === 'enemy') ctx.fillText('👹', t.x * CELL, t.y * CELL) })\n  const ps = s.playerStart || { x: 1.5, y: 1.5, angle: 0 }\n  ctx.save()\n  ctx.translate(ps.x * CELL, ps.y * CELL)\n  ctx.rotate(ps.angle || 0)\n  ctx.fillStyle = '#00e6e6'\n  ctx.beginPath(); ctx.moveTo(12, 0); ctx.lineTo(-8, 8); ctx.lineTo(-8, -8); ctx.closePath(); ctx.fill()\n  ctx.restore()\n}\n\nexport default {\n  css: `\n    .app{position:absolute;inset:0;display:flex;font-family:'Courier New',monospace;background:#0d0f14;color:#e8e8ee;overflow:hidden}\n    .sidebar{width:300px;flex:0 0 300px;background:#161822;border-right:1px solid #2a2e3d;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}\n    .sidebar h1{font-size:15px;letter-spacing:2px;margin:0 0 4px;color:#f6e58d;text-transform:uppercase}\n    .field{display:flex;flex-direction:column;gap:4px;font-size:11px;color:#9aa0b4}\n    .field input{background:#0d0f14;border:1px solid #2a2e3d;color:#e8e8ee;padding:6px 8px;border-radius:4px;font-family:inherit;box-sizing:border-box;width:100%}\n    .row2{display:flex;gap:8px}\n    .row2 .field{flex:1}\n    .toolgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}\n    .toolbtn{display:flex;align-items:center;gap:6px;background:#1d2030;border:1px solid #2a2e3d;color:#cfd2e0;padding:6px 8px;border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit}\n    .toolbtn .sw{width:14px;height:14px;border-radius:3px;background:var(--c);flex:0 0 14px;display:inline-block}\n    .toolbtn.active{border-color:#f6e58d;background:#2a2410;color:#f6e58d}\n    .actions{display:flex;flex-direction:column;gap:6px;margin-top:4px}\n    .btn{background:#2a2e3d;border:1px solid #3a3f52;color:#e8e8ee;padding:8px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:12px}\n    .btn.primary{background:#f6e58d;color:#161822;font-weight:700;border-color:#f6e58d}\n    .msg{font-size:11px;color:#78e08f;min-height:14px}\n    .levelList{display:flex;flex-direction:column;gap:4px;font-size:11px;border-top:1px solid #2a2e3d;padding-top:10px}\n    .levelList label{color:#9aa0b4;text-transform:uppercase;font-size:10px;letter-spacing:1px}\n    .levelItem{background:#1d2030;border:1px solid #2a2e3d;border-radius:4px;padding:6px 8px;cursor:pointer}\n    .levelItem.active{border-color:#00e6e6;color:#00e6e6}\n    .canvasArea{flex:1;overflow:auto;padding:24px;box-sizing:border-box}\n    .canvasWrap{position:relative;box-shadow:0 0 0 2px #2a2e3d}\n    .grid{display:block;width:100%;height:100%;cursor:crosshair}\n  `,\n  render(d2) {\n    const s = d2.state\n    const width = s.width || 12\n    const height = s.height || 12\n    const tool = s.tool || '1'\n    const name = s.name != null ? s.name : 'Novo Nível'\n    const msg = s.msg || ''\n    const levelId = s.levelId || null\n    const ds = d2.data('levels')\n    const rows = ds && !ds.loading ? ds.records() : []\n    const listHtml = rows.length\n      ? rows.map((r) => `<div class=\"levelItem ${r._id === levelId ? 'active' : ''}\" data-key=\"${r._id}\" data-id=\"${r._id}\">${esc(r.name)} (${r.width}x${r.height})</div>`).join('')\n      : `<div style=\"color:#666\">Nenhum nível salvo ainda.</div>`\n    const wrapW = width * CELL, wrapH = height * CELL\n    return `<div class=\"app\">\n      <div class=\"sidebar\">\n        <h1>PixelDoom · Editor</h1>\n        <div class=\"field\"><label>Nome do nível</label><input class=\"nameIn\" value=\"${esc(name)}\"/></div>\n        <div class=\"row2\">\n          <div class=\"field\"><label>Largura</label><input class=\"widthIn\" type=\"number\" min=\"4\" max=\"40\" value=\"${width}\"/></div>\n          <div class=\"field\"><label>Altura</label><input class=\"heightIn\" type=\"number\" min=\"4\" max=\"40\" value=\"${height}\"/></div>\n        </div>\n        <div class=\"field\"><label>Ferramentas</label>\n          <div class=\"toolgrid\">\n            ${TOOLS.map((t) => `<button class=\"toolbtn ${tool === t.id ? 'active' : ''}\" data-tool=\"${t.id}\" style=\"--c:${t.sw}\"><span class=\"sw\"></span>${t.label}</button>`).join('')}\n          </div>\n        </div>\n        <div class=\"actions\">\n          <button class=\"btn newBtn\">＋ Novo Nível</button>\n          <button class=\"btn saveBtn\">💾 Salvar</button>\n          <button class=\"btn primary playBtn\">▶ Jogar</button>\n        </div>\n        <div class=\"msg\">${esc(msg)}</div>\n        <div class=\"levelList\">\n          <label>Níveis salvos</label>\n          ${listHtml}\n        </div>\n      </div>\n      <div class=\"canvasArea\">\n        <div class=\"canvasWrap\" style=\"width:${wrapW}px;height:${wrapH}px\">\n          <canvas class=\"grid\" data-d2-static></canvas>\n        </div>\n      </div>\n    </div>`\n  },\n  mounted(d2) {\n    if (d2.state.width === undefined) {\n      d2.setState({\n        levelId: null, name: 'Novo Nível', width: 12, height: 12,\n        grid: blankGrid(12, 12), things: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },\n        tool: '1', msg: 'Escolha uma ferramenta e desenhe o nível.',\n      })\n    }\n\n    let painting = false\n    let draft = null\n\n    const cellFromEvent = (e, cv) => {\n      const rect = cv.getBoundingClientRect()\n      const w = d2.state.width, h = d2.state.height\n      const cx = Math.floor(((e.clientX - rect.left) / rect.width) * w)\n      const cy = Math.floor(((e.clientY - rect.top) / rect.height) * h)\n      return { cx: Math.max(0, Math.min(w - 1, cx)), cy: Math.max(0, Math.min(h - 1, cy)) }\n    }\n\n    const applyTool = (cx, cy, gridRef) => {\n      const s = d2.state\n      const idx = cy * s.width + cx\n      if (s.tool === 'player') {\n        s.playerStart = { x: cx + 0.5, y: cy + 0.5, angle: (s.playerStart && s.playerStart.angle) || 0 }\n        return null\n      }\n      if (s.tool === 'enemy') {\n        const things = (s.things || []).slice()\n        const at = things.findIndex((t) => t.type === 'enemy' && Math.floor(t.x) === cx && Math.floor(t.y) === cy)\n        if (at >= 0) things.splice(at, 1)\n        else things.push({ x: cx + 0.5, y: cy + 0.5, type: 'enemy' })\n        s.things = things\n        return null\n      }\n      const val = Number(s.tool)\n      const g = gridRef.slice()\n      if (val === 5) { for (let i = 0; i < g.length; i++) if (g[i] === 5) g[i] = 0 }\n      g[idx] = val\n      return g\n    }\n\n    d2.on('.grid', 'mousedown', (e, cv) => {\n      const s = d2.state\n      if (s.tool === 'player' || s.tool === 'enemy') {\n        const { cx, cy } = cellFromEvent(e, cv)\n        applyTool(cx, cy, s.grid)\n        drawGrid(cv, d2.state, null)\n        return\n      }\n      painting = true\n      draft = s.grid.slice()\n      const { cx, cy } = cellFromEvent(e, cv)\n      const g2 = applyTool(cx, cy, draft)\n      if (g2) draft = g2\n      drawGrid(cv, d2.state, draft)\n    })\n    d2.on(window, 'mousemove', (e) => {\n      if (!painting) return\n      const cv = d2.$('.grid'); if (!cv) return\n      const { cx, cy } = cellFromEvent(e, cv)\n      const g2 = applyTool(cx, cy, draft)\n      if (g2) draft = g2\n      drawGrid(cv, d2.state, draft)\n    })\n    d2.on(window, 'mouseup', () => {\n      if (painting && draft) d2.state.grid = draft\n      painting = false\n      draft = null\n    })\n\n    d2.on('.toolbtn', 'click', (e, el) => { d2.state.tool = el.dataset.tool })\n    d2.on('.nameIn', 'input', (e, el) => { d2.state.name = el.value })\n    d2.on('.widthIn', 'change', (e, el) => {\n      const nw = Math.max(4, Math.min(40, parseInt(el.value, 10) || 12))\n      const s = d2.state\n      const ng = resizeGrid(s.grid, s.width, s.height, nw, s.height)\n      d2.setState({ grid: ng, width: nw })\n    })\n    d2.on('.heightIn', 'change', (e, el) => {\n      const nh = Math.max(4, Math.min(40, parseInt(el.value, 10) || 12))\n      const s = d2.state\n      const ng = resizeGrid(s.grid, s.width, s.height, s.width, nh)\n      d2.setState({ grid: ng, height: nh })\n    })\n\n    d2.on('.newBtn', 'click', () => {\n      d2.setState({\n        levelId: null, name: 'Novo Nível', width: 12, height: 12,\n        grid: blankGrid(12, 12), things: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },\n        tool: '1', msg: 'Novo nível — comece a desenhar as paredes.',\n      })\n    })\n\n    d2.on('.levelItem', 'click', (e, el) => {\n      const ds = d2.data('levels')\n      const rec = (ds.records() || []).find((r) => r._id === el.dataset.id)\n      if (!rec) return\n      d2.setState({\n        levelId: rec._id, name: rec.name, width: rec.width, height: rec.height,\n        grid: (rec.grid || []).slice(), things: (rec.things || []).map((t) => ({ ...t })),\n        playerStart: rec.playerStart ? { ...rec.playerStart } : { x: 1.5, y: 1.5, angle: 0 },\n        msg: `Carregado: ${rec.name}`,\n      })\n    })\n\n    const doSave = async () => {\n      const s = d2.state\n      const ds = d2.data('levels')\n      const payload = {\n        name: s.name || 'Nível sem nome', width: s.width, height: s.height,\n        grid: s.grid, things: s.things || [], playerStart: s.playerStart || { x: 1.5, y: 1.5, angle: 0 },\n        updatedAt: new Date().toISOString(),\n      }\n      d2.state.msg = 'Salvando…'\n      try {\n        if (s.levelId) {\n          await ds.update({ _id: s.levelId, ...payload })\n        } else {\n          const rec = await ds.create(payload)\n          let newId = (rec && rec._id) || (rec && rec.data && rec.data._id) || null\n          if (!newId) {\n            await ds.refresh()\n            const all = (ds.records() || []).slice().sort((a, b) => (a._id < b._id ? 1 : -1))\n            newId = all[0] && all[0]._id\n          }\n          d2.state.levelId = newId\n        }\n        d2.state.msg = 'Salvo!'\n      } catch (err) {\n        d2.state.msg = 'Erro ao salvar.'\n      }\n    }\n    d2.on('.saveBtn', 'click', () => { doSave() })\n    d2.on('.playBtn', 'click', async () => {\n      await doSave()\n      const store = d2.store('gameStore')\n      if (store) store.set('selectedLevelId', d2.state.levelId)\n      window.location.href = d2.param('playUrl', '/')\n    })\n  },\n  updated(d2) {\n    const cv = d2.$('.grid')\n    if (cv) drawGrid(cv, d2.state, null)\n  },\n}\n"}},"levels":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"levels"}}}},"gameStore":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gameStore"}}}},"playUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"playUrl"}}}}}}}}}},{"_id":"6a73ee81d5c61d2c1c9a94e7","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"pixeldoomPlay","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const WALL_COLORS = {\n  1: [179, 57, 57],\n  2: [74, 105, 189],\n  3: [56, 173, 169],\n  4: [229, 142, 38],\n}\nconst FLOOR_COLOR = [26, 26, 26]\nconst SKY_COLOR = [40, 46, 66]\nconst RENDER_W = 320\nconst RENDER_H = 200\nconst FOV = (66 * Math.PI) / 180\nconst MINI = 160\n\nfunction isWallCell(v) { return v >= 1 && v <= 4 }\nfunction clamp(v, a, b) { return Math.max(a, Math.min(b, v)) }\n\nexport default {\n  css: `\n    .app{position:absolute;inset:0;background:#000;overflow:hidden;font-family:'Courier New',monospace;color:#e8e8ee}\n    .scene{position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;cursor:crosshair}\n    .mini{position:absolute;top:16px;right:16px;width:160px;height:160px;border:2px solid #444;background:#0d0f14;opacity:0.92}\n    .hud{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;gap:4px;pointer-events:none;text-shadow:0 1px 3px #000}\n    .hudName{font-size:14px;letter-spacing:2px;color:#f6e58d;text-transform:uppercase}\n    .hudKills{font-size:13px;color:#78e08f}\n    .crosshair{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px;pointer-events:none}\n    .crosshair::before,.crosshair::after{content:'';position:absolute;background:#fff;opacity:.85}\n    .crosshair::before{left:6px;top:0;width:2px;height:14px}\n    .crosshair::after{left:0;top:6px;width:14px;height:2px}\n    .backBtn{position:absolute;bottom:16px;left:16px;background:#1d2030;border:1px solid #3a3f52;color:#e8e8ee;padding:8px 14px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:12px;z-index:5}\n    .loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#0d0f14;color:#9aa0b4;font-size:14px;letter-spacing:2px;z-index:9}\n    .loading.hide{display:none}\n    .victory{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:rgba(10,12,18,0.9);z-index:10}\n    .victory.show{display:flex}\n    .victory h2{font-size:32px;color:#f6e58d;letter-spacing:4px;margin:0}\n    .victory button{background:#f6e58d;color:#161822;font-weight:700;border:0;padding:10px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px}\n    .hint{position:absolute;bottom:16px;right:16px;font-size:10px;color:#666;text-align:right;pointer-events:none}\n  `,\n  render() {\n    return `<div class=\"app\">\n      <canvas class=\"scene\" data-d2-static></canvas>\n      <canvas class=\"mini\" data-d2-static></canvas>\n      <div class=\"crosshair\" data-d2-static></div>\n      <div class=\"hud\">\n        <div class=\"hudName\">Carregando…</div>\n        <div class=\"hudKills\">Abates: 0/0</div>\n      </div>\n      <div class=\"hint\">WASD/Setas move · Q/E strafe · Espaço/Click atira</div>\n      <button class=\"backBtn\">← Voltar ao editor</button>\n      <div class=\"loading\">Carregando nível…</div>\n      <div class=\"victory\">\n        <h2>NÍVEL COMPLETO</h2>\n        <button class=\"victoryBtn\">Voltar ao editor</button>\n      </div>\n    </div>`\n  },\n  mounted(d2) {\n    const keys = {}\n    let level = null\n    let player = { x: 1.5, y: 1.5, angle: 0 }\n    let things = []\n    let kills = 0\n    let totalEnemies = 0\n    let gameWon = false\n    let ready = false\n    let lastT = null\n    const depthBuffer = new Array(RENDER_W).fill(1e9)\n\n    const goEditor = () => { window.location.href = d2.param('editorUrl', '/') }\n    d2.on('.backBtn', 'click', goEditor)\n    d2.on('.victoryBtn', 'click', goEditor)\n\n    function cellAt(gx, gy) {\n      if (gx < 0 || gy < 0 || gx >= level.width || gy >= level.height) return 1\n      return level.grid[gy * level.width + gx] || 0\n    }\n    function canStand(x, y) { return !isWallCell(cellAt(Math.floor(x), Math.floor(y))) }\n\n    function castRay(px, py, angle) {\n      const dirX = Math.cos(angle), dirY = Math.sin(angle)\n      let mapX = Math.floor(px), mapY = Math.floor(py)\n      const deltaDistX = dirX === 0 ? 1e30 : Math.abs(1 / dirX)\n      const deltaDistY = dirY === 0 ? 1e30 : Math.abs(1 / dirY)\n      let stepX, stepY, sideDistX, sideDistY\n      if (dirX < 0) { stepX = -1; sideDistX = (px - mapX) * deltaDistX } else { stepX = 1; sideDistX = (mapX + 1 - px) * deltaDistX }\n      if (dirY < 0) { stepY = -1; sideDistY = (py - mapY) * deltaDistY } else { stepY = 1; sideDistY = (mapY + 1 - py) * deltaDistY }\n      let side = 0, cellVal = 0\n      for (let i = 0; i < 64; i++) {\n        if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0 } else { sideDistY += deltaDistY; mapY += stepY; side = 1 }\n        cellVal = cellAt(mapX, mapY)\n        if (isWallCell(cellVal)) break\n      }\n      let dist\n      if (side === 0) dist = (mapX - px + (1 - stepX) / 2) / (dirX || 1e-6)\n      else dist = (mapY - py + (1 - stepY) / 2) / (dirY || 1e-6)\n      return { dist: Math.max(dist, 0.0001), side, cellVal }\n    }\n\n    function shadeRGB(rgb, mult, fogT) {\n      const r = rgb[0] * mult, g = rgb[1] * mult, b = rgb[2] * mult\n      return [\n        r + (FLOOR_COLOR[0] - r) * fogT,\n        g + (FLOOR_COLOR[1] - g) * fogT,\n        b + (FLOOR_COLOR[2] - b) * fogT,\n      ]\n    }\n\n    function shoot() {\n      if (!ready || gameWon) return\n      const wallDist = castRay(player.x, player.y, player.angle).dist\n      let best = null, bestAngle = 999\n      for (const e of things) {\n        if (e.type !== 'enemy' || !e.alive) continue\n        const ex = e.x - player.x, ey = e.y - player.y\n        const dist = Math.hypot(ex, ey)\n        let ang = Math.atan2(ey, ex) - player.angle\n        while (ang > Math.PI) ang -= 2 * Math.PI\n        while (ang < -Math.PI) ang += 2 * Math.PI\n        if (Math.abs(ang) < 0.14 && dist < wallDist + 0.6 && Math.abs(ang) < bestAngle) { bestAngle = Math.abs(ang); best = e }\n      }\n      if (best) { best.alive = false; kills += 1 }\n    }\n    d2.on('.scene', 'click', shoot)\n    d2.on(window, 'keydown', (e) => {\n      const c = e.code\n      if (c === 'KeyW' || c === 'ArrowUp') keys.forward = true\n      else if (c === 'KeyS' || c === 'ArrowDown') keys.back = true\n      else if (c === 'KeyA' || c === 'ArrowLeft') keys.rotLeft = true\n      else if (c === 'KeyD' || c === 'ArrowRight') keys.rotRight = true\n      else if (c === 'KeyQ') keys.strafeLeft = true\n      else if (c === 'KeyE') keys.strafeRight = true\n      else if (c === 'Space') { e.preventDefault(); shoot() }\n    })\n    d2.on(window, 'keyup', (e) => {\n      const c = e.code\n      if (c === 'KeyW' || c === 'ArrowUp') keys.forward = false\n      else if (c === 'KeyS' || c === 'ArrowDown') keys.back = false\n      else if (c === 'KeyA' || c === 'ArrowLeft') keys.rotLeft = false\n      else if (c === 'KeyD' || c === 'ArrowRight') keys.rotRight = false\n      else if (c === 'KeyQ') keys.strafeLeft = false\n      else if (c === 'KeyE') keys.strafeRight = false\n    })\n\n    async function loadLevel() {\n      const store = d2.store('gameStore')\n      const ds = d2.data('levels')\n      await ds.refresh()\n      const selId = store ? store.get('selectedLevelId') : null\n      const rows = ds.records() || []\n      let rec = selId ? rows.find((r) => r._id === selId) : null\n      if (!rec) rec = rows[0]\n      if (!rec) {\n        const nameEl = d2.$('.hudName'); if (nameEl) nameEl.textContent = 'Nenhum nível encontrado'\n        return\n      }\n      level = { width: rec.width, height: rec.height, grid: rec.grid || [], name: rec.name || 'Nível' }\n      things = (rec.things || []).map((t) => ({ ...t, alive: true }))\n      const ps = rec.playerStart || { x: 1.5, y: 1.5, angle: 0 }\n      player = { x: ps.x, y: ps.y, angle: ps.angle || 0 }\n      kills = 0\n      totalEnemies = things.filter((t) => t.type === 'enemy').length\n      gameWon = false\n      const loadingEl = d2.$('.loading'); if (loadingEl) loadingEl.classList.add('hide')\n      const nameEl = d2.$('.hudName'); if (nameEl) nameEl.textContent = level.name\n      const killsEl = d2.$('.hudKills'); if (killsEl) killsEl.textContent = `Abates: 0/${totalEnemies}`\n      ready = true\n    }\n\n    function render3D(ctx) {\n      const img = ctx.createImageData(RENDER_W, RENDER_H)\n      for (let col = 0; col < RENDER_W; col++) {\n        const rayAngle = player.angle - FOV / 2 + (FOV * col) / RENDER_W\n        const { dist, side, cellVal } = castRay(player.x, player.y, rayAngle)\n        const perp = dist * Math.cos(rayAngle - player.angle)\n        depthBuffer[col] = perp\n        const lineH = Math.min(RENDER_H * 3, RENDER_H / Math.max(perp, 0.0001))\n        const drawStart = Math.floor(RENDER_H / 2 - lineH / 2)\n        const drawEnd = Math.floor(RENDER_H / 2 + lineH / 2)\n        const fogT = clamp(perp / 11, 0, 1)\n        const mult = side === 1 ? 1 : 0.7\n        const wc = shadeRGB(WALL_COLORS[cellVal] || WALL_COLORS[1], mult, fogT)\n        for (let y = 0; y < RENDER_H; y++) {\n          let color\n          if (y < drawStart) color = SKY_COLOR\n          else if (y > drawEnd) color = FLOOR_COLOR\n          else color = wc\n          const idx = (y * RENDER_W + col) * 4\n          img.data[idx] = color[0] | 0\n          img.data[idx + 1] = color[1] | 0\n          img.data[idx + 2] = color[2] | 0\n          img.data[idx + 3] = 255\n        }\n      }\n      ctx.putImageData(img, 0, 0)\n      const alive = things.filter((t) => t.type === 'enemy' && t.alive)\n      alive.sort((a, b) => {\n        const da = Math.hypot(a.x - player.x, a.y - player.y)\n        const db = Math.hypot(b.x - player.x, b.y - player.y)\n        return db - da\n      })\n      ctx.textAlign = 'center'\n      ctx.textBaseline = 'middle'\n      alive.forEach((e) => {\n        const ex = e.x - player.x, ey = e.y - player.y\n        const dist = Math.hypot(ex, ey)\n        let ang = Math.atan2(ey, ex) - player.angle\n        while (ang > Math.PI) ang -= 2 * Math.PI\n        while (ang < -Math.PI) ang += 2 * Math.PI\n        if (Math.abs(ang) > FOV / 2 + 0.4 || dist < 0.15) return\n        const screenX = (0.5 + Math.tan(ang) / Math.tan(FOV / 2) / 2) * RENDER_W\n        const col = Math.floor(screenX)\n        if (col < -20 || col > RENDER_W + 20) return\n        const cCol = clamp(col, 0, RENDER_W - 1)\n        if (dist > depthBuffer[cCol] + 0.35) return\n        const size = clamp(RENDER_H / dist, 10, RENDER_H * 1.6)\n        const fogT = clamp(dist / 11, 0, 1)\n        ctx.globalAlpha = 1 - fogT * 0.7\n        ctx.font = `${size}px sans-serif`\n        ctx.fillText('👹', screenX, RENDER_H / 2 + size * 0.08)\n        ctx.globalAlpha = 1\n      })\n    }\n\n    function renderMini(ctx) {\n      ctx.clearRect(0, 0, MINI, MINI)\n      ctx.fillStyle = '#0d0f14'\n      ctx.fillRect(0, 0, MINI, MINI)\n      if (!level) return\n      const cs = Math.min(MINI / level.width, MINI / level.height)\n      for (let y = 0; y < level.height; y++) {\n        for (let x = 0; x < level.width; x++) {\n          const v = level.grid[y * level.width + x] || 0\n          if (v === 0) continue\n          ctx.fillStyle = v === 5 ? 'rgb(246,229,141)' : '#666'\n          ctx.fillRect(x * cs, y * cs, cs, cs)\n        }\n      }\n      things.filter((t) => t.type === 'enemy' && t.alive).forEach((e) => {\n        ctx.fillStyle = '#e84118'\n        ctx.beginPath(); ctx.arc(e.x * cs, e.y * cs, Math.max(2, cs * 0.3), 0, 7); ctx.fill()\n      })\n      ctx.save()\n      ctx.translate(player.x * cs, player.y * cs)\n      ctx.rotate(player.angle)\n      ctx.fillStyle = '#00e6e6'\n      ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, 4); ctx.lineTo(-4, -4); ctx.closePath(); ctx.fill()\n      ctx.restore()\n    }\n\n    function checkWin() {\n      if (gameWon || !ready) return\n      if (kills >= totalEnemies) {\n        const onExit = cellAt(Math.floor(player.x), Math.floor(player.y)) === 5\n        if (onExit) {\n          gameWon = true\n          const v = d2.$('.victory'); if (v) v.classList.add('show')\n        }\n      }\n    }\n\n    const scene = d2.$('.scene')\n    const mini = d2.$('.mini')\n    if (!scene || !mini) return\n    scene.width = RENDER_W\n    scene.height = RENDER_H\n    const sctx = scene.getContext('2d')\n    mini.width = MINI\n    mini.height = MINI\n    const mctx = mini.getContext('2d')\n\n    loadLevel()\n\n    d2.raf((t) => {\n      if (lastT == null) lastT = t\n      const dt = Math.min((t - lastT) / 1000, 0.05)\n      lastT = t\n      if (!ready) return\n      if (!gameWon) {\n        const moveSpeed = 3, rotSpeed = 2.4, strafeSpeed = 2.6\n        let dx = 0, dy = 0\n        if (keys.forward) { dx += Math.cos(player.angle) * moveSpeed * dt; dy += Math.sin(player.angle) * moveSpeed * dt }\n        if (keys.back) { dx -= Math.cos(player.angle) * moveSpeed * dt; dy -= Math.sin(player.angle) * moveSpeed * dt }\n        if (keys.strafeLeft) { dx += Math.cos(player.angle - Math.PI / 2) * strafeSpeed * dt; dy += Math.sin(player.angle - Math.PI / 2) * strafeSpeed * dt }\n        if (keys.strafeRight) { dx += Math.cos(player.angle + Math.PI / 2) * strafeSpeed * dt; dy += Math.sin(player.angle + Math.PI / 2) * strafeSpeed * dt }\n        if (keys.rotLeft) player.angle -= rotSpeed * dt\n        if (keys.rotRight) player.angle += rotSpeed * dt\n        if (dx !== 0) { const nx = player.x + dx; if (canStand(nx + Math.sign(dx) * 0.2, player.y)) player.x = nx }\n        if (dy !== 0) { const ny = player.y + dy; if (canStand(player.x, ny + Math.sign(dy) * 0.2)) player.y = ny }\n        checkWin()\n      }\n      render3D(sctx)\n      renderMini(mctx)\n      const killsEl = d2.$('.hudKills')\n      if (killsEl) killsEl.textContent = `Abates: ${kills}/${totalEnemies}`\n    })\n  },\n}\n"}},"levels":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"levels"}}}},"gameStore":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gameStore"}}}},"editorUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"editorUrl"}}}}}}}}}},{"_id":"6abeecc5ba309d7a9face034","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"Rubik_s_Cube","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n    :host { display: block; width: 100%; height: 100%; }\n    .rb { position: relative; width: 100%; height: 100%; min-height: 480px; overflow: hidden;\n      background: radial-gradient(120% 80% at 50% 30%, #2a2f45 0%, #12141f 70%);\n      color: #f3f4f8; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n      touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n      display: flex; flex-direction: column; }\n    .hud { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 0; }\n    .title { font-weight: 700; font-size: 18px; letter-spacing: .3px; }\n    .stats { display: flex; gap: 16px; font-variant-numeric: tabular-nums; font-size: 14px; opacity: .85; }\n    .stats b { font-weight: 600; }\n    .stage { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; }\n    .cube { position: relative; width: 0; height: 0; transform-style: preserve-3d; }\n    .cubie { position: absolute; transform-style: preserve-3d; }\n    .f { position: absolute; left: 0; top: 0; width: var(--s); height: var(--s); margin: calc(var(--s) / -2) 0 0 calc(var(--s) / -2);\n      background: #0b0c10; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-sizing: border-box; }\n    .f.c::after { content: \"\"; position: absolute; inset: 6%; border-radius: 14%; background: var(--c);\n      box-shadow: inset 0 -2px 4px rgba(0,0,0,.25), inset 0 2px 3px rgba(255,255,255,.25); }\n    .hint { text-align: center; font-size: 13px; opacity: .55; padding: 0 24px; min-height: 18px; }\n    .bar { display: flex; gap: 10px; justify-content: center; padding: 14px 16px calc(18px + env(safe-area-inset-bottom)); }\n    .bar button { flex: 1; max-width: 140px; height: 48px; border: 0; border-radius: 14px; font-size: 15px; font-weight: 600;\n      color: #f3f4f8; background: rgba(255,255,255,.1); touch-action: manipulation; }\n    .bar button.primary { background: #4f7cff; }\n    .bar button:active { transform: scale(.97); }\n    .win { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; pointer-events: none; }\n    .win.show { display: flex; }\n    .win div { background: rgba(18,20,31,.85); padding: 18px 26px; border-radius: 18px; text-align: center; font-size: 15px;\n      box-shadow: 0 10px 40px rgba(0,0,0,.4); animation: pop .35s ease-out; }\n    .win strong { display: block; font-size: 22px; margin-bottom: 4px; }\n    @keyframes pop { from { transform: scale(.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }\n  `,\n\n  render() {\n    return `\n      <div class=\"rb\" data-d2-static>\n        <div class=\"hud\">\n          <div class=\"title\">Cube</div>\n          <div class=\"stats\"><span>Moves <b class=\"moves\">0</b></span><span><b class=\"time\">0:00</b></span></div>\n        </div>\n        <div class=\"stage\"><div class=\"cube\"></div><div class=\"win\"><div><strong>Solved!</strong><span class=\"winmsg\"></span></div></div></div>\n        <div class=\"hint\">Drag the background to spin · swipe a face to turn it</div>\n        <div class=\"bar\">\n          <button class=\"undo\">Undo</button>\n          <button class=\"reset\">Reset</button>\n          <button class=\"scramble primary\">Scramble</button>\n        </div>\n      </div>`;\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s);\n    const rb = $('.rb'), stage = $('.stage'), cubeEl = $('.cube');\n    if (!rb || rb.__init) return;\n    rb.__init = true;\n\n    // ---------- math (3x3 row-major, CSS coords: x right, y down, z toward viewer) ----------\n    const I = () => [[1,0,0],[0,1,0],[0,0,1]];\n    const mul = (A, B) => A.map((r) => [0,1,2].map((c) => r[0]*B[0][c] + r[1]*B[1][c] + r[2]*B[2][c]));\n    const mv = (A, v) => A.map((r) => r[0]*v[0] + r[1]*v[1] + r[2]*v[2]);\n    const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];\n    const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];\n    const rnd = (v) => v.map((x) => Math.round(x));\n    const rot = (a, t) => {\n      const [x, y, z] = a, c = Math.cos(t), s = Math.sin(t), C = 1 - c;\n      return [[c+x*x*C, x*y*C-z*s, x*z*C+y*s], [y*x*C+z*s, c+y*y*C, y*z*C-x*s], [z*x*C-y*s, z*y*C+x*s, c+z*z*C]];\n    };\n    const css = (M, t = [0,0,0]) =>\n      `matrix3d(${M[0][0]},${M[1][0]},${M[2][0]},0,${M[0][1]},${M[1][1]},${M[2][1]},0,${M[0][2]},${M[1][2]},${M[2][2]},0,${t[0]},${t[1]},${t[2]},1)`;\n\n    // ---------- cube model ----------\n    const FACES = [ // local normal, css placement, color, test on solved position\n      { n: [0,0,1],  t: 'translateZ(calc(var(--s)/2))',                  c: '#2ecc71', k: (p) => p[2] === 1 },\n      { n: [0,0,-1], t: 'rotateY(180deg) translateZ(calc(var(--s)/2))',  c: '#3498ff', k: (p) => p[2] === -1 },\n      { n: [1,0,0],  t: 'rotateY(90deg) translateZ(calc(var(--s)/2))',   c: '#ff4757', k: (p) => p[0] === 1 },\n      { n: [-1,0,0], t: 'rotateY(-90deg) translateZ(calc(var(--s)/2))',  c: '#ff9f1a', k: (p) => p[0] === -1 },\n      { n: [0,-1,0], t: 'rotateX(90deg) translateZ(calc(var(--s)/2))',   c: '#f5f6fa', k: (p) => p[1] === -1 },\n      { n: [0,1,0],  t: 'rotateX(-90deg) translateZ(calc(var(--s)/2))',  c: '#ffd32a', k: (p) => p[1] === 1 },\n    ];\n    let S = 60, cubies = [];\n    let view = mul(rot([1,0,0], -0.5), rot([0,1,0], -0.65));\n\n    const build = () => {\n      cubeEl.innerHTML = '';\n      cubies = [];\n      for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) {\n        if (!x && !y && !z) continue;\n        const el = document.createElement('div');\n        el.className = 'cubie';\n        const home = [x, y, z];\n        FACES.forEach((F, i) => {\n          const f = document.createElement('div');\n          const colored = F.k(home);\n          f.className = 'f' + (colored ? ' c' : '');\n          f.dataset.fi = i;\n          f.style.transform = F.t;\n          if (colored) f.style.setProperty('--c', F.c);\n          el.appendChild(f);\n        });\n        const cb = { pos: home.slice(), R: I(), el };\n        el.__cb = cb;\n        cubies.push(cb);\n        cubeEl.appendChild(el);\n      }\n      paintAll();\n    };\n    const paint = (cb, A = null) => {\n      const M = A ? mul(A, cb.R) : cb.R;\n      const p = A ? mv(A, cb.pos) : cb.pos;\n      cb.el.style.transform = css(M, p.map((v) => v * S));\n    };\n    const paintAll = () => cubies.forEach((c) => paint(c));\n    const paintView = () => { cubeEl.style.transform = css(view); };\n\n    const layout = () => {\n      const r = stage.getBoundingClientRect();\n      const span = Math.min(r.width * 0.62, r.height * 0.55, 420);\n      S = Math.max(24, span / 3);\n      rb.style.setProperty('--s', S + 'px');\n      stage.style.perspective = S * 14 + 'px';\n      paintAll();\n    };\n\n    // ---------- moves ----------\n    let busy = false, queue = [], history = [], moves = 0, startT = 0, timerId = 0, scrambled = false;\n    const movesEl = $('.moves'), timeEl = $('.time'), winEl = $('.win'), winMsg = $('.winmsg');\n    const fmt = (ms) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };\n    const tick = () => { if (startT) timeEl.textContent = fmt(Date.now() - startT); };\n\n    const layerOf = (axis, idx) => cubies.filter((c) => Math.round(dot(c.pos, axis)) === idx);\n    const commit = (axis, idx, q) => {\n      const A = rot(axis, q * Math.PI / 2);\n      layerOf(axis, idx).forEach((c) => { c.pos = rnd(mv(A, c.pos)); c.R = mul(A, c.R).map(rnd); paint(c); });\n    };\n    const animate = (layer, axis, from, to, ms, done) => {\n      const t0 = performance.now();\n      const step = (now) => {\n        const k = Math.min(1, (now - t0) / ms), e = 1 - Math.pow(1 - k, 3);\n        const A = rot(axis, from + (to - from) * e);\n        layer.forEach((c) => paint(c, A));\n        if (k < 1) requestAnimationFrame(step); else done();\n      };\n      requestAnimationFrame(step);\n    };\n    // move = { axis, idx, q } ; q in {-2, -1, 1, 2}\n    const run = () => {\n      if (busy || !queue.length) return;\n      busy = true;\n      const m = queue.shift();\n      animate(layerOf(m.axis, m.idx), m.axis, m.from || 0, m.q * Math.PI / 2, m.ms || 200, () => {\n        commit(m.axis, m.idx, m.q);\n        busy = false;\n        if (m.user) onUserMove(m);\n        run();\n      });\n    };\n    const push = (m) => { queue.push(m); run(); };\n\n    const onUserMove = (m) => {\n      if (!m.undo) history.push(m);\n      moves += m.undo ? -1 : 1;\n      movesEl.textContent = Math.max(0, moves);\n      try { navigator.vibrate && navigator.vibrate(8); } catch (e) {}\n      if (scrambled && !startT && !m.undo) { startT = Date.now(); timerId = setInterval(tick, 250); }\n      checkSolved();\n    };\n    const isSolved = () => {\n      const seen = {};\n      for (const c of cubies) for (const [i, F] of FACES.entries()) {\n        if (!c.el.children[i].classList.contains('c')) continue;\n        const n = rnd(mv(c.R, F.n)).join(',');\n        (seen[n] = seen[n] || new Set()).add(F.c);\n      }\n      return Object.values(seen).every((s) => s.size === 1);\n    };\n    const checkSolved = () => {\n      if (!scrambled || !isSolved()) return;\n      scrambled = false;\n      clearInterval(timerId);\n      const took = startT ? Date.now() - startT : 0;\n      startT = 0;\n      winMsg.textContent = `${moves} moves · ${fmt(took)}`;\n      winEl.classList.add('show');\n      try { navigator.vibrate && navigator.vibrate([30, 40, 30]); } catch (e) {}\n      setTimeout(() => winEl.classList.remove('show'), 2600);\n    };\n\n    const AXES = [[1,0,0],[0,1,0],[0,0,1]];\n    const scramble = () => {\n      if (busy || queue.length) return;\n      let last = -1;\n      for (let i = 0; i < 22; i++) {\n        let a; do { a = Math.floor(Math.random() * 3); } while (a === last);\n        last = a;\n        const idx = [-1, 0, 1][Math.floor(Math.random() * 3)];\n        queue.push({ axis: AXES[a], idx, q: Math.random() < 0.5 ? 1 : -1, ms: 90 });\n      }\n      history = []; moves = 0; movesEl.textContent = '0';\n      clearInterval(timerId); startT = 0; timeEl.textContent = '0:00';\n      winEl.classList.remove('show');\n      scrambled = true;\n      run();\n    };\n    const reset = () => {\n      if (busy) return;\n      queue = []; history = []; moves = 0; scrambled = false;\n      clearInterval(timerId); startT = 0;\n      movesEl.textContent = '0'; timeEl.textContent = '0:00';\n      winEl.classList.remove('show');\n      build();\n    };\n    const undo = () => {\n      if (busy || queue.length || !history.length) return;\n      const m = history.pop();\n      push({ axis: m.axis, idx: m.idx, q: -m.q, ms: 180, user: true, undo: true });\n    };\n    $('.scramble').addEventListener('click', scramble);\n    $('.reset').addEventListener('click', reset);\n    $('.undo').addEventListener('click', undo);\n\n    // ---------- gestures ----------\n    let g = null;\n    const toScreen = (d) => { const v = mv(view, d); return [v[0], v[1]]; };\n\n    stage.addEventListener('pointerdown', (ev) => {\n      if (g) return;\n      const face = ev.target.closest && ev.target.closest('.f');\n      g = { id: ev.pointerId, x0: ev.clientX, y0: ev.clientY, x: ev.clientX, y: ev.clientY, mode: 'orbit' };\n      if (face && face.classList.contains('c') && !busy && !queue.length) {\n        const cb = face.parentElement.__cb;\n        const n = rnd(mv(cb.R, FACES[+face.dataset.fi].n));\n        g.mode = 'pending'; g.cb = cb; g.n = n;\n      }\n      try { stage.setPointerCapture(ev.pointerId); } catch (e) {}\n      ev.preventDefault();\n    });\n\n    stage.addEventListener('pointermove', (ev) => {\n      if (!g || ev.pointerId !== g.id) return;\n      const dx = ev.clientX - g.x, dy = ev.clientY - g.y;\n      g.x = ev.clientX; g.y = ev.clientY;\n      if (g.mode === 'orbit') {\n        const len = Math.hypot(dx, dy);\n        if (len > 0) { view = mul(rot([-dy / len, dx / len, 0], len * 0.009), view); paintView(); }\n        return;\n      }\n      const tx = ev.clientX - g.x0, ty = ev.clientY - g.y0;\n      if (g.mode === 'pending') {\n        if (Math.hypot(tx, ty) < 10) return;\n        // pick the in-face direction whose screen projection best matches the swipe\n        let best = null;\n        for (const d of AXES.concat(AXES.map((a) => a.map((v) => -v)))) {\n          if (Math.abs(dot(d, g.n)) > 0.5) continue;\n          const s = toScreen(d), sl = Math.hypot(s[0], s[1]) || 1;\n          const score = (tx * s[0] + ty * s[1]) / sl;\n          if (!best || score > best.score) best = { d, s: [s[0] / sl, s[1] / sl], sl, score };\n        }\n        const axis = cross(g.n, best.d); // rotating +θ about n×d moves the sticker toward +d\n        g.mode = 'turn';\n        g.axis = axis;\n        g.dir = best.s;\n        g.scale = best.sl; // screen shrink of that direction\n        g.idx = Math.round(dot(g.cb.pos, axis));\n        g.layer = layerOf(axis, g.idx);\n      }\n      if (g.mode === 'turn') {\n        const along = tx * g.dir[0] + ty * g.dir[1];\n        g.angle = along / (S * 2.2 * Math.max(g.scale, 0.35)) * (Math.PI / 2);\n        const A = rot(g.axis, g.angle);\n        g.layer.forEach((c) => paint(c, A));\n      }\n    });\n\n    const end = (ev) => {\n      if (!g || ev.pointerId !== g.id) return;\n      const cur = g; g = null;\n      try { stage.releasePointerCapture(ev.pointerId); } catch (e) {}\n      if (cur.mode !== 'turn') return;\n      const quarter = cur.angle / (Math.PI / 2);\n      let q = Math.round(quarter);\n      if (q === 0 && Math.abs(quarter) > 0.28) q = Math.sign(quarter); // a short flick still turns\n      q = Math.max(-2, Math.min(2, q));\n      if (q === 0) {\n        busy = true;\n        animate(cur.layer, cur.axis, cur.angle, 0, 140, () => { busy = false; paintAll(); run(); });\n        return;\n      }\n      queue.unshift({ axis: cur.axis, idx: cur.idx, q, from: cur.angle, ms: 160, user: true });\n      run();\n    };\n    stage.addEventListener('pointerup', end);\n    stage.addEventListener('pointercancel', end);\n\n    build();\n    paintView();\n    layout();\n    if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(stage);\n    else window.addEventListener('resize', layout);\n  },\n};\n"}}}}}}}},{"_id":"6abf00de492457561460ce5d","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"Star_Fox_Mobile_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// Mobile SNES player for Star Fox: the player picks their own ROM on the device (it never\n// leaves the browser), EmulatorJS + snes9x (Super FX) run from the \"starfox-emulator\" Drive,\n// the phone's tilt steers the D-pad and touching the screen fires.\nconst MEDIA = 'https://lucca-playground.data2.media/file/';\nconst FILES = {\n  'loader.js': 'sjn4NtjRSdexz2quJ5Icu',\n  'emulator.js': 'H9sfJ9h2M4sL9l0V8lTRF',\n  'GameManager.js': '7-WiYcktgqc2iE805n4WB',\n  'compression.js': 'Z_xhOCdoDAvuDa0e8_MoL',\n  'gamepad.js': 'eQ7YsA53xxlQhATIzf1Lk',\n  'nipplejs.js': 'tQ3GRf6D_m2B_DgJ5FwCv',\n  'shaders.js': 'QSnBt1zhdtx9pOlWWit7k',\n  'storage.js': 'D59vCRKTT6jeJNnN2g0UB',\n  'socket.io.min.js': 'UUZqWxMwZg2qimoR3Nw8b',\n  'emulator.css': 'MNqXIt8RLPj-pB2WuqG7Q',\n  'snes9x-wasm.data': 'pVUBC-nZEN82gyuTpPsRw',\n  'snes9x-legacy-wasm.data': 'myfEzZFCCUes_kFsjUUCd',\n  'snes9x.json': 'YNbaUeVsno2mDYsEOtp39',\n  // the core .data files are 7z archives; these workers unpack cores and zipped ROMs\n  'extract7z.js': 'CPfp383FjI0u02qrXlbzl',\n  'extractzip.js': 'iqumdLN_ZjOfnsttklMob',\n  'libunrar.js': 'chOV7VBt3LvWNwsFSwxGk',\n  'libunrar.wasm': '8ftfUbTkQF-wO5oIGWIt1',\n};\n\n// libretro joypad ids as EmulatorJS's simulateInput expects them\nconst BTN = { B: 0, Y: 1, SELECT: 2, START: 3, UP: 4, DOWN: 5, LEFT: 6, RIGHT: 7, A: 8, X: 9, L: 10, R: 11 };\n\nconst DEAD_ZONE = 4;   // degrees of tilt ignored around neutral\nconst FULL_TILT = 20;  // degrees of tilt that hold the direction permanently\nconst YAW_GAIN = 1.0;      // degrees of steering per degree of yaw (turning the phone left/right)\nconst YAW_RECENTER = 1.2;  // s: yaw leaks back to neutral, so you never have to hold your body turned\nconst STEER_SMOOTH = 0.06; // s: low-pass time constant on the combined steering angle\nconst INVERT_STEER = true; // horizontal axis flipped: rolling/turning right steers left\nconst INVERT_YAW = false;  // flip yaw alone if it ever disagrees with roll on a device\nconst INVERT_ROLL = true;  // flip roll alone (tilting like a steering wheel)\n\nconst SHOOT_OPTIONS = ['B', 'Y', 'A', 'X'];\n\nfunction emulatorPage(romUrl, romName) {\n  const paths = {};\n  for (const [name, ref] of Object.entries(FILES)) paths[name] = MEDIA + ref;\n  const cfg = {\n    EJS_player: '#game',\n    EJS_core: 'snes',\n    EJS_gameName: romName.replace(/\\.[^.]+$/, ''),\n    EJS_gameUrl: romUrl,\n    EJS_pathtodata: MEDIA,\n    EJS_paths: paths,\n    EJS_DEBUG_XX: true,          // load the unminified sources individually (that's what the Drive holds)\n    EJS_startOnLoaded: true,\n    EJS_language: 'en-US',\n    EJS_disableAutoLang: false,  // loader's flag is inverted: false skips the localization fetch\n    EJS_threads: false,\n    EJS_color: '#ff6a2b',\n    EJS_backgroundColor: '#000',\n    EJS_Buttons: { netplay: false, saveState: false, loadState: false, screenRecord: false, screenshot: false, cacheManager: false, gamepad: false },\n  };\n  const assigns = Object.entries(cfg).map(([k, v]) => `window.${k}=${JSON.stringify(v)};`).join('');\n  return `<!doctype html><html><head><meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">\n<style>\nhtml,body{margin:0;height:100%;background:#000;overflow:hidden;touch-action:none}\n#game{width:100%;height:100%}\n.ejs_virtualGamepad_parent,.ejs_menu_bar,.ejs_context_menu{display:none!important}\n</style></head><body><div id=\"game\"></div>\n<script>${assigns}</script>\n<script src=\"${paths['loader.js']}\"></script>\n</body></html>`;\n}\n\nexport default {\n  css: `\n    :host{display:block;width:100%;height:100%}\n    .sf{position:relative;width:100%;height:100%;min-height:320px;overflow:hidden;background:#05060d;color:#e9ecff;\n      font-family:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,sans-serif;touch-action:none;user-select:none;-webkit-user-select:none}\n    .sf:fullscreen{width:100vw;height:100vh}\n    .stage{position:absolute;inset:0}\n    .emu{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;background:#000}\n    .touch{position:absolute;inset:0;z-index:2;display:none}\n    .playing .touch{display:block}\n    .hud{position:absolute;inset:0;z-index:3;pointer-events:none;display:none}\n    .playing .hud{display:block}\n    .hb{pointer-events:auto;position:absolute;border:1px solid rgba(255,255,255,.35);background:rgba(10,12,30,.45);color:#fff;\n      font-family:inherit;font-size:12px;font-weight:600;line-height:1;letter-spacing:.08em;border-radius:999px;padding:10px 14px;-webkit-tap-highlight-color:transparent}\n    .hb:active,.hb.on{background:rgba(255,106,43,.75);border-color:#ff6a2b}\n    .hb.start{top:max(10px,env(safe-area-inset-top));left:50%;transform:translateX(-50%)}\n    .hb.cal{top:max(10px,env(safe-area-inset-top));left:max(10px,env(safe-area-inset-left))}\n    .hb.bomb{bottom:max(14px,env(safe-area-inset-bottom));right:max(14px,env(safe-area-inset-right));width:68px;height:68px;padding:0;border-radius:50%}\n    .tilt{position:absolute;bottom:max(14px,env(safe-area-inset-bottom));left:max(14px,env(safe-area-inset-left));width:64px;height:64px;\n      border-radius:50%;border:1px solid rgba(255,255,255,.3);background:rgba(10,12,30,.35)}\n    .tilt i{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#ff6a2b;\n      box-shadow:0 0 10px #ff6a2b;transform:translate(0,0)}\n    .intro{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;\n      padding:24px;text-align:center;background:radial-gradient(ellipse at 50% 120%,#2a1240 0%,#0b0b22 45%,#05060d 75%)}\n    .intro::before{content:\"\";position:absolute;inset:0;pointer-events:none;opacity:.7;\n      background-image:radial-gradient(1px 1px at 12% 22%,#fff 50%,transparent 51%),radial-gradient(1px 1px at 72% 14%,#fff 50%,transparent 51%),\n      radial-gradient(1.5px 1.5px at 38% 68%,#cfd6ff 50%,transparent 51%),radial-gradient(1px 1px at 86% 58%,#fff 50%,transparent 51%),\n      radial-gradient(1px 1px at 22% 84%,#fff 50%,transparent 51%),radial-gradient(1.5px 1.5px at 58% 36%,#ffd9c7 50%,transparent 51%),\n      radial-gradient(1px 1px at 92% 88%,#fff 50%,transparent 51%),radial-gradient(1px 1px at 6% 52%,#cfd6ff 50%,transparent 51%)}\n    .playing .intro{display:none}\n    .intro > *{position:relative}\n    h1{margin:0;font-size:26px;letter-spacing:.18em;font-weight:800}\n    .sub{margin:0;font-size:13px;opacity:.7;max-width:340px;line-height:1.45}\n    .btn{appearance:none;border:0;border-radius:12px;padding:14px 22px;min-width:220px;font-family:inherit;font-size:15px;font-weight:700;line-height:1;letter-spacing:.06em;cursor:pointer;\n      -webkit-tap-highlight-color:transparent}\n    .pick{background:#1b1f3f;color:#fff;border:1px solid rgba(255,255,255,.25)}\n    .go{background:#ff6a2b;color:#140700}\n    .go[disabled]{opacity:.35}\n    .msg{min-height:18px;font-size:13px;color:#ffb38f;max-width:340px}\n    .help{list-style:none;margin:6px 0 0;padding:0;font-size:12px;opacity:.65;line-height:1.7}\n    .file{display:none}\n    .toast{position:absolute;left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);max-width:60%;font-size:12px;\n      text-align:center;color:#ffb38f;background:rgba(5,6,13,.7);border-radius:8px;padding:6px 10px;pointer-events:none}\n    .toast:empty{display:none}\n  `,\n\n  render() {\n    return `<div class=\"sf\">\n      <div class=\"stage\" data-d2-static></div>\n      <div class=\"touch\" data-d2-static></div>\n      <div class=\"hud\" data-d2-static>\n        <button class=\"hb cal\" type=\"button\">LEVEL</button>\n        <button class=\"hb start\" type=\"button\">START</button>\n        <button class=\"hb bomb\" type=\"button\">BOMB</button>\n        <div class=\"tilt\"><i></i></div>\n        <div class=\"toast\"></div>\n      </div>\n      <div class=\"intro\" data-d2-static>\n        <h1>FLIGHT DECK</h1>\n        <p class=\"sub\">Load your own Star Fox (SNES) ROM. The file stays on this device and is never uploaded.</p>\n        <input class=\"file\" type=\"file\" accept=\".sfc,.smc,.zip,.7z,.bin\">\n        <button class=\"btn pick\" type=\"button\">Choose ROM</button>\n        <button class=\"btn go\" type=\"button\" disabled>Launch</button>\n        <p class=\"msg\"></p>\n        <ul class=\"help\">\n          <li>Tilt the phone to steer — hold it the way you like, it levels on launch</li>\n          <li>Touch anywhere to shoot (hold for rapid fire)</li>\n          <li>START pauses &middot; BOMB fires a bomb &middot; LEVEL re-centres the tilt</li>\n        </ul>\n      </div>\n    </div>`;\n  },\n\n  mounted(d2) {\n    const sf = d2.$('.sf');\n    const stage = d2.$('.stage');\n    const touch = d2.$('.touch');\n    const fileInput = d2.$('.file');\n    const goBtn = d2.$('.go');\n    const msg = d2.$('.msg');\n    const dot = d2.$('.tilt i');\n    const toast = d2.$('.toast');\n\n    const shootName = String(d2.param('shootButton') || 'Y').toUpperCase();\n    const SHOOT = BTN[SHOOT_OPTIONS.includes(shootName) ? shootName : 'Y'];\n\n    let rom = null;          // { url, name }\n    let frame = null;\n    let firing = 0;          // active touches on the play surface\n    let fireTick = 0;\n    const held = new Set();  // buttons currently pressed by this component\n    const tilt = { roll: 0, rollWeight: 0, pitch: 0, yaw: 0, ok: false };\n    const neutral = { roll: 0, pitch: 0 };\n    let prevR = null, prevT = 0;\n    let steer = 0, lastFrame = 0;\n    let needsLevel = true;\n    const acc = { x: 0, y: 0 };\n\n    const say = (t) => { msg.textContent = t || ''; toast.textContent = sf.classList.contains('playing') ? (t || '') : ''; };\n\n    const gm = () => {\n      try { return frame && frame.contentWindow && frame.contentWindow.EJS_emulator && frame.contentWindow.EJS_emulator.gameManager; }\n      catch (e) { return null; }\n    };\n    const press = (id, down) => {\n      const g = gm();\n      if (!g) return;\n      if (down === held.has(id)) return;\n      down ? held.add(id) : held.delete(id);\n      try { g.simulateInput(0, id, down ? 1 : 0); } catch (e) {}\n    };\n    const resumeAudio = () => {\n      try {\n        const ctx = frame.contentWindow.EJS_emulator.Module.AL.currentCtx.audioCtx;\n        if (ctx && ctx.state !== 'running') ctx.resume();\n      } catch (e) {}\n    };\n\n    // --- ROM selection: read locally, hand the emulator a blob: URL ---\n    d2.on('.pick', 'click', () => fileInput.click());\n    fileInput.addEventListener('change', () => {\n      const f = fileInput.files && fileInput.files[0];\n      if (!f) return;\n      if (rom) URL.revokeObjectURL(rom.url);\n      rom = { url: URL.createObjectURL(f), name: f.name };\n      goBtn.disabled = false;\n      say(f.name + ' ready');\n    });\n\n    // --- Tilt: device orientation -> gravity in screen coordinates ---\n    const screenAngle = () => {\n      const a = (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : (window.orientation || 0);\n      return ((a % 360) + 360) % 360;\n    };\n    const wrap = (deg) => ((deg + 540) % 360) - 180;\n    // W3C device->earth rotation matrix (Z-X'-Y'' Euler: alpha, beta, gamma)\n    const rotation = (a, b, g) => {\n      const cA = Math.cos(a), sA = Math.sin(a), cB = Math.cos(b), sB = Math.sin(b), cG = Math.cos(g), sG = Math.sin(g);\n      return [\n        [cA * cG - sA * sB * sG, -cB * sA, cA * sG + cG * sA * sB],\n        [cG * sA + cA * sB * sG, cA * cB, sA * sG - cA * cG * sB],\n        [-cB * sG, sB, cB * cG],\n      ];\n    };\n    const onOrient = (e) => {\n      if (e.beta == null || e.gamma == null) return;\n      const r = Math.PI / 180;\n      const R = rotation((e.alpha || 0) * r, e.beta * r, e.gamma * r);\n      // \"up\" in device coordinates is the earth-z row of R\n      const ux = R[2][0], uy = R[2][1];\n\n      // Roll = steering-wheel rotation about the screen normal, read from where gravity points in the\n      // screen plane. It does not depend on the reported screen angle, and fades out as the phone goes\n      // flat (gravity leaves the screen plane), where yaw takes over the same motion.\n      const inPlane = Math.hypot(ux, uy);\n      const wheel = Math.atan2(uy, ux) / r;   // grows when the phone turns clockwise, as seen by the player\n      tilt.rollWeight = Math.max(0, Math.min(1, (inPlane - 0.2) / 0.3));\n\n      // Pitch = elevation of the screen's top edge (unchanged behaviour)\n      const t = screenAngle() * r;\n      const sy = ux * Math.sin(t) + uy * Math.cos(t);\n      tilt.pitch = Math.asin(Math.max(-1, Math.min(1, sy))) / r;\n\n      // Yaw = rotation about the earth's vertical between two samples, from the incremental rotation\n      // R_t * R_(t-1)^T. Integrated with a leak so compass drift and a turned body both re-centre.\n      const now = e.timeStamp || performance.now();\n      if (prevR && e.alpha != null) {\n        const dt = Math.max(0, Math.min(0.25, (now - prevT) / 1000));\n        let d10 = 0, d01 = 0, d00 = 0, d11 = 0;\n        for (let k = 0; k < 3; k++) {\n          d10 += R[1][k] * prevR[0][k]; d01 += R[0][k] * prevR[1][k];\n          d00 += R[0][k] * prevR[0][k]; d11 += R[1][k] * prevR[1][k];\n        }\n        const step = Math.atan2(d10 - d01, d00 + d11) / r;  // + = counter-clockwise seen from above (turning left)\n        if (Math.abs(step) < 30) tilt.yaw = (tilt.yaw - step) * Math.exp(-dt / YAW_RECENTER);  // + = turned right\n        tilt.yaw = Math.max(-45, Math.min(45, tilt.yaw));\n      }\n      prevR = R; prevT = now;\n\n      if (needsLevel) {\n        neutral.roll = wheel; neutral.pitch = tilt.pitch; tilt.yaw = 0; steer = 0; needsLevel = false;\n      }\n      tilt.roll = wrap(wheel - neutral.roll);\n      tilt.ok = true;\n    };\n    window.addEventListener('deviceorientation', onOrient);\n\n    const axis = (deg) => {\n      const m = Math.abs(deg);\n      if (m < DEAD_ZONE) return 0;\n      return Math.sign(deg) * Math.min(1, (m - DEAD_ZONE) / (FULL_TILT - DEAD_ZONE));\n    };\n    // The SNES pad is digital: pulse the direction with a duty cycle proportional to the tilt.\n    const pulse = (v, k) => {\n      const m = Math.abs(v);\n      if (m === 0) { acc[k] = 0; return 0; }\n      if (m >= 0.97) return Math.sign(v);\n      acc[k] += m;\n      if (acc[k] >= 1) { acc[k] -= 1; return Math.sign(v); }\n      return 0;\n    };\n\n    d2.raf(() => {\n      if (!sf.isConnected) return;\n      if (!gm()) return;\n      const invert = !!d2.param('invertPitch');\n      // horizontal: roll + yaw, low-passed so the pulses into the digital pad follow a smooth curve\n      const nowMs = performance.now();\n      const dt = lastFrame ? Math.min(0.1, (nowMs - lastFrame) / 1000) : 1 / 60;\n      lastFrame = nowMs;\n      const yawSign = INVERT_YAW ? -1 : 1;\n      const rollSign = INVERT_ROLL ? -1 : 1;\n      const target = tilt.ok ? rollSign * tilt.roll * tilt.rollWeight + YAW_GAIN * yawSign * tilt.yaw : 0;\n      steer += (target - steer) * (1 - Math.exp(-dt / STEER_SMOOTH));\n      const x = axis(INVERT_STEER ? -steer : steer);\n      // tilting the top edge away (nose forward) presses UP, like pushing a flight stick\n      let y = tilt.ok ? axis(neutral.pitch - tilt.pitch) : 0;\n      if (invert) y = -y;\n      dot.style.transform = `translate(${(x * 22).toFixed(1)}px,${(-y * 22).toFixed(1)}px)`;\n      const px = pulse(x, 'x'), py = pulse(y, 'y');\n      press(BTN.RIGHT, px > 0); press(BTN.LEFT, px < 0);\n      press(BTN.UP, py > 0); press(BTN.DOWN, py < 0);\n      // shooting: held touch = rapid fire (press 4 frames, release 4 frames)\n      if (firing > 0) {\n        const auto = d2.param('autoFire') !== false;\n        fireTick++;\n        press(SHOOT, auto ? (fireTick % 8) < 4 : true);\n      } else {\n        fireTick = 0;\n        press(SHOOT, false);\n      }\n    });\n\n    // --- Touch surface: any touch shoots ---\n    const touchDown = (e) => { e.preventDefault(); resumeAudio(); firing = e.touches ? e.touches.length : 1; };\n    const touchUp = (e) => { e.preventDefault(); firing = e.touches ? e.touches.length : 0; };\n    touch.addEventListener('touchstart', touchDown, { passive: false });\n    touch.addEventListener('touchend', touchUp, { passive: false });\n    touch.addEventListener('touchcancel', touchUp, { passive: false });\n    touch.addEventListener('mousedown', (e) => { resumeAudio(); firing = 1; e.preventDefault(); });\n    window.addEventListener('mouseup', () => { if (firing && !('ontouchstart' in window)) firing = 0; });\n\n    // --- HUD buttons: held for as long as the finger is down ---\n    const holdButton = (sel, id) => {\n      const el = d2.$(sel);\n      const down = (e) => { e.preventDefault(); e.stopPropagation(); resumeAudio(); el.classList.add('on'); press(id, true); };\n      const up = (e) => { e.preventDefault(); e.stopPropagation(); el.classList.remove('on'); press(id, false); };\n      el.addEventListener('touchstart', down, { passive: false });\n      el.addEventListener('touchend', up, { passive: false });\n      el.addEventListener('touchcancel', up, { passive: false });\n      el.addEventListener('mousedown', down);\n      el.addEventListener('mouseup', up);\n      el.addEventListener('mouseleave', up);\n    };\n    holdButton('.hb.start', BTN.START);\n    holdButton('.hb.bomb', BTN.A);\n    d2.on('.hb.cal', 'click', () => { needsLevel = true; });\n\n    // --- Launch: permissions need this user gesture, then boot the emulator in an iframe ---\n    d2.on('.go', 'click', () => {\n      if (!rom) return;\n      const DOE = window.DeviceOrientationEvent;\n      if (DOE && typeof DOE.requestPermission === 'function') {\n        DOE.requestPermission().then((s) => { if (s !== 'granted') say('Motion access denied — steering is disabled. Reload to try again.'); }).catch(() => {});\n      }\n      try { if (sf.requestFullscreen) sf.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}); } catch (e) {}\n      try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {}); } catch (e) {}\n\n      stage.innerHTML = '';\n      frame = document.createElement('iframe');\n      frame.className = 'emu';\n      frame.title = 'SNES emulator';\n      frame.setAttribute('allow', 'autoplay; fullscreen; gamepad; accelerometer; gyroscope');\n      frame.srcdoc = emulatorPage(rom.url, rom.name);\n      stage.appendChild(frame);\n      needsLevel = true;\n      sf.classList.add('playing');\n      setTimeout(() => { if (!tilt.ok) say('No tilt sensor reported — steering needs a phone with a gyroscope.'); }, 3000);\n    });\n  },\n};\n"}},"autoFire":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"autoFire"}}}},"invertPitch":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertPitch"}}}},"shootButton":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"shootButton"}}}}}}}}}},{"_id":"6abff75fce21b725b3165a70","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"dataFox2_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// dataFox2 — runs the Starship (SF64 PC port) WebAssembly build in a WebGL2 canvas,\n// with phone controls: gyroscope steering, a touch stick, and touch buttons.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap .login input { -webkit-user-select: text; user-select: text; touch-action: manipulation; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #16203a 0%, #05060a 70%);\n      text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .login { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login[hidden] { display: none; }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 10px;\n      padding: 12px 14px; font-size: 16px; outline: none; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .go { width: 100%; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    .knob-base { position: absolute; width: 120px; height: 120px; margin: -60px 0 0 -60px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <canvas class=\"game\" id=\"canvas\" tabindex=\"-1\"></canvas>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"title\">dataFox2</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <form class=\"login\" hidden autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"go signin\" type=\"submit\">Sign in</button>\n        </form>\n        <button class=\"go start-btn\" disabled>Start</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Restart</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const canvas = $('canvas.game')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const loginForm = $('.login')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.disabled = true }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'datafox2keysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    const pad = makePad()\n    let uninstallPad = null\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(useGyro ? 1 : 0, live(input.gyro[0]), live(-input.gyro[1])) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : input.stickActive ? input.stick : (useGyro && !direct ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      }\n    })\n\n    // ---- engine boot ---------------------------------------------------------------------\n    let blobs = []\n    let started = false\n    let files = null\n    let engine = null\n    let crashed = false\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = []\n    const remember = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext. When the engine dies, its audio callback\n    // keeps replaying the last buffer (the stuck shooting sound), so close the context outright.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const resumeAudio = () => {\n      if (crashed) return\n      try { const ac = audioContext() || unlockedAudio; if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Created and started inside the Start tap, then given to the engine.\n    let unlockedAudio = null\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+).\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx || unlockedAudio) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        try { unlockedAudio = new Ctx({ sampleRate: 32000 }) } catch (e) { unlockedAudio = new Ctx() }\n        unlockedAudio.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = unlockedAudio.createBufferSource()\n        src.buffer = unlockedAudio.createBuffer(1, 1, 22050)\n        src.connect(unlockedAudio.destination)\n        src.start(0)\n      } catch (e) { unlockedAudio = null }\n    }\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error('[dataFox2] crashed:', why)\n    }\n    d2.on('.restart', 'click', () => location.reload())\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      const ac = audioContext()\n      if (!ac || crashed) return\n      try { document.hidden ? ac.suspend() : ac.resume() } catch (e) {}\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => {\n      if (e && e.persisted) { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (err) {} }\n      else stopAudio()\n    })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    d2.on(root, 'pointerup', () => resumeAudio())\n    d2.on(root, 'pointerdown', () => resumeAudio())\n    d2.onUnmount(stopAudio)\n\n    const showLogin = (msg) => {\n      loginForm.hidden = false\n      goBtn.hidden = true\n      errEl.textContent = msg || ''\n      setStatus('Sign in to play')\n    }\n\n    d2.on(loginForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(loginForm)\n      const btn = loginForm.querySelector('.signin')\n      btn.disabled = true\n      errEl.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        loginForm.hidden = true\n        goBtn.hidden = false\n        progress.total = 0\n        progress.loaded = {}\n        preload()\n      } catch (err) {\n        errEl.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    const preload = async () => {\n      try {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        files = { js, wasm, main, port }\n        setProgress(1)\n        setStatus('Ready. Tap Start (sound and motion need a tap).')\n        goBtn.disabled = false\n      } catch (e) {\n        if (e instanceof Refused) showLogin()\n        else fail(String(e && e.message || e))\n      }\n    }\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    const enterFullscreen = async () => {\n      const el = d2.root.host || root\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = 'datafox2.warm.v1'\n    const learned = new Set()\n    try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => learned.add(l)) } catch (e) {}\n    let warmSaveTimer = null\n    let warming = false // items the warm-up itself loads are already listed: don't re-learn them\n    const learnWarm = (line) => {\n      if (warming || learned.has(line) || learned.size > 30000) return\n      learned.add(line)\n      clearTimeout(warmSaveTimer)\n      warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const started = performance.now()\n      const done = () => { warming = false; resolve() }\n      warming = true\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return done() }\n        if (left > 0 && performance.now() - started < 8000) requestAnimationFrame(step)\n        else done()\n      }\n      requestAnimationFrame(step)\n    })\n\n    const boot = async () => {\n      if (started || !files) return\n      started = true\n      goBtn.disabled = true\n      // Must run synchronously inside the Start tap: iOS only lets audio start from a user gesture,\n      // and the engine creates its output several awaits later (activation can be gone by then).\n      unlockAudio()\n      setStatus('Starting engine…')\n      await Promise.all([askMotionPermission(), enterFullscreen()])\n      $('.rotate').classList.add('armed')\n\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      blobs.push(jsUrl)\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier mount\n        // (a resumed home-screen app, a remount without reload) belongs to an older engine and\n        // would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { remember(t); console.log('[starship]', t) },\n          printErr: (t) => { remember(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => crash(`Engine aborted: ${what}`, new Error('abort').stack),\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by the Start tap (SDL reuses Module.SDL2.audioContext).\n            if (unlockedAudio) { M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = unlockedAudio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        engine = Module\n        files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        overlay.hidden = true\n        hud.hidden = false\n      } catch (e) {\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      }\n    }\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while visible = frozen.\n    let lastDraw = 0\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage 'datafox2.hitches').\n    const diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n    const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        root.querySelector('.wrap').appendChild(el)\n      }\n      return el\n    }\n    const diagFrame = (now) => {\n      const gap = now - diag.last\n      if (diag.last && gap > 150 && !document.hidden && uiMode() === 0 && diag.reports < 6) {\n        diag.reports++\n        const n = diag.n\n        const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n          `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n          `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n          `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n          `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n        const msg = parts.join(' · ')\n        const box = diagBox()\n        box.textContent = msg\n        box.style.display = 'block'\n        clearTimeout(diag.timer)\n        diag.timer = setTimeout(() => { box.style.display = 'none' }, 8000)\n        try {\n          const prev = JSON.parse(localStorage.getItem('datafox2.hitches') || '[]')\n          prev.push({ at: new Date().toISOString(), msg })\n          localStorage.setItem('datafox2.hitches', JSON.stringify(prev.slice(-20)))\n        } catch (e) {}\n      }\n      diag.last = now\n      diagReset()\n    }\n    const watchFrames = () => {\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        for (const fn of ['clear']) {\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => { lastDraw = performance.now(); diagFrame(lastDraw); return orig(...a) }\n        }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n      d2.onUnmount(() => { window.requestAnimationFrame = raf })\n      lastDraw = performance.now()\n      const id = setInterval(() => {\n        if (crashed || !engine) return clearInterval(id)\n        if (document.hidden) { lastDraw = performance.now(); return }\n        if (performance.now() - lastDraw > 4000) {\n          crash('The game froze (no frames for 4 seconds).', '')\n          clearInterval(id)\n        }\n      }, 1000)\n      d2.onUnmount(() => clearInterval(id))\n    }\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    d2.on('.start-btn', 'click', boot)\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    d2.onUnmount(() => {\n      uninstallPad?.()\n      blobs.forEach((u) => URL.revokeObjectURL(u))\n    })\n\n    preload()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac6b49fd0e6a463e356f93f","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Player","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF (Project: TFSF, a modded Star Fox 64; cloned from dataFox2) — runs the Starship (SF64 PC port) WebAssembly build in a WebGL2 canvas,\n// with phone controls: gyroscope steering, a touch stick, and touch buttons.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap .login input { -webkit-user-select: text; user-select: text; touch-action: manipulation; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #16203a 0%, #05060a 70%);\n      text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .login { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login[hidden] { display: none; }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 10px;\n      padding: 12px 14px; font-size: 16px; outline: none; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .go { width: 100%; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    .knob-base { position: absolute; width: 120px; height: 120px; margin: -60px 0 0 -60px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <canvas class=\"game\" id=\"canvas\" tabindex=\"-1\"></canvas>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"title\">TFSF</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <form class=\"login\" hidden autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"go signin\" type=\"submit\">Sign in</button>\n        </form>\n        <button class=\"go start-btn\" disabled>Start</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Restart</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const canvas = $('canvas.game')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const loginForm = $('.login')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.disabled = true }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    const pad = makePad()\n    let uninstallPad = null\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(useGyro ? 1 : 0, live(input.gyro[0]), live(-input.gyro[1])) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : input.stickActive ? input.stick : (useGyro && !direct ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      }\n    })\n\n    // ---- engine boot ---------------------------------------------------------------------\n    let blobs = []\n    let started = false\n    let files = null\n    let engine = null\n    let crashed = false\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = []\n    const remember = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext. When the engine dies, its audio callback\n    // keeps replaying the last buffer (the stuck shooting sound), so close the context outright.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const resumeAudio = () => {\n      if (crashed) return\n      try { const ac = audioContext() || unlockedAudio; if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Created and started inside the Start tap, then given to the engine.\n    let unlockedAudio = null\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+).\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx || unlockedAudio) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        try { unlockedAudio = new Ctx({ sampleRate: 32000 }) } catch (e) { unlockedAudio = new Ctx() }\n        unlockedAudio.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = unlockedAudio.createBufferSource()\n        src.buffer = unlockedAudio.createBuffer(1, 1, 22050)\n        src.connect(unlockedAudio.destination)\n        src.start(0)\n      } catch (e) { unlockedAudio = null }\n    }\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error('[TFSF] crashed:', why)\n    }\n    d2.on('.restart', 'click', () => location.reload())\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      const ac = audioContext()\n      if (!ac || crashed) return\n      try { document.hidden ? ac.suspend() : ac.resume() } catch (e) {}\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => {\n      if (e && e.persisted) { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (err) {} }\n      else stopAudio()\n    })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    d2.on(root, 'pointerup', () => resumeAudio())\n    d2.on(root, 'pointerdown', () => resumeAudio())\n    d2.onUnmount(stopAudio)\n\n    const showLogin = (msg) => {\n      loginForm.hidden = false\n      goBtn.hidden = true\n      errEl.textContent = msg || ''\n      setStatus('Sign in to play')\n    }\n\n    d2.on(loginForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(loginForm)\n      const btn = loginForm.querySelector('.signin')\n      btn.disabled = true\n      errEl.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        loginForm.hidden = true\n        goBtn.hidden = false\n        progress.total = 0\n        progress.loaded = {}\n        preload()\n      } catch (err) {\n        errEl.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    const preload = async () => {\n      try {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        files = { js, wasm, main, port }\n        setProgress(1)\n        setStatus('Ready. Tap Start (sound and motion need a tap).')\n        goBtn.disabled = false\n      } catch (e) {\n        if (e instanceof Refused) showLogin()\n        else fail(String(e && e.message || e))\n      }\n    }\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    const enterFullscreen = async () => {\n      const el = d2.root.host || root\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = 'tfsf.warm.v1'\n    const learned = new Set()\n    try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => learned.add(l)) } catch (e) {}\n    let warmSaveTimer = null\n    let warming = false // items the warm-up itself loads are already listed: don't re-learn them\n    const learnWarm = (line) => {\n      if (warming || learned.has(line) || learned.size > 30000) return\n      learned.add(line)\n      clearTimeout(warmSaveTimer)\n      warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const started = performance.now()\n      const done = () => { warming = false; resolve() }\n      warming = true\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return done() }\n        if (left > 0 && performance.now() - started < 8000) requestAnimationFrame(step)\n        else done()\n      }\n      requestAnimationFrame(step)\n    })\n\n    const boot = async () => {\n      if (started || !files) return\n      started = true\n      goBtn.disabled = true\n      // Must run synchronously inside the Start tap: iOS only lets audio start from a user gesture,\n      // and the engine creates its output several awaits later (activation can be gone by then).\n      unlockAudio()\n      setStatus('Starting engine…')\n      await Promise.all([askMotionPermission(), enterFullscreen()])\n      $('.rotate').classList.add('armed')\n\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      blobs.push(jsUrl)\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier mount\n        // (a resumed home-screen app, a remount without reload) belongs to an older engine and\n        // would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { remember(t); console.log('[starship]', t) },\n          printErr: (t) => { remember(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => crash(`Engine aborted: ${what}`, new Error('abort').stack),\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by the Start tap (SDL reuses Module.SDL2.audioContext).\n            if (unlockedAudio) { M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = unlockedAudio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        engine = Module\n        files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        overlay.hidden = true\n        hud.hidden = false\n      } catch (e) {\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      }\n    }\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while visible = frozen.\n    let lastDraw = 0\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage 'tfsf.hitches').\n    const diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n    const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        root.querySelector('.wrap').appendChild(el)\n      }\n      return el\n    }\n    const diagFrame = (now) => {\n      const gap = now - diag.last\n      if (diag.last && gap > 150 && !document.hidden && uiMode() === 0 && diag.reports < 6) {\n        diag.reports++\n        const n = diag.n\n        const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n          `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n          `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n          `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n          `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n        const msg = parts.join(' · ')\n        const box = diagBox()\n        box.textContent = msg\n        box.style.display = 'block'\n        clearTimeout(diag.timer)\n        diag.timer = setTimeout(() => { box.style.display = 'none' }, 8000)\n        try {\n          const prev = JSON.parse(localStorage.getItem('tfsf.hitches') || '[]')\n          prev.push({ at: new Date().toISOString(), msg })\n          localStorage.setItem('tfsf.hitches', JSON.stringify(prev.slice(-20)))\n        } catch (e) {}\n      }\n      diag.last = now\n      diagReset()\n    }\n    const watchFrames = () => {\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        for (const fn of ['clear']) {\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => { lastDraw = performance.now(); diagFrame(lastDraw); return orig(...a) }\n        }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n      d2.onUnmount(() => { window.requestAnimationFrame = raf })\n      lastDraw = performance.now()\n      const id = setInterval(() => {\n        if (crashed || !engine) return clearInterval(id)\n        if (document.hidden) { lastDraw = performance.now(); return }\n        if (performance.now() - lastDraw > 4000) {\n          crash('The game froze (no frames for 4 seconds).', '')\n          clearInterval(id)\n        }\n      }, 1000)\n      d2.onUnmount(() => clearInterval(id))\n    }\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    d2.on('.start-btn', 'click', boot)\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    d2.onUnmount(() => {\n      uninstallPad?.()\n      blobs.forEach((u) => URL.revokeObjectURL(u))\n    })\n\n    preload()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"6ac6c328d0e6a463e35716ab","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"extends":"data2-core-primitives@Block","component":"TFSF_Player_DEV","package":"lucca-playground","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// TFSF DEV — development copy of TFSF_Player. All player changes are made here first, tested on\n// the tfsf-dev app, then promoted to TFSF_Player (PRD) by copying this source with the DEV markers\n// (title, badge, storage keys) switched back. Runs the Starship (SF64 PC port) WebAssembly build in a WebGL2 canvas,\n// with phone controls: gyroscope steering, a touch stick, and touch buttons.\n//\n// Input reaches the engine as a *virtual gamepad*: navigator.getGamepads() is wrapped so\n// SDL's emscripten joystick backend sees one \"standard\"-mapped pad whose axes come from the\n// gyro / touch stick and whose buttons come from the on-screen controls. Starship's default\n// SDL mapping then turns that into N64 input (A, B, Z, R, Start, C-buttons, stick).\n\nconst BUTTON = { A: 0, CDOWN: 1, B: 2, CLEFT: 3, Z: 4, R: 5, BACK: 8, START: 9, DUP: 12, DDOWN: 13, DLEFT: 14, DRIGHT: 15 }\n\nfunction makePad() {\n  const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }))\n  return {\n    id: 'dataFox2 virtual pad (standard)',\n    index: 0,\n    connected: true,\n    mapping: 'standard',\n    axes: [0, 0, 0, 0],\n    buttons,\n    timestamp: performance.now(),\n    vibrationActuator: null,\n  }\n}\n\nfunction installVirtualPad(pad) {\n  const nav = window.navigator\n  const original = nav.getGamepads ? nav.getGamepads.bind(nav) : () => []\n  const patched = () => {\n    pad.timestamp = performance.now()\n    const real = Array.from(original() || []).filter(Boolean)\n    return [pad, ...real.map((g, i) => g && { ...g, index: i + 1 })]\n  }\n  try {\n    Object.defineProperty(nav, 'getGamepads', { configurable: true, value: patched })\n  } catch (e) {\n    nav.getGamepads = patched\n  }\n  const ev = new Event('gamepadconnected')\n  ev.gamepad = pad\n  window.dispatchEvent(ev)\n  return () => {\n    try { Object.defineProperty(nav, 'getGamepads', { configurable: true, value: original }) } catch (e) {}\n  }\n}\n\nconst clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))\n\nexport default {\n  css: `\n    /* No long-press magnifier / callout / selection / tap flash anywhere in the game UI. */\n    .wrap, .wrap * { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;\n      -webkit-tap-highlight-color: transparent; touch-action: none; }\n    .wrap .login input { -webkit-user-select: text; user-select: text; touch-action: manipulation; }\n    .wrap { position: relative; display: block; width: 100%; height: 100%; background: #05060a;\n      overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n      font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e8ecf4; }\n    /* !important: SDL writes inline css sizes on resize; the canvas must always fill the component. */\n    canvas.game { position: absolute; inset: 0; width: 100% !important; height: 100% !important; display: block; outline: none; }\n    .crash { position: absolute; inset: 0; z-index: 9; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 12px; padding: 24px; box-sizing: border-box; background: #05060af2; text-align: center; }\n    .crash[hidden] { display: none; }\n    .crash pre { max-width: 92%; max-height: 40%; overflow: auto; margin: 0; padding: 10px; border-radius: 8px;\n      background: #0c1222; color: #ffb4b4; font: 11px/1.4 ui-monospace, Menlo, monospace; text-align: left; white-space: pre-wrap; }\n    .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;\n      justify-content: center; gap: 14px; background: radial-gradient(circle at 50% 40%, #16203a 0%, #05060a 70%);\n      text-align: center; padding: 24px; box-sizing: border-box; z-index: 5; }\n    .overlay[hidden] { display: none; }\n    .title { font-size: 28px; font-weight: 800; letter-spacing: 0.08em; }\n    .sub { font-size: 14px; opacity: 0.75; max-width: 420px; line-height: 1.4; }\n    .bar { width: min(320px, 70vw); height: 6px; border-radius: 3px; background: #1d2740; overflow: hidden; }\n    .bar > i { display: block; height: 100%; width: 0%; background: #5aa2ff; transition: width 0.2s; }\n    .go { appearance: none; border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 700;\n      background: #5aa2ff; color: #05060a; cursor: pointer; }\n    .go[disabled] { opacity: 0.4; }\n    .err { color: #ff8a8a; font-size: 13px; max-width: 460px; white-space: pre-wrap; }\n    .login { display: flex; flex-direction: column; gap: 10px; width: min(300px, 80vw); }\n    .login[hidden] { display: none; }\n    .login input { appearance: none; border: 1px solid #2c3a5c; background: #0c1222; color: #e8ecf4; border-radius: 10px;\n      padding: 12px 14px; font-size: 16px; outline: none; }\n    .login input:focus { border-color: #5aa2ff; }\n    .login .go { width: 100%; }\n\n    .hud { position: absolute; inset: 0; z-index: 3; pointer-events: none; }\n    .hud[hidden] { display: none; }\n    .btn { position: absolute; pointer-events: auto; display: flex; align-items: center; justify-content: center;\n      border-radius: 50%; background: rgba(255,255,255,0.14); border: 2px solid rgba(255,255,255,0.35);\n      color: #fff; font-weight: 800; font-size: 18px; box-sizing: border-box; backdrop-filter: blur(2px); }\n    .btn.on { background: rgba(90,162,255,0.55); border-color: #9cc6ff; }\n    .btn.pill { border-radius: 14px; font-size: 13px; letter-spacing: 0.05em; }\n    .a { width: 74px; height: 74px; right: calc(26px + env(safe-area-inset-right)); bottom: 34px; }\n    .b { width: 60px; height: 60px; right: calc(112px + env(safe-area-inset-right)); bottom: 22px; }\n    .r { width: 64px; height: 46px; right: calc(28px + env(safe-area-inset-right)); bottom: 124px; }\n    .z { width: 64px; height: 46px; left: calc(22px + env(safe-area-inset-left)); bottom: 186px; }\n    .boost { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 104px; font-size: 12px; }\n    .brake { width: 68px; height: 68px; left: calc(22px + env(safe-area-inset-left)); bottom: 24px; font-size: 12px; }\n    .start { width: 78px; height: 34px; left: 50%; transform: translateX(-50%); bottom: 14px; }\n    .top { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: calc(10px + env(safe-area-inset-right));\n      display: flex; gap: 8px; pointer-events: auto; }\n    .chip { pointer-events: auto; appearance: none; border: 1px solid rgba(255,255,255,0.35); background: rgba(0,0,0,0.45);\n      color: #fff; border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 600; }\n    .chip.on { background: rgba(90,162,255,0.55); }\n    .chip { transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; }\n    .chip.flash { background: rgba(90,162,255,0.85); border-color: #cfe3ff; transform: scale(0.92); }\n    .toast { position: absolute; top: calc(52px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);\n      padding: 6px 14px; border-radius: 999px; background: rgba(0,0,0,0.6); color: #fff; font-size: 13px; font-weight: 700;\n      letter-spacing: 0.04em; opacity: 0; transition: opacity 0.2s ease; pointer-events: none; white-space: nowrap; }\n    .toast.show { opacity: 1; }\n    /* Swipe trail feedback (maneuvers / menu steps). */\n    .gesture-hint { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(0.9); font-size: 44px;\n      font-weight: 900; color: #fff; opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; pointer-events: none;\n      text-shadow: 0 2px 12px rgba(0,0,0,0.6); }\n    .gesture-hint.show { opacity: 0.85; transform: translate(-50%, -50%) scale(1); }\n    /* The hint glyphs, drawn (nearly invisibly) on the loading screen. iOS has no U+21B6 (the U-turn\n       arrow) in the system font, and the first time it is shown it loads a fallback font, which\n       froze the first U-turn of each session. Showing it here does that load while we wait. */\n    .glyph-warm { position: absolute; left: -9999px; top: 0; font-size: 44px; font-weight: 900;\n      pointer-events: none; user-select: none; }\n    .knob-base { position: absolute; width: 120px; height: 120px; margin: -60px 0 0 -60px; border-radius: 50%;\n      border: 2px solid rgba(255,255,255,0.3); background: rgba(255,255,255,0.06); display: none; }\n    .knob { position: absolute; width: 54px; height: 54px; margin: -27px 0 0 -27px; border-radius: 50%;\n      background: rgba(255,255,255,0.35); display: none; }\n    .rotate { position: absolute; inset: 0; z-index: 8; display: none; align-items: center; justify-content: center;\n      background: #05060aee; font-size: 16px; text-align: center; padding: 24px; }\n    @media (orientation: portrait) and (pointer: coarse) { .rotate.armed { display: flex; } }\n    /* Environment marker: this is the DEV player. */\n    .envbadge { position: absolute; top: calc(6px + env(safe-area-inset-top)); left: calc(8px + env(safe-area-inset-left));\n      z-index: 10; padding: 2px 8px; border-radius: 6px; background: #ff9f1a; color: #05060a;\n      font: 800 11px/1.5 system-ui, -apple-system, sans-serif; letter-spacing: 0.08em; pointer-events: none; }\n  `,\n\n  // Render once; everything after mount is imperative (canvas + engine) so re-renders never touch it.\n  render() {\n    return `<div class=\"wrap\" data-d2-static>\n      <canvas class=\"game\" id=\"canvas\" tabindex=\"-1\"></canvas>\n      <div class=\"envbadge\">DEV</div>\n      <div class=\"hud\" hidden>\n        <div class=\"gesture-hint\"></div>\n        <div class=\"btn a\" data-b=\"A\">A</div>\n        <div class=\"btn b\" data-b=\"B\">B</div>\n        <div class=\"btn pill r\" data-b=\"R\">R</div>\n        <div class=\"btn pill z\" data-b=\"Z\">Z</div>\n        <div class=\"btn boost\" data-b=\"CLEFT\">BOOST</div>\n        <div class=\"btn brake\" data-b=\"CDOWN\">BRAKE</div>\n        <div class=\"btn pill start\" data-b=\"START\">START</div>\n        <div class=\"toast\"></div>\n        <div class=\"top\">\n          <button class=\"chip gyro on\" data-act=\"gyro\">Gyro</button>\n          <button class=\"chip\" data-act=\"sens\">Tilt: Med</button>\n          <button class=\"chip\" data-act=\"center\">Center</button>\n          <button class=\"chip\" data-act=\"hud\">Hide</button>\n        </div>\n      </div>\n      <div class=\"overlay\">\n        <div class=\"glyph-warm\" aria-hidden=\"true\">↻↶▲▼▶◀</div>\n        <div class=\"title\">TFSF DEV</div>\n        <div class=\"sub status\">Preparing…</div>\n        <div class=\"bar\"><i></i></div>\n        <form class=\"login\" hidden autocomplete=\"on\">\n          <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\n          <input name=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n          <button class=\"go signin\" type=\"submit\">Sign in</button>\n        </form>\n        <button class=\"go start-btn\" disabled>Start</button>\n        <div class=\"err\"></div>\n      </div>\n      <div class=\"rotate\">Rotate your phone to landscape</div>\n      <div class=\"crash\" hidden>\n        <div class=\"title\">The game stopped</div>\n        <div class=\"sub crash-why\"></div>\n        <pre class=\"crash-log\"></pre>\n        <button class=\"go restart\">Restart</button>\n      </div>\n    </div>`\n  },\n\n  mounted(d2, root) {\n    const $ = (s) => root.querySelector(s) || d2.$(s)\n    const canvas = $('canvas.game')\n    const overlay = $('.overlay')\n    const statusEl = $('.status')\n    const barEl = $('.bar > i')\n    const goBtn = $('.start-btn')\n    const loginForm = $('.login')\n    const errEl = $('.err')\n    const hud = $('.hud')\n\n    const filesBase = String(d2.param('filesBase', '')).replace(/\\/$/, '')\n    const refs = {\n      js: d2.param('engineJs', ''),\n      wasm: d2.param('engineWasm', ''),\n      main: d2.param('mainO2r', ''),\n      port: d2.param('portO2r', ''),\n    }\n\n    const setStatus = (t) => { statusEl.textContent = t }\n    const setProgress = (f) => { barEl.style.width = `${Math.round(clamp(f, 0, 1) * 100)}%` }\n    const fail = (msg) => { errEl.textContent = msg; setStatus('Could not start'); goBtn.disabled = true }\n\n    // Two ways in: a builder session (the Drive's builder-only policy) or a player session from\n    // the login strategy. Both live in localStorage; each request carries whichever exist.\n    const systemDomain = String(d2.param('systemDomain', 'lucca-playground'))\n    const readKey = (k) => { try { return localStorage.getItem(k) || '' } catch (e) { return '' } }\n    const authHeaders = () => {\n      const h = {}\n      const builderToken = readKey('__data2-session-data2')\n      const sessionToken = readKey(`__data2-session-${systemDomain}`)\n      if (builderToken) h['X-Data2-Builder-Token'] = builderToken\n      if (sessionToken) h.Authorization = `Bearer ${sessionToken}`\n      return h\n    }\n    class Refused extends Error {}\n\n    const progress = { total: 0, loaded: {} }\n    // When a download dies at the network level ('Load failed' / 'Failed to fetch'), find out which\n    // hop broke: the authorised request to the server, or the redirect it answers with.\n    const diagnose = async (ref) => {\n      try {\n        const r = await fetch(`${filesBase}/file/${ref}`, { headers: authHeaders(), redirect: 'manual' })\n        if (r.type === 'opaqueredirect') return 'server accepted the session; the browser blocked the redirect to the file'\n        return `server answered HTTP ${r.status} without redirecting`\n      } catch (e) {\n        return `the request to the server itself failed (${e && e.message})`\n      }\n    }\n\n    // Downloads go out as plain GETs with the key in the query string (the Drive has a policy that\n    // accepts it). A request with no custom headers needs no CORS preflight, and WebKit (every\n    // iPhone browser) refuses to follow the server's redirect to the signed file URL after one.\n    // The key itself is read through the session-gated keys DataSet, which involves no redirect.\n    const keysDataset = String(d2.param('keysDataset', 'tfsfkeysDataSet'))\n    const getDownloadKey = async () => {\n      const res = await fetch(`${filesBase}/dataset/${keysDataset}?$limit=1`, { headers: authHeaders() })\n      if (res.status === 401 || res.status === 403) throw new Refused(`keys: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`keys: HTTP ${res.status}`)\n      const body = await res.json()\n      const rows = Array.isArray(body) ? body : (body && body.data) || []\n      const k = rows[0] && rows[0].key\n      if (!k) throw new Refused('keys: no key readable for this session')\n      return k\n    }\n    let downloadKey = ''\n\n    const fetchBytes = async (key, ref) => {\n      if (!ref) throw new Error(`Missing file reference for ${key}`)\n      let res\n      try {\n        res = await fetch(`${filesBase}/file/${ref}?t=${encodeURIComponent(downloadKey)}`)\n      } catch (e) {\n        const ua = (navigator.userAgent.match(/(iPhone|iPad|Android|Macintosh|Windows)[^;)]*/) || [''])[0]\n        throw new Error(`${key}: ${e && e.message} | ${await diagnose(ref)} | ${ua}`)\n      }\n      if (res.status === 401 || res.status === 403) throw new Refused(`${key}: HTTP ${res.status}`)\n      if (!res.ok) throw new Error(`${key}: HTTP ${res.status}`)\n      const len = Number(res.headers.get('content-length')) || 0\n      progress.total += len\n      if (!res.body || !len) return new Uint8Array(await res.arrayBuffer())\n      const reader = res.body.getReader()\n      const chunks = []\n      let got = 0\n      for (;;) {\n        const { done, value } = await reader.read()\n        if (done) break\n        chunks.push(value)\n        got += value.length\n        progress.loaded[key] = got\n        const sum = Object.values(progress.loaded).reduce((a, b) => a + b, 0)\n        setProgress(progress.total ? sum / progress.total : 0)\n      }\n      const out = new Uint8Array(got)\n      let o = 0\n      for (const c of chunks) { out.set(c, o); o += c.length }\n      return out\n    }\n\n    // ---- input state ---------------------------------------------------------------------\n    const pad = makePad()\n    let uninstallPad = null\n    // gyro: exact steering (-1..1, +x right, +y down). gyroPad: the same, pushed past\n    // libultraship's 20% stick deadzone, for engines without the direct stick export.\n    const input = { gyroOn: true, stickActive: false, stick: [0, 0], gyro: [0, 0], gyroPad: [0, 0], neutral: null, lastOri: null }\n\n    // Motion steering goes straight into the engine's stick (exact values, no gamepad deadzone or\n    // notch snapping) through web_set_stick (engine v10+). The touch stick, and older engines,\n    // go through the virtual gamepad.\n    const syncAxes = () => {\n      const direct = !!(engine && typeof engine._web_set_stick === 'function' && !crashed)\n      // Only once motion data is actually arriving (desktop keeps keyboard/gamepad sticks), and\n      // only while flying: in menus the tilt would wander the cursor.\n      const useGyro = input.gyroOn && !input.stickActive && !!input.hasMotion && uiMode() === 0\n      // On motion-controlled devices the maneuvers belong to the swipes: switch off the classic\n      // pull-back + Boost/Brake trigger so hard tilts never loop by accident (desktop keeps it).\n      if (input.hasMotion && !input.touchManeuvers && engine && typeof engine._web_set_touch_maneuvers === 'function' && !crashed) {\n        try { engine._web_set_touch_maneuvers(1); input.touchManeuvers = true } catch (e) {}\n      }\n      if (direct && input.pulse) {\n        // Gesture pulses (maneuvers, menu steps) are full-strength stick pushes, +y up.\n        try { engine._web_set_stick(1, input.pulse[0], input.pulse[1]) } catch (e) {}\n        pad.axes[0] = 0\n        pad.axes[1] = 0\n        return\n      }\n      if (direct) {\n        // Star Fox ignores raw stick values within +-16 (of 85) and saturates at 76, per axis\n        // (sys_joybus.c). Map our 0..1 onto exactly that live band, so the smallest tilt past our\n        // own tremor filter already turns the ship and full tilt is full turn.\n        const live = (v) => (v === 0 ? 0 : Math.sign(v) * (16.5 + Math.min(1, Math.abs(v)) * (76 - 16.5)) / 85)\n        try { engine._web_set_stick(useGyro ? 1 : 0, live(input.gyro[0]), live(-input.gyro[1])) } catch (e) {}\n      }\n      const src = input.pulse ? [input.pulse[0], -input.pulse[1]] : input.stickActive ? input.stick : (useGyro && !direct ? input.gyroPad : [0, 0])\n      pad.axes[0] = src[0]\n      pad.axes[1] = src[1]\n    }\n\n    const setButton = (name, down) => {\n      const i = BUTTON[name]\n      if (i == null) return\n      pad.buttons[i] = { pressed: down, touched: down, value: down ? 1 : 0 }\n    }\n\n    // Buttons: multi-touch, each pointer tracked to the button it started on.\n    // The engine samples the pad once per frame, so a quick tap is held for MIN_HOLD_MS —\n    // otherwise down+up can both land between two polls and the press is never seen.\n    const MIN_HOLD_MS = 80\n    const held = new Map()\n    d2.on('.btn', 'pointerdown', (e, el) => {\n      e.preventDefault()\n      el.setPointerCapture?.(e.pointerId)\n      held.set(e.pointerId, { el, at: performance.now() })\n      el.classList.add('on')\n      setButton(el.dataset.b, true)\n      if (navigator.vibrate) navigator.vibrate(8)\n    })\n    const release = (e) => {\n      const h = held.get(e.pointerId)\n      if (!h) return\n      held.delete(e.pointerId)\n      const up = () => {\n        h.el.classList.remove('on')\n        setButton(h.el.dataset.b, false)\n      }\n      const left = MIN_HOLD_MS - (performance.now() - h.at)\n      if (left > 0) setTimeout(up, left)\n      else up()\n    }\n    d2.on(root, 'pointerup', release)\n    d2.on(root, 'pointercancel', release)\n\n    // ---- touch gestures on the game screen ------------------------------------------------\n    // Flying:  swipe up = somersault (pull back + Boost), swipe down = U-turn (pull back + Brake).\n    //          The game only triggers them from that combination inside a few frames, so the\n    //          swipe plays it for ~160 ms. Barrel roll stays on double-tapping Z / R.\n    // Menus:   tap = select (main menu cards are hit-tested by the engine; elsewhere a tap is A,\n    //          and START on the title screen); swipes step the cursor one notch.\n    // The engine says which applies (web_ui_mode); motion steering is off in menus.\n    const uiMode = () => {\n      try { return engine && typeof engine._web_ui_mode === 'function' ? engine._web_ui_mode() : 0 } catch (e) { return 0 }\n    }\n    const pressFor = (name, ms) => {\n      setButton(name, true)\n      setTimeout(() => setButton(name, false), ms)\n    }\n    const pulse = (x, y, ms) => {\n      input.pulse = [x, y]\n      syncAxes()\n      clearTimeout(input.pulseTimer)\n      input.pulseTimer = setTimeout(() => { input.pulse = null; syncAxes() }, ms)\n    }\n    let hintTimer = null\n    const hint = (glyph) => {\n      const h = $('.gesture-hint')\n      if (!h) return\n      h.textContent = glyph\n      h.classList.add('show')\n      clearTimeout(hintTimer)\n      hintTimer = setTimeout(() => h.classList.remove('show'), 350)\n    }\n    const maneuver = (kind, button, glyph) => {\n      // The engine starts the maneuver itself, under the game's own rules (full boost meter;\n      // U-turn only in all-range). No input macro: when it is not possible, nothing happens.\n      if (typeof engine._web_maneuver !== 'function') return\n      let ok = 0\n      try { ok = engine._web_maneuver(kind) } catch (e) { return }\n      if (!ok) return // not possible right now (boost recharging, on rails for a U-turn, ...)\n      hint(glyph)\n      try { navigator.vibrate && navigator.vibrate(15) } catch (e) {}\n    }\n    // Canvas point -> the game's 320x240 2D space (a 4:3 area centred in the canvas).\n    const toGame = (cx, cy) => {\n      const r = canvas.getBoundingClientRect()\n      const w = (r.height * 4) / 3\n      return [((cx - r.left - (r.width - w) / 2) / w) * 320, ((cy - r.top) / r.height) * 240]\n    }\n    const gestures = new Map()\n    const gestureTarget = (t) => !(t && t.closest && t.closest('.btn, .chip, .overlay, .crash, input'))\n    d2.on(root, 'pointerdown', (e) => {\n      if (!engine || crashed || !gestureTarget(e.target)) return\n      gestures.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() })\n    })\n    const endGesture = (e) => {\n      const g = gestures.get(e.pointerId)\n      if (!g) return\n      gestures.delete(e.pointerId)\n      if (e.type === 'pointercancel' || !engine || crashed) return\n      const dx = e.clientX - g.x\n      const dy = e.clientY - g.y\n      const dt = performance.now() - g.t\n      const dist = Math.hypot(dx, dy)\n      const mode = uiMode()\n      if (dist < 14 && dt < 400) {\n        if (mode !== 1) return // taps do nothing while flying\n        const [gx, gy] = toGame(e.clientX, e.clientY)\n        let code = 0\n        try { code = typeof engine._web_tap === 'function' ? engine._web_tap(gx, gy) : 0 } catch (err) { code = 0 }\n        if (code === 0) pressFor('A', 120)\n        else if (code === 2) pressFor('START', 120)\n        return\n      }\n      if (dist < 40 || dt > 600) return\n      const vertical = Math.abs(dy) > 1.3 * Math.abs(dx)\n      const horizontal = Math.abs(dx) > 1.3 * Math.abs(dy)\n      if (mode === 1) {\n        // One cursor step per swipe, as a short full stick push.\n        if (vertical) { pulse(0, dy < 0 ? 1 : -1, 130); hint(dy < 0 ? '▲' : '▼') }\n        else if (horizontal) { pulse(dx > 0 ? 1 : -1, 0, 130); hint(dx > 0 ? '▶' : '◀') }\n        return\n      }\n      if (vertical) {\n        if (dy < 0) maneuver(1, 'CLEFT', '↻') // up: somersault\n        else maneuver(2, 'CDOWN', '↶')        // down: U-turn\n      }\n    }\n    d2.on(root, 'pointerup', endGesture)\n    d2.on(root, 'pointercancel', endGesture)\n\n    // ---- motion steering: 6-axis sensor fusion -------------------------------------------\n    // The gyroscope (rotationRate, deg/s) is the primary signal: fast and clean, so no heavy\n    // smoothing (and no lag). The accelerometer only anchors it: a complementary filter pulls the\n    // gyro-propagated gravity estimate toward the measured one, trusting the measurement less\n    // when its magnitude is far from 1 g (hand jolts, not tilt).\n    //   vertical   = pitch: tipping the top edge toward / away     (absolute, gravity-anchored)\n    //   horizontal = roll (turning the phone like a wheel)         (absolute, gravity-anchored)\n    //              + yaw  (turning it like a tray, about the real-world vertical, whatever the\n    //                      phone's tilt). Yaw has no absolute reference: it holds while you\n    //                      hold the turn, fades back over ~20 s, and Center zeroes it.\n    // Angles are relative to a neutral pose captured at start, on rotation, and by Center.\n    const SENS = [{ label: 'Low', deg: 28 }, { label: 'Med', deg: 20 }, { label: 'High', deg: 14 }]\n    let sensIdx = 1\n    // The Tilt chip owns sensitivity; the instance's gyroRangeDeg is only a hint for the start level.\n    const hinted = Number(d2.param('gyroRangeDeg', 0)) || 0\n    if (hinted) sensIdx = SENS.reduce((best, s, i) => (Math.abs(s.deg - hinted) < Math.abs(SENS[best].deg - hinted) ? i : best), 0)\n    const rollRange = () => SENS[sensIdx].deg        // degrees of wheel-turn for full stick\n    const pitchRange = () => SENS[sensIdx].deg       // degrees of tip for full stick\n    const yawRange = () => SENS[sensIdx].deg * 1.5   // degrees of tray-turn for full stick\n    // Yaw holds while the phone is held turned (like roll), and fades back only slowly so leftover\n    // gyro drift can never build up. (It was 2 s: the ship turned, then drifted back mid-hold.)\n    const YAW_RECENTER_S = 20\n    const YAW_RATE_DEAD = 0.3   // deg/s: ignore residual rate when still\n    const YAW_BIAS_S = 5        // gyro bias learning time constant\n    const YAW_BIAS_GATE = 1.5   // deg/s: only learn bias while (nearly) not turning\n    const DEAD = 0.0075                              // soft deadzone (hand tremor), stick units\n    const LUS_DEAD = 0.21                            // only for the virtual-pad fallback path\n    const G = 9.81\n    const DEG = 180 / Math.PI\n\n    const screenAngle = () => {\n      const a = screen.orientation && typeof screen.orientation.angle === 'number' ? screen.orientation.angle : (window.orientation || 0)\n      return ((a % 360) + 360) % 360\n    }\n    // Device x/y (portrait frame) -> screen x (right) / y (up) for the current rotation; z unchanged.\n    const toScreen = (x, y) => {\n      switch (screenAngle()) {\n        case 90: return [y, -x]\n        case 180: return [-x, -y]\n        case 270: return [-y, x]\n        default: return [x, y]\n      }\n    }\n    const wrapDeg = (d) => ((d + 540) % 360) - 180\n    const clamp1 = (v) => clamp(v, -1, 1)\n    // Soft deadzone + mild curve: tremor vanishes, precision near centre, full range kept.\n    const shapeAxis = (v) => {\n      const a = Math.abs(v)\n      if (a <= DEAD) return 0\n      const n = Math.min(1, (a - DEAD) / (1 - DEAD))\n      return Math.sign(v) * n * (0.85 + 0.15 * n)\n    }\n    // Tiered smoothing: only tiny changes are smoothed; real motions pass straight through.\n    const out = [0, 0]\n    const smoothTiered = (i, v) => {\n      const d = v - out[i]\n      const w = 0.35 + 0.65 * Math.min(1, Math.abs(d) / 0.06)\n      out[i] += w * d\n      return out[i]\n    }\n\n    const fusion = { f: null, t: 0, yaw: 0, yawBias: 0, neutralRef: null }\n    const norm = (v) => { const m = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / m, v[1] / m, v[2] / m] }\n    // A world-fixed vector seen from the device turns opposite to the device: dv = -w x v dt.\n    const rotStep = (v, w, dt) => norm([\n      v[0] - (w[1] * v[2] - w[2] * v[1]) * dt,\n      v[1] - (w[2] * v[0] - w[0] * v[2]) * dt,\n      v[2] - (w[0] * v[1] - w[1] * v[0]) * dt,\n    ])\n\n    // Gyro axis order. The spec says rotationRate alpha/beta/gamma are rates about the device's\n    // z/x/y axes; WebKit (every iPhone browser) reports them about x/y/z. Read the wrong way,\n    // pitching leaks into yaw and the ship moves diagonally. Rather than trust either, keep\n    // checking which mapping (and sign) predicts the accelerometer's view of gravity best and use\n    // that one; the platform only sets the starting guess.\n    const GYRO_MAPS = [\n      (r) => [r.beta, r.gamma, r.alpha],\n      (r) => [r.alpha, r.beta, r.gamma],\n      (r) => [-r.beta, -r.gamma, -r.alpha],\n      (r) => [-r.alpha, -r.beta, -r.gamma],\n    ]\n    const isWebKitMobile = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n    const cal = { score: GYRO_MAPS.map(() => 0), best: isWebKitMobile ? 1 : 0, q: null, n: 0 }\n    const calibrate = (cands, m, dt, mag) => {\n      // Only while the accelerometer reads clean gravity (no jolts).\n      if (!cal.q || Math.abs(mag - G) > 0.08 * G) { cal.q = cands.map(() => m); cal.n = 0; return }\n      cal.q = cal.q.map((q, i) => rotStep(q, cands[i], dt))\n      if (++cal.n < 8) return\n      // Over ~130 ms windows with real rotation (> ~20 deg/s), score each mapping's prediction.\n      if (Math.hypot(cands[0][0], cands[0][1], cands[0][2]) > 0.35) {\n        cal.q.forEach((q, i) => { cal.score[i] = cal.score[i] * 0.97 + (q[0] - m[0]) ** 2 + (q[1] - m[1]) ** 2 + (q[2] - m[2]) ** 2 })\n        let best = cal.best\n        cal.score.forEach((s, i) => { if (s < cal.score[best] * 0.6) best = i })\n        cal.best = best\n      }\n      cal.q = cands.map(() => m)\n      cal.n = 0\n    }\n\n    const publish = (h, v) => {\n      // h: +right, v: +down (gamepad convention). Default pitch: tipping the top edge away climbs\n      // (flight-sim style); invertY flips it.\n      const invertY = !!d2.param('invertY', false)\n      const sx = smoothTiered(0, shapeAxis(clamp1(h)))\n      const sy = smoothTiered(1, shapeAxis(clamp1(v)) * (invertY ? 1 : -1))\n      input.gyro = [sx, sy]\n      const fb = (x) => (x === 0 ? 0 : Math.sign(x) * (LUS_DEAD + (1 - LUS_DEAD) * Math.abs(x)))\n      input.gyroPad = [fb(sx), fb(sy)]\n      syncAxes()\n    }\n\n    let gotMotion = false\n    d2.on(window, 'devicemotion', (e) => {\n      const a = e.accelerationIncludingGravity\n      if (!a || a.x == null || a.y == null || a.z == null) return\n      gotMotion = true\n      input.hasMotion = true\n      const now = performance.now()\n      const dt = fusion.t ? Math.min(0.1, Math.max(0.001, (now - fusion.t) / 1000)) : (e.interval > 1 ? e.interval / 1000 : (e.interval || 0.016))\n      fusion.t = now\n\n      // Everything in the screen frame: x right, y up, z out of the screen.\n      const [ax, ay] = toScreen(a.x, a.y)\n      const meas = [ax, ay, a.z]\n      const mag = Math.hypot(meas[0], meas[1], meas[2])\n      const r = e.rotationRate\n      const haveGyro = !!(r && r.alpha != null && r.beta != null && r.gamma != null)\n      let w = [0, 0, 0]\n      if (haveGyro) {\n        // Each candidate mapping as a device-frame rate vector, then into the screen frame (rad/s).\n        const cands = GYRO_MAPS.map((fn) => {\n          const d = fn(r)\n          const [sx, sy] = toScreen(d[0], d[1])\n          return [sx / DEG, sy / DEG, d[2] / DEG]\n        })\n        calibrate(cands, norm(meas), dt, mag)\n        w = cands[cal.best]\n      }\n\n      if (!fusion.f || !haveGyro) {\n        fusion.f = norm(meas)\n      } else {\n        const pred = rotStep(fusion.f, w, dt)\n        // Trust the accelerometer only when it reads ~1 g AND the phone is not rotating: turning the\n        // phone swings it on an arc, and that sideways push read as a tilt (a phantom roll).\n        const spin = Math.hypot(w[0], w[1], w[2]) * DEG\n        const trust = Math.exp(-Math.pow((mag - G) / (0.15 * G), 2)) * Math.exp(-Math.pow(spin / 45, 2))\n        const k = Math.min(1, 0.5 * dt) * trust                     // ~2 s time constant at rest\n        const m = norm(meas)\n        fusion.f = norm([pred[0] + k * (m[0] - pred[0]), pred[1] + k * (m[1] - pred[1]), pred[2] + k * (m[2] - pred[2])])\n      }\n      const f = fusion.f\n\n      // Absolute angles from the fused gravity (either sign convention works: we use deltas).\n      const rollDeg = Math.atan2(f[0], -f[1]) * DEG\n      const pitchDeg = Math.atan2(f[2], -f[1]) * DEG\n      input.lastOri = [rollDeg, pitchDeg]\n      if (!input.neutral) input.neutral = [rollDeg, pitchDeg]\n      if (fusion.neutralRef !== input.neutral) { fusion.neutralRef = input.neutral; fusion.yaw = 0 }\n\n      // Yaw about the real-world vertical: project the rotation rate onto 'up'. 'Up' is the fused\n      // gravity axis, signed so it points toward the top of the screen / out of a tipped-back screen,\n      // which is independent of each browser's accelerometer sign convention.\n      if (haveGyro) {\n        const s = (f[1] + f[2]) >= 0 ? 1 : -1\n        const yawRate = (w[0] * f[0] + w[1] * f[1] + w[2] * f[2]) * s * DEG // deg/s, + = turning left\n        // Sign confirmed on a real phone (twice): this way, turning the phone right steers right.\n        // (The desktop motion simulator suggests the opposite; trust the device.)\n        let yr = yawRate\n        if (Math.abs(yr - fusion.yawBias) < YAW_BIAS_GATE) fusion.yawBias += (yr - fusion.yawBias) * Math.min(1, dt / YAW_BIAS_S)\n        yr -= fusion.yawBias\n        if (Math.abs(yr) < YAW_RATE_DEAD) yr = 0\n        fusion.yaw = (fusion.yaw + yr * dt) * Math.exp(-dt / YAW_RECENTER_S)\n      }\n\n      const roll = wrapDeg(rollDeg - input.neutral[0])\n      const pitch = wrapDeg(pitchDeg - input.neutral[1])\n      publish(roll / rollRange() + fusion.yaw / yawRange(), pitch / pitchRange())\n    })\n    // Fallback for browsers that only expose orientation angles (no gyro rate, no yaw).\n    d2.on(window, 'deviceorientation', (e) => {\n      if (gotMotion || e.beta == null || e.gamma == null) return\n      input.hasMotion = true\n      const [x, y] = toScreen(e.gamma, -e.beta)\n      input.lastOri = [x, -y]\n      if (!input.neutral) input.neutral = [x, -y]\n      publish(wrapDeg(x - input.neutral[0]) / rollRange(), wrapDeg(-y - input.neutral[1]) / pitchRange())\n    })\n    d2.on(window, 'orientationchange', () => { input.neutral = null; fusion.f = null; cal.q = null })\n\n    // Press feedback for the chips, like the game buttons: a flash, a tick of vibration where the\n    // browser supports it, and a short message for actions with no other visible effect.\n    let toastTimer = null\n    const toast = (msg) => {\n      const t = $('.toast')\n      if (!t) return\n      t.textContent = msg\n      t.classList.add('show')\n      clearTimeout(toastTimer)\n      toastTimer = setTimeout(() => t.classList.remove('show'), 1100)\n    }\n    d2.on('.chip', 'pointerdown', (e, el) => {\n      el.classList.add('flash')\n      setTimeout(() => el.classList.remove('flash'), 160)\n      try { navigator.vibrate && navigator.vibrate(10) } catch (err) {}\n    })\n\n    d2.on('.chip', 'click', (e, el) => {\n      const act = el.dataset.act\n      if (act === 'gyro') {\n        input.gyroOn = !input.gyroOn\n        el.classList.toggle('on', input.gyroOn)\n        syncAxes()\n      } else if (act === 'sens') {\n        sensIdx = (sensIdx + 1) % SENS.length\n        el.textContent = `Tilt: ${SENS[sensIdx].label}`\n        toast(`Tilt sensitivity: ${SENS[sensIdx].label}`)\n      } else if (act === 'center') {\n        input.neutral = input.lastOri ? [...input.lastOri] : null\n        toast(input.hasMotion ? 'Centered' : 'No motion sensor')\n      } else if (act === 'hud') {\n        const hidden = root.querySelectorAll('.btn, .stickzone')\n        const now = el.textContent === 'Hide'\n        hidden.forEach((n) => { n.style.visibility = now ? 'hidden' : '' })\n        el.textContent = now ? 'Show' : 'Hide'\n      }\n    })\n\n    // ---- engine boot ---------------------------------------------------------------------\n    let blobs = []\n    let started = false\n    let files = null\n    let engine = null\n    let crashed = false\n\n    // Last engine log lines, shown on the crash screen so a failure can be reported.\n    const logTail = []\n    const remember = (line) => { logTail.push(String(line).slice(0, 300)); if (logTail.length > 40) logTail.shift() }\n\n    // SDL's web audio lives on Module.SDL2.audioContext. When the engine dies, its audio callback\n    // keeps replaying the last buffer (the stuck shooting sound), so close the context outright.\n    const audioContext = () => (engine && engine.SDL2 && engine.SDL2.audioContext) || null\n    const stopAudio = () => { try { const ac = audioContext(); if (ac && ac.state !== 'closed') ac.close() } catch (e) {} }\n    const resumeAudio = () => {\n      if (crashed) return\n      try { const ac = audioContext() || unlockedAudio; if (ac && ac.state !== 'running' && ac.state !== 'closed') ac.resume() } catch (e) {}\n    }\n    // Created and started inside the Start tap, then given to the engine.\n    let unlockedAudio = null\n    const unlockAudio = () => {\n      // Play through the iPhone silent switch, like a game or video app (Safari 16.4+).\n      try { if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {}\n      try {\n        const Ctx = window.AudioContext || window.webkitAudioContext\n        if (!Ctx || unlockedAudio) return\n        // The game synthesises at 32 kHz: running the context at that rate lets the browser resample\n        // natively instead of SDL doing it in WebAssembly every audio callback.\n        try { unlockedAudio = new Ctx({ sampleRate: 32000 }) } catch (e) { unlockedAudio = new Ctx() }\n        unlockedAudio.resume()\n        // A one-sample silent buffer started within the gesture fully unlocks older iOS.\n        const src = unlockedAudio.createBufferSource()\n        src.buffer = unlockedAudio.createBuffer(1, 1, 22050)\n        src.connect(unlockedAudio.destination)\n        src.start(0)\n      } catch (e) { unlockedAudio = null }\n    }\n\n    const crash = (why, stack) => {\n      if (crashed) return\n      crashed = true\n      stopAudio()\n      try { engine && engine.pauseMainLoop && engine.pauseMainLoop() } catch (e) {}\n      const box = $('.crash')\n      box.querySelector('.crash-why').textContent = String(why || 'Unknown error').slice(0, 300)\n      // Stack first (the engine is built with function names), then the engine's last log lines.\n      const frames = String(stack || '').split('\\n').map((l) => l.trim()).filter(Boolean).slice(0, 14)\n      box.querySelector('.crash-log').textContent = [...frames, frames.length ? '----' : '', ...logTail.slice(-15)].filter(Boolean).join('\\n')\n      box.hidden = false\n      console.error('[TFSF DEV] crashed:', why)\n    }\n    d2.on('.restart', 'click', () => location.reload())\n\n    // Only engine failures count: the host page can raise its own unrelated errors.\n    const isEngineError = (err, filename) =>\n      String(filename || '').startsWith('blob:') ||\n      (err && (err instanceof WebAssembly.RuntimeError || /Aborted|RuntimeError|wasm|unreachable|memory access out of bounds/i.test(String(err.message || err))))\n    d2.on(window, 'error', (e) => {\n      if (!engine || !isEngineError(e.error || e.message, e.filename)) return\n      // Name the source too: the engine and this component can both be blob: scripts.\n      const where = `at ${String(e.filename || '?').slice(-60)}:${e.lineno || 0}:${e.colno || 0}`\n      crash(e.message || e.error, [where, (e.error && e.error.stack) || ''].join('\\n'))\n    })\n    d2.on(window, 'unhandledrejection', (e) => { if (engine && isEngineError(e.reason)) crash(e.reason && (e.reason.message || e.reason), e.reason && e.reason.stack) })\n    d2.on(canvas, 'webglcontextlost', (e) => {\n      e.preventDefault()\n      crash('The browser dropped the graphics context (usually low memory on the phone).')\n    })\n    // Hidden tab: silence it; closing/leaving: shut audio down so nothing outlives the page.\n    d2.on(document, 'visibilitychange', () => {\n      const ac = audioContext()\n      if (!ac || crashed) return\n      try { document.hidden ? ac.suspend() : ac.resume() } catch (e) {}\n    })\n    // Leaving for good closes the audio; a page kept in memory (home-screen app switched away, back/\n    // forward cache) only suspends it, so it can play again when the player returns.\n    d2.on(window, 'pagehide', (e) => {\n      if (e && e.persisted) { try { const ac = audioContext(); if (ac && ac.state === 'running') ac.suspend() } catch (err) {} }\n      else stopAudio()\n    })\n    d2.on(window, 'pageshow', () => resumeAudio())\n    // Any tap is a chance to (re)start audio iOS suspended or interrupted (calls, Siri, other apps).\n    d2.on(root, 'pointerup', () => resumeAudio())\n    d2.on(root, 'pointerdown', () => resumeAudio())\n    d2.onUnmount(stopAudio)\n\n    const showLogin = (msg) => {\n      loginForm.hidden = false\n      goBtn.hidden = true\n      errEl.textContent = msg || ''\n      setStatus('Sign in to play')\n    }\n\n    d2.on(loginForm, 'submit', async (e) => {\n      e.preventDefault()\n      const fd = new FormData(loginForm)\n      const btn = loginForm.querySelector('.signin')\n      btn.disabled = true\n      errEl.textContent = ''\n      try {\n        const auth = d2.auth(d2.param('auth'))\n        if (!auth) throw new Error('Login is not configured')\n        // AuthenticationStrategy.login takes { credentials, metadata? } and merges credentials in.\n        await auth.login({\n          credentials: { email: String(fd.get('email') || '').trim(), password: String(fd.get('password') || '') },\n        })\n        loginForm.hidden = true\n        goBtn.hidden = false\n        progress.total = 0\n        progress.loaded = {}\n        preload()\n      } catch (err) {\n        errEl.textContent = 'Sign-in failed. Check your email and password.'\n      } finally {\n        btn.disabled = false\n      }\n    })\n\n    const preload = async () => {\n      try {\n        if (!window.WebGL2RenderingContext) throw new Error('This browser has no WebGL2.')\n        setStatus('Downloading game files…')\n        errEl.textContent = ''\n        downloadKey = await getDownloadKey()\n        const [js, wasm, main, port] = await Promise.all([\n          fetchBytes('js', refs.js), fetchBytes('wasm', refs.wasm),\n          fetchBytes('main', refs.main), fetchBytes('port', refs.port),\n        ])\n        files = { js, wasm, main, port }\n        setProgress(1)\n        setStatus('Ready. Tap Start (sound and motion need a tap).')\n        goBtn.disabled = false\n      } catch (e) {\n        if (e instanceof Refused) showLogin()\n        else fail(String(e && e.message || e))\n      }\n    }\n\n    const askMotionPermission = async () => {\n      // iOS gates motion behind a prompt that must come from a tap; ask for both event types.\n      for (const Ev of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) {\n        if (Ev && typeof Ev.requestPermission === 'function') {\n          try { await Ev.requestPermission() } catch (e) { /* declined; the touch stick still works */ }\n        }\n      }\n    }\n\n    const enterFullscreen = async () => {\n      const el = d2.root.host || root\n      try { if (el.requestFullscreen && !document.fullscreenElement) await el.requestFullscreen({ navigationUI: 'hide' }) } catch (e) {}\n      try { await screen.orientation?.lock?.('landscape') } catch (e) {}\n      try { await navigator.wakeLock?.request?.('screen') } catch (e) {}\n    }\n\n    // ---- warm-up list learned on this device --------------------------------------------\n    const WARM_KEY = 'tfsf-dev.warm.v1'\n    const learned = new Set()\n    try { (localStorage.getItem(WARM_KEY) || '').split('\\n').filter(Boolean).forEach((l) => learned.add(l)) } catch (e) {}\n    let warmSaveTimer = null\n    let warming = false // items the warm-up itself loads are already listed: don't re-learn them\n    const learnWarm = (line) => {\n      if (warming || learned.has(line) || learned.size > 30000) return\n      learned.add(line)\n      clearTimeout(warmSaveTimer)\n      warmSaveTimer = setTimeout(() => { try { localStorage.setItem(WARM_KEY, [...learned].join('\\n')) } catch (e) {} }, 3000)\n    }\n    const warmUp = (M) => new Promise((resolve) => {\n      if (typeof M._web_warm_step !== 'function') return resolve()\n      const started = performance.now()\n      const done = () => { warming = false; resolve() }\n      warming = true\n      const step = () => {\n        let left = 0\n        try { left = M._web_warm_step(14) } catch (e) { return done() }\n        if (left > 0 && performance.now() - started < 8000) requestAnimationFrame(step)\n        else done()\n      }\n      requestAnimationFrame(step)\n    })\n\n    const boot = async () => {\n      if (started || !files) return\n      started = true\n      goBtn.disabled = true\n      // Must run synchronously inside the Start tap: iOS only lets audio start from a user gesture,\n      // and the engine creates its output several awaits later (activation can be gone by then).\n      unlockAudio()\n      setStatus('Starting engine…')\n      await Promise.all([askMotionPermission(), enterFullscreen()])\n      $('.rotate').classList.add('armed')\n\n      const jsUrl = URL.createObjectURL(new Blob([files.js], { type: 'text/javascript' }))\n      blobs.push(jsUrl)\n      try {\n        // Always load this build's loader: a createStarship left on the page by an earlier mount\n        // (a resumed home-screen app, a remount without reload) belongs to an older engine and\n        // would pair old JS glue with the new wasm, hiding the new exports.\n        try { delete window.createStarship } catch (e) { window.createStarship = undefined }\n        await new Promise((resolve, reject) => {\n          // A private blob, not a CDN library — d2.lib does not apply here.\n          const s = document.createElement('script')\n          s.src = jsUrl\n          s.onload = resolve\n          s.onerror = () => reject(new Error('Engine script failed to load'))\n          document.head.appendChild(s)\n        })\n        const factory = window.createStarship\n        if (typeof factory !== 'function') throw new Error('Engine factory missing')\n\n        const Module = await factory({\n          canvas,\n          noInitialRun: true,\n          wasmBinary: files.wasm,\n          print: (t) => { remember(t); console.log('[starship]', t) },\n          printErr: (t) => { remember(t); console.warn('[starship]', t) },\n          onNewShader: (lo, hi, id1) => { diag.shd++; learnWarm(`S ${lo} ${hi} ${id1}`) },\n          onNewResource: (path) => { diag.res.push(path); learnWarm(`R ${path}`) },\n          onAbort: (what) => crash(`Engine aborted: ${what}`, new Error('abort').stack),\n          preRun: [(M) => {\n            // SDL addresses its canvas as '#canvas'; the canvas lives in a shadow root that\n            // document.querySelector cannot see, so map the selector to the element directly.\n            if (M.specialHTMLTargets) M.specialHTMLTargets['#canvas'] = canvas\n            // Hand SDL the context unlocked by the Start tap (SDL reuses Module.SDL2.audioContext).\n            if (unlockedAudio) { M.SDL2 = M.SDL2 || {}; M.SDL2.audioContext = unlockedAudio }\n            try { M.FS.writeFile('/warmup.txt', [...learned].join('\\n')) } catch (e) {}\n            M.FS.writeFile('/sf64.o2r', files.main)\n            M.FS.writeFile('/starship.o2r', files.port)\n          }],\n        })\n        engine = Module\n        files = null // let the download buffers go; the engine holds its own copies\n        watchFrames()\n        setStatus('Preparing graphics…')\n        // Install before main so SDL's joystick enumeration at init already sees the pad.\n        uninstallPad = installVirtualPad(pad)\n        canvas.focus()\n        try {\n          Module.callMain([])\n        } catch (e) {\n          // emscripten_set_main_loop(..., simulate_infinite_loop=1) unwinds out of main.\n          if (!(e === 'unwind' || (e && e.name === 'ExitStatus'))) throw e\n        }\n        // And announce it again now that SDL's gamepadconnected listener is registered.\n        const ev = new Event('gamepadconnected')\n        ev.gamepad = pad\n        window.dispatchEvent(ev)\n        // Behind the loading screen, compile the shaders and load the resources gameplay will ask\n        // for (the engine's own list plus what this device learned), so first use does not stall.\n        await warmUp(Module)\n        overlay.hidden = true\n        hud.hidden = false\n      } catch (e) {\n        if (e && (e.name === 'ExitStatus' || e === 'unwind')) return\n        overlay.hidden = false\n        fail(String(e && e.message || e))\n      }\n    }\n\n    // Freeze watchdog: a frozen engine raises no error, so the crash screen never came up and the\n    // last audio buffer kept looping. The renderer clears its target once per frame, so stamp that\n    // (one call per frame, not per draw); no frame for a few seconds while visible = frozen.\n    let lastDraw = 0\n    // Hitch recorder (diagnostics for first-use stalls on phones). Per frame it counts what the\n    // engine created and times the WebGL calls and rAF callbacks; a frame over 150 ms while flying\n    // is reported on screen (and kept in localStorage 'tfsf-dev.hitches').\n    const diag = { res: [], shd: 0, n: {}, slow: ['', 0], cpu: 0, last: 0, reports: 0 }\n    const diagReset = () => { diag.res = []; diag.shd = 0; diag.n = {}; diag.slow = ['', 0]; diag.cpu = 0 }\n    const diagBox = () => {\n      let el = root.querySelector('.diag')\n      if (!el) {\n        el = document.createElement('div')\n        el.className = 'diag'\n        el.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:9;max-width:92%;padding:6px 10px;border-radius:8px;background:rgba(0,0,0,0.75);color:#ffd27a;font:600 11px/1.35 ui-monospace,Menlo,monospace;pointer-events:none;white-space:pre-wrap'\n        root.querySelector('.wrap').appendChild(el)\n      }\n      return el\n    }\n    const diagFrame = (now) => {\n      const gap = now - diag.last\n      if (diag.last && gap > 150 && !document.hidden && uiMode() === 0 && diag.reports < 6) {\n        diag.reports++\n        const n = diag.n\n        const parts = [`Hitch ${Math.round(gap)}ms`, `js ${Math.round(diag.cpu)}ms`,\n          `slowest gl: ${diag.slow[0] || '-'} ${Math.round(diag.slow[1])}ms`,\n          `draws ${n.drawArrays || 0}`, `newTex ${n.createTexture || 0}`, `texUpload ${n.texImage2D || 0}`,\n          `shaders ${diag.shd}/${n.linkProgram || 0}`, `readback ${(n.readPixels || 0) + (n.blitFramebuffer || 0)}`,\n          `res ${diag.res.length}${diag.res.length ? ' (' + diag.res.slice(0, 3).map((p) => p.split('/').pop()).join(', ') + ')' : ''}`]\n        const msg = parts.join(' · ')\n        const box = diagBox()\n        box.textContent = msg\n        box.style.display = 'block'\n        clearTimeout(diag.timer)\n        diag.timer = setTimeout(() => { box.style.display = 'none' }, 8000)\n        try {\n          const prev = JSON.parse(localStorage.getItem('tfsf-dev.hitches') || '[]')\n          prev.push({ at: new Date().toISOString(), msg })\n          localStorage.setItem('tfsf-dev.hitches', JSON.stringify(prev.slice(-20)))\n        } catch (e) {}\n      }\n      diag.last = now\n      diagReset()\n    }\n    const watchFrames = () => {\n      const gl = canvas.getContext('webgl2')\n      if (gl) {\n        for (const fn of ['clear']) {\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => { lastDraw = performance.now(); diagFrame(lastDraw); return orig(...a) }\n        }\n        for (const fn of ['drawArrays', 'bufferData', 'texImage2D', 'texSubImage2D', 'createTexture', 'useProgram',\n          'compileShader', 'linkProgram', 'readPixels', 'blitFramebuffer', 'bindFramebuffer', 'uniform1iv']) {\n          if (typeof gl[fn] !== 'function') continue\n          const orig = gl[fn].bind(gl)\n          gl[fn] = (...a) => {\n            const t = performance.now()\n            const r = orig(...a)\n            const d = performance.now() - t\n            diag.n[fn] = (diag.n[fn] || 0) + 1\n            if (d > diag.slow[1]) diag.slow = [fn, d]\n            return r\n          }\n        }\n      }\n      const raf = window.requestAnimationFrame.bind(window)\n      window.requestAnimationFrame = (cb) => raf((ts) => {\n        const t = performance.now()\n        try { return cb(ts) } finally { diag.cpu += performance.now() - t }\n      })\n      d2.onUnmount(() => { window.requestAnimationFrame = raf })\n      lastDraw = performance.now()\n      const id = setInterval(() => {\n        if (crashed || !engine) return clearInterval(id)\n        if (document.hidden) { lastDraw = performance.now(); return }\n        if (performance.now() - lastDraw > 4000) {\n          crash('The game froze (no frames for 4 seconds).', '')\n          clearInterval(id)\n        }\n      }, 1000)\n      d2.onUnmount(() => clearInterval(id))\n    }\n\n    // ---- page takeover (phones) ----------------------------------------------------------\n    // Edge to edge: extend the page under the notch/home-indicator areas (viewport-fit=cover),\n    // paint everything dark, and pin the component over the whole screen. The HUD already keeps\n    // its buttons inside env(safe-area-inset-*). Skipped in the builder so its UI is not covered.\n    // Gestures: iOS turns a second finger into pinch-zoom, double taps into zoom and long presses\n    // into the magnifier; cancel those on everything except the form fields and the chips/buttons\n    // that rely on click.\n    const undo = []\n    const takeOverPage = () => {\n      const inBuilder = !/\\.data2\\.app$/.test(location.hostname) // only the published app takes over the page\n      const setMeta = (name, content) => {\n        let m = document.head.querySelector(`meta[name=\"${name}\"]`)\n        const had = !!m\n        const prev = m && m.getAttribute('content')\n        if (!m) { m = document.createElement('meta'); m.setAttribute('name', name); document.head.appendChild(m) }\n        m.setAttribute('content', content)\n        undo.push(() => { had ? m.setAttribute('content', prev) : m.remove() })\n      }\n      if (!inBuilder) {\n        setMeta('viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')\n        setMeta('apple-mobile-web-app-capable', 'yes')\n        setMeta('mobile-web-app-capable', 'yes')\n        setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')\n        setMeta('theme-color', '#05060a')\n        const styleOf = (el, css) => {\n          const prev = el.getAttribute('style')\n          el.style.cssText += css\n          undo.push(() => { prev == null ? el.removeAttribute('style') : el.setAttribute('style', prev) })\n        }\n        styleOf(document.documentElement, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;')\n        styleOf(document.body, ';background:#05060a;height:100%;overflow:hidden;overscroll-behavior:none;margin:0;')\n        const host = d2.root && d2.root.host\n        if (host) styleOf(host, ';position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;background:#05060a;')\n      }\n      const allowed = (t) => t && t.closest && t.closest('input, .chip, .go, .crash, .overlay')\n      const on = (target, type, fn) => {\n        target.addEventListener(type, fn, { passive: false })\n        undo.push(() => target.removeEventListener(type, fn, { passive: false }))\n      }\n      const block = (e) => { if (!allowed(e.target)) e.preventDefault() }\n      // Pointer events (buttons, stick) still fire when touchstart is cancelled.\n      on(root, 'touchstart', block)\n      on(root, 'touchmove', block)\n      on(root, 'touchend', (e) => { if (!allowed(e.target) && e.touches.length) e.preventDefault() })\n      on(root, 'contextmenu', (e) => e.preventDefault())\n      on(root, 'selectstart', block)\n      on(root, 'dblclick', (e) => e.preventDefault())\n      for (const g of ['gesturestart', 'gesturechange', 'gestureend']) on(document, g, (e) => e.preventDefault())\n    }\n    takeOverPage()\n    d2.onUnmount(() => { while (undo.length) { try { undo.pop()() } catch (e) {} } })\n\n    d2.on('.start-btn', 'click', boot)\n\n    // SDL owns the canvas size: it follows the canvas's CSS box on every window resize.\n\n    d2.onUnmount(() => {\n      uninstallPad?.()\n      blobs.forEach((u) => URL.revokeObjectURL(u))\n    })\n\n    preload()\n  },\n}\n"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"engineJs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineJs"}}}},"engineWasm":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"engineWasm"}}}},"filesBase":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"filesBase"}}}},"gyroRangeDeg":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"gyroRangeDeg"}}}},"invertY":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"invertY"}}}},"keysDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"keysDataset"}}}},"mainO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"mainO2r"}}}},"portO2r":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"portO2r"}}}},"systemDomain":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"systemDomain"}}}}}}}}}},{"_id":"67d5e1e8698690841856d023","component":"data2-core-types@Component","system":"6172b957976026c073ab9058","params":{"component":"JfsOzWC5Bl9CZyvsPoJgN","package":"lucca-playground","definition":{"params":{"portions":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"transform-bricks"}}},"component":"data2-core-types@Reference"}},"component":"data2-core-functions@StringConcat"},"inlineParamsControl":{"component":"data2-core-primitives@Boolean","params":{"content":false}}}},{"_id":"5ed9bed7b9e0891ce913e884","component":"data2-core-types@Component","params":{"package":"data2-core","component":"DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}},{"_id":"5f472ae463c680de4ff296e9","component":"data2-core-types@Component","params":{"package":"data2-core","component":"Data2TableDataSource","extends":"data2-core@DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}}]}},{"reference":"ExternalComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"_id":"5f060d9b1ca96def42f3c40a","displayName":"Builder AuthStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"credentials":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"UsersDataSet"}},"sessionShared":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},"email":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"nR6Q9d7qdaPtZhJ28Q8Dy"},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"yPCJBTKo8q1Mw0B_3Kpwl"},"component":"data2-core-types@ArtboardReference"}}},"session":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"name"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"sessionMetadata":{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},{"params":{"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"active"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.status"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is active user"}]},"component":"data2-core-logical-operators@And","displayName":"is active and has _id"}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $user"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-primitives@String","params":{"content":"@data2.app"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.email"}}}}},"component":"data2-core-functions@Includes","displayName":"isData2User"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}},"component":"data2-core-flow@Step"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.d2_app"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"management"}}]},"component":"data2-core-functions@Includes","displayName":"isData2App Exception"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}},"component":"data2-core-flow@Step"},{"params":{"resultName":{"component":"data2-core-primitives@String","params":{"content":"has_access"}},"action":{"params":{"compare":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}}}]},"component":"data2-core-logical-operators@Or"},"else":{"params":{"actions":[{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $metadata.system"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":"filter","args":[{"_id":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records.length"}}}}},"component":"data2-core-logical-operators@Not","displayName":"systems not found"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check system found"},{"params":{"actions":[{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"organization":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"cursor.current.organization._id"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"}]},"component":"data2-core-flow@Parallel"}]},"component":"data2-core-flow@Series","displayName":"common setup"},{"params":{"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"else":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"compare":{"params":{"components":{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"mapper":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"system"}}],"body":{"params":{"expression":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.visibility"}}}},"defaultCase":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"cases":[{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"public"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"billing-owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}},{"component":"data2-core-primitives@String","params":{"content":"member"}}]},"component":"data2-core-functions@Includes","displayName":"has role with access"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"collaborator"}}},"component":"data2-core-comparison-operators@Equal","displayName":"is collaborator"}]},"component":"data2-core-logical-operators@And","displayName":"is org member and collaborator"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is collaborator"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is collaborator and has system access"}]},"component":"data2-core-logical-operators@Or"}}},{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"private"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}}]},"component":"data2-core-functions@Includes","displayName":"is owner/admin"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is owner/admin"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is member and has system access"}]},"component":"data2-core-logical-operators@Or","displayName":"is owner/admin or has system access"}}}]},"component":"data2-core-flow@Switch"}}}},"component":"data2-core-functions@ArrayMap"}},"component":"data2-core-logical-operators@And","displayName":"has org member for all systems in request"}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"}},"component":"data2-core-flow@If"}},"component":"data2-core-flow@Step"},{"params":{"then":{"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"else":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"},"compare":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"has_access"}}}}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"sessionExpiresIn":{"component":"data2-core-primitives@Number","params":{"content":604800}},"sessionRenewal":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"identityProviders":[{"provider":{"component":"data2-core-primitives@String","params":{"content":"GOOGLE"}},"clientId":{"component":"data2-core-primitives@String","params":{"content":"55470861603-22pvfc1t49lp8cjnbfk71s5ct9hp1q1v.apps.googleusercontent.com"}}}]},"reference":"Data2BuilderAuthentication","updatedAt":"2026-08-06T19:31:23.557Z"},{"_id":"5f060ee51ca96def42f3fbd1","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"UsersTableDataSource"}},"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}}},"reference":"UsersDataSet","updatedAt":"2026-03-04T21:16:40.291Z"},{"_id":"68f938cf5fb002e33752a43d","displayName":"InternalApplicationsAuthenticationStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}}},"verifier":{"disabled":false,"component":"data2-core-types@DataSetReference","params":{"reference":"InternalApplicationsVerifierDataSet"}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"secret"}},"credentials":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}],"session":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}]},"reference":"InternalApplicationsAuthenticationStrategy","updatedAt":"2025-10-22T20:08:54.442Z","createdAt":"2025-10-22T20:04:31.198Z"},{"component":"data2-core@DataSet","reference":"SessionsDataSet","params":{"requiresConfirmation":false,"pk":"key","datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"data2sessionsdatasource"}}}},{"reference":"UsersTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@ServerSessionsDataSource","reference":"data2sessionsdatasource","params":{}},{"_id":"5f1600b70528b92601890f75","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ServiceComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ServiceComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:20.208Z","createdAt":"2020-07-20T20:38:15.436Z"},{"_id":"5f1602190528b92601890f7d","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":40000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentInstancesTableDataSource"}},"sort":{"_id":1},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:07.089Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"5f16017f0528b92601890f7a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"FileComponentInstancesDataSet","updatedAt":"2023-08-03T16:08:18.193Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"5f1601990528b92601890f7b","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ApplicationsTableDataSource"}},"pk":"_id","autoEdit":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"params":{"column":"_id"},"component":"data2-core-types@DataSetColumnReference"}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"ApplicationsDataSet","updatedAt":"2022-12-13T21:51:13.995Z","createdAt":"2020-07-20T20:42:01.657Z"},{"_id":"5f16172a0528b92601890f85","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":10000}}},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"DataComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"DataComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:12.831Z","createdAt":"2020-07-20T22:14:02.097Z"},{"_id":"60ddc1bbda3861ca65407c5a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"pagination":{"size":1},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"}},"reference":"OneFileComponentInstancesDataSet","updatedAt":"2022-02-09T19:44:38.036Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"63ee3d38b8c9838c62fb05fb","displayName":"DriveFileComponentInstancesDataSet","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core@Drive"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"DriveFileComponentInstancesDataSet","updatedAt":"2025-12-04T21:58:32.367Z","createdAt":"2023-02-16T14:27:07.797Z"},{"reference":"ServiceComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"69483e4605714c8c39688670","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"1","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"x":96,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"1"},{"_id":"69483e4605714c8c3968866d","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"2","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.8199999999999998,"exactY":1.63,"x":2,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":1,"reference":"2"},{"_id":"69483e4605714c8c3968866e","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"3","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.96,"exactY":96.42,"x":2,"y":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"3"},{"_id":"69483e4605714c8c3968866f","parent":"69483e4605714c8c3968866c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"4","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.75,"exactY":189.75,"x":2,"y":190}},"text":"","opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":3,"reference":"4"},{"_id":"69483e4605714c8c3968865f","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"5","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.75,"exactY":189.75,"x":2,"y":190}},"text":"","opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":0,"reference":"5"},{"_id":"69483e4605714c8c3968865c","parent":"69483e4605714c8c3968865b","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"x","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":282,"height":282,"exactWidth":282,"exactHeight":282}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotatex(90deg) translatey(141px)  translatez(-141px)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}},"transform-style":{"component":"data2-core-primitives@String","params":{"content":"preserve-3d"}}},"childrenOverflow":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"opacity":{"disabled":true,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"x"},{"_id":"68e47144c6e2258edb0bd7c4","parent":"68e4710ac6e2258edb0bd7bb","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"18"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":343,"height":511}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0}},"style":{"transform":{"params":{"portions":[{"params":{"transform-bricks":[{"type":"perspective","value":{"component":"data2-core-primitives@Number","params":{"content":1800}}},{"type":"rotateX","value":{"component":"data2-core-primitives@Number","params":{"content":0}}},{"type":"rotateY","value":{"component":"data2-core-primitives@Number","params":{"content":0}}},{"type":"rotateZ","value":{"component":"data2-core-primitives@Number","params":{"content":0}}}]},"component":"lucca-playground@JfsOzWC5Bl9CZyvsPoJgN"},{"component":"data2-core-primitives@String","params":{"content":" scale(1)"}}]},"component":"data2-core-functions@StringConcat"},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","easing":"cubic-bezier(0.42105263157894735,0.982375,0.9407894736842105,1.070375)","duration":"200"}},{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"360","easing":"cubic-bezier(0.13815789473684212,1.074375,0.40789473684210525,0.882375)"}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"backdropFilter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"brightness","unit":"%","value":40}}],"shadow":[{"component":"data2-core-types@Shadow","params":{"type":"inset","spread":"1","color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,0,0,1)"}}}}],"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,0,0,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}}},"reference":"main"},{"_id":"6ac6b4c778f3730f24d06cc4","component":"data2-core-primitives@Artboard","system":"6172b957976026c073ab9058","displayName":"TFSF","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"TFSF"}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":844,"height":390}},"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0}}},"reference":"IioBhzdrSfRNXfs734BCE"},{"_id":"69483e4605714c8c3968865e","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"6","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.96,"exactY":96.42,"x":2,"y":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":1,"reference":"6"},{"_id":"69483e4605714c8c3968865d","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"7","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":1.8199999999999998,"exactY":1.63,"x":2,"y":2}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":2,"reference":"7"},{"_id":"69483e4605714c8c39688660","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"8","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":96}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":3,"reference":"8"},{"_id":"69483e4605714c8c39688661","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"9","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(103,83,209,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"height":90,"exactHeight":90,"exactWidth":90,"width":90}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"exactX":false,"exactY":false,"y":2,"x":190}},"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":100}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"index":4,"reference":"9"},{"_id":"69483e4605714c8c39688662","parent":"69483e4605714c8c3968865c","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","displayName":"shadow4","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"99999"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(205,168,255,0.84)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":185,"height":159,"exactWidth":184.99,"exactHeight":158.98}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":156,"y":-24,"exactX":155.82,"exactY":-23.78}},"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":77}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"100","delay":"0","easing":"cubic-bezier(0.7368421052631579,0.21437499999999998,0.7828947368421053,0.262375)"}}]},"reference":"shadow4"},{"_id":"68e5c3efdbc11be40cc12a54","parent":"68e47144c6e2258edb0bd7c4","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"},"disabled":true},"gradient":{"disabled":false,"component":"data2-core-types@ColorGradient","params":{"colors":[{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"offset":"0%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(207,207,207,1)"}},"offset":"18%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(154,154,154,0.81)"}},"offset":"36%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(95,95,95,0.83)"}},"offset":"56.00000000000001%"}},{"component":"data2-core-types@ColorGradientStop","params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,0,0,0.66)"}},"offset":"100%"}}],"angle":90}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":126,"height":559,"exactHeight":558.77,"exactWidth":126.2}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-25,"y":-131,"exactX":-24.569999999999997,"exactY":-130.71}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotate(45deg) scale(0,2)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"350","easing":"cubic-bezier(0,1.022375,0.32894736842105265,0.934375)","delay":"0"}},{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","duration":"350","easing":"cubic-bezier(0.7105263157894737,0.01837500000000003,0.8618421052631579,0.354375)","delay":"0"}}],"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":20}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}}},"index":0,"reference":"reflect2"},{"_id":"68e47144c6e2258edb0bd7c5","parent":"68e47144c6e2258edb0bd7c4","component":"data2-core-primitives@Block","system":"6172b957976026c073ab9058","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":126,"height":559,"exactHeight":558.77,"exactWidth":126.2}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":-25,"y":-131,"exactX":-24.569999999999997,"exactY":-130.71}},"style":{"transform":{"component":"data2-core-primitives@String","params":{"content":"rotate(45deg) scale(0,1)"}},"transform-origin":{"component":"data2-core-primitives@String","params":{"content":"center center"}}},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"TRANSFORM","duration":"350","easing":"cubic-bezier(0,1.022375,0.32894736842105265,0.934375)","delay":"0"}},{"component":"data2-core-types@Transition","params":{"scope":"OPACITY","duration":"350","easing":"cubic-bezier(0.7105263157894737,0.01837500000000003,0.8618421052631579,0.354375)","delay":"0"}}],"filter":[{"component":"data2-core-types@BitmapFilter","params":{"type":"blur","unit":"px","value":20}}],"opacity":{"disabled":false,"component":"data2-core-types@Opacity","params":{"value":0}}},"index":1,"reference":"reflect"},{"_id":"9e3516032e1c3d8c76a19fa3","parent":"6ac6b4c778f3730f24d06cc4","component":"lucca-playground@TFSF_Player","system":"6172b957976026c073ab9058","displayName":"TFSF Player","params":{"filesBase":{"component":"data2-core-primitives@String","params":{"content":"https://lucca-playground.data2apis.com"}},"engineJs":{"component":"data2-core-primitives@String","params":{"content":"MGL8ymomL1--bCEvqsgwQ"}},"engineWasm":{"component":"data2-core-primitives@String","params":{"content":"OblomxNGYsSmoVe7VjL6D"}},"mainO2r":{"component":"data2-core-primitives@String","params":{"content":"aYbtK4_tVFa5WkDK5gWWT"}},"portO2r":{"component":"data2-core-primitives@String","params":{"content":"CPQn52M0zAn-otpyT4zjZ"}},"gyroRangeDeg":{"component":"data2-core-primitives@Number","params":{"content":0}},"invertY":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"pin":"stretch","auth":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"tfsfAuth"}},"systemDomain":{"component":"data2-core-primitives@String","params":{"content":"lucca-playground"}},"keysDataset":{"component":"data2-core-primitives@String","params":{"content":"tfsfkeysDataSet"}},"super":{"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0,"freeTransform":true,"left":true,"right":true,"top":true,"bottom":true}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":844,"height":390}}}},"reference":"CdSeRyMHtRsMs8ibWx634"}]}},{"_id":"5fc4faeec8da12be8bf3d08a","component":"data2-core@Data2TableDataSource","system":"5e3b796d6228227ca637d0f4","params":{"paginate":{"default":1000},"super":{},"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"fileId"}},"title":"File ID","component":"data2-core-primitives@ObjectId"},"break":true}],"hooks":{"before":{"all":[{"hook":"softDelete"}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"timestamp","options":{"fields":["createdAt","updatedAt"]}}],"update":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}],"patch":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}]},"after":{"all":[{"hook":"fileUrl","options":{"downloadField":"params.fileUrl","uploadField":"params.uploadFileUrl","enhanceSchema":false}}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"update":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"patch":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"remove":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}]}},"multi":["create"],"useEstimatedDocumentCount":true},"reference":"FileComponentInstancesTableDataSource","updatedAt":"2022-12-19T20:42:51.837Z","createdAt":"2020-11-30T13:59:52.294Z"},{"reference":"ApplicationsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6ac6ba8c5da28ca8d58c4ef4","displayName":"TFSF","reference":"FhY_NU1ZkWc_zlMdKgDlW","params":{"domain":{"component":"data2-core-primitives@String","params":{"content":"tfsf"}},"entryPoint":{"component":"data2-core-types@ComponentInstanceReference","params":{"component":"data2-core-primitives@Artboard","reference":"IioBhzdrSfRNXfs734BCE"}}},"system":"6172b957976026c073ab9058","component":"data2-core-primitives@Application"}]}},{"reference":"DataComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a73eb729b80399cc1ea52fb","component":"data2-core@Store","system":"6172b957976026c073ab9058","params":{"initialData":{"selectedLevelId":null}},"reference":"gameStore"},{"_id":"6ac000c7c1d9b5fa79c91a25","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"datafox2keys"}}},"reference":"datafox2keysDataSet"},{"_id":"6ac6b3973cd42e411b477e29","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"tfsfkeys"}}},"reference":"tfsfkeysDataSet"},{"_id":"6ac000c7c5d15a59091cc155","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"datafox2keys","schema":[{"component":"data2-core-types@Field","params":{"key":"key","title":"Key","component":"data2-core-primitives@String","required":true}}]},"reference":"datafox2keys"},{"_id":"6ac6b3979cddc83ab29f2cd9","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"tfsfkeys","schema":[{"component":"data2-core-types@Field","params":{"key":"key","title":"Key","component":"data2-core-primitives@String","required":true}}]},"reference":"tfsfkeys"},{"_id":"6ac6b3caf121324ec02776db","component":"data2-core@AuthenticationStrategy","system":"6172b957976026c073ab9058","params":{"credentials":[{"key":"email"},{"key":"password"}],"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"tfsfusersDataSet"}},"email":"email","password":"password"},"reference":"tfsfAuth"},{"_id":"6ac6b39478f3730f24d069ab","component":"data2-core@DataSet","system":"6172b957976026c073ab9058","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"tfsfusers"}}},"reference":"tfsfusersDataSet"},{"_id":"6ac6b394862cb6202e1c93b3","component":"data2-core@Data2TableDataSource","system":"6172b957976026c073ab9058","params":{"table":"tfsfusers","schema":[{"component":"data2-core-types@Field","params":{"key":"email","title":"Email","component":"data2-core-primitives@String","required":true}},{"component":"data2-core-types@Field","params":{"key":"password","title":"Password","component":"data2-core-types@Password","required":true}}]},"reference":"tfsfusers"}]}},{"reference":"BundlesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}}],"processesParams":{}}}}