Skip to content

agc: break the graphics boot blocker — real sceAgcCreateShader + shader-derived pipeline state - #1

Merged
mattias800 merged 1 commit into
masterfrom
agc-front-half
Jul 4, 2026
Merged

agc: break the graphics boot blocker — real sceAgcCreateShader + shader-derived pipeline state#1
mattias800 merged 1 commit into
masterfrom
agc-front-half

Conversation

@mattias800

Copy link
Copy Markdown
Owner

What

Resolves the libSceAgc boot blocker (the CreateWorkload register-context null-deref chain 0x3b5ea60x3b15620x3b1533) that docs/GRAPHICS.md had parked as "SDK-gated." No SDK headers needed — and no fabricated objects: the game supplies everything itself.

Root cause

The null "register source" global [eboot+0x2048c60] is slot+0x10 of Unity's built-in shader registry (~30 0x28-byte slots). At graphics init, eboot+0x14e74c0 parses shader ELFs embedded in eboot rodata (EM_AMDGPU, .shader_header/.shader_text) and calls sceAgcCreateShader(&slot->shader, header, code) per shader. Our stub returned 0 without writing *dst → every slot stayed null → SetSource fed null into the register-context sub-objects. The earlier "no writer exists" static scan missed the store because it goes through a register base (slot+0x10), not the literal address — the RGCTX blind spot again.

Key insight: the register-source object IS the SDK Shader (SetSource reads +0x08 user_data / +0x28 specials / +0x5a type). The classify tables ship inside the game's own shader blobs.

Implemented (hle_agc.cpp)

NID Function Notes
f3dg2CSgRKY sceAgcCreateShader relocates self-relative header ptrs, binds code, patches SPI/COMPUTE_PGM_LO/HI (all 5 stage pairs — Kyty supports only ES/PS and aborts otherwise), double-relocation guard, host-side registry prosper_agc_shader_count() for the AGC→Vulkan pipeline
V++UgBtQhn0 sceAgcGetDataPacketPayloadAddress register banks [sub+0x10]/[sub+0x18] = Dcb data-packet payloads
n2fD4A+pb+g sceAgcCbSetShRegisterRangeDirect IT_SET_SH_REG range packet + the real lib's marker NOP
D9sr1xGUriE sceAgcCreatePrimState prim registers from gs specials; logs (not aborts) on tessellation
HV4j+E0MBHE sceAgcCreateInterpolantMapping generalized semantic matching for SPI_PS_INPUT_CNTL_* (Kyty hard-asserts identity layout)

Semantics per Kyty (MIT) but layout-verified against this title's blobs and eboot disassembly — note the game passes AGC interface version 13 vs Kyty's 8, so Kyty was treated as a reference, not ground truth.

Result

  • All 36 built-in shaders register (PROSPER_GFXLOG: pgm_patched=1 each)
  • Zero unimplemented libSceAgc calls remain in the boot
  • Boot advances far past graphics init; next fault is a separate frontier at eboot+0xba6e08 (addr=0x8, non-AGC backtrace through 0xd3xxxx/0x15fxxxx) — will chase next
  • Tests 20/20 green (WSL2)

For the back-half pipeline (heads-up @recompiler-agent)

  • prosper_agc_shader_count() + the internal shader registry give you header+code pointers for every game shader — the .shader_text payloads are real gfx1030 streams for the recompiler
  • The register banks now fill with real values inside the game's own Dcb data packets — your CommandProcessor will start seeing real SET_*_REG traffic once the boot reaches submits
  • New tool: imgdump <module> <out.img> (flat image for objdump -b binary offline disassembly)

🤖 Generated with Claude Code

…d pipeline state

The 'null register source' [eboot+0x2048c60] was never libSceAgc-private
state: it is slot+0x10 of Unity's built-in shader registry (~30 0x28-byte
slots at 0x20488xx-0x2048cxx). eboot+0x14e74c0 parses shader ELFs embedded
in eboot rodata (EM_AMDGPU; .shader_header/.shader_text sections) and calls
sceAgcCreateShader(&slot->shader, header, code) per shader; our stub never
wrote *dst, so every slot stayed null and SetSource fed null into the
register-context sub-objects (the 0x3b5ea6/0x3b1562 faults). The earlier
'no writer exists' scan missed it because the store goes through a register
base (slot+0x10), not the literal address — same blind spot as RGCTX.

The register 'source' object IS the SDK Shader (SetSource reads +0x08
user_data / +0x28 specials / +0x5a type). Nothing is fabricated: the
classify tables ship inside the game's own shader blobs.

Implemented (semantics per Kyty MIT, layout-verified against this title's
blobs — note: game passes AGC interface version 13, newer than Kyty's 8):
- f3dg2CSgRKY sceAgcCreateShader: relocate self-relative header pointers,
  bind code, patch SPI/COMPUTE_PGM_LO/HI (all 5 stage pairs, beyond Kyty's
  ES/PS-only), double-relocation guard, *dst write, host-side registry
  (prosper_agc_shader_count) for the AGC->Vulkan pipeline.
- V++UgBtQhn0 sceAgcGetDataPacketPayloadAddress: register banks
  ([sub+0x10]/[sub+0x18]) resolve to Dcb data-packet payloads.
- n2fD4A+pb+g sceAgcCbSetShRegisterRangeDirect: IT_SET_SH_REG range packet.
- D9sr1xGUriE sceAgcCreatePrimState + HV4j+E0MBHE
  sceAgcCreateInterpolantMapping: pipeline regs from shader specials/
  semantics (generalized semantic matching, no hard-asserted layouts).

Result: all 36 built-in shaders register (pgm_patched=1), the whole
CreateWorkload register-context chain completes, ZERO unimplemented
libSceAgc calls remain in the boot. Next fault is far past graphics init
(eboot+0xba6e08, non-AGC backtrace). Tests 20/20.

Also: tools/imgdump (flat-image dumper for offline objdump disassembly —
how the registry writer was found) + .gitignore for the dump symlink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mattias800
mattias800 merged commit 6877ee6 into master Jul 4, 2026
mattias800 added a commit that referenced this pull request Jul 4, 2026
…mmandProcessor

The boot now reaches GPU submission: implement sceAgcDriverSubmitDcb
(AgcDriver UglJIZjGssM, per Kyty Gen5Driver::Packet{addr,dw_num}) by
replaying the submitted PM4 stream through gpu::run_command_buffer into a
persistent GpuState. Verified live: 'SubmitDcb #1: 71 dwords -> 12 packets
applied' — the game's own AGC command stream, built by our Dcb functions,
decoded and folded by the CommandProcessor. prosper_agc_submit_stats()
exposes submit/draw counts.

Also bound (semantics per Kyty): YUeqkyT7mEQ sceAgcDcbSetFlip (impl
existed, NID was never registered), Qrj4c+61z4A/z2duB-hHQSM = the Sh
variants of the indirect-patch helpers. Added tSBxhAPyytQ to the arg
tracers (fires once in CreateWorkload with (ctx,1,0x11,0x55,0x69) = the
cx/sh/uc register-set counts; not in Kyty).

Boot log now shows heavy register-set traffic (fPSCdQxgpSw/3KDcnM3lrcU/
0fWWK5uG9rQ triplets), a Zw7uUVPulbw polling loop (workload status?), and
Unity proceeding into 'unity default resources' loading. Terminal fault
unchanged at eboot+0xba6e08 (null [r15+0x140] on a Unity gfx object during
resource load) — next frontier. Note: data packets of type 0 exist (Kyty
only handles type 1); payload offset for type 0 needs RE.

Tests 21/21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 6, 2026
Replace the no-op stubs for sceKernelAddUserEvent / TriggerUserEvent /
AddHRTimerEvent / AddTimerEvent with a real backend: registration records
the (eq,id[,udata]) source; a trigger or timer expiry posts a matching
SceKernelEvent (FreeBSD filter ids: EVFILT_USER=-11, EVFILT_TIMER=-7) into
the equeue so WaitEqueue returns it. HRTimer reads the orbis timespec* arg.
New test_equeue_events (43/43 green) verifies user-trigger and timer paths.

Also adds permanent PROSPER_EVLOG tracing of actual event delivery
("-> delivered N ev(s)") and the user/timer registration+trigger calls.

Why: EVLOG re-diagnosis corrected the render-loop picture (docs/RENDER_LOOP.md).
The game gets far deeper than previously documented — it creates 35 shaders,
issues SubmitDcb #1 (setup, 0 draws) and one blank SubmitFlip(bufidx=-1), and
its flip thread (eboot+0x14bd47f) SUCCESSFULLY receives + processes ~1167 flip
events from our pump. The remaining stall: the FTM thread (eboot+0x14dfb43)
waits on UnityFTMFlipQueue for user event id=999 that is never triggered, and
the PreloadManager work-queue producer never runs — both downstream of GPU
upload completion we don't yet signal. Implementing these event sources is
correct HLE but (confirmed empirically) does NOT unblock: the trigger is
itself gated by the upstream GPU-execution stall, which remains the real
milestone for game pixels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 6, 2026
…fence handshake)

PROSPER_GFXLOG now prints every submitted packet's kind + payload, and the
ReleaseMem/WaitRegMem/WriteData Dcb builders log their real args. This
pinpointed the render-loop deadlock precisely (docs/RENDER_LOOP.md):

SubmitDcb #1 is a GPU→CPU *fence handshake*, not draws — DrawReset,
WaitFlipDone, ReleaseMem(EOP)×5, WaitRegMem, an embedded Flip(bufidx=2), and
two cache-flush EventWrites. The ReleaseMem args carry a destination GPU
address (a5) and the WaitRegMem waits on the SAME address — the classic
EOP-label fence: RELEASE_MEM writes a completion value to label A when the
pipe drains; WAIT_REG_MEM (and, cross-thread, the PreloadManager / FTM
user-event-999 producers) block until [A] satisfies a compare.

We never write A: agc_cb_release_mem / agc_dcb_wait_reg_mem / agc_dcb_write_data
zero their payloads and CommandProcessor::apply() no-ops events/fences. Since
our CommandProcessor folds each Dcb synchronously, honoring the fence (write
[dstGpuAddr] on submit, satisfy the matching WaitRegMem) is correct EOP
semantics and is the concrete next milestone. Remaining blocker to do it
right (not fake): the fence *value* is a 7th stack arg the HLE ABI shim
doesn't capture, and the WAIT_REG_MEM cmpFunc/ref must be honored exactly —
needs stack-arg capture or the Kyty Gen5 field reference (boot-wall-caliber).

All GFXLOG additions are env-gated and non-destructive; 43/43 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 6, 2026
…ted subprog read

Divcap now reads the deserializer's saved typetree node ([caller_rbp-0x1c0]) at the
safe driver bp (single compact write to avoid perturbation; reaches the real crash).

All 6 driver hits: #1 (type A), #2/#3 (type B), #4/#5/#6 all index the Shader
typetree node array. The byteSize/elemSize(0x10000)/count triplet shows #4/#5 read
7-element and #6 a 2-element array of ~64KB elements = m_SubPrograms (CubeBlur=2).
#6's node (byteSize=0x27a10, cnt=2) is a valid-but-different Shader field vs #4/#5;
no raw-field corruption visible. => the crash is a NESTED read: subprogram ->
m_CommonParameters -> m_ConstantBuffers, where 0x400 is consumed as an alignedString
count. Node records are 0x18 bytes {field0, byteSize@+8, field2@+0x10}; field0 looks
like a type/name string-offset, field2 a child link.

