lecodes-web-runtime 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/dist/creator-2d.js +2 -0
- package/native/dist/creator-2d.wasm +0 -0
- package/native/dist/creator-full.js +2 -0
- package/native/dist/creator-full.js.symbols +8124 -0
- package/native/dist/creator-full.wasm +0 -0
- package/native/dist/creator-headless.js +0 -0
- package/native/dist/creator-nav.js +14 -0
- package/native/dist/creator-ui.js +2 -0
- package/native/dist/creator-ui.wasm +0 -0
- package/native/exports/2d.json +119 -0
- package/native/exports/gl.json +166 -0
- package/native/exports/nav.json +3 -0
- package/native/exports/runtime.json +58 -0
- package/native/exports/ui.json +60 -0
- package/native/gen/2d.exports.json +109 -0
- package/native/gen/gl.exports.json +272 -0
- package/native/gen/nav.exports.json +24 -0
- package/package.json +30 -0
- package/src/assets/materials-world.bin +0 -0
- package/src/assets/materials.bin +0 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/assets/uberarchive.bin +0 -0
- package/src/gen/host.gen.ts +199 -0
- package/src/gen/materialGroups.gen.ts +12 -0
- package/src/gen/wasm.ts +156 -0
- package/src/jsb.ts +494 -0
- package/src/native.d.ts +23 -0
- package/src/profile.ts +50 -0
- package/src/validateGlb.ts +262 -0
- package/src/vite-env.d.ts +1 -0
- package/src/wasmCrashSymbolizer.ts +133 -0
package/src/profile.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/// <reference path="./native.d.ts" />
|
|
2
|
+
// The BROWSER profiles of runtime/web/native/CMakeLists.txt: one Emscripten module per engine set, one
|
|
3
|
+
// heap for every engine in it. A web host loads the profile its bundle needs — the imports below are
|
|
4
|
+
// static strings so a bundler splits each profile into its own chunk and a page that never opens a
|
|
5
|
+
// 3D scene never downloads Filament.
|
|
6
|
+
import type { WasmModule } from "./gen/wasm"
|
|
7
|
+
|
|
8
|
+
// The other two are not a page's: `headless` is Bun's (runtime/headless loads it itself), `nav` an
|
|
9
|
+
// engine-only twin — named here, a bundler would put both into every browser build.
|
|
10
|
+
export type Profile = "full" | "2d" | "ui"
|
|
11
|
+
|
|
12
|
+
export type ProfileOptions = {
|
|
13
|
+
/** creator-gl's WebGL2 context + the attributes it was created with (init() registers it). */
|
|
14
|
+
glContext?: WebGL2RenderingContext
|
|
15
|
+
glOptions?: WebGLContextAttributes
|
|
16
|
+
/** creator-2d's own WebGL2 context (c2dInit registers it) — a second canvas in dual mode. */
|
|
17
|
+
gl2dContext?: WebGL2RenderingContext
|
|
18
|
+
gl2dOptions?: WebGLContextAttributes
|
|
19
|
+
/** Extra Emscripten module arguments (print, printErr, …). */
|
|
20
|
+
moduleArgs?: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const factories: Record<Profile, () => Promise<{ default: (args?: Record<string, unknown>) => Promise<any> }>> = {
|
|
24
|
+
full: () => import("../native/dist/creator-full.js"),
|
|
25
|
+
"2d": () => import("../native/dist/creator-2d.js"),
|
|
26
|
+
ui: () => import("../native/dist/creator-ui.js"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// the .wasm beside each module
|
|
30
|
+
const wasmUrl: Record<Profile, () => string> = {
|
|
31
|
+
full: () => new URL("../native/dist/creator-full.wasm", import.meta.url).href,
|
|
32
|
+
"2d": () => new URL("../native/dist/creator-2d.wasm", import.meta.url).href,
|
|
33
|
+
ui: () => new URL("../native/dist/creator-ui.wasm", import.meta.url).href,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Instantiate a profile module. The engines' WebGL contexts ride in as module fields (the engines
|
|
37
|
+
* read `Module.filament_glContext` / `Module.creator2d_glContext` when they init). */
|
|
38
|
+
export const loadProfileModule = async (profile: Profile, options: ProfileOptions = {}): Promise<WasmModule> => {
|
|
39
|
+
const factory = (await factories[profile]()).default
|
|
40
|
+
const url = wasmUrl[profile]()
|
|
41
|
+
const args: Record<string, unknown> = {
|
|
42
|
+
locateFile: (file: string) => (file.endsWith(".wasm") ? url : file),
|
|
43
|
+
...(options.glContext ? { filament_glContext: options.glContext, filament_glOptions: options.glOptions ?? {} } : {}),
|
|
44
|
+
...(options.gl2dContext ? { creator2d_glContext: options.gl2dContext, creator2d_glOptions: options.gl2dOptions ?? {} } : {}),
|
|
45
|
+
...options.moduleArgs,
|
|
46
|
+
}
|
|
47
|
+
const module = (await factory(args)) as WasmModule
|
|
48
|
+
if (typeof module._creatorWebVersion !== "function") throw new Error(`creator-web: ${profile} is not a creator-web profile module`)
|
|
49
|
+
return module
|
|
50
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the creator-gl wasm is shipped WITHOUT emscripten assertions, so when a
|
|
4
|
+
// model violates a filament hard-limit the C++ code calls abort() with no usable message
|
|
5
|
+
// (e.g. "bone count > 256", or a missing texture-decoder for webp). Validate the bytes here,
|
|
6
|
+
// BEFORE handing them to module._loadGlb(), and reject with a clear, actionable error instead.
|
|
7
|
+
//
|
|
8
|
+
// Mirrors the exact constraints filament/gltfio enforce at load time:
|
|
9
|
+
// - filament CONFIG_MAX_BONE_COUNT = 256 (skin.joints per renderable)
|
|
10
|
+
// - filament CONFIG_MAX_MORPH_TARGET_COUNT = 256 (morph targets per primitive)
|
|
11
|
+
// - gltfio texture providers registered in glb.cpp: image/png, image/jpeg, image/ktx2 only
|
|
12
|
+
// (webp / EXT_texture_webp has no provider -> load fails).
|
|
13
|
+
|
|
14
|
+
// ---- filament limits (keep in sync with EngineEnums.h) ----------------------------------------
|
|
15
|
+
export const MAX_BONE_COUNT = 256
|
|
16
|
+
export const MAX_MORPH_TARGET_COUNT = 256
|
|
17
|
+
|
|
18
|
+
// Texture formats with a provider registered in creator-gl (see engines/gl/src/glb.cpp).
|
|
19
|
+
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
|
|
20
|
+
export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
|
|
21
|
+
|
|
22
|
+
export interface GlbIssue {
|
|
23
|
+
level: 'error' | 'warning'
|
|
24
|
+
code: 'CONTAINER' | 'BONE_COUNT' | 'MORPH_COUNT' | 'TEXTURE_FORMAT'
|
|
25
|
+
message: string
|
|
26
|
+
detail?: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GlbValidationResult {
|
|
30
|
+
ok: boolean
|
|
31
|
+
errors: GlbIssue[]
|
|
32
|
+
warnings: GlbIssue[]
|
|
33
|
+
summary: {
|
|
34
|
+
skins: number
|
|
35
|
+
maxJointCount: number
|
|
36
|
+
images: number
|
|
37
|
+
formats: Record<string, number>
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const GLB_MAGIC = 0x46546c67 // "glTF"
|
|
42
|
+
const CHUNK_JSON = 0x4e4f534a // "JSON"
|
|
43
|
+
const CHUNK_BIN = 0x004e4942 // "BIN\0"
|
|
44
|
+
|
|
45
|
+
class GlbParseError extends Error {}
|
|
46
|
+
|
|
47
|
+
export interface ParsedGlb {
|
|
48
|
+
json: any
|
|
49
|
+
bin: Uint8Array | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toBytes(data: ArrayBuffer | Uint8Array): Uint8Array {
|
|
53
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Container parse only (JSON chunk + BIN chunk); also accepts raw .gltf JSON bytes.
|
|
57
|
+
* Exported for reuse (backend asset-metadata extraction) — throws GlbParseError on bad input. */
|
|
58
|
+
export function parseGlb(bytes: Uint8Array): ParsedGlb {
|
|
59
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
60
|
+
|
|
61
|
+
// Accept a raw .gltf (JSON) too — useful for editor/CI paths. No BIN chunk in that case,
|
|
62
|
+
// so byte-sniffing of embedded images is skipped (uri/mimeType still validated).
|
|
63
|
+
if (bytes.byteLength < 12 || dv.getUint32(0, true) !== GLB_MAGIC) {
|
|
64
|
+
try {
|
|
65
|
+
return { json: JSON.parse(new TextDecoder().decode(bytes)), bin: null }
|
|
66
|
+
} catch {
|
|
67
|
+
throw new GlbParseError('Not a GLB (bad magic) and not parseable as glTF JSON')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const version = dv.getUint32(4, true)
|
|
72
|
+
if (version !== 2) throw new GlbParseError(`Unsupported GLB container version ${version} (expected 2)`)
|
|
73
|
+
|
|
74
|
+
let offset = 12
|
|
75
|
+
let json: any = null
|
|
76
|
+
let bin: Uint8Array | null = null
|
|
77
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
78
|
+
const chunkLen = dv.getUint32(offset, true)
|
|
79
|
+
const chunkType = dv.getUint32(offset + 4, true)
|
|
80
|
+
const start = offset + 8
|
|
81
|
+
const end = start + chunkLen
|
|
82
|
+
if (end > bytes.byteLength) throw new GlbParseError(`Chunk at ${offset} overruns the file`)
|
|
83
|
+
if (chunkType === CHUNK_JSON) {
|
|
84
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, end)))
|
|
85
|
+
} else if (chunkType === CHUNK_BIN) {
|
|
86
|
+
bin = bytes.subarray(start, end)
|
|
87
|
+
}
|
|
88
|
+
offset = end // chunkLength already includes the 4-byte alignment padding (glTF spec)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!json) throw new GlbParseError('GLB has no JSON chunk')
|
|
92
|
+
return { json, bin }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- image format detection -------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function mimeToFormat(mime: string): ImageFormat {
|
|
98
|
+
switch (mime) {
|
|
99
|
+
case 'image/png': return 'png'
|
|
100
|
+
case 'image/jpeg': return 'jpeg'
|
|
101
|
+
case 'image/ktx2': return 'ktx2'
|
|
102
|
+
case 'image/webp': return 'webp'
|
|
103
|
+
default: return 'unknown'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extToFormat(ext: string): ImageFormat {
|
|
108
|
+
switch (ext) {
|
|
109
|
+
case 'png': return 'png'
|
|
110
|
+
case 'jpg':
|
|
111
|
+
case 'jpeg': return 'jpeg'
|
|
112
|
+
case 'ktx2': return 'ktx2'
|
|
113
|
+
case 'webp': return 'webp'
|
|
114
|
+
default: return 'unknown'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sniffMagic(b: Uint8Array): ImageFormat {
|
|
119
|
+
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'
|
|
120
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'
|
|
121
|
+
// RIFF....WEBP
|
|
122
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
123
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp'
|
|
124
|
+
// KTX2: 0xAB 'KTX 20' 0xBB 0x0D 0x0A 0x1A 0x0A
|
|
125
|
+
if (b.length >= 8 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58 &&
|
|
126
|
+
b[4] === 0x20 && b[5] === 0x32 && b[6] === 0x30 && b[7] === 0xbb) return 'ktx2'
|
|
127
|
+
return 'unknown'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function detectFormat(image: any, json: any, bin: Uint8Array | null): { format: ImageFormat; via: string } {
|
|
131
|
+
if (typeof image?.uri === 'string') {
|
|
132
|
+
const uri: string = image.uri
|
|
133
|
+
if (uri.startsWith('data:')) {
|
|
134
|
+
const m = /^data:([^;,]+)[;,]/.exec(uri)
|
|
135
|
+
const mime = (m?.[1] ?? '').toLowerCase()
|
|
136
|
+
return { format: mimeToFormat(mime), via: `data-uri "${mime || '?'}"` }
|
|
137
|
+
}
|
|
138
|
+
const ext = (uri.split('?')[0].split('#')[0].split('.').pop() ?? '').toLowerCase()
|
|
139
|
+
return { format: extToFormat(ext), via: `uri ".${ext}"` }
|
|
140
|
+
}
|
|
141
|
+
if (typeof image?.mimeType === 'string') {
|
|
142
|
+
const mime = image.mimeType.toLowerCase()
|
|
143
|
+
return { format: mimeToFormat(mime), via: `mimeType "${image.mimeType}"` }
|
|
144
|
+
}
|
|
145
|
+
if (typeof image?.bufferView === 'number' && bin) {
|
|
146
|
+
const bv = json.bufferViews?.[image.bufferView]
|
|
147
|
+
// embedded images live in buffer 0 (the BIN chunk) for GLB
|
|
148
|
+
if (bv && (bv.buffer ?? 0) === 0) {
|
|
149
|
+
const start = bv.byteOffset ?? 0
|
|
150
|
+
return { format: sniffMagic(bin.subarray(start, start + 16)), via: 'magic-bytes' }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { format: 'unknown', via: 'undetermined' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Image indices actually referenced by a texture (standard source + ktx2/webp extension sources).
|
|
157
|
+
function collectUsedImages(json: any): Set<number> {
|
|
158
|
+
const used = new Set<number>()
|
|
159
|
+
for (const t of json.textures ?? []) {
|
|
160
|
+
if (typeof t.source === 'number') used.add(t.source)
|
|
161
|
+
const basisu = t.extensions?.KHR_texture_basisu
|
|
162
|
+
if (typeof basisu?.source === 'number') used.add(basisu.source)
|
|
163
|
+
const webp = t.extensions?.EXT_texture_webp
|
|
164
|
+
if (typeof webp?.source === 'number') used.add(webp.source)
|
|
165
|
+
}
|
|
166
|
+
return used
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- main entry -------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export function validateGlb(data: ArrayBuffer | Uint8Array): GlbValidationResult {
|
|
172
|
+
const errors: GlbIssue[] = []
|
|
173
|
+
const warnings: GlbIssue[] = []
|
|
174
|
+
const formats: Record<string, number> = {}
|
|
175
|
+
|
|
176
|
+
let parsed: ParsedGlb
|
|
177
|
+
try {
|
|
178
|
+
parsed = parseGlb(toBytes(data))
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
errors: [{ level: 'error', code: 'CONTAINER', message: (e as Error).message }],
|
|
183
|
+
warnings: [],
|
|
184
|
+
summary: { skins: 0, maxJointCount: 0, images: 0, formats: {} },
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const { json, bin } = parsed
|
|
188
|
+
|
|
189
|
+
// --- skins: joints per skin == filament mSkinningBoneCount, capped at CONFIG_MAX_BONE_COUNT ---
|
|
190
|
+
const skins: any[] = json.skins ?? []
|
|
191
|
+
let maxJointCount = 0
|
|
192
|
+
for (let i = 0; i < skins.length; i++) {
|
|
193
|
+
const n = skins[i]?.joints?.length ?? 0
|
|
194
|
+
maxJointCount = Math.max(maxJointCount, n)
|
|
195
|
+
if (n > MAX_BONE_COUNT) {
|
|
196
|
+
errors.push({
|
|
197
|
+
level: 'error',
|
|
198
|
+
code: 'BONE_COUNT',
|
|
199
|
+
message: `skin[${i}]${skins[i].name ? ` "${skins[i].name}"` : ''} has ${n} joints, exceeds filament limit of ${MAX_BONE_COUNT}`,
|
|
200
|
+
detail: { skin: i, name: skins[i].name, joints: n, limit: MAX_BONE_COUNT },
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- morph targets per primitive, capped at CONFIG_MAX_MORPH_TARGET_COUNT --------------------
|
|
206
|
+
for (let mi = 0; mi < (json.meshes?.length ?? 0); mi++) {
|
|
207
|
+
const mesh = json.meshes[mi]
|
|
208
|
+
for (let pi = 0; pi < (mesh.primitives?.length ?? 0); pi++) {
|
|
209
|
+
const n = mesh.primitives[pi]?.targets?.length ?? 0
|
|
210
|
+
if (n > MAX_MORPH_TARGET_COUNT) {
|
|
211
|
+
errors.push({
|
|
212
|
+
level: 'error',
|
|
213
|
+
code: 'MORPH_COUNT',
|
|
214
|
+
message: `mesh[${mi}]${mesh.name ? ` "${mesh.name}"` : ''} primitive ${pi} has ${n} morph targets, exceeds filament limit of ${MAX_MORPH_TARGET_COUNT}`,
|
|
215
|
+
detail: { mesh: mi, primitive: pi, targets: n, limit: MAX_MORPH_TARGET_COUNT },
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- textures: every image must decode with a registered provider (png/jpeg/ktx2) -----------
|
|
222
|
+
const images: any[] = json.images ?? []
|
|
223
|
+
const used = collectUsedImages(json)
|
|
224
|
+
for (let i = 0; i < images.length; i++) {
|
|
225
|
+
const { format, via } = detectFormat(images[i], json, bin)
|
|
226
|
+
formats[format] = (formats[format] ?? 0) + 1
|
|
227
|
+
const supported = (SUPPORTED_IMAGE_FORMATS as readonly string[]).includes(format)
|
|
228
|
+
if (supported) continue
|
|
229
|
+
|
|
230
|
+
const referenced = used.has(i)
|
|
231
|
+
const issue: GlbIssue = {
|
|
232
|
+
level: referenced ? 'error' : 'warning',
|
|
233
|
+
code: 'TEXTURE_FORMAT',
|
|
234
|
+
message:
|
|
235
|
+
`image[${i}]${images[i].name ? ` "${images[i].name}"` : ''} is ${format.toUpperCase()} ` +
|
|
236
|
+
`(${via})${referenced ? '' : ' [unreferenced]'} — only ${SUPPORTED_IMAGE_FORMATS.join('/')} are supported`,
|
|
237
|
+
detail: { image: i, format, via, referenced },
|
|
238
|
+
}
|
|
239
|
+
;(referenced ? errors : warnings).push(issue)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
ok: errors.length === 0,
|
|
244
|
+
errors,
|
|
245
|
+
warnings,
|
|
246
|
+
summary: { skins: skins.length, maxJointCount, images: images.length, formats },
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Human-readable report (string, so it works identically in browser console and Bun stdout).
|
|
251
|
+
export function formatReport(result: GlbValidationResult, label = 'GLB'): string {
|
|
252
|
+
const lines: string[] = []
|
|
253
|
+
const s = result.summary
|
|
254
|
+
lines.push(`${result.ok ? '✅ VALID' : '❌ INVALID'} ${label}`)
|
|
255
|
+
lines.push(
|
|
256
|
+
` skins=${s.skins} (max joints=${s.maxJointCount}/${MAX_BONE_COUNT}) ` +
|
|
257
|
+
`images=${s.images} [${Object.entries(s.formats).map(([k, v]) => `${k}:${v}`).join(', ') || 'none'}]`,
|
|
258
|
+
)
|
|
259
|
+
for (const e of result.errors) lines.push(` ✗ ${e.code}: ${e.message}`)
|
|
260
|
+
for (const w of result.warnings) lines.push(` ⚠ ${w.code}: ${w.message}`)
|
|
261
|
+
return lines.join('\n')
|
|
262
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Lazy crash symbolizer for the creator-gl wasm. The shipped wasm has no name section
|
|
2
|
+
// (see creator-gl/CMakeLists.txt: --emit-symbol-map), so a runtime trap surfaces as
|
|
3
|
+
// "RuntimeError: unreachable at wasm-function[1234]" with anonymous frames. The build deploys
|
|
4
|
+
// creator-gl.js.symbols (function index -> C++ name) next to the wasm; this module fetches that
|
|
5
|
+
// map ONLY after a crash has already happened and prints a decoded trace to the console. Happy
|
|
6
|
+
// path cost: zero — nothing is fetched or parsed until the first wasm frame appears in an error.
|
|
7
|
+
//
|
|
8
|
+
// The map is only valid for creator-gl.wasm. Frames from another wasm module (creator-ui,
|
|
9
|
+
// creator-2d) carry their own URL in the stack line and are left untouched; frames with no URL
|
|
10
|
+
// at all (Safari's "[wasm code]") are decoded anyway, since a creator-gl trap is by far the
|
|
11
|
+
// likeliest source, and marked with "?" so a misattributed name isn't taken as gospel.
|
|
12
|
+
|
|
13
|
+
import symbolsUrl from '../native/dist/creator-full.js.symbols?url'
|
|
14
|
+
|
|
15
|
+
const WASM_FRAME = /wasm-function\[(\d+)\]/g
|
|
16
|
+
|
|
17
|
+
// index -> raw (still escaped) name. Parsed once, kept raw — escape decoding runs only on the
|
|
18
|
+
// handful of frames actually printed, not on all ~40k entries.
|
|
19
|
+
let mapPromise: Promise<Map<number, string> | null> | null = null
|
|
20
|
+
|
|
21
|
+
const loadSymbolMap = (): Promise<Map<number, string> | null> => {
|
|
22
|
+
if (!mapPromise) {
|
|
23
|
+
mapPromise = fetch(symbolsUrl)
|
|
24
|
+
.then(resp => {
|
|
25
|
+
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`)
|
|
26
|
+
return resp.text()
|
|
27
|
+
})
|
|
28
|
+
.then(text => {
|
|
29
|
+
const map = new Map<number, string>()
|
|
30
|
+
for (const line of text.split('\n')) {
|
|
31
|
+
const sep = line.indexOf(':')
|
|
32
|
+
if (sep > 0) map.set(Number(line.slice(0, sep)), line.slice(sep + 1))
|
|
33
|
+
}
|
|
34
|
+
return map
|
|
35
|
+
})
|
|
36
|
+
.catch(e => {
|
|
37
|
+
console.warn('[creator-gl] creator-gl.js.symbols unavailable, wasm frames stay anonymous:', e)
|
|
38
|
+
return null
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return mapPromise
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The symbol map escapes non-identifier chars as \XX hex (emscripten format): \20 = space,
|
|
45
|
+
// \2c = comma, \28\29 = parens.
|
|
46
|
+
const decodeName = (raw: string): string =>
|
|
47
|
+
raw.replace(/\\([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
48
|
+
|
|
49
|
+
// Which wasm module does this stack line reference? Matches "creator-ui.wasm:",
|
|
50
|
+
// "creator-gl-B3xYz9.wasm)" etc., but not the ".wasm-function[N]" token itself (the lookahead
|
|
51
|
+
// rejects the "-f"). The name is matched loosely ("creator-gl" prefix) because Vite hashes prod
|
|
52
|
+
// asset filenames. null = no module URL in the line (Safari's "[wasm code]").
|
|
53
|
+
const MODULE_REF = /([\w.-]+)\.wasm(?=[^\w-]|$)/g
|
|
54
|
+
const lineModule = (line: string): 'ours' | 'other' | null => {
|
|
55
|
+
const refs = [...line.matchAll(MODULE_REF)].map(m => m[1]!)
|
|
56
|
+
if (refs.length === 0) return null
|
|
57
|
+
return refs.some(r => r.includes('creator-gl')) ? 'ours' : 'other'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode every creator-gl `wasm-function[N]` frame in a stack trace. Resolves to null when the
|
|
62
|
+
* stack has no wasm frames (or the symbol map can't be fetched) — callers use that to stay
|
|
63
|
+
* silent for ordinary JS errors.
|
|
64
|
+
*/
|
|
65
|
+
export const symbolizeWasmStack = async (stack: string): Promise<string | null> => {
|
|
66
|
+
WASM_FRAME.lastIndex = 0
|
|
67
|
+
if (!WASM_FRAME.test(stack)) return null
|
|
68
|
+
const map = await loadSymbolMap()
|
|
69
|
+
if (!map) return null
|
|
70
|
+
|
|
71
|
+
let decodedAny = false
|
|
72
|
+
const lines = stack.split('\n').map(line => {
|
|
73
|
+
WASM_FRAME.lastIndex = 0
|
|
74
|
+
if (!WASM_FRAME.test(line)) return line
|
|
75
|
+
const mod = lineModule(line)
|
|
76
|
+
// A frame naming a different wasm module belongs to that module's map, not ours. URL-less
|
|
77
|
+
// frames are decoded anyway (a creator-gl trap is by far the likeliest source) but marked "?".
|
|
78
|
+
if (mod === 'other') return line
|
|
79
|
+
return line.replace(WASM_FRAME, (frame, index) => {
|
|
80
|
+
const raw = map.get(Number(index))
|
|
81
|
+
if (raw === undefined) return frame
|
|
82
|
+
decodedAny = true
|
|
83
|
+
return `${mod === null ? '?' : ''}${decodeName(raw)} (${frame})`
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
return decodedAny ? lines.join('\n') : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The display-friendly cut of symbolizeWasmStack for UI overlays: just the decoded C++ names of
|
|
91
|
+
* the creator-gl frames, top-first — no URLs, no JS glue frames. Empty when the stack has no
|
|
92
|
+
* decodable creator-gl frames (or the map can't be fetched).
|
|
93
|
+
*/
|
|
94
|
+
export const symbolizeWasmFrames = async (stack: string): Promise<string[]> => {
|
|
95
|
+
WASM_FRAME.lastIndex = 0
|
|
96
|
+
if (!WASM_FRAME.test(stack)) return []
|
|
97
|
+
const map = await loadSymbolMap()
|
|
98
|
+
if (!map) return []
|
|
99
|
+
const names: string[] = []
|
|
100
|
+
for (const line of stack.split('\n')) {
|
|
101
|
+
if (lineModule(line) === 'other') continue
|
|
102
|
+
for (const m of line.matchAll(WASM_FRAME)) {
|
|
103
|
+
const raw = map.get(Number(m[1]))
|
|
104
|
+
// libc++'s internal namespace is pure noise in a UI overlay.
|
|
105
|
+
if (raw !== undefined) names.push(decodeName(raw).replaceAll('std::__2::', 'std::'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return names
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fire-and-forget: print a decoded trace for `err` if it contains creator-gl wasm frames. */
|
|
112
|
+
export const symbolizeWasmError = (err: unknown): void => {
|
|
113
|
+
const stack = (err as { stack?: unknown } | null)?.stack
|
|
114
|
+
if (typeof stack !== 'string') return
|
|
115
|
+
symbolizeWasmStack(stack).then(decoded => {
|
|
116
|
+
if (decoded) console.error('[creator-gl] decoded wasm stack (via creator-gl.js.symbols):\n' + decoded)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let installed = false
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Catch wasm traps that escape to the top (the render rAF loop has no try/catch — an engine
|
|
124
|
+
* crash lands on the global 'error' event) and follow the browser's own report with a decoded
|
|
125
|
+
* trace. Errors swallowed by app-level try/catch never get here — those paths call
|
|
126
|
+
* symbolizeWasmError directly (see createGlb/loadGlb).
|
|
127
|
+
*/
|
|
128
|
+
export const installWasmCrashSymbolizer = (): void => {
|
|
129
|
+
if (installed) return
|
|
130
|
+
installed = true
|
|
131
|
+
addEventListener('error', e => symbolizeWasmError((e as ErrorEvent).error))
|
|
132
|
+
addEventListener('unhandledrejection', e => symbolizeWasmError((e as PromiseRejectionEvent).reason))
|
|
133
|
+
}
|