The engine (src/engine/) is invariant across Dart versions/platforms; everything that varies by version/platform lives in the JSON files under profiles/. This document specifies both configuration kinds and defines the contract that "adding a new Dart version/platform requires configuration changes only."
Struct layouts are a separate, compile-time tier: profiles/struct/*.h holds the version × architecture DartThread field layouts (generated by compiling the Dart VM) and is embedded into the binary via src/struct_tables.rs; the DartObjectPool struct is generated per target at export time. A missing struct layout degrades gracefully to a generic DartThread, so it does not participate in the fill-DSL contract below.
Keyed by (Dart version, word_size, compressed_pointers). All 26 versions in profiles/sdk/ (1.24.3 → 3.14β), as 47 files once the 21 compressed-pointer
variants are counted, are compiled into the binary: at runtime dae matches the exact version by snapshot version fingerprint (32-byte hash, see version_hashes.json); on a miss it runs structural probes against the embedded versions with fill scoring (see src/profile/detect.rs). The byte-comparison baseline is dart-3.3.4-w64-no-compressed.json (a macOS Flutter app / arm64 / 3.3.4 / no pointer compression / --obfuscate — full comparison passed).
Top-level fields (SdkProfile; serde definition in src/profile/mod.rs):
| field | meaning |
|---|---|
abi |
version identifier (documentation use) |
status |
verified / unverified: export-time notice when a version's sample comparison is incomplete |
format |
snapshot-format switches (see §1.1) |
word_size / compressed_pointers |
affect frida constants and runtime offsets |
tagging |
heap_object_tag / object_alignment(_log2) / smi_mask / smi_shift / cid_tag_pos / cid_tag_mask / num_predefined_cids |
header_fields |
snapshot stream header fields, read order (name → header map); each entry is either "name" (unsigned) or {"name": ..., "kind": "signed"} (1.24.3 reads everything signed) |
full_aot_kind / object_start_alignment |
kind=3 (Snapshot::kFullAOT); data_image alignment 64 |
alloc |
allocation-phase classification (see §1.2) |
cluster_layouts |
fill layout per cid (fill DSL, see §3) |
runtime_offsets |
object layout offsets used by asm IL (thread_field_table_values / array_data_minus_tag, etc.) |
frida_cid_constants |
header constants for frida.js (values emitted verbatim; ClassIdTagMask kept as the string "0xfffff" to preserve the original form) |
frida_special_layouts |
special class entries in the frida Classes array (static names + offsets for bool/int/String/List/Map/Closure/Object; key order = output order) |
frida_int_typed_cids |
the 8 integer TypedData cids that carry lenOffset/dataOffset |
class_id_names |
cid → predefined class name (sparse map, from the class_id.h enum + expanded TypedData names) |
Snapshot-format differences that used to be engine code branches; all serde-defaulted:
| field | values / meaning |
|---|---|
ref_encoding |
ref_id_128 (2.19+, big-endian 7-bit groups +128) / unsigned_leb128 (≤2.17) |
instructions_table_source |
snapshot_rodata (default) / image (2.15: table lives in the AOT image) / code_text_offsets (2.15-2.16 bare instructions) |
cluster_header |
cid_and_canonical (≤3.5: cid=value>>1, canonical=value&1) / cid_tags (3.6+: full object tags, ClassIdTag decode) |
single_snapshot |
3.13+: VM/ISO merged into a single snapshot |
class_alloc |
pre_ids (≤3.12) / fixed (3.13+) |
code_leading_refs |
3.13+: 2 leading refs before Code fill objects |
string_clusters_separate |
≤2.14: string data spread over three cids |
code_alloc |
state_deferred (2.10+) / count_only (≤2.9) |
type_dual_count |
≤2.9: Type cluster dual-segment dual-count alloc |
instance_legacy / instance_alloc_nfo_only |
≤2.9 no-bitmap instances / 2.10 alloc reads nfo only |
objectpool_legacy / objectpool_type_low7 / objectpool_type_low7_swapped |
ObjectPool entry bitfield eras (≤2.9 / 2.10–3.1 / 3.2.x swap) |
code_refs |
refs per Code object (default 6; 2.15 = 7) |
code_has_text_offset |
≤2.16: leading text-offset uvarint in Code fill |
class_fill_adj / code_fill_adj |
2.18.x-only fill start corrections (bytes; -2 / -4, recovered by byte-diffing) |
elf_name_backfill |
transitional versions (2.16/2.18/2.19.6): backfill function names from ELF symbols by entry address |
| key | meaning |
|---|---|
rodata_cids |
ROData classes ({23,24,25,92,93,94}): alloc reads delta offsets; fill is empty |
var_cids |
variable-length classes ({17,22,27,28,29,46,66,89,90}): alloc reads count lengths |
mint_cid(60) / code_cid(18) / class_cid(5) / instance_cid(44) / library_cid(13) / function_cid(7) |
special alloc sections |
instance_min(176) / ffi_cids |
instance alloc (first reads the nfo/isize pair as svarints) |
type_cid / type_parameter_cid |
Type / TypeParameter cluster cids (≤2.10 dual-segment dual-count alloc) |
canonical_table_cids |
additionally read the canonical table when canonical=1 |
canonical_subset_table_cids |
subset-style canonical tables (2.13/2.14 only Type): extra first_element varint |
cid_only_alloc_cids |
clusters whose alloc writes cid only, no count (2.13 WeakSerializationReference) |
typed_data_first/count/stride |
TypedData classification: slot=(cid-first)/stride, rem=(cid-first)%stride |
typed_data_elem_widths/var_rem/view_rem |
elem widths per slot; rem ∈ var_rem → variable-length, view_rem → view |
string_cid(92) / one_byte_string_cid(93) / two_byte_string_cid(94) |
string decoding |
The generator starts from the 3.3.4 baseline and remaps class ids for other versions. Remapping
does not remove fields that a given version's serializer never wrote, so every one of these had
to be pinned per version by reading that version's app_snapshot.cc. Get one wrong and the whole
snapshot drifts from that cluster onward — silently, with warnings 0.
| Cluster | Field | Present from | Layout consequence |
|---|---|---|---|
TypeParameter |
base_/index_ as uint8 vs uint16 |
int32 + 3×uint8 ≤2.19; int32 + 2×uint16 + uint8 3.0; 2×uint16 + uint8 ≥3.2 |
1 svarint + 3 raw bytes / 3 svarints + 1 byte / 2 svarints + 1 byte |
Function |
packed_fields_ |
written unconditionally ≤2.17; moved inside if (kind != kFullAOT) from 2.18 |
2 trailing svarints ≤2.17, 1 from 2.18 |
SubtypeTestCache |
num_inputs, num_occupied |
3.2.0 | refs(1) only before that |
FfiTrampolineData |
ffi_function_kind_ |
3.2.0 | no trailing byte before that |
Type |
trailing combined byte |
present 2.15–2.18, gone from 2.19 | refs(3) + uvarint + byte vs refs(3) + uvarint |
Two encoding rules from datastream.h are easy to get backwards and cost hours:
Serializer::Write<T>is documented as "raw data", butRaw<sizeof(T),T>only writes a genuine raw byte whensizeof(T) == 1.Raw<2>/Raw<4>/Raw<8>forward toBaseWriteStream::Write<T>, which is a varint. SoWrite<uint8_t>is raw;Write<uint16_t>/Write<int32_t>/Write<int64_t>/Write<double>are varints. OnlyWriteFixed<T>is fixed-width.WriteUnsignedsets the high bit on the terminator, not on continuation bytes (kEndUnsignedByteMarker = 255 − 127 = 0x80) — the inverse of standard ULEB128. Encoding a length as ULEB128 while searching for it yields plausible-looking false hits.
check_profiles.sh regenerates all 47 files (26 versions × word size, plus 21 compressed-pointer
variants; 2.7/2.10/2.12 predate pointer compression) from SDK source and diffs them, so a
hand-edited or stale profile fails. It hard-codes the expected compressed-variant count: "the file
isn't there, so skip it" is the same false-pass shape the analyze gates closed.
Table of (container, arch): macho/elf/pe × arm64/x64/…. Fields (PlatformProfile):
| field | meaning |
|---|---|
container.kind |
macho / elf / pe (selects the parser) |
symbols |
the three entry symbol names (_kDartVmSnapshotData etc., same names across platforms) |
symbols_alt |
backup symbol set (e.g. the 3.13 single-snapshot three-segment names); used when the primary set is incomplete |
registers |
role registers for IL/disassembly (thr/pp/null/barrier/fp/sp/lr/code_reg…) |
register_aliases |
op_str rewrite alias table (x29→fp etc., matching blutter's output style) |
non_field_base |
fixed-role registers that must never be used as an object-field base |
polymorphic_entry_offset_aot |
monomorphic entry offset (arm64 AOT = 24) |
frida_heap_address_reg |
HeapAddressReg rewrite value for the frida template |
r2_app_base |
optional override for f app.base in addNames.r2 (default: the container's text-segment VM address) |
code_floor |
minimum file offset for function addresses (entry_for filter): the stripped-executable fallback path can leak bogus entries from the tail of the instruction table into container-header offsets; Mach-O uses 4096, other containers 0 (default) |
status |
verified / unverified (currently only macho-arm64 is verified; pe-arm64 is an unverified structural combination, no real device sample) |
The step list is interpreted per object (k = 0..count); captured values go into the per-object ctx (a key/value table) and the collect lists. Allocation-phase products are referenced with times:"lengths".
| op | fields | semantics |
|---|---|---|
refs |
n, aliases (capture names in order, null skipped), collect (with n=1, append the value to the named list) |
read n ref-ids |
uvarint |
alias / collect / transform:"shr4_and_mask" |
ReadUnsigned; transform = Type's (flags>>4)&cid_tag_mask |
svarint |
alias / collect |
signed variable-length Read<T> |
byte |
alias / collect |
1 byte |
u32 |
alias / collect |
fixed 4-byte little-endian uint32 (2.15–2.17 Function packed_fields_/kind_tag_) |
text_offset |
signed(bool, 2.10 special) |
read one varint and accumulate it into the snapshot-level text_offsets (≤2.16 bare-instruction entry addressing, code_text_offsets source) |
skip_raw_elem_width |
— | skip lengths[k] * elem_width(cid) raw bytes (TypedData) |
skip_align |
n |
alignment padding: jump to position % n == 0 (≤2.9 ExternalTypedData, n=8) |
skip_const |
n |
skip n bytes |
| op | fields | semantics |
|---|---|---|
loop |
times = "lengths" (alloc lengths) or a constant; steps |
sub-loop |
cond |
on (captured field), lt/ge numeric tests, then; set_default (defaults captured when not matched) |
conditional execution (used for the Class top-level nullable bitmap) |
objectpool |
— | special: length + per-entry bits dispatch (captures pp.txt entries) |
instance_fields |
— | special: cluster-level bitmap + nfo-1 slots (ref or word32x2) |
code |
— | special: payload_info (read only when k<nondef) + 6 refs |
emit |
store, fields (record field → alias name / `{"builtin":"cid" |
"ref"}`) |
Built-in layouts (no JSON declaration needed): string classes (cid==string_cid, decoded via rodata offsets), rodata/mint (empty fill), instance classes (handled by the instance_fields step), TypedData and View (atomic skip / 3 refs + 2 svarint).
| store | record fields | produced |
|---|---|---|
functions |
name_ref, owner_ref, code_index, kind_tag | function table |
classes |
name_ref, library_ref, class_id, super_type_ref, next_field_off, type_arg_off, field_bitmap | class table |
libraries |
name_ref, url_ref | library table |
patch_classes |
wrapped_class | PatchClass → wrapped class |
type_cids |
type_cid | class id of Type objects |
map_data |
data_ref, used_ref (cid taken automatically from the cluster cid) | ConstMap/ConstSet |
array_elements |
ta, elems (collect list) | Array/ImmutableArray |
- After alloc,
next_ref - 1 == num_objectsis checked; a mismatch raises a warning (corresponds to the reference implementation's alloc mismatch). - A cid > 60000 in the stream is treated as drift: parsing stops with a warning.
- fill fails explicitly for a cid without a layout, suggesting to add one to
cluster_layouts(the reference implementation aborts silently; we fail loudly instead). - The engine errors on any uncaptured field of an emitted record (a
condmissingset_defaultis the most common cause). - Version auto-detection is two-stage: exact snapshot version-hash match (
version_hashes.json), then a structural probe (alloc trial-parse + fill scoring) when the hash is unknown; if neither produces a result the embedded dart/3.3.4 profile is used with an explicit warning (seesrc/profile/detect.rs).