Menu

Memory ABI contract — the stone tablet

Status: shipped; normative. The layout and refcount contract between the compiler and its runtime.

The single source of truth for string / object / array layout, refcount encoding, ownership transitions, destructor order and the child-iteration ABI. Anything that breaks this contract breaks the compiler; anything that extends it updates this doc in the same patch.

Every number here is mirrored by a constant in src/Compile/MemoryAbi.php — that file is the machine-readable version and wins any disagreement. Cite it, do not re-derive it. Current MemoryAbi::VERSION is 24 (v24: a class tree with a typed property php starts UNINITIALIZED carries a per-object INIT BITMAP, §3.3; v23: a risky DENORMAL float (bits in (0xFFFF, 2^48)) is boxed or'ed with FLOAT_DENORM_TAG (0x7FF0<<48), so no cell can look like a heap pointer; every cell->double read decodes it; v22: a PACKED array's buckets@48 word is its head gap, §4.1; v21: a closure DROP fn takes a mode; v20: Manticore\Ds hash table block and the cell object-tag constants; v19: descriptor grew visit_fn@64 — the json encoders walk public props without a temporary array; v18: v17 AND the native-buffer lineage — the BUF_* block, §7c, the descriptor's json_fn@56, buffer kind 12 (U64); v17: a Generator frame OWNS every cell of its header and __mir_str_reclaim destroys a frame it frees — v17 and v18 each merged two lineages that both called themselves v17, so neither links with the other, see the comments at the top of MemoryAbi; v16: zero-cost exception object, §7b, AND tagged bucket words + int bucket hash, §4.1 — two separate v15 lineages merged, so neither v15 links with v16; v11: element repr ARRAY_REPR_CLO = 10 — raw closure-slot words the buffer counted, §4.1; v10: descriptor grew cmp_view_fn@40 and cmp_group@48 — php's object ==/<=>; v9: a reference box carries [REF_TAG_MAGIC@-8, value@0, rc@+8]; v8: descriptor grew dyn_methods@24; props_fn@32 followed without a bump — it is appended, older .os never read it).

Supersedes the former docs/bootstrap/12-memory-abi-contract.md and the unified-array design note docs/bootstrap/16, which source files used to cite. Sections on the deleted AST backend (exprProducesAssoc, assocOwnedLocals, emitObjReleaseHelper, forEachRefcountedChild) are gone — none of those symbols exist; ownership is decided by MIR passes, described in §5.


1. The rc self-routing tag#

Every heap value that participates in refcounting is reachable through a data pointer, and ptr-8 (RC_TAG_OFFSET) always holds something the rc helpers can dispatch on:

Word at ptr-8Kindrc lives at
0x7E66000000000000 (RC_TAG_MAGIC)object / vecptr+8
0x7E66000000000001 (ASSOC_TAG_MAGIC)assocptr+24
0x7E66000000000002 (ARRAY_TAG_MAGIC)unified array, heapptr+24
0x7E66000000000003 (ARRAY_TAG_ARENA)unified array, arenanever — the arena reclaims it
anything elsestringptr-8 is the rc

This is why __mir_rc_retain / __mir_rc_release self-route regardless of the static type the call site guessed. The magic is chosen far above any real refcount (< 2^56, see RC_MASK) and distinct from the immortal -1, so a string's rc word can never collide with it. Tagged allocations hand out malloc_base + 8 as the data pointer.

An arena array is never rc-bumped and never free()d — the retain/release helpers bail on ARRAY_TAG_ARENA immediately; grow / promote / index paths route to the arena allocator.

2. String#

STRING_HEADER_SIZE = 32, all offsets relative to the data pointer:

data - 32 : i64  hash    -- cached FNV-1a; 0 = not computed. Literals bake it in.
data - 24 : i64  cap     -- byte capacity of the data region (content + NUL)
data - 16 : i64  len     -- content length, binary-safe
data -  8 : i64  rc      -- small count, or -1 = immortal
data +  0 : bytes...

cap is what makes amortized .= possible: the append happens in place when rc == 1 and len + addlen < cap. Immortal strings (literals, arena) carry rc = -1 and still carry a full header, so len reads stay valid.

Two small-string free-list size classes recycle freed buffers (STRING_POOL0_ALLOC = 64, STRING_POOL1_ALLOC = 128); a class's data capacity is alloc - 32, and a freed buffer is recognised by that cap.

