From 4df692ea3e71fc944df6dddf5513f3f4b52160bd Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 18:50:44 -0400 Subject: [PATCH] bugc: give each memory-homed scalar its own word, stop byte-packing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame/static memory allocator byte-packed several sub-word scalars (address, bool, intN/uintN, bytesN) into one 32-byte word. But a memory-homed value is read and written with full-word MLOAD/MSTORE, so two sub-word scalars sharing a word clobber each other: storing one writes the whole word and overwrites the other's bytes. This is a real runtime corruption — e.g. an address packed with a bool reads back zero once the bool is stored, because the bool's full-word store overwrites the address's low bytes. Allocate one full 32-byte word per memory-homed value instead. A word is the EVM's natural load/store granularity and memory is not scarce at O0, so the only cost is a slightly larger frame. Each scalar now solely occupies its word, so its full-word store cannot touch a neighbor. Adds a memory-planning test asserting that several sub-word scalars each receive a distinct, word-aligned slot (it fails against the old packing, where all four collapsed into one word). --- .../bugc/src/evmgen/analysis/memory.test.ts | 52 +++++++++++++++++++ packages/bugc/src/evmgen/analysis/memory.ts | 45 +++++----------- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/packages/bugc/src/evmgen/analysis/memory.test.ts b/packages/bugc/src/evmgen/analysis/memory.test.ts index 03a09fdebf..9c6fb346d7 100644 --- a/packages/bugc/src/evmgen/analysis/memory.test.ts +++ b/packages/bugc/src/evmgen/analysis/memory.test.ts @@ -299,4 +299,56 @@ describe("Memory Planning", () => { // Free pointer should be after all allocations expect(memory.nextStaticOffset).toBeGreaterThanOrEqual(0x80 + 64); }); + + it("gives each sub-word scalar its own word (no byte-packing)", () => { + // Sub-word scalars (address, bool, uint8, bytes4) must not be + // byte-packed into a shared 32-byte word: each is homed by a + // full-word MSTORE, so two in one word would clobber each other's + // bytes at runtime. Every allocation must occupy a distinct word. + const func: Ir.Function = { + name: "packed", + parameters: [ + { name: "a", tempId: "%a", type: Ir.Type.Scalar.address }, + { name: "b", tempId: "%b", type: Ir.Type.Scalar.bool }, + { name: "c", tempId: "%c", type: Ir.Type.Scalar.uint8 }, + { name: "d", tempId: "%d", type: Ir.Type.Scalar.bytes4 }, + ], + entry: "entry", + blocks: new Map([ + [ + "entry", + { + id: "entry", + phis: [], + instructions: [], + terminator: { kind: "return", operationDebug: {} }, + predecessors: new Set(), + debug: {}, + } as Ir.Block, + ], + ]), + }; + + const liveness = Liveness.Function.analyze(func); + const memoryResult = Memory.Function.plan(func, liveness); + expect(memoryResult.success).toBe(true); + if (!memoryResult.success) throw new Error("Memory planning failed"); + const { allocations } = memoryResult.value; + + // All four parameters are allocated. + for (const id of ["%a", "%b", "%c", "%d"]) { + expect(id in allocations, `alloc for ${id}`).toBe(true); + } + + // No two allocations share a 32-byte word. + const words = Object.values(allocations).map((a) => + Math.floor(a.offset / 32), + ); + expect(new Set(words).size, "each value owns its word").toBe(words.length); + + // And each is word-aligned (a sole occupant reads its whole word). + for (const a of Object.values(allocations)) { + expect(a.offset % 32, "word-aligned offset").toBe(0); + } + }); }); diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 7137d1b87b..d57cb6213b 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -186,49 +186,30 @@ export namespace Function { ); } - // Sort values by size (largest first) for better packing + // Allocate largest-first for deterministic, stable offsets. const sortedValues = Array.from(needsMemory.entries()).sort( ([_a, typeA], [_b, typeB]) => getTypeSize(typeB) - getTypeSize(typeA), ); - // Track current slot usage for packing - let currentSlotOffset = nextStaticOffset; - let currentSlotUsed = 0; const SLOT_SIZE = 32; + let currentSlotOffset = nextStaticOffset; for (const [valueId, type] of sortedValues) { - const size = getTypeSize(type); - - // If this value needs a full slot or won't fit in current slot, start new slot - if (size >= SLOT_SIZE || currentSlotUsed + size > SLOT_SIZE) { - if (currentSlotUsed > 0) { - // Move to next slot if current slot has something - currentSlotOffset += SLOT_SIZE; - currentSlotUsed = 0; - } - } - - // Allocate in current slot + // One full 32-byte word per value; sub-word scalars are NOT + // byte-packed into a shared word. A memory-homed value is read + // and written with full-word MLOAD/MSTORE, so packing two + // sub-word scalars into one word makes each one's store clobber + // the other's bytes (a real runtime corruption). A word is the + // EVM's natural granularity and memory is not scarce at O0, so + // the only cost of one-per-word is a slightly larger frame. allocations[valueId] = { - offset: currentSlotOffset + currentSlotUsed, - size: size, + offset: currentSlotOffset, + size: getTypeSize(type), }; - - currentSlotUsed += size; - - // If we filled the slot exactly, prepare for next slot - if (currentSlotUsed >= SLOT_SIZE) { - currentSlotOffset += SLOT_SIZE; - currentSlotUsed = 0; - } + currentSlotOffset += SLOT_SIZE; } - // Update next static offset to next available slot - if (currentSlotUsed > 0) { - nextStaticOffset = currentSlotOffset + SLOT_SIZE; - } else { - nextStaticOffset = currentSlotOffset; - } + nextStaticOffset = currentSlotOffset; // For user functions, the saved return PC lives at a // fixed offset in the frame header. frameSize is the