Pinning the exact wrong value now needs decoding the runtime TypeTree::Node struct
(resolve field0 -> field name) to see which node/handler is selected wrongly. Data,
relocations, and code are all verified correct; the divergence is in typetree
generation/dispatch for this one nested path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 7, 2026
… a separate load stall (#2) sits behind it

Added PROSPER_NULLGUARD=addr,len: a null-receiver guard trampoline (gated, default no-op). Installed on
the Stopwatch getter it removes the crash (700+ frames, no fault), but the cutscene still doesn't render:
the async loader stalls at exactly 3 resources.assets reads (blue clears). Both guard returns (0 and
rdtsc) stall identically, so it's a genuine SECOND blocker behind the Stopwatch, not a timing artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK
mattias800 added a commit that referenced this pull request Jul 7, 2026
…y's async-load WorkerThread (#43)

* diag(cutscene): post-#42 crash is deterministic — WorkerThread.field_0x40 is null (PROSPER_HWBP_KLASS)

With #42's GC/corruption fix landed, the level1 crash is no longer random: 4/4 repros crash identically
at Il2cpp+0x1637697. Added PROSPER_HWBP_KLASS (dump il2cpp class name of a register's object at a bp),
which identifies the crashing receiver as a 'WorkerThread' with a null field at +0x40, accessed while
iterating 'AsyncRequest`1' nodes — Unity's async-load job system. The remaining blocker is that single
uninitialized managed field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK

* diag(cutscene): stub test + full backtrace — WorkerThread.field_0x40 must be populated (PreloadManager integrate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK

* diag(cutscene): write-watch confirms WorkerThread.field_0x40 is never written (missing init)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK

* diag(cutscene): field_0x40 is a null System.Diagnostics.Stopwatch; WorkerThread is partially-init (ctor ran)

Resolved the null field's type via PROSPER_HWBP_GLOBAL (getter's class-global -> 'Stopwatch') and dumped
the WorkerThread layout via PROSPER_HWBP_FIELDS: it is partially initialized (several object fields set,
Stopwatch at +0x40 null), so the Stopwatch is created later than the ctor (lazily / in the worker Run()),
and the main-thread PreloadManager integration times AsyncRequests before it exists. Timer HLE is
implemented, so the Stopwatch class works; only the instance is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK

* diag(cutscene): PROSPER_NULLGUARD trampoline — Stopwatch is crash #1, a separate load stall (#2) sits behind it

Added PROSPER_NULLGUARD=addr,len: a null-receiver guard trampoline (gated, default no-op). Installed on
the Stopwatch getter it removes the crash (700+ frames, no fault), but the cutscene still doesn't render:
the async loader stalls at exactly 3 resources.assets reads (blue clears). Both guard returns (0 and
rdtsc) stall identically, so it's a genuine SECOND blocker behind the Stopwatch, not a timing artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhrNzDBN986tKzU7wpAEjK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 9, 2026
…all is a guest-chosen-token contract gap, not IO latency

Findings (all live-verified under gdb; full dossier in docs/UE4_APR_IOSTORE_BRINGUP.md):
- DOLL's boot stalls after exactly 90 APR reads on BOTH ext4 and 9p dumps — the
  earlier '~9.5 min IO-bound load' reading was this same stall. ext4 dump copy
  (/root/PPSA17942-app0) reaches it in ~15-80 s instead of ~9.5 min (~40x).
- Main thread parks in CreateGlobalShaderMap's blocking archive reader
  (IAsyncReadRequest::WaitCompletion, eboot+0x24ae718) waiting for an APR
  completion event; manually triggering its FEvent un-wedges the whole engine.
- APR completion tokens are GUEST-CHOSEN: the H896Pt-yB4I binding tag
  ((ring<<58)|1000+n) is the expected token in the listener's per-ring tracking
  slot ([ctx+0xa8+ring*0x28] -> [slot+0x10]; ctx is an eboot global). prosper's
  invented per-ring counters can never match, so the engine believes async
  batch #1 is in flight forever and the IO pipeline jams behind it.
- ASoW5WE-UPo's out pointers alias the request's completion record
  (status@req+0x28 / bytes@req+0x30): writing a token there fails the read
  (eboot+0x22738a5 check) -> 'GEngineLoop.PreInit Failed!'.
- Events with mismatched/slot-less tokens fault the handler (eboot+0x229dd21 /
  +0x229df3e) — re-verified 2/2 both directions.

Changes (default behavior byte-identical to before — verified: same stall
point, 0 faults, ctest 56/56, Messenger render smoke 488 frames):
- PROSPER_APR_TAG_ECHO=1 (experimental): bound cbs echo the H896 tag as token,
  post it verbatim on the binding's own equeue, preserve the completion record,
  plus a slot-echo scan that re-posts guest-tracked expected tokens. Consumes
  the jammed batch but still trips the listener's 1..1000 range walk over a
  non-empty hash — the remaining unknown is how real HW seeds the listener's
  per-ring last-processed to the tag base.
- PROSPER_APR_EVENT_ARG8=1 (bisection only): arg8-based eventful marking,
  disproven as a discriminator.
- Diagnostics: ReadFile arg8/notify dumps (FILELOG), ASoW out-slot logging
  (AMPRLOG), sSAUCCU/H896 ctx+tag capture.

Refs #180.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 9, 2026
…L passes the 90-read wall to its first DCBs + flip

Fixes #208. The #180 open question — how real HW seeds the listener's
per-ring last-processed so the first tag event's range walk starts at the
tag base — is answered by static disassembly: THE GUEST SEEDS IT ITSELF.

The listener-ctx constructor (eboot+0x22a0670, ctx = the eboot global
0x95aebd8) creates the APREventQueue, registers ids 0x74fe+ring for rings
0..5, and initializes each ring pair: token counter [ctx+0xc0+ring*0x28] =
0x3e8 (1000) and last-processed [ctx+0xc8+ring*0x28] = 0x3e7 (999). The
batch submit (+0x22a02b0) draws token = (ring<<58)|counter++, binds it as
the H896Pt-yB4I tag, tracks it at [slot+0x10] AND in a {token -> callback}
hash at ctx+0x58; the walk over the first tag event covers exactly seq 1000.

The #180 tag-echo experiment's residual +0x229df3e fault was self-inflicted:
the listener stores last := cnt UNCONDITIONALLY after every event
(+0x2274143), so prosper's invented-counter events (registration catch-up
replays, vWU direct-read wakeups) regressed the guest's 999 seed; the next
real tag event then walked the gap seqs into the fatal null-entry path (a
64-byte ymm swap against address 0x10 — fatal on real HW too, proving the
guest guarantees dense counters from exactly 1000).

Default behavior now (experiments PROSPER_APR_TAG_ECHO / slot-echo retired):
- H896-bound submits: post the binding tag verbatim (2 ms deferred, coalesced
  per ring to the highest counter — kqueue "completed up to" semantics), and
  do NOT write the out slots (they alias the completion record).
- Everything else posts NO event: unbound submits keep returning counter
  tokens through the out slots (record-polled), vWU direct reads complete
  eventless (live-verified by the #180 gdb-unwedge streaming the whole load).
- Registration is bookkeeping only: catch-up replay and ring resets removed.

Measured (ext4 fast path): the deterministic 90-read stall is GONE — 978 APR
reads served, the guest tag counter advances 112+ batches through the
listener, GlobalShaderMap loads, PreInit completes, VideoOut + AGC RHI come
up, every plugin assetregistry.bin loads, and the engine submits its first
real DCBs (SubmitDcb #1: 85 dwords/13 packets, #2: 1436 dwords/232 packets)
and performs its first flip (GpuFlip handle=0x1001 bufidx=0 fliparg=1).
No draws yet — the post-flip wall (RHI thread in a VideoOutQueue wait loop,
new unimplemented libSceAgc/libSceVideoOut NIDs) is documented in
docs/UE4_APR_IOSTORE_BRINGUP.md as the next frontier.

ctest 60/60; Messenger smoke renders 3860 frames in 300 s.
CONFIDENCE: HIGH (static disassembly of ctor/submit/listener/handler + live
boot flipping from the stall to DCB submission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 9, 2026
…ression) — DOLL reaches first DCBs + flip (#208) (#209)

* fix(hle/ampr): SetBuffer no longer maps over live guest heap — fixes the issue-88 null virtual call

Root cause of the UE4 (PPSA17942) early-RHI/CVar null virtual call (rip=0 at
eboot+0x24c75ef, worker-face HLE faults): sceAmprCommandBufferSetBuffer HLE
(k_ampr_push_map) treated EVERY SetBuffer as 'map fresh phys page at va, plus a
mirror at va-0x540000000'. The APR read flow also issues SetBuffer for an
already-live 0x4000-byte descriptor buffer (va=0x15a0dfc000); its mirror
(0x1060dfc000) MAP_FIXED'd fresh zero pages over live MallocBinned heap — the
exact pages holding FConsoleManager's registered-CVar key strings. The zeroed
key made FEngineModule::StartupModule's unchecked
FindConsoleVariable("r.Shadow.CacheWPOPrimitives")->SetOnChangedCallback(...)
operate on null -> call *0x10([0]) -> rip=0. Proven by a single-run
registration-time vs crash-time key dump plus a hardware write-watch that never
fired (no CPU store — the mapping was replaced under the VA).

- k_ampr_push_map: discriminate the two SetBuffer flavors by their live-captured
  args (map flavor: a3 != va, a4 = 0xffffffff sentinel; existing-buffer flavor:
  a3 == va, a4 = allocCtx, a5 = small flags — 3/3 and 13/13 in capture). The
  existing-buffer flavor is now a strict memory no-op; the map flavor keeps the
  fresh-zeroed-phys + mirror model the pool flow depends on.
- f_apr_read_submit: process_vm_writev cannot fault through the lazy-commit
  SIGSEGV handler, so an untouched reserved dst EFAULT'd and the record
  published prosper's host staging pointer, which the engine later freed
  ('FMallocBinned3 Attempt to free an unrecognized block'). Commit lazy 64K
  pages exactly like the fault handler and retry.
- linker: PROSPER_INITLOG=1 prints each module's DT_INIT/init_array (diagnostic
  used to prove the eboot self-runs its .ctors from DT_INIT at +0x10).

Boot now passes LoadPreInitModules/Engine StartupModule deterministically;
FConfigCacheIni::InitializeConfigSystem runs, plugin/localization/ICU pak reads
serve, and the engine reads DefaultEngine.ini. New wall (4/4 deterministic):
FMallocBinned3 free-unrecognized-block right after the compressed
DefaultEngine.ini entry read (documented in UE4_APR_IOSTORE_BRINGUP.md).

ctest 47/47; Messenger render smoke 2860+ frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle/ampr): map-flavor SetBuffer mirror no longer clobbers live MallocBinned heap (#107)

The UE4 title (PPSA17942 DOLL) crashed with "FMallocBinned3 Attempt to free an
unrecognized block" during FConfigCacheIni::InitializeConfigSystem, right after
DefaultEngine.ini was read from the pak.

Root cause (proven by MEMLOG + gdb HW watchpoint): k_ampr_push_map's map flavor
maps the buffer at va AND MAP_FIXED's a "mirror" at va-0x540000000 (a heuristic
pinned on one title, The Messenger). On this title the mirror VA (e.g.
0x11e0df0000) lands on a page the guest already lazy-committed and filled as
MallocBinned heap; the later MAP_FIXED replaced that live heap page with a fresh
page aliased to the Ampr buffer's phys, corrupting the pool. Downstream the pool
carved two blocks 0x10 apart, their fields aliased, and config teardown freed a
TSparseArray bookkeeping word (0xd) as a pointer. Same clobber class as #88,
here via the map-flavor mirror.

Fix: only create the mirror when its target VA is not already backed guest
memory (mincore == fully mapped == live -> skip). A live target means the guest
uses that VA as its own heap, not as a second view of the Ampr pool, so the
mirror is a false positive of the 0x540000000 rule and must not overwrite it.

Verified: the FMallocBinned3 crash is gone; DOLL boots through config init into
UE's online/PSN subsystem init (new wall). The Messenger is unaffected — its
mirror targets are unmapped at map time so it still creates the mirror (smoke:
0 mirror-skips, 30k+ render lines, no crash). ctest 50/50.

Also: named two more libSceAmpr NIDs recovered by brute-forcing nid_hash
(tZDDEo2tE5k = sceAmprCommandBufferGetSize, ULvXMDz56po =
sceAmprCommandBufferClearBuffer); GetSize verified non-critical to the crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): deliver APR async read-completion events — DOLL boots past CreateGlobalShaderMap into AGC RHI bring-up (issue #115)

The issue-115 hang was never online/PSN init (every online NID fires exactly
once; all threads parked): the main thread waits in CreateGlobalShaderMap for
an async APR read of Engine/GlobalShaderCache-SF_PS5.bin (inside
pakchunk0-ps5.pak) whose completion event prosper never posted. The engine's
FAPREventQueueListener blocks in WaitEqueue(APREventQueue, 15) and decodes
each event via sceKernelGetEventData as (ring<<58)|counter, completing every
newly-counted request via a token match.

Implemented, from live captures + disassembly:
- sSAUCCU1dv4 / H896Pt-yB4I: register the APR event target equeue; H896 also
  records the BOUND command-buffer ctx.
- libkernel ASoW5WE-UPo: the APR submit (cb, ring_1based, out1, out2) —
  assigns the packed completion token, publishes it through both out slots,
  returns 0 (nonzero is an engine error path). Completion events fire ONLY
  for submissions of an equeue-bound cb: unbound (record-polled) submissions
  delivered to the listener hit its non-null-tolerant hash-miss path
  (fault at eboot+0x229df3e, 2/2 runs).
- vWU-odnS+fU: the direct async read (fileId, dst, size, off, off, ring1b) —
  the exact GlobalShaderCache region; pread + lazy-commit dst write +
  deferred completion event.
- Tokens: per-ring counters; untracked pre-registration history resets at
  first registration (no phantom seqs); posts deferred ~2ms and always carry
  the ring's current counter (models DMA latency; closes the submit-vs-
  tracking-insert race).
- sceKernelGetEventData/Id/Filter/Fflags/UserData/Error (Kyty field reads) —
  the listener consumes events exclusively through GetEventData.
- POSIX pthread_cond_timedwait (27bAgiJmOh0): real timed wait on the shared
  cond/mutex slot scheme; FreeBSD ETIMEDOUT=60. The unimplemented-0 stub spun
  UE's IAsyncReadRequest::WaitCompletion(timeout) loop at 100% CPU
  (live-caught at RA 0x4022ea954).
- Cached per-container host fd for APR reads (open+close per read over the
  WSL 9p mount measured ~1.7s/read against the 2GB pak).

Result: GlobalShaderMap loads, PreInit passes online init unchanged, 180+
async/sync pak reads serve, boot reaches AGC RHI bring-up (AgcCleanup/
Interrupt/SubmissionThread + AgcEqueue with AgcDriverAddEqEvent 0x20/0x0)
and keeps streaming content. ctest 50/50.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): four follow-on walls past the APR event fix — cond_timedwait, RTC ticks, APR fd cache, race-free stack-arg capture (issue #115)

With APR completion events delivered, DOLL's load phase runs deep and
parallel, exposing four more real contracts (each live-diagnosed):

- POSIX pthread_cond_timedwait (libScePosix 27bAgiJmOh0): real timed wait on
  the shared cond/mutex pointer-slot scheme, FreeBSD ETIMEDOUT=60. The
  unimplemented-0 stub meant 'signaled', spinning UE's
  IAsyncReadRequest::WaitCompletion(timeout) loop at 100% CPU (the one busy
  thread's RA 0x4022ea954 inside prosper_on_unimpl, caught live).
- sceRtcSetTick/sceRtcGetTick (tick <-> UTC datetime, shadPS4 semantics):
  unimplemented SetTick left the out datetime zeroed and FDateTime(0,0,0)
  'Invalid Date values' fatals spammed by the thousands.
- Per-container host fd cache for APR reads: open+close per read against the
  2GB pak over the 9p mount measured ~1.7s/read; load phase now ~15x faster.
- sceAmprAprCommandBufferReadFile entry shim passes %rsp as a real 7th
  argument instead of a global: concurrent loader/precacher submissions
  could read the file OFFSET from another thread's frame (no TLS, issue #89
  constraint respected).
- APR event delivery now gated on the H896Pt-yB4I cb<->equeue BINDING (the
  principled discriminator; live-confirmed: the one bound ctx is exactly the
  one submit whose event the listener consumes).

New wall documented in UE4_APR_IOSTORE_BRINGUP.md: MallocBinned3 free-block
canary corruption during the parallel content-load burst (~10s in) —
A/B-proven independent of these fixes, no prosper page clobber in MEMLOG;
needs a HW write-watch session.

ctest 50/50; Messenger smoke renders frames with no fatals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): sceKernelReserveVirtualRange non-FIXED hint is a SEARCH START — two guest VM spaces got the same base and MallocBinned3's bit tree overwrote its pool-info table pointer (issue #161)