3. Object#

OBJECT_HEADER_SIZE = 16. Non-#[Struct] instances reserve it before the first property:

offset 0  : ptr  class descriptor      -- NOT the raw class id
offset 8  : i64  rc_word               -- packed rc | color | buffered
offset 16 : ...  properties

The descriptor (@__mir_cd_<id>, { i64, ptr, ptr, ptr, ptr, ptr, i64, ptr, ptr }, 72 bytes) is a static global, linkonce_odr so each class has exactly one across every separately-linked object:

descriptor + 0  : i64  class_id     -- never 0
descriptor + 8  : ptr  drop_fn      -- or null
descriptor + 16 : ptr  rmeta        -- reflection metadata, or null
descriptor + 24 : ptr  dyn_methods  -- compiler-owned dynamic-method table, or null
descriptor + 32 : ptr  props_fn     -- @__mir_props_<id>: declared props + bag as a FRESH
                                       assoc; what json_encode / (array) / get_object_vars
                                       read from inside stdlib.o. Null for a class with
                                       neither properties nor a bag
descriptor + 40 : ptr  cmp_view_fn  -- @__mir_cmpview_<id>: the COMPARE view as a FRESH assoc —
                                       every declared prop (any visibility) then the bag, or
                                       only the #[CompareKey] props. Null: nothing to compare
descriptor + 48 : i64  cmp_group    -- objects compare through their views only within one
                                       group: the class id, CMP_GROUP_KEYED (-1) for every
                                       #[CompareKey] class, 0 (identity only) for an enum and
                                       an #[Uncomparable] class. #[CompareNone]: a null
                                       cmp_view_fn under the class-id group (any two equal)
descriptor + 56 : ptr  json_fn      -- `i64 (i64 cell)`: the jsonSerialize() result (a backed
                                       enum: its value; Resource: raises UNSUPPORTED_TYPE),
                                       or null (v18)
descriptor + 64 : ptr  visit_fn     -- @__mir_pvisit_<id>(obj, ctx, cb): props_fn's public view
                                       WALKED — cb(ctx, name, cell) per declared public prop
                                       (cell borrowed for the call), then cb(ctx, null, bag).
                                       Null exactly where props_fn is (v19)

instanceof, method dispatch and exception catch read class_id at descriptor offset 0; object release calls drop_fn indirectly. Offsets 0 and 8 are ABI — new fields append. The struct is spelled in exactly one place: Compile\Mir\RuntimeLibrary::descriptorType; every offset is a MemoryAbi::DESCRIPTOR_*_OFFSET constant.

rmeta stays null unless reflection actually reaches the class, so a binary that never reflects pays 8 rodata bytes per class and nothing else. Its layout (RMETA_*, RMETA_SIZE = 104, plus method / param / attribute row shapes) lives in MemoryAbi.php; it is metadata, not a memory-management contract, and is not duplicated here.

3.1 rc_word encoding#

One i64 at offset 8:

BitsWidthPurpose
631buffered — member of the cc candidate list (BUFFERED_MASK)
62..567color — 0 BLACK, 1 PURPLE, 2 GRAY, 3 WHITE (COLOR_MASK, COLOR_SHIFT = 56)
55..056rc — signed; trial deletion drives it negative (RC_MASK)

Read by masking and sign-extending from bit 55; compare with signed predicates:

%rc_only = and i64 %word, 0x00FFFFFFFFFFFFFF
%shl8    = shl i64 %rc_only, 8
%rc_s    = ashr i64 %shl8, 8

Write only the rc field — colour and buffered are mutated by their own helpers:

new_word = (word & ~RC_MASK) | ((rc + delta) & RC_MASK)

A plain add %word, 1 is forbidden: the carry overflows into the colour field. It used to look like it worked because the corruption landed in bytes nobody read.

3.2 #[Struct] opt-out#

Classes tagged #[Struct] skip the header entirely — fields start at offset 0, dispatch is fully static, and no retain / release / cycle-collector hook touches them (ClassDef::headerSize() returns 0). A 3-field struct is 24 bytes against 40 for the refcounted form.

There is no #[NoRefcount] attribute. AllocationKind::NoRefcount exists but is an escape-analysis verdict produced by InferAllocKind, not a user-facing marker — do not confuse the two.

3.3 Init bitmap (v24)#

