Memory regions in WATX — deleting the magic numbers

Milestone 6 of docs/watx-migration-plan.md. This document decides the feature before the bulk of it is written. Step 1 (region.declare-fixed, the whole map declared, the compiler validating it) has landed; everything from §4 on is the design for the rest.

1. The goal

Each fixed base address appears exactly ONCE in the tree — in its region declaration. Every other occurrence — WAT arithmetic, JS mirrors, tools, tests, the docs table — references it symbolically or reads generated output.

Validation is not the goal. Validation is the safety net that makes the cleanup survivable. The goal is that the map becomes data: a thing you can move, because nothing else knows where it was.

That is a real distance from where the tree is. tools/region-census.js (§7) counts 648 raw literals that are a second copy of the map, and 171 of the 221 (data ...) segments sit at absolute addresses inside no declared region at all. Every one of those is a nail holding the map in place.

What guarantees exist today

Guarantee Who provides it What it cannot see
Regions do not overlap test/test-wat-memory-map.js (build gate) anything not spelled (global $X i32 ...) + (global $X_SIZE i32 ...)
Free-space / collision queries tools/wat-memory-map.js (on demand) same
WAT↔JS constant agreement tools/check-wat-js-constants.js (build gate) a JS copy whose surrounding code was reshaped until the regex stopped matching
An address is inside its region nobody
The map could be changed at all nobody

The last two rows are the feature. (i32.const 0x07F60000) written in two files is the same token to every tool we have, and (i32.add (global.get $DX_OBJECTS) (i32.mul (local.get $slot) (i32.const 0x400))) with a $slot past $DX_MAX is a silent write into COM_WRAPPERS.

2. This is one family, not a new subsystem

The vendored compiler already carries almost everything Milestone 6 needs:

Facility Where State
layout declarations, hard-error unknown layout/field compiler-codegen.js ~26-62, ~521-547 complete
load.field / store.field / load.elem / store.elem / size-of / offset-of / elem-addr compiler-codegen.js ~1121, 2544+ complete
defmacro compiler-stages.js:43 complete
region family: region.declare-static / -bump / -rc, region.alloc, region.enter/exit compiler-codegen.js:925, 2533 complete
a region symbol resolves to its base in operand position compiler-codegen.js:1320-1327 complete

Reused verbatim: the top-level collection scan; the (size N) spelling; regionBase, the name→base map, and the bare-symbol resolution it feeds — a declared region's $NAME in operand position already emits i32.const <base> with no new code. And layout + load.field/store.field/size-of carry Milestone 6 step 3 entirely: a WND record becomes a layout, its base stays the region symbol, and

(load.field WndRecord hwnd (elem-addr WndRecord $WND_RECORDS (local.get $slot)))

is already a compilable sentence. Layouts-on-regions needs no compiler work at all, only source conversion.

Rejected — region.declare-static is FORBIDDEN for this map, as the migration plan requires: the staticCursor allocation in region.declare-static, which lays regions out from STATIC_REGION_BASE = 1024 and would put Wine's map on top of the decoder scratch, the window tables and NULL_SENTINEL. A declare-fixed region contributes nothing to staticCursor or DATA_BASE, so it cannot move the bump heap or the interned-string pool (pinned by a test).

Why not parallel machinery: two grammars for one concept, two name maps, two places to look when an address is wrong, and a permanent fork between what the vendored compiler understands upstream and what this repo understands. The provenance seal exists to keep the vendored copy explainable as a copy.

3. The core principle: allocated by default

A region is compiler-allocated. A fixed pin requires a documented reason, and there are exactly two admissible reasons:

Chasing that test through the real map is what makes it useful, because most of the map fails it:

So region.declare-fixed stays in the family for the rare justified pin — and as the migration's bridge (§9) — but the target state is allocation.

4. Compiler features

4.1 Allocated declarations, and a DETERMINISTIC allocator

(region.declare $WND_RECORDS (size 0x1800) (align 0x100)
                (owner "per-window records, 256 x 24B"))

Reproducible builds are non-negotiable: the same source must yield the same layout, or nothing downstream — byte identity, the shake test, a diffable combined.wat — means anything.

Algorithm: declaration-order first-fit above a floor.

  1. Regions are ordered by the position of their declaration in the module's top-level form sequence — the same order src/main.watx fixes and tools/check-wat-manifest.js gates. Not by name, not by size: those change under an unrelated rename or a capacity bump.
  2. A cursor starts at ALLOC_FLOOR, declared once per module: (region.floor 0x00000100). Wine's floor is 0x100 — below it lives NULL_SENTINEL at 0xF0, pinned by $g2w's sink behaviour. (This section originally said 0x1000; wave 2 declared the real low string pool — $STRING_CONSTANTS at 0x100, $VK_SCAN_TABLES at 0x380 — and a floor above them makes first-fit unable to reproduce the map, so §8.1 and tools/region-alloc.js moved it to 0x100.)
  3. Each region is placed at the first cursor position at or above the cursor that satisfies its (align N), and the cursor advances past it. First-fit above the cursor, never backfilling into an earlier gap — backfilling makes the layout depend on the size history of every earlier region.
  4. Pinned (declare-fixed) and derived regions are placed first, at their stated addresses, and are treated as obstacles the cursor skips.

Reproducing today's map exactly. Stage A of the migration must not move a byte, so the allocator has to be able to land on the current layout. Two mechanisms, and the design commits to both:

node tools/region-alloc.js --diff prints allocated-vs-current for every region and must be empty before stage A can ship. If a gap cannot be explained, it is declared with (reason "unknown, preserved") — an honest marker beats a silent constant.

4.2 Constraints