The MB3 "Corruption Canary" spam-crash during DOLL's parallel content-load
burst was never a thread/TLS or completion race: UE4 reserves its 512 GiB
MallocBinned3 arena (len=0x8000000000, flags=0) and then a 64 MiB allocator-
metadata pool (len=0x4000000, flags=0), BOTH with hint=0x1000000000 and
NEITHER with SCE_KERNEL_MAP_FIXED. prosper treated every hinted reserve as
fixed, and the #115 "re-reserve-of-own-range -> OK" workaround blessed the
second call with the SAME base — so the metadata space overlapped the arena,
and MB3's class-0 pool-info-table pointer array and its block-of-blocks bit
tree were carved at the SAME VA (0x1000000000).

Proven live under gdb: the first fatal's entry pointer was misaligned
(0x20015f0011) while the aligned table at 0x20015f0000 was healthy; the
stored table pointer read 0x20015f0001 (low byte flipped) with slot 1 all-
ones; a HW watchpoint on those qwords caught the writer — the guest's own
bit tree (bts loop, eboot+0x231c0b0..ce) filling level-1 bits as pools 0..63
of size-class 0 were allocated, then propagating Bits[0] |= 1 into the
aliased pointer slot when the qword went all-ones.

Fix (k_reserve_vrange): honor SCE_KERNEL_MAP_FIXED (0x10). Fixed keeps the
old semantics including the #115 idempotent own-range re-reserve. A non-
fixed hint now SEARCHES upward (MAP_FIXED_NOREPLACE probes, skipping past
tracked mappings), matching the BSD/PS4/PS5 contract (shadPS4 SearchFree).
len==0 -> EINVAL, search exhaustion -> ENOMEM. The metadata pool now lands
at 0x9000000000.

Verified: DOLL boots through the whole load burst with 0 canary lines over
full 300 s runs (previously ~31k fatal lines + SIGSEGV at ~10 s); ctest
52/52; Messenger smoke renders 400+ frames at 1920x1080, unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: issue-161 post-fix frontier — full content load survives; first AGC driver calls + ReleaseMem packets at ~9.5 min (IO-bound)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* diag(apr): issue-180 boot wall dissected — deterministic post-load stall is a guest-chosen-token contract gap, not IO latency

Findings (all live-verified under gdb; full dossier in docs/UE4_APR_IOSTORE_BRINGUP.md):
- DOLL's boot stalls after exactly 90 APR reads on BOTH ext4 and 9p dumps — the
  earlier '~9.5 min IO-bound load' reading was this same stall. ext4 dump copy
  (/root/PPSA17942-app0) reaches it in ~15-80 s instead of ~9.5 min (~40x).
- Main thread parks in CreateGlobalShaderMap's blocking archive reader
  (IAsyncReadRequest::WaitCompletion, eboot+0x24ae718) waiting for an APR
  completion event; manually triggering its FEvent un-wedges the whole engine.
- APR completion tokens are GUEST-CHOSEN: the H896Pt-yB4I binding tag
  ((ring<<58)|1000+n) is the expected token in the listener's per-ring tracking
  slot ([ctx+0xa8+ring*0x28] -> [slot+0x10]; ctx is an eboot global). prosper's
  invented per-ring counters can never match, so the engine believes async
  batch #1 is in flight forever and the IO pipeline jams behind it.
- ASoW5WE-UPo's out pointers alias the request's completion record
  (status@req+0x28 / bytes@req+0x30): writing a token there fails the read
  (eboot+0x22738a5 check) -> 'GEngineLoop.PreInit Failed!'.
- Events with mismatched/slot-less tokens fault the handler (eboot+0x229dd21 /
  +0x229df3e) — re-verified 2/2 both directions.

Changes (default behavior byte-identical to before — verified: same stall
point, 0 faults, ctest 56/56, Messenger render smoke 488 frames):
- PROSPER_APR_TAG_ECHO=1 (experimental): bound cbs echo the H896 tag as token,
  post it verbatim on the binding's own equeue, preserve the completion record,
  plus a slot-echo scan that re-posts guest-tracked expected tokens. Consumes
  the jammed batch but still trips the listener's 1..1000 range walk over a
  non-empty hash — the remaining unknown is how real HW seeds the listener's
  per-ring last-processed to the tag base.
- PROSPER_APR_EVENT_ARG8=1 (bisection only): arg8-based eventful marking,
  disproven as a discriminator.
- Diagnostics: ReadFile arg8/notify dumps (FILELOG), ASoW out-slot logging
  (AMPRLOG), sSAUCCU/H896 ctx+tag capture.

Refs #180.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): ADAPTIVE mutexes return EDEADLK on self-lock (FreeBSD contract) — un-wedges the DOLL boot

Regression from #183 (first-bad-commit fe8e8d7, found by git bisect): the DOLL
(PPSA17942) boot wedged ~10 s in, single-threaded, self-deadlocked inside
k_mutex_lock (mutex __owner == the calling thread, glibc __kind = NORMAL).

UE4's PS5 lock wrapper (eboot+0x24ca4b6; same shape inside the APR completion
handler at +0x229dcf5) builds its own recursion on the FreeBSD self-lock
contract:

    err = mutex_lock(obj);
    if (err) /* EDEADLK: already mine */ skip-acquire;
    depth++;

and DOLL creates those mutexes with pthread_mutexattr_settype(type=4)
(ADAPTIVE_NP — live-captured via the new PROSPER_MUTEXLOG at the wedge).
FreeBSD libthr's mutex_self_lock returns EDEADLK for ERRORCHECK AND
ADAPTIVE_NP (adaptive is errorcheck + a spin heuristic); only NORMAL
hard-deadlocks. #183 followed Kyty's 4 -> NORMAL mapping, which self-deadlocks
on glibc (its ADAPTIVE/NORMAL self-lock blocks forever). Kyty is weighted DOWN
here per policy: no title it runs exercises adaptive self-lock.

- settype type 4 -> host PTHREAD_MUTEX_ERRORCHECK (was NORMAL)
- a FRESH mutexattr defaults to ERRORCHECK, the FreeBSD attr default (#183
  only covered the no-attr init path; attr-without-settype got glibc NORMAL)
- the static ADAPTIVE sentinel (1) also maps to ERRORCHECK
- PROSPER_MUTEXLOG=1 logs settype/init (the diagnostic that caught this)

Verified: DOLL boots past the wedge into the full content load (was: 5 log
lines then a permanent single-thread futex wait); ctest 60/60; Messenger
smoke renders 3860 frames in 300 s. CONFIDENCE: HIGH (FreeBSD libthr source
+ the live wedge flipping to a full boot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): APR completions carry the guest-chosen tag by default — DOLL passes the 90-read wall to its first DCBs + flip

Fixes #208. The #180 open question — how real HW seeds the listener's
per-ring last-processed so the first tag event's range walk starts at the
tag base — is answered by static disassembly: THE GUEST SEEDS IT ITSELF.

The listener-ctx constructor (eboot+0x22a0670, ctx = the eboot global
0x95aebd8) creates the APREventQueue, registers ids 0x74fe+ring for rings
0..5, and initializes each ring pair: token counter [ctx+0xc0+ring*0x28] =
0x3e8 (1000) and last-processed [ctx+0xc8+ring*0x28] = 0x3e7 (999). The
batch submit (+0x22a02b0) draws token = (ring<<58)|counter++, binds it as
the H896Pt-yB4I tag, tracks it at [slot+0x10] AND in a {token -> callback}
hash at ctx+0x58; the walk over the first tag event covers exactly seq 1000.

The #180 tag-echo experiment's residual +0x229df3e fault was self-inflicted:
the listener stores last := cnt UNCONDITIONALLY after every event
(+0x2274143), so prosper's invented-counter events (registration catch-up
replays, vWU direct-read wakeups) regressed the guest's 999 seed; the next
real tag event then walked the gap seqs into the fatal null-entry path (a
64-byte ymm swap against address 0x10 — fatal on real HW too, proving the
guest guarantees dense counters from exactly 1000).

Default behavior now (experiments PROSPER_APR_TAG_ECHO / slot-echo retired):
- H896-bound submits: post the binding tag verbatim (2 ms deferred, coalesced
  per ring to the highest counter — kqueue "completed up to" semantics), and
  do NOT write the out slots (they alias the completion record).