php starts a typed property with no default (not promoted, not hooked) UNINITIALIZED: a read throws Error: Typed property C::$p must not be accessed before initialization, and isset / json_encode / get_object_vars / var_dump skip it. An int / float / bool slot has no spare value to mark that, so a class tree that declares such a property carries ClassDef::$initMapWords i64 words at ClassDef::$initMapOffset:

bit b = (slot_offset - OBJECT_HEADER_SIZE) / INIT_MAP_SLOT_BYTES   -- word b / 64, bit b % 64

4. Arrays#

4.1 Unified array (the live representation)#

One header for every PHP array, packed or hashed — ARRAY_HEADER_SIZE = 56:

offset 0  : i64  length          -- live element count
offset 8  : i64  capacity        -- slots (i64 if packed, entry slots if hashed)
offset 16 : i64  next_int_key
offset 24 : i64  rc              -- SINGLE FIXED OFFSET for every array
offset 32 : i64  flags           -- mode + element repr + tombstones + internal pointer
offset 40 : i64  n_buckets       -- 0 = index not built
offset 48 : ptr  buckets         -- side allocation; null until built
                                 -- PACKED: i64 head gap (v22), see below
offset 56 : ...  data

Head gap (v22). A PACKED buffer has no bucket index (n_buckets stays 0), so its buckets word is the HEAD GAP: how many 8-byte slots of the malloc block lie in front of the tag. array_shift on a uniquely owned heap packed buffer writes tag + header one slot further on instead of moving the data (array pointer +8, capacity -1, gap +1), and the caller stores the moved pointer back; array_unshift spends the gap the other way and regrows with front slack when there is none. The block start is ptr - 8 - gap*8: release and promote free that, and __mir_realloc_tagged first moves a gapped header back to the block start. Copies and clones are fresh blocks and zero the word.

The fixed rc offset is the structural fix: a value can never have its rc read at the wrong offset regardless of mode. Mode lives in the flags word instead.

PACKED (flags & ARRAY_FLAG_HASHED == 0): contiguous i64 values at data + i*8, implicit int keys. HASHED: 24-byte entries.

entry + 0  : i64  kind    -- 0 STRING, 1 INT, -1 DELETED
entry + 8  : ptr  key
entry + 16 : i64  value

Flags-word bitfields:

BitsFieldNotes
0ARRAY_FLAG_HASHEDcleared ⇒ PACKED
1..3element repr (ARRAY_REPR_*, shift 1)0 RAW, 2 STR, 4 OBJ, 6 ARR, 8 CELL, 10 CLO
8..35tombstone counter (ARRAY_TOMB_SHIFT, 28 bits)every read must mask
36..63internal pointer (ARRAY_PTR_SHIFT, 28 bits)current()/next() cursor

The element-repr nibble is the runtime-truthful record of what retain / release / COW must do to each element. It is stamped as elements are stored, so it travels with the array through erased aliases — unlike the compile-time flavour guess.

CLO (v11) is the one repr a CONCRETE array stamps: a Closure / callable element has no hint code, and its static type cannot be the owner because a callable slot also holds words that are not closure envs (a function-name string, an [obj, 'm'] array) and builders copy closure words without counting them. So a closure-element store stamps CLO only when the value it wrote is the buffer's sole element, or the buffer already says CLO; a closure literal stamps it outright. Its element op is __mir_closure_retain / __mir_closure_release, which act only on a word carrying CLOSURE_TAG_MAGIC — anything else is left alone. The overwrite / unset drop of a closure slot is the runtime-gated __mir_array_clo_drop, so a buffer that never said CLO never gives back a count it did not take. Read only while the element hint is 0.

⚠ The tombstone counter used to run to bit 63. It is now bounded so the internal pointer can live above it: an unmasked flags >> 8 reads the pointer as tombstones, and __mir_array_live_len compacts whenever that is non-zero — so a moved cursor would compact the array on every foreach and every count. Compaction resets both fields, which is why and flags, 255 (ARRAY_FLAGS_LOW_MASK) is still the right reset.

Bucket index (v16). buckets holds n_buckets i64 words (a power of two, linear probing, 0 = empty slot). A used word is (h32 << 32) | (entry_index + 1): h32 is the low 32 bits of the key hash, so a probe rejects a colliding slot and a delete's backward shift finds a slot's home without loading the entry or re-hashing its key. The int key hash is (k ^ (k >> 12)) * golden; the string hash is __mir_array_hash_str. The index is trusted, not validated, so a library built with another hash or word format misses keys in the arrays it built. A value copy (__mir_array_copy_bare) holds the same entries at the same positions and duplicates the source's index verbatim instead of resetting it.