(region.declare $THREAD_CACHE_BASE
  (size 0x02000000) (align 0x00100000)
  (stride $THREAD_CACHE_STRIDE (count 8))   ;; size == stride * count, exactly
  (size-is-power-of-2)
  (mask $CACHE_MASK)                        ;; mask == (size / entry) - 1
  (owner "8 x 4MB per-thread decoded-code arenas"))

Each clause is a law the compiler enforces and, where the value is derived, emits nowhere — the mask stays an ordinary global whose value is checked against the region, until step 2 lets (region.mask $R) replace it. This is the answer to the PAGE_DIR_ENTRIES 1024 / PAGE_DIR_MASK 1023 pair and to DLL_TABLE_SIZE == DLL_TABLE_CAPACITY * 32, relationships test/test-wat-memory-map.js today asserts by hand, one assert per pair.

4.3 Derived bases

(region.declare-derived $GUEST_STACK
  (base (g2w 0x07100000))       ;; the guest VA is the ABI; the wasm offset follows
  (size 0x00100000) (align 0x1000)
  (owner "1MB main stack; the guest holds these as ESP"))

g2w here is a compile-time function of the module's own $GUEST_BASE region and the image base, not a call. It makes the guest address the written constant — which is the one that is actually an ABI — and lets the wasm offset follow whatever GUEST_BASE ends up being. Today the relationship is written backwards: the wasm offset is the constant and the guest VA is derived at runtime.

4.4 Region-relative data segments

This is the largest single anchor and it is invisible from the WAT side today. Measured over the real tree: 221 (data (i32.const 0x…) "…") segments, of which 171 are at absolute addresses inside no declared region at all — the string-constant pool from 0x100 upward, which has no _SIZE global and is therefore invisible to wat-memory-map.js, test-wat-memory-map.js and the declaration set alike. The other 50 sit inside declared regions (GDI_BITMAP_FONT_STATIC 19, CLASS_NAME_STRINGS 12, TT_FONT_STRING_STORAGE 8, and singletons).

So: an absolute data offset pins the map forever, and most of them are not even in a region. The design needs

(data (region.addr $CLASS_NAME_STRINGS 0x40) "Button\00")

— an active data segment whose offset is a region-relative constant, checked against the region's extent at compile time and emitted as the same i32.const the absolute form emits. Plus a declared home for the string pool itself ($STRING_CONSTANTS), which is the prerequisite: a segment cannot be region-relative until its region exists.

tools/check-data-strings.js and tools/wasm-data.js --overlaps keep working unchanged — they read the compiled offsets, which do not change.

5. The symbolic spelling of every pattern

If a pattern cannot be written symbolically, its magic number survives. The inventory, with the spelling each one converts to:

# Pattern Today Symbolic spelling
1 Bare base (global.get $DX_OBJECTS) / (i32.const 0x07F60000) $DX_OBJECTS
2 Base + constant offset (i32.add (global.get $X) (i32.const 0x40)) (region.addr $X 0x40) — one i32.const, bounds-checked
3 Base + index × stride (i32.add (global.get $WND_RECORDS) (i32.mul $slot (i32.const 24))) (elem-addr WndRecord $WND_RECORDS $slot) — stride is size-of the layout
4 Per-thread partition (i32.add (global.get $THREAD_CACHE_BASE) (i32.mul $tid (i32.const 0x400000))) (region.slot $THREAD_CACHE_BASE $tid) — stride from (stride … (count N)), so the count is checked too
5 Region end / limit test $THREAD_END, $THUNK_ENDseparate globals holding base + size (region.end $X); the twin global is generated or deleted
6 Mask from capacity $PAGE_DIR_MASK 1023 beside $PAGE_DIR_ENTRIES 1024; $CACHE_MASK (region.mask $X) — derived, so it cannot be off by one
7 Capacity ↔ extent DLL_TABLE_SIZE == DLL_TABLE_CAPACITY * 32, hand-asserted (stride 32 (count $DLL_TABLE_CAPACITY)) on the declaration
8 Guest↔wasm translation guest - image_base + 0x12000 in $g2w and in five JS files (region.addr $GUEST_BASE …) in WAT; the generated JS mirror (§6) in JS
9 Window range test eip >= thunk_guest_base && eip < thunk_guest_end (region.contains $THUNK_BASE x) over a derived region (§4.3)
10 Fixed data segment (data (i32.const 0x11300) "…") (data (region.addr $STRING_CONSTANTS 0x…) "…") (§4.4)
11 JS copy const DX_OBJECTS_WA = 0x07F60000 + a regex in check-wat-js-constants.js require('./regions.generated').REGIONS.DX_OBJECTS.base (§6)
12 Docs table the hand-drawn diagram in docs/memory-map.md generated from the declarations (§6)

Patterns 4-7 and 9-10 are the ones that need new spellings; 1-3 exist already. Pattern 12 is why docs/memory-map.md currently still draws "Cache indexes (256KB)" at 0x07152000, a region src/01-header.wat:1460 says page compilation retired — a hand-drawn map goes stale silently.

5.1 What the survey found that changes the design

A file-by-file survey of how these addresses are actually used turned up six things the naive plan would have walked into.

The offset= memarg is a second, invisible constant addend. Base-plus-offset is written two ways, and only one of them looks like arithmetic:

(i32.load (i32.add (global.get $PE_STAGING) (i32.const 0x3C)))          ;; 08-pe-loader.wat:16
(i32.load offset=4 (global.get $DX_VTBL_REGISTRY))                      ;; 09a8-…-directx.wat:210

No region check sees the second form today and none of §5's spellings covers it either. (region.addr $R 0x…) must therefore be usable as the memarg base with the offset= folded in and checked — otherwise converting the visible adds just pushes the debt into the memarg.

The stride is usually a bare literal even when a global exists. WND_RECORDS is addressed as base + slot * 24 (09c0-window-table.wat:12-13) while $WND_RECORDS_SIZE 0x1800 = 256×24 sits unasserted next to it; DX_OBJECTS writes 32 inline (09a8-…-directx.wat:606) although $DX_ENTRY_SIZE 32 exists eight lines away. There is also an inverse shape — address back to index, (i32.div_u (i32.sub $entry $DX_OBJECTS) (i32.const 32)) at :610 — so pattern 3 needs a (region.index $R addr) companion, not only elem-addr.

Per-thread partitioning does not use its own globals. src/13-exports.wat:2665-2667 computes the thread cache partition from the raw literals 0x05000000 and 0x400000, not from $THREAD_CACHE_BASE and not from any stride global (none exists). $PAGE_DIR_STRIDE and $PAGE_INDEX_STRIDE do exist and are used — and none of the three stride × 8 == _SIZE relations is asserted anywhere. THREAD_RPC's partitioning has no WAT arithmetic at all: it lives only in lib/guest-rpc.js:133.

A structural obstacle: a mutable global's initializer cannot global.get a module-defined global. That is why $THREAD_BASE, $THREAD_END, $PAGE_DIR, $PAGE_INDEX and $thread_alloc are declared with literal initializers (01-header.wat:1530,1534,1539,1540,2378) that duplicate the map. A constant region symbol is a i32.const, so (region.slot $R 0) can be a legal initializer where (global.get $R) cannot — but the design must say so explicitly, because "just use the global" is the obvious fix and it does not compile.

Region ends are spelled three different ways, and one of them is another region's base.

$g2w's direct window is a union of regions with no name. Its upper limit is the bare literal 0x8000000 in three places (03-registers.wat:81,177,179), which is $VIRTUAL_BACKING_BASE spelled as a number. The window covers GUEST_BASE + stack + thunks + PE staging + the DLL tables, so it needs a declared span — a region whose extent is the union of its members ((region.declare-span $DIRECT_WINDOW (covers $GUEST_BASE $GUEST_HEAP_BASE …))) rather than an unnamed constant. The DIB window next to it is already fully symbolic (03-registers.wat:86-92) and is the model to copy. The thunk-zone EIP test is replicated in ~10 places with a bare 8 for the thunk stride.

Precedents that already exist and should be generalized, not reinvented. test/test-wat-memory-map.js:342 already asserts by regex that the treeview table uses $TV_TABLE and not a hard-coded 0x9000 — a one-off of exactly what region-census.js --gate does for every region. And that file's highFixedAliases allow-list (lines 200-241, 40 entries) is precisely what (within $R) plus region.addr replaces: those are sub-fields declared as absolute literals — $D3DIM_UNIMPL_EXEC_OP 0x07FEB000 inside $D3DIM_AUX, the whole hand-packed 0x07F0CExx page — flattened at declaration so no gate can see the containment.

5.2 Where the worst concentrations are

The survey's honest estimate is ~45 true magic-address sites in WAT, not the several hundred a naive grep reports (window styles like 0x04000000 and colour masks like 0x00FFFFFF fall inside $GUEST_BASE's 60 MB span). They cluster:

And the mask/stride debt is larger and more dangerous than the address debt. Fourteen derived globals have no assertion tying them to the extent they come from — $PAGE_DIR_MASK 1023 beside $PAGE_DIR_ENTRIES 1024, $CS_MASK 63 (which is not $CS_TABLE_ENTRIES 256 minus one), $PAGE_INDEX_SLOTS 128, every $GDI_*_COUNT × _STRIDE vs its _SIZE, $WND_RECORDS_SIZE vs stride 24, $THREAD_CACHE_BASE_SIZE vs the 8 threads. Two idioms are in use: a mask stored as a literal, and a mask computed at the use site as SLOTS - 1 (10c-truetype.wat:3144,3160, 10g-gdi-raster.wat:3828). Only the second cannot drift, and (region.mask $R) makes it the only one available. One derivation has already rotted all the way through: tools/cache-slots.js:37-49 still greps $CACHE_MASK out of src/01-header.wat, and that global no longer exists.

6. Generated mirrors

tools/gen-region-constants.js reads src/00-regions.wat (via the vendored parser — one grammar for the map) and writes:

--check mode (regenerate, diff, exit 1) joins tools/build.sh beside gen_dispatch.js --check and gen-host-import-sigs.js --check.

7. The census — a completeness detector

tools/region-census.js counts raw literals that are a second copy of the map, per region and per file. It is importable (require('./region-census').census()) and has --json, --region=, --file=, --gate and --record.

Calibration is the whole design, and the obvious definition is useless. "Any literal inside any declared region" counts 7136 sites, almost none of which are the map: $GUEST_BASE is a 60 MB address space, so every guest VA and every 0x400000 image base falls inside it, and $CLIENT_RECT is a 4 KB table low in memory, so the GDI raster tests' colour constants (0x6A6A and friends) land in it by arithmetic accident. A number that big cannot detect anything, because nobody can tell a conversion from noise. So a site counts when it is actually a second copy:

Measured at the declaration commit: 648 sites — 541 base, 41 end, 66 interior. Banded by what kind of region they name:

band sites confidence
high private map (≥ 0x07000000) 202 near-certain debt
low WAT tables (< 0x12000) 255 mixed — bases like 0x2000/0x3000 are also ordinary numbers
guest windows and spaces 191 mostly the base repeated (the 0x12000 copies in JS)

Worst files: src/10b-gdi-font.wat 38, test/test-wat-gdi-raster-handlers.js 35, test/run.js 26, src/09a7c-mixer.wat 19.

The census is a completeness detector, not a pacing ratchet. With the big-bang plan (§9) it answers "how many raw literals remain", and the answer should go to approximately zero in one wave. --gate still refuses an increase per file, so nothing regrows afterwards, and a region listed in the baseline's converted array must stay at zero. Per-file rather than one total, deliberately: a single number lets a cleanup in one file pay for a regression in another, which is how ratchets stop ratcheting.

What it cannot do is prove completeness — a literal is evidence, not proof, and a missed conversion that happens to be spelled in decimal, or split across an add, is invisible to it. That is what §8 is for.

8. The shake test

A layout that never moves is a layout nobody has tested. Byte identity proves each conversion was exact; it cannot prove the conversions were complete, because a missed raw literal that still equals the right address produces identical bytes. The only way to find the ones that are left is to move the map and see what breaks.

WINE_REGION_SHAKE=gap    bash tools/build.sh   # insert a prime gap before each region
WINE_REGION_SHAKE=rotate bash tools/build.sh   # rotate the allocation order
WINE_REGION_SHAKE=pad    bash tools/build.sh   # round every region up to the next prime page count
WINE_REGION_SHAKE=0x9E3779B9 bash tools/build.sh  # a numeric value seeds a pseudorandom permutation

The env var reaches the allocator (§4.1) and nothing else; pinned and derived regions are not shaken — moving GUEST_BASE or a guest-VA-anchored stack changes the guest ABI, which is a different experiment. Prime-sized gaps are deliberate: a shift that is a multiple of every stride in the tree can be absorbed by an off-by-a-stride bug and stay green.

Acceptance: the 234-test pinned pool and the screenshot comparison suite must be green under at least three distinct permutations, including one gap and one rotate. A failure under shake localizes a missed literal — the failing test names the subsystem, and region-census.js --file= names the line. The loop is shake → fix → reshake, and it is the only evidence that the map is data.

Running the pool under a shaken build is not free, so it is a tools/shake-sweep.sh job and an acceptance gate for stage C, not a per-commit gate. What ships afterwards is the natural allocation.

8.1 Gap reclamation — what has to happen before §8 can run at all

EXECUTED 2026-08-31 (wave 3). The plan below is what was done, and its numbers held: 167 regions allocated above floor 0x00000100, seven pinned, no gap forms, 0x0047A000 free below 0x08000000 unshaken and 0x00209000 in the tightest mode — against the ≈3.9 MB the pad mode needs. Two corrections are recorded in §13: $GUEST_BASE stays fixed rather than derived, and $GUEST_HEAP_BASE had to declare the 4MB extent it was already using. §8's shake now runs a real app on a permuted map (tools/region-shake-smoke.js).

Stage A finished with a blocker: every shake mode overflows. The regions and their preserved gaps fill the 512 MB span end to end, so the allocator has nowhere to put the displacement a shake exists to create. This section is the measurement behind that sentence and the reclamation plan that clears it. It proposes no change to any region's placement — the whole point is that the shake is what moves things, and it cannot start from a map with no slack.

The arithmetic

Measured over the current declaration set (167 regions), floor 0x00000100:

region bytes 0x1F79BC8D
the map's last byte 0x20000000exactly the end of memory
headroom above the map 0
holes inside the map 45, totalling 0x00864273 (≈ 8.39 MiB)

That is the entire budget. There is no other slack anywhere: three regions — $VIRTUAL_BACKING_BASE (320 MB), $DIB_BACKING_BASE (63 MB) and $THREAD_RPC (1 MB) — run from 0x08000000 to the last byte of memory with nothing between them, because they were sized to consume whatever was left. So the ~8.39 MiB of holes below 0x08000000 is not merely the cheapest reclamation, it is the only one. Growing the memory is not an alternative: (memory 8192 8192 shared) is an ABI the JS side allocates and every $g2w bound is stated against.

The 45 holes, classified

A hole is a stretch between one region's end and the next region's base. Twenty of them need no action at all — the next region's declared (align N) already accounts for the whole gap, so the allocator reproduces them for free and no (region.gap …) form is written. The other 25 are the ones stage A had to spell out explicitly, and they are what this plan is about.

class holes bytes reclaim?
round-address hand placement 6 0x006F9780 yes — 81% of the budget
guest-ABI hole 2 0x00112000 no — derive instead
retired region 1 0x00040000 yes
hand-rounding / growth headroom 16 0x00013204 yes
alignment padding 20 0x000058EF n/a — no gap form exists

Round-address hand placement (6 holes, 0x006F9780). The successor sits at an address somebody typed because it was round, and the hole is the distance from wherever the previous region happened to end. Six holes carry 81% of the whole budget:

hole size between
0x079DE000 0x00422000 $WIN16_SEG_TABLE$API_HASH_TABLE (at a round 0x07E00000)
0x03E12000 0x001EE000 $GUEST_HEAP_BASE$HANDLER_PAIR_HIST_COUNTS (at a round 0x04000000)
0x04920000 0x000E0000 $PAGE_DIR_BASE$WIN16_APP_DLL_STAGING (at 0x04A00000)
0x07F1A000 0x00006000 $D3DIM_VIEWPORT_LIGHT_HEAD$HIT_COUNT_BASE (at 0x07F20000)
0x07F5E000 0x00002000 $DX_CURSOR_SAVE$DX_OBJECTS (at 0x07F60000)
0x079CE880 0x00001780 $THREAD_MSG_QUEUES$GDI_NEAREST_CACHE (at 0x079D0000)

A round base is §3's test failing out loud: none of these six successors has a guest-visible ABI or a derivation law, so the address is decoration. Reclaimed by deleting the gap form and letting the allocator pack.

Guest-ABI holes (2, 0x00112000) — the ones that must NOT be deleted.

hole size between
0x03C12000 0x00100000 $GUEST_BASE$GUEST_HEAP_BASE
0x07000000 0x00012000 $THREAD_CACHE_BASE$GUEST_STACK

Both successors are anchored by a guest address, so their wasm base is g2w(VA) and the hole in front of them is the translation skew, not a decision. The second one says so numerically: 0x07012000 − 0x07000000 is 0x00012000, which is $GUEST_BASE exactly. Deleting these gaps would move a base the guest holds. They are reclaimed by §4.3 instead — declare the successor (region.declare-derived $GUEST_STACK (base (g2w 0x…)) …) and the hole becomes computed from $GUEST_BASE rather than preserved as a mystery. That is strictly better than reclaiming it: the space is still consumed, but nothing has to remember why.

Retired region (1, 0x00040000). 0x07152000, between $THUNK_BASE and $PE_STAGING, is the retired block-cache index — src/01-header.wat:1460 says page compilation retired it, and §4.1 already names this hole as the example of one that should carry a (reason …). docs/memory-map.md still draws "Cache indexes (256KB)" there, which is §5's pattern 12 in the flesh. Reclaiming it and deleting that row from the hand-drawn map are one change.

Hand-rounding / growth headroom (16, 0x00013204). Small holes where the author left room beside a table. Individually trivial; the honest fix is that room for a table to grow belongs in that table's (size N), where a bound gets checked, not in an anonymous hole beside it where nothing does.

First, a blocker the wave-2 declarations introduced

node tools/region-alloc.js --diff — the gate §4.1 says must be empty before stage A ships — is currently red:

$STRING_CONSTANTS at 0x00000100 is BELOW where the cursor already reached
(0x00001000); the declaration order is not ascending and first-fit cannot
reproduce it without backfilling

$STRING_CONSTANTS (0x100) and $VK_SCAN_TABLES (0x380) were declared in wave 2 and both live below ALLOC_FLOOR = 0x1000. The floor was chosen for NULL_SENTINEL at 0xF0 and the decoder scratch; the string pool starts at 0x100, immediately above the sentinel, so the floor should be 0x00000100, and tools/region-alloc.js's ALLOC_FLOOR with it. With that one change --diff is empty again and the allocated form reproduces the map exactly, ending at 0x20000000 with zero bytes to spare — which is the stage-A finding, reproduced rather than assumed.

Feasibility, measured

Three reclamation policies over the same 167 regions, each asked of the compiler under each shake mode. OVERFLOW is failure mode 15; a number is the slack left below 0x08000000 after the allocated map is placed.

policy none gap pad rotate reverse seed 0x9E3779B9
preserve (today: every hole an explicit region.gap) fits, 0 B OVERFLOW OVERFLOW OVERFLOW OVERFLOW OVERFLOW
reclaim (no gap forms; all 167 allocated) 8.36 MB 4.48 MB 4.52 MB 8.36 MB 8.32 MB 8.25 MB
reclaim + pin (the recommendation below) 4.48 MB 2.08 MB 2.06 MB 4.48 MB 5.60 MB 6.49 MB

Two things fall out of that table. First, the preserve row is the stage-A blocker stated exactly: the unshaken map fits with zero bytes left, so every permutation overflows and no amount of cleverness in the shake changes it. Second, reclaiming the gaps is not merely necessary, it is sufficient — the worst mode (pad, which spaces all 167 regions by a prime) needs ≈ 3.9 MB and the budget is 8.39 MB. There is no third step to find.

Recommended shake configuration

(region.floor 0x00000100)          ;; not 0x1000 — the string pool is at 0x100
  1. Floor 0x00000100. Above NULL_SENTINEL, below $STRING_CONSTANTS. Fixes region-alloc --diff as a side effect.
  2. No (region.gap …) forms at all. All 25 explicit gaps are deleted, and the 20 alignment-only holes never needed a form. The two guest-ABI holes come back computed through §4.3, not preserved.
  3. Pin exactly seven regions — the shake must not touch them:
    • $VIRTUAL_BACKING_BASE, $DIB_BACKING_BASE, $THREAD_RPC — the backing windows. These are the two §8 names plus the RPC block wedged between them. They are guest-visible (the DIB window has its own translation class in $g2w; the sparse map hands guest pointers into the virtual backing) and they are sized to fill memory, so there is no layout in which they move.
    • $GUEST_BASE, $GUEST_HEAP_BASE, $GUEST_STACK, $THUNK_BASE — the guest-VA-anchored set, as region.declare-derived (base (g2w VA)). Derived regions are already excluded from the shake (§8), which is the right rule: moving these changes the guest ABI, a different experiment.
  4. Everything else — 160 regions — allocates and shakes.

Measured, that configuration fits under every mode with 2.06–6.49 MB clear of 0x08000000, so §8's "at least three distinct permutations" is available immediately: gap, rotate and one numeric seed, with pad and reverse as spares. The tight modes are gap and pad at ≈ 2 MB; if a later region grows past that, the next reclamation is not another hole — it is $VIRTUAL_BACKING_BASE's 320 MB, which is the only place left with room.

One property of this plan worth stating separately, because it is what makes it safe to run at all: reclamation deletes declarations, it does not move regions. Under WINE_REGION_SHAKE unset the allocator packs to a different map than today's — that is stage D, not stage C — so the reclamation commit and the shake commit must be separated by a byte-identity check that is expected to fail, and the acceptance evidence for it is the test pool, never the hashes. Until stage D ships, what builds is the pinned map.

9. The migration: big bang, verified by byte identity

Not a gradual per-region staging. The staging was rejected because a half-symbolized region is the worst of both worlds — it still cannot move, and it costs a conversion pass per region.

Stage A — compiler. Allocated declarations with the deterministic allocator, constraints, derived bases, region-relative data segments. The allocator must reproduce today's map exactly, proven by tools/region-alloc.js --diff being empty and by the canonical artifacts staying byte-identical.

