agc: break the graphics boot blocker — real sceAgcCreateShader + shader-derived pipeline state - #1
Merged
Merged
Conversation
…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
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>
This was referenced Jul 4, 2026
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>
This was referenced Jul 9, 2026
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>
This was referenced Jul 10, 2026
Closed
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>
This was referenced Aug 14, 2026
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
This was referenced Aug 17, 2026
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.
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Resolves the libSceAgc boot blocker (the
CreateWorkloadregister-context null-deref chain0x3b5ea6→0x3b1562→0x3b1533) 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+0x14e74c0parses shader ELFs embedded in eboot rodata (EM_AMDGPU,.shader_header/.shader_text) and callssceAgcCreateShader(&slot->shader, header, code)per shader. Our stub returned 0 without writing*dst→ every slot stayed null →SetSourcefed 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(SetSourcereads+0x08user_data /+0x28specials /+0x5atype). The classify tables ship inside the game's own shader blobs.Implemented (hle_agc.cpp)
f3dg2CSgRKYsceAgcCreateShaderSPI/COMPUTE_PGM_LO/HI(all 5 stage pairs — Kyty supports only ES/PS and aborts otherwise), double-relocation guard, host-side registryprosper_agc_shader_count()for the AGC→Vulkan pipelineV++UgBtQhn0sceAgcGetDataPacketPayloadAddress[sub+0x10]/[sub+0x18]= Dcb data-packet payloadsn2fD4A+pb+gsceAgcCbSetShRegisterRangeDirectIT_SET_SH_REGrange packet + the real lib's marker NOPD9sr1xGUriEsceAgcCreatePrimStateHV4j+E0MBHEsceAgcCreateInterpolantMappingSPI_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
PROSPER_GFXLOG:pgm_patched=1each)eboot+0xba6e08(addr=0x8, non-AGC backtrace through0xd3xxxx/0x15fxxxx) — will chase nextFor 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_textpayloads are real gfx1030 streams for the recompilerSET_*_REGtraffic once the boot reaches submitsimgdump <module> <out.img>(flat image forobjdump -b binaryoffline disassembly)🤖 Generated with Claude Code