The intermittent map-load crash is heap corruption, not a null pointer.
It surfaces as a store through NULL in sub_8298CEF8 (intrusive list
unlink) reached from sub_82990768 +0x1D8, the guest small-block pool
allocator. Decoded with --protect_zero=true:
sub_82990768 reads the chunk's free-block list head [chunk+8] and
gets 0xFFFFFFFF, treats it as a free block, and rounds
it down to a chunk header:
((0xFFFFFFFF+0x7FFF) & 0xFFFF8000) - 0x8000 = 0xFFFF8000
sub_8298CEF8 node->next/prev at that address read as 0, so
"next->prev = prev" stores through NULL
So the null store is three steps downstream. The defect is that
0xFFFFFFFF was in the free list at all.
Root cause, caught in the act by a store watch over the pool arena:
WRITE INTO A FREE BLOCK: guest 0x40D22A6C, 2 bytes, thread "Job Manager"
= +12 of free block 0x40D22A60 (size 64)
writer: sub_82816A40 <- sub_82817138 <- sub_82812A20
<- sub_8290AFA0 <- sub_8290B750 <- sub_82A412B8
freed by: sub_82792040 <- sub_82791FD8 <- sub_82791500 (render_thread)
sub_82791CF8 (under Sk8::WorldPresentation::AddRenderInstance) allocates
two u16 arrays at self+496/+500 and publishes INTERIOR pointers into
them - &A[idx], &B[idx] - into other objects' +64/+68. A job work item
captures one at its +12 and walks it writing 4x u16 records. When
sub_82792040 frees those arrays on render_thread, the job keeps writing
into freed memory. If the block is on the pool's free list, the write
lands on its link word, which is how 0xFFFFFFFF gets there.
The fix, skate3_instance_free_defer_ms (default 0 = off), holds the
arrays freed by that teardown for N ms so a late job write lands on
memory the allocator has not reused. It is bounded by a 512 KB cap, so
a long job latency cannot starve the pool. This is a mitigation: the
root fix is draining or cancelling the outstanding jobs before teardown,
which needs a job-manager drain primitive I could not identify.
Also adds the diagnostics that found it, all default-off: a pool
integrity checker (per-alloc sweep of all 20 size-class bins, poison and
verify of freed blocks, and a check of the link word carried out of a
popped block), and the store watch's host side. The watch needs a
REX_STORE_* hook in the generated header, which is regenerated by
codegen and so is not included here.
Validation is honest but incomplete: 28 runs / 865 map-load events with
the fix on produced no corruption and no crash, but the corruption
occurred only ~2-3 times in ~3900 map-load events without it, so a clean
arm this size has close to even odds by chance. Longer soaks needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>