Stage B — symbolization, all at once. Convert every raw address literal in the tree to region-symbolic form: all regions, all files, parallelized per file across agents, using the spellings in §5. Acceptance is byte identity: with the allocator reproducing the current map, a correct conversion changes no bytes and an incorrect one does. 01daf6ccfbd115e3 / 0ee6414668129ac4 is therefore a per-file, per-agent correctness oracle, not merely a final check — which is exactly what makes the fan-out safe.

THE INSTRUMENT, RESTATED 2026-09-01 — those two hashes are history, and reading them as the oracle will send you looking for a machine that no longer exists. 01daf6ccfbd115e3 / 0ee6414668129ac4 were the artifacts of one commit and are wrong today; and the two-compiler column that made them quotable — legacy beside WATX, agreeing to the byte — was retired in §11, because lib/compile-wat.js compiles a region.addr to unreachable rather than refusing it and so cannot build this tree at all.

What every wave since has actually used, and what the next one should: build-vs-build byte identity ACROSS THE CHANGE, in one tree. Compile build/wine-assembly.wasm, make the edit, compile again, compare the sha256. Both arms then carry whatever else is uncommitted in a shared worktree, so the comparison stays controlled even when it is not reproducible from a clean checkout — which is the property a fixed hash cannot offer and is why a fixed hash goes stale the same afternoon somebody else lands a commit.

The oracle still divides conversions the same way, and the division is the useful part. A change that only adds compile-time CHECKS is byte-identical and the hash proves it outright — the 32 region laws of bd23c4c5 are the worked example, dropping straight out at dab89c62e1279a8ac2b0bfe43ec43bfecb0ddd2b7404a17ac0e68f48caf3a260 either side. A change that alters what is EMITTED cannot use it and must say so rather than quietly weakening the claim: region.addr constant-folds where (i32.add (global.get $R) (i32.const N)) emits three instructions, so 71481e1e's 23 conversions moved the module by 29 bytes and were verified with a functional oracle instead (sol and notepad pixel-identical against a pristine worktree, plus the subsystem suites). Establish which of the two a spelling is BEFORE converting it, by reading the compiler's emission path, not by hoping the hash matches: discovering it after the fact is how a wave ends up with neither oracle.

Stage C — shake. §8. Iterate until three permutations are green.

Stage D — natural allocation ships. The map becomes data. Pins remain only for guest-visible ABI, expressed as derivations (§4.3), never as bare wasm offsets.

region.declare-fixed is the bridge: a region can be declared fixed at its current address first — byte-identical, zero risk, already done for all 160 — and un-pinned to allocated once §5's spellings cover its references.

10. Per-region verdict

The full 160-row table is src/00-regions.wat itself. The classes, and the verdict for each:

Region(s) Current base Verdict Reason / constraints
GUEST_BASE 0x00012000 derive (pin until §6 lands) the single parameter of $g2w; ~171 JS/WAT copies must become generated first
GUEST_STACK 0x07012000 derived base guest holds these as ESP — pin the guest VA, derive the wasm offset
GUEST_HEAP_BASE 0x03D12000 derived base guest holds these as heap pointers
THUNK_BASE / THUNK_END 0x07112000 derived base thunk EIPs are guest-visible; THUNK_END becomes (region.end $THUNK_BASE)
DIB_GUEST_BASE window 0x500000000x1C000000 pin a guest-visible ABI window with its own translation class in $g2w
THREAD_CACHE_BASE, PAGE_INDEX_ARENA, PAGE_DIR_BASE 0x05000000, 0x04100000, 0x04900000 allocate with constraints base + tid*stride; power-of-two stride, count = thread count, mask derived
PE_STAGING, DLL_TABLE, API_HASH_TABLE 0x07192000, 0x07992000, 0x07E00000 allocate emulator-private; historical hand-placement
every table below GUEST_BASE (WND/class/control/timer/scroll/dialog/paint…) 0x20000x12000 allocate emulator-private; the guest reaches them only by convention
the high private map (0x07E…0x07FF…: GDI regions, DX objects, COM wrappers, TV tables, histograms) various allocate emulator-private; this is also where the census's 202 near-certain literals live
string constants at 0x100+ undeclared declare, then allocate 171 data segments sit here with no region at all (§4.4)

11. Rollback — VERDICT (the door has since been taken)

Read this section as history. Everything below was true of the tree at step 1, and it is kept because it records why the retirement was safe to schedule and what was measured before it. WINE_WAT_COMPILER=legacy is a hard error as of 24b79256 — see the addendum at the end of this section. Present tense below means "at step 1", not "today".

WINE_WAT_COMPILER=legacy bash tools/build.sh worked at step 1 because src/ was standard WAT. Did a declaration end that?

Investigated, not assumed. lib/compile-wat.js dispatches top-level forms through a flat if (head === '…') chain (lines 913-1037) over iterTopLevel(exprs). There is no else and no whitelist: a form whose head matches nothing falls out of the chain and the loop moves on. Unknown top-level forms are silently ignored — a different fact from the previously recorded "compile-wat only warns on unknown func calls", which is about expression position.

Proven, twice. First against a synthetic part, then against the real thing: bash tools/build.sh with all 160 declarations present produces, in both modes,

watx    tail 984347 B 01daf6ccfbd115e3   compat 984796 B 0ee6414668129ac4
legacy  tail 984347 B 01daf6ccfbd115e3   compat 984796 B 0ee6414668129ac4

VERDICT AT STEP 1: declarations were legacy-safe, and rollback survived step 1 intact. It did not survive stage B, by design — see immediately below.

The retirement is scheduled, not avoided. Rollback survives top-level declarations. It cannot survive either addressing spelling: a bare $REGION or a (region.addr …) in expression position is not ignored harmlessly — and the failure mode is worse than "fails validation". Legacy compiles an unknown expression op to unreachable (lib/compile-wat.js:1528-1532, verified by compiling a (region.addr …) under it: it builds, prints one unknown op warning, and traps at runtime when that path executes). A legacy rollback of a partially converted tree can therefore ship a module that instantiates cleanly and dies mid-app. So:

Stage B's first converted file formally retires WINE_WAT_COMPILER=legacy. That commit must say so, flip the migration plan's rollback checklist row, and update the mode comment in tools/build.sh. It is a one-way door, taken deliberately at the start of stage B rather than as a side effect of some region conversion.

The door was taken 2026-08-31: wave 1 of the symbolization landed expression- and data-position region spellings (d1a22e79 onward), and WINE_WAT_COMPILER=legacy is now a hard error in tools/build-compile-wat.js. The migration plan's §5.1 selector is marked retired.

12. Failure-mode catalogue

Every one is a hard compile error carrying file, line, col. None is a warning: a memory map that compiles with a diagnostic nobody reads is the status quo. Rows 1-14 are implemented; rows 15-20 accompany their feature.

# Condition Message shape
1 Two regions' extents intersect region.declare-fixed $B [0x07F70000,0x07F78000) overlaps $A [0x07F60000,0x07F80000) (declared at 00-regions.wat:41); use (within $A) if the nesting is deliberate
2 Region ends past initial memory $A ends at 0x20001000, past the 0x20000000 bytes of initial memory (8192 pages)
3 Duplicate declaration $A is already declared at 00-regions.wat:12
4 Missing / dual extent $A needs exactly one of (size N) or (end N)
5 end at or below base (end 0x800) is not above (base 0x1000)
6 Zero size (size 0) — a region must have an extent
7 Unknown clause unknown clause (sixe ...); expected base, size, end, align, owner, within
8 Duplicate clause duplicate (base ...) clause
9 Misaligned base base 0x00012004 is not a multiple of its (align 0x1000)
10 Non-power-of-two align (align 12) is not a power of two
11 Non-integer literal (size "big") is not an integer literal
12 (within …) names nothing / does not contain (within $OUTER) names no declared region · $A […) is not contained in $OUTER […)
13 region.addr on an unknown region unknown region $NOPE; declared regions are …
14 region.addr offset/span out of bounds, negative, or not a literal offset 0x800 runs past the region's 0x800 bytes · offset must be a non-negative integer literal
15 (4.1) allocation runs out of memory allocating $A (0x…) past the 0x20000000 bytes of memory; the last placed region was $B
16 (4.1) an allocated region collides with a pin $A cannot be allocated at 0x…: pinned $B occupies it (the allocator skips pins, so this means a pin above the floor with no room after it)
17 (4.2) a stride/count law fails $A (size 0x2000000) is not (stride 0x400000) x (count 8)
18 (4.2) a power-of-two law fails $A declares (size-is-power-of-2) but its size is 0x1800
19 (4.3) a derived base has no $GUEST_BASE region (g2w 0x07100000) needs a declared $GUEST_BASE region
20 (4.4) a data segment's region-relative offset is out of bounds (data (region.addr $A 0x900) …) with 0x20 bytes runs past the region's 0x800

13. Status

Landed (step 1):

  1. region.declare-fixed + region.addr / region.size / region.end in tools/watx-src/compiler-codegen.js, failure modes 1-14, provenance resealed.
  2. test/watx-compiler-regions.test.js — 60 checks.
  3. src/00-regions.watall 160 fixed regions declared, registered in src/main.watx and WAT_FILES; tools/check-region-decls.js holds them against the $NAME/$NAME_SIZE globals in tools/build.sh.
  4. tools/region-census.js — the odometer, calibrated in §7.
  5. Byte identity proven in both compiler modes (§11).

Landed (stage A + wave 1, 2026-08-31):

  1. §4.1 allocator (region.declare first-fit, deterministic; tools/region-alloc.js --emit/--diff/--prove, --diff EMPTY against the hand-placed map), §4.2 constraints (stride/mask/size-is-power-of-2, global operands for the derived assertions), §4.3 derived bases ((base (g2w VA)) + region.image-base), §4.4 region-relative data segments, §8 shake (WINE_REGION_SHAKE=, banner, refuses a proves-nothing all-pinned shake), failure modes 15-20, and the round-6 collision lints. test/watx-compiler-alloc.test.js (76) + regions suite at 67.
  2. §6 JS mirror: tools/gen-region-map.jslib/region-map.generated.js (single reader via check-region-decls; --check gated in the build).
  3. Wave 1 of stage B: ~96 sites symbolized across 14 src files + 11 lib files, every conversion proven byte-identical by paired HEAD-vs-HEAD+file compiles; census banked 662→566. Legacy compiler retired (§11, taken).

Wave-1 corrections to this design: the census's per-file counts ran ~44% false positives in flag-heavy files (0x1000/0x2000/0x4000 VT_/OF_/style constants colliding with low region bases) — §7's "evidence, never proof" understated it; treat counts as leads only. Seven real fixed addresses matched only as an adjacent region's exclusive end (0xD160, 0x5110, 0x11500, 0x11D80, 0x3E00, 0x3180…) — per §5.1 those need their own declarations, never (region.end $NEIGHBOR). $CLASS_NAME_STRINGS is under-declared (0x80 declared, block runs past 0x3240).

Landed (wave 2, 2026-08-31):

  1. §5.1's region.declare-span — a named address LIMIT, transparent to the overlap check because the regions it bounds live inside it, never allocated and never shaken, with (owner "…") mandatory for the reason (reason "…") is mandatory on region.gap. $DIRECT_WINDOW [0, 0x08000000) is declared, and $g2w's three 0x8000000 literals in src/03-registers.wat now read (region.end $DIRECT_WINDOW). Byte-identical, regions suite 67 → 119.
  2. A hole in §4.4 closed on the way past: active data segments accepted (region.end $R) and (region.size $R) as offsets with failure mode 20's payload-length bounds check silently skipped. Neither is an addressable location; region.addr is now the only region-relative data offset.
  3. §8.1 — the gap-reclamation plan, measured: 45 holes, 8.39 MiB, and the empirical finding that reclaiming them is both necessary and sufficient for every shake mode.