- Everything else posts NO event: unbound submits keep returning counter
  tokens through the out slots (record-polled), vWU direct reads complete
  eventless (live-verified by the #180 gdb-unwedge streaming the whole load).
- Registration is bookkeeping only: catch-up replay and ring resets removed.

Measured (ext4 fast path): the deterministic 90-read stall is GONE — 978 APR
reads served, the guest tag counter advances 112+ batches through the
listener, GlobalShaderMap loads, PreInit completes, VideoOut + AGC RHI come
up, every plugin assetregistry.bin loads, and the engine submits its first
real DCBs (SubmitDcb #1: 85 dwords/13 packets, #2: 1436 dwords/232 packets)
and performs its first flip (GpuFlip handle=0x1001 bufidx=0 fliparg=1).
No draws yet — the post-flip wall (RHI thread in a VideoOutQueue wait loop,
new unimplemented libSceAgc/libSceVideoOut NIDs) is documented in
docs/UE4_APR_IOSTORE_BRINGUP.md as the next frontier.

ctest 60/60; Messenger smoke renders 3860 frames in 300 s.
CONFIDENCE: HIGH (static disassembly of ctor/submit/listener/handler + live
boot flipping from the stall to DCB submission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hle): sceAmprCommandBufferGetSize returns the per-cb capacity — un-spins the IoStore append loop

Follow-up to the #208 un-jam: with APR completions flowing, the boot next
parked its IoStore thread in a busy poll (live gdb: the only running thread
inside k_ampr_getsize, guest RA eboot+0x227e2eb). The guest's batch-append
loop (eboot+0x227e2c0) polls

    GetSize(cb) - <used>(cb) > 0xff     (wrappers 0x59b5dd0 / 0x59b5e00)

before appending the next ~0x100-byte command packet: GetSize is the cb's
fixed byte CAPACITY, the companion its used/pending count (a 0-returning
stub today — truthful, since prosper serves every appended command
synchronously at ReadFile time, so nothing is ever pending). The old
GetSize returned a single GLOBAL "last cb size" which is 0/tiny for this
cb flavor (its init carries the size in a5=0x720, not a1), so free space
never exceeded 0xff and the loop spun forever.

k_ampr_init now records {cb -> capacity} from both live-captured init
flavors (a1 = size for the APR read-request flavor, a5 = size for the
IoStore batch cb); k_ampr_getsize returns the per-cb capacity, falling
back to the legacy global, then to a roomy 0x10000.

Verified: the spinning thread now waits in k_eq_wait like the rest of the
pool and the boot continues (main thread busy in guest-side processing).
ctest 60/60. CONFIDENCE: MED (semantics inferred from the decompiled
append loop + the live spin clearing; NID name from the verified corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: #208 session frontier — GetSize spin solved, next wall diagnosed to the thread level

The post-un-jam boot state, live-measured: first DCBs + flip land, then the
main thread runs hot in a UE4 flush-async-loading-shaped lock-poll loop while
every IO/TaskGraph worker parks in its wait — the async-loading pipeline is
missing a completion/user-event. Next-session pointers (wait shapes, lock
words, new unimplemented AGC/VideoOut NIDs) recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 10, 2026
… suspect-report time gate (refs #312)

- honor_dma_data: reject dst < 0x10000 (a small dst is a GDS offset we don't model, and the
  PROSPER_NULL_PAGE read-only zero page would pass guest_readable and SEGV the fill).
- PROSPER_WAIT_DEFER deadlock fix: deferred streams were only re-checked at subsequent submits;
  a guest waiting on a paused stream's EOP before submitting again wedged the boot at submit #1.
  A lazy 2 ms watchdog (started on the first deferred fold, inert otherwise) re-runs the flush
  under the submit mutex and fires the owed EOP pulses. WAIT_DEFER stays default-off: with the
  DmaData init in place it now runs without corruption fatals but at ~2x slowdown and one run
  was OOM-killed (deferred-item accumulation) — usable as a diagnostic, not yet as the default.
- report_suspect_write: skip the first 10 s (the boot-time label-array residue burned the whole
  report budget at t=3.4 s in every run). Run evidence: the mid-burst SUSPECT-REL1 chain reads
  (pre@build = next label's address) are the guest WALKING its intrusive pending-label list at
  emit time — normal protocol, not corruption; the +0 field is overloaded (list next / fence
  value / pool next), which is why any out-of-order fence write mangles a chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 10, 2026
…the barrier (liveness experiment, still opt-in)

Every WAIT_DEFER run that withheld flips/EOP wedged the frame loop; this variant lets the
pacing signals through and holds back only the corrupting ReleaseMem/WriteData/EventWrite/
DmaData writes (never show a fence value ahead of its barrier; 'label still 0 for longer'
is the safe direction of the guest's consumption poll). Result: DEFER TIMEOUTs now fire
(the watchdog works) but the boot still wedges at submit #1 — the guest CPU-polls a label
whose write is deferred and enters a no-submit fallback before the 50 ms timeout releases
it. Conclusion recorded on #312: the deferral needs a real per-queue model (defer only
CROSS-queue-dependent effects), not a whole-stream pause. Default behavior (WAIT_DEFER=0)
is provably unchanged: g_fold_deferring is never set and last_fold_deferred() is false, so
the unconditional EOP pulse and the un-deferred Flip case equal the old path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Jul 10, 2026
…rker label-init protocol leg (refs #312) (#337)

* diag(#312): fence build journal (timing-vs-wrong-target discriminator) + hot-label page watch with stack capture

- command_processor: per-packet fence BUILD journal (target, pre-content at build,
  timestamp); REL1/WDATA stomp reports now include build-age + STALE-AT-BUILD verdict;
  unsatisfied WaitRegMem logs build-age + freed-heap-shaped content flags.
- hle_agc: journal recording at ReleaseMem/WriteData/WaitRegMem builders + address patchers;
  PROSPER_WATCH_HOT=N arms the label page watch on the Nth fence build to one heap label.
- exec_image_linux: PROSPER_WATCH_ABS fixed-slot page watch, PROSPER_WATCH_MAX cap,
  lwatch call-stack capture (eboot return addrs), worker-fault deep dump (registers +
  heap windows + GPU write-ring at the MB3 freelist-pop faults).
- tools/dbg: diswin.sh (eboot text window disasm), findcalls.py/findrefs.py.

Run-1/2 findings (repro'd twice): fence targets are FAITHFUL (built-addr == written addr,
age 0-9ms) but the guest freshly records fences/waits to labels ALREADY freed by MB3
(pre@build = 0xe7010002 free-block headers / freelist chain ptrs) for seconds during the
menu-load burst; freelist-pop fault head slot captured (pool struct, head=0x20015f00).

refs #312

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gpu): execute sceAgcDcb/AcbDmaData immediate fills — the #312 MallocBinned3 heap-corruption root cause

The RHIThread translate loop's per-segment consumed-marker protocol is
  DmaData(label := 0, 4 bytes, CP-immediate)  +  ReleaseMem(label <- 1, EOP)
with the label a LIFO-recycled 0x20-byte MallocBinned3 block whose pointer lives at the
64 KiB command-chunk boundary header. We dropped every DMA_DATA packet, so the label kept
the PREVIOUS generation's 1: the guest's consumption poll (frame-end batched free at
eboot+0x220bd65) instantly saw 'consumed', freed the label while this generation's fence
packets were still in flight, and our faithful value-1 EOP writes landed in freed heap —
the 'Canary was 0x3, should be 0x1' / 'free an unrecognized block 0x1000000001' /
0x20015f00 freelist-pop fatals (all three signatures are the same stomp).

Evidence (live, this branch): hot-label page watch caught the full alloc->fence->free->
realloc lifecycle with call stacks — 16 of our fence writes landed on the INTACT
0xe7010002 free-block header in 10 s of one label; no guest CPU write ever initializes
the label between alloc and fence (the GPU DmaData is the only init). ABI re-pinned from
three eboot callsites (a4=dst — one site passes a fresh GMalloc buffer there; a1=src-or-
immediate — patcher family sceAgcDmaDataPatchSetSrcAddressOrOffsetOrIMMEDIATE; stack
arg9=byte count, recovered via the validated-guest-frame walk like w1KFAHVqpaU).

- hle_agc: agc_dcb_dma_data re-encoded to the pinned ABI; new sceAgcAcbDmaData
  (-RnpfpxIhec) async-compute sibling; both DmaData packet patchers (IxYiarKlXxM,
  cdDRpqcFGbU). GFXLOG kind-name table refreshed (Jump/SetPredication/DmaData).
- pm4_decode: R_DMA_DATA -> Kind::DmaData {dst, srcOrImm, bytes, sels}.
- command_processor: honor_dma_data executes the immediate-fill form (32-bit value
  replicated; covers the 4-byte label init and the 64 KiB chunk zero-fills whose boundary
  headers hold the label pointers), ordered through the same completion-write FIFO the
  ReleaseMem leg rides (stream order between generations at a recycled address is what
  the guest's poll depends on). Unmodeled forms (real src copies/GDS) log-and-skip.

Menu-drive repro: pre-fix fatal at t=40-55s; init-only gate moved it to t~150s; with the
chunk fills executing, a full 175 s input-driven run completes with no fatal.

refs #312

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gpu): DMA_DATA dst>=0x10000 guard + WAIT_DEFER watchdog flusher + suspect-report time gate (refs #312)

- honor_dma_data: reject dst < 0x10000 (a small dst is a GDS offset we don't model, and the
  PROSPER_NULL_PAGE read-only zero page would pass guest_readable and SEGV the fill).
- PROSPER_WAIT_DEFER deadlock fix: deferred streams were only re-checked at subsequent submits;
  a guest waiting on a paused stream's EOP before submitting again wedged the boot at submit #1.
  A lazy 2 ms watchdog (started on the first deferred fold, inert otherwise) re-runs the flush
  under the submit mutex and fires the owed EOP pulses. WAIT_DEFER stays default-off: with the
  DmaData init in place it now runs without corruption fatals but at ~2x slowdown and one run
  was OOM-killed (deferred-item accumulation) — usable as a diagnostic, not yet as the default.
- report_suspect_write: skip the first 10 s (the boot-time label-array residue burned the whole
  report budget at t=3.4 s in every run). Run evidence: the mid-burst SUSPECT-REL1 chain reads
  (pre@build = next label's address) are the guest WALKING its intrusive pending-label list at
  emit time — normal protocol, not corruption; the +0 field is overloaded (list next / fence
  value / pool next), which is why any out-of-order fence write mangles a chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wait-defer(#312): 50ms timeout + memory bound + missing <thread> include (still opt-in, liveness not solved)

Corrections from live testing: the watchdog commit didn't compile (missing <thread>;
the build-error grep truncated the failure), so the earlier WAIT_DEFER runs used the
watchdog-less binary. Full picture across 5 WAIT_DEFER runs (both binaries):
ZERO MallocBinned3 corruption fatals — strong causal confirmation that the remaining
corruption comes from the fence writes we emit when the fold proceeds past an
unsatisfied WAIT_REG_MEM barrier — but every run wedged or OOM-killed before full
length (submits stop at t=5-59s, no DEFER TIMEOUTs logged), so the naive stream-pause
model lacks liveness and stays default-off. kDeferTimeoutMs 500->50ms, total-item
memory guard (kDeferMaxItems force-flush), watchdog now actually builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wait-defer(#312): defer only memory writes — flips + EOP pulses pass the barrier (liveness experiment, still opt-in)

Every WAIT_DEFER run that withheld flips/EOP wedged the frame loop; this variant lets the
pacing signals through and holds back only the corrupting ReleaseMem/WriteData/EventWrite/
DmaData writes (never show a fence value ahead of its barrier; 'label still 0 for longer'
is the safe direction of the guest's consumption poll). Result: DEFER TIMEOUTs now fire
(the watchdog works) but the boot still wedges at submit #1 — the guest CPU-polls a label
whose write is deferred and enters a no-submit fallback before the 50 ms timeout releases
it. Conclusion recorded on #312: the deferral needs a real per-queue model (defer only
CROSS-queue-dependent effects), not a whole-stream pause. Default behavior (WAIT_DEFER=0)
is provably unchanged: g_fold_deferring is never set and last_fold_deferred() is false, so
the unconditional EOP pulse and the un-deferred Flip case equal the old path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Aug 10, 2026
…Quest VII boots on Windows (#2447)

* fix(win): never emulate an %fs/%gs read as a null-page read

Dragon Quest VII Reimagined (PPSA17942) did not boot on Windows at all: it
died in the UE4 bootstrap with an ACCESS-VIOLATION at 0xfffffffffffffff0,
rip=eboot+0x24d3c3c, 0 frames rendered, under both tools/screenshot and
prosper-app.

PROSPER_NULL_PAGE (which UE4 titles need) cannot map the reserved bottom
64 KiB on Windows, so exec_image_win.cpp emulates a faulting low-address
READ by zeroing the destination register and stepping over the instruction.
decode_low_read_dest() skipped 0x64/0x65 as ignorable legacy prefixes, so an
%fs-relative access qualified as "a read of a guest null field".

It is not one. For every other prefix the faulting linear address IS the
address the guest computed, so a fault below 64 KiB really does mean the
guest read a null field. With an FS/GS override the address is segbase +
offset, so a low fault means OUR base is wrong -- Windows zeroes the user FS
base at every kernel transition -- and the guest's offset was small, not
null. Emulating it as zero both fabricates a value and consumes the fault
that guest_fs_reapply() needs to restore the base and retry.

Measured, verbatim bytes at eboot+0x24d3c30:

    66 66 66 64 48 8b 04 25 00 00 00 00   mov rax,QWORD PTR fs:0x0   (12 B)
    80 b8 f0 ff ff ff 00                  cmp BYTE PTR [rax-0x10],0x0

The TCB self-pointer load was emulated to rax=0 and Rip advanced 12 bytes to
0x24d3c3c, where the cmp faulted at 0xfffffffffffffff0. The run log shows
exactly one "[nullpage] #1 addr=0x0 rip=eboot+0x24d3c3c (read->0)" line
immediately before the fatal fault -- so that single emulation WAS the bug;
the null-page emulator had no legitimate hit on this title.

Decline FS/GS overrides in the decoder. CS/DS/ES/SS (0x2E/0x36/0x3E/0x26)
stay ignorable: x86-64 forces those bases to zero, so they are genuinely
equivalent to no prefix.

After: boot completes, no access violation, zero null-page emulations, AGC
device init runs and the renderer produces frames (168 in 6 s).

Tests use the verbatim DQ7 encoding. The load-bearing arm is the
discriminator: the same instruction with 0x64 removed must still decode
(reg=rax, len=11), so the rejection is proved to come from the FS prefix and
not from the triple-0x66 padding or the SIB/disp32 form. Against the
unfixed header the six new reject arms fail (the DQ7 one as reg=0 len=12,
matching the runtime log) while all four discriminator arms pass.

Refs #1874
Fixes #2112

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(win): record the null-page/%fs-reapply interaction and correct #2112's route

The gotcha list already said Windows zeroes the user %fs base at every kernel
transition and that the VEH re-apply is what saves guest TLS. It did not say
that the re-apply is FAULT-driven, so anything that answers the fault first
silently breaks guest TLS -- which is what PROSPER_NULL_PAGE's low-read
emulator did until #2112.

Also records that #2112's original diagnosis reached the right conclusion by
the wrong route. It said page 0 is "mapped and readable" on Windows, so the
%fs read succeeds and returns zeros -- that is the Linux mechanism. Windows
cannot map the reserved bottom 64 KiB; the read does fault and the emulator
answered it. The conclusion held, but the route is what tells the next reader
where the fix goes, and following the stated one leads to the memory mapper
rather than to the instruction decoder.

Refs #2112

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Aug 17, 2026
…ap #NN` form; three void arms

B1 -- `\d{1,3}` had no trailing boundary, so it bit a prefix out of anything longer. Reproduced with
the shipped regex:

  '// trap 0x40 is the debug vector'         -> cites row 0    RED on a repo-wide required gate
  '// the guest traps 1234 times per frame'  -> cites row 123   SILENTLY wrong; resolves today
  '// trap 41.5 ms'                          -> cites row 41    spurious; resolves

The second is the worse one: it does not fail, it points at an unrelated row. `(?![\w.])` rejects all
three outright rather than truncating. Nothing in the tree matches today, which is exactly why it
would have been introduced by someone who had never heard of this checker, in a codebase that
legitimately discusses CPU traps by vector number.

B2 -- `trap #NN` is a live in-repo citation form the checker never saw: `trap #16` and `trap #35` in
DRAGON_QUEST_STATUS.md, `instrument trap #13` in OREGON_TRAIL_STATUS.md and SYBERIA_STATUS.md. Four
references, unexamined, while the success line said "all resolving". Now accepted.

Accepting `#` naively would also swallow `[agc] WRITE-TRAP #1[val=...]`, a pasted diagnostic line in
tools/AGENTS.md. So the leading boundary is now SPLIT rather than widened: an explicitly prefixed
citation may follow a hyphen ("instrument-trap 43"), a bare `trap` may not, because after a hyphen it
is part of a compound word.

THREE VOID ARMS, found by the reviewer answering the question I asked. Deleting the whole
`(?:instrument[- ]|orchestration )` group left the suite green, because the bare `trap N` inside each
fixture still matched and still resolved. The arms asserted rc=0, and rc=0 cannot distinguish
"recognised and resolved" from "never seen at all" -- a form-recognition arm has to assert in the
FAILING direction, so the three now cite a row the fixture table does not hold and must be REPORTED.

...and a FOURTH, which I introduced while fixing the third and my own mutation arm caught: the
`WRITE-TRAP #1` fixture used the real log line's operand, and 1 is a valid row, so it passed whether
the compound word was excluded or matched-and-resolved. Same shape as the `s_trap 1` draft. Every
"must not fire" arm in this file now uses a number the fixture table does not hold, which is the only
thing that makes rc=0 mean "not matched" -- stated in the file so the next arm is written that way.

Four mutations, each failing exactly the arms it should and no others:
  delete the prefix branch          -> 1 (the hyphenated-form arm)
  delete the trailing boundary      -> 3 (0x40, 1234, 41.5)
  drop the optional #               -> 1 (the hash-form arm)
  widen the compound-word boundary  -> 1 (the WRITE-TRAP arm)

Also: the docstring froze a citation count that had already been corrected twice elsewhere -- inside
the tool that PRINTS the live figure, which is the failure it exists to prevent. No total is quoted
in any of the three files now. And the spurious-hit count disagreed with itself (4 / 3 / "three");
measured on origin/master 7413647 it is exactly 4, three of which cite row 0, so they would turn the
gate red rather than merely being noise.

Refs #2089, #1729
mattias800 added a commit that referenced this pull request Aug 17, 2026
…— and no arm could see it

trap_number.py crashed whenever a number was actually contested. `claims` became a 6-tuple when the
patch-derived set was appended; the report loop was widened and the `contested` branch was not, so
the tool printed the table and then died with `ValueError: too many values to unpack (expected 5,
got 6)` -- in the one path it exists for, after the table had printed so the run looked half
successful, and with --quiet returning cleanly beforehand so a scripted caller saw nothing wrong.

TWO fixes, and only one is the crash. The collision arithmetic read `added` (this file's numbers
minus master's) when the tool also carries the patch-derived set. Those disagree exactly where it
matters: a STACKED PR inherits its base's rows, so `added` credited #2616 with #2610's 189 and
invented a collision. Switching to the patch-derived notion removes that misreport AND removes the
TRIGGER -- but not the crash, which would still fire on a genuine collision. Fixing only the trigger
would have left it live and looked green. The display now uses the same notion, so a stacked PR reads
"inherits 189 from its base branch, claims nothing of its own" rather than claiming it.

AN ARM THAT EXECUTES main(). Every defect this tool has had lived in a path no arm reached, because
importing three pure functions is not coverage of a CLI -- which is why a 5-vs-6 unpack survived two
review rounds. main() now runs as a subprocess against a stubbed `gh` and a real git repository.
Reintroducing the exact unpack reddens three arms.

Then the generalisation, which is the part worth keeping: for each sentence trap_number.py's
docstring PROMISES, an arm that fails if the promise is broken. Two had none --

  * "fails closed if gh is not authenticated, rather than falling back to master alone"
  * "a PR at the files cap is SCANNED rather than skipped"

-- and both are reachable through the stub. Skipping a capped PR reddens the second.

AND THE FIRST DRAFT OF THE FIRST ONE WAS VOID, which is this whole class one level up: I asserted
rc=1, and with a silent fallback in place the NEXT gh call fails too, so the run exits 1 either way
and the arm could not tell the implementations apart. It now asserts WHICH call failed. Same shape as
the `s_trap 1` and `WRITE-TRAP #1` fixtures -- found this time in the arms added to fix "no arm
reaches this path".

Non-blocking from the same review: an UNCLOSED fence silences every check from that point on --
including the marker scan added last round -- so a real conflict marker after it read CLEAN. That is
the fenced-region skip decaying from a trade into a blind spot, and unlike the fenced-example case it
has no legitimate shape. Gated by the same measurement that made the marker scan safe: 0 of 101
tracked Markdown files end with an unbalanced fence count, re-measured here.

Refs #2089, #1729, #2211
mattias800 added a commit that referenced this pull request Aug 17, 2026
… half of the numbering contract nobody checked (#2621)

* tools(docs): check every by-number trap reference resolves — the half of the contract nobody checked

The instrument-trap table's whole value is that other files point AT it by number. Measured on
origin/master 7413647: 118 references on 110 lines in 46 files, naming 63 distinct rows -- and 50 of
those references are .cpp/.hpp/.py COMMENTS, not prose. That is a contract compiled files depend on.

Nothing checked that any of it resolves. check_numbered_table.py validates the TABLE -- structure,
arity, uniqueness, ascent, and (with --baseline) that no row was deleted -- and has no idea anything
cites it. So a reference to a row that never existed reads as perfectly correct: a plausible number
in a plausible sentence, findable only by opening the table and counting, which nobody does for a
number in a code comment.

That is the more expensive half of the numbering problem. A duplicate row is visible the moment you
look at the table; a stale by-number reference quietly sends the next agent to an unrelated entry, or
to nothing at all, and it does so wearing the authority of a citation.

Deliberately narrow, because it scans all 1,044 tracked files and a checker that fires on ordinary
prose gets disabled rather than heeded: `trap 41`, `traps 55 and 56`, `instrument-trap 43`,
`orchestration trap 68` and comma lists count; a bare number, "trapdoor 99", and anything preceded by
an identifier character do not. That last exclusion is what keeps the RDNA2 shader tests' `s_trap 1`
out, and it is measured -- deleting the lookbehind makes the real corpus report 3 spurious hits and
turns exactly two test arms red.

Fails closed: a table that cannot be found is rc=2 with a message, not a clean run, and the success
line quotes the reference and row counts, since a scan that matched nothing exits 0 exactly like a
clean one.

Current state on master: all 118 resolve, so this lands green and locks the property in.

Tests: 14 cases via `ctest -R trap_citation_checker`, half of them false-positive shapes. One arm was
VOID in its first draft -- it used the repository's real text `s_trap 1`, and 1 is a valid row, so the
case passed whether the mnemonic was excluded or matched-and-resolved. The mutation arm exposed it;
it now uses a number the table does not hold.

Refs #2089, #1729

* fix(tools/docs): citation regex needs a trailing boundary and the `trap #NN` form; three void arms

B1 -- `\d{1,3}` had no trailing boundary, so it bit a prefix out of anything longer. Reproduced with
the shipped regex:

  '// trap 0x40 is the debug vector'         -> cites row 0    RED on a repo-wide required gate
  '// the guest traps 1234 times per frame'  -> cites row 123   SILENTLY wrong; resolves today
  '// trap 41.5 ms'                          -> cites row 41    spurious; resolves

The second is the worse one: it does not fail, it points at an unrelated row. `(?![\w.])` rejects all
three outright rather than truncating. Nothing in the tree matches today, which is exactly why it
would have been introduced by someone who had never heard of this checker, in a codebase that
legitimately discusses CPU traps by vector number.

B2 -- `trap #NN` is a live in-repo citation form the checker never saw: `trap #16` and `trap #35` in
DRAGON_QUEST_STATUS.md, `instrument trap #13` in OREGON_TRAIL_STATUS.md and SYBERIA_STATUS.md. Four
references, unexamined, while the success line said "all resolving". Now accepted.

Accepting `#` naively would also swallow `[agc] WRITE-TRAP #1[val=...]`, a pasted diagnostic line in
tools/AGENTS.md. So the leading boundary is now SPLIT rather than widened: an explicitly prefixed
citation may follow a hyphen ("instrument-trap 43"), a bare `trap` may not, because after a hyphen it
is part of a compound word.

THREE VOID ARMS, found by the reviewer answering the question I asked. Deleting the whole
`(?:instrument[- ]|orchestration )` group left the suite green, because the bare `trap N` inside each
fixture still matched and still resolved. The arms asserted rc=0, and rc=0 cannot distinguish
"recognised and resolved" from "never seen at all" -- a form-recognition arm has to assert in the
FAILING direction, so the three now cite a row the fixture table does not hold and must be REPORTED.

...and a FOURTH, which I introduced while fixing the third and my own mutation arm caught: the
`WRITE-TRAP #1` fixture used the real log line's operand, and 1 is a valid row, so it passed whether
the compound word was excluded or matched-and-resolved. Same shape as the `s_trap 1` draft. Every
"must not fire" arm in this file now uses a number the fixture table does not hold, which is the only
thing that makes rc=0 mean "not matched" -- stated in the file so the next arm is written that way.

Four mutations, each failing exactly the arms it should and no others:
  delete the prefix branch          -> 1 (the hyphenated-form arm)
  delete the trailing boundary      -> 3 (0x40, 1234, 41.5)
  drop the optional #               -> 1 (the hash-form arm)
  widen the compound-word boundary  -> 1 (the WRITE-TRAP arm)

Also: the docstring froze a citation count that had already been corrected twice elsewhere -- inside
the tool that PRINTS the live figure, which is the failure it exists to prevent. No total is quoted
in any of the three files now. And the spurious-hit count disagreed with itself (4 / 3 / "three");
measured on origin/master 7413647 it is exactly 4, three of which cite row 0, so they would turn the
gate red rather than merely being noise.

Refs #2089, #1729

* fix(tools/docs): the trailing boundary must not reject a full stop — 22 citations lost, silently

The B1 fix used the obvious trailing boundary, `(?![\w.])`. A full stop is also how a sentence ends,
so `See instrument trap 41.` stopped being a citation at all. Measured over the same corpus, old
head against new: 118 -> 100 references, 22 lost and 4 gained. Five of the losses are .cpp/.hpp/test
comments -- the half this tool exists for -- and it also truncated LISTS, so `See traps 55 and 56.`
was yielding only 55.

The failure mode is the one this tool was built to police, aimed at itself: the gate stays GREEN and
the success line still says "all resolving" while the auditor has quietly stopped seeing a fifth of
the references. Nothing anywhere would have reported it.

Boundary is now `(?!\w|\.\d)` -- exclude only a DIGIT after the stop. Verified independently against
every case before applying: restores sentence-final citations and sentence-final list members, still
rejects `trap 41.5`, `trap 0x40`, `traps 1234`, `s_trap 99` and `WRITE-TRAP #99`, still keeps
`trap #7`. On origin/master's tree with the fix: 122 references / 114 lines / 47 files / 65 rows,
all resolving -- the original 118 plus exactly the 4 `#NN` gains.

Two arms added, both mutation-checked: reverting the boundary reddens exactly them and nothing else.
One is the sentence-final citation itself; the other is the sentence-final LIST member, which the
review did not name and which the same defect was also eating. A third arm pins that a decimal is
still rejected, so the looser boundary cannot be loosened further without a red.

Also renamed two of the three "asserted by failing" arms. They do not discriminate the alternation --
deleting the prefix group leaves both green, because the bare `trap 99` inside each string still
matches through the other branch. They pin that a prefixed citation is found at all, which is worth
having under an honest name; only the hyphenated case can test the alternation, because a hyphen is
the one separator the bare branch refuses.

Refs #2089, #1729
mattias800 added a commit that referenced this pull request Aug 17, 2026
…— and no arm could see it

trap_number.py crashed whenever a number was actually contested. `claims` became a 6-tuple when the
patch-derived set was appended; the report loop was widened and the `contested` branch was not, so
the tool printed the table and then died with `ValueError: too many values to unpack (expected 5,
got 6)` -- in the one path it exists for, after the table had printed so the run looked half
successful, and with --quiet returning cleanly beforehand so a scripted caller saw nothing wrong.

TWO fixes, and only one is the crash. The collision arithmetic read `added` (this file's numbers
minus master's) when the tool also carries the patch-derived set. Those disagree exactly where it
matters: a STACKED PR inherits its base's rows, so `added` credited #2616 with #2610's 189 and
invented a collision. Switching to the patch-derived notion removes that misreport AND removes the
TRIGGER -- but not the crash, which would still fire on a genuine collision. Fixing only the trigger
would have left it live and looked green. The display now uses the same notion, so a stacked PR reads
"inherits 189 from its base branch, claims nothing of its own" rather than claiming it.

AN ARM THAT EXECUTES main(). Every defect this tool has had lived in a path no arm reached, because
importing three pure functions is not coverage of a CLI -- which is why a 5-vs-6 unpack survived two
review rounds. main() now runs as a subprocess against a stubbed `gh` and a real git repository.
Reintroducing the exact unpack reddens three arms.

Then the generalisation, which is the part worth keeping: for each sentence trap_number.py's
docstring PROMISES, an arm that fails if the promise is broken. Two had none --

  * "fails closed if gh is not authenticated, rather than falling back to master alone"
  * "a PR at the files cap is SCANNED rather than skipped"

-- and both are reachable through the stub. Skipping a capped PR reddens the second.

AND THE FIRST DRAFT OF THE FIRST ONE WAS VOID, which is this whole class one level up: I asserted
rc=1, and with a silent fallback in place the NEXT gh call fails too, so the run exits 1 either way
and the arm could not tell the implementations apart. It now asserts WHICH call failed. Same shape as
the `s_trap 1` and `WRITE-TRAP #1` fixtures -- found this time in the arms added to fix "no arm
reaches this path".

Non-blocking from the same review: an UNCLOSED fence silences every check from that point on --
including the marker scan added last round -- so a real conflict marker after it read CLEAN. That is
the fenced-region skip decaying from a trade into a blind spot, and unlike the fenced-example case it
has no legitimate shape. Gated by the same measurement that made the marker scan safe: 0 of 101
tracked Markdown files end with an unbalanced fence count, re-measured here.

Refs #2089, #1729, #2211
mattias800 added a commit that referenced this pull request Aug 17, 2026
…k deletion against the base, and allocate numbers against open PRs (#2610)

* tools(docs): drop the gapless rule from the numbered-table gate and check deletion directly

The instrument-trap table's gate required its numbered column to be unique, ascending AND gapless.
Gaplessness has a property the other two do not: a violation of it is not repairable by the author.
A duplicate is a one-character bump and an out-of-order row is a one-line move, but a gap means a
lower number is sitting in somebody else's unmerged branch, and the only local "fix" is the
forbidden renumber. So a lane's Docs job went red purely from other lanes' merge timing, and the
only way out was to wait. #2089 records four lanes blocked in one session, two of them writing
push-window guard scripts instead of working on titles.

Gaplessness was kept because it catches a row that was DELETED -- a real danger, since a vanished
row leaves a perfectly well-formed file. Measured on master's 192-row table, it only ever caught an
INTERIOR deletion:

  delete row 100 (interior)                          -> rc=1, caught
  delete row 186 (the highest)                       -> rc=0, GREEN
  restore the file from a 40-commit-old revision,
  losing 61 rows (instrument trap 41 / #1701)        -> rc=0, "contiguous and unbroken"

The last is the shape this repository has actually suffered. A truncated tail is still gapless.

So --sequential becomes --ordered (unique + strictly ascending, gaps legal) and the deleted-row job
moves to --baseline, which compares against the same table in the PR's own base: interior deletion,
truncated tail, whole-file revert and renumber are all reported, and nothing another lane does can
make it fire. CI passes `git show HEAD^1:<file>`, which is the base commit on a pull_request merge
ref and the previous master on a push, so both the pre-merge and post-merge runs are covered.
--sequential now errors and names its replacement rather than aliasing silently: a caller who still
passes it believes gaplessness is running, and giving them the weaker check without a word is the
failure mode this checker's own KNOWN LIMIT section is about.

What the gate can no longer catch, stated so silence is not read as coverage: a number allocated and
never used leaves a permanent gap and nothing reports it. That is deliberate -- such a gap is not a
defect, and treating it as one is what produced the serialization. The success line prints the
unused numbers so a reader can still see them.

Tests: 61 cases (was 44). Mutation-checked in three arms -- disabling the persistence check fails
exactly the 6 baseline cases, re-enabling gaplessness fails exactly the 4 gap cases, and aliasing
--sequential to --ordered fails exactly the 2 CLI cases.

Fixes #2089
Refs #1729, #2211

* tools(docs): trap_number.py — allocate a row number against master AND every open PR

The obvious allocation (read origin/master's highest row, add one) is wrong the moment another lane
pushes, and it is not wrong rarely: #1729 records four collisions in one day, and on 2026-08-17
because the competing claim was never on master to be read -- it was in an open PR.

trap_number.py reads the table out of origin/master and out of the head of every open PR that
touches the file (via `gh pr list --json files` then the contents API, so one request per candidate
PR and no fetch, and it works for forks). It names each claimant rather than printing a bare number:
"PR #1728: highest row 41" against a master of 42 is not a claim but an older base, and the
difference is what tells you whether you are in a race.

Verified live against this repository at the moment of writing -- master 186, PR #2607 holding 187 --
so allocating from master alone would have collided, which is the issue's exact scenario.

Advisor, not a gate. Two lanes running it in the same minute both see the same free number; it
shrinks the window and cannot close it. Merge order closes it, and --ordered is the backstop. This
tool is only SAFE to use now that gaps are legal: under the gapless rule, allocating above an open
PR meant that if that PR was abandoned its number never landed, leaving a permanent gap -- which the
old gate rejected on master, i.e. a permanently red required check for everyone. #1729 could not
have been implemented on its own.

Fails closed. If `gh` is unauthenticated or a fetch fails it errors rather than falling back to
master alone, because the fallback answer is exactly the defect the tool exists to prevent, and the
caller could not tell the two apart.

Tests: 12 cases via `ctest -R trap_number`. The network half is deliberately untested (a test that
needs authentication gets disabled); the last section pins the tool's table parser against
check_numbered_table's on the repository's real table, since a divergence between two independent
parsers would hand out a number the gate then rejects, on somebody else's PR, hours later.

Fixes #1729
Refs #2089

* docs: record instrument trap 188 — a GREEN required check can be as stale as a red one

Trap 187 records that `gh run rerun` re-executes against the original merge ref, so a red result
after the base moved is void. The same staleness applies to a green one, and that is the direction
nobody looks: a red check gets investigated, so its staleness surfaces; a green check is what
authorises the merge, and an author who has just been told they may proceed has no reason to ask
what tree the answer was about.

Live instance today: #2581's Docs job was green against a base predating #2574, so nothing warned
either author that both had written instrument-trap row 182. The collision was found by simulating
the merge by hand, never by CI.

Number 188 allocated with tools/docs/trap_number.py against master AND every open PR — master held
187 and PR #2607 had claimed it, so reading master alone would have collided.

Refs #2211, #2089

* tools(docs): quote the BASELINE's row count in the success line, so a green run is falsifiable

The first CI run of this change reported "no row present in .../orchestration-base.md has been
deleted" -- which prints identically whether 187 rows were compared or the subject was compared
against ITSELF. That is exactly what would happen if a merge ref's parent order were the other way
round and HEAD^1 resolved to the head rather than the base, and nothing in the output would say so.

Now it reads "none of the 187 rows in <baseline> has been deleted" alongside the subject's own
"188 rows", so the run states a checkable fact: on a PR appending one row the baseline count must be
one LESS, and a reader who sees the two agree knows the comparison was void. This is the charter's
"quote the count alongside the exit code -- a count is falsifiable, an exit code alone is not",
applied to this gate's own output.

Confirmed against the real CI run for this branch (Docs job on run 32025683959): the checkout was
`Merge 8f12485 into 7413647`, i.e. HEAD^1 is master, and the subject/baseline counts are 188/187.

Mutation-checked: reporting the subject's count instead fails exactly the new case.

Refs #2089

* tools(docs): the out-of-order message must not imply a direction, and must forbid renumbering

Self-review of the wording. "Move this row down to its numeric position" is wrong half the time: in
the case that actually produces this error -- a merge that put another lane's higher-numbered row
above yours -- the row named is the one that must move UP, not down. Which of the two rows moves
depends on whose arrived by the merge, and the checker cannot know that.

So the message now says the table is sorted by this column and to MOVE the row, without naming a
direction, and keeps the part that matters: renumbering is NOT the repair. That is the reflex, and
it is the one operation the citation contract forbids -- 64 places in this repository cite these
rows by number.

Also "the row above reached N" -> "an earlier row already reached N": N is the running maximum, not
necessarily the immediately preceding row.

Two test arms, one per half of the message. Caught by the first arm failing on the reword, which is
the lever working.

Refs #2089

* docs: correct the citation count — it was measured in a stale checkout, and the real figure is higher

Self-caught, and worth recording rather than quietly fixing, because it is the failure mode this
repository has a rule about: a number stated in a document is inherited by every later reader without
being re-derived.

I wrote "64 places in this repository cite these rows by number" in the checker's docstring, the
table's maintenance note and a test comment. That grep was run in the SHARED MAIN CHECKOUT, which was
several hundred commits behind origin/master at the time. Re-derived against origin/master:

  95 lines across 50 files, naming 91 distinct rows, every one of which resolves to a real row
  -- 47 in .md and 48 in .cpp/.hpp/tests (4 s_trap/v_trap false positives excluded)

The correction runs the same direction as the argument rather than against it -- roughly half the
citations are SOURCE COMMENTS, not prose, so a renumber breaks code comments too and stable identity
matters more than I had claimed, not less. That is precisely why it needed checking: a figure that
supports what you already believe is the one nobody re-derives.

The wording now names the command to re-derive instead of resting on the figure, and dates it, since
any restated count goes stale on the next append -- the same reason the table's header states no
total.

Refs #2089

* test(docs): pin what --baseline does when a table SPLIT orphans rows, as a decision not an accident

A blank line mid-table takes every row below it out of the numbered checks, so --baseline reports
those rows as gone on top of the structure error naming the real cause. Two problems, structure
first, because structure is the repair.

Suppressing persistence whenever structure complains would be the tidier output and the wrong
behaviour: it would let a genuine deletion hide behind a stray blank line. That is the same shape the
suite already guards with "a duplicate hidden below an interruption cannot pass green" -- a file must
not be able to pass by breaking the checker's view of itself.

Refs #2089

* docs: state the citation figures at one consistent scope, measured rather than grepped

Second correction to the same number, and the reason is worth recording: my first correction fixed
the staleness but left two DIFFERENT scopes in one sentence -- "95 lines across 50 files" counted
matches outside the table, while "91 distinct rows" counted matches including the table's own
self-references in row text. Both were defensible; together they were incoherent, and a reader would
have had no way to notice.

Re-measured at one scope, excluding the table's self-references, on origin/master 7413647:

  118 references on 110 lines in 46 files, naming 63 distinct rows, every one resolving
  -- 66 in .md prose, 50 in .cpp/.hpp/.py COMMENTS

The half-in-code claim survives and is the load-bearing part: a renumber breaks compiled files, not
just documentation.

The figures now come from a parser rather than a grep, which is why they are trustworthy this time
-- `s_trap 1` and `v_trap` in the shader tests are RDNA2 mnemonics and were inflating every grep-
based count.

Refs #2089

* docs: record the two rejected alternatives where the next person will look

Both live only in #2089's comment thread today, and the charter is explicit that a falsification
recorded only in an issue comment is one the next agent re-derives at full cost. Anyone who finds
this numbering painful will propose one of the two, and each is more expensive than it looks:

  * BATCHING (#2089's own proposal) removes the serialization but destroys the property that issue
    correctly refuses to give up -- a row staged and never promoted is INVISIBLE, because it never
    had a number and so leaves no gap to notice. It trades a check that fires for one that cannot,
    and puts a mandatory orchestrator step in every lane's path.
  * STABLE NON-SEQUENTIAL IDS (#1664) break every existing citation to buy a property a claim-time
    check gets for free. Its own filer conceded this after reading #1729.

Recorded in the checker's docstring, next to the reasoning that removed gaplessness, so the whole
decision is readable in one place.

Refs #2089, #1664, #1729

* fix(tools/docs): address review B1-B4 and N1-N5, and renumber the trap row to 188

B1 -- the corpus figures were measured under --sequential and re-labelled for --ordered without
re-measuring, which is the charter's "true statement reached by a route that does not establish it".
Relaxing gaplessness can only RAISE the count, so the old figure could not survive the rewrite.
Re-measured apples-to-apples by loading both checkers and running each over the same corpus:

  tracked .md files                             101   (text said 77)
  docs with an all-numeric-first-column table     10   (text said 5)
  ... satisfying OLD --sequential                  3
  ... satisfying NEW --ordered                     6   (text said 2)

The conclusion is unchanged -- 4 of 10 still fail, so "do not apply it broadly" stands on the new
rule as it did on the old one -- and the three newly-qualifying documents are now named.

B2 -- "the Instrument table at 192 rows, max 186" is self-contradictory under the gapless rule then
in force, and 192 was wrong. `0c268362`'s Instrument table is 186 rows, 1..186; the 192 came from a
whole-FILE grep that counted the numbered rows of all 14 tables in the document (248 of them). Fixed
in four places, with the mistake named so the next reader knows which measurement to distrust.

B3 -- the trap_number.py bullet had been inserted into the MIDDLE of the check_numbered_table.py
entry, so "On arity, and why it is not optional" and the what-it-cannot-cover paragraph read as
documentation of an allocator that does no arity checking. Moved below them.

B4 -- trap_number.py degraded silently when --limit truncated the PR list, which is precisely what
its own docstring promises it does not do. Reproduced: `--limit 3` returned 188 while three open PRs
held it. Now a hard ScanError. The reviewer's related uncertainty is settled rather than restated:
`gh --json files` DOES cap the array, measured two ways -- GraphQL refuses `files(first: 101)`
outright (EXCESSIVE_PAGINATION, "exceeds the `first` limit of 100 records"), and in the wild
`cli/cli#14082` has 1,161 changed files while `gh pr view --json files` returns exactly 100, silently.
So a PR at the cap is now SCANNED rather than skipped; skipping it would make its claim invisible.

N1 -- a mistyped digit was green. `1189` for `189` passes uniqueness and ascent and permanently
poisons the space, because every later allocation comes off the new maximum. A new number more than
MAX_JUMP (50) above the BASELINE's maximum is now rejected. The bound is local by construction -- it
compares against the base's own max, never against another lane's timing -- so it reintroduces no
coupling. Four arms, including the discriminating counter-arm that a deliberate step clear of a
contested band still passes, and that an existing far-out row is never re-judged.

N2 -- the baseline was parsed twice, so the compared content and the printed count could come from
different reads. `--baseline <(cat f)` is a single-read FIFO and printed "none of the None rows ...
has been deleted", rc=0 -- a falsifiability device that can print None is not one. One cached parse
now serves both; the same invocation reports 187.

N3 -- CLAUDE.md's recipe said `origin/master` was "what CI passes as --baseline"; CI passes `HEAD^1`.
Both are correct baselines and origin/master is the stricter one, so the command stays and the
comment is fixed, with the gap between them named as trap 188's subject. Also `mktemp` instead of a
fixed path, which several concurrent agents share.

N4 -- trap_number.py now detects when more than one open PR claims a number, says so, and suggests
stepping CLEAR of the contested band rather than to the next free number, since every loser stepping
to "next free" collides again one number up. It also reports the numbers a PR actually ADDS rather
than the range base_max+1..pr_max, which since #2089 are different things.

N5 -- prose in test_trap_number.py named a row count that goes stale on every append.

Row renumbered 191 -> 188 (master max re-read as 187 immediately before writing), on the
orchestrator's sequencing instruction: landing first at the lowest free number turns the three PRs
queued behind this one into a uniqueness problem rather than an ordering one.

Mutation-checked: raising MAX_JUMP to 100000 fails exactly the typo arm; removing the --limit guard
reproduces the reviewer's wrong 188.

Refs #2089, #1729, #2211

* fix(tools/docs): renumber to 189 after #2617 landed 188, and take the review's non-blocking items

Renumber, and the resolution is the thing this PR is about. #2617 merged its own 188 while this
branch's macOS jobs were queued, so the rebase conflicted on that row. Resolved by keeping BOTH --
master's 188 untouched, mine renumbered to 189 below it -- rather than by taking either side of the
file, which would have dropped one lane's row silently (instrument trap 41). Master max re-read as
188 immediately before writing. Verified after the rebase that the only deletions in the document
are this PR's own maintenance-note rewrite, and that master's 188 row survives byte-for-byte.

The "192" parenthetical: the MECHANISM was right and the count attached to it was wrong, which is
the same class this PR was already rejected for once (B2). `grep -cE '^\s*\|\s*[0-9]+\s*\|'` on
0c26836 returns 192 -- that IS the whole-file numbered-row count. 248 is the document's total BODY
rows, and I had written it as the grep's result: a third wrong number in the same sentence. Now
stated with the exact command, and the earlier mistake named so nobody re-derives it.

trap_number.py had no MAX_JUMP, so a typo'd number in an open PR propagated straight into the advice
and the allocator would hand out a number check_numbered_table then rejects -- the tool and its own
backstop disagreeing, which is worse than either being wrong alone. A claim more than 50 above the
base is now reported and excluded from the calculation rather than silently believed.

The `files` cap is stated as confirmed fact rather than as a caveat: changedFiles=1161 against
`--json files|length`=100 on cli/cli#14082, and `files(first:101)` returning EXCESSIVE_PAGINATION
verbatim.

AND A DEFECT THE REVIEW DID NOT FIND, which this tool exhibited on this tool's own PR. Once master
landed a DIFFERENT row 188, the report read `#2610 ... no claim (older base, adds no row)`, because
`added_numbers` is set arithmetic and 188 was in both sets. The single most urgent collision there
is -- a duplicate already on master -- was the one case the report called benign.

Fixed by asking the PR's PATCH what it adds rather than diffing file contents, which also avoids the
false positive the first attempt had: "present in both, text differs" is equally what an AMENDED row
looks like, and trap rows are amended routinely. A number on both sides of the patch is an
amendment; only added-and-not-removed is an allocation. Split into a pure `added_rows_from_patch` so
it is testable without the network -- five arms, and the amendment guard is mutation-checked
(returning `sorted(added)` reddens exactly the two that name it).

It immediately found a live one: **#2602's head carries TWO rows numbered 186** -- #2070's, inherited
when it rebased onto 7413647, plus its own ("A diagnostic that perturbs the subject: arming a
software WATCHPOINT..." against "A line-based read of a registration table...").

Not a trap-189 instance, and the distinction matters enough to state: trap 189 is about the BASE
moving under a fixed head, and this is the HEAD moving. Plain uniqueness catches it with no
--baseline and no merge simulation, and CI already did. Calling it a trap-189 instance would point
the next reader at the expensive check when the cheap one suffices, which blunts the trap.

Refs #2089, #1729, #2211

* fix(tools/docs): catch unresolved conflict markers — my own rebase pushed one past every check here

A stray `>>>>>>> 8f12485 (...)` from resolving the 188/189 conflict landed immediately after the
LAST row of the instrument table, and I pushed it. Every class in this file passed the file: the
marker is not a table row, so it simply ENDED the table, and with no orphaned rows below it there
was no fragment to report. The run said "189 rows, unique and ascending" over a line that was a
merge artifact.

`git diff --check` caught it, which is exactly why the charter runs that as a separate gate. But
nothing stops a marker reaching a branch where nobody runs it, and the class is worth closing where
the rest of the table's structural checks live. Verified against the pushed revision itself:

  $ check_numbered_table.py --ordered --table-header Instrument <52f3830d's copy>
  error: ...:312: unresolved merge conflict marker: '>>>>>>> 8f12485 (docs: record ...' rc=1

`=======` is deliberately NOT treated as a marker: it is a legal setext heading underline, and a
check that fires on correct Markdown gets deleted rather than heeded. The other two are unambiguous
-- measured across the tracked corpus, zero instances of either, which is what makes gating on them
safe rather than merely plausible. Three arms including that discrimination; mutation-checked, and
neutering the pattern reddens exactly the two that name it.

Two process notes, because both are the charter's own traps and I hit them in the same minute:

  * I read `grep -c '^<<<<<<<...'` returning 1 as "clean". It was reporting one REMAINING marker.
  * I ran `diff --check` in a chain that had already pushed, so its rc=2 arrived after the fact
    rather than gating it. Each gate is now checked on its own line.

Refs #2089, #1729

* fix(tools/docs): the conflict-marker scan must skip fenced blocks, like every other class here

The scan I added ran BEFORE parse_tables, so it had no fence tracking, and it is always on --
including the repo-wide *.md sweep. It therefore rejected any document that PASTES an example
conflict inside a ``` block, which is exactly how this defect gets documented. Reproduced: two
problems on both the sweep and --ordered for a file whose only marker is inside a fence.

This file's own header settled that question long ago -- "Fenced code blocks are skipped: this
repository's docs paste tool output containing pipe characters, and this file's own defect example
would otherwise fail the check that documents it" -- and the scan simply did not reuse FENCE. It
does now.

The shape is worth naming because it is the second time in this PR: I added the scan BECAUSE I
pushed an unresolved marker that the checker passed, and the fix as first written would have
rejected the documentation of that very defect. Same self-referential failure as the citation
auditor that stopped seeing sentence-final citations while reporting full coverage.

Three arms, mutation-checked: removing fence tracking reddens exactly the two fenced-example cases.
The third is the counter-arm -- a marker AFTER a fence closes is still a defect -- so "skip fenced"
cannot decay into "skip everything once a fence is seen".

The resulting blind spot is recorded rather than implied: a genuine unresolved conflict that lands
inside a fenced block is invisible here. The alternative rejects the documentation of the defect, so
the trade is made the same way it is made for tables, and `git diff --check` has no such blind spot
and remains the backstop the charter runs separately.

Non-blocking, same review: added_rows_from_diff returned None on a failing `gh pr diff` and the
caller treated None as falsy, so it fell through to the set-difference branch -- the exact logic that
function was added to REPLACE, because set arithmetic cannot see a duplicate the base already holds.
An unreadable diff would have silently restored the defect while the run looked successful. It raises
ScanError now; the failure path is exercised against a nonexistent PR.

Also the last two "192" sites (tools/AGENTS.md and the test comment), which the earlier correction
missed.

Refs #2089, #1729, #2211

* fix(tools/docs): the collision reporter could not report a collision — and no arm could see it

trap_number.py crashed whenever a number was actually contested. `claims` became a 6-tuple when the
patch-derived set was appended; the report loop was widened and the `contested` branch was not, so
the tool printed the table and then died with `ValueError: too many values to unpack (expected 5,
got 6)` -- in the one path it exists for, after the table had printed so the run looked half
successful, and with --quiet returning cleanly beforehand so a scripted caller saw nothing wrong.

TWO fixes, and only one is the crash. The collision arithmetic read `added` (this file's numbers
minus master's) when the tool also carries the patch-derived set. Those disagree exactly where it
matters: a STACKED PR inherits its base's rows, so `added` credited #2616 with #2610's 189 and
invented a collision. Switching to the patch-derived notion removes that misreport AND removes the
TRIGGER -- but not the crash, which would still fire on a genuine collision. Fixing only the trigger
would have left it live and looked green. The display now uses the same notion, so a stacked PR reads
"inherits 189 from its base branch, claims nothing of its own" rather than claiming it.

AN ARM THAT EXECUTES main(). Every defect this tool has had lived in a path no arm reached, because
importing three pure functions is not coverage of a CLI -- which is why a 5-vs-6 unpack survived two
review rounds. main() now runs as a subprocess against a stubbed `gh` and a real git repository.
Reintroducing the exact unpack reddens three arms.

Then the generalisation, which is the part worth keeping: for each sentence trap_number.py's
docstring PROMISES, an arm that fails if the promise is broken. Two had none --

  * "fails closed if gh is not authenticated, rather than falling back to master alone"
  * "a PR at the files cap is SCANNED rather than skipped"

-- and both are reachable through the stub. Skipping a capped PR reddens the second.

AND THE FIRST DRAFT OF THE FIRST ONE WAS VOID, which is this whole class one level up: I asserted
rc=1, and with a silent fallback in place the NEXT gh call fails too, so the run exits 1 either way
and the arm could not tell the implementations apart. It now asserts WHICH call failed. Same shape as
the `s_trap 1` and `WRITE-TRAP #1` fixtures -- found this time in the arms added to fix "no arm
reaches this path".

Non-blocking from the same review: an UNCLOSED fence silences every check from that point on --
including the marker scan added last round -- so a real conflict marker after it read CLEAN. That is
the fenced-region skip decaying from a trade into a blind spot, and unlike the fenced-example case it
has no legitimate shape. Gated by the same measurement that made the marker scan safe: 0 of 101
tracked Markdown files end with an unbalanced fence count, re-measured here.

Refs #2089, #1729, #2211

* test(tools/docs): four arms that reddened nothing, and the static rule that would have caught them

All four reproduced before fixing -- each mutation applied cleanly and the suite stayed green:

  racing = []                          -> 0 failures
  DUPLICATES branch disabled           -> 0 failures
  IGNORING wild report removed         -> 0 failures
  MAX_JUMP filter on `sane` removed    -> 0 failures

The first was void: `expect="#11"` matched the per-PR TABLE ROW ("PR #11    highest row 3"), not the
collision line it meant, so emptying the racing list satisfied it just as well. It now asserts
"open PR (#11, #22)", which only the collision line emits.

The other three were promises with no arm at all, and the first of them is the tool's headline
capability -- the DUPLICATES report that found the live #2602 collision. Also the wild-claim report
and, separately, its EXCLUSION from the arithmetic: those are two different promises, and only the
second one matters. Without the MAX_JUMP filter the answer becomes 901 and every later allocation
inherits it, while the IGNORING line prints either way -- so the arm asserts the resulting number,
not the message.

A fifth zero is left alone deliberately: `claimed` reading c[4] instead of c[5] is genuinely
equivalent, because `contested` already filters by a count over c[5]. Verified rather than assumed.

THE RULE, which is worth more than the four fixes and is now in the suite's docstring and in
tools/AGENTS.md:

    Ask "what else in this output could satisfy this assertion?"
    An arm discriminates only if it asserts on a string ONLY THE BRANCH UNDER TEST can produce.

Every void arm this batch produced fails exactly that question -- the `#11` one above; `want_rc=1`
for a fail-closed path whose silent fallback exits 1 anyway; `expect="4"` for a --quiet output any
report containing a 4 satisfies; a `// s_trap 1` fixture whose operand is a valid row. It is a static
check, applicable while writing, and it costs nothing. It does not replace mutating -- an arm can
name a unique string and still test the wrong branch -- but nothing that fails it is worth mutating.

Applied it to the remaining arms rather than only the reported four: the --quiet arm was the weak
one, asserting a bare "4". It now asserts the WHOLE output, and a mutation that makes --quiet print
the full report reddens it.

Not recorded as a trap row: 190, 191 and 192 are claimed by the three PRs queued behind this one, so
a second row would take 193 and add churn to a table four PRs are queued against. The docstring and
AGENTS.md are also where someone WRITING an arm looks, which the trap table is not.

Refs #2089, #1729, #2211

* test(tools/docs): the gh stub must be a .cmd on Windows — the new main() arms were POSIX-only

Windows MinGW went red at d57e59e: eleven of the twelve main() arms failed with the same traceback
at the tool's FIRST `gh` call. Windows resolves an executable through PATHEXT
(.COM;.EXE;.BAT;.CMD;...), so a stub file named `gh` with no extension matches nothing and every
call reports as a failure. Linux and macOS never see it.

So the arms written to close the "nothing executes main()" gap were themselves platform-specific --
new test infrastructure well-formed on the two platforms it was written against and ill-formed on the
third, the same class as #2579's em dash and #2583's bare size_t. Fourth time today a change has been
correct on Linux and broken on Windows or macOS, and every time the cause was an assumption invisible
on the author's own machine.

Fixed by splitting the stub from its LAUNCHER: the logic lives in a .py, and what goes on PATH is a
`gh.cmd` on Windows and an exec'ing `gh` shell script elsewhere, both delegating to sys.executable.
Every arm keeps running on all three platforms.

Deliberately NOT a Windows skip, even though the machinery exists (#2568) and would have been quicker.
A skip here leaves the tool's primary path untested on exactly the platform where PATHEXT and the
path separator differ -- which is where a CLI tool breaks, and is how this defect arrived.

Audited the sibling suites for the same assumption: none of them builds a fake executable. They spawn
`git` (a real executable, resolved normally on Windows) or sys.executable, and their paths are built
with pathlib rather than concatenated with "/". The one place a forward-slash path is compared as a
STRING is against `git`'s own diff output, which is forward-slash on every platform.

Refs #2089, #1729, #2211

* fix(tools/docs): resolve gh through shutil.which — an absent gh printed a TRACEBACK, not a refusal

The Windows fix in the previous commit was not enough, and the reason is sharper than PATHEXT.
Python resolves a bare command name through CreateProcess, which appends only `.EXE` and does NOT
walk PATHEXT -- that is cmd.exe's job. So `gh.cmd` on PATH stayed invisible to
`subprocess.run(["gh", ...])` and all eleven main() arms failed again, this time on an uncaught
FileNotFoundError.

Chasing that surfaced a defect that is NOT Windows-specific and is worse than the CI failure.
Reproduced on Linux with an empty PATH:

  FileNotFoundError: [Errno 2] No such file or directory: 'gh'

The docstring's central promise is that the tool "fails closed ... a hard error and not a fallback",
and an absent `gh` broke it on every platform: the run ended in a traceback rather than the ScanError
it advertises. A traceback is not a refusal to answer; it is a crash that also happens not to answer,
and the two read very differently to whoever is holding a number they are about to write.

`run()` now resolves with shutil.which first -- which walks PATHEXT, so the launcher is found on
Windows, and which makes the absent case the tool's own message everywhere. OSError is converted
too, so nothing escapes as a traceback.

The arm asserts the message AND the ABSENCE of a traceback: rc=1 alone is satisfied by the crash, so
asserting it would have been another void arm of exactly the kind this suite keeps producing.
Mutation: reverting to the bare name reddens it.

This is also the arm that closes the Windows failure, which is the part worth noting -- the fix is
not a platform workaround but a promise the tool was already making and not keeping. It runs on all
three platforms, so Windows is covered by the same arm as Linux rather than by a skip.

Refs #2089, #1729, #2211

* fix(tools/docs): clear two leftover conflict markers from the rebase onto a moved master

#2612 and #2621 merged while this branch was open, both touching prosper/CMakeLists.txt and
prosper/tools/AGENTS.md. The rebase conflicted on both; every conflict here was "master added an
entry, this branch added an entry", never a contested edit, so each was resolved by keeping BOTH
sides. Verified afterwards that master's own additions survive (trap_citation_checker from #2621,
worktree_reclaim from #2612) and that the only deletions in either shared file are this branch's own
rewrite of the check_numbered_table entry.

The resolution left one `>>>>>>>` line in each file. Which gate caught them is the interesting part:

  git diff --check                          rc=0   -- SILENT
  check_numbered_table.py (marker scan)     rc=1   -- caught prosper/tools/AGENTS.md

`git diff --check` inspects the working-tree diff, and these markers were already COMMITTED by the
rebase, so it had nothing to look at. That is the exact blind spot the marker scan was added for two
rounds ago -- itself added because a marker of mine reached a pushed branch -- and this is the first
time it has caught one that the backstop could not. The CMakeLists one is still uncovered here: the
scan runs on Markdown, so a marker in a build file remains a `diff --check`-only class.

Refs #2089, #1729, #2211
mattias800 added a commit that referenced this pull request Aug 23, 2026
Third independent review of #2947. The code and the case analysis both cleared;
all four blocking findings were the SAME propagation pattern as rounds 1 and 2 —
a retracted claim fixed in the file the previous reviewer cited and left
standing everywhere else. That is now three rounds of one failure mode, so the
argument has been moved out of the comments entirely rather than restated in
them a fourth time.

N4: the retracted derivation was still in the source. hle_kernel_mem.cpp still
said "map_phys_at answers null in exactly two situations ... (the guest has that
memory and can write it)" in two places, and the test header carried a weaker
copy — the same inference this branch had already deleted from the log line for
being unsupported. All three now point at the status docs instead of restating
it. A claim that needs a case analysis to be true does not belong in a comment.

N5: "exactly three states" was wrong, and the comment two lines above the line
it cited says so. prosper_reserved_range_state returns 0/1/2/4 on POSIX and
0/1/2/3 on Windows, enumerated at hle_kernel_mem.cpp:2906-2910; it also answers
about one ADDRESS, not a range. Both docs now enumerate all of them. The
conclusion survives: state 4 disposes exactly as 0 does and cannot arise for
these VAs anyway, since the AMM window is searched upward from 1 TiB (:2537)
while the Ampr VAs sit near 139 GiB.

N7: three different census figures appeared in one PR — remembered run-to-run
ranges of 4,294-4,574 and 31,839-32,192 against the head's own measured 4,646
and 31,716, which fall outside both. One measured census is now quoted
everywhere.

Two things the reviewer asked for that strengthen the argument rather than
patch it. The fault each title takes is the discriminator and is now recorded:
both die at SIGSEGV addr=(nil) on UE's own int $0x45 ; nop ; ud2, and both
rescue arms are floored far above zero (exec_image_linux.cpp:2150 needs
addr >= 0x1000000000), so a null fault cannot be a lost Ampr commit; no fault at
any Ampr-range address appears in either census run. And state 1 is now OBSERVED
rather than inferred — a Sifu run logs [lazy-commit] #1 mapped page=0x20e1520000,
131 GiB, in the same range as the Ampr VAs. The state-0 arm is labelled as the
hypothetical branch it is: every refused page in the census already carries a
VMA, so on the measured population the argument runs through states 1 and 2.

Every file:line in both docs was re-verified against the source; three had
drifted (:2918 -> :2921, :2900-2912 -> :2906-2910, :2535 -> :2537).

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Aug 23, 2026
…fu was losing 2 GiB of the pool per boot (#2947)

* fix(memory): a refused Ampr map must give its direct memory back — Sifu was losing 2 GiB of the pool per boot

sceAmprCommandBufferSetBuffer's map flavor claimed a physical range from the
direct-memory pool and then asked the host to place it at the guest's VA. When
that placement was refused the pool offset was simply dropped: nothing
referenced it, nothing could ever release it, and because the claim is made at
64 KiB alignment every carcass retired a full 64 KiB stride however small the
request was.

The refusal itself is correct. map_phys_at answers null only where
MAP_FIXED_NOREPLACE would clobber an already-mapped range, or where the address
is not page-aligned — and these calls are the BUFFER flavor ("this buffer
already exists") arriving in an argument shape the a3 == a1 discriminator does
not recognise, so leaving their memory untouched is exactly what real hardware
does. Only the accounting was wrong.

Measured with PROSPER_MEMLOG=1, tools/screenshot, default route, Linux/RADV:
The First Berserker: Khazan (PPSA20447) produces 4,294-4,574 refusals in a 6 s
boot, retiring 268-286 MiB of pool against the 300 MiB scratch block that is the
only headroom left after UE4's halving probe. Sifu (PPSA03001) produces
31,839-32,192, retiring 1.9-2.0 GiB. With the fix the post-probe allocations go
from scattered across the leak's fragments to contiguous, and Khazan gets one
more allocation than it used to.

The map-flavor success path now zeroes its pages as well, because a released
offset can be recycled and the pool's memfd retains bytes across reuse. Today
that is a no-op — the offsets were never freed, so never recycled — which is
precisely why the path got away without it.

This does NOT get either title past its out-of-memory assert, and the negative
result is the more useful half: with the leak gone, both titles still assert
while prosper's pool holds a 230 MiB free block and no prosper call has failed.
The handler now also records WHY each map was refused, and the census is
unanimous — 100% of the refusals on both titles target memory the guest ALREADY
HAS MAPPED, so none of them was ever a lost page commit. Both falsifications are
recorded in docs/KHAZAN_STATUS.md and docs/UE4_APR_IOSTORE_BRINGUP.md.

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

* fix(review): the refusal probe measured one page and claimed the whole range

Three blocking findings from independent review of #2947, plus the instrument
trap that turned up while chasing them.

B2, the substantive one. The `ampr push-map ... -> FAILED` diagnostic called
mincore on ONE page, but MAP_FIXED_NOREPLACE fails if ANY page in [a1, a1+a2)
is mapped, and the dominant refusal shape is 0x4000 -- four host pages. So the
instrument examined a quarter of each range while the log, and two `## Ruled
out` sections, said "100% ... target memory the guest ALREADY HAS MAPPED, so
none was ever a lost page commit". A stronger statement than the probe could
make, in the direction the author wanted, written where nobody re-derives it.

The probe now covers the whole range (mincore answers -1/ENOMEM on the first
hole, which is the property in question), is gated on memlog() so a path that
runs tens of thousands of times per boot does no syscalls when the log is off,
and `resident` is renamed `fully_mapped`: mincore's RETURN means mapped, its
vec bit means resident, and the bit was never read.

Both docs now lead with the structural argument instead, which does not depend
on a probe at all: map_phys_at returns null only where the address is not
page-aligned, or where range_is_free_reservation declined -- and that, at
hle_kernel_mem.cpp:1183, means the range holds a COMMITTED mapping or an
untracked gap. A refusal therefore means the guest already holds that memory
and prosper refused to MAP_FIXED over it, which is the #88/#107 clobber the
discriminator exists to prevent. Refusing is protective. Both `## Ruled out`
entries state what the old claim was and why it was wrong rather than reading
as though they had always been right.

B1: the new test's header asserted the pool-exhaustion hypothesis this change
falsifies. Rewritten to say what the test guards -- an allocator invariant that
stands on its own -- and to record that the obvious story is wrong and was
believed.

Non-blocking, and it cut in the author's favour: "the dmem_zero is a no-op
today" was wrong. dmem_take is first-fit over gaps that
sceKernelReleaseDirectMemory frees too, and Khazan hands back a 300 MiB scratch
block mid-boot, so an Ampr map flavor could already land on a previous tenant's
bytes. The release widens that window; it did not open it.

Also records instrument trap 222: `pgrep -x a b c` takes only one pattern, so
the multi-name "is the GPU free?" check exits 2 having looked at nothing -- and
both common spellings fail safe-looking, with `|| echo "GPU free"` printing the
reassuring answer BECAUSE the command failed. The repo's own 19 uses are all
single-pattern and correct; the operative counting rule gains a one-line
warning. #2948.

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

* fix(review): replace the refusal argument with a case analysis, and measure the census it used to assert

Second round of independent review of #2947 rejected the REPLACEMENT argument
for the same reason the first round rejected the measurement: it reached the
right answer by a route that did not establish it.

N3, the substantive one. "range_is_free_reservation declined" is NECESSARY, not
SUFFICIENT -- the code goes on to try prosper_mmap_noreplace and succeeds
whenever the host range is free, so the decline alone proves nothing. The step
that carries the argument is the NOREPLACE also failing, which is what proves a
host VMA exists, and it was missing. "Returns null in only two situations" was
also not exhaustive: map_at contributes two more.

Both `## Ruled out` entries now argue by case analysis over the states a refusal
can leave the range in, which is complete and appeals to no probe:
prosper_reserved_range_state (hle_kernel_mem.cpp:2906) answers 2 = committed, so
the guest already holds it; 1 = tracked but uncommitted, so the lazy-commit
fault arm backs it on first touch (exec_image_linux.cpp:2150, gated on exactly
== 1); or 0 = untracked, where nothing rescues it -- the unified-memory fallback
spans only GPU_VA_LO..GPU_VA_HI = 4-64 GiB (exec_image_linux.cpp:1115-1116)
while these VAs sit near 139 GiB -- so a genuinely lost commit would be a FATAL
SIGSEGV, and neither title takes one.

The reviewer's vm.max_map_count hole is real and is bounded rather than waved
away: on a host with the default 65530 a title making 30,000+ mappings could
take ENOMEM from the limit instead of from an existing VMA, and only a probe
separates those. Measured on this box: 2,147,483,642, so it is unreachable here.
Recorded with both halves, because the code has to be right elsewhere.

N1: both rows cited re-derived numbers "in that PR" that did not exist. The
forward reference is gone and the numbers are now real -- whole-range mincore
gives Khazan 4,646/4,646 and Sifu 31,716/31,716, 36,362 refusals with no
unmapped page. Reporting VA and length alignment separately surfaced a shape the
single flag hid: 14 Khazan and 5 Sifu refusals have a page-aligned VA and an
UNALIGNED length.

N2: BLOG.md still carried the retracted claim and contradicted itself. Rewritten.
The correction reaching two of three files in one commit is its own lesson.

Non-blocking, all taken. The log line reports what mincore SAW ("every page of
the range has a VMA") instead of what it implies -- the old wording is precisely
what got transcribed into two Ruled-out sections as though it were a
measurement. VA and length alignment are separate fields. The probe stride is
named kProbePages and sizes both `vec` and the loop, with a comment saying why
they must move together, because a stride raised without the array would be a
silent stack overwrite. The dead !memlog() arm inside MLOG is gone.

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

* fix(review): the correction reached the docs and not the code, again

Third independent review of #2947. The code and the case analysis both cleared;
all four blocking findings were the SAME propagation pattern as rounds 1 and 2 —
a retracted claim fixed in the file the previous reviewer cited and left
standing everywhere else. That is now three rounds of one failure mode, so the
argument has been moved out of the comments entirely rather than restated in
them a fourth time.

N4: the retracted derivation was still in the source. hle_kernel_mem.cpp still
said "map_phys_at answers null in exactly two situations ... (the guest has that
memory and can write it)" in two places, and the test header carried a weaker
copy — the same inference this branch had already deleted from the log line for
being unsupported. All three now point at the status docs instead of restating
it. A claim that needs a case analysis to be true does not belong in a comment.

N5: "exactly three states" was wrong, and the comment two lines above the line
it cited says so. prosper_reserved_range_state returns 0/1/2/4 on POSIX and
0/1/2/3 on Windows, enumerated at hle_kernel_mem.cpp:2906-2910; it also answers
about one ADDRESS, not a range. Both docs now enumerate all of them. The
conclusion survives: state 4 disposes exactly as 0 does and cannot arise for
these VAs anyway, since the AMM window is searched upward from 1 TiB (:2537)
while the Ampr VAs sit near 139 GiB.

N7: three different census figures appeared in one PR — remembered run-to-run
ranges of 4,294-4,574 and 31,839-32,192 against the head's own measured 4,646
and 31,716, which fall outside both. One measured census is now quoted
everywhere.

Two things the reviewer asked for that strengthen the argument rather than
patch it. The fault each title takes is the discriminator and is now recorded:
both die at SIGSEGV addr=(nil) on UE's own int $0x45 ; nop ; ud2, and both
rescue arms are floored far above zero (exec_image_linux.cpp:2150 needs
addr >= 0x1000000000), so a null fault cannot be a lost Ampr commit; no fault at
any Ampr-range address appears in either census run. And state 1 is now OBSERVED
rather than inferred — a Sifu run logs [lazy-commit] #1 mapped page=0x20e1520000,
131 GiB, in the same range as the Ampr VAs. The state-0 arm is labelled as the
hypothetical branch it is: every refused page in the census already carries a
VMA, so on the measured population the argument runs through states 1 and 2.

Every file:line in both docs was re-verified against the source; three had
drifted (:2918 -> :2921, :2900-2912 -> :2906-2910, :2535 -> :2537).

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

* fix(review): the census figures, the arithmetic that depends on them, and one claim the census cannot support

Fourth independent review of #2947. No code change asked for; three text
findings, and two of them are this branch's own recurring failure repeated at
smaller scale.

N10, the arithmetic. 4,646 x 64 KiB is 290.4 MiB, not the 286 MiB quoted beside
it -- 286 was 4,574's figure, so the Sifu half of the update landed and the
Khazan half did not. "One census quoted everywhere" was also false: BLOG.md and
two KHAZAN_STATUS.md lines still said 4,574 / 32,192, and the area doc still
said 286 MiB / 1.96 GiB nine lines under its own 4,646 / 31,716. Every file now
carries one census and totals derived from it, checked by grepping the tree
rather than by asserting it again.

N9 is the one worth reading. The sentence added in the previous round to fix a
round-3 finding committed the round-1 error inside it: "on the measured
population the argument runs through states 1 and 2 and state 0 never engages"
is a claim about prosper_reserved_range_state, settled with a mincore
measurement. mincore reports whether a VMA exists; the state function reports
whether prosper TRACKS the range, and states 1 and 2 both require tracking
(hle_kernel_mem.cpp:2928, :2931 return 0 for anything absent from g_maps).
Untracked-but-mapped is exactly the population that yields an EEXIST refusal, so
the census cannot say which state a refused page is in. It does not need to: the
case analysis is complete over all four. Both rows now say so, and say what the
census does not license.

N8: the PR body still carried "Today it is a no-op" in a second place, while its
own Risk paragraph and the source comment said the opposite. Fixed.

Two non-blocking corrections that were also real. The fault discriminator is the
fault's ADDRESS -- a lost commit at one of these VAs would fault at that VA --
not "both rescue arms are floored above zero", which is consistent with the
conclusion but does not carry it. And the VAs are at 129.5-156.5 GiB, not
"139 GiB": that was the figure in GB wearing a GiB label, which had the added
absurdity of making the larger address carry the smaller magnitude once the
correct 131.5 GiB lazy-commit page was printed next to it. The lazy-commit line
is also hedged properly now -- 0x20e1520000 is not one of the refused VAs, so it
evidences the arm being live in that address region, not the disposal of any
particular refusal.

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

* docs: narrow one overstatement and align the size split with the census it sits beside

Non-blocking items from the fifth review, taken because two are factual rather
than stylistic. No code change; the approval at 9e24b1d rests on code and
citations that this commit does not touch.

"Untracked-but-mapped is EXACTLY the population that yields an EEXIST refusal"
was one word too strong: range_is_free_reservation also declines tracked-AND-
COMMITTED mappings, so the population is the union of the two and a VMA census
cannot separate them. The slip was self-limiting -- it overstated what the
census CANNOT do, so no conclusion leaned on it -- but a Ruled-out row is the
wrong place to leave an inaccuracy of any size.

The per-size split beside the Khazan census was still the retired run's
"568 of 0x40", against a census that splits 4,041 / 591 / 14. Same census, same
sentence now. The PR body's "~90% of the 300 MiB scratch block" is likewise
~97% at 290 MiB.

Refs #2908

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
mattias800 added a commit that referenced this pull request Aug 29, 2026
…#3115)

Diagnostic only; no behaviour change, and off unless PROSPER_UNIFORMLOG=1.

`retained_frame_action()` decides on BYTE SIZES ALONE. That is by design -- it
cannot know what a frame should look like -- but the consequence is that a
correctly-sized CONTENTLESS frame is retained as readily as a good one, and is
then re-served on every later submit that produces no present source.

Little Nightmares III presents a uniform frame on ~2/3 of samples for exactly
that reason (#2014). The existing counters say a substitution happened
(`fresh=5672 retained=N`); they cannot say WHAT was substituted or where it came
from, and that was the missing fact. With this:

    [uniformlog] #1 retaining a UNIFORM frame rgba=(255,255,0,0)
                 bytes=33177600 origin=Composited

66 of them in a 320 s run. Two things follow that no previous instrument could
give:

  * origin=Composited -- the uniform frame comes from the COMPOSITE, not from
    the guest-scanout fallback (which declined all 66 times in the same run);
  * alpha is ZERO, and the texel is 0xFFFF0000 read little-endian as
    (255,255,0,0). The low 16 bits set and the high 16 clear is the signature of
    a 16-bit-per-channel surface being read as RGBA8 -- an R16G16 holding
    (1.0, 0.0) decodes to exactly this.

That retires the whole clear-colour family this defect was filed under (three
prior exclusions, #3113/#3114) and points at present-source SELECTION or format
instead.

Sampled rather than exhaustive: a full uniformity scan of a 33 MB frame on the
present path would cost more than the render, so it strides.

VERIFICATION NOTE, stated because it is not clean: five Vulkan render tests
(recompiled_shaders_render, multidraw_render, indexed_render,
descriptor_array_render, gpu_execute) currently fail on this machine
INDEPENDENTLY of this change -- clean master fails them, and so does an
unrelated lane's build of a different commit in the main checkout. It is a
degraded local GPU state, not a regression. The other 309 pass. CI is the
trustworthy signal for this PR.

Refs #2014, #3113, #3114.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant