pyGdbToolkit 0.1.0__py3-none-any.whl

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,52 @@
1
+ # SPDX-FileCopyrightText: 2026 H2Lab Development Team
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """GDB adapters for architecture-neutral diagnostic runtime contracts."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import gdb
9
+
10
+ from .arch.diagnostics import (
11
+ DiagnosticRegisterReader,
12
+ DiagnosticRuntimeAccess,
13
+ DiagnosticSymbolResolver,
14
+ )
15
+
16
+
17
+ class GdbDiagnosticRegisterReader:
18
+ """Read runtime registers from GDB's selected frame."""
19
+
20
+ def read_first(self, names: tuple[str, ...]) -> int | None:
21
+ """Return the first GDB register exposed under the supplied names."""
22
+ try:
23
+ frame = gdb.selected_frame()
24
+ except gdb.error:
25
+ return None
26
+ for name in names:
27
+ try:
28
+ return int(frame.read_register(name))
29
+ except (gdb.error, ValueError, TypeError):
30
+ continue
31
+ return None
32
+
33
+
34
+ class GdbDiagnosticSymbolResolver:
35
+ """Resolve target addresses through GDB without architecture assumptions."""
36
+
37
+ def resolve(self, address: int) -> str:
38
+ """Return GDB's symbol description or ``"?"`` when unavailable."""
39
+ try:
40
+ output = gdb.execute(f"info symbol 0x{address:X}", to_string=True).strip()
41
+ except (gdb.error, TypeError, ValueError):
42
+ return "?"
43
+ if "No symbol matches" in output or "not in valid memory" in output:
44
+ return "?"
45
+ return output.split(" in section ")[0]
46
+
47
+
48
+ def gdb_diagnostic_access() -> DiagnosticRuntimeAccess:
49
+ """Create portable diagnostic access adapters backed by the selected GDB frame."""
50
+ registers: DiagnosticRegisterReader = GdbDiagnosticRegisterReader()
51
+ symbols: DiagnosticSymbolResolver = GdbDiagnosticSymbolResolver()
52
+ return DiagnosticRuntimeAccess(registers=registers, symbols=symbols)
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 H2Lab Development Team
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """RTOS implementations available to GDB commands."""
5
+
6
+ from . import camelot
7
+
8
+ SUPPORTED_RTOS = {camelot.NAME: camelot}
@@ -0,0 +1,513 @@
1
+ # SPDX-FileCopyrightText: 2026 H2Lab Development Team
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Camelot RTOS project symbol locations."""
5
+
6
+ from dataclasses import dataclass
7
+ import json
8
+ from pathlib import Path
9
+ import struct
10
+ import tomllib
11
+ from typing import Literal
12
+ import xml.etree.ElementTree as ET
13
+
14
+ import gdb
15
+ from rich import box
16
+ from rich.console import Group
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ NAME = "camelot"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TaskSlot:
25
+ """One task metadata slot and its resolved application name."""
26
+
27
+ index: int
28
+ address: int
29
+ task_name: str | None
30
+ text_address: int
31
+ ram_address: int
32
+ metadata: dict[str, int | tuple[int, ...] | str]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TaskList:
37
+ """Location and decoded slots of the kernel's task metadata section."""
38
+
39
+ address: int
40
+ size: int
41
+ entry_size: int
42
+ slots: tuple[TaskSlot, ...]
43
+
44
+
45
+ def task_list_section(kernel_elf: Path) -> tuple[int, bytes]:
46
+ """Read the linked address and contents of the kernel's task metadata section."""
47
+ data = kernel_elf.read_bytes()
48
+ if data[:4] != b"\x7fELF" or data[4] not in (1, 2) or data[5] not in (1, 2):
49
+ raise ValueError(f"Not a supported ELF: {kernel_elf}")
50
+ byte_order = "<" if data[5] == 1 else ">"
51
+ is_64_bit = data[4] == 2
52
+ header_format = byte_order + ("HHIQQQIHHHHHH" if is_64_bit else "HHIIIIIHHHHHH")
53
+ header = struct.unpack_from(header_format, data, 16)
54
+ section_offset, section_size, section_count, names_index = (
55
+ header[5],
56
+ header[10],
57
+ header[11],
58
+ header[12],
59
+ )
60
+ section_format = byte_order + ("IIQQQQIIQQ" if is_64_bit else "IIIIIIIIII")
61
+ if section_size < struct.calcsize(section_format) or names_index >= section_count:
62
+ raise ValueError(f"Invalid ELF section table: {kernel_elf}")
63
+ sections = [
64
+ struct.unpack_from(section_format, data, section_offset + index * section_size)
65
+ for index in range(section_count)
66
+ ]
67
+ names = sections[names_index]
68
+ name_table = data[names[4] : names[4] + names[5]]
69
+ for section in sections:
70
+ name = name_table[section[0] :].split(b"\0", 1)[0]
71
+ if name == b".task_list":
72
+ address, offset, size = section[3:6]
73
+ if offset + size > len(data):
74
+ raise ValueError(f"Truncated .task_list section: {kernel_elf}")
75
+ return address, data[offset : offset + size]
76
+ raise ValueError(f"No .task_list section in {kernel_elf}")
77
+
78
+
79
+ def load_task_list(project_path: Path, kernel_elf: Path) -> TaskList:
80
+ """Decode the task slots using the generated C type's DWARF layout."""
81
+ header = project_path / "output/build/kernel/kernel/include/sentry/managers/task_metadata.h"
82
+ if not header.is_file():
83
+ raise FileNotFoundError(header)
84
+ address, contents = task_list_section(kernel_elf)
85
+ task_type = gdb.lookup_type("task_meta_t")
86
+ entry_size = task_type.sizeof
87
+ if not entry_size or len(contents) % entry_size:
88
+ raise ValueError(".task_list size is not a multiple of task_meta_t")
89
+ fields = {field.name: field for field in task_type.fields()}
90
+ if not {"s_text", "s_svcexchange"} <= fields.keys():
91
+ raise ValueError("task_meta_t is missing task memory addresses")
92
+ byte_order: Literal["little", "big"] = "little" if kernel_elf.read_bytes()[5] == 1 else "big"
93
+
94
+ with (project_path / "output/build/camelot_private/layout.json").open() as layout_file:
95
+ regions = json.load(layout_file)["regions"]
96
+ text_regions = {
97
+ int(region["start_address"], 0): region["name"]
98
+ for region in regions
99
+ if region["type"] == "text" and region["name"] != "kernel"
100
+ }
101
+ ram_regions = {
102
+ region["name"]: int(region["start_address"], 0)
103
+ for region in regions
104
+ if region["type"] == "ram"
105
+ }
106
+
107
+ def field_bytes(entry: bytes, field: gdb.Field) -> bytes:
108
+ if field.bitpos is None or field.type is None or field.name is None:
109
+ raise ValueError("Incomplete task_meta_t field in debug symbols")
110
+ if field.bitpos % 8:
111
+ raise ValueError(f"Unaligned task_meta_t field: {field.name}")
112
+ offset = field.bitpos // 8
113
+ return entry[offset : offset + field.type.sizeof]
114
+
115
+ def field_value(entry: bytes, name: str) -> int:
116
+ return int.from_bytes(field_bytes(entry, fields[name]), byte_order)
117
+
118
+ slots = []
119
+ for index in range(len(contents) // entry_size):
120
+ entry = contents[index * entry_size : (index + 1) * entry_size]
121
+ text_address = field_value(entry, "s_text")
122
+ ram_address = field_value(entry, "s_svcexchange")
123
+ name = text_regions.get(text_address) if any(entry) else None
124
+ if any(entry) and (name is None or ram_regions.get(name) != ram_address):
125
+ raise ValueError(f"Cannot match .task_list slot {index} to a task in layout.json")
126
+ metadata: dict[str, int | tuple[int, ...] | str] = {}
127
+ if name is not None:
128
+ for field in task_type.fields():
129
+ raw = field_bytes(entry, field)
130
+ if field.name is None or field.type is None:
131
+ raise ValueError("Incomplete task_meta_t field in debug symbols")
132
+ if field.name in ("task_hmac", "metadata_hmac"):
133
+ metadata[field.name] = raw.hex()
134
+ elif field.type.code == gdb.TYPE_CODE_ARRAY:
135
+ item_type = field.type.target()
136
+ if item_type is None:
137
+ raise ValueError(f"Unknown element type for {field.name}")
138
+ item_size = item_type.sizeof
139
+ metadata[field.name] = tuple(
140
+ int.from_bytes(raw[offset : offset + item_size], byte_order)
141
+ for offset in range(0, len(raw), item_size)
142
+ )
143
+ else:
144
+ metadata[field.name] = int.from_bytes(raw, byte_order)
145
+ slots.append(
146
+ TaskSlot(index, address + index * entry_size, name, text_address, ram_address, metadata)
147
+ )
148
+ return TaskList(address, len(contents), entry_size, tuple(slots))
149
+
150
+
151
+ def project_elfs(project_path: Path) -> list[Path]:
152
+ """Return the linked kernel and application ELF files declared by a project."""
153
+ with (project_path / "project.toml").open("rb") as project_file:
154
+ project = tomllib.load(project_file)
155
+
156
+ private_build = project_path / "output" / "build" / "camelot_private"
157
+ elfs = []
158
+ if "kernel" in project:
159
+ elfs.append(private_build / "sentry-kernel.elf")
160
+ applications = project.get("application", {})
161
+ if not isinstance(applications, dict):
162
+ raise ValueError("project.toml: application must be a table")
163
+ for name in applications:
164
+ elfs.append(private_build / f"{name}-app.elf")
165
+ if not elfs:
166
+ raise ValueError("project.toml: no kernel or application declared")
167
+ for elf in elfs:
168
+ if not elf.is_file():
169
+ raise FileNotFoundError(elf)
170
+ return elfs
171
+
172
+
173
+ def elected_task(task_list: TaskList, handle: int) -> str:
174
+ """Resolve a live scheduler handle through the kernel task table."""
175
+ task_type = gdb.lookup_type("task_t")
176
+ handle_field = next(field for field in task_type.fields() if field.name == "handle")
177
+ if handle_field.bitpos is None or handle_field.type is None:
178
+ raise ValueError("Incomplete task handle in debug symbols")
179
+ if handle_field.bitpos % 8:
180
+ raise ValueError("Unaligned task handle in task_t")
181
+ table = int(gdb.parse_and_eval("&task_table"))
182
+ inferior = gdb.selected_inferior()
183
+ for index in range(len(task_list.slots) + 1):
184
+ entry = table + index * task_type.sizeof
185
+ stored = int.from_bytes(
186
+ inferior.read_memory(entry + handle_field.bitpos // 8, handle_field.type.sizeof),
187
+ "little",
188
+ )
189
+ if stored != handle:
190
+ continue
191
+ task_address = gdb.Value(entry)
192
+ if task_address is None:
193
+ raise ValueError("Cannot resolve task table address")
194
+ task = task_address.cast(task_type.pointer()).dereference()
195
+ label = int(task["metadata"].dereference()["label"])
196
+ if label == 0xCAFE:
197
+ return "idle"
198
+ for slot in task_list.slots:
199
+ if slot.task_name is not None and slot.metadata["label"] == label:
200
+ return slot.task_name
201
+ return f"unknown (label 0x{label:X})"
202
+ return f"unknown (handle 0x{handle:X})"
203
+
204
+
205
+ def scheduling_chart(elections: list[str]) -> Group:
206
+ """Render chronological election windows without exceeding terminal width."""
207
+ charts = []
208
+ for start in range(0, len(elections), 8):
209
+ window = elections[start : start + 8]
210
+ chart = Table(
211
+ title=f"Task scheduling: elections {start + 1}-{start + len(window)}",
212
+ box=box.SIMPLE_HEAVY,
213
+ header_style="bold cyan",
214
+ )
215
+ chart.add_column("Task", style="bold", no_wrap=True)
216
+ for index in range(start, start + len(window)):
217
+ chart.add_column(str(index + 1), justify="center", no_wrap=True)
218
+ for task in dict.fromkeys(elections):
219
+ chart.add_row(task, *("●" if elected == task else "·" for elected in window))
220
+ charts.append(chart)
221
+ return Group(*charts)
222
+
223
+
224
+ def _interrupt_names() -> dict[int, str]:
225
+ """Resolve IRQ numbers from the SVD currently loaded by the svd command."""
226
+ from ..cmd_svd import SESSION
227
+ from ..svd import _parse_int
228
+
229
+ if SESSION.svd_path is None:
230
+ return {}
231
+ root = ET.parse(SESSION.svd_path).getroot()
232
+ names: dict[int, str] = {}
233
+ for peripheral in root.iter():
234
+ if peripheral.tag.rsplit("}", 1)[-1] != "peripheral":
235
+ continue
236
+ for interrupt in peripheral:
237
+ if interrupt.tag.rsplit("}", 1)[-1] != "interrupt":
238
+ continue
239
+ children = {child.tag.rsplit("}", 1)[-1]: child.text for child in interrupt}
240
+ name = children.get("name")
241
+ value = children.get("value")
242
+ if name and value:
243
+ names.setdefault(_parse_int(value), name)
244
+ return names
245
+
246
+
247
+ def _task_symbol(address: int, text_start: int, text_end: int) -> str | None:
248
+ """Resolve a Thumb address only within the selected task's text range."""
249
+ code_address = address & ~1
250
+ if not text_start <= code_address < text_end:
251
+ return None
252
+ try:
253
+ symbol = gdb.execute(f"info symbol 0x{code_address:X}", to_string=True).strip()
254
+ except gdb.error:
255
+ return None
256
+ if symbol.startswith("No symbol matches"):
257
+ return None
258
+ return symbol.split(" in section ", 1)[0]
259
+
260
+
261
+ def show_task(project_path: Path, task_list: TaskList, task_name: str) -> Group:
262
+ """Inspect a named task's live kernel context on a stopped target."""
263
+ layout_path = project_path / "output/build/camelot_private/layout.json"
264
+ with layout_path.open(encoding="utf-8") as layout_file:
265
+ regions = [r for r in json.load(layout_file)["regions"] if r["name"] == task_name]
266
+ if not regions:
267
+ raise ValueError(f"Unknown task: {task_name}")
268
+ slot = next((s for s in task_list.slots if s.task_name == task_name), None)
269
+ task_type = gdb.lookup_type("task_t")
270
+ table_address = int(gdb.parse_and_eval("&task_table"))
271
+ source_names: dict[int, str] = {}
272
+ selected = None
273
+ for index in range(len(task_list.slots) + 1):
274
+ task_address = gdb.Value(table_address + index * task_type.sizeof)
275
+ if task_address is None:
276
+ raise ValueError("Cannot resolve task table address")
277
+ task = task_address.cast(task_type.pointer()).dereference()
278
+ metadata_address = int(task["metadata"])
279
+ if not metadata_address:
280
+ continue
281
+ label = int(task["metadata"].dereference()["label"])
282
+ source_name = next(
283
+ (
284
+ candidate.task_name
285
+ for candidate in task_list.slots
286
+ if candidate.task_name is not None and candidate.metadata.get("label") == label
287
+ ),
288
+ "idle" if label == 0xCAFE else f"label 0x{label:X}",
289
+ )
290
+ source_names[index] = source_name
291
+ if (slot is not None and metadata_address == slot.address) or (
292
+ task_name == "idle" and label == 0xCAFE
293
+ ):
294
+ selected = task
295
+ if selected is None:
296
+ raise ValueError(f"Task {task_name} is not initialized in task_table")
297
+
298
+ def table(title: str, columns: tuple[str, ...]) -> Table:
299
+ result = Table(title=title, box=box.SIMPLE_HEAVY, header_style="bold cyan")
300
+ for column in columns:
301
+ result.add_column(column)
302
+ return result
303
+
304
+ mapping = table(f"Task: {task_name}", ("Region", "Address range", "Access"))
305
+ ram_start = ram_end = 0
306
+ text_start = text_end = 0
307
+ for region in regions:
308
+ start = int(region["start_address"], 0)
309
+ end = start + int(region["size"], 0)
310
+ if region["type"] == "ram":
311
+ ram_start, ram_end = start, end
312
+ if region["type"] == "text":
313
+ text_start, text_end = start, end
314
+ permission = int(region["permission"])
315
+ access = "".join(
316
+ flag if permission & bit else "-" for bit, flag in ((1, "R"), (2, "W"), (4, "X"))
317
+ )
318
+ mapping.add_row(region["type"], f"0x{start:08X}-0x{end:08X}", access)
319
+
320
+ state_number = int(selected["state"])
321
+ state = next(
322
+ (
323
+ field.name
324
+ for field in gdb.lookup_type("job_state_t").fields()
325
+ if field.enumval == state_number
326
+ ),
327
+ f"unknown ({state_number})",
328
+ )
329
+ context = table("Kernel context", ("Field", "Value"))
330
+ context.add_row("State", state)
331
+ handle_address = selected["handle"].address
332
+ if handle_address is None:
333
+ raise ValueError("Cannot resolve task handle address")
334
+ context.add_row(
335
+ "Handle",
336
+ f"0x{int(handle_address.cast(gdb.lookup_type('uint32_t').pointer()).dereference()):08X}",
337
+ )
338
+ saved_sp = int(selected["sp"])
339
+ context.add_row("Saved SP", f"0x{saved_sp:08X}")
340
+
341
+ events = table("Pending events", ("Type", "Source", "Value"))
342
+ for field_name in ("ipcs", "sigs"):
343
+ for index in range(
344
+ selected[field_name].type.sizeof // selected[field_name].type.target().sizeof
345
+ ):
346
+ value = int(selected[field_name][index])
347
+ if value:
348
+ events.add_row(
349
+ "IPC" if field_name == "ipcs" else "Signal",
350
+ source_names.get(index, f"task #{index}"),
351
+ str(value),
352
+ )
353
+ irq_names = _interrupt_names()
354
+ fields = {field.name for field in task_type.fields()}
355
+
356
+ def queued(field: str, head: str, bottom: str) -> list[gdb.Value]:
357
+ values = selected[field]
358
+ length = values.type.sizeof // values.type.target().sizeof
359
+ cursor, stop = int(selected[bottom]), int(selected[head])
360
+ if cursor >= length or stop >= length:
361
+ raise ValueError(f"Invalid {field} queue indices")
362
+ items = []
363
+ while cursor != stop:
364
+ items.append(values[cursor])
365
+ cursor = (cursor + 1) % length
366
+ return items
367
+
368
+ for event in queued("ints", "ints_head", "ints_bottom"):
369
+ number = int(event)
370
+ events.add_row("IRQ", str(number), irq_names.get(number, "unresolved"))
371
+ if {"dmas", "dmas_head", "dmas_bottom"} <= fields:
372
+ for event in queued("dmas", "dmas_head", "dmas_bottom"):
373
+ events.add_row("DMA", f"0x{int(event['handle']):X}", str(event["event"]))
374
+ if not events.rows:
375
+ events.add_row("None", "", "")
376
+
377
+ stack = table("Stack", ("Measure", "Value"))
378
+ metadata = selected["metadata"].dereference()
379
+ stack_size = int(metadata["stack_size"])
380
+ config_paths = list(
381
+ (project_path / "output/build/kernel/subprojects").glob("kconfig-*/generated_kconfig.json")
382
+ )
383
+ if len(config_paths) != 1:
384
+ raise ValueError("Expected one generated kernel Kconfig file")
385
+ config_path = config_paths[0]
386
+ with config_path.open(encoding="utf-8") as config_file:
387
+ config = json.load(config_file)
388
+ svc_size = int(config["CONFIG_SVC_EXCHANGE_AREA_LEN"])
389
+ stack_bottom = (
390
+ ram_start
391
+ + svc_size
392
+ + sum(
393
+ (int(metadata[field]) + 3) & ~3
394
+ for field in ("got_size", "data_size", "bss_size", "heap_size")
395
+ )
396
+ )
397
+ stack_top = stack_bottom + ((stack_size + 3) & ~3)
398
+ pc = int(gdb.selected_frame().pc())
399
+ current = text_start <= pc < text_end
400
+ stack_pointer = int(gdb.parse_and_eval("$psp")) if current else saved_sp
401
+ if not ram_start or stack_top > ram_end or not stack_bottom <= stack_pointer <= stack_top:
402
+ stack.add_row("Usage", "Unavailable (saved SP outside stack bounds)")
403
+ else:
404
+ used = stack_top - stack_pointer
405
+ stack.add_row("Bounds", f"0x{stack_bottom:08X}-0x{stack_top:08X}")
406
+ stack.add_row("SP", f"0x{stack_pointer:08X} ({'live PSP' if current else 'saved'})")
407
+ stack.add_row("Usage", f"{used}/{stack_size} bytes ({used / stack_size:.1%})")
408
+
409
+ frames = table("Backtrace", ("Frame", "Location"))
410
+ # GDB can unwind the running task only when it is stopped in its own code.
411
+ if current:
412
+ for line in gdb.execute("bt", to_string=True).splitlines():
413
+ frames.add_row("GDB", line)
414
+ elif stack_bottom <= saved_sp < stack_top and task_name != "idle":
415
+ frame = selected["sp"].dereference()
416
+ for field in ("pc", "prev_lr", "lr"):
417
+ address = int(frame[field])
418
+ symbol = _task_symbol(address, text_start, text_end)
419
+ frames.add_row(
420
+ f"saved {field}", f"0x{address:08X} {symbol}" if symbol else f"0x{address:08X}"
421
+ )
422
+ frames.add_row(
423
+ "Note", "Full unwinding of an inactive task requires its register context in GDB"
424
+ )
425
+ else:
426
+ frames.add_row("Note", "No saved frame available for this task")
427
+ return Group(mapping, context, stack, events, frames)
428
+
429
+
430
+ def show_project(project_path: Path, task_list: TaskList) -> Group:
431
+ """Render the kernel, task mappings and decoded build-time metadata."""
432
+ layout_path = project_path / "output" / "build" / "camelot_private" / "layout.json"
433
+ with layout_path.open(encoding="utf-8") as layout_file:
434
+ layout = json.load(layout_file)
435
+ regions = layout["regions"]
436
+ if not isinstance(regions, list):
437
+ raise ValueError("layout.json: regions must be a list")
438
+
439
+ owners: dict[str, list[tuple[str, str, str]]] = {}
440
+ for region in regions:
441
+ name = region["name"]
442
+ start = int(region["start_address"], 0)
443
+ size = int(region["size"], 0)
444
+ permission = int(region["permission"])
445
+ access = "".join(
446
+ flag if permission & bit else "-" for bit, flag in ((1, "R"), (2, "W"), (4, "X"))
447
+ )
448
+ owners.setdefault(name, []).append(
449
+ (region["type"], f"0x{start:08X}-0x{start + size:08X}", access)
450
+ )
451
+
452
+ if "kernel" not in owners:
453
+ raise ValueError("layout.json: kernel mapping is missing")
454
+
455
+ def mapping_table(title: str, mapping: list[tuple[str, str, str]]) -> Table:
456
+ table = Table(title=Text(title), box=box.SIMPLE_HEAVY, header_style="bold cyan")
457
+ table.add_column("Region", style="bold")
458
+ table.add_column("Address range")
459
+ table.add_column("Access")
460
+ for region_type, address_range, access in mapping:
461
+ table.add_row(region_type, address_range, access)
462
+ return table
463
+
464
+ slots_by_name: dict[str, list[TaskSlot]] = {}
465
+ for slot in task_list.slots:
466
+ if slot.task_name is not None:
467
+ slots_by_name.setdefault(slot.task_name, []).append(slot)
468
+
469
+ output: list[Table | Text] = [mapping_table("Kernel", owners.pop("kernel"))]
470
+ output.append(
471
+ Text(
472
+ f".task_list: 0x{task_list.address:08X}-0x{task_list.address + task_list.size:08X} "
473
+ f"({len(task_list.slots)} slots, {task_list.entry_size} bytes each)",
474
+ style="cyan",
475
+ )
476
+ )
477
+ for name, mapping in owners.items():
478
+ output.append(mapping_table(f"Task: {name}", mapping))
479
+ for slot in slots_by_name.get(name, []):
480
+ table = Table(
481
+ title=f"Metadata: {name} (slot {slot.index} at 0x{slot.address:08X})",
482
+ box=box.SIMPLE_HEAVY,
483
+ header_style="bold cyan",
484
+ )
485
+ table.add_column("Field", style="bold")
486
+ table.add_column("Value", overflow="fold")
487
+ for field_name, value in slot.metadata.items():
488
+ if isinstance(value, tuple):
489
+ count = slot.metadata[f"num_{field_name}"]
490
+ rendered = ", ".join(f"0x{item:X}" for item in value)
491
+ if isinstance(count, int):
492
+ rendered += f" ({count} active)"
493
+ elif isinstance(value, str):
494
+ rendered = value
495
+ elif field_name in {
496
+ "magic",
497
+ "label",
498
+ "capabilities",
499
+ "flags",
500
+ "s_text",
501
+ "s_got",
502
+ "s_svcexchange",
503
+ "entrypoint_offset",
504
+ "finalize_offset",
505
+ }:
506
+ rendered = f"0x{value:X}"
507
+ else:
508
+ rendered = str(value)
509
+ table.add_row(field_name, rendered)
510
+ output.append(table)
511
+ if name not in slots_by_name:
512
+ output.append(Text("No task metadata in .task_list", style="dim"))
513
+ return Group(*output)