Landed (wave 3, 2026-08-31) — §8.1 EXECUTED:

  1. The mirrors first, because they were the real blocker and this plan did not name them. Every region has a (global $R i32 …) / $R_SIZE pair and 1066 global.get sites read them; a literal mirror pins its region, so the map could not move until all 348 were (region.addr $R 0) / (region.size $R). A region constant may now initialize a global (compiler-codegen.js, resolved through the same regionConstValue as the operand and data positions; regions suite 119 → 140). Plus 67 interior aliases — globals holding an address inside a region under another name, $STATIC_SYS_DIR = $RESERVED_PAGE_STRINGS + 0x74 and the like — and the last two literal-anchored data segments. All byte-identical, which is the oracle a conversion wave has while the regions are still pinned.

  2. The map is allocated. 167 of 175 regions are region.declare above (region.floor 0x00000100); there are no region.gap forms and never were any in the source (stage A's 25 were synthesized by region-alloc --emit). Seven stay pinned: $GUEST_BASE fixed (it is what (g2w …) resolves through, so it cannot be expressed in terms of itself), $GUEST_HEAP_BASE / $GUEST_STACK / $THUNK_BASE derived at (g2w 0x04100000 / 0x07400000 / 0x07500000), and the three backing windows fixed. 35 holes remain, all alignment padding.

  3. §8's shake is executed, not merely available. tools/region-shake-smoke.js builds the shaken wasm AND the matching JS mirror (gen-region-map --shake --out, $WINE_REGION_MAP) — pairing them is not optional, because a shaken artifact against the canonical mirror reads the wrong bytes and draws a plausible wrong picture instead of failing. Measured: sol under gap, rotate and seed 0x9E3779B9, marbles under pad, 0 of 307200 pixels differ in every case. The slack each mode left below 0x08000000 at that commit reproduced §8.1's predicted table to the byte (gap 0x0020C000, pad 0x00209000, rotate 0x00479D80, reverse 0x0059AC80, seed 0x00608900).

    Those five numbers are historical and were never re-measured. Every one of them is wrong today, because the map has grown since. Re-measured 2026-08-31: gap 0x001B1000, pad 0x001B8000, rotate 0x00000000, reverse 0x00563D80, seed 0x0048B800. Rotate's is not a typo — under that permutation $TV_IMAGE_TABLE ends on $VIRTUAL_BACKING_BASE's first byte exactly, with the map perfectly packed and not one spare byte below the ceiling.

    A written-down slack figure is a copy of the map by another name, and it went stale the same silent way every other copy does — so it is now computed, not quoted: node tools/region-alloc.js --shake-all prints the table and exits nonzero when any mode cannot be placed, and tools/build.sh runs it beside the other region gates. That is the gate §8 always needed. Read the tool's output for the current numbers; the ones above are a record of two measurements, not a specification.

    Two things the re-measurement corrected. First, rotate's zero is not a one-page knife edge: placeShakenAroundPins is best fit, so growing a region by a page re-drains the small regions into different windows and rotate comes back with 0x280 spare. The zero is what perfect packing looks like. Second, the number that actually bounds growth is therefore the tightest mode's slack, gap's 0x001B1000 — and about 6 MB of new region below the ceiling is what it takes to make any mode fail.

Wave-3 corrections to this design.

The seven pins are four fixed + three derived, not "four derived". §8.1 asks for $GUEST_BASE as region.declare-derived; the compiler refuses it, and is right to — a derived base is g2w(VA), and g2w is defined by $GUEST_BASE.

A region's neighbour is not a bound. Reclaiming the holes put $PAGE_INDEX_ARENA immediately above the guest heap, and $heap_low_reserve stopped there — it had been growing 4MB into an anonymous hole that the hand-placed map happened to leave. $GUEST_HEAP_BASE declares 0x3EE000 now and the WAT bound is (region.end $GUEST_HEAP_BASE). This is the failure the reclamation exists to surface, and the only one the whole corpus produced.

The census measures something different now. It counted a literal equal to a region base as a copy of the map. An allocated base is an output — written down nowhere, so nothing can be a second copy of it — and packing put regions at 0x1000, 0x2000, 0x3000, 0x4000 and 0x10000, which took the count from 351 to 884 on coincidence alone without one line of source changing. It now counts base/end literals for PINNED regions only, plus the interior rule: 70 across 35 files, which is the guest ABI and the high tables — the part that was ever evidence.

What a moved region actually breaks is JS, and it breaks quietly. Three copies survived the mirror sweep because they were in lib/, not in WAT: $DI_MOUSE_INPUT_STATE in lib/renderer-input.js and $VIRTUAL_MAP_STATE / $VIRTUAL_MAP_TABLE in both lib/mem-utils.js and renderer-input.js. None of them threw. The relative-mouse tests read zeros out of a cell nobody writes any more, and the stale virtual-map walk translated two font files' reads to the wrong place, so test-wat-font-metrics-reference reported Times New Roman and Courier New drifting off their measured budgets — a rasterizer-shaped symptom six regions from its cause. The census gate is the standing defence; the practical rule is that a JS file may not contain a hex literal that equals a region base, and the three that did now read the generated mirror.

Next, in order: wire the shake into a scheduled run over more than two apps (the harness takes --app/--modes, nothing consumes it automatically yet); then decide whether $VIRTUAL_BACKING_BASE's 320 MB is the right shape now that it is the only slack left in the map.