@geastack/cli 0.1.51 → 0.1.53

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.
@@ -0,0 +1,1276 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import process from "node:process"
5
+
6
+ export const DEFAULT_TITLE = "ESP32 Heap Memory Map"
7
+
8
+ function usage() {
9
+ console.error("Usage: node scripts/esp32-heap-map-report.mjs <log...> --out <report.html> [--map <gea_embedded.map>] [--title <title>]")
10
+ }
11
+
12
+ function parseArgs(argv) {
13
+ const inputs = []
14
+ const maps = []
15
+ let out = ""
16
+ let title = DEFAULT_TITLE
17
+
18
+ for (let i = 0; i < argv.length; i += 1) {
19
+ const arg = argv[i]
20
+ if (arg === "--out") {
21
+ out = argv[++i] || ""
22
+ } else if (arg.startsWith("--out=")) {
23
+ out = arg.slice("--out=".length)
24
+ } else if (arg === "--title") {
25
+ title = argv[++i] || title
26
+ } else if (arg.startsWith("--title=")) {
27
+ title = arg.slice("--title=".length)
28
+ } else if (arg === "--map") {
29
+ maps.push(argv[++i] || "")
30
+ } else if (arg.startsWith("--map=")) {
31
+ maps.push(arg.slice("--map=".length))
32
+ } else if (arg === "--help" || arg === "-h") {
33
+ usage()
34
+ process.exit(0)
35
+ } else {
36
+ inputs.push(arg)
37
+ }
38
+ }
39
+
40
+ if (inputs.length === 0) {
41
+ usage()
42
+ throw new Error("at least one log file is required")
43
+ }
44
+ if (!out) {
45
+ usage()
46
+ throw new Error("--out <report.html> is required")
47
+ }
48
+ return { inputs, maps: maps.filter(Boolean), out, title }
49
+ }
50
+
51
+ function stripAnsi(line) {
52
+ return line.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
53
+ }
54
+
55
+ function toNumber(value) {
56
+ if (value === undefined || value === null || value === "") return null
57
+ return Number.parseInt(value, 10)
58
+ }
59
+
60
+ function parseHex(value) {
61
+ return Number.parseInt(value, 16)
62
+ }
63
+
64
+ function relPath(file) {
65
+ return path.relative(process.cwd(), path.resolve(file)) || path.basename(file)
66
+ }
67
+
68
+ function parseKeyValues(text) {
69
+ const out = {}
70
+ const re = /\b([A-Za-z_][A-Za-z0-9_]*)=([+-]?\d+)/g
71
+ for (const match of text.matchAll(re)) {
72
+ out[match[1]] = Number.parseInt(match[2], 10)
73
+ }
74
+ return out
75
+ }
76
+
77
+ function parseLogTimeMs(line) {
78
+ const match = line.match(/\b[VDIWE]\s*\((\d+)\)\s+[A-Za-z0-9_]+:/)
79
+ return match ? Number.parseInt(match[1], 10) : null
80
+ }
81
+
82
+ function normalizeStackLine(line) {
83
+ return stripAnsi(line).trim()
84
+ }
85
+
86
+ function inferLiveLabel(allocation) {
87
+ const stack = allocation.stack.join("\n")
88
+ if (stack.includes("tryConfigureFlushPipelineCandidate") || stack.includes("configureFlushPipeline")) {
89
+ return "LCD flush staging slot"
90
+ }
91
+ if (stack.includes("xTaskCreate") || stack.includes("xTaskCreatePinnedToCore")) {
92
+ return "FreeRTOS task stack/control block"
93
+ }
94
+ if (stack.includes("operator new")) {
95
+ return "C++ heap allocation"
96
+ }
97
+ return "live internal allocation"
98
+ }
99
+
100
+ function sourceRef(file, line) {
101
+ return `${relPath(file)}:${line}`
102
+ }
103
+
104
+ function parseLinkerMap(file) {
105
+ const absolute = path.resolve(file)
106
+ const text = fs.readFileSync(absolute, "utf8")
107
+ const lines = text.split(/\r?\n/)
108
+ const linkerMap = {
109
+ file: absolute,
110
+ label: relPath(absolute),
111
+ bytes: Buffer.byteLength(text),
112
+ memoryRegions: [],
113
+ sections: [],
114
+ }
115
+ let inMemoryConfig = false
116
+ let inMemoryMap = false
117
+ let pendingTopLevelSection = ""
118
+
119
+ function addSection(name, start, size, line) {
120
+ if (start === 0 && size === 0) return
121
+ if (size === 0 && !name.includes("heap_start")) return
122
+ const section = {
123
+ name,
124
+ start,
125
+ end: start + size,
126
+ size,
127
+ line,
128
+ ref: sourceRef(absolute, line),
129
+ memoryRegion: "",
130
+ }
131
+ const candidates = linkerMap.memoryRegions.filter((region) => section.start >= region.start && section.end <= region.end)
132
+ let region = candidates[0]
133
+ if (section.name.startsWith(".ext_ram")) {
134
+ region = candidates.find((candidate) => candidate.name === "extern_ram_seg") || region
135
+ } else if (section.name.startsWith(".flash.rodata") || section.name.startsWith(".flash.appdesc")) {
136
+ region = candidates.find((candidate) => candidate.name === "drom0_0_seg") || region
137
+ }
138
+ if (!region) return
139
+ section.memoryRegion = region.name
140
+ region.sectionIndexes.push(linkerMap.sections.length)
141
+ linkerMap.sections.push(section)
142
+ }
143
+
144
+ for (let index = 0; index < lines.length; index += 1) {
145
+ const line = lines[index]
146
+ if (line.trim() === "Memory Configuration") {
147
+ inMemoryConfig = true
148
+ continue
149
+ }
150
+ if (line.trim() === "Linker script and memory map") {
151
+ inMemoryConfig = false
152
+ inMemoryMap = true
153
+ continue
154
+ }
155
+
156
+ if (inMemoryConfig) {
157
+ const match = line.match(/^(\S+)\s+0x([0-9A-Fa-f]+)\s+0x([0-9A-Fa-f]+)\s+([A-Za-z]*)/)
158
+ if (!match || match[1] === "Name" || match[1] === "*default*") continue
159
+ const start = parseHex(match[2])
160
+ const size = parseHex(match[3])
161
+ linkerMap.memoryRegions.push({
162
+ name: match[1],
163
+ start,
164
+ end: start + size,
165
+ size,
166
+ attributes: match[4],
167
+ sectionIndexes: [],
168
+ })
169
+ continue
170
+ }
171
+
172
+ if (inMemoryMap) {
173
+ const match = line.match(/^(\.[^\s]+)\s+0x([0-9A-Fa-f]+)\s+0x([0-9A-Fa-f]+)/)
174
+ if (match) {
175
+ pendingTopLevelSection = ""
176
+ addSection(match[1], parseHex(match[2]), parseHex(match[3]), index + 1)
177
+ continue
178
+ }
179
+ const headerOnly = line.match(/^(\.[^\s]+)\s*$/)
180
+ if (headerOnly) {
181
+ pendingTopLevelSection = headerOnly[1]
182
+ continue
183
+ }
184
+ if (pendingTopLevelSection) {
185
+ const continuation = line.match(/^\s+0x([0-9A-Fa-f]+)\s+0x([0-9A-Fa-f]+)/)
186
+ if (continuation) {
187
+ addSection(pendingTopLevelSection, parseHex(continuation[1]), parseHex(continuation[2]), index + 1)
188
+ pendingTopLevelSection = ""
189
+ continue
190
+ }
191
+ if (line.trim() === "") {
192
+ pendingTopLevelSection = ""
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return linkerMap
199
+ }
200
+
201
+ function attachFreeBlock(snapshot, block) {
202
+ snapshot.freeBlocks.push(block)
203
+ }
204
+
205
+ function findSnapshot(currentByKey, stage, caps) {
206
+ return currentByKey.get(`${stage}\u0000${caps}`)
207
+ }
208
+
209
+ function parseLogs(files, mapFiles = []) {
210
+ const data = {
211
+ generatedAt: new Date().toISOString(),
212
+ inputs: [],
213
+ linkerMaps: [],
214
+ bootRegions: [],
215
+ heapEvents: [],
216
+ probes: [],
217
+ snapshots: [],
218
+ liveAllocations: [],
219
+ warnings: [],
220
+ }
221
+
222
+ let pendingLive = null
223
+
224
+ function finishPendingLive() {
225
+ if (!pendingLive) return
226
+ pendingLive.label = inferLiveLabel(pendingLive)
227
+ data.liveAllocations.push(pendingLive)
228
+ pendingLive = null
229
+ }
230
+
231
+ for (const file of files) {
232
+ const absolute = path.resolve(file)
233
+ const text = fs.readFileSync(absolute, "utf8")
234
+ const lines = text.split(/\r?\n/)
235
+ data.inputs.push({ file: absolute, label: relPath(absolute), bytes: Buffer.byteLength(text), lines: lines.length })
236
+ const currentByKey = new Map()
237
+
238
+ for (let index = 0; index < lines.length; index += 1) {
239
+ const raw = lines[index]
240
+ const lineNumber = index + 1
241
+ const line = stripAnsi(raw)
242
+ const trimmed = line.trim()
243
+ const timeMs = parseLogTimeMs(line)
244
+
245
+ if (pendingLive) {
246
+ const isStack = trimmed.startsWith("--- ") || trimmed.startsWith("at ") || trimmed.includes(" at /")
247
+ const startsNewRecord =
248
+ trimmed.startsWith("[heap-") ||
249
+ trimmed.includes(" heap probe [") ||
250
+ trimmed.includes("heap_init: At ") ||
251
+ trimmed === ""
252
+ if (isStack && !startsNewRecord) {
253
+ pendingLive.stack.push(normalizeStackLine(raw))
254
+ continue
255
+ }
256
+ finishPendingLive()
257
+ }
258
+
259
+ const bootMatch = trimmed.match(/heap_init: At ([0-9A-Fa-f]+) len ([0-9A-Fa-f]+) \(([^)]+)\): ([A-Za-z0-9_]+)/)
260
+ if (bootMatch) {
261
+ const start = parseHex(bootMatch[1])
262
+ const size = parseHex(bootMatch[2])
263
+ data.bootRegions.push({
264
+ file: absolute,
265
+ line: lineNumber,
266
+ ref: sourceRef(absolute, lineNumber),
267
+ timeMs,
268
+ start,
269
+ end: start + size,
270
+ size,
271
+ printableSize: bootMatch[3],
272
+ type: bootMatch[4],
273
+ })
274
+ continue
275
+ }
276
+
277
+ const probeMatch = trimmed.match(/heap probe \[([^\]]+)\]\s+(.+)$/)
278
+ if (probeMatch) {
279
+ data.probes.push({
280
+ file: absolute,
281
+ line: lineNumber,
282
+ ref: sourceRef(absolute, lineNumber),
283
+ timeMs,
284
+ stage: probeMatch[1],
285
+ values: parseKeyValues(probeMatch[2]),
286
+ })
287
+ continue
288
+ }
289
+
290
+ const flushPoolMatch = trimmed.match(/display:\s+LCD flush pool reserved:\s+(\d+)\s+bytes/)
291
+ if (flushPoolMatch) {
292
+ data.heapEvents.push({
293
+ type: "flush_pool_reserved",
294
+ label: "LCD flush pool reserved",
295
+ bytes: toNumber(flushPoolMatch[1]),
296
+ file: absolute,
297
+ line: lineNumber,
298
+ ref: sourceRef(absolute, lineNumber),
299
+ timeMs,
300
+ })
301
+ continue
302
+ }
303
+
304
+ const summaryMatch = trimmed.match(/\[heap-map\]\s+stage=(\S+)\s+caps=(\S+)\s+total_free=(\d+)\s+largest=(\d+)\s+min=(\d+)\s+free_blocks=(\d+)\s+alloc_blocks=(\d+)\s+total_blocks=(\d+)/)
305
+ if (summaryMatch) {
306
+ const snapshot = {
307
+ id: data.snapshots.length,
308
+ file: absolute,
309
+ fileLabel: relPath(absolute),
310
+ line: lineNumber,
311
+ ref: sourceRef(absolute, lineNumber),
312
+ timeMs,
313
+ stage: summaryMatch[1],
314
+ caps: summaryMatch[2],
315
+ totalFree: toNumber(summaryMatch[3]),
316
+ largestFree: toNumber(summaryMatch[4]),
317
+ minFree: toNumber(summaryMatch[5]),
318
+ freeBlockCount: toNumber(summaryMatch[6]),
319
+ allocBlockCount: toNumber(summaryMatch[7]),
320
+ totalBlockCount: toNumber(summaryMatch[8]),
321
+ regions: [],
322
+ freeBlocks: [],
323
+ }
324
+ data.snapshots.push(snapshot)
325
+ currentByKey.set(`${snapshot.stage}\u0000${snapshot.caps}`, snapshot)
326
+ continue
327
+ }
328
+
329
+ const freeMatch = trimmed.match(/\[heap-free\]\s+stage=(\S+)\s+caps=(\S+)\s+heap=(\d+)\s+ptr=0x([0-9A-Fa-f]+)\s+size=(\d+)/)
330
+ if (freeMatch) {
331
+ const snapshot = findSnapshot(currentByKey, freeMatch[1], freeMatch[2])
332
+ if (!snapshot) {
333
+ data.warnings.push(`free block without summary at ${sourceRef(absolute, lineNumber)}`)
334
+ continue
335
+ }
336
+ const start = parseHex(freeMatch[4])
337
+ const size = toNumber(freeMatch[5])
338
+ attachFreeBlock(snapshot, {
339
+ heap: toNumber(freeMatch[3]),
340
+ start,
341
+ end: start + size,
342
+ size,
343
+ ref: sourceRef(absolute, lineNumber),
344
+ })
345
+ continue
346
+ }
347
+
348
+ const regionMatch = trimmed.match(/\[heap-map\]\s+stage=(\S+)\s+caps=(\S+)\s+heap=(\d+)\s+range=0x([0-9A-Fa-f]+)-0x([0-9A-Fa-f]+)\s+free=(\d+)\s+used=(\d+)\s+largest_free=(\d+)\s+free_blocks=(\d+)\s+used_blocks=(\d+)/)
349
+ if (regionMatch) {
350
+ const snapshot = findSnapshot(currentByKey, regionMatch[1], regionMatch[2])
351
+ if (!snapshot) {
352
+ data.warnings.push(`heap region without summary at ${sourceRef(absolute, lineNumber)}`)
353
+ continue
354
+ }
355
+ const start = parseHex(regionMatch[4])
356
+ const endInclusive = parseHex(regionMatch[5])
357
+ snapshot.regions.push({
358
+ heap: toNumber(regionMatch[3]),
359
+ start,
360
+ end: endInclusive + 1,
361
+ size: endInclusive - start + 1,
362
+ free: toNumber(regionMatch[6]),
363
+ used: toNumber(regionMatch[7]),
364
+ largestFree: toNumber(regionMatch[8]),
365
+ freeBlocks: toNumber(regionMatch[9]),
366
+ usedBlocks: toNumber(regionMatch[10]),
367
+ ref: sourceRef(absolute, lineNumber),
368
+ })
369
+ continue
370
+ }
371
+
372
+ const liveMatch = trimmed.match(/\[heap-live\]\s+scope=(\S+)\s+ptr=0x([0-9A-Fa-f]+)\s+size=(\d+)(.*)$/)
373
+ if (liveMatch) {
374
+ const start = parseHex(liveMatch[2])
375
+ const size = toNumber(liveMatch[3])
376
+ pendingLive = {
377
+ id: data.liveAllocations.length,
378
+ file: absolute,
379
+ fileLabel: relPath(absolute),
380
+ line: lineNumber,
381
+ ref: sourceRef(absolute, lineNumber),
382
+ timeMs,
383
+ scope: liveMatch[1],
384
+ start,
385
+ end: start + size,
386
+ size,
387
+ pcs: [...liveMatch[4].matchAll(/\bpc\d+=0x([0-9A-Fa-f]+)/g)].map((m) => `0x${m[1]}`),
388
+ stack: [],
389
+ label: "",
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ for (const mapFile of mapFiles) {
396
+ data.linkerMaps.push(parseLinkerMap(mapFile))
397
+ }
398
+
399
+ finishPendingLive()
400
+
401
+ const labelByPcSignature = new Map()
402
+ for (const allocation of data.liveAllocations) {
403
+ const signature = allocation.pcs.join("|")
404
+ if (signature && allocation.label !== "live internal allocation") {
405
+ labelByPcSignature.set(signature, allocation.label)
406
+ }
407
+ }
408
+ for (const allocation of data.liveAllocations) {
409
+ const signature = allocation.pcs.join("|")
410
+ const inferred = labelByPcSignature.get(signature)
411
+ if (inferred && allocation.label === "live internal allocation") {
412
+ allocation.label = `${inferred} (same PC signature)`
413
+ }
414
+ }
415
+
416
+ for (const snapshot of data.snapshots) {
417
+ snapshot.regions.sort((a, b) => a.start - b.start)
418
+ snapshot.freeBlocks.sort((a, b) => a.start - b.start)
419
+ for (const region of snapshot.regions) {
420
+ region.freeBlockIndexes = snapshot.freeBlocks
421
+ .map((block, index) => ({ block, index }))
422
+ .filter(({ block }) => block.heap === region.heap && block.start >= region.start && block.end <= region.end)
423
+ .map(({ index }) => index)
424
+ }
425
+ }
426
+
427
+ return data
428
+ }
429
+
430
+ function htmlEscape(text) {
431
+ return String(text).replace(/[&<>"']/g, (char) => {
432
+ switch (char) {
433
+ case "&":
434
+ return "&amp;"
435
+ case "<":
436
+ return "&lt;"
437
+ case ">":
438
+ return "&gt;"
439
+ case '"':
440
+ return "&quot;"
441
+ case "'":
442
+ return "&#39;"
443
+ default:
444
+ return char
445
+ }
446
+ })
447
+ }
448
+
449
+ function renderHtml(data, title) {
450
+ const safeJson = JSON.stringify(data).replace(/</g, "\\u003c")
451
+ const safeTitle = htmlEscape(title)
452
+ return `<!doctype html>
453
+ <html lang="en">
454
+ <head>
455
+ <meta charset="utf-8">
456
+ <meta name="viewport" content="width=device-width, initial-scale=1">
457
+ <title>${safeTitle}</title>
458
+ <style>
459
+ :root {
460
+ color-scheme: dark;
461
+ --bg: #101418;
462
+ --panel: #171d22;
463
+ --panel-2: #1d242a;
464
+ --ink: #e8eef2;
465
+ --muted: #a2adb5;
466
+ --line: #303a42;
467
+ --line-2: #43505a;
468
+ --free: #46bf86;
469
+ --free-soft: rgba(70, 191, 134, 0.18);
470
+ --used: #844b3e;
471
+ --used-soft: rgba(132, 75, 62, 0.58);
472
+ --new-used: #c76b58;
473
+ --new-used-soft: rgba(199, 107, 88, 0.66);
474
+ --static: #d4a056;
475
+ --static-soft: rgba(212, 160, 86, 0.42);
476
+ --gap: #26313a;
477
+ --live: #5bc4d8;
478
+ --warn: #e1b85b;
479
+ --bad: #df7d72;
480
+ --focus: #8ecae6;
481
+ }
482
+ * { box-sizing: border-box; }
483
+ html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
484
+ body { padding: 24px; }
485
+ header, main { max-width: 1480px; margin: 0 auto; }
486
+ header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: end; margin-bottom: 18px; }
487
+ h1 { margin: 0; font-size: 26px; letter-spacing: 0; }
488
+ h2 { margin: 0 0 10px; font-size: 15px; letter-spacing: 0; }
489
+ p { margin: 4px 0 0; color: var(--muted); }
490
+ .meta { text-align: right; color: var(--muted); font-size: 12px; }
491
+ .toolbar { display: grid; grid-template-columns: minmax(180px, 240px) minmax(280px, 1fr) auto auto auto; gap: 10px; align-items: end; padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 14px; }
492
+ label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
493
+ select, button { height: 34px; border-radius: 6px; border: 1px solid var(--line-2); background: #11171b; color: var(--ink); font: inherit; }
494
+ select { min-width: 0; padding: 0 10px; }
495
+ button { min-width: 38px; padding: 0 11px; cursor: pointer; }
496
+ button:hover, select:hover { border-color: var(--focus); }
497
+ .grid { display: grid; gap: 14px; }
498
+ .summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
499
+ .metric { min-width: 0; padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
500
+ .metric .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
501
+ .metric .value { margin-top: 6px; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
502
+ .metric .sub { margin-top: 3px; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
503
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px; min-width: 0; }
504
+ .split { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, 0.9fr); gap: 14px; }
505
+ .timeline { width: 100%; height: 210px; display: block; border: 1px solid var(--line); border-radius: 6px; background: #11171b; }
506
+ .legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; color: var(--muted); }
507
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
508
+ .swatch { width: 18px; height: 8px; border-radius: 999px; display: inline-block; }
509
+ .swatch.free { background: var(--free); }
510
+ .swatch.used { background: var(--used); }
511
+ .swatch.new-used { background: var(--new-used); }
512
+ .swatch.live { background: var(--live); }
513
+ .swatch.static { background: var(--static); }
514
+ .swatch.gap { background: var(--gap); }
515
+ .memory-map { display: grid; gap: 12px; }
516
+ .region-row { display: grid; grid-template-columns: 190px minmax(0, 1fr) 138px; gap: 10px; align-items: center; }
517
+ .region-label { color: var(--muted); min-width: 0; }
518
+ .region-label strong { color: var(--ink); font-weight: 650; display: block; }
519
+ .region-bar { position: relative; height: 34px; overflow: hidden; border-radius: 5px; border: 1px solid var(--line-2); background: #0c1114; }
520
+ .segment { position: absolute; top: 0; bottom: 0; min-width: 1px; }
521
+ .segment.used { background: linear-gradient(90deg, var(--used-soft), rgba(132, 75, 62, 0.36)); }
522
+ .segment.new-used { background: repeating-linear-gradient(135deg, var(--new-used-soft) 0, var(--new-used-soft) 5px, rgba(239, 159, 132, 0.32) 5px, rgba(239, 159, 132, 0.32) 10px); border-left: 1px solid rgba(255, 191, 168, 0.74); border-right: 1px solid rgba(255, 191, 168, 0.44); z-index: 2; }
523
+ .segment.free { background: linear-gradient(90deg, var(--free-soft), rgba(70, 191, 134, 0.35)); border-left: 1px solid rgba(96, 230, 168, 0.7); border-right: 1px solid rgba(96, 230, 168, 0.25); }
524
+ .segment.static { background: linear-gradient(90deg, var(--static-soft), rgba(212, 160, 86, 0.28)); border-left: 1px solid rgba(234, 193, 126, 0.72); }
525
+ .segment.gap { background: var(--gap); }
526
+ .heap-marker { position: absolute; top: 0; bottom: 0; width: 2px; background: #f0d99a; z-index: 4; }
527
+ .live-overlay { position: absolute; top: 3px; bottom: 3px; min-width: 2px; background: repeating-linear-gradient(135deg, rgba(91, 196, 216, 0.92) 0, rgba(91, 196, 216, 0.92) 3px, rgba(91, 196, 216, 0.32) 3px, rgba(91, 196, 216, 0.32) 7px); border: 1px solid rgba(181, 241, 251, 0.8); border-radius: 3px; z-index: 3; }
528
+ .region-stat { color: var(--muted); text-align: right; font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
529
+ .matrix-meta { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 10px; color: var(--muted); }
530
+ .matrix-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; margin-bottom: 10px; }
531
+ .matrix-controls label { min-width: 150px; }
532
+ .matrix-wrap { display: grid; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
533
+ .matrix-row { display: grid; grid-template-columns: 92px max-content; gap: 8px; align-items: center; }
534
+ .matrix-address { color: var(--muted); font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
535
+ .matrix-cells { display: grid; grid-template-columns: repeat(var(--matrix-cols), 10px); gap: 2px; }
536
+ .matrix-cell { width: 10px; height: 10px; border-radius: 2px; background: var(--gap); border: 1px solid rgba(255, 255, 255, 0.04); }
537
+ .matrix-cell:hover { outline: 2px solid var(--focus); outline-offset: 1px; }
538
+ .matrix-static { background: var(--static); }
539
+ .matrix-used { background: var(--used); }
540
+ .matrix-newUsed { background: var(--new-used); box-shadow: 0 0 0 1px rgba(255, 207, 190, 0.62) inset; }
541
+ .matrix-free { background: var(--free); }
542
+ .matrix-live { background: var(--live); box-shadow: 0 0 0 1px rgba(221, 253, 255, 0.76) inset; }
543
+ .matrix-gap { background: var(--gap); }
544
+ .matrix-mixed { border-color: rgba(255, 255, 255, 0.36); }
545
+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }
546
+ th, td { padding: 8px 9px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
547
+ th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 650; }
548
+ td { overflow-wrap: anywhere; }
549
+ tbody tr:hover { background: rgba(255, 255, 255, 0.035); }
550
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
551
+ .small { color: var(--muted); font-size: 12px; }
552
+ .warn { color: var(--warn); }
553
+ .empty { padding: 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line-2); border-radius: 6px; }
554
+ .scroll-table { max-height: 360px; overflow: auto; border: 1px solid var(--line); border-radius: 6px; }
555
+ .scroll-table table { border-collapse: separate; border-spacing: 0; }
556
+ .scroll-table th { position: sticky; top: 0; background: var(--panel-2); z-index: 1; }
557
+ .two-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
558
+ .stack { white-space: pre-wrap; color: var(--muted); font-size: 12px; max-height: 92px; overflow: auto; }
559
+ @media (max-width: 980px) {
560
+ body { padding: 14px; }
561
+ header { grid-template-columns: 1fr; }
562
+ .meta { text-align: left; }
563
+ .toolbar { grid-template-columns: 1fr 1fr; }
564
+ .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
565
+ .split, .two-cols { grid-template-columns: 1fr; }
566
+ .region-row { grid-template-columns: 1fr; }
567
+ .region-stat { text-align: left; }
568
+ }
569
+ </style>
570
+ </head>
571
+ <body>
572
+ <header>
573
+ <div>
574
+ <h1>${safeTitle}</h1>
575
+ <p>Free holes are from <span class="mono">[heap-free]</span>. Used spans are inferred as everything between holes. Brighter used spans were free in the first snapshot for the same caps.</p>
576
+ </div>
577
+ <div class="meta" id="reportMeta"></div>
578
+ </header>
579
+ <main class="grid">
580
+ <section class="toolbar">
581
+ <label>Caps<select id="capsSelect"></select></label>
582
+ <label>Snapshot<select id="snapshotSelect"></select></label>
583
+ <button id="prevButton" title="Previous snapshot">Prev</button>
584
+ <button id="nextButton" title="Next snapshot">Next</button>
585
+ <button id="latestButton" title="Latest snapshot for selected caps">Latest</button>
586
+ </section>
587
+ <section class="summary" id="summary"></section>
588
+ <section class="split">
589
+ <div class="panel">
590
+ <h2>Memory Over Time</h2>
591
+ <svg class="timeline" id="timeline" viewBox="0 0 920 210" role="img" aria-label="Heap memory over time"></svg>
592
+ <div class="legend">
593
+ <span><i class="swatch free"></i>Total free</span>
594
+ <span><i class="swatch live"></i>Largest free block</span>
595
+ <span><i class="swatch used"></i>Minimum ever free</span>
596
+ </div>
597
+ </div>
598
+ <div class="panel">
599
+ <h2>Boot Heap Regions</h2>
600
+ <div class="scroll-table" id="bootRegions"></div>
601
+ </div>
602
+ </section>
603
+ <section class="panel">
604
+ <h2>Contiguous Address Matrix</h2>
605
+ <div class="matrix-controls">
606
+ <label>Cell Size
607
+ <select id="matrixCellSizeSelect">
608
+ <option value="1024">1 KiB</option>
609
+ <option value="2048">2 KiB</option>
610
+ <option value="4096" selected>4 KiB</option>
611
+ <option value="8192">8 KiB</option>
612
+ <option value="16384">16 KiB</option>
613
+ </select>
614
+ </label>
615
+ <span class="small">Default: 4 KiB per cell. Use smaller cells only when zooming into a suspect range.</span>
616
+ </div>
617
+ <div id="addressMatrix"></div>
618
+ <div class="legend">
619
+ <span><i class="swatch static"></i>Static section</span>
620
+ <span><i class="swatch used"></i>Heap used</span>
621
+ <span><i class="swatch new-used"></i>New heap used since first snapshot</span>
622
+ <span><i class="swatch free"></i>Heap free</span>
623
+ <span><i class="swatch live"></i>Live allocation</span>
624
+ <span><i class="swatch gap"></i>Unclaimed gap</span>
625
+ </div>
626
+ </section>
627
+ <section class="panel">
628
+ <h2>Static Linker Map</h2>
629
+ <div id="linkerMap"></div>
630
+ </section>
631
+ <section class="panel">
632
+ <h2>Snapshot Memory Map</h2>
633
+ <div class="memory-map" id="memoryMap"></div>
634
+ </section>
635
+ <section class="two-cols">
636
+ <div class="panel">
637
+ <h2>Free Blocks In Snapshot</h2>
638
+ <div class="scroll-table" id="freeBlocks"></div>
639
+ </div>
640
+ <div class="panel">
641
+ <h2>Heap Regions In Snapshot</h2>
642
+ <div class="scroll-table" id="regionsTable"></div>
643
+ </div>
644
+ </section>
645
+ <section class="panel">
646
+ <h2>Heap Used Spans In Snapshot</h2>
647
+ <p>These brown ranges are inferred from heap region boundaries minus free holes. The introduced range column shows bytes that were free in the first snapshot for the same caps.</p>
648
+ <div class="scroll-table" id="usedSpans"></div>
649
+ </section>
650
+ <section class="panel">
651
+ <h2>Live Allocation Evidence</h2>
652
+ <div class="scroll-table" id="liveAllocations"></div>
653
+ </section>
654
+ <section class="panel">
655
+ <h2>Probe Timeline</h2>
656
+ <div class="scroll-table" id="probeTimeline"></div>
657
+ </section>
658
+ </main>
659
+ <script>
660
+ window.__HEAP_MAP_DATA__ = ${safeJson};
661
+
662
+ const data = window.__HEAP_MAP_DATA__;
663
+ const ESP32_S3_INTERNAL_DRAM_START = 0x3fc80000;
664
+ const ESP32_S3_INTERNAL_DRAM_END = ESP32_S3_INTERNAL_DRAM_START + 512 * 1024;
665
+ const capsSelect = document.getElementById("capsSelect");
666
+ const snapshotSelect = document.getElementById("snapshotSelect");
667
+ const summaryEl = document.getElementById("summary");
668
+ const timelineEl = document.getElementById("timeline");
669
+ const memoryMapEl = document.getElementById("memoryMap");
670
+ const freeBlocksEl = document.getElementById("freeBlocks");
671
+ const usedSpansEl = document.getElementById("usedSpans");
672
+ const regionsTableEl = document.getElementById("regionsTable");
673
+ const bootRegionsEl = document.getElementById("bootRegions");
674
+ const addressMatrixEl = document.getElementById("addressMatrix");
675
+ const matrixCellSizeSelect = document.getElementById("matrixCellSizeSelect");
676
+ const linkerMapEl = document.getElementById("linkerMap");
677
+ const liveAllocationsEl = document.getElementById("liveAllocations");
678
+ const probeTimelineEl = document.getElementById("probeTimeline");
679
+ const reportMetaEl = document.getElementById("reportMeta");
680
+
681
+ function fmtBytes(value) {
682
+ if (value == null || Number.isNaN(value)) return "-";
683
+ if (Math.abs(value) >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + " MiB";
684
+ if (Math.abs(value) >= 1024) return (value / 1024).toFixed(1) + " KiB";
685
+ return String(value) + " B";
686
+ }
687
+
688
+ function hex(value) {
689
+ if (value == null || Number.isNaN(value)) return "-";
690
+ return "0x" + Math.round(value).toString(16).padStart(8, "0");
691
+ }
692
+
693
+ function rangeText(start, endExclusive) {
694
+ return hex(start) + "-" + hex(Math.max(start, endExclusive - 1));
695
+ }
696
+
697
+ function esc(text) {
698
+ return String(text ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
699
+ }
700
+
701
+ function snapshotsForCaps(caps) {
702
+ return data.snapshots.filter((snapshot) => snapshot.caps === caps);
703
+ }
704
+
705
+ function firstSnapshotForCaps(caps) {
706
+ return snapshotsForCaps(caps)[0] || null;
707
+ }
708
+
709
+ function selectedSnapshot() {
710
+ return data.snapshots.find((snapshot) => String(snapshot.id) === snapshotSelect.value) || data.snapshots[0] || null;
711
+ }
712
+
713
+ function table(headers, rows) {
714
+ if (rows.length === 0) return '<div class="empty">No rows in this view.</div>';
715
+ return '<table><thead><tr>' + headers.map((h) => '<th>' + esc(h) + '</th>').join("") + '</tr></thead><tbody>' + rows.join("") + '</tbody></table>';
716
+ }
717
+
718
+ function cell(text, className = "") {
719
+ return '<td' + (className ? ' class="' + className + '"' : '') + '>' + esc(text) + '</td>';
720
+ }
721
+
722
+ function bootTable() {
723
+ const rows = data.bootRegions.map((region) => {
724
+ return '<tr>' +
725
+ cell(region.type) +
726
+ cell(rangeText(region.start, region.end), "mono") +
727
+ cell(fmtBytes(region.size)) +
728
+ cell(region.printableSize) +
729
+ cell(region.ref, "small") +
730
+ '</tr>';
731
+ });
732
+ bootRegionsEl.innerHTML = table(["Type", "Range", "Bytes", "Boot text", "Source"], rows);
733
+ }
734
+
735
+ function sectionKind(section) {
736
+ if (section.name.includes("heap_start")) return "marker";
737
+ if (section.size === 0) return "marker";
738
+ return "static";
739
+ }
740
+
741
+ function linkerRegionSegments(map, region) {
742
+ const sections = (region.sectionIndexes || [])
743
+ .map((index) => map.sections[index])
744
+ .filter((section) => section.size > 0)
745
+ .sort((a, b) => a.start - b.start);
746
+ const segments = [];
747
+ let cursor = region.start;
748
+ for (const section of sections) {
749
+ if (section.start > cursor) {
750
+ segments.push({ type: "gap", name: "not statically allocated / heap candidate", start: cursor, end: Math.min(section.start, region.end), size: Math.min(section.start, region.end) - cursor });
751
+ }
752
+ segments.push({ type: "static", name: section.name, start: section.start, end: section.end, size: section.size, ref: section.ref });
753
+ cursor = Math.max(cursor, section.end);
754
+ }
755
+ if (cursor < region.end) {
756
+ segments.push({ type: "gap", name: "not statically allocated / heap candidate", start: cursor, end: region.end, size: region.end - cursor });
757
+ }
758
+ return segments.filter((segment) => segment.size > 0);
759
+ }
760
+
761
+ function overlapBytes(start, end, interval) {
762
+ return Math.max(0, Math.min(end, interval.end) - Math.max(start, interval.start));
763
+ }
764
+
765
+ function subtractIntervals(span, blockers) {
766
+ let parts = [{ start: span.start, end: span.end }];
767
+ for (const blocker of blockers) {
768
+ const next = [];
769
+ for (const part of parts) {
770
+ const start = Math.max(part.start, blocker.start);
771
+ const end = Math.min(part.end, blocker.end);
772
+ if (start >= end) {
773
+ next.push(part);
774
+ continue;
775
+ }
776
+ if (part.start < start) next.push({ start: part.start, end: start });
777
+ if (end < part.end) next.push({ start: end, end: part.end });
778
+ }
779
+ parts = next;
780
+ if (parts.length === 0) break;
781
+ }
782
+ return parts.map((part) => ({ ...part, size: part.end - part.start })).filter((part) => part.size > 0);
783
+ }
784
+
785
+ function usedSpansForSnapshot(snapshot) {
786
+ if (!snapshot) return [];
787
+ return snapshot.regions.flatMap((region) => {
788
+ return regionSegments(snapshot, region)
789
+ .filter((segment) => segment.type === "used")
790
+ .map((segment) => ({
791
+ ...segment,
792
+ heap: region.heap,
793
+ regionRef: region.ref,
794
+ }));
795
+ }).sort((a, b) => a.start - b.start);
796
+ }
797
+
798
+ function introducedUsedSpans(snapshot) {
799
+ if (!snapshot) return [];
800
+ const first = firstSnapshotForCaps(snapshot.caps);
801
+ if (!first || first.id === snapshot.id) return [];
802
+ const baselineUsed = usedSpansForSnapshot(first);
803
+ return usedSpansForSnapshot(snapshot).flatMap((span) => {
804
+ return subtractIntervals(span, baselineUsed).map((part) => ({
805
+ ...part,
806
+ heap: span.heap,
807
+ baselineStage: first.stage,
808
+ regionRef: span.regionRef,
809
+ }));
810
+ });
811
+ }
812
+
813
+ function introducedPartsForSpan(snapshot, span) {
814
+ return introducedUsedSpans(snapshot).filter((introduced) => overlapBytes(span.start, span.end, introduced) > 0);
815
+ }
816
+
817
+ function matchingFlushPoolEvent(size) {
818
+ return (data.heapEvents || []).find((event) => event.type === "flush_pool_reserved" && Math.abs((event.bytes || 0) - size) <= 64) || null;
819
+ }
820
+
821
+ function isInternalDramRange(region) {
822
+ return region && region.start >= ESP32_S3_INTERNAL_DRAM_START && region.start < ESP32_S3_INTERNAL_DRAM_END;
823
+ }
824
+
825
+ function matrixSpan(snapshot) {
826
+ const map = data.linkerMaps && data.linkerMaps[0];
827
+ const dram = map && map.memoryRegions.find((region) => region.name === "dram0_0_seg");
828
+ const ranges = [];
829
+ if (dram) ranges.push(dram);
830
+ if (snapshot) ranges.push(...snapshot.regions.filter(isInternalDramRange));
831
+ ranges.push(...data.bootRegions.filter((region) => region.type === "RAM" || region.type === "DRAM"));
832
+ if (ranges.some(isInternalDramRange)) {
833
+ return {
834
+ start: ESP32_S3_INTERNAL_DRAM_START,
835
+ end: ESP32_S3_INTERNAL_DRAM_END,
836
+ label: "ESP32-S3 internal DRAM window",
837
+ };
838
+ }
839
+ if (ranges.length === 0) return null;
840
+ const rawStart = Math.min(...ranges.map((region) => region.start));
841
+ const rawEnd = Math.max(...ranges.map((region) => region.end));
842
+ const alignment = 64 * 1024;
843
+ return {
844
+ start: Math.floor(rawStart / alignment) * alignment,
845
+ end: Math.ceil(rawEnd / alignment) * alignment,
846
+ label: dram ? "internal DRAM span" : "captured internal RAM span",
847
+ };
848
+ }
849
+
850
+ function matrixIntervals(snapshot, span) {
851
+ const intervals = [];
852
+ const map = data.linkerMaps && data.linkerMaps[0];
853
+ const dram = map && map.memoryRegions.find((region) => region.name === "dram0_0_seg");
854
+ if (map && dram) {
855
+ for (const index of dram.sectionIndexes || []) {
856
+ const section = map.sections[index];
857
+ if (!section || section.size <= 0) continue;
858
+ intervals.push({ type: "static", start: section.start, end: section.end, name: section.name, ref: section.ref });
859
+ }
860
+ }
861
+ if (snapshot) {
862
+ for (const region of snapshot.regions) {
863
+ for (const segment of regionSegments(snapshot, region)) {
864
+ intervals.push({
865
+ type: segment.type === "free" ? "free" : "used",
866
+ start: segment.start,
867
+ end: segment.end,
868
+ name: segment.type === "free" ? "heap free" : "heap used",
869
+ ref: segment.ref || region.ref,
870
+ });
871
+ }
872
+ for (const live of liveForRegion(snapshot, region)) {
873
+ intervals.push({ type: "live", start: live.start, end: live.end, name: live.label, ref: live.ref });
874
+ }
875
+ }
876
+ for (const introduced of introducedUsedSpans(snapshot)) {
877
+ intervals.push({
878
+ type: "newUsed",
879
+ start: introduced.start,
880
+ end: introduced.end,
881
+ name: "new heap used since " + introduced.baselineStage,
882
+ ref: introduced.regionRef,
883
+ });
884
+ }
885
+ }
886
+ return intervals.filter((interval) => interval.end > span.start && interval.start < span.end);
887
+ }
888
+
889
+ function matrixStateForCell(start, end, intervals) {
890
+ const coverage = { static: 0, used: 0, newUsed: 0, free: 0, live: 0 };
891
+ const names = [];
892
+ for (const interval of intervals) {
893
+ const bytes = overlapBytes(start, end, interval);
894
+ if (bytes <= 0) continue;
895
+ coverage[interval.type] += bytes;
896
+ names.push(interval.name + " " + rangeText(interval.start, interval.end) + " " + fmtBytes(interval.end - interval.start) + (interval.ref ? " " + interval.ref : ""));
897
+ }
898
+ const present = Object.entries(coverage).filter(([, bytes]) => bytes > 0).map(([type]) => type);
899
+ const type = ["live", "newUsed", "free", "used", "static"].find((candidate) => coverage[candidate] > 0) || "gap";
900
+ return {
901
+ type,
902
+ mixed: present.length > 1,
903
+ title: rangeText(start, end) + " " + fmtBytes(end - start) + (names.length ? "\\n" + names.join("\\n") : "\\nunclaimed gap"),
904
+ };
905
+ }
906
+
907
+ function renderAddressMatrix(snapshot) {
908
+ const span = matrixSpan(snapshot);
909
+ if (!span) {
910
+ addressMatrixEl.innerHTML = '<div class="empty">No contiguous address span is available.</div>';
911
+ return;
912
+ }
913
+ const cellBytes = Number.parseInt(matrixCellSizeSelect.value, 10) || 4096;
914
+ const cols = 64;
915
+ const cellCount = Math.ceil((span.end - span.start) / cellBytes);
916
+ const intervals = matrixIntervals(snapshot, span);
917
+ const rows = [];
918
+ for (let rowStart = 0; rowStart < cellCount; rowStart += cols) {
919
+ const rowAddress = span.start + rowStart * cellBytes;
920
+ const cells = [];
921
+ for (let col = 0; col < cols && rowStart + col < cellCount; col += 1) {
922
+ const cellStart = span.start + (rowStart + col) * cellBytes;
923
+ const cellEnd = Math.min(cellStart + cellBytes, span.end);
924
+ const state = matrixStateForCell(cellStart, cellEnd, intervals);
925
+ cells.push('<div class="matrix-cell matrix-' + state.type + (state.mixed ? ' matrix-mixed' : '') + '" title="' + esc(state.title) + '"></div>');
926
+ }
927
+ rows.push('<div class="matrix-row"><div class="matrix-address">' + esc(hex(rowAddress)) + '</div><div class="matrix-cells">' + cells.join("") + '</div></div>');
928
+ }
929
+ const staticBytes = intervals.filter((interval) => interval.type === "static").reduce((sum, interval) => sum + Math.max(0, Math.min(span.end, interval.end) - Math.max(span.start, interval.start)), 0);
930
+ const freeBytes = intervals.filter((interval) => interval.type === "free").reduce((sum, interval) => sum + Math.max(0, Math.min(span.end, interval.end) - Math.max(span.start, interval.start)), 0);
931
+ const liveBytes = intervals.filter((interval) => interval.type === "live").reduce((sum, interval) => sum + Math.max(0, Math.min(span.end, interval.end) - Math.max(span.start, interval.start)), 0);
932
+ addressMatrixEl.innerHTML =
933
+ '<div class="matrix-meta">' +
934
+ '<span>Span <span class="mono">' + esc(span.label) + '</span> ' + esc(rangeText(span.start, span.end)) + '</span>' +
935
+ '<span>' + esc(fmtBytes(span.end - span.start)) + ' total</span>' +
936
+ '<span>' + esc(fmtBytes(cellBytes)) + ' per cell</span>' +
937
+ '<span>static ' + esc(fmtBytes(staticBytes)) + '</span>' +
938
+ '<span>free ' + esc(fmtBytes(freeBytes)) + '</span>' +
939
+ '<span>live ' + esc(fmtBytes(liveBytes)) + '</span>' +
940
+ '</div>' +
941
+ '<div class="matrix-wrap" style="--matrix-cols:' + cols + '">' + rows.join("") + '</div>';
942
+ }
943
+
944
+ function renderLinkerMap() {
945
+ if (!data.linkerMaps || data.linkerMaps.length === 0) {
946
+ linkerMapEl.innerHTML = '<div class="empty">No linker map supplied. Re-run with <span class="mono">--map path/to/gea_embedded.map</span> to add static sections.</div>';
947
+ return;
948
+ }
949
+ linkerMapEl.innerHTML = data.linkerMaps.map((map) => {
950
+ const internalRegions = map.memoryRegions.filter((region) => /dram|iram|rtc|extern/i.test(region.name));
951
+ const regionRows = internalRegions.map((region) => {
952
+ const segments = linkerRegionSegments(map, region).map((segment) => {
953
+ const left = ((segment.start - region.start) / Math.max(region.size, 1)) * 100;
954
+ const width = Math.max((segment.size / Math.max(region.size, 1)) * 100, 0.16);
955
+ const title = segment.name + " " + rangeText(segment.start, segment.end) + " " + fmtBytes(segment.size) + (segment.ref ? " " + segment.ref : "");
956
+ return '<div class="segment ' + segment.type + '" title="' + esc(title) + '" style="left:' + left.toFixed(4) + '%;width:' + width.toFixed(4) + '%"></div>';
957
+ }).join("");
958
+ const markers = map.sections.filter((section) => section.memoryRegion === region.name && sectionKind(section) === "marker").map((section) => {
959
+ const left = ((section.start - region.start) / Math.max(region.size, 1)) * 100;
960
+ return '<div class="heap-marker" title="' + esc(section.name + " " + hex(section.start) + " " + section.ref) + '" style="left:' + left.toFixed(4) + '%"></div>';
961
+ }).join("");
962
+ const staticBytes = map.sections
963
+ .filter((section) => section.memoryRegion === region.name && section.size > 0)
964
+ .reduce((sum, section) => sum + section.size, 0);
965
+ return '<div class="region-row">' +
966
+ '<div class="region-label"><strong>' + esc(region.name) + '</strong><span class="mono">' + esc(rangeText(region.start, region.end)) + '</span></div>' +
967
+ '<div class="region-bar">' + segments + markers + '</div>' +
968
+ '<div class="region-stat">' + esc(fmtBytes(staticBytes)) + ' static<br>' + esc(fmtBytes(Math.max(region.size - staticBytes, 0))) + ' gap</div>' +
969
+ '</div>';
970
+ }).join("");
971
+ const topSections = [...map.sections]
972
+ .filter((section) => section.memoryRegion && (section.size > 0 || section.name.includes("heap_start")))
973
+ .sort((a, b) => b.size - a.size)
974
+ .slice(0, 24)
975
+ .map((section) => '<tr>' +
976
+ cell(section.name) +
977
+ cell(section.memoryRegion) +
978
+ cell(section.size === 0 ? hex(section.start) : rangeText(section.start, section.end), "mono") +
979
+ cell(fmtBytes(section.size)) +
980
+ cell(section.ref, "small") +
981
+ '</tr>');
982
+ return '<div class="small" style="margin-bottom:10px">' + esc(map.label) + '</div>' +
983
+ '<div class="memory-map" style="margin-bottom:14px">' + regionRows + '</div>' +
984
+ '<div class="scroll-table">' + table(["Section", "Memory", "Range", "Size", "Source"], topSections) + '</div>';
985
+ }).join("");
986
+ }
987
+
988
+ function renderSummary(snapshot) {
989
+ if (!snapshot) {
990
+ summaryEl.innerHTML = '<div class="empty">No heap-map snapshots found.</div>';
991
+ return;
992
+ }
993
+ const regionBytes = snapshot.regions.reduce((sum, region) => sum + region.size, 0);
994
+ const used = snapshot.regions.reduce((sum, region) => sum + region.used, 0);
995
+ const cards = [
996
+ ["Stage", snapshot.stage, snapshot.ref],
997
+ ["Caps", snapshot.caps, snapshot.fileLabel],
998
+ ["Total free", fmtBytes(snapshot.totalFree), snapshot.freeBlockCount + " free blocks"],
999
+ ["Largest hole", fmtBytes(snapshot.largestFree), "min ever " + fmtBytes(snapshot.minFree)],
1000
+ ["Used in regions", fmtBytes(used), snapshot.allocBlockCount + " allocated blocks"],
1001
+ ["Mapped region bytes", fmtBytes(regionBytes), snapshot.regions.length + " heap regions"],
1002
+ ];
1003
+ summaryEl.innerHTML = cards.map(([label, value, sub]) => '<div class="metric"><div class="label">' + esc(label) + '</div><div class="value">' + esc(value) + '</div><div class="sub">' + esc(sub) + '</div></div>').join("");
1004
+ }
1005
+
1006
+ function regionSegments(snapshot, region) {
1007
+ const blocks = (region.freeBlockIndexes || []).map((index) => snapshot.freeBlocks[index]).sort((a, b) => a.start - b.start);
1008
+ const segments = [];
1009
+ let cursor = region.start;
1010
+ for (const block of blocks) {
1011
+ if (block.start > cursor) {
1012
+ segments.push({ type: "used", start: cursor, end: Math.min(block.start, region.end), size: Math.min(block.start, region.end) - cursor });
1013
+ }
1014
+ segments.push({ type: "free", start: block.start, end: block.end, size: block.size, ref: block.ref });
1015
+ cursor = Math.max(cursor, block.end);
1016
+ }
1017
+ if (cursor < region.end) {
1018
+ segments.push({ type: "used", start: cursor, end: region.end, size: region.end - cursor });
1019
+ }
1020
+ return segments.filter((segment) => segment.size > 0);
1021
+ }
1022
+
1023
+ function liveForRegion(snapshot, region) {
1024
+ const byStart = new Map();
1025
+ for (const allocation of data.liveAllocations) {
1026
+ if (!snapshot || allocation.file !== snapshot.file || allocation.line > snapshot.line) continue;
1027
+ if (allocation.start < region.start || allocation.end > region.end) continue;
1028
+ const existing = byStart.get(allocation.start);
1029
+ if (!existing || allocation.line > existing.line) byStart.set(allocation.start, allocation);
1030
+ }
1031
+ return [...byStart.values()].sort((a, b) => a.start - b.start);
1032
+ }
1033
+
1034
+ function renderMemoryMap(snapshot) {
1035
+ if (!snapshot || snapshot.regions.length === 0) {
1036
+ memoryMapEl.innerHTML = '<div class="empty">No heap regions in this snapshot.</div>';
1037
+ return;
1038
+ }
1039
+ memoryMapEl.innerHTML = snapshot.regions.map((region) => {
1040
+ const segments = regionSegments(snapshot, region).map((segment) => {
1041
+ const left = ((segment.start - region.start) / region.size) * 100;
1042
+ const width = Math.max((segment.size / region.size) * 100, 0.18);
1043
+ const title = segment.type + " " + rangeText(segment.start, segment.end) + " " + fmtBytes(segment.size) + (segment.ref ? " " + segment.ref : "");
1044
+ return '<div class="segment ' + segment.type + '" title="' + esc(title) + '" style="left:' + left.toFixed(4) + '%;width:' + width.toFixed(4) + '%"></div>';
1045
+ }).join("");
1046
+ const introduced = introducedUsedSpans(snapshot).filter((span) => span.heap === region.heap && span.start >= region.start && span.end <= region.end).map((span) => {
1047
+ const left = ((span.start - region.start) / region.size) * 100;
1048
+ const width = Math.max((span.size / region.size) * 100, 0.24);
1049
+ const event = matchingFlushPoolEvent(span.size);
1050
+ const label = "new heap used since " + span.baselineStage + (event ? " - matches " + event.label + " (" + fmtBytes(event.bytes) + ")" : "");
1051
+ const title = label + " " + rangeText(span.start, span.end) + " " + fmtBytes(span.size) + (event ? " " + event.ref : "");
1052
+ return '<div class="segment new-used" title="' + esc(title) + '" style="left:' + left.toFixed(4) + '%;width:' + width.toFixed(4) + '%"></div>';
1053
+ }).join("");
1054
+ const live = liveForRegion(snapshot, region).map((allocation) => {
1055
+ const left = ((allocation.start - region.start) / region.size) * 100;
1056
+ const width = Math.max((allocation.size / region.size) * 100, 0.24);
1057
+ const title = allocation.label + " " + hex(allocation.start) + " " + fmtBytes(allocation.size) + " " + allocation.ref;
1058
+ return '<div class="live-overlay" title="' + esc(title) + '" style="left:' + left.toFixed(4) + '%;width:' + width.toFixed(4) + '%"></div>';
1059
+ }).join("");
1060
+ return '<div class="region-row">' +
1061
+ '<div class="region-label"><strong>heap ' + esc(region.heap) + '</strong><span class="mono">' + esc(rangeText(region.start, region.end)) + '</span></div>' +
1062
+ '<div class="region-bar">' + segments + introduced + live + '</div>' +
1063
+ '<div class="region-stat">' + esc(fmtBytes(region.free)) + ' free<br>' + esc(fmtBytes(region.used)) + ' used</div>' +
1064
+ '</div>';
1065
+ }).join("");
1066
+ }
1067
+
1068
+ function renderFreeBlocks(snapshot) {
1069
+ if (!snapshot) {
1070
+ freeBlocksEl.innerHTML = '<div class="empty">No selected snapshot.</div>';
1071
+ return;
1072
+ }
1073
+ const largest = Math.max(...snapshot.freeBlocks.map((block) => block.size), 1);
1074
+ const rows = snapshot.freeBlocks.map((block) => {
1075
+ const pct = ((block.size / largest) * 100).toFixed(1) + "%";
1076
+ return '<tr>' + cell(block.heap) + cell(hex(block.start), "mono") + cell(hex(Math.max(block.start, block.end - 1)), "mono") + cell(fmtBytes(block.size)) + cell(pct) + cell(block.ref, "small") + '</tr>';
1077
+ });
1078
+ freeBlocksEl.innerHTML = table(["Heap", "Start", "End", "Size", "Of largest", "Source"], rows);
1079
+ }
1080
+
1081
+ function renderRegionsTable(snapshot) {
1082
+ if (!snapshot) {
1083
+ regionsTableEl.innerHTML = '<div class="empty">No selected snapshot.</div>';
1084
+ return;
1085
+ }
1086
+ const rows = snapshot.regions.map((region) => '<tr>' +
1087
+ cell(region.heap) +
1088
+ cell(rangeText(region.start, region.end), "mono") +
1089
+ cell(fmtBytes(region.size)) +
1090
+ cell(fmtBytes(region.free)) +
1091
+ cell(fmtBytes(region.used)) +
1092
+ cell(fmtBytes(region.largestFree)) +
1093
+ cell(region.freeBlocks + " / " + region.usedBlocks) +
1094
+ '</tr>');
1095
+ regionsTableEl.innerHTML = table(["Heap", "Range", "Region bytes", "Free", "Used", "Largest free", "Free / used blocks"], rows);
1096
+ }
1097
+
1098
+ function renderUsedSpans(snapshot) {
1099
+ if (!snapshot) {
1100
+ usedSpansEl.innerHTML = '<div class="empty">No selected snapshot.</div>';
1101
+ return;
1102
+ }
1103
+ const rows = usedSpansForSnapshot(snapshot)
1104
+ .filter((span) => span.size > 16)
1105
+ .map((span) => {
1106
+ const introducedParts = introducedPartsForSpan(snapshot, span);
1107
+ const introducedBytes = introducedParts.reduce((sum, part) => sum + overlapBytes(span.start, span.end, part), 0);
1108
+ const introducedText = introducedParts.length
1109
+ ? introducedParts.map((part) => rangeText(part.start, part.end) + " " + fmtBytes(part.size)).join("\\n")
1110
+ : "-";
1111
+ const event = matchingFlushPoolEvent(introducedBytes);
1112
+ const note = event
1113
+ ? "Contains newly used bytes matching " + event.label + " (" + fmtBytes(event.bytes) + ") at " + event.ref
1114
+ : (introducedBytes > 0 ? "Newly used since " + introducedParts[0].baselineStage : "Already used in first snapshot for " + snapshot.caps);
1115
+ return '<tr>' +
1116
+ cell(span.heap) +
1117
+ cell(rangeText(span.start, span.end), "mono") +
1118
+ cell(fmtBytes(span.size)) +
1119
+ '<td class="mono">' + esc(introducedText).replace(/\\n/g, "<br>") + '</td>' +
1120
+ cell(fmtBytes(introducedBytes)) +
1121
+ cell(note, "small") +
1122
+ cell(span.regionRef, "small") +
1123
+ '</tr>';
1124
+ });
1125
+ usedSpansEl.innerHTML = table(["Heap", "Used range", "Used size", "Introduced range", "Introduced bytes", "What changed", "Source"], rows);
1126
+ }
1127
+
1128
+ function renderLiveAllocations(snapshot) {
1129
+ const rows = data.liveAllocations.map((allocation) => {
1130
+ const inSelected = snapshot && snapshot.regions.some((region) => liveForRegion(snapshot, region).some((live) => live.id === allocation.id));
1131
+ return '<tr>' +
1132
+ cell(allocation.label + (inSelected ? " (in selected map)" : "")) +
1133
+ cell(hex(allocation.start), "mono") +
1134
+ cell(fmtBytes(allocation.size)) +
1135
+ cell(allocation.scope) +
1136
+ cell(allocation.ref, "small") +
1137
+ '<td><div class="stack">' + esc(allocation.stack.slice(0, 5).join("\\n")) + '</div></td>' +
1138
+ '</tr>';
1139
+ });
1140
+ liveAllocationsEl.innerHTML = table(["Label", "Pointer", "Size", "Scope", "Source", "Stack"], rows);
1141
+ }
1142
+
1143
+ function renderProbeTimeline() {
1144
+ const rows = data.probes.map((probe) => {
1145
+ const values = probe.values;
1146
+ return '<tr>' +
1147
+ cell(probe.timeMs == null ? "-" : probe.timeMs + " ms") +
1148
+ cell(probe.stage) +
1149
+ cell(fmtBytes(values.internal_free)) +
1150
+ cell(fmtBytes(values.internal_largest)) +
1151
+ cell(fmtBytes(values.internal_min)) +
1152
+ cell(fmtBytes(values.psram_free)) +
1153
+ cell(probe.ref, "small") +
1154
+ '</tr>';
1155
+ });
1156
+ probeTimelineEl.innerHTML = table(["Time", "Stage", "Internal free", "Largest", "Min", "PSRAM free", "Source"], rows);
1157
+ }
1158
+
1159
+ function polyline(points, key, minValue, maxValue, width, height, padX, padY) {
1160
+ if (points.length === 0) return "";
1161
+ const span = Math.max(maxValue - minValue, 1);
1162
+ return points.map((point, index) => {
1163
+ const x = padX + (points.length === 1 ? width / 2 : (index / (points.length - 1)) * width);
1164
+ const y = padY + height - ((point[key] - minValue) / span) * height;
1165
+ return x.toFixed(2) + "," + y.toFixed(2);
1166
+ }).join(" ");
1167
+ }
1168
+
1169
+ function renderTimeline(caps, selectedId) {
1170
+ const snapshots = snapshotsForCaps(caps);
1171
+ if (snapshots.length === 0) {
1172
+ timelineEl.innerHTML = "";
1173
+ return;
1174
+ }
1175
+ const width = 840;
1176
+ const height = 150;
1177
+ const padX = 58;
1178
+ const padY = 24;
1179
+ const values = snapshots.flatMap((snapshot) => [snapshot.totalFree, snapshot.largestFree, snapshot.minFree].filter((v) => v != null));
1180
+ const minValue = Math.min(...values, 0);
1181
+ const maxValue = Math.max(...values, 1);
1182
+ const grid = [0, 0.25, 0.5, 0.75, 1].map((ratio) => {
1183
+ const y = padY + height - ratio * height;
1184
+ const value = minValue + (maxValue - minValue) * ratio;
1185
+ return '<line x1="' + padX + '" y1="' + y + '" x2="' + (padX + width) + '" y2="' + y + '" stroke="rgba(255,255,255,0.08)"/><text x="8" y="' + (y + 4) + '" fill="#a2adb5" font-size="11">' + esc(fmtBytes(value)) + '</text>';
1186
+ }).join("");
1187
+ const totalPoints = polyline(snapshots, "totalFree", minValue, maxValue, width, height, padX, padY);
1188
+ const largestPoints = polyline(snapshots, "largestFree", minValue, maxValue, width, height, padX, padY);
1189
+ const minPoints = polyline(snapshots, "minFree", minValue, maxValue, width, height, padX, padY);
1190
+ const dots = snapshots.map((snapshot, index) => {
1191
+ const x = padX + (snapshots.length === 1 ? width / 2 : (index / (snapshots.length - 1)) * width);
1192
+ const y = padY + height - ((snapshot.totalFree - minValue) / Math.max(maxValue - minValue, 1)) * height;
1193
+ const selected = snapshot.id === selectedId;
1194
+ return '<circle class="timeline-point" data-id="' + snapshot.id + '" cx="' + x.toFixed(2) + '" cy="' + y.toFixed(2) + '" r="' + (selected ? 5 : 3.5) + '" fill="' + (selected ? '#e8eef2' : '#46bf86') + '" stroke="#11171b" stroke-width="2"><title>' + esc(snapshot.stage + " " + fmtBytes(snapshot.totalFree) + " at " + snapshot.ref) + '</title></circle>';
1195
+ }).join("");
1196
+ timelineEl.innerHTML =
1197
+ grid +
1198
+ '<polyline points="' + esc(minPoints) + '" fill="none" stroke="#844b3e" stroke-width="2" opacity="0.85"/>' +
1199
+ '<polyline points="' + esc(largestPoints) + '" fill="none" stroke="#5bc4d8" stroke-width="3"/>' +
1200
+ '<polyline points="' + esc(totalPoints) + '" fill="none" stroke="#46bf86" stroke-width="3"/>' +
1201
+ dots +
1202
+ '<text x="' + padX + '" y="200" fill="#a2adb5" font-size="11">' + esc(snapshots[0].stage) + '</text>' +
1203
+ '<text x="' + (padX + width) + '" y="200" text-anchor="end" fill="#a2adb5" font-size="11">' + esc(snapshots[snapshots.length - 1].stage) + '</text>';
1204
+ timelineEl.querySelectorAll(".timeline-point").forEach((point) => {
1205
+ point.addEventListener("click", () => {
1206
+ snapshotSelect.value = point.getAttribute("data-id");
1207
+ render();
1208
+ });
1209
+ });
1210
+ }
1211
+
1212
+ function populateControls(keepSelected = true) {
1213
+ const currentCaps = capsSelect.value;
1214
+ const currentSnapshot = snapshotSelect.value;
1215
+ const caps = [...new Set(data.snapshots.map((snapshot) => snapshot.caps))];
1216
+ capsSelect.innerHTML = caps.map((cap) => '<option value="' + esc(cap) + '">' + esc(cap) + '</option>').join("");
1217
+ if (caps.includes(currentCaps)) capsSelect.value = currentCaps;
1218
+ const snapshots = snapshotsForCaps(capsSelect.value);
1219
+ snapshotSelect.innerHTML = snapshots.map((snapshot) => '<option value="' + snapshot.id + '">' + esc("#" + snapshot.id + " " + snapshot.stage + " - " + snapshot.fileLabel + ":" + snapshot.line) + '</option>').join("");
1220
+ if (keepSelected && snapshots.some((snapshot) => String(snapshot.id) === currentSnapshot)) {
1221
+ snapshotSelect.value = currentSnapshot;
1222
+ } else if (snapshots.length > 0) {
1223
+ snapshotSelect.value = String(snapshots[snapshots.length - 1].id);
1224
+ }
1225
+ }
1226
+
1227
+ function moveSnapshot(delta) {
1228
+ const snapshots = snapshotsForCaps(capsSelect.value);
1229
+ const index = snapshots.findIndex((snapshot) => String(snapshot.id) === snapshotSelect.value);
1230
+ if (index < 0) return;
1231
+ const next = Math.max(0, Math.min(snapshots.length - 1, index + delta));
1232
+ snapshotSelect.value = String(snapshots[next].id);
1233
+ render();
1234
+ }
1235
+
1236
+ function render() {
1237
+ const snapshot = selectedSnapshot();
1238
+ renderSummary(snapshot);
1239
+ renderTimeline(capsSelect.value, snapshot ? snapshot.id : null);
1240
+ renderAddressMatrix(snapshot);
1241
+ renderMemoryMap(snapshot);
1242
+ renderFreeBlocks(snapshot);
1243
+ renderUsedSpans(snapshot);
1244
+ renderRegionsTable(snapshot);
1245
+ renderLiveAllocations(snapshot);
1246
+ }
1247
+
1248
+ reportMetaEl.innerHTML = esc("Generated " + data.generatedAt) + "<br>" + esc(data.inputs.length + " input log(s), " + data.snapshots.length + " snapshot(s), " + data.probes.length + " probe(s)");
1249
+ bootTable();
1250
+ renderLinkerMap();
1251
+ renderProbeTimeline();
1252
+ populateControls(false);
1253
+ render();
1254
+
1255
+ capsSelect.addEventListener("change", () => {
1256
+ populateControls(false);
1257
+ render();
1258
+ });
1259
+ snapshotSelect.addEventListener("change", render);
1260
+ matrixCellSizeSelect.addEventListener("change", () => renderAddressMatrix(selectedSnapshot()));
1261
+ document.getElementById("prevButton").addEventListener("click", () => moveSnapshot(-1));
1262
+ document.getElementById("nextButton").addEventListener("click", () => moveSnapshot(1));
1263
+ document.getElementById("latestButton").addEventListener("click", () => {
1264
+ const snapshots = snapshotsForCaps(capsSelect.value);
1265
+ if (snapshots.length > 0) {
1266
+ snapshotSelect.value = String(snapshots[snapshots.length - 1].id);
1267
+ render();
1268
+ }
1269
+ });
1270
+ </script>
1271
+ </body>
1272
+ </html>
1273
+ `
1274
+ }
1275
+
1276
+ export { parseArgs as parseHeapReportArgs, parseLogs, renderHtml }