IMMORTAL_ARRAY_RC = 1 << 62 is baked into the empty-array singleton. It is deliberately not -1: COW's sle rc, 1 and release's sle rc, 0 must both stay false, and the string immortal encoding would make COW mutate the shared singleton and release free it (__mir_array_cow never reads the tag).

4.2 Arena arrays#

Non-escaping arrays can be bump-allocated and bulk-freed at scope exit (__mir_array_alloc_arena, __mir_arena_realloc), tagged ARRAY_TAG_ARENA. Eligibility is decided by InferAllocKind::isArenaEligibleType() and is deliberately narrow today: flat int / float / bool arrays with int keys. Gated by Debug::$arenaArrays (MANTICORE_ARENA_ARRAYS, on by default).

4.3 Legacy vec / assoc headers#

VEC_* and ASSOC_* constants still exist and are still referenced by paths that predate the unified array. VEC_HEADER_SIZE = 16 (length@0, rc@8, 8-byte elements). ASSOC_HEADER_SIZE = 48 (length@0, capacity@8, next_int@16, rc@24, n_buckets@32, buckets@40, 24-byte entries at 48); an empty [] stub is ASSOC_STUB_SIZE = 16 with capacity == 0 and no rc slot, so every assoc helper must guard cap == 0 before touching the rc word. The hash index is built at ASSOC_INDEX_THRESHOLD = 8 live entries — below that a linear scan beats hashing plus the bucket allocation.

Assoc rc is a plain count, not packed with colour/buffered, so cycles routed purely through assoc values are not collected.

5. Ownership transitions#

Ownership is not decided by an expression predicate any more. It is a MIR pass chain, run in this order (src/Manticore/Main.php):

InferEffects → InferAllocKind → ApplyMemoryMode → SpillFreshBases → InsertMemoryOps → OwnershipFlow → Verify

A name is managed or not as a whole — never "one flavor per name, else blocked and leaked": the state is per point, so a name bound to an owned value on one path and a borrowed one on another is exact on both. Names a reference can reach (&, static, a by-ref capture or foreach) are left to their storage. A local the flow owns and hands to a container that takes NO count of it (Ownership::containerStoreRetains refuses) MOVES there — holds a borrow of the container's reference from then on — only when nothing reads the name afterwards (no other read, not in a loop, no goto, no symbol-table reader). Otherwise the container takes its own +1 (own_share, before the statement) and the local stays owned: php's $a[] = $x; unset($a); echo $x->p; reads a live value. An array local handed to an object-producing call whose callee may keep it with its element refs (Ownership::elementSharedArgs) releases its buffer only (vecbuf / assocbuf).

Conditional expressions are a shared contract rather than per-consumer guesswork: ?:, ??, ternary and match all route through Compile\Mir\CondOwn so the arms and their consumer agree on who owns the result. An emitter-only fix leaks; a pass-only fix double-frees.

Returning a value hands the caller +1. An owned returned local moves; any other value takes the borrowed-return retain. A conditional the return takes no +1 on (an erased arm the retain cannot name) hands back one arm's reference as it stands: the pass lists those arm locals (Ownership::returnArmLocals → Return_::$ownArms) and the emitter drops each only when its slot word is not the returned word. Which +1 a return takes is ONE decision (Ownership::returnRetain) the emitter executes and the pass reads. Every function, method, static method or closure that DECLARES a bare array / ?array return (Ownership::erasedArrayReturn, decided on the declaration and carried across .sig as array) returns +1 on EVERY path — a borrowed value is retained by its tag, every arm of a conditional is normalized — so a caller owns what such a call hands back: a local stores it at the erasedarr class (raw buffer or tagged cell, split by tag at every retain and drop), an argument temp or a discarded result is released after the call. A generator FRAME owns what it holds (ABI v17): its params from creation (the creator takes their +1), its locals, and the four header cells — current@16, key@24, sent@40, retval@48, each a tagged cell. Every reader (current(), key(), getReturn(), a foreach value / key var) takes its own +1; a yield expression MOVES the sent value out of sent. The frame is freed through the string release, and __mir_str_reclaim calls __mir_gen_destroy on it first: a frame that is not finished (state ≥ 0) re-enters its resume function at -2 - state, where each yield carries the exit of a generator abandoned there — the finally of every enclosing try, then the drops of the locals OwnershipFlow has Own at that point (Yield_::$ownLive, or the outermost finally's ownFinally) — and a frame nobody started drops its params; then the header cells are dropped. The resume entry stores state -1, so an exception that leaves the body leaves the generator finished, and its unwind drops the locals through the same cleanup pads as any other function. By-reference binding forwards the slot to a shared cell and bypasses rc ops at the binding site; the underlying buffer stays owned by whichever local holds it.

Array elements belong to the buffer#

A holder's count on an array is a count on the BUFFER (Debug::$rcBufferOnly, on by default; MANTICORE_RC_BUF_ONLY=0 restores the old model for bisecting). Every retain variant is rc + 1, every release variant is rc - 1, and the keys and elements are dropped once, when rc reaches 0 — whatever variant name (_obj, _str, _ownel_*) a site picked. The element walk at 0 is keyed by the buffer's ELEMENT HINT, and by the repr bits only for an unstamped buffer; every walk — release, adopt, cow — uses that same key.

What that asks of everything else: a buffer must OWN the elements it holds.

The model it replaced let every retain co-own the elements and a proven _ownel_ release give one back. That is sound only when every count on a buffer follows it, and a fresh +1 carries no element refs of its own, so on a buffer with mixed holders the element count depended on which holder let go last. The extra element refs also MASKED each shallow copy above; each one surfaced as a premature free when they were gone.

6. Destructor order#

__mir_rc_release brings the rc to 0 (signed comparison) and calls the descriptor's drop_fn indirectly. The drop body for class C:

  1. Run the user __destruct() if declared, resolved to the most-derived one (EmitLlvmRuntime.php). PHP calls it before properties are released, and so do we.
  2. Release each refcounted property in declaration order — string, object, array, or cell by its element repr. Structs and raw FFI pointers are skipped.
  3. free(self).

Children are released after the user destructor so destructor code still sees its state, and before free so their own destructors see a valid parent pointer — there are no weak refs.

Iteration order is declaration order and that stability is load-bearing: the cycle collector's walkers must visit the same set in the same order, or trial deletion and restoration get out of sync.

Release recurses directly, so a long ownership chain (a linked list) can still exhaust the stack. Iterative release via a worklist is open work.

Shutdown sweep. A class that declares __destruct registers each instance in @__mir_dtor_ents (creation order) at new/clone; the drop body unregisters it as its destructor starts. An atexit hook (@__mir_dtor_sweep, after the register_shutdown_function queue, before the output-buffer drain) runs the destructors still owed (statics, cycles, anything alive at exit()) through the class's drop function in one-shot destructor-only mode (@__mir_dtor_only): members and storage stay intact.

7. Cycle collector#

Synchronous Bacon–Rajan, in tree, emitted as LLVM IR by src/Compile/Mir/Passes/EmitLlvmRuntime.php and reached through the gc_collect_cycles() builtin (EmitLlvmBuiltins.php). Regression test: tests/aot/cases/gc_cycles.php.

Emitted symbols:

@__manticore_cc_add_root        @__manticore_cc_scan
@__manticore_cc_collect_cycles  @__manticore_cc_scan_black
@__manticore_cc_mark_gray       @__manticore_cc_collect_white
@__manticore_cc_child_apply     @__manticore_cc_drop_strings

Global state: @__manticore_cc_roots, @__manticore_cc_count, @__manticore_cc_cap, @__manticore_cc_children, @__manticore_cc_freed.

Triggering is manual only. gc_collect_cycles() is the sole entry point; there is no threshold heartbeat and no safe-point trigger, and the collector does not scan static or global roots. Both are open work — see docs/ROADMAP.md.

7a. Small-object pool#

Objects, unified-array buffers and hash bucket side-arrays are served by a size-classed pool in front of malloc, emitted by EmitLlvmRuntime::poolRuntime() and gated on Compile\Debug::$pool (MANTICORE_POOL=0 disables). Strings keep their own older two-class free list; __mir_alloc (closure envs, generator frames, boxed args) is still plain malloc.

Shape (MemoryAbi): one mmap region of POOL_REGION_BYTES (1 GiB of ADDRESS SPACE — untouched pages are never committed), carved into POOL_SPAN_SIZE (64 KiB) span-aligned spans. A span's first word holds its size class, so __mir_pool_free derives the class by masking the pointer to its span and needs no size from its caller — that is what keeps the pool out of the ABI: no block header, no descriptor field, no offset moves, nothing a stale .o can disagree about. Classes are exact multiples of POOL_GRAIN (16 B) up to POOL_MAX_SMALL (512 B); anything larger, or an exhausted region, falls through to malloc. A failed mmap leaves base/top at 0, which reads as "not mine" for every pointer, so the whole thing degrades to plain malloc/free.

Invariants worth stating out loud:

7b. Exception object (ABI v15, carried into v16)#

A PHP throw is a zero-cost Itanium unwind (src/Compile/Runtime/UnwindRuntime.php). @__mc_throw(obj) mallocs one exception object per raise and hands it to _Unwind_RaiseException:

OffsetSizeField
08exception_class = MemoryAbi::EXC_CLASS ("MNTCPHP\0")
88exception_cleanup = null (the landing pad frees the object)
168private_1 (unwinder)
248private_2 (unwinder)
328payload: the Throwable's address, owning one reference

EXC_HEADER_SIZE = 32 (the _Unwind_Exception header on every 64-bit target), EXC_PAYLOAD_OFFSET = 32, EXC_SIZE = 48.

7c. BIGINT box and native buffers (ABI v14 / v18)#

BIGINT (cell tag 5, CELL_TAG_BIGINT). An int past the signed-48 inline cell form lives in a heap box [rc@0 | value@8] (BIGINT_BOX_SIZE = 16). The cell's payload points at the VALUE, so every reader is a plain load; the box count is at BIGINT_BOX_RC_OFFSET = -8 relative to the payload pointer, the value at BIGINT_BOX_VALUE_OFFSET = 8 relative to the malloc base. The box is COUNTED like any payload a cell carries (__mir_cell_retain / __mir_cell_drop); it was immortal once, which leaked one box per boxing of a pointer-sized int through a mixed slot.

Native fixed-width buffer (v18; SplFixedArray, Manticore\Ds\*). A malloc'd block reached through an int handle (its address):

offset 0  : i64  len     -- BUF_LEN_OFFSET
offset 8  : i64  cap     -- BUF_CAP_OFFSET
offset 16 : i32  kind    -- BUF_KIND_OFFSET
offset 20 : i32  flags   -- BUF_FLAGS_OFFSET
offset 24 : ...  data    -- BUF_DATA_OFFSET

The element type is kind; codes (BUF_KIND_*): I8 1, I16 2, I32 3, I64 4, U8 5, U16 6, U32 7, F32 8, F64 9, BIT 10 (packed 64 per i64 word), CELL 11 (one 8-byte cell word the slot OWNS), U64 12 (raw 64 bits read as unsigned, stored like I64). Slots in [len, cap) are zero / null cells. The bootstrap twins of the __mc_nbuf_* codegen builtins are src/Runtime/Stdlib/Buf.php.

8. Debug and verification#

src/Compile/Debug.php reads these environment variables, once, at startup:

Env varDefaultEffect
MANTICORE_MEMORYhybridallocation strategy; also --memory=<rc|arena|hybrid>
MANTICORE_ARENA_ARRAYSonarena-allocate non-escaping eligible arrays
MANTICORE_EMPTY_SINGLETONonshare one immortal empty-array buffer
MANTICORE_POOLonsize-classed small-object pool (§7a); 0 opts out
MANTICORE_DEBUG_VERIFYoffslow-path invariant checks at memory ops
MANTICORE_PROFILEoffthread-local rc / alloc counters
MANTICORE_REFLECT_REPORToffreport what reflection kept alive

MANTICORE_TYPECHECK=1 gates the TypeCheck pass and is read in the driver, not here. There is no MANTICORE_DEBUG_RC_TRACE.

9. Versioning#

Bump MemoryAbi::VERSION in the same patch as any layout or encoding change, and in that patch also: rewrite the drop-body / walker emission against the new shape, update every direct offset GEP, and bump the affected *_HEADER_SIZE.

No command prints the constant — manticore version prints the release version only. A library's .sig header carries it as "abi", and the compiler refuses a .sig whose abi differs from its own MemoryAbi::VERSION. A memory-ABI bump needs no bootstrap-pin raise (AGENTS.md, "Bootstrap: the pinned release").

Edit this page on GitHub