## HANDOFF 2026-08-24h (PS2X_ORDER, 12 wrappers + 2 anchors) - CORRECTED 08-24. THE "CONTRADICTION" WAS NEVER ONE. **UNCAPPED.** `[order] total=14192 logged=14192`. Both anchors fired twice (`11f268` @ idx 0 and 5306; `11f448` @ idx 5305 and 14191), so the log brackets BOTH ADX sessions end to end. No cap caveat this run. ### Result: wrapper balance NEVER goes negative | wrapper | type | count | |---|---|---| | 0x13bc10 | LOCK 1 | 7096 | | 0x13bc28 | UNLOCK 1 | 7078 | | 0x13bc40 | LOCK 2 | 10 | | 0x13bcb8 | UNLOCK 2 | 4 | | types 3,4,5,1000 | - | **0 - never fired** | Running balance over all 12 wrappers: **final +24, minimum 0, zero negative dips.** Per-type net: t1 = +18, t2 = +6. Both positive. Correction to 24g: the log does NOT start at run start. Its first event is `11f268`, so SVM traffic begins at ADX init exactly as originally assumed. 24g's contrary claim was inference from a capped log, not measurement. ### ⚠️ RETRACTED: "the contradiction is airtight" The original text of this section claimed the balanced call counts plus a negative counter formed an airtight contradiction. **They do not form a contradiction at all.** Retracted 2026-08-24 under [[feedback_no_guessing]]. **What is VERIFIED** (re-decoded from the ELF this session, `svm_lock` `0x13bb20`+0x18): ``` 0x13bb34 lw $v1, 0($v0) ; [0x54EBE0] LOCK hook 0x13bb38 beq $v1, $zero, 0x13bb70 ; null -> skip the ENTIRE block ... 0x13bb68 addiu $v0, $v0, 0x1 ; the increment, reached only if hook live 0x13bb6c sw $v0, 0($v1) ; -> 0x45EFC0 ``` `svm_unlock` is gated the same way on `[0x54EBE8]`. The gates are real and independent. **Why that kills the framing.** The gate is *designed* to decouple the counter from the call balance. A lock fired while its hook is null legitimately writes nothing. So net +18 calls alongside a -2 counter is what correct code produces. The only balance the counter tracks is the balance **inside the hook-live windows**, which this run cannot compute - only 2 of the 4 registrar sites were armed. That is the actual open question, not a paradox. ### Three coverage holes the retracted claim ignored - **(a) Dispatch invisibility.** `PS2X_ORDER` sees dispatch-loop calls only; direct `fn_`->`fn_` calls bypass it ([[feedback_registerfunction_bypass]]). The call census is partial **by construction**. 24h flagged this for types 3/4/5/1000 and then ignored it for types 1/2 in the next breath. - **(b) eeref's indirect blind spot.** `eeref refs 0x45efc0` returned `IMM 0x13bb54` + `IMM 0x13bba4`, i.e. two `lui`/`addiu` address-formations. A write through a register-held pointer (a `memset` over a range) is **invisible** to that scan. Read it as "two writers eeref can see", never "two writers" ([[project_eeref_static_xref]]). - **(c) Unknown live-window boundaries.** See above. ### The wrapper model was also too tidy `eeref refs 0x13bc10` -> **17 callers**; `eeref refs 0x13bc28` -> **19 callers** (`VU0_RunMicro`, `sub_12EB28`, `sub_12F4A8`, `sub_13C880`, and others). These are general-purpose lock/unlock wrappers, **not an SVM-private pair**, and the static caller sets are **asymmetric** (`sub_12EB28` and `sub_12F4A8` each call unlock twice against one lock - the early-return pattern). There is therefore no construction-level balance guarantee to appeal to. What *does* still hold: `eeref refs 0x13bb20` / `0x13bb80` -> `call=6` each, all six being wrapper tail-jumps. The core is only reached through wrappers **within eeref's coverage**. ### No re-zero path FOUND (not: none exists) `0x13c740` memsets `0x45EFE8`+0x20, `0x54EBE0`+8, `0x54EBE8`+8, `0x54EC00`+8, `0x54EC08`+8. It clears **both hooks** but the `0x45EFE8` block does **not** cover `0x45EFC0`. ⚠️ That rules out **this** memset only. Per hole (b), other range writes would not appear in the eeref scan at all, so "nothing ever re-zeros the counter" is unproven. The observation that `n` *holds* -1 across a full teardown and re-init is the real evidence here, and it is measured. ### The four registrar sites, read off the ELF | site | lock hook <- | unlock hook <- | order | |---|---|---|---| | `ADX_Init` 0x11f2b8/0x11f2c8 | 0x11E598 | 0x11E620 | lock first | | `sub_11FE90` 0x11fed4/0x11fee4 | 0x11E598 | 0x11E620 | lock first | | `sub_11F448` 0x11f4ac/0x11f4b8 | **0** | **0** | lock first | | `sub_120080` 0x1200f4/0x120100 | **0** | **0** | lock first | Both teardown sites clear the LOCK hook first and the UNLOCK hook second. In that gap: locks skip, unlocks still decrement -> **-1, permanently**. That is the correct sign. ⚠️ HYPOTHESIS, not verified - the gap is ~3 instructions and the scheduler is cooperative, so a straddling pair needs an interrupt-context caller. Only two of the four sites (`11f268`, `11f448`) were armed this run. ### `[pump]` reproduced, shifted ~18 s later ``` t=144 h2=0x11e598 n=0 nostream=1 <- register t=148 h2=0x11e598 n=-1 nostream=0 <- DRIFT, 4 s later t=170 h2=0x0 n=-1 <- teardown, n does not move t=206 h2=0x11e598 n=-1 nostream=1 <- register t=210 h2=0x11e598 n=-2 nostream=0 <- DRIFT, 4 s later t=235 h2=0x0 n=-2 <- teardown, n does not move ``` Two things this kills and one it raises: - **Kills the teardown-gap timing.** The drift is 4 s AFTER registration and ~22 s BEFORE teardown, in both sessions. The lock hook is live at the samples on both sides of it. - **Kills "the drift is noise."** Identical offset (+4 s) in both sessions. - **Raises:** the drift sample is exactly the sample where `nostream` (`0x460F04`) flips 1 -> 0, both times. Co-occurrence, not causation - noted. ⚠️ `[0x54EBE8]`, the UNLOCK hook, has **never been measured**. `[pump]` prints `h2` = `[0x54EBE0]` (lock) and `h2a` = `[0x54EBE4]` (its arg) only. The one field the mechanism turns on is the one field with no data. ### NEXT ARM - still NO BUILD. 16 addresses. 12 wrappers + all FOUR registrar call sites. The sites are what change hook state, so their positions in the order stream are the missing variable; with them the counter can be replayed exactly. `PS2X_ORDER=13bc10,13bc28,13bc40,13bcb8,13bc58,13bcd0,13bc70,13bce8,13bc88,13bd00,13bca0,13bd18,11f268,11f448,11fe90,120080` Event volume was 14192 of 16384 with 14 armed; the two added sites are low-frequency, so the headroom holds. If `logged` == 16384 the run is capped again and absence past it is not evidence. **Pre-registered reading:** - Locate each `11f448`/`120080` event. Any lock/unlock pair straddling one confirms the teardown-gap hypothesis and names the drift event exactly. - **Corrected goal (08-24).** The arm is NOT "reproduce the contradiction" - there is no contradiction. It is: **establish hook state at every event**, so gated calls can be excluded and the live-window balance computed for the first time. - Replay the counter: +1 per lock while the last hook-state event was a register, -1 per unlock likewise, skipping gated calls. If it reaches -1 then -2, the event index that first goes negative is the answer. - ⚠️ The replay is **bounded by holes (a) and (b)** regardless of outcome. It cannot see direct `fn_`->`fn_` calls, and it assumes the unlock hook's state rather than reading it - nothing has ever sampled `[0x54EBE8]`. - **If the replay does not go negative**, that is the expected-and-uninformative outcome, not a surprise. Escalate to a built probe: read `[0x54EBE0]`, `[0x54EBE8]` and `[0x45EFC0]` at both core entries in `game_overrides.cpp`. That costs a build, and it is now the **more likely** branch. ⚠️ Types 3/4/5/1000 have now been silent across two runs. Their wrappers are NOT confirmed dispatch-visible, so this is not yet proof of absence ([[feedback_registerfunction_bypass]]). ## HANDOFF 2026-08-24g (PS2X_ORDER, 6 addrs) — ★★★ THE ARM WAS AIMED AT 1 OF 6 LOCK TYPES. `svm_lock` HAS A **12-ENTRY WRAPPER TABLE**; I TRACED ONE PAIR THREE TIMES OVER. Run: `PS2X_ORDER=13bc10,13bc28,126440,126458,118d98,118db0`, det=1, 260 s, no rebuild (same exe as 24d/24e/24f). `[order] armed -- tracing dispatch ORDER of 6 addresses`. All six addresses appeared ⇒ **no dispatch bypass** for any of them; the order trace sees this layer. ### ⚠️ CAPPED — `[order] total=25784 logged=16384` (63.5%) Absence past the cap is NOT evidence ([[feedback_capped_probes_false_negatives]]). No timestamps in the order log, so the window's end is unbounded by measurement. Uniform-rate ESTIMATE ⇒ ~165 s. **HYPOTHESIS, not verified.** Next arm fixes this with an anchor. ### RESULT — type-1 lock/unlock is PERFECTLY BALANCED Expanded 16384 events: `13bc10`=4610, `13bc28`=4598, `126440`=2233, `126458`=2195, `118d98`=1833, `118db0`=915. Running balance over the innermost pair (9208 events): **final=+12, min=0, negative dips=0.** The counter never went below zero on type 1. It cannot be the source of a NEGATIVE nest count — *provided* the logged window covers t=129 s (see cap caveat). ### ★★★ WHY THE ARM WAS TOO NARROW — the three traced layers are ONE path Disassembly proves they are a tail-jump chain, not three independent probes: ``` 0x126440 j 0x118d98 ; thunk 0x118d98 j 0x13bc10 ; thunk 0x13bc10 addiu $a0,$zero,1 ; <-- TYPE = 1 j 0x13bb20 ; svm_lock core ``` So all six armed addresses were **type 1 only**. Five other lock types were never observed. ### ★★★ THE FULL WRAPPER TABLE (0x13bc10–0x13bd2c) — 6 types, 12 wrappers, symmetric Each wrapper sets `$a0` = lock type, then tail-jumps to the core (`0x13bb20` = lock, `0x13bb80` = unlock). | type | LOCK | UNLOCK | static callers (lock/unlock) | |---|---|---|---| | 1 | `0x13bc10` | `0x13bc28` | (traced — balanced) | | 2 | `0x13bc40` | `0x13bcb8` | 8 / 8 | | 3 | `0x13bc58` | `0x13bcd0` | 1 / 1 | | 4 | `0x13bc70` | `0x13bce8` | 5 / 5 | | 5 | `0x13bc88` | `0x13bd00` | 1 / 1 | | 1000 | `0x13bca0` | `0x13bd18` | 0 / 0 — UNREACHABLE statically | Caller counts are symmetric per type — no static smoking gun. Type 2's callers live in `0x12E688`–`0x132610`; types 3/4/5 route through `0x1547c8`–`0x154894`, i.e. **the SofDec/movie region** (the movie gate is `0x155268`). ### ★★★ CORRECTED COUNTER SEMANTICS — read off the ELF, replaces the 24e/24f paraphrase `svm_lock` @ `0x13bb20`: ``` v0 = 0x54EBE0 ; v1 = [v0] ; lock hook fn beq v1,0 -> 0x13bb70 ; NULL HOOK: return, NO increment, NO WRITE jalr v1 (a0 = [0x54EBE4]) ; call the hook lw v0,[0x45EFC0] ; beql v0,0 -> sw s0,[0x45EFC4] ; record TYPE only on 0->1 lw v0,[0x45EFC0] ; addiu +1 ; sw [0x45EFC0] ; increment ``` `svm_unlock` @ `0x13bb80`: ``` v0 = [0x54EBE8] ; unlock hook fn beq v0,0 -> 0x13bbf8 ; NULL HOOK: return, NO decrement lw v1,[0x45EFC0]; addiu -1; sw ; DECREMENT FIRST, unconditionally bne [0x45EFC0],0 -> 0x13bbec ; still nested: just call the hook lw v1,[0x45EFC4] ; beq v1,a2 -> skip ; count hit 0: TYPE MUST MATCH jal 0x13bd40 (str @ 0x4BC7C0) ; <-- MISMATCH = ERROR PRINTER sw zero,[0x45EFC4] lw v0,[0x54EBE8] ; jalr v0 (a0=[0x54EBEC]) ``` Two consequences, both new: 1. **`0x45EFC4` is a single shared type slot** guarding a single shared counter across all 6 types. Interleaving two different types is a guest-visible error condition. 2. **The guest has its own assert at `0x13bd40`.** If it ever fires we should see its string (`0x4BC7C0`) in the log. Not yet checked — do it. ### The guest's OWN assert — found, and structurally unable to fire here String at `0x4BC7C0` (referenced ONLY from `0x13bbe0`, the mismatch path): ``` 2103102:SVM:svm_unlock:lock type miss match.(type org=%d, type now=%d) ``` This confirms the whole model from CRI's own text: one shared counter, one shared type slot, mixing types is an error they explicitly guard. **It never appears in `run_log.txt` (0 hits) and that is NOT evidence.** The check sits behind `bne [0x45EFC0],0 -> 0x13bbec` — it runs *only* when the decrement lands exactly on 0. The very first unmatched unlock takes the count 0 -> -1, which is non-zero, so the check is skipped; every subsequent unlock sees a non-zero count too. **The assert is structurally incapable of catching this failure.** Do not read its silence as a clean bill of health. ### `[pump]` REPRODUCED, ~4 s earlier than 24f — the shape is stable | t | hook | n | | |---|---|---|---| | 126 s | registered | 0 | ADX_Init | | **129 s** | registered | **-1** | drift, **3 s after** register, both hooks live | | 148 s | cleared | -1 | `sub_11F448` teardown — **count does not move** | | 181 s | registered | -1 | ADX_Init | | **185 s** | registered | **-2** | drift, **4 s after** register | | 206 s | cleared | -2 | teardown — count does not move | Ends at -2, matching 24f. 24e's terminal `n=0` remains unreproduced ⇒ noise, as recorded. ### STANDING CONCLUSION (unchanged, now better founded) One unlock-without-lock per ADX session, ~3–4 s into it, while both hooks are live. Not the registrar window. Not the teardown window. With `n` ≤ 0, `svm_lock` returns ≤ 0 and the movie gate `bne $s0,$s2` @ `0x155268` cannot pass. ### NEXT ARM — again NO BUILD. All 12 wrappers + a WINDOW ANCHOR. The cap caveat is removed by arming two addresses that fire exactly twice each at known wall-clock seconds, giving the order log the timestamps it lacks: - `11f268` = `ADX_Init` — fires at t=126 s and t=181 s - `11f448` = teardown — fires at t=148 s and t=206 s **Pre-registered reading, declared before the run:** - Count `11f268` occurrences. **2 ⇒ the window covers both drifts. 1 ⇒ it covers only t=129 s** (still enough — drift #1 is inside). **0 ⇒ the window ends before ADX init and the whole trace is uninformative**; rerun with a shorter `-RunSeconds` to move the cap earlier. - Between the 1st `11f268` and the 1st `11f448`, compute the running balance over all 12 wrappers. **The first event that drives it below zero is the unmatched unlock — and its wrapper address names the lock type.** - The three redundant thunk layers (`126440/126458/118d98/118db0`) are DROPPED: they are type-1 duplicates and consumed 7176 of 16384 slots for no information. - ⚠️ Type-1 wrappers are confirmed dispatch-visible. The other five are NOT yet confirmed — if a type never appears, that may be `registerFunction` bypass ([[feedback_registerfunction_bypass]]), not absence. ``` $env:PS2X_ORDER="13bc10,13bc28,13bc40,13bcb8,13bc58,13bcd0,13bc70,13bce8,13bc88,13bd00,13bca0,13bd18,11f268,11f448" ``` ## HANDOFF 2026-08-24f (HWWATCH on 0x54EBE0) — ★★★ TEARDOWN OWNER IDENTIFIED = `sub_11F448`. DRIFT IS **NOT** AT REGISTER OR TEARDOWN — IT IS ~4 s INTO EACH ADX SESSION. Run `20260824-171409`, `exeWritten=2026-08-24 16:33:12` (SAME exe as 24d/24e — no rebuild), 260 s, `hwWatch=True`, `PS2X_HWWATCH_ADDR=0x54EBE0`, `PS2X_HWWATCH_VAL=0xFFFFFFFF`, `PS2X_DETERMINISM=1`, quantum 20000, `PS2X_IPUTRACE=64`. ### Coverage — CLEAN, no saturation **8 hits** (cap is 16384). Full 260 s covered. Unlike 24e, absence IS evidence here. `[HWSTAT] records=0` again — the stat tag is simply never emitted; not a negative result. ### CONFIRMED — two writers of `0x54EBE0`, and two complete ADX life cycles | vbl | order | store site | newval | guest path | |---|---|---|---|---| | 750 | #0 | `0x18e408` memset | 0 | `ADX_Init+0x73b` → `0x13c7e8` → `0x13c740` | | 750 | #1 | `0x13c4c8` registrar | **0x11E598** | `ADX_Init+0x7f4` ← `sub_113F40` ← `sub_113D30` | | 1019 | #2 | `0x13c4c8` registrar | 0 | **`sub_11F448+0x8c4`** ← `0x113ee0` ← `0x113c60` ← dispatchLoop | | 1019 | #3 | `0x18e408` memset | 0 | `sub_11F448+0xa4a` → `0x13c828` → `0x13c740` | | 1217 | #4/#5 | — | 0 then 0x11E598 | identical repeat of vbl 750 | | 1665 | #6/#7 | — | 0 then 0 | identical repeat of vbl 1019 | ⇒ **`sub_11F448` is the ADX teardown function.** This is the thing static xrefs could not answer. `sub_11FE90` and `sub_120080` — the two statically-unreachable candidates — were **NOT** observed. They are not on the live path. ⇒ The game runs **ADX_Init → sub_11F448 → ADX_Init → sub_11F448** twice in 260 s. ### CONFIRMED — hook values, from the ELF `eeref refs` gives four callers for each registrar, and they are the SAME four (`ADX_Init+0x50/0x60`, `sub_11F448+0x64/0x70`, `sub_11FE90+0x44/0x54`, `sub_120080+0x74/0x84`) — lock registrar then unlock registrar, **3 instructions apart** in every case. ``` 0x11f2b8 jal 0x13c4c8 a0 = 0x11E598 ; register lock hook 0x11f2c8 jal 0x13c4e0 a0 = 0x11E620 ; register unlock hook 0x11f4ac jal 0x13c4c8 a0 = 0 ; deregister lock hook 0x11f4b8 jal 0x13c4e0 a0 = 0 ; deregister unlock hook ``` ### ★★★ REFUTED — the register/teardown window is NOT the drift mechanism `[pump]` (256 records, no cap) correlates 1:1 with the watchpoints and kills it: | t | h2 (`0x54EBE0`) | n (`0x45EFC0`) | matches | |---|---|---|---| | 1 | 0 | 0 | — | | 130 | **0x11e598** | 0 | hwwatch vbl=750 register | | 134 | 0x11e598 | **-1** | **drift, 4 s AFTER register, both hooks LIVE** | | 154 | 0 | -1 | hwwatch vbl=1019 teardown — count does NOT move | | 189 | **0x11e598** | -1 | hwwatch vbl=1217 register | | 192 | 0x11e598 | **-2** | **drift, 3 s AFTER register** | | 217 | 0 | -2 | hwwatch vbl=1665 teardown — count does NOT move | | 256 | 0 | -2 | end of run | ⇒ The count loses **exactly one increment per ADX session**, ~3–4 s in, while both hooks are registered. Neither the 3-instruction register window nor the teardown window is responsible. ⚠️ **CORRECTION to 24e.** 24e's final sample read `n=0x0`; this run ends at `n=-2`. That "climb back to 0" was run-specific and did **not** reproduce. Treat it as noise, not a fact. ### The lock/unlock thunks are separate functions, not a straight-line pair ``` 0x126440 addiu $sp,-0x10 / sd $ra / ld $ra / j 0x118d98 / addiu $sp,+0x10 ; LOCK thunk 0x126458 addiu $sp,-0x10 / sd $ra / ld $ra / j 0x118db0 / addiu $sp,+0x10 ; UNLOCK thunk ``` `eeref refs`: `0x126440` has **10** callers, `0x126458` has **15**. The excess is concentrated in multi-exit functions (`sub_125470` 1 lock / 4 unlocks; `sub_125898` 2 / 4), which is the normal single-entry-multiple-return shape — **not by itself a bug**, same as `sub_11D510`. Static analysis cannot settle it further. ### ★★★ Why a data watchpoint can never close this A `svm_lock` that takes the null-hook branch at `0x13bb38` **writes nothing**. The missing increment is invisible to `PS2X_HWWATCH` by construction. 24e's #418 showed the unmatched *unlock*; the absent *lock* leaves no trace to watch. **Stop watching `0x45EFC0`.** ### NEXT ARM — again NO BUILD. Trace ORDER, not writes. `PS2X_ORDER` records dispatch order with no memory reads, so it sees calls that produce no store. SVM traffic does not begin until ADX init (24e proved: zero writes to `0x45EFC0` before vbl 956), so the 16384-entry log starts filling at init and covers ~18 s — the drift is 3–4 s in, comfortably inside. ``` cd "F:\SDBZ Recomp"; $env:PS2X_IPUTRACE="64"; $env:PS2X_ORDER="13bc10,13bc28,126440,126458,118d98,118db0"; Remove-Item Env:PS2X_HWWATCH_ADDR -EA SilentlyContinue; Remove-Item Env:PS2X_HWWATCH_VAL -EA SilentlyContinue; Remove-Item Env:PS2X_HWWATCH_VBL_LO -EA SilentlyContinue; Remove-Item Env:PS2X_HWWATCH_VBL_HI -EA SilentlyContinue; Remove-Item Env:PS2X_REFSTAT_YIELD -EA SilentlyContinue; .\launch_recomp.ps1 -Determinism 1 -RunSeconds 260 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` (No `-HwWatch` this time, and every `PS2X_HWWATCH_*` var must be cleared or the launcher throws at `launch_recomp.ps1:264`.) **Pre-registered reading — declared before the run:** - A `13bc28` (unlock) with **no preceding `13bc10`** (lock) = the lost pair, located exactly. - Same at the thunk layer: a `126458` not preceded by `126440`. - ⚠️ `PS2X_ORDER` only sees **dispatch-loop** calls; direct C++ `fn_` calls bypass it ([[feedback_registerfunction_bypass]]). 24e's backtraces show `0x13bc10` IS dispatch-reached, but `0x118d98`/`0x126440` are unverified. If some of the six addresses never appear, that is the bypass, **not** absence. - Check `[order]` for its cap line before reading any absence as evidence. - `[order] armed -- tracing dispatch ORDER of 6 addresses` must appear at startup; "parsed 0 addresses" means it did not arm. ## HANDOFF 2026-08-24e (HWWATCH on 0x45EFC0) — ★★★ SVM READING **CONFIRMED**. THE LOST INCREMENT IS AT **vbl=962**, NOT AT THE t=203 DEREGISTRATION. Run `20260824-165316`, `exeWritten=2026-08-24 16:33:12` (same exe as 24d), 260 s, `hwWatch=True`, `PS2X_HWWATCH_ADDR=0x45EFC0`, `PS2X_HWWATCH_VAL=0xFFFFFFFF` (0xFFFFFFFF behaves as a **wildcard** — newvals 0,1,2,3,4 were all captured). ### ⚠️ The capture SATURATED. Read the coverage before reading anything else. - `run_probe.jsonl.hwwatch.txt` holds **exactly 16384 hits = `kHwWatchMaxHits`**. - Coverage is **vbl 956 → 1162 only**. Everything after vbl 1162 is UNOBSERVED. - ⇒ The t=203 deregistration and the t=237 re-registration are **not in this capture**. Any statement about who wrote the count at those moments is unsupported by this run. - `[HWSTAT]` printed **records=0** — the stat line never emitted. Saturation is inferred from the hit count matching the cap exactly, not from a `[cap]` marker. (`analyze_run.py --tag HWSTAT` therefore says "absence IS evidence" — **it is wrong here**; the tag was never written at all. See [[feedback_capped_probes_false_negatives]].) ### CONFIRMED: 0x45EFC0 has exactly two writers, and they are the two SVM primitives Every one of the 16384 store sites is one of: | store site | guest | role | |---|---|---| | `noop_sub_bb20_0x13bb20 + 0xc7b` | `0x13bb6c` | `svm_lock` increment | | `noop_sub_bb80_0x13bb80 + 0xa6d` | `0x13bbb0` | `svm_unlock` decrement | **No third party touches the nest count.** HANDOFF 24d's SVM identification is confirmed by direct observation, not inference. ### The drift begins at vbl=962 — during ADX/boot init, long before the movie ``` #416 vbl=962 -> 1 LOCK 0x13bc10 <- 0x138a58 <- 0x139ce8 <- sub_125470+0x1b27 #417 vbl=962 -> 0 UNLOCK 0x13bc28 <- 0x138a70 <- 0x139ce8 <- sub_125470+0x1b27 (balanced pair) #418 vbl=962 -> -1 UNLOCK 0x13bc28 <- 0x118db0 <- 0x126458 <- sub_125470+0x473a <<< NO MATCHING LOCK #419 vbl=962 -> 0 LOCK 0x13bc10 <- 0x118d98 <- sub_11C940+0x18 ``` From #418 onward the counter oscillates `0 / -1` for the rest of the capture and never recovers inside it. Value census over all 16384 hits: `0`=4901, `-1`=3792, `2`=3255, `3`=2441, `1`=1959, `4`=36. It reaches +4, so nesting is real and deep. ### The lost write is an INCREMENT, and the guest code is statically balanced `0x118d98` = ADX lock wrapper, `0x118db0` = ADX unlock wrapper (thin `j` into `0x13bc10`/`0x13bc28`). `eeref refs` on both returns **matched pairs in every caller** — `sub_114C98`, `sub_11C150`, `sub_11C400` (x2), `sub_11C660`, `sub_11C940`, `sub_11D728`, `sub_11D8B8`, `sub_1218F8` (x2), `sub_121D68`, `sub_124118`, `sub_1275F8`, `noop_sub_6ce0`, `noop_sub_7140`, `noop_sub_76b0`, `sub_128FA0`… Only `sub_11D510` shows 1 lock / 2 unlocks, and that is two exit paths from one critical section. ⇒ **The guest source is balanced. The imbalance is produced at runtime.** #418's unlock arrives via `0x126458`; its lock counterpart is `0x126440` (`noop_wrapper_c`, `j 0x118d98`). That lock is absent from the write stream — so it **executed but did not increment**, i.e. it took the `beq $v1,$zero,0x13bb70` null-hook branch at `0x13bb38`. **HYPOTHESIS (not yet verified):** `[0x54EBE0]` was null when the lock ran and non-null when the unlock ran — the lock-hook registration landed between them. ### REFUTED this session - **"The registrars take the SVM lock themselves."** No. `0x13c4c8` and `0x13c4e0` are each five instructions, two plain `sw`s and a `jr`. No locking. - Also clarified: the pair is **not** (lock hook, unlock hook). `0x13c4c8` writes `[0x54EBE0]`=fn + `[0x54EBE4]`=arg; `0x13c4e0` writes `[0x54EBE8]`=fn + `[0x54EBEC]`=arg. Two registrars, one per hook, each taking fn+arg. ### Reproduction + one new fact `[pump] records=256, no [cap]`. Same shape as 24d, shifted ~58 s later by HWWATCH's cost: `t=179` h2 registers with `n=0` → `t=182` `n=-1` → `t=203` h2 **deregistered** → `t=237` re-registered (34 s gap, matching 24d's 33 s) → `t=240` `n=-2`. **NEW: `t=256` (final sample) `n=0x0`.** The count climbed from -2 back to 0 in the last second — two increments with no decrements. Not explained; the run ended there, so we do not know whether it held. If it holds, `svm_lock` would return 1 on the next call and the movie body's `bne $s0,$s2` gate at `0x155268` would pass. ### NEXT ARM — again NO BUILD. Move the watch to the hook itself. `0x54EBE0` is written only by the registrars — a handful of times, so **no saturation risk**, and the full 260 s is covered instead of 3 s of it. ``` cd "F:\SDBZ Recomp"; $env:PS2X_IPUTRACE="64"; $env:PS2X_HWWATCH_ADDR="0x54EBE0"; $env:PS2X_HWWATCH_VAL="0xFFFFFFFF"; Remove-Item Env:PS2X_HWWATCH_VBL_LO -EA SilentlyContinue; Remove-Item Env:PS2X_HWWATCH_VBL_HI -EA SilentlyContinue; Remove-Item Env:PS2X_HWWATCH_CLIENT -EA SilentlyContinue; Remove-Item Env:PS2X_REFSTAT_YIELD -EA SilentlyContinue; .\launch_recomp.ps1 -HwWatch -Determinism 1 -RunSeconds 260 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ⚠️ `-HwWatch` is MANDATORY — `launch_recomp.ps1:264` throws if any `PS2X_HWWATCH_*` var is set without it, precisely to stop a silent false negative. **Pre-registered reading** (declared before the run): - **Every write to `0x54EBE0` gets a backtrace.** Expect ≥4 (register at ~t=179, clear at ~t=203, register at ~t=237), each naming its caller — which finally identifies who tears ADX down mid-run, the thing static xrefs cannot answer (`sub_11FE90` and `sub_120080` are both UNREACHABLE in the static image). - **hits=0 and skipped=0** ⇒ nothing writes the hook and the `[pump]` h2 transitions came from somewhere else — the whole 24d/24e model is wrong. - Check the hit count against **16384** before treating any absence as evidence. --- ## HANDOFF 2026-08-24d (`[pump]` h2/n RESULT) — ★★★ IDENTIFIED: THE GATE IS CRI **SVM LOCK NESTING**, AND THE NEST COUNT IS **NEGATIVE**. Run `20260824-163809`, exe written 16:33:12 (fresh, 5 min before launch). `[pump] records=197, no [cap]`. `PS2X_IPUTRACE=64`, det=1, quantum=20000, `PS2X_REFSTAT_YIELD` correctly absent. ### The string that named the subsystem `0x4bc7c0` = `"2103102:SVM:svm_unlock:lock type miss match.(type org=%d, type now=%d)"` So, verified by decode + string, not inference: | addr | role | |---|---| | `0x13bb20` | `svm_lock` core — calls hook `[0x54EBE0]`, then `[0x45EFC0]++`, **returns the NEW count** | | `0x13bb80` | `svm_unlock` core — gated on hook `[0x54EBE8]`, does `[0x45EFC0]--` | | `0x45EFC0` | SVM lock **nest count** | | `0x45EFC4` | SVM lock **type** (the `type org` in the error string); written only when count was 0 | | `0x13bd30` | getter for the lock type | | 6+6 thin wrappers | one per lock type; `0x13bc10` passes type `1` | `0x155264 daddu $s0,$v0` / `0x155268 bne $s0,$s2` (s2=1) ⇒ **the movie body demands `svm_lock` return exactly 1**, i.e. it must be the OUTERMOST lock holder. ### The measurement (1 Hz transitions — this is why sampling mattered) ``` t=1 en=0 h2=0x0 n=0 t=121 en=1 h2=0x11e598 n=0 <- registered; a lock here returns 1. CORRECT. t=124 en=1 h2=0x11e598 n=0xffffffff (-1) t=130 en=1 h2=0x11e598 n=0x1 t=131 en=1 h2=0x11e598 n=0xffffffff (-1) t=143 en=1 h2=0x0 n=0xffffffff <- HOOK DEREGISTERED t=176 en=1 h2=0x11e598 n=0xffffffff <- re-registered 33 s later t=178 en=1 h2=0x11e598 n=0xfffffffe (-2) ``` `h2a` (`[0x45EFC4]`) = **0x0 at all 197 samples** — the "count was 0" branch never left a visible mark. ⚠️ A single end-of-run read would have shown `h2=0x11e598` and called it healthy. The register→deregister→register cycle is only visible because the probe samples at 1 Hz. See [[feedback_probe_the_final_value]]. ### Why the count goes negative `svm_lock` increments **only if** `[0x54EBE0]` is registered. `svm_unlock` decrements **only if** `[0x54EBE8]` is registered. Calls that straddle a register/deregister boundary are unbalanced, and **nothing ever resets `[0x45EFC0]`** — so the drift is permanent. Once negative, `svm_lock` returns 0 or -1, never 1, and the movie body bails to `0x155308` on every attempt. Registration map (all four confirmed by disassembling the call sites): | caller | a0 → lock hook | a0 → unlock hook | role | |---|---|---|---| | `ADX_Init+0x50` (`0x11f2b8`) | `0x11e598` | `0x11e620` | REGISTER | | `sub_11FE90+0x44` (`0x11fed4`) | `0x11e598` | `0x11e620` | REGISTER | | `sub_11F448+0x64` (`0x11f4ac`) | **0** | **0** | DEREGISTER | | `sub_120080+0x74` (`0x1200f4`) | **0** | **0** | DEREGISTER | ### Two corrections to HANDOFF 2026-08-24c 1. `sub_13C740` clears **both** hooks (`memset(0x54EBE0,0,8)` AND `0x54EBE8`), so it is NOT the asymmetric wipe I hypothesized. More decisively, `eeref` reports it **UNREACHABLE — nothing links to it**. It is dead code and cannot be what cleared `h2` at t=143. The clear came from a real ADX deregistration. 2. The "memset ordering hazard" pre-registered reading is therefore retired. The observed `h2 → 0 → h2` churn is an **ADX shutdown/re-init cycle**, not an init race. ### Still unmeasured (the next question) `[0x54EBE8]` was never sampled — the unlock hook's timeline is unknown, so the exact straddle window is inferred, not measured. And no probe names **who** calls `sub_11F448` at t=143. `sub_11FE90` and `sub_120080` are both UNREACHABLE statically ⇒ reached only via function pointer, so static xref cannot close this. It needs a runtime writer trace. ### NO BUILD NEEDED for the next arm `PS2X_HWWATCH_ADDR` takes a **guest EE address**, and the frame-trace wrapper ([game_overrides.cpp:3387](ps2xRuntime/src/lib/game_overrides.cpp:3387)) arms DR0 unconditionally whenever it is set — `PS2X_FRAMETRACE=1` is already in the run env. So a DR0 write-watch on `0x45EFC0` is a **pure run**, no rebuild. Pre-registered reading: - `HWSTAT hits>0` → `.hwwatch.txt` backtraces name every writer of the nest count, in store order. That directly identifies the unbalanced caller. - `hits=0, skipped>0` → the value filter is wrong; re-run (should not happen with `VAL=0xFFFFFFFF`). - `hits=0 AND skipped=0` → nothing writes it; the whole SVM read above is wrong. - Cap is `kHwWatchMaxHits = 16384` — check HWSTAT for saturation before treating any absence as evidence ([[feedback_capped_probes_false_negatives]]). ⚠️ Use `-RunSeconds 260`, not 200. HWWATCH costs ~20% wall-clock; the t=176 re-registration sits close enough to the old 200 s edge to be lost ([[feedback_run_window_false_negative]]). ### Unchanged from previous runs `[ipu:cmd] records=12, no [cap]` — byte-identical to arms A–D. Still **zero decode commands** (codes seen: 0, 5=SETIQ, 6=SETVQ, 9=SETTH). Consistent: the movie body bails before ever issuing a decode. `[cblist]` L6 = 9→11 in 197 s while L0 reaches 443. `[thsync]` still alternates WORKER-INSIDE-WORK / WORKER-NOT-RUNNING. --- ## HANDOFF 2026-08-24c (`[pump]` RESULT) — ★★★ THE FALLBACK HYPOTHESIS WAS WRONG. THE REAL GATE IS A SECOND HOOK AT `0x54EBE0`, REGISTERED BY THE ADX LAYER. Run `20260824-161358`, exe written 16:12:14 (fresh). `[pump] records=202, no [cap]`. ### Verified - `en=0` for t=1..126, then **`en=1` from t=127 to the end**. Master enable opens. - `skip=0` at every one of the 202 samples. The skip gate never fires. - `nostream=0` at nearly every sample. The stream loop is NOT the thing being skipped. - Only `s[0]` is ever non-zero, and its value is `1` — not a pointer. `s[1..7]` always 0. - **`hook(0x54EBF8)=0x0` at all 202 samples.** - `[ipu:cmd] records=12, no [cap]` — byte-identical to arms A-D. Still ZERO decode commands. ### ⚠️ CORRECTION — my pre-registered reading #3 was wrong I pre-registered "`en=1, skip=0, hook=0x0` -> the fallback hypothesis is confirmed; `0x13c880`'s handler was never registered, and *that* registration is the stage." **`hook=0x0` is the DESIGNED state, not a missing registration.** Static proof: - The only writer of `0x54EBF8` other than a `sw $zero` clear at `0x13c7dc` is `0x13c8fc`, inside the register-once function `0x13c8e8`. - `eeref refs 0x13c8e8` -> **exactly one caller**, `0x13c920`, inside `0x13c910`. - `0x13c910` passes **`a0 = 0`**. - `eeref refs 0x13c910` -> **UNREACHABLE in the static image; nothing links to it.** No path in this ELF can make `0x54EBF8` non-zero. `0x13c880` ALWAYS takes its `0x13bc10` fallback. That fallback is the normal handler, not a degraded one. ### The real gate (decoded field by field) `0x13bc10` discards its argument (`addiu $a0,$zero,0x1`) and tail-jumps `0x13bb20`: ``` 0x13bb20: v0 = 0x54EBE0 if ([v0] == 0) goto 0x13bb70; // v0 left holding 0x54EBE0 jalr [v0] (a0 = [v0+4]) if ([0x45EFC0] == 0) [0x45EFC4] = arg; v0 = [0x45EFC0] + 1; [0x45EFC0] = v0; 0x13bb70: jr $ra // $v0 NEVER assigned on this path ``` - **null** -> returns `0x54EBE0`, the address itself. - **bound** -> first call returns exactly **1**. And `0x155264 daddu $s0,$v0,$zero` / `0x155268 bne $s0,$s2` with `s2=1` means the movie body bails to `0x155308` on anything but `1`. Confirmed by disassembly, not inferred. ### Why this closes the loop on the ten-session ADX drill Registrar is `0x13c4c8` (`sw $a0,0($v0); sw $a1,4($v0)` -> `0x54EBE0`/`0x54EBE4`). `eeref refs 0x13c4c8`, call=4: | caller | function | |---|---| | `0x11f2b8` | **`ADX_Init+0x50`** | | `0x11f4ac` | `sub_11F448+0x64` | | `0x11fed4` | **`sub_11FE90+0x44`** | | `0x1200f4` | `sub_120080+0x74` | `sub_11FE90` is the function last session proved DID run. The ADX layer the drill kept circling is exactly what registers the handler this gate needs. **Ordering hazard, not yet measured:** `0x13c750` does `memset(0x54EBE0, 0, 8)` (via `0x18e408`). If that init runs AFTER the ADX registration, it wipes it. ### `[pump]` extended — pre-registered reading (written BEFORE the run) New fields `h2=[0x54EBE0]`, `h2a=[0x54EBE4]`, `n=[0x45EFC0]` at `ps2xRuntime/src/lib/ps2_runtime.cpp:5153`. - `h2=0x0` at **every** sample -> the registration never happens. Stage = why `ADX_Init`/`sub_11FE90` don't reach `0x13c4c8`. - `h2` non-zero **then returns to 0** -> the `0x13c750` memset wipes a live registration. Stage = init ordering. This is the case a single end-of-run read would have missed entirely. - `h2` non-zero and stable, `n=0` -> registered but `0x13bb20` is never called; the fault is upstream of `0x1548a0`. - `h2` non-zero, `n` climbing, still no IPU decode -> the gate passes and the fault is inside the stream loop at `0x155320`. ### Still open - `iChangeThreadPriority` (`Thread.cpp:1138`) still calls `force_reschedule()`, contrary to ps2tek 2Ah. - `0x4418F8` value mismatch; `dispFbp`/`ctx0.fbp` divergence. --- ## HANDOFF 2026-08-24b (`[cblist]` RESULT) — **★★★ THE MOVIE PUMP IS ONE FUNCTION (`0x154fa8`), IT IS DISPATCHED 9 TIMES IN 200 s, AND THE WHOLE CHAIN IS NOW DECODED.** Run `20260824-155704`, arm-A settings + `PS2X_IPUTRACE=64`, `PS2X_REFSTAT_YIELD` unset. `[cblist] records=198, no [cap]`. ### Verified (not inferred) 1. **`[thsync] tick` IS the list-6 dispatch counter.** They match sample for sample: t=124 → 1/1, t=127 → 3/3, t=140 → 4/4, t=142 → 5/5, t=147 → 6/6. The `[thsync]` verdict string "stuck in 0x13c6e8 / the movie pump" was a guess in a string; it is now grounded — list 6 really is the movie worker's list. 2. **List 6 holds exactly ONE callback: `fn=0x154fa8, arg=0x0`.** Slots 1–5 null. That is *not* a missing registration — every list is sparse (L2 fill=2, L4 fill=1, L5 fill=2). The pre-registered "a null slot means an unregistered callback" reading does **not** apply. 3. **The movie is started TWICE, with a full teardown between.** - t=124 registration → lists live → L6 tick creeps 1→5 - **t=147 teardown**: all `fn` slots cleared, ticks for L0–L5 zeroed (L6/L7 ticks survive) - **t=147–183: 36 seconds where every list is empty and nothing ticks at all** - t=184 re-registration, identical shape, L6 tick 7→9 then frozen This exactly explains `[ipu:cmd] records=12` = two identical 6-command blocks. 4. **List 6 is dispatched only while `req=1`.** L0/L2/L4/L5 tick at ~22–29/s (frame rate); L6 ticked **9 times in 198 s**. `busy=1` at consecutive 1 Hz samples ⇒ the callback dwells ≥1 s per call. 5. **IPU unchanged from arms A–D**: BCLR/SETIQ/SETIQ/SETVQ/SETTH/BCLR ×2, zero decode. ### The decoded pump chain — every address a fixed lui+addiu absolute ``` 0x14e4d0: return 0x45F678 <- movie global base 0x154ff0: return [base+0x10] = [0x45F688] 0x1556f8: return [base+0x188C]= [0x460F04] 0x154fa8 (THE registered callback) if ([0x45F688] == 1) return 0; // SKIP flag return f_155210(); 0x155210 (the body) if ([0x45F674] != 1) return 0; // master enable (base-4) if (f_1548a0(base+0x58) != 1) return 0; // object state f_155148(); if (f_1556f8() == 1) skip the stream loop; // [0x460F04] for (i=7; i>=0; --i) f_155320(0x45F6E4 + i*0x304); // 8 streams, stride 0x304 0x1548a0 -> tail-jumps 0x13c880: v0 = [0x54EBF8]; // RUNTIME-REGISTERED fn ptr if (v0) return v0(a0); else return f_13bc10(a0); ``` ⚠️ `eeref refs 0x45f674` returns only ONE imm site (`0x155058`). It does **not** see the `lui $v0,0x46 / lw $s2,-2444($v0)` reads at `0x15522c` and `0x15571c` — eeref pairs `lui` with `addiu`, not with `lw` offsets. **Treat its writer set for this address as incomplete** ([[project_eeref_static_xref]]). ### HYPOTHESIS — not yet verified The t=190 `[watchdog]` trace reads `... 0x12f1e8 -> 0x13c880 -> 0x13bc10 ...`, which is the **null-pointer fallback branch** of `0x13c880`. If `[0x54EBF8]` is genuinely 0, the object-state gate in `0x155210` is answered by a fallback rather than the real handler. `hook=` in the new probe confirms or kills this. Do not act on it before the run. ### `[pump]` probe added — `ps2_runtime.cpp:5071`, UNBUILT 1 Hz, unconditional, appended to the `[cblist]` block. Prints `en= skip= nostream= hook= obj= s:<8 stream head words>`. **Pre-registered reading** (declared before the run): - `en != 1` → the body never runs; find the writer of `0x45F674` — that is the stage. - `en=1, skip=1` → the callback self-skips every dispatch; `0x45F688` is the gate. - `en=1, skip=0, hook=0x0` → the fallback hypothesis is confirmed; `0x13c880`'s handler was never registered, and *that* registration is the stage. - `en=1, skip=0, hook!=0, nostream=1` → the stream loop is skipped; `0x460F04` is the gate. - all gates open, streams non-zero, still no IPU decode → the fault is inside `0x155320`. ### Corrections to the previous handoff - "no run has ever read those six pointers" — now read. There is one pointer, and it is present. The pump is **registered**; it is **under-dispatched** (9×/200 s) and it **dwells**. - The 8-second `[thsync]` freeze is not one event: it is a start → 23 s of shallow ticking → teardown → **36 s of total silence** → restart. The silence window is the larger anomaly. ### Still open `0x4418F8` value mismatch; `dispFbp` vs `ctx0.fbp` divergence. Perf lane parked. `iChangeThreadPriority` (`Thread.cpp:1138`) still calls `force_reschedule()` against ps2tek 2Ah. --- # PS2_PROJECT_STATE — SDBZ Recomp ## HANDOFF 2026-08-24 (arms A–D RESULT) — **★★★ THE IPU IS CONFIGURED AND NEVER FED A DECODE. `PS2X_REFSTAT_YIELD` is a hard REGRESSION — leave it OFF. The quantum is NOT the frame-rate cap.** Four arms, one exe (`exeWritten=2026-08-24 13:13:26`, identical bytes in all four `[runmeta]` lines — no stale-binary trap). All 200 s, det=1, `PS2X_IPUTRACE=64`. | arm | log | `REFSTAT_YIELD` | `quantum` | .SFD open | adx st1@ / maxst | movie maxstat | |---|---|---|---|---|---|---| | **A** control | `20260824-132448` | unset | 20000 | ✅ | 123 s / 3 | 1 | | **B** quantum | `20260824-132046` | unset | 2000 | ✅ | **70 s** / 3 | 1 | | **C** yield | `20260824-132857` | 1 | 20000 | ❌ | 120 s / **1** | — | | **D** both | `run_log.txt` | 1 | 2000 | ❌ | 87 s / **1** | — | ### ★★★ Result 1 — `PS2X_REFSTAT_YIELD` breaks the game. Verdict: REJECTED. - C and D **never open the `.SFD`**: `dvci=0 mvopen=0 cdsrch=0 sfdhd=0`, adx never passes status 1 (A/B reach 3). - `[thsync]` in C: **`nTh=2`** for the whole run vs **`nTh=6`** in A. Four threads are never created. `tick` frozen at 10; `req` never rises — the handshake the edit was written to unblock **never happens at all**, because the game dies upstream of it. - ⇒ forcing a reschedule out of syscall 0x30 diverts early init. ps2tek was right: `ReferThreadStatus` does not reschedule on real hardware. - **Action: keep the gate, keep it default-OFF.** The binary with the gate unset is byte-identical in behaviour to the old one (arm A reproduces the 08-24 11:07 run exactly). No rebuild needed to neutralise it. Do not promote it. ### ★★★ Result 2 — my Finding-1 premise was HALF RIGHT. `vbl/s` has a second source. The previous handoff said `vbl/s = progress/quantum, a hard-coded constant`. That was derived from samples that all read `vblSrc=q/0/0` — I never looked at a sample where the other source fired. Across a full run it does: ``` arm A t=181: vbl/s=35 vblSrc=0/35/0 <-- 35 from the guest-IDLE path, 0 from quantum arm B t=101: vbl/s=31 vblSrc=2/29/0 ``` ⇒ **the machine already delivers ~35 vbl/s whenever the guest genuinely idles.** It delivers 5–8 the rest of the time because the guest is *spinning*, and spin only earns vblanks through the quantum divider. **The cap is not the constant — it is that the guest almost never reaches the idle path.** Retuning the quantum cannot fix that, and the measurement proves it: | | arm A (q=20000) | arm B (q=2000) | |---|---|---| | progress @ ~198 s | 20,096,047 | 3,518,197 | | `vbl/s` typical | 4–8 | 7–13 | | `busy%` typical | 83–107 | 88–102 | 10× smaller quantum bought **~1.75×** more vblanks, not 10×, because guest progress collapsed 5.7× in step. Progress is dominated by *spin*, so it is not a work metric. **Exit test `vbl/s ≥ 40 sustained`: FAILED (7–13).** **But B's wall-clock gain is real and free:** adx status 1 at **t=70 s vs t=123 s**, status 3 at t=71 vs t=126 — 1.75× sooner to the interesting part, same milestones, no rebuild. Worth using for diagnostic runs. It is a knob, not a fix; it unblocked nothing. ### ★★★ Result 3 — THE HEADLINE. The IPU is initialised and never asked to decode. `PS2X_IPUTRACE=64` armed the probe for the first time. **`records=12, no [cap]` — that is the complete count for 198 s, identical in all four arms:** ``` #1 code=0 BCLR #7 code=0 BCLR #2 code=5 SETIQ #8 code=5 SETIQ #3 code=5 SETIQ (opt=0x8000000, second table) #9 same #4 code=6 SETVQ #10 code=6 SETVQ #5 code=9 SETTH #11 code=9 SETTH #6 code=0 BCLR #12 code=0 BCLR ``` Codes verified against `ps2_ipu_core.cpp:1178-1226` (0=BCLR 1=IDEC 2=BDEC 3=VDEC 4=FDEC 5=SETIQ 6=SETVQ 7=CSC 8=PACK 9=SETTH). **Zero IDEC/BDEC/VDEC/FDEC/CSC/PACK.** ⇒ The guest loads the quantiser, VQ and threshold tables — twice, bracketed by BCLR — and then never issues a decode. `avail=0` after SETTH: the bitstream FIFO is empty. **The IPU path is wired, reached, and correctly configured. Only the per-frame decode trigger is missing.** This retires the old `[ipu:cmd] records=0` false negative and replaces it with a much narrower target. ### ★★★ Result 4 — `0x13c6e8` is NOT "the movie pump". It is a callback-list thunk. `[thsync]`'s verdict string says *"stuck in 0x13c6e8; blocker moved into the movie pump"*. That label was the probe author's guess, baked into a string. Decoded from the ELF (`mips_r5900_disassembler.py`): ``` 0x13c6d0 addiu $a0,$zero,5 ; j 0x13c4f8 three 24-byte thunks, 0x13c6e8 addiu $a0,$zero,6 ; j 0x13c4f8 selector in $a0 0x13c700 addiu $a0,$zero,7 ; j 0x13c4f8 ``` `0x13c4f8` is a generic **"run callback list N"** dispatcher (funcmap: `sub_13C658_tail0013C4F8`, 0xc4 bytes — note the `_tail` naming, a hole-recovery fragment): ``` v0 = ((a0<<3)+a0)<<3 = a0*72 s0 = 0x54E960 + a0*72 list base s1 = 0x45EFE8 + a0*4 busy flag s2 = 5, loop 6x SIX entries, 12 bytes each per entry: if (fn) { [s1]=1; v0 = fn(arg); [s1]=0; s3 |= v0; } then [0x45EFC8 + a0*4]++ (per-list tick); return s3 ``` ⇒ `inWork=1` means the worker is inside **one of the six callbacks in list 6**, which are registered at runtime. **No run has ever read those six pointers.** A null slot there is exactly what "IPU configured, never fed a decode" would look like. ### Result 5 — `intrEsc` is NOT the bottleneck. The `di` gate is largely innocent. First run ever to print it. A=40, B=21, C=0, D=0 escapes over ~198 s; `intrStray=0` everywhere (the single-bit EIE model is not under-protecting). 40 escapes ×4096 samples is ~0.8% of arm A's back-edges. The pre-committed rule said "non-zero ⇒ fix `yield_point` step 2b" — **that rule was mis-calibrated**; non-zero is not the same as significant. Recording the miscalibration rather than following it mechanically. Note C/D read 0 *because* the extra yields pre-empt the escape, and C/D are the broken arms — so 0 here is a symptom of the regression, not a win. ### Edit made this cycle (UNBUILT) `ps2_runtime.cpp` — new **`[cblist]`** 1 Hz probe after the `[thsync]` block. Dumps, for lists 0–7: `tick/fill` (+`*` if busy), then list 6's six `fn`/`arg` pairs in full. Pure diagnostic, no gate, no header touched. **Next run: arm A settings (defaults) + `PS2X_IPUTRACE=64`.** `PS2X_REFSTAT_YIELD` must stay unset. ### Pre-registered reading of `[cblist]` - **a slot in L6 is `fn=0x0`** → the pump has an unregistered callback. Find its registrar (`eeref refs` on `0x54EB10 + e*12`) — that is the stage. - **all six non-null, L6 tick climbing** → the pump runs; one callback is the one that should issue BDEC/VDEC. Disassemble each `fn`, cross-check against the IPU writers. - **L6 tick frozen while `inWork=1`** → a callback genuinely never returns; that `fn` is the hang. - **L6 fill=0 but tick climbing** → dispatching into nothing; registration never ran. ### Still open, unchanged - `iChangeThreadPriority` (`Thread.cpp:1138`) still reschedules, contrary to ps2tek 2Ah. Deliberately left alone during A–D. **Now safe to fix** — and Result 1 makes it more suspicious, not less. - `0x4418F8` value mismatch (read 0, expected 0xF from an observed `sw a1,0x8(v0)`). - `dispFbp=0x0` vs `ctx0.fbp=0x70` in arm A; arm B shows them agreeing at `0x70`. - Perf lane — parked by user decision. --- ## HANDOFF 2026-08-24 (reassess) — **★★★ THE MOVIE WAS NEVER THE CONSTRAINT. `vbl/s`=5–7 is a hard-coded pacing constant (`quantum=20000`), not a stall and not a perf wall. Ten sessions of ADX/ADXM drilling were aimed three layers below the actual bottleneck. Two edits made, UNBUILT.** **Method:** re-measured the top-level premise instead of drilling further. Everything below comes from the **existing** `20260824-101518` run — **zero new runs, zero builds.** ### ★★★ Finding 1 — the ~7 fps is a tuning constant - The run's own startup line: `[determinism] vblank paced by guest progress, quantum=20000 ticks` - Guest progress = **139,944 ticks/s** (`[watchdog]` t=194→195: 19,815,948 → 19,955,892) - **139,944 ÷ 20,000 = 7.0** — matches observed `vbl/s` 5–7 exactly - `vblSrc=q/0/0` on every sample ⇒ **100%** of vblanks come from the quantum path - Source `Kernel/Syscalls/Interrupt.cpp:618`, default `20000`, own comment: *"There is no principled value here"* - `launch_recomp.ps1:156` hard-codes 20000 but **honours a pre-set env var** ⚠️ **This is a regression, and the direction is counter-intuitive.** At the Stage 5.6 close (2026-07-28) the same RelWithDebInfo config ran `vbl/s = 47–49` with progress ≈ **14,837/s**. Guest throughput has since improved **9.4×** while the displayed frame rate **fell 7×** — because quantum-paced vblank was added *during* Stage 5.17, calibrated against the old slower guest, and never re-tuned. Do not read "9× more guest work" as "faster"; it is mostly spin. ### ★★ Finding 2 — an 8-second whole-machine freeze, mechanism in OUR code `[thsync]` t=186→194, one handshake, eight seconds: ``` t=186 req 0→1 tick=9 WORKER-NOT-RUNNING t=187 inWork=1 tick=10 t=188 inWork=1 tick=11 t=189 inWork=0 tick=11 WORKER-NOT-RUNNING <- 3 dead seconds t=190 inWork=0 tick=11 WORKER-NOT-RUNNING t=191 inWork=0 tick=11 WORKER-NOT-RUNNING t=192 inWork=1 tick=12 t=194 req 1->0 ``` Same seconds in `[watchdog]`: `vbl/s=1 gif/s=0 dma/s=0 pc=0x11e6f0`, trace = `0x11ed28 → 0x174ba0 → 0x11ed90 → 0x174ba0` ×32. `0x174ba0` = syscall 0x30 `ReferThreadStatus`. Read from source, not inferred: - `maybe_yield()` (`ps2_scheduler.cpp:1194`) selects a **strictly higher**-priority ready head (`<`) - `yield_point()` step 3 (`ps2_scheduler.cpp:1420`) calls exactly that ⇒ **no equal-priority rotation exists anywhere** - `force_reschedule()` (`<=`, correct EE 29h semantics, `ps2_scheduler.cpp:1221`) is called **only** from `ChangeThreadPriority` (`Thread.cpp:1131`) — once, *before* the loop - `ReferThreadStatus` (`Thread.cpp:796`), the only syscall *inside* the loop, had **no yield at all** ⇒ once the equal-priority worker blocks and becomes Ready again, the spinner can never hand back the slot. `force_reschedule()`'s own comment already names `sub_11E690` and this exact scenario. ### ⚠️ Finding 3 — `[ipu:cmd] records=0` is a FALSE NEGATIVE Gated on `PS2X_IPUTRACE` (`Kernel/Ipu/ps2_ipu_core.cpp:101`, `:1427`), never set on any run. **Nobody has ever measured whether the validated IPU decoder receives a single decode command in-game.** Do not cite the zero. ### Finding 4 — the screen is fully painted `[present] has=1 w=512 h=448 nonblack=229376` = 512×448, **every pixel**. Not a black screen. Also `dispFbp=0x0` while `ctx0.fbp=0x70` — draw and display targets differ. Noted, **not** investigated. ### Finding 5 — a dead diagnostic (now wired up) `ps2x_guest_intr_disable_escapes()` + 3 siblings existed (`ps2_scheduler.cpp:252`) but **nothing printed them**. `yield_point` step 2b (`:1406`) refuses to yield while the guest holds `di`, for up to 4096 samples ≈ **512K back-edges per escape** — a plausible second cause of the 3-second dead stretches, until now unmeasurable. ### Q6 / Q7 answered with evidence - **Q6 (existing tooling):** `git log --all --diff-filter=D -- build_scripts/*` returns only `elf_disasm.py`, which lives on as `mips_r5900_disassembler.py`. **Nothing to recover.** Everything above was answered by tools that already existed. - **Q7 (upstream):** head moved to `14b1e5cb` *"Start the main thread with COP0 Status.IE set (#214)"*. **We already have it** (`ps2_runtime.cpp:1057`, `ps2_scheduler.cpp:799`). `EeScheduler.cpp` (#184, 2,106 ln) still collides — `Kernel/*.cpp` is `GLOB_RECURSE`. Not now. - **Q8 (clean rebuild):** **NO.** No codegen change, no header under `include/` touched. ### Edits made — UNBUILT 1. **`Kernel/Syscalls/Thread.cpp`** — split `ReferThreadStatus` into `referThreadStatusImpl()` (no scheduler action, lock scoped) + a gated `ps2sched::force_reschedule()` **outside** `info->m`. `iReferThreadStatus` calls the impl directly, so the gate can never leak into interrupt context (ps2tek 31h). Gate `refstatYieldEnabled()` reads `PS2X_REFSTAT_YIELD`, **default OFF** ⇒ the binary is behaviourally identical to today unless enabled. 2. **`ps2_runtime.cpp`** — watchdog line now carries `intrEsc= intrSec= intrStray=`, placed before `trace=` so truncation cannot clip them. Pure diagnostic. **Deliberately NOT done:** `iChangeThreadPriority` (`Thread.cpp:1138`) still inherits `ChangeThreadPriority`'s `force_reschedule()`, which ps2tek 2Ah says it must not. Real defect, **left alone on purpose** — an ungated scheduling change would contaminate arms A–D below. Fix it after this experiment. ### The run matrix — ONE build, FOUR arms, same exe `det=1` throughout (mandatory). 200 s each. `PS2X_IPUTRACE=64` on **all four**. Launcher `:158` only sets its own keys, so pre-set vars pass through, and `:518` dumps every `PS2*` var into each log — **each arm self-documents**. ⚠️ Env vars persist across invocations in one shell — clear them between arms or the arms contaminate. | arm | `PS2X_REFSTAT_YIELD` | `PS2X_DET_VBLANK_QUANTUM` | isolates | |---|---|---|---| | A | unset | 20000 | new binary ≡ old run (control) | | B | unset | 2000 | pacing alone | | C | 1 | 20000 | fairness alone | | D | 1 | 2000 | interaction | ### Exit tests — PRE-REGISTERED, declared before the runs | signal | now | pass | |---|---|---| | `vbl/s` (arm B) | 5–7 | ≥ 40 sustained | | `[thsync] tick` (arm C) | 12 in 198 s | > 100 | | `req` 1→0 latency (arm C) | 8 s | < 1 s | | `vbl/s` during handshake (arm C) | 1 | no collapse | | `[ipu:cmd]` (any arm) | never measured | any BDEC/VDEC ⇒ decoder is fed | | `[movie] maxstat` | 1, ~8 s | holds a full logo duration | | `intrEsc` | unmeasured | 0 ⇒ the `di` gate is innocent | ### Decision rules — PRE-COMMITTED - **B passes + movie renders** ⇒ Stage 5.17 closes; the ADX/ADXM branch was chasing a pacing artifact. - **B passes + movie blank + `[ipu:cmd]` shows BDEC/VDEC** ⇒ decoder fed, output not reaching GS. Next lane is the frame-upload path (`0x16B318` → `mwPlyGetCurFrm 0x14D2C0` → `MovieUpdate 0x113920`), **not** the IPU. - **B passes + `[ipu:cmd]` still zero** ⇒ guest never issues a decode. Back to the CRI layer — but now at 60 fps, so SofDec's timing assumptions finally hold. - **C passes, B doesn't** ⇒ the scheduler was the constraint, not pacing. Promote the yield out of its gate. - **Neither** ⇒ `intrEsc` is the tiebreaker. Non-zero ⇒ the `di` gate eats the yields and the fix belongs in `yield_point` step 2b, not the syscall. **Parked by user decision:** the perf lane. `[hostprof]` shows the main guest thread at 92% of one core — ~20% software GS rasterizer, ~21% MMIO/memory plumbing (`readIORegister` 8.2%, `read32` 5.2%, `translateAddress` 3.0%, an Fnv1a hash 1.8%, `isScratchpad` 1.9%). Real numbers, but the frame rate was never a throughput wall. Revisit only if the quantum sweep proves us genuinely CPU-bound. --- ## HANDOFF 2026-08-24 (real-HW memory check) — **REVERSED: `sub_11FE90` DID run on real PCSX2. The execution breakpoint was a false negative, not proof of unreachability. Investigation branch closed — this is not the movie-stall cause.** **What happened:** user set a PCSX2 execution breakpoint at `0x11fe90` and played through the movie; it never hit. Per [[reference_pcsx2_debugger_quirks]] (`"z_un"-named functions never trip breakpoints` entry, 2026-06-22), execution-breakpoint absence is a known unreliable signal on this build — checked memory state directly instead, per that memory's documented workaround. **Static read of `sub_11FE90` (via PCSX2 native disassembler, live):** ``` 0x0011fe90 lui s2, 0x0044 0x0011feb4 lw v1, 0x18E4(s2) ; guard flag @ 0x004418E4 0x0011febc bnezl v1, ->0x00120058 ; already-initialized? skip re-init ... 0x0011ff1c sw t2(0x18), 0x14(v0) ; v0 = 0x004418F0 (table base) 0x0011ff20 sw v1(0x01), 0x00(v0) 0x0011ff24 sw a0(0x08), 0x04(v0) 0x0011ff28 sw a1(0x0F), 0x08(v0) 0x0011ff2c sw a2(0x10), 0x0C(v0) ``` (disasm window capped at 40 instrs — a3/t0/t1 stores past +0x10 not directly viewed, extrapolated only) **Live memory read at `0x004418E0` (u32_array, 64B), game paused mid-run:** ``` 0x4418E0: 0x004b86f0 0x4418E4: 0x00000001 <- guard flag = 1 (SET, not 0) 0x4418E8: 0x00000000 0x4418EC: 0x00000000 0x4418F0: 0x00000001 <- table[0x00] = 1 (matches v1 store exactly) 0x4418F4: 0x00000008 <- table[0x04] = 8 (matches a0 store exactly) 0x4418F8: 0x00000000 <- expected 0xF (a1); mismatch — see caveat below 0x4418FC: 0x00000010 <- table[0x0C] = 0x10 (matches a2 store exactly) 0x441900: 0x00000012 0x441904: 0x00000018 <- table[0x14] = 0x18 (matches t2 store exactly) 0x441908: 0x00000019 0x44190C: 0x00000000 <- extrapolated slot; mismatch — see caveat below ``` 4 of the 5 offsets **directly confirmed in the disassembled store instructions** (`0x00`→1, `0x04`→8, `0x0C`→0x10, `0x14`→0x18) match their live memory values exactly, at the exact byte offsets those instructions target. ⚠️ **Corrected 2026-08-24:** the two mismatches are NOT the same kind of thing, and an earlier version of this entry wrongly lumped them together. - `0x44190C` reads 0 against an **extrapolated** expectation past the 40-instruction disasm window — no observed instruction, so no inconsistency, just missing coverage. - `0x4418F8` reads **0 where `sw a1(0x0F), 0x8(v0)` at `0x11ff28` was directly observed** in the disassembly. That is a **genuine mismatch against observed code**, not a coverage gap. Not investigated. Candidates, none tested: a later overwrite with 0, a second write path, or `v0` not holding `0x004418F0` at that particular store — note `v0` is loaded **twice** (`addiu v0, 0x18E0` at `0x11feac`, then `addiu v0, s0, 0x18F0` at `0x11fefc`). Neither changes this entry's conclusion — the guard flag is latched and 4 offsets match exactly — but do not cite the match as clean. **Conclusion:** `sub_11FE90` has executed and its guard flag is latched. This reverses the prior HANDOFF's "possibly unreachable" framing built on `eeref`'s `call=0 ptr=0 imm=0 gp=0` result — that static xref gap is real (nothing in the ELF text/data references this address by any method `eeref` traces — likely reached via a computed/jump-table dispatch [[project_eeref_static_xref]]) but does NOT mean the function never runs. Combined with the PCSX2 execution-breakpoint false-negative, **both signals available (static xref, dynamic breakpoint) were misleading in the same direction; only a direct memory read cut through it.** **Consequence:** `sub_11F160` (ADXM UsrIdleThread spawn), whose only caller is `sub_11FE90+0x17c`, is also presumptively reached — the guard-flag/table evidence shows `sub_11FE90` runs its full body (past the guard check) at least once, and the `jal` to `sub_11F160` sits inside that same unconditional path. **Not yet independently confirmed** — would need the same memory-read technique against whatever `sub_11F160` itself writes (e.g. a thread ID or TCB slot) to close this fully. This ADXM-thread-never-spawns theory as the Stage 5.17 stall root cause is now **weakened, not strengthened** — the subsystem this branch chases appears to initialize correctly on real hardware. **Next step:** either (a) confirm `sub_11F160` similarly via a live memory read of its output (thread handle/TCB), closing this branch entirely, or (b) abandon this ADXM-init theory for the movie-stall root cause and look elsewhere, since the evidence so far says this subsystem path is NOT the stall's cause. --- **Command run (user):** ```powershell python "F:\SDBZ Recomp\build_scripts\eeref.py" refs 0x11f160 python "F:\SDBZ Recomp\build_scripts\eeref.py" refs 0x11fe90 ``` **Result 1 — `sub_11F160` (ADXM UsrIdleThread spawn) HAS a caller, static grep just missed it:** ``` target 0x11f160 sub_11F160 slot=yes CALL 0x12000c jal in sub_11FE90+0x17c total: call=1 ptr=0 imm=0 gp=0 ``` So the chain from the prior HANDOFF entry is real: `sub_11FE90` → (jal at +0x17c) → `sub_11F160`. `sub_11F160` never firing in the `PS2X_ORDER` trace is a *consequence* of `sub_11FE90` never firing, not an independent bug. This also confirms the earlier "zero-hit decompile-grep ≠ unreachable" caveat was correct to apply — `eeref`'s MIPS-level xref pass found what the text grep missed. **Result 2 — `sub_11FE90` (ADXM priority-table init) has NO references of any kind:** ``` target 0x11fe90 sub_11FE90 slot=yes total: call=0 ptr=0 imm=0 gp=0 UNREACHABLE in the static image -- nothing links to this address. ``` This is stronger than the `sub_11F160` case — `eeref` checks direct calls, pointer stores, immediate loads, AND `$gp`-relative access, and found zero hits across all four categories. ⚠️ Per [[project_eeref_static_xref]], `eeref`'s own bugs present as confident "nothing found" — a computed/jump-table dispatch could still reach this address without `eeref` seeing it. **Do not conclude "dead code" from this alone.** **Working hypothesis (unconfirmed):** if `sub_11FE90` really is unreachable, the ADX priority table never gets initialized and the ADXM idle/worker thread (`sub_11F160`) never spawns — which would leave the ADX streaming subsystem running without its worker thread. Whether that's the actual root cause of the Stage 5.17 movie-streaming stall is still unproven. **Next step, not yet done:** real-hardware A/B check. Set a PCSX2 execution breakpoint at `0x11fe90` during movie playback — if real hardware also never hits it, this is conditionally-skipped by design (not a recomp bug, dead end for this theory). If real hardware DOES hit it, the recomp is missing whatever dispatch mechanism reaches it (jump table, function pointer table, etc. — worth then checking `sub_11FE90`'s neighbors/xrefs-to-region for a jump table pattern). --- ## HANDOFF 2026-08-24 (PS2X_ORDER trace) — **`ADX_Init` fires (x2). `sub_11EAC8`/`sub_11F0C8`/0x11ed78 fire heavily (1422 total dispatches). But `sub_11F160` (ADXM UsrIdleThread spawn) and `sub_11FE90` (ADXM priority-table init) NEVER fire — not capped, absence is real. Next: find who's supposed to call `sub_11F160`.** **Command run (user):** ```powershell $env:PS2X_ORDER = "11eac8,11ed78,11f0c8,11f268,11fe90,11f160" & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 1 -RunSeconds 200 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag order ``` **Raw result** (capacity 16384, `total=1422 logged=1422` — NOT capped, so the absence below is real evidence, not truncation): ``` [order] #0 0x11f268 [order] #1 0x11f0c8 [order] #2 0x11ed78 x2 [order] #4 0x11eac8 [order] #5 0x11ed78 x527 [order] #532 0x11f268 [order] #533 0x11f0c8 [order] #534 0x11ed78 x2 [order] #536 0x11eac8 [order] #537 0x11ed78 x885 ``` **Identity check** (`sdbz_func_map_merged.csv`, confirmed by grep — do not re-derive): | addr | symbol | fired? | |---|---|---| | `0x11f268` | `ADX_Init` | ✅ yes, x2 (once near start, again at #532 — looks like two separate init attempts) | | `0x11f0c8` | `sub_11F0C8` | ✅ yes, x2 | | `0x11ed78` | `noop_wrapper_l_2` | ✅ yes, MASSIVELY (527 then 885 calls back-to-back). ⚠️ Per the existing "misleading func-map names" trap (documented earlier in this file re: Stage 5.11/movie-open chain — `noop_wrapper___` symbols in this exact CRI subsystem are confirmed LIVE stream functions, not noops) — treat this as a real hot function, not literally a no-op, until decompiled and checked. | | `0x11eac8` | `sub_11EAC8` | ✅ yes, x2 | | `0x11f160` | `sub_11F160` | ❌ **never** | | `0x11fe90` | `sub_11FE90` | ❌ **never** | **What `sub_11F160`/`sub_11FE90` actually do (read from `ida_scripts/decompiles_SLUS_214_42.txt`, hypothesis-labeled — NOT yet confirmed against runtime):** - `sub_11F160` (`ida_scripts/decompiles_SLUS_214_42.txt:23412`) — logs `"ADXM: UsrIdleThread Stack pointer = 0x%08x, size = %d"`, then calls `syscall_stub_r` (thread-create-shaped syscall) → `ADX_unk_unk` → `array_state_dispatch_c`. Reads as **the function that spawns the ADX streaming subsystem's idle/worker thread.** - `sub_11FE90` (`ida_scripts/decompiles_SLUS_214_42.txt:23951`) — gated on `if (!dword_4418E4)`; its "use defaults" branch hardcodes `dword_4418F0=1, dword_4418F4=8, dword_4418F8=15, dword_4418FC=16, dword_441900=18, dword_441908=25, dword_44190C=26`. **These are the exact same priority values (1/8/16/18/25) already seen live in run 89's `[chgpri]` boost bursts** — meaning either (a) this init function DOES run through a code path this trace didn't capture, or (b) those values are baked into the static `.data` image directly and this function's job was already done at ELF-load time, or (c) something else writes the same constants. Not yet distinguished — **hypothesis, not fact.** - Static grep for callers of `sub_11F160(` in the decompile dump: **zero hits.** Per this file's own established pattern in this subsystem (`noop_wrapper___` thunks, indirect/computed calls), a zero-hit static grep does NOT mean unreachable — it means the caller is likely a thunk/indirect call IDA didn't resolve. Do not conclude "dead code" from this alone. **Next step, not yet done:** find `sub_11F160`'s real caller. Two options: (1) `eeref.py refs 0x11f160` / `up` for a MIPS-level xref pass that (per this file's own notes) sometimes catches what IDA's decompile-dump grep misses, or (2) a hardware data/execution breakpoint on `0x11f160` itself in PCSX2 to catch it live on real hardware and see if IT ever fires there (A/B signal — if PCSX2 also never hits it during movie playback, it may be conditionally skipped by design, not a recomp bug). --- ## HANDOFF 2026-08-24 (web research) — **General PS2 movie pitfall (thread-priority/ring-buffer stalls) matches our OLD "boost-a-worker" theory — which run 89 already disproved. Doesn't change the plan. `PS2X_ORDER` trace is still the next actionable step.** **What was searched:** CRI SofDec/IPU pipeline architecture, PCSX2 FMV-stall bug reports, and — most useful — a real open-source PS2 homebrew player ([Simple Media System](https://github.com/TheMrIron2/Simple-Media-System)) whose changelog documents actual ring-buffer stalls on real hardware caused by thread-priority misconfiguration, because PS2 EE threading is non-preemptive/cooperative at a given priority level (same-priority threads only swap on explicit yield/reschedule, not a timer). **Why this doesn't change anything:** that failure mode — a worker boosted to the caller's own priority never actually getting scheduled because nothing forces a reschedule — is EXACTLY the theory already tested in this codebase (`ps2sched::force_reschedule()`, `ps2xRuntime/src/lib/ps2_scheduler.cpp:1221`, wired into `ChangeThreadPriority` at `Thread.cpp:1131`, with `<=` instead of `maybe_yield()`'s strict `<`). Run 89 (see entry below) already showed the boost bursts DO fire (40 `OTHER` calls, 3 discrete bursts, priorities 8→16→18→25) and `[thsync] tick` **still** stuck at 12 regardless. So this specific PS2 pitfall is a real thing in general, but it's a dead end for OUR stall specifically — already falsified empirically, not just in theory. **Confirms the architecture context for later reasoning:** SofDec's video path is IOP CD-read → EE memory buffer → IPU (bitstream decode + IDCT + colorspace conversion only) → EE MMI does motion compensation in software (NOT IPU hardware) → GS via GIF. Audio (CRI ADX/ADPCM) is a separate stream, synced via its own buffer/semaphore handoff — consistent with the `sub_11EAC8`(worker)/`sub_11ED78`(acker)/`0x441924`(shared flag) shape already found by static trace. `[sfdc] nz=0` (all 12 words zero, entire 198s of run 89) reads as "the producer side of this pipeline never puts real data in the buffer" — consistent with, not contradicted by, the standing hypothesis that the worker-thread cluster itself may never fire at runtime (still unconfirmed — `PS2X_ORDER` trace below is what settles it). **No new action taken** — this was research only, no files touched besides this entry. Next step is still the one queued in the "bypass check" entry directly below: run `$env:PS2X_ORDER = "11eac8,11ed78,11f0c8,11f268,11fe90,11f160"` before the next `launch_recomp.ps1`, to see whether the ADX worker/acker cluster dispatches at all. --- ## HANDOFF 2026-08-24 (bypass check) — **NO BYPASS. Every `jal` in generated runner code (checked `sub_113F40`→`ADX_Init` and `ADX_Init`→`sub_11F0C8`) routes through `runtime->dispatchGuestBranch()` → `lookupFunction()`, the SAME table `registerFunction()` writes into. The whole ADX/worker-thread chain is override-eligible AND already traceable for free via `PS2X_ORDER` env var — no new code needed.** **What was checked:** read `ps2xRuntime/src/runner/sub_00113F40_0x113f40.cpp` and `ADX_Init_0x11f268.cpp` directly (naming convention: `_0x.cpp`, flat in `src/runner/`, confirmed via `ps2xRuntime/CMakeLists.txt:485` glob and `fn_forward_decls.h` for the name mapping). Every single `jal` instruction in both files — including `ADX_Init`'s call into `sub_11F0C8` (0x11f3a4) and `sub_113F40`'s call into `ADX_Init` (0x113fc8) — emits `if (!runtime->dispatchGuestBranch(rdram, ctx, target, src, fallthrough, GuestBranchKind::DirectCall, "JAL")) return;`. No exception, no direct C++ call. **Why that matters:** read `PS2Runtime::dispatchGuestBranch` (`ps2xRuntime/src/lib/ps2_runtime.cpp:1953`) — for any call kind it does `RecompiledFunction targetFn = lookupFunction(targetPc); targetFn(...)`. `lookupFunction` (line 1620) and `registerFunction`/`replaceFunction` (line 1590) both read/write `g_ps2RecompiledFunctionTable[slot]` — the identical table. So `[[feedback_registerfunction_bypass]]`'s "direct C++ `fn_` calls bypass it" caveat does **not** apply to guest-code `jal`→`jal` chains; it's a real risk only for **hand-written C++ in `game_overrides.cpp`** calling an `fn_XXXX` body directly as a plain function call instead of through `dispatchGuestBranch`. The generated runner code never takes that shortcut. **Consequence for Stage 5.17:** `sub_11EAC8`, `sub_11EC00`, `sub_11ED78` (acker), `sub_11F0C8`/`sub_11F160` (spawners), `ADX_Init`, `sub_11FE90` are ALL reachable through the override table. Two ways forward, no new C++: 1. **Free, zero-risk:** the codebase already has a generic ordered-dispatch tracer gated on env var `PS2X_ORDER` (`ps2_runtime.cpp:3053`, armed at startup, comma-separated bare hex, e.g. `PS2X_ORDER=11eac8,11ed78,11f0c8,11f268,11fe90,11f160`). This reports the **order** these addresses actually dispatch in at runtime — settles "does this cluster fire at all, and in what order" with one run, no build needed (assuming the exe already has this feature — confirm it's wired into the launch script/`-Watch` flag before assuming; not yet checked this segment). 2. If richer per-call data is needed later (args, `0x441924`/`0x4419D8` state at call time): a `registerFunction` passthrough wrapper (capture original via `runtime.lookupFunction(addr)`, log, call through) is now confirmed safe to add for any of these addresses. **Confirmed:** `launch_recomp.ps1` doesn't name `PS2X_ORDER` explicitly, but it follows the script's general pattern (seen for `PS2X_HWWATCH`, `PS2X_DIAG`, etc.) of honoring any `$env:` var already set in the parent shell before launch — it never clears the environment. So `$env:PS2X_ORDER = "11eac8,11ed78,11f0c8,11f268,11fe90,11f160"` set in the calling PowerShell session before running `launch_recomp.ps1` should reach the exe and arm the tracer, no script edit needed. Not run this segment (user runs launches, not me). --- ## HANDOFF 2026-08-24 (static trace) — **`sub_11EAC8` fully traced: it's a spawned WORKER THREAD, not a helper function, part of the CRI ADX audio-init chain. Real acker is `sub_11ED78`, gated on shared flag `0x441924`. Not yet confirmed live at runtime — `registerFunction` probing is blocked by the direct-call bypass.** **Verified from static disasm (`mips_r5900_disassembler.py --func`) + `eeref.py refs`:** - `sub_11EAC8` (0x11eac8–0x11ebfc) is **not called** anywhere (`eeref refs` → 0 CALL sites). Its address is only materialized as data in `sub_11F0C8+0x2c` (`slot=yes`). - `sub_11F0C8` loads that address into `$a1` and calls, in order: `jal 0x174aa0` (CreateThread-style SDK call, entry=`sub_11EAC8`), `jal 0x175de0` (StartThread), `jal 0x174b30` (the same syscall-0x29 `ChangeThreadPriority` thunk from run 89's `[chgpri]` probe, priority pulled from `0x4419C8`), `jal 0x11edf8`. **`sub_11EAC8` is a dedicated PS2 thread body**, spawned+started+priority-set by `sub_11F0C8`. This is a strong candidate source for at least some of run 89's 40 `OTHER` `[chgpri]` calls (creator thread setting a freshly-spawned worker's priority). - `sub_11EAC8`'s thread loop: while gate word `0x4419D8`==0, calls worker fn `0x13c6e8`, then checks flag `0x441924`. If `==1`: calls `sub_11ED78` and clears the flag to 0. Exits (sets `0x4419E0=1`, tail-calls `0x174ae0`, likely a semaphore/event signal) once `0x4419D8`!=0. - `sub_11EC00` (0x11ec00–0x11ecd4) is a **structural twin**: spawned the same way by `sub_11F160` (called from `sub_11FE90+0x17c`), own gate `0x4419E8`, own counter/busy-flag, but polls the **same shared flag `0x441924`** and calls the **same acker `sub_11ED78`**. Two worker threads, one shared completion flag, one acker. - `eeref refs 0x11f0c8` → real callers: `ADX_Init+0x13c`, `mem_fill_j_0+0x4c`, `sub_120220+0x4c`. This ties the whole cluster to **CRI ADX audio init** — matches the earlier `eeref refs 0x4418f0` hit on `ADX_Init+0x94/+0xd0`. Stage 5.17's stall may be an audio-streaming-thread stall, not (only) a SofDec video one. - `eeref refs 0x11fe90` (the other spawner, `sub_11FE90`) → **0 refs, reported UNREACHABLE**. Per `[[feedback_validate_tables_against_an_oracle]]`/eeref's known blind spots, this is flagged as **inconclusive, not dead code** — `sub_11FE90` itself calls both `sub_11F0C8` and `sub_11F160` and is a real 0x1ec-byte function, so something reaches it that the static tool can't see (likely another IMM/vtable slot pattern not covered). - One further hop up: `ADX_Init` (real entry `0x11f268`, func-map size `0x190`) has 3 static callers (`sub_113F40+0x88`, `mem_fill_j_0+0x4c`, `sub_120220+0x4c`); `sub_113F40` itself is called from `sub_113D30+0x8`. Chain not yet walked up to a landmark already confirmed live (GameMain/CAppInit) — stopped here for this pass, static reachability alone doesn't prove "runs in this session." **Blocker for a direct probe:** `eeref refs 0x11f0c8`/`0x11f268` show these are all reached via plain `jal` (direct calls), not `jalr`/dispatch-table dispatch. Per `[[feedback_registerfunction_bypass]]`, `runtime.registerFunction()` overrides only intercept calls that go through `dispatchGuestBranch` — direct `jal`-compiled calls between recompiled functions bypass the override table silently. Grepped `game_overrides.cpp` for the `dispatchGuestBranch` convention (lines ~783, 917, 996, 1408, 4507, 4618, 5605, 5916) confirming this is a known, actively-managed distinction in this codebase, not a guess. **Before writing a logging override on `ADX_Init`, `sub_11F0C8`, or `sub_11ED78`, must first confirm (by reading the one specific generated `.cpp` file for whichever caller) whether that particular call site actually routes through `dispatchGuestBranch` — otherwise the override silently never fires and produces a false "never called" reading.** **Recommended next step:** identify the runner `.cpp` file for `sub_113F40` (or `mem_fill_j_0`/`sub_120220`) and check whether its call into `ADX_Init` is a direct C++ call or a `dispatchGuestBranch`. If direct, the cheaper path is a host-side approach that doesn't depend on override interception at all — e.g. a hardware data breakpoint on `0x441924`/`0x4419D8` (`[[feedback_hardware_data_breakpoint]]`, ~19s/96s cost) or a hand-added log line inside `ChangeThreadPriority` keyed on `newPrio` values matching `0x4419C8`'s content, since that syscall path is already proven to fire and is not subject to the bypass. --- ## HANDOFF 2026-08-24 (run 89) — **`[chgpri]` now fires (28,877×) on a verified fresh binary. 99.83% is `SELF`; a real 40-call `OTHER` burst pattern exists but does NOT move `[thsync] tick` off 12. Boost-a-worker theory is dead as the unblock mechanism — look at the acker (`sub_11EAC8`) / `[sfdc]` producer next.** Run 88 (03:23) was the stale-binary false negative already flagged below — do not read it. This run rebuilt `RelWithDebInfo` first (`exeWritten=10:10:41` vs `Thread.cpp` mtime 03:14:58 — checked before trusting anything) and re-ran the identical 200s det=1 command. Full log decoded and grepped, not skimmed. **Verified, from the log:** - `[chgpri:enter]` = 28,877, `[chgpri]` = 28,877 — the probe fires copiously. Run 87's "zero calls" conclusion stands as correct *for that run's binary*, but is superseded now that a valid build shows the syscall firing constantly. - `SELF` (tid==callerTid) = 28,829 (99.83%). `OTHER` = 40 (0.17%), 100% `callerTid=1` (main thread) targeting tid 3/4/5/6 and 7/8/9/10. - The 40 `OTHER` calls are NOT scattered — they cluster into 3 discrete bursts (line numbers 3852–4128, 23926–25405, 62246–63013), landing at watchdog t≈116–119s, t≈129–171s, and t≈184–192s respectively. Each burst boosts one batch of 4 worker threads (either {3,4,5,6} or {7,8,9,10}) up through priorities 8→16→18→25 in order, then later resets that same batch back to priority 1. This is a real, reproducible round-robin/ping-pong worker-dispatch pattern — not noise. - Despite these 3 boost bursts happening, `[thsync] tick` reads **12** at t=196/197/198s — the exact same stuck value run 87 reported for its (differently-probed) run. The boosts demonstrably do not advance the worker's tick counter. `[sfdc]` stays `nz=0` (all 12 words zero) and `[mvgate]` stays `nLive=0 live=-1 GEOMETRY-SUSPECT` for the entire 198s, unaffected by the bursts. `pc` still parks at `0x422660` (the GameMain spin, see below) between bursts. - Arithmetic re-check on `sub_11E690`'s `$a1` load (`lui $v0,0x44; lw $a1, 6384($v0)`): 6384 = 0x18F0, so the address is `0x440000+0x18F0 = 0x4418F0` exactly — matching `[thsync]`'s existing `boost=` field address. Run 87's note below claims this is "off by 0x10" (`0x4418E0`); recomputing independently from the same disasm output does not reproduce that — **this looks like an arithmetic slip in run 87's session, flagged but not yet fixed pending a second confirmation.** **Not yet investigated (side finding, unverified):** the GameMain watchdog `pc=0x422660` spin (`ELF/SLUS_214.42` @ 0x422660) polls a *dynamically allocated* cell — `lw $v0,-524($v0)` off a `lui $v0,0x64` base, i.e. reads `*0x63fe04` — against the address of a `$gp`-relative global, `0x500854` (`addiu $v1,$gp,0xffffd7e4`). `eeref refs 0x63fe04` returns UNREACHABLE (it's a heap cell, not a static symbol, so the static xref tool can't help here). `0x500854` itself has GP-relative writers in `GameState_Init`, `GameState_ReadInput`, `async_task_tick`, `async_task_complete`, and several `pool_entry_push_b_0_clone_*`/vtable-dispatch helpers — consistent with a generic object-pool/vtable-registration slot, not confirmed as this specific stall's cause. **This is a hypothesis, not a finding — has not been probed at runtime.** Per the decision tree below, the next concrete step is still to trace `sub_11EAC8` (the acker for `[0x441924]`) directly to see whether it's even reached on a separate host fiber, since that's what the existing plan already called for and this run's data doesn't change that. --- ## HANDOFF 2026-08-24 (run 87) — **The run-86 "boosts the worker" reading is WRONG. `[chgpri]` fired zero times in a 198s run whose `[thsync]` tick still reached 11. Two new probes added.** ⚠️ **Run "88" (03:23) was a STALE-BINARY false negative — do not read anything into it.** `Thread.cpp` was edited 03:14, `ps2EntryRunner.exe` was last built 02:30 (44 min *before* the edit). `[chgpri:enter]`=0 and `[chgpri]`=0 in that log just means the old narrow probe never fired, same as run 87 — zero new information. **Must run `build.ps1` before the next `launch_recomp.ps1`** — check `Thread.cpp` mtime < exe mtime before trusting any future `[chgpri]` read. Run 86's `PS2_PROJECT_STATE.md` entry (below) proposed that `sub_11E690` boosts a *different* worker thread to its own priority via `ChangeThreadPriority`, and that `maybe_yield()`'s strict `<` (vs `force_reschedule()`'s `<=`) was why the boost never actually preempted the spinner. That theory is now falsified by direct measurement, not just re-reading: - Rebuilt with the `[chgpri]` probe (`target != callerTid` only) and ran the same 200s command. Result: **`[chgpri]` count = 0** for the entire run, while `[thsync]`'s `tick` counter still climbed to 11 (`[ipu:cmd]` stayed at 12, unchanged from run 86) and the last `[thsync]` line still read `VERDICT=WORKER-NOT-RUNNING`. Full tag census: `grep -c` per tag over the 238s→198s log, see command below. - That means `ChangeThreadPriority` (syscall 0x29) is **never called with a target other than the caller itself** — the run-86 "boosts a worker thread" reading was an unverified inference from IDA's pseudocode variable naming (`ChangeThreadPriority(tid, [0x4418F0])` in the old comment), never checked against raw MIPS. - Went back to static (per `[[feedback_static_before_probe]]` — should have done this before writing `[chgpri]` in run 86, not after): IDA was disconnected this session, so used `build_scripts/mips_r5900_disassembler.py` directly against `ELF/SLUS_214.42`, no MCP needed. - `sub_11E690` (`noop_sub_e690`) disassembled at 0x11e690: calls `jal 0x174b30` **twice**, once before the spin loop (0x11e6e0) and once after it exits (0x11e744). `0x174b30` disassembles to `addiu $v1,$zero,0x29 ; syscall ; jr $ra` — confirmed, this really is syscall 0x29 (`ChangeThreadPriority`), not a naming artifact. - Argument setup: `$a0` is untouched since function entry (still the caller-supplied `tid` param), `$a1` is reloaded right before the call via `lw $a1, 6384($v0)` with `$v0=0x440000` → **`*0x4418E0`**, not `*0x4418F0`. The existing `[thsync]` probe's `boost` field reads `0x4418F0` — **off by 0x10**, so that field has never shown the real value handed to the syscall. Not fixed yet; low priority next to the bigger question below. - The spin loop itself (`0x11e6f0`..`0x11e710`, up to 200,000,000 iterations) contains **no syscall at all** — only two conditional-wakeup helpers (`array_state_dispatch_b`/`0x11ed28`, `thread_resume_if_suspended`/`0x11ed90`, both gated on the target's `status` field) and a poll of `[0x441924]`. On real EE this works because a second, genuinely concurrent thread clears the flag; our cooperative scheduler has nothing to switch to unless one of those two helpers, or the boost call surrounding the loop, triggers a reschedule. - Given `[chgpri]`=0 but the boost call demonstrably executes twice per invocation of `sub_11E690` (per the disasm), the only ways to reconcile that with zero logged targets are: (a) every call is a **self**-boost (`tid == callerTid`, i.e. the thread raises its own priority, not a worker's — a legitimate and different real-code pattern than assumed), or (b) the call bails inside `ChangeThreadPriority()` before reaching the probe line (`resolveSelfOrThread()` miss, or a `DORMANT`/illegal-priority early return). **Both widened this session, not yet tested:** 1. `[chgpri]` in `Thread.cpp` no longer filters on `tid != callerTid` — now logs every call, tagged `SELF` or `OTHER`. 2. New `[chgpri:enter]` logs at function entry (before `resolveSelfOrThread`) and at each early-return path (`resolveSelfOrThread` miss, `DORMANT`, `ILLEGAL_PRIORITY`), so "never called" and "called then bailed" are now distinguishable without another blind round trip. - **Next step:** rebuild, re-run with the same command, then `analyze_run.py --tag chgpri` (or grep, since `analyze_run.py` may not know this tag — see `[[project_diagnostic_tooling]]`) and: - if `[chgpri:enter]` count is 0 → `sub_11E690` itself never executes in this run; the movie-pump thread relationship needs re-tracing upstream from scratch, the run-86/87 reading of this function was moot. - if `[chgpri:enter]` fires but `[chgpri]` (post-lock) stays low/0 → read which bail fired (`resolveSelfOrThread MISS` / `DORMANT` / `ILLEGAL_PRIORITY`). - if `[chgpri]` fires and every line reads `SELF` → the boost-a-worker theory is dead; `force_reschedule()`'s `<=` fix (already in `ps2_scheduler.cpp:1221`, uncommitted) is not the mechanism that unblocks this stall, and the investigation should look at what actually clears `[0x441924]` (i.e., trace `sub_11EAC8`, the acker, directly — is it even running on a separate host fiber, or does everything happen on one?). Command used to extract tag counts from the UTF-16LE log (works without `analyze_run.py`, useful for tags it doesn't know about yet): ```python import io, re from collections import Counter with io.open('run_log.txt', encoding='utf-16-le', errors='replace') as f: lines = f.readlines() tagc = Counter() for l in lines: m = re.search(r'\[([a-zA-Z0-9_:]+)\]', l) if m: tagc[m.group(1)] += 1 ``` ## HANDOFF 2026-08-24 (run 86, SUPERSEDED — see run 87 above) — **Root cause found: the movie worker IS ticking, but our scheduler starves it to ~1 tick/2s. A fix is already written; needs a rebuild+re-run to test.** Supersedes the previous "run 85" entry below, which mislabeled the run and — more importantly — was written without reading the `[thsync]`/`[mvgate]` data that was **already in the same log**. There was no separate "run 85"; `run_log.txt` (238s, ends 08-24 02:39) already contains `[mvslot]`, `[movie]`, `[ipu:cmd]`, `[adx:stream]`, `[mvgate]`, **and** `[thsync]` together — the `ps2_runtime.cpp` comment trail itself calls this "run 86" (see the `[thsync]` block, ~line 4810). Read the whole probe stack before writing a new handoff; don't stop at the first tag that looks conclusive. ### What still holds from the old "run 85" reading - The movie object **is** created, twice (t=111-125s, t=159-176s), cleanly — `[mvslot]`'s 5 fields all go nonzero together both times, so `module_obj_init_z_42` isn't failing on any branch. - `[ipu:cmd]` is still only 12 commands total, all init, zero BDEC/VDEC/FDEC. `[adx:stream]` is silent the whole run. - **New correlation this pass:** 12 is not a coincidence. `[thsync]`'s `tick` counter (the movie worker's own tick, `[0x441960]`) also reaches exactly **12** by the end of the second window (t=181s). Every IPU init command lines up with one worker tick — the worker *is* driving the pump, just far too rarely to ever reach a real decode call. ### The actual mechanism — `[thsync]`, `sub_11E690`/`sub_11EAC8` `sub_11E690` is a synchronous cross-thread handshake: set `[0x441924]=1`, `ChangeThreadPriority`-boost the worker to the caller's own priority, then spin on `ReferThreadStatus` until the worker ACKs by clearing `[0x441924]`. The worker side (`sub_11EAC8`) sets `[0x441934]=1`, calls the actual movie pump (`jal 0x13c6e8`, which reaches `sub_155320`), clears it, then ACKs. `[thsync]`'s own pre-committed verdict logic caught it directly: `VERDICT=WORKER-INSIDE-WORK` and `VERDICT=WORKER-NOT-RUNNING` **alternate roughly every 1s** while `req=1` is held — i.e. one worker tick takes multiple seconds of wall/host time to complete, when the guest math inside it should be near-instant. Across both ~8-9s "playing" windows the worker gets scheduled only ~5-6 times each, then the window closes (SofDec's own ~8s timeout fires) before anything downstream ever reaches a real decode. `Thread.cpp`'s `ChangeThreadPriority` already carries a comment diagnosing exactly this: boosting a worker to the **caller's own** priority (both at 1) doesn't cause `maybe_yield()` to give up the fiber, because `maybe_yield()` only yields to a **strictly higher**-priority head. `force_reschedule()` (the real EE-syscall-29h semantics, `<=` not `<`) is the fix for that half, and **it is already present in the working tree** (`ps2xRuntime/src/lib/ps2_scheduler.cpp:1221`, uncommitted, part of this same investigative arc). It was not confirmed whether this run's binary was built *with* that fix in place or the log predates it — this needs settling before drawing conclusions from the tick rate, not assumed either way. ### Loose end this pass closed only partially — `[chgpri]` probe added, not yet run The `[thsync]` thread table can't safely identify *which* tid is the boosted worker: at t=112 (`inWork=1`, mid-tick) **three different tids simultaneously read `st=1` (THS_RUN)** (tid 1, 4, 6) — impossible for a single EE core, so `ThreadInfo::status` is not being kept in lockstep with real dispatch on every transition and cannot be trusted to pick the target out of a crowd. `tid=2` looked like a candidate (`pri=1` matches `boost=1`) but reads `st=4(WAIT) wt=2(SEMA) wid=3` **unchanged across the entire window**, t=111 through t=130, regardless of `req`/`inWork`/`tick` — it is blocked on something else entirely, not our worker. Added a one-line, unambiguous probe instead of guessing further: `[chgpri]` in `ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp` (`ChangeThreadPriority`, ~line 1046) prints `target= newPrio= callerTid= targetStatusBefore=0x.. targetWaitTypeBefore= targetWaitIdBefore=` at the exact syscall that names the boost target — no inference from a 1Hz snapshot needed. Uncapped (ChangeThreadPriority is a rare syscall, not a per-tick one). Not yet built or run. ### Next step — one build + run 1. Rebuild (picks up the `[chgpri]` probe and confirms whether `force_reschedule`'s `<=` fix was already in the binary that produced this log or is new). 2. Re-run with the same command as before. 3. `analyze_run.py --tag chgpri` — read the target tid off the `sub_11E690` boost calls during t=111-130 and t=159-181, then re-read `[thsync]`'s thread table for *that specific* tid only (ignore the others — they're a red herring per the THS_RUN-x3 finding above). 4. Compare `[thsync]` tick-rate and `[ipu:cmd]`/`[adx:stream]` record counts against this run. If `force_reschedule`'s fix was already active and the worker is *still* starved, the remaining gap is elsewhere in the scheduler (maybe the boosted thread isn't actually *Ready* — it could be blocked on something upstream of the priority boost, same shape as the `tid=2` red herring above, just for the real target). If the fix was NOT active in this log, this alone may resolve the movie stall — rerun is the only way to tell. Also still open and unrelated: `vbl/s` = 0-1 instead of 60. --- ## HANDOFF 2026-08-24 (run 85, SUPERSEDED — see run 86 above) — **The object IS created (twice); it "plays" ~8s with zero frames decoded.** Corrects run 84: that handoff read `obj=0x0 for all 238 ticks` from a run that predates the new `[mvslot]` probe. Re-ran with the probe in place; the picture is different. **Superseded 2026-08-24, same day:** this entry was written without reading `[thsync]`/`[mvgate]`, which were already present in the very same `run_log.txt`. The "next thread" section below (chase `mwPlyGetCurFrm`/`MovieUpdate` statically) was overtaken by data that was already sitting in the log — the movie-pump call chain was already identified down to `sub_11E690`/`sub_11EAC8`/ `0x13c6e8` by the code comments before this entry was even written. Kept for the raw `[mvslot]` signal table below, which is still accurate; follow the run-86 entry above for what to do next. ### Both creation attempts succeed `obj` (`*(u32*)0x54BD90`) goes nonzero **twice**: t=111-125s and t=159-176s, both times to `0x45f6e4` (pool slot 0). `objSt=1`, `stat` hits `1` ("playing") for ~8s inside each window, `objFile=0x4597b0`, `objLen=0xfffff`. Then `crt2` drops, then `obj` drops, and the object tears itself down. No third attempt in the remaining 62s of the 238s run. The `[mvslot]` probe (5 fields inside `module_obj_init_z_42`'s target struct, all previously suspected failure points) confirms this: all 5 go nonzero **together** in the same two windows — so that function is not failing on any branch, it's succeeding cleanly both times. (The old "which of 5 branches fails" framing from the deeper `module_obj_init_z_42` trace was answered: none of them fail.) | Signal | Value | Reading | |---|---|---| | `dvci` / `mvopen` / `cdsrch` | 2 / 2 / 4 | the `.SFD` opened, twice — matches 2 create/play/teardown cycles | | `[movie] wrkAdr` | `0x1806c00`, `wrkSiz=0x332100` | SofDec work area allocated, stable all run | | `[movie] obj` | `0x45f6e4` for t=111-125s and t=159-176s, else `0x0` | object created and destroyed twice | | `[movie] stat` | `1` for ~8s inside each window | playback state reached, then reverts | | `[ipu:cmd]` | 12 total, all init, 0 BDEC/VDEC/FDEC | decoder never fed a bitstream despite "playing" | | `[adx:stream]` | `nonzero=0/40` always | audio also never fed | `obj` sampled at `ps2xRuntime/src/lib/ps2_runtime.cpp:3552`. `[mvslot]` probe added at `ps2xRuntime/src/lib/ps2_runtime.cpp:4738` (5 fields off pool slot 0, `0x45f6e4`). --- ## HANDOFF 2026-08-23 (later) — **THE IPU IS IMPLEMENTED AND VALIDATED. Needs a build.** **Session type:** static scoping, then implementation, then offline validation against ffmpeg. **Zero game builds, zero game runs.** The IPU was written and proved correct outside the 48-minute build loop entirely. ### State in one line Stage 5.17's answer was "the IPU is pure register storage". It is now a real MPEG decoder: IPU_CMD/CTRL/BP/TOP behave, the input and output FIFOs are real, DMA channels 3 and 4 walk their own descriptors, and BCLR/BDEC/VDEC/FDEC/SETIQ/SETVQ/SETTH/CSC/PACK all execute. Validated by playing three real SDBZ movies through it and diffing every frame against ffmpeg: **422 frames, worst absolute pixel difference 2, min PSNR 63.7 dB, zero decode errors.** The remaining +/-2 is this float IDCT versus ffmpeg's integer IDCT. ### What to do next — ONE build, then one run ```powershell & "F:\SDBZ Recompuild.ps1" RelWithDebInfo ``` Then the normal runner. Expect the two logo movies to render. If they do not, the IPU is no longer the first thing to suspect: it is proved correct against a reference decoder, so look at what the guest does with the decoded frame (`0x16B318` -> `mwPlyGetCurFrm` `0x14D2C0` -> `0x113920 MovieUpdate`) and at the GS upload path. ### The scope discovery that made this tractable **Every movie this game ships is MPEG-1, not MPEG-2.** ATARI, OKR, OP, OP1, OP_PAL and OP_USA contain **zero** extension start codes (`0x000001B5`) — no sequence_extension, no picture_coding_extension. That removes intra_vlc_format, alternate scan, non-linear quantiser and the MPEG-2-only Table B.15 from the critical path. B.15 is deliberately NOT transcribed; a half-remembered table would decode into plausible garbage, whereas the missing-table path raises IPU_CTRL.ECD, which the guest driver already checks at `0x424bec` and handles cleanly. A static scan of **every** IPU_CMD store in the ELF shows the guest only ever issues BCLR, BDEC, VDEC, FDEC, SETIQ, SETVQ, SETTH and CSC. No IDEC, no PACK. ### Facts pinned from the disassembly, not assumed | Fact | Evidence | |---|---| | BDEC output is **768 bytes** = int16 `Y[16][16]` + `Cb[8][8]` + `Cr[8][8]` | `0x424710` `addiu $a1, $zero, 0x300` feeding the IPU_FROM helper at `0x426048` | | VDEC result is in **IPU_CMD[15:0], sign-extended 16-bit** | `0x424440` `andi $v0,$a2,0xffff` then `sll 16 / sra 16` | | macroblock-type flags: bit2 = backward, bit3 = forward | `0x424484` `andi $v0, $a0, 0xc` | | The guest **refills IPU_TO from inside its wait loop**, with a 501-iteration bail-out | `0x424b98`; `0x424c70` reads IPU_TO QWC, `0x424c7c` calls the refill helper `0x428448` | | So reporting BUSY while starved is correct AND safe | same loop — it reacts by feeding data instead of hanging | | The guest drives IPU_FROM/IPU_TO **directly via MMIO**, not `sceDmaSend` | stores to `0x1000B0x0` / `0x1000B4x0` throughout `0x163xxx` and `0x42xxxx` | | IPU_TO uses **both** normal (`0x101`) and chain (`0x105`) mode | `0x429620` and `0x42784c` | | IPU_FROM is normal mode, QWC up to `0xFFC0` — a streaming pull the guest aborts by clearing STR | `0x4293dc` | | The guest polls IPU_CMD as a **64-bit** load and tests bit 63 | `0x424bd4` `ld $s1, 0($v0)` + `bgezl` | ### What was written | File | Role | |---|---| | `ps2xRuntime/src/lib/Kernel/Ipu/ps2_ipu_core.h` | interface, ~120 lines | | `ps2xRuntime/src/lib/Kernel/Ipu/ps2_ipu_core.cpp` | registers, FIFOs, DMA ch3/ch4, command engine, block decode, IDCT, CSC/PACK | | `ps2xRuntime/src/lib/Kernel/Ipu/ps2_ipu_tables.cpp` | MPEG VLC tables B.1/B.2-B.4/B.9/B.10/B.12/B.13/B.14, built and prefix-checked at startup | | `ps2xRuntime/src/lib/ps2_memory.cpp` | wiring only — MMIO, 64-bit IPU_CMD/IPU_TOP reads, 128-bit FIFO windows, ch3/ch4 claim, D_STAT bits 3/4 | | `build_scripts/ipu_validation/` | the offline harness + README | **No CMakeLists edit was needed** — `src/lib/Kernel/*.cpp` is already a `GLOB_RECURSE CONFIGURE_DEPENDS`, so the new directory is picked up automatically. **No header under `include/` was touched**, so no runner TU is invalidated. ### The design decision that matters Input is **pulled one quadword at a time straight out of the ch4 descriptor**, exactly like hardware, rather than being gathered up front. That keeps MADR/QWC/TADR truthful while a command is mid-flight, so the guest's halt-and-resume bookkeeping keeps working. Output is pushed into the ch3 transfer as it is produced, and a ch3 kick that arrives before there is any data simply stays pending — which is the normal ordering for Sony's FMV libraries and is covered by a test. ### Three real bugs the offline validation caught Each of these would have cost a build+run cycle to even notice, and two of them do not desync the bitstream — they just corrupt a few macroblocks per frame. 1. **CSC fixed-point shift.** The IPU's colour matrix is 6-bit fixed point (76/64 = 1.1875), not 7-bit. Shifting by 7 turned a full-white macroblock into mid-grey. 2. **Table B.10, motion codes.** `+/-11` and `+/-12` are **11-bit** codes. Writing `+/-11` as the 10-bit `0000010000` swallowed `+/-12`'s slots and shifted `+/-13..+/-16` down one rung. Large motion vectors then decoded at the wrong length and desynced the stream a few hundred macroblocks later. The prefix-code self-check did **not** catch this — the wrong table was still a valid prefix code. 3. **Table B.14, runs 22-26.** That group descends as the code value ascends; I had it ascending, which swaps (22,1)<->(25,1) and (23,1)<->(26,1). Identical code lengths, so no desync — coefficients just land 2-3 positions too far along the scan and the block overruns index 63 and aborts. Caught only by validating **every one of the 111 entries individually against ffmpeg**. ### How it is validated (reproducible, no game build) `build_scripts/ipu_validation/` — see its README. Three layers: 1. `validate_b14.py` — synthesises a one-macroblock MPEG-1 stream per table entry and asks ffmpeg what each code means. Currently **111/111 match**. 2. `ipu_player.cpp` — a full MPEG-1 player that does header parsing and motion compensation itself (as the guest does) and drives everything below the slice header through the real IPU registers and DMA. Whole-movie diff against ffmpeg. 3. `ipu_csc_test.cpp` — CSC through the FIFO and both DMA channels, RGB32 and RGB16, with and without SETTH thresholds. All cases pass. | Movie | Frames | Min PSNR | Worst abs diff | ECD | |---|---|---|---|---| | ATARI | 122 | 71.8 dB | 2 | 0 | | OKR | 150 | 67.0 dB | 2 | 0 | | OP_USA | 150 | 63.7 dB | 2 | 0 | ### Known limits, stated plainly - **IDEC is not implemented** — it raises ECD and logs once. No SDBZ code path issues it. - **Table B.15 is not transcribed** — MPEG-2 only, unreachable for this title. Raises ECD. - The IDCT is float, so results are within +/-2 of an integer-IDCT reference rather than bit-exact. Within IEEE 1180. - **The ADX audio silence (`[adx:stream] nonzero=0/40`) is NOT addressed by any of this.** ADX is CRI's own ADPCM, decoded on the EE into SPU2; it never touches the IPU. Still open. ## ⚠️ SUPERSEDED by the entry above — Stage 5.17 diagnosis, 2026-08-23 **Session type:** one build + one 197 s run (`[sreg]` probe, det=1, no behavior change), then a **long static lane, zero builds**. The run reached the `.SFD` open like the previous 37. The answer came out of the static lane — **no further runs were spent**. ### State in one line SofDec video decodes MPEG-2 **through the IPU**. Our IPU (`0x10002000`-`0x10002030`) is **pure register storage** — commands stored and never executed, `IPU_CTRL` BUSY permanently clear, `IPU_TOP`/`IPU_BP` echoing the last write, and **no IPU_FROM/IPU_TO DMA at all** — while the guest ships a full software IPU driver at `0x423xxx`-`0x42Axxx`. So `mwPlyGetCurFrm` bails **silently** with `frm = 0` every tick, forever, and every layer above it correctly reports success. **Nothing was ever stuck.** ### Run 88 — the four retractions from the `[sreg]` probe | # | Was believed | Run 88 says | |---|---|---| | 1 | step 0 blocks on `(sceSifGetReg(13) & 0x201)` | `gate13block=0`, `gate13pass=5183`, **every value `0x0`, every verdict PASS**. IOP `SET_SREG` fired 742× and wrote only regs **12/14/15**, never 13. EE `sceSifSetReg` called **once** (`idx=12 val=0xf0000000`). | | 2 | the logo states never reach step 0xd | both ran the **full** machine `0x0→0x1→0xb→0xc→0xd→0xe→0xf`, once each. `0x3E2E80` never ran (`calls=0`). | | 3 | `sub_113D30` sets `[0x500728]` | it is `jal 0x113f40; jal 0x114b60(a0=0); memset(0x54BD60, 0, 0xD8)`. The real writer is `0x113FD8`, inside `sub_113F40`. | | 4 | `0x500724`/`0x500728` are globals | ⚠️ **this retraction was itself WRONG — see below.** They ARE globals. | ### ✅ The static lane — what it found (2026-08-23, no builds) **1. `$gp = 0x503070`, and `eeref.py` was blind to an entire class of globals.** Decoded from crt0 (`0x1001c0 or $gp,$a0,$zero`). PS2 SDK code reaches the small-data area as `imm16($gp)` — an address that never exists as a `lui` constant, so a lui-only index returned a confident **"UNREACHABLE"**. The 109,770-EA "independent confirmation" shared the same blind spot, so it agreed for the same wrong reason. **FIXED in `build_scripts/eeref.py` 08-23** — `find_gp()` + `GP` rows in `refs`, `--gp` overrides. Reproduces the hand-built toucher table exactly. **2. `0x500728` is a MODE SELECTOR, not a gate.** Sole consumer `0x1712D0 RenderDispatch`, called twice from `SyncFrame`: flag==1 → movie-driven field sync (`sub_14FF28`+`sub_11FBA0`), else GS `CSR` bit 13 FIELD (`sub_1721E0`). ⇒ the 1→0→1→0 toggling is **two movies starting and ending**. Nothing anomalous. **3. ❌ RETRACTED: "the movie object is never constructed."** That came from reading only the **tail** of the `[movie]` tag. The whole tag shows two complete sessions: | logo | window | obj | stat | |---|---|---|---| | Atari | **t=110–124 s** | `0x45f6e4` | 0 → **1** @112 → 0 @121 | | Okrtron | **t=158–175 s** | `0x45f6e4` | 0 → **1** @160 → 0 @172 | `objFile=0x4597b0`, `objSt=1`, `crt2=0x500730`. **4. The guest's own TTY is already in the run log** — channel `[Deci2Call:reqsend:tty]`. Run 88 contains **10 records total**: `CMemory::Init`, five `Load Module ... OK!`, and `MovieCreate: Use Work Size = 0x332100` / `Use Work Adrs = 0x1806c00`, each twice. This is a free permanent oracle — the game printfs every CRI error path. **5. Every layer with an error path reports success.** `sub_113AA0` = `MovieCreate`; its three failure strings (`0x4B7310`/`0x4B7340`/`0x4B7370`) never printed ⇒ movie handle created (`sw $v0,48($s0)` at `0x113bb0`) and texture created. Success path calls `0x14F140` = **`mwPlyStartFname`**, whose two error strings ("handle is invalid", "fname is NULL") also never printed ⇒ valid handle, non-NULL filename, playback started. Files: **`movie/atari.sfd`** and **`movie/okr.sfd`**, 256×448. **6. ❌ `objSrc=0` is a dead-code field.** Its only setters `0x14F880` / `0x14F8D0` (and entry points `0x14F998` / `0x14F6B0`) have **zero `jal` callers and zero data-word occurrences in the whole image**. ⇒ Stage 5.16's outcome **C** ("the bug is above CVFS, in the movie source binding") is **void** — it rested on a field this path never writes. **7. Free symbol recovery.** `0x11xxxx` / `0x14Fxxx` / `0x15xxxx` are **CRI SofDec + ADX**, fully named by their own error strings (`mwPly*`, `ADXT_*`, `ADXF_*`). ADXRT 3020, ADXF/PS2EE 7.36, ADXT/PS2EE 9.61, ADXPS2 2.63. "SJ" = CRI **Stream Joint**. ### 🔴 Do NOT build the sreg-mirror fix `ps2_iop_sifSetEeSreg` (`ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp:2215`) writing a host `std::unordered_map`, and `g_sifCmdHandlers` being written+erased but never read, are **real defects**. They are **irrelevant to this stall** — reg 13 is never written by anyone. ### ★★★ THE ANSWER — found 2026-08-23, static, zero runs **The pull side is fully wired, reachable, and runs every tick:** ``` AtariLogo / OkrtronLogo update 0x420FC0 / 0x421830 -> 0x113920 MovieUpdate -> 0x14D2C0 mwPlyGetCurFrm(obj, &frm) -> 0x150008 mwPlyGetSfdHn -> obj+60 -> 0x167320 SofDec get-current-frame -> 0x16B318(sfd, 6, 0xB, &frm, 0) THE ACTUAL FRAME FETCH -> 0x14DC98 mwPlyIsNextFrmReady ``` Named from their own CRI error strings via `eeref refs`. `0x14D160 mwPlyGetFrmSync` and `0x14D1B8 mwPlyGetFrm` have **zero callers** — `mwPlyGetCurFrm` is the one that runs. **Both failure exits are silent** (`sw $zero, 0($s2)` and return; `0x167320` writes `frm = 0` up front and skips its callback when it is still 0). No printf on either. `obj+132` (frames consumed) and `obj+120` (current frame) therefore never advance. **The IPU evidence, both sides:** | register | guest refs | our runtime | |---|---|---| | `IPU_CMD` `0x10002000` | 33 | stored, never executed — no IDEC/BDEC/VDEC/FDEC/CSC | | `IPU_CTRL` `0x10002010` | 50 | reads mask off bit 31 (BUSY) ⇒ guest never spins | | `IPU_BP` `0x10002020` | 13 | echoes last write | | `IPU_TOP` `0x10002030` | 8 | echoes last write ⇒ bitstream reader sees 0 | | `IPU_IN_FIFO` `0x10007010` | 2 | `sceIpuInit` writes IQ/VQ into a FIFO that discards | Guest IPU driver: `0x423828`, `0x423A68`, `0x423D98`, `0x424360`, `0x4248B8`, `0x424A30`, `0x424B98`, `0x4271F8`, `0x4294B8`, `0x429990`, `0x429EB0`, `0x429F98`, `0x42A1C8`. Runtime: `ps2xRuntime/src/lib/ps2_memory.cpp:1292` (write), `:2631` (read), `ps2xRuntime/src/lib/Kernel/Stubs/IPU.cpp` (all no-ops). **DMA matches.** Guest drives IPU_FROM `0x1000B000` (ch3) and IPU_TO `0x1000B400` (ch4) from `0x429990`, `0x429AD8`, `0x163A98`, `0x163C98`, `0x426048`, `0x427770`, `0x427870`, `0x4280E0`, `0x4292F8`, `0x4294B8`. Our `Support.h` dispatches **only VIF1 and GIF**; IPU appears in a base list at `Support.h:1240` and a comment at `ps2_memory.cpp:1424`, nowhere else. The generic MMIO read clears `STR` on any `(addr & 0xFF) == 0`, so the guest's "wait for DMA" poll **exits instantly** and it believes the transfer completed. On real hardware the IPU cannot fail this way, so CRI wrote **no error path** for it — which is exactly why every layer above reported success. ### ⚠️ The audio silence is NOT explained by this `[adx:stream] nonzero=0/40` is ADX (CRI ADPCM, decoded on the EE into SPU2) and does **not** go through the IPU. Do not fold it in. It may share an upstream cause (nothing pushed into the SJ Stream Joint pipe) or be entirely separate. **Unmeasured either way.** ### 🔴 DECISION THE USER OWNS — do not start the work without it The fix is **Fix Tool 2** (runtime `src/lib/`, no headers, no recompiler) but it is a real subsystem, not a stub tweak: 1. IPU command engine — IDEC / BDEC / VDEC / FDEC / CSC / SETTH / SETIQ / SETVQ, plus `IPU_BP` bit-pointer and `IPU_TOP` bitstream-peek semantics. 2. An MPEG-2 macroblock decoder behind it — VLC tables, IQ, IDCT, motion compensation. 3. `IPU_IN_FIFO` / `IPU_OUT_FIFO` plus DMA ch3/ch4 plumbing in `Support.h`, which today has no IPU case at all. 4. `IPU_CTRL` BUSY must become real, or guest timing assumptions break the other way. **Question:** are the two logo movies worth an MPEG-2 decoder now, or do we skip the movies and press on to the title screen? ### Still open, unrelated to the CRI gate - `vbl/s` ≈ 6 instead of 60 — the game runs ~10× slow. - `iChangeThreadPriority` (`Kernel/Syscalls/Thread.cpp:1079`) forces a reschedule; ps2tek 2Ah says it must not. Unfixed. - Unconditional `THS_RUN` at `Thread.cpp:729/876/932` and `Helpers/Runtime.h:234/261` → three simultaneous THS_RUN threads. Unfixed. - Upstream **#214** (2-hunk COP0 IE change) — never bundle with a Stage 5.17 change. Upstream **#210** — HOLD (recompiler, 30 h rebuild). - PCSX2 ground-truth lane still needs the user: DebugServer build running + its MCP enabled. --- ## ⚠️ SUPERSEDED 2026-08-22 — **was the single entry point; its Stage 5.17 conclusions failed at run 88** **Session type:** static-only. **Zero runs. Zero builds.** Nothing here was measured on the runtime this session — it was decoded from the ELF and from the run-85 log that was already on disk. ### State in one line Stage 5.17's mechanism is **solved and written down**; the `[thsync]` probe that discriminates the remaining four candidates is **shipped in source but never compiled**. The project is one build + one run from a verdict. ### Do this first, in this order 1. **Build.** `build.ps1`. Aux targets `iop_harness` / `ps2x_tests` will LNK1120 on `fn_*` symbols — that is normal, by construction. The build is good iff no error names `ps2EntryRunner.vcxproj`. 2. **Run 86.** Use the corrected Active Runner Command (`-Determinism 1 -RunSeconds 200`). ⚠️ The stored command was wrong on **both** of those until today; do not copy an older one out of this file. 3. **Read `[thsync]`.** Convert first — `run_log.txt` is **UTF-16LE**, plain grep returns zero matches for everything. The probe prints its own `VERDICT=` field. The decision table it maps to was committed **before** the run — see *"Run 86 — `[thsync]` probe SHIPPED"* in the 08-22 entry. Do not re-derive it after seeing the numbers. ### The one thing this session actually proved `0x13c6e8` — the frame worker's work call — is what invokes `0x155320`, the movie pump. **The "movie gate" and the "thread spin" are one mechanism, not two problems.** Two weeks of Stage 5.17 were spent on the downstream symptom. The main thread sits in a naked spin (`sub_11E690`) waiting for a worker ACK. Its only syscall is `ReferThreadStatus`, which does not yield. On real EE the priority boost preempts the spinner; we have no preemption. ### Do NOT do these — they are closed or void | lane | status | |---|---| | `Thread.cpp` "make `WakeupThread`/`ResumeThread` land" | **VOID.** No kick is ever issued — both are absent from the spin trace. Marked ⚠️ VOID in entry (c). | | `ee_thread_status_t` layout | cleared. `status` at offset 0x00, `Helpers/State.h:132`. | | `ChangeThreadPriority` unimplemented | cleared. Exists, `Thread.cpp:1044`. | | Codegen / recompiler | cleared, see (b). | | Upstream #170 / #179 / **#184** | **HELD by decision.** #184 replaces this exact subsystem (`EeScheduler.cpp`, 2038 lines) but is welded to the IOP stack — 5–8 day re-port. Strategic destination, not this session's work. | ### Still open, needs the user - **PCSX2 ground-truth lane.** Not run. Needs PCSX2 (DebugServer build) launched **and** its MCP server enabled — MCP servers are disabled by default and the agent will not enable them. Capture list is in the 08-22 entry. Worth doing *before* interpreting run 86: if `[0x4419D8]` is nonzero on hardware too, the gate is normal and the preemption theory dies without spending a run. - **The candidate fix** (`maybe_yield()` in `ReferThreadStatus`) is a **hypothesis only**. Do not build it together with the probe — a combined run cannot tell which change moved the needle. ### Touched this session | file | change | |---|---| | `ps2xRuntime/src/lib/ps2_runtime.cpp` | `[thsync]` probe added to the 1 Hz sampler, beside `[mvgate]`, so it shares timestamps with `[watchdog]`. **Unbuilt.** | | `PS2_PROJECT_STATE.md` | this handoff; the 08-22 retraction entry; entry (c) marked ⚠️ VOID; Active Runner Command corrected. | | `memory/project_stage517_movie_streaming_stall.md` | "READ THIS FIRST" retraction prepended. | | `memory/MEMORY.md` | Stage 5.17 index line rewritten to the retraction. | **No build, no run, no commit, no zip this session.** ### Process note worth keeping The static pass that produced all of the above took roughly ten minutes and was available on day one of Stage 5.17. `feedback_static_before_probe` says to run that lane first; it was skipped in favour of the runtime until the user intervened. The lane order is **binary → PCSX2 → probe**, and it is not advisory. ## 2026-08-22 — Stage 5.17: ⚠️ RETRACTS (c). No kick was ever issued. The movie code was never the bug. Zero runs. Everything below is static (ELF disassembly + `eeref`) or a re-read of the run-85 log that was **already on disk**. Entry (c) below is superseded. ### Retraction — "the thread kick never lands" was never tested (c) concluded the game was "alternately resuming and waking a worker thread indefinitely" and sent the next lane at `Thread.cpp`. Its own source log refutes it. Run 85, t=132–137, the trace field contains: | addr | syscall | present in the spin? | |---|---|---| | `0x174ba0` | `ReferThreadStatus` (0x30) | **yes** | | `0x174bd0` | `WakeupThread` (0x33) | **no — absent the whole window** | | `0x174c30` | `ResumeThread` (0x39) | **no — absent the whole window** | Both helpers are conditional, decoded from the ELF: ``` 0x11ed28 if (status == 4 || status == 0xc) WakeupThread(tid) 0x11ed90 if (status == 8 || status == 0xc) ResumeThread(tid) ``` Neither fires ⇒ the worker's status is **not** WAIT and **not** SUSPEND. So no kick was sent, and a fix making `WakeupThread`/`ResumeThread` "land" would have changed nothing. **The three `Thread.cpp` questions in (c)'s "Open, not started" are void.** Also corrected: `pc` is **not** pinned at `0x113c9c`. It moves across the window (`0x113c9c` → `0x113cd8` → `0x113cf8` → `0x11f478`). One sample was read as a trend. ### THE MECHANISM — a guest spin-wait our scheduler cannot break `sub_11E690` — the spinner: ``` [0x441924] = 1 ; raise request ChangeThreadPriority(tid, [0x4418F0]) ; boost worker ABOVE us LOOP 0x11e6f0: 0x11ed28(tid) ; 0x11ed90(tid) ; kick (both no-op, see above) if ([0x441924] == 0) break ; worker ACKs by clearing if (++n > 199,999,999) -> error 0x4B8720 ``` `sub_11EAC8` — the acknowledger: ``` if ([0x4419D8] != 0) return ; gate: skips the ACK entirely [0x441960]++ ; [0x441934] = 1 jal 0x13c6e8 ; THE WORK — this is what calls 0x155320 [0x441934] = 0 if ([0x441924] == 1) { [0x441924] = 0 } ; the ACK ``` **`0x13c6e8` drives the movie pump.** So the "movie gate" and the "thread spin" are one mechanism, not two problems: the main thread is blocked waiting on the frame worker, and the worker is not completing a tick. Stage 5.17 has been looking at the downstream symptom for two weeks. ### Why the spin cannot break here - The loop's **only** syscall is `ReferThreadStatus`, which does **not** yield. - `ChangeThreadPriority` — the one call that does `maybe_yield()` — is **outside** the loop. - On real EE the boosted worker **preempts** the spinner. We have no preemption. - `ps2_scheduler.cpp:1340 yield_point()` step 3 is `maybe_yield()`, which only switches to a higher-priority **READY** fiber. **A Blocked worker is invisible to it.** Corroborated in the same log: during the stall `res/s` falls to **1–3** while `progress` climbs **~40× faster**. `yield_point` is sampled ~21,000×/s and actually yields ~1–3×/s. ### Ruled out for free — do not re-check these - **`ee_thread_status_t` layout** — `status` is at offset 0x00 (`Helpers/State.h:132`). The guest's `lw $v1, 0($sp)` reads the right word. Not a struct bug. - **`ChangeThreadPriority` unimplemented** — exists, calls `update_priority` + `maybe_yield` (`Thread.cpp:1044`). The boost is not silently dropped. ### Q7 finding — upstream already replaced this subsystem `f4309cd1` *"refactor: from guest threads to EE scheduler (#184)"* adds `runtime/ee_scheduler.h` (447 lines) + `Kernel/EeScheduler.cpp` (2,038 lines). It is welded to the held #170/#179 `ps2xIOP/` stack, so it carries the same 5–8 day re-port of our ARKD bridge. **Decision taken: #170/#179/#184 stay HELD**; probe + surgical fix first. If the fix turns structural, taking all three becomes the cheaper path. ### Run 86 — `[thsync]` probe SHIPPED, awaiting build `ps2_runtime.cpp`, in the 1 Hz sampler beside `[mvgate]` so it shares timestamps with `[watchdog]` (the cross-read that cracked this). Prints unconditionally, no gate. Reuses `getThreadDebugSnapshot()` — the data source already existed; only the emit was missing (its sole caller sits in the `RecompDbg` block, dead under `-NoDebugger`). Fields: `req` `inWork` `gate` `tick` `dTick` `boost` `idle` `tokW` + full thread table (`tid:status,waitType,waitId,priority,pc`). **Decision table is pre-committed and evaluated inline as `VERDICT=`**, so a later reading cannot be fitted to whatever came back: | verdict | conclusion | next action | |---|---|---| | `WORKER-GATED` | `[0x4419D8] != 0`, acker early-outs before the ACK | fix the gate's producer — **not** the scheduler | | `WORKER-INSIDE-WORK` | stuck inside `0x13c6e8` | blocker moved into the movie pump | | `WORKER-NOT-RUNNING` | request up, worker never ticked | read the thread table: Blocked vs Ready | | worker Ready, pri ≤ spinner, `res/s` 1–3 | pure preemption failure | make `ReferThreadStatus` a yield point | | status RUN/READY but fiber Blocked | bookkeeping desync | reconcile `info->status` with fiber state | **Do not build the probe and a fix together** — a combined run cannot say which change moved the needle. ### Run command for 86 — two changes from the stored Active Runner Command ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 1 -RunSeconds 200 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` - **`-Determinism 1`**, not 0 — the stored command contradicts [[feedback_determinism_gates_guest_progress]] for Stage 5.15+. - **`-RunSeconds 200`**, not 90 — the stall episodes are at **t=121–138** and **t=171–192**. A 90 s run ends before both and reads as "never happens" ([[feedback_run_window_false_negative]]). ### The candidate fix — NOT implemented, hypothesis until run 86 returns A guest polling thread status is by definition waiting on another thread, so `ReferThreadStatus` (`Thread.cpp:794`) should call `ps2sched::maybe_yield()` after releasing `info->m` — the spinner calls it 2×/iteration, making every iteration a preemption point, which is what the hardware priority boost buys and we do not honour. Yield **outside** `info->m`, matching `ResumeThread` (`Thread.cpp:773`). ### Learned patterns 1. **A trace that omits a syscall is evidence, not noise.** Two weeks rested on "the kick doesn't land". The log said no kick was sent. The absent addresses were the finding; nobody listed what was *missing* from the trace. 2. **Lane 1 was skipped for two weeks.** Ten minutes of `eeref` + disassembly produced the mechanism, the acknowledger, and two free eliminations. Every prior session went straight to a probe. [[feedback_static_before_probe]] exists because of runs 74–81 and it was not applied here either. 3. **Name the caller before naming the bug.** `0x13c6e8` links the movie pump to the thread handshake. Stage 5.17 treated them as separate problems for two weeks because nobody walked one level up the call chain. 4. **Put the decision table in the probe's own output.** `VERDICT=` strings written before the run cannot be retrofitted to the result — which is exactly how (a), (b) and (c) each got fitted to their own numbers and then retracted. ## ⚠️ SUPERSEDED 2026-08-21 (c) — Stage 5.17: it is a THREAD KICK THAT NEVER LANDS. Gate arithmetic retracted. Run 85 (`[mvgate]` + canary, `PS2_COVERAGE=1`). The probe refuted the reading it was built to test. The canary read **clean** (`rate=0x426fc28f` throughout, matching PCSX2 exactly), which killed the "foreign bulk write" branch outright -- useful, but not what found the bug. The bug was found by cross-reading `[watchdog]` at the seconds where `g36` flipped: a line already in the same log, written by an older probe, never previously read alongside this one. ### Retraction — `0x1551E0` was misidentified, so the run-84 read-side arithmetic is void Last entry treated `0x1551E0` as "the fall-through taken when the pause read returns != 1", and built the whole gate-5 conviction on its count. It is nothing of the sort. Decoded: ``` 0x1551e0 jal 0x14e4d0 ; lw $v1, 80($v0) ; beq $v1,$zero,ret ; lw $a0,84($v0) ; jalr $v1 0x155178 jal 0x14e4d0 ; lw $v1, 72($v0) ; beq $v1,$zero,ret ; lw $a0,76($v0) ; jalr $v1 ``` Both are **callback invokers** -- fptr at `[0x45F6C8]`/`[0x45F6C0]` with its arg. Not gates, not fall-throughs. Every number derived from that premise is dropped. ### The gate ladder is real, but counts cannot pick the gate — by construction Verified against the ELF, not from memory: ``` 0x155338 lw $a1, -2444($v0=0x460000) ; [0x45F674] 0x155358 bne $a1, 1 -> ret GATE 1 0x155360 bne $s1, 0 -> assert GATE 2 0x155380 lw $s0, 0($s1) 0x155384 bne $s0, $a1 -> ret GATE 3 (so $s0 == 1) 0x15538c jal 0x155520 ; = lw $v0,96($a0) 0x155394 beql $v0, $s0 -> ret GATE 4 0x5050000a, rs=$v0 rt=$s0 0x15539c jal 0x1555e8 ; = lw $v0,36(0x45F678) = [0x45F69C] 0x1553a4 beq $v0, $s0 -> ret GATE 5 ``` Pump geometry confirmed from `0x155210`: slot base `$s1+0x6c` = **0x45F6E4**, stride **0x304 = 772**, **8** slots. The `[mvgate]` probe reads exactly this, so its geometry is not in doubt. `0x155520` and `0x1555E8` each have two callers, and both split assignments fit the totals. That ambiguity is **not** resolvable by counting and should not be attempted again. What the counts *do* pin, under either split, is that `[0x45F69C]` reads **1** at essentially every read -- `0x1551E0` ran 4 times, and it sits directly behind the pump's own `!= 1` test. ### Codegen is NOT at fault — checked, so nobody re-checks it - `get_field_val___0x155520.cpp` executes the delay-slot `lw` **before** taking `jr $ra`. Correct. - `beql` at `0x155394` is emitted as `GPR_U64(2) == GPR_U64(16)` with the delay slot inside the taken branch. Correct nullification semantics. - ⚠️ **Three** generated bodies exist for this one address: `fn_155320_0x155320.cpp`, `sub_00155320_0x155320.cpp`, `sub_155320_0x155320.cpp`. The live one is `sub_155320_0x155320.cpp` -- it is the only variant that routes calls through `dispatchGuestBranch`, which is what coverage counts, and coverage is non-zero. Worth a separate look; not this bug. ### THE FINDING — `g36=1` is not a movie flag, it is a system-wide stall `[mvgate]` collapsed to transitions gives two identical episodes: ``` t=121 en=1 g36=1 nLive=1 live=0 movie A starts t=122 en=1 g36=0 nLive=1 "playing" -- 10 s t=132 en=1 g36=1 nLive=1 <-- pause raised t=136 en=1 g36=1 nLive=0 slot torn down t=138 en=1 g36=0 nLive=0 recovered t=171 .. t=192 movie B, same shape ``` Cross-referencing `[watchdog]` at those exact seconds is what breaks the case open: | | t=125 (`g36=0`) | t=134 (`g36=1`) | |---|---|---| | `vbl/s` | 27 | **1** | | `res/s` | 106 | **1** | | `gif/s` | 27 | **0** | | `busy%` | 88 | **100** | | `pc` / `ra` | 0x422660 | **0x113c9c, pinned** | | `cb` | 0 | **0x154fa8** | Rendering stops dead. The trace is one repeating pair, forever: ``` 0x11ed90 -> 0x174ba0 -> 0x11ed28 -> 0x174ba0 -> 0x11ed90 -> ... ``` Decoded, that is a **thread-kick poll loop**: ``` 0x174ba0 $v1=0x30 syscall ReferThreadStatus 0x174bd0 $v1=0x33 syscall WakeupThread 0x174c30 $v1=0x39 syscall ResumeThread 0x11ed28(tid): status == 4 (THS_WAIT) || 0xc -> WakeupThread(tid) 0x11ed90(tid): status == 8 (THS_SUSPEND) || 0xc -> ResumeThread(tid) ``` The game is alternately resuming and waking a worker thread, reading its status back, and finding it unchanged -- indefinitely. The pause flag stays 1 for the whole spin because the `setPause(1) ... work ... setPause(0)` pair never reaches its clear: the *work* call in the middle is what is waiting on that thread. So gate 5 is shut as a **symptom**. The pause flag is held up by a thread that our scheduler never actually runs, and the movie tick starving is downstream of that, not the cause of it. ### Where to look next — scheduler, not movie code The target thread's status never leaves whatever value it has despite `WakeupThread` and `ResumeThread` being issued at full tilt. Candidates, in order: 1. `WakeupThread`/`ResumeThread` in `Kernel/Syscalls/Thread.cpp` update bookkeeping but never make the thread runnable to the scheduler. 2. `ReferThreadStatus` reports a stale/wrong status word, so the game's `== 4` / `== 8` tests pick the wrong kick every iteration. 3. The spinning thread never yields, so at `busy%=100 vbl/s=1` the woken thread is runnable but never scheduled. (3) is strongly suggested by `vbl/s` collapsing 27 -> 1 during the spin: the vblank ISR itself is being starved, which is a host-side scheduling symptom, not a guest-logic one. ### Method note Zero new runs were needed to reach this. The gate arithmetic that consumed two windows was answered -- and invalidated -- by cross-referencing two samplers that were already in the same log. Read `[watchdog]` alongside any new sampler before theorising about the sampler's own field. ### Learned patterns from this session 1. **A new sampler's own field is the least reliable thing in its own line.** `[mvgate] g36` was read three ways across two sessions and each reading was defensible. What settled it was `[watchdog]` at the *same timestamps* -- already in the same log, already written, never cross-read. Before theorising about a probe's field, print the other samplers at that second. 2. **A two-caller function cannot be convicted by a coverage count -- ever.** `0x155520` and `0x1555E8` both fit both hypotheses to the digit. Two sessions were spent re-deriving that. If `eeref refs` shows >1 live caller, stop counting and go read the value. 3. **"Unreachable" from `refs` must be paired with a coverage zero AND a check that the caller isn't invoked directly.** `0x1551E0` looked like a gate fall-through purely because its position fit the story; nobody decoded it. Decode every address before it carries an argument. 4. **`busy%=100` with `vbl/s` collapsing is a scheduler signature, not a guest one.** A guest logic bug leaves the frame loop running. When the vblank ISR itself starves, look host-side first. 5. **Duplicate generated bodies are possible.** Three files exist for `0x155320`. The live one is identifiable by `dispatchGuestBranch` (coverage only counts those). Check for duplicates before reading generated code. ### Exit test for run 86 No probe change is required to run this; the next lane is static (Thread.cpp). When a run is next taken, the pass condition is: - `[watchdog]` at the seconds where `[mvgate] g36=1` no longer shows `vbl/s <= 1` / `gif/s = 0` -- i.e. the thread kick lands and the spin ends. - Secondary: `0x165250` (the movie tick) rises above **2** in coverage. - If the spin persists but `vbl/s` recovers, the kick works and the blocker has moved downstream -- that is progress, record it as such rather than a failure. ### ⚠️ VOID — do not start (cancelled 2026-08-22) > These three `Thread.cpp` questions rest on a kick that is **never issued**. See the > 2026-08-22 entry at the top: `WakeupThread`/`ResumeThread` are absent from the spin > trace entirely. Q1 and Q2 are unanswerable because their syscalls never run; only > Q3 ("the spinning thread yields") survives, and it is now the leading hypothesis — > tracked there, not here. ~~`ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp` -- three questions, in order: `WakeupThread`(0x33)/`ResumeThread`(0x39) actually make a thread runnable; `ReferThreadStatus`(0x30) returns a live status word; the spinning thread yields.~~ ## ⚠️ PARTLY RETRACTED 2026-08-21 (a) — Stage 5.17: "THE TICK IS THE BUG" > **Superseded in part by entry (c) above.** The run-83 retraction and the > pump/gate geometry here still stand. The gate-4-vs-gate-5 arithmetic does not. Run 84 (`PS2X_ORDER` + `PS2_COVERAGE=1`, `PS2_COV_GAMELO=0x100000`). The ordered dispatch trace paid for itself on its first use, and it overturned the previous entry rather than confirming it. ### Retraction — we were never stuck in state 1 The 2026-08-20 (d) entry concluded that `dec+76` was written to 3 and then **reset** before the state machine next ticked, because `0x164F78` ran 3x with its guard passing while `0x165458` (state-1 handler) kept reading `dec+76 < 2`. That is wrong, and the evidence was already in the same log: ``` [sfdst] t=83s .. t=294s dec=0x1b12cc0 st=2 ``` `st` is `read32(dec + 72)`. **Ours reads 2 from t=83 s onward — identical to PCSX2 on real hardware.** The single tick we get advances the ladder 1 -> 2 correctly. Nothing is being reset. There is no ordering bug. The `[order]` trace shows the same thing directly. Per movie, three times over, byte for byte: ``` ... 0x14ee80 0x164f78 0x165300 0x165458 0x166a28 0x166ab0 0x1668f0 ... ``` `0x164F78` (writes `dec+76 = 3`) lands **immediately before** `0x165300`, with no writer in between. The bootstrap is correctly ordered. Total log: 38 records, all 38 kept, no `[cap]`. ### What the trace actually says `0x165300` was dispatched **exactly 3 times in 300 s** — once per movie, and only at start. State 2's handler `0x165488` cannot run on the tick that *produces* state 2; it needs the **next** tick. There is no next tick. The preroll gate `0x165548` has therefore never been called, and every symptom downstream follows. This count is exact, not a lower bound: `jal` to an external function is emitted as `dispatchGuestBranch(... DirectCall ...)` -> `lookupFunction` -> the hook (`control_flow_emitter.cpp:emitExternalJumpDispatch`). Only *intra*-function jumps bypass it. ### The pump is alive and hammering — the gate is what starves Static walk up from `0x165300`, all of it read-only in minutes: ``` class-5/6 callback registry (table 0x54E960, 8 classes, runners 0x13C658+0x18*k) -> 0x154FA8 / 0x154F58 registered by 0x14E6B0 via 0x154688 / 0x1546E0 -> 0x155210 loops 8 slots -> 0x155320 per-object movie update -> 0x165250 -> jal 0x165300 THE TICK ``` Coverage on the same run: | function | calls | | |---|---:|---| | `0x155210` 8-slot loop | 113,719 | the pump, per frame | | `0x155320` per-object update | **909,186** | = 8 x 113,648 | | `0x155520` gate-4 getter | 113,711 | gates 1-3 pass every tick | | `0x155518` **only** writer of `+96` | 9 | | | `0x165250` -> the tick | **3** | | | `0x1652A8` | ZERO | dead, see below | **909,186 calls reach the gate; 3 get through.** ### The five gates, and which two are left `sub_155320`, entry `0x155320`, decoded: ``` 0x155340 lw a1, -2444(0x460000) ; [0x45F674] global "movie system enabled" 0x155358 bne a1, 1 -> ret 0 GATE 1 0x155360 bne s1, 0 -> ret 0 GATE 2 (obj != NULL) 0x155380 lw s0, 0(s1) 0x155384 bne s0, 1 -> ret 0 GATE 3 (obj->+0 == 1, "playing") 0x15538c jal 0x155520 ; = lw v0,96(a0) 0x155394 beql v0, 1 -> ret 0 GATE 4 (obj->+96 re-entrancy latch) 0x15539c jal 0x1555e8 ; = lw v0,36(0x45F678) 0x1553a4 beq v0, 1 -> ret 0 GATE 5 ([0x45F69C] global pause) ... tail 0x1553D8 ... 0x15542C jal 0x165250 ``` Gates 1-3 pass ~113,711 times per run (that is `0x155520`'s count). So the tick is eaten by **gate 4 or gate 5**. Arithmetic pins the tail exactly. `0x155518` is the sole writer of `+96` and ran 9 times: 3 from `0x14C598` (which passes `a1=0` — always a clear) plus `2N` from the tail block (one set at `0x1553F4`, one clear at `0x155410` or `0x155464`, per entry). `2N + 3 = 9` gives **N = 3**, matching `0x165250`'s 3 dispatches exactly. The tail was entered 3 times out of 113,711 chances. ### Why static cannot finish this one Both gates read **balanced** in the image: - `0x1555A0` is `setPause(obj, val)`: `if (obj) obj->+92 = val; global->+36 = val` — the `bnel` puts the per-object store in the taken-delay-slot, the global store is unconditional. - All three call sites are matched pairs: `0x14E92C`/`0x14E940`, `0x154A1C`/`0x154A30`, `0x155648`/`0x15565C` — `set(1) ... work ... set(0)`. - `0x1555A0` ran an even **18** times. `0x14C598` only ever clears. On paper both words end at 0. One of them does not, at runtime. And counts cannot say which: `0x155520` and `0x1555E8` each have **two** call sites, and both attributions fit the totals to the digit (`113,711 + 9 = 113,720`). Order cannot separate two callers of one address either. This is a read-the-word question. ### New probe `[mvgate]` (in `ps2_runtime.cpp`, 1 Hz sampler) Geometry is fixed, not inferred — `0x14E4D0` is three instructions (`lui v0,0x46; jr ra; addiu v0,v0,-2440`), a constant `0x45F678`, and `0x155210` computes the slot array as `+108`, stride 772: - global enable `[0x45F674]`, global pause `[0x45F69C]` - per slot i in 0..7 at `0x45F6E4 + 772*i`: `+0`, `+92`, `+96`, `+100` `nLive` (count of slots with `+0 == 1`) is the shape gate: gates 1-3 passing 113,711 times means **exactly one** slot must read 1. `nLive != 1` prints `GEOMETRY-SUSPECT` and every other column is to be treated as fiction. Reading: | result | verdict | |---|---| | `g36=1` persistently | gate 5 — global pause stuck; find the `setPause` pair whose middle call never returned | | `L.f96=1` persistently | gate 4 — re-entrancy latch never cleared | | both 0, `nLive=1` | all five gates pass; the bug is in **our** dispatch of `0x165250` | | `nLive != 1` | probe geometry wrong, stop and re-derive | ### Not bugs — measured, do not reopen - **`dec+76` reset / ordering.** Retracted above. `dec+72=2` matches hardware. - **`0x1652A8`** — the 8-slot loop that calls `0x165300` directly — is **UNREACHABLE**: 0 jal, 0 j, 0 data word, confirmed twice (eeref and an independent raw PT_LOAD scan). Coverage ZERO. It is not the missing pump. - **The `0x154F58`/`0x154FA8`/`0x1554B8` "unreachable roots"** are not dead: they are **registered callbacks**, materialized `lui+lo` at `0x14E6DC`/`0x14E6F4` and handed to the class registry. Class 2, 5 and 6. This is the runtime-built-table case the static lane cannot see, and it is now documented rather than re-derived. ### Tool fix — `eeref.py` was hiding data references `refs` only recorded `lui+lo` materializations landing in `CODE_LO..CODE_HI` (`0x100000..0x500000`), so **every data address reported "UNREACHABLE in the static image"** — confidently and wrongly. `refs 0x54e960` said `imm=0` while the class table is materialized **6 times** inside `0x13Cxxx`. Also `daddiu` (op 0x19) was not folded at all — same class of hole as the `sdl`/`sdr` gap in `field`. Both fixed; the plan's four self-tests still pass (`0x168320` 0/0/0, `0x16C620` one data word at `0x4bf7ac`, `field 14000` exactly one store at `0x167BA8`). ## ⚠️ RETRACTED 2026-08-21 (b) — Stage 5.17: "it is GATE 5, and the game is not the one closing it" > **Superseded by entry (c) above.** The read-side arithmetic here rests on > `0x1551E0` being a gate fall-through. It is a **callback invoker**. Gate 5 is > shut, but as a *symptom* of a stalled thread kick, not because a non-game > writer touches `0x45F69C`. Kept for the PCSX2 reference values only. Lane 2 finally ran. PCSX2 (real, Pine confirms "Super Dragon Ball Z") was sitting at a breakpoint on `0x155320` with `a0 = 0x0045F6E4` — which independently confirms the probe geometry we derived statically, base and all. Backtrace: `0x11EAC8 -> 0x13C4FC -> 0x155228 -> 0x155320`, exactly the class-registry chain. Every hand decode was re-checked against PCSX2's native disassembler and all of it held, with one refinement worth keeping: gates 4 and 5 compare `v0` against **`s0`**, not a literal 1. `s0` is `obj->+0`, which gate 3 has already forced to equal `[0x45F674]` = 1, so the meaning is unchanged — but the code does not contain the constant, and a future reader will look for it. Confirmed verbatim from the emulator: ``` 0x14E4D0 lui v0,0x46 ; jr ra ; addiu v0,-0x988 -> constant 0x45F678 0x1555E8 jal 0x14E4D0 ; lw v0,0x24(v0) -> reads [0x45F69C] 0x1555A0 jal 0x14E4D0 ; bnezl s0 ; sw s1,0x5C(s0) ; sw s1,0x24(v0) -> writes [0x45F69C] ``` The `bnezl` guards only the per-object store; the global store is unconditional. ### The read side: `[0x45F69C]` returns 1 for essentially the whole run The attribution problem from the 08-21 (a) entry is now closed, by two static facts rather than another run: - `wrap_ptr_deref_safe_i @ 0x155538` — the gate-4 getter's *other* caller — is **unreachable in the image and absent from coverage**. So all 113,711 calls to `0x155520` come from gate 4. Gates 1-3 pass 113,711 times, measured, not assumed. - `0x1551E0` and `0x155178` have **exactly one caller each**, so their counts are clean readings of the loop tail. `0x1555E8` (the gate-5 getter) ran 113,720 times, split between the loop tail at `0x1552EC` and gate 5 at `0x15539C`. We cannot see the split — but we do not need to, because `0x1551E0` is the fall-through taken when that read returns **not 1**, and it ran **6 times**. Both extremes land in the same place: | split | consequence | |---|---| | tail-heavy (gate 4 blocks) | 113,709 of 113,715 tail reads returned 1 | | gate-heavy (gate 4 passes) | gate 5 blocked 113,708 times, i.e. returned 1 | **Either way the pause word reads 1.** Gate 5 is shut. ### The write side flatly contradicts it — and that is the finding `0x1555A0` is the **only** writer of `0x45F69C` in the image; `eeref refs 0x45f69c` finds no direct-addressed store either. It ran 18 times, and its three call sites account for all 18 exactly: | pair | container | container ran | calls | |---|---|---:|---:| | `0x14E92C` / `0x14E940` | `sub_14E8B0` | 2 | 4 | | `0x154A1C` / `0x154A30` | `sub_1549C0` | **0** | 0 | | `0x155648` / `0x15565C` | `0x155630` | 7 | 14 | | | | | **18** | All three are straight-line `set(1) ... work ... set(0)` pairs (verified natively). Every set has its matching clear. **The game cannot be leaving its own pause flag at 1.** So something that is not the game is writing that word. ### `[mvgate]` extended — the canary costs nothing PCSX2 at the main menu holds these exact values, and they are the reference: ``` [0x45F674] = 1 [0x45F678] = 0 [0x45F67C] = 0x426FC28F (59.94f, the frame rate) [0x45F680] = 1 [0x45F69C] = 0 ``` `0x426FC28F` is a specific float six words from the flag, so the probe now samples it as a clobber canary and prints `REGION-CLOBBERED` when it differs: | result | verdict | |---|---| | `rate` wrong | foreign bulk write — arm a data breakpoint on `0x45F69C` next | | `rate` right, `g36=1` | a single targeted store; distrust `0x1555A0`'s own count | | `g36=0` throughout | the read-side arithmetic above is wrong, gate 4 is back on | Note `[0x45F674] = 1` on hardware **at the main menu** with every slot zeroed — the movie system is enabled globally all the time, so gate 1 tells us nothing about whether a movie is playing. `[0x5E6B3C]` (GameMode) was 0 = MainMenu, which is why all eight slots read zero and why hardware could not be asked directly what a *healthy playing* slot looks like this session. ### Method note This entry cost zero runs. The gate-4-vs-gate-5 question was supposed to need the `[mvgate]` build; it was answered instead by one unreachability check, two caller-count checks and a native disassembly. Lane 1 and Lane 2 did the work the plan said they would. ## 2026-08-20 (d) — Stage 5.17: THE MOVIE STATE LADDER, and where ours stalls Run 83 (`PS2_COV_GAMELO=0x100000`) plus a PCSX2 A/B session. This is the first time the movie stall has a named mechanism rather than a suspect. ### The ladder `sub_165300` is a jump-table state machine on **`dec+72`**, table at `0x4BF510`: | state | handler | ours | |---|---|---| | 1 | `jal 0x165458` | **3x** | | 2 | `jal 0x165488` -> `jal 0x165548` (preroll gate) | never | | 3 | `jal 0x165820` | never | | 4 | `jal 0x1658C0` `state_byte_transition_s_2` | never | State-1 handler `0x165458`, decoded field by field: ``` v1 = dec+76 ; a1 = dec+72 (=1) if (v1 < 2) return 1 # stay <- WE ARE HERE if (v1 < 5) return 2 # advance if (v1 == 6) return 2 # advance ``` So **`dec+76` must be 2,3,4 or 6** to leave state 1. `0x16AAA0` = 6 = exactly 2 per `0x165300` entry (it is called at +0x3c and +0xd8), which pins the entry count at exactly 3 and makes "3 entries, all state 1" arithmetic, not inference. ### PCSX2 ground truth `$s1` at `0x165398` = **`0x01B12CC0`** — the state machine runs on `dec` itself, the same object and the same address as ours. Hardware at that moment: ``` dec+64 = 0x4000 dec+68 = 0 dec+72 = 2 (state) dec+76 = 3 ``` ### Every writer of `dec+76`, enumerated | site | value | gate | |---|---|---| | `0x164F78` `wrap_sif_is_bound_e` | **3** | `0x15B560`: `dec != 0 && dec+72 != 0` | | `0x166998` | 4 | requires `dec+72 == 3` | | `0x1661B4` `sub_166190` | 6 | state-4 path only | | `0x1665DC` `sub_1663D0` | 1 (`$s3`) | also writes `dec+72 = 1` | | `0x166AF4` `sub_166AB0` | 1 | also writes `dec+72 = 1` | | `0x16645C`, `0x166A58`, `0x166900` | 0 | resets | `0x1687B8` returns `lw $v0, 72($s0)` — **it returns the state**, which is why every producer above state 1 is gated on the state having already advanced. `sub_16AD18` is NOT a `+76` writer: it is a stride-68 loop zeroing `slot[i]+8` (eeref's fold arithmetic coincided at +76). Ruled out. ### The contradiction that defines the next run `0x164F78` — the **sole** writer of `dec+76 = 3` — ran **3 times** for us, and its error path `0x15B340` is **ZERO**, so the guard passed and the store executed. Yet `sub_165300` read `dec+76 < 2` on all three of its own ticks. ⇒ The value is being written and **reset before the state machine next ticks**, or the two are interleaved wrongly. Counts cannot separate those. A 1 Hz sampler cannot either — a value written and overwritten between samples is invisible, the same false-negative shape this project has paid for before. ### New instrument: `PS2X_ORDER` (ordered dispatch trace) `ps2_runtime.cpp` — hooks `pushDispatchPc`, env-driven address list, run-length compressed dump at shutdown, explicit `[cap]` line on saturation. **No memory reads**: order alone answers this. Cost when unarmed is one relaxed bool load. Next ordering question costs a run, not a build. ### Not the bug (measured, do not reopen) - Everything on the movie-open path runs **exactly 3x** = **once per movie**, 3 movies in the run. That is correct, not a starved pump. - `0x154C70`'s ZERO is an attribution artifact — it is a folded interior block of `0x154AE0` (cov 3) reached by an internal `j`, not a dispatch target. - The `dec+14000` line is closed: hardware leaves those four words at constructor values while the movie plays correctly. - The first PCSX2 watchpoint hit was `memset` at `0x18E404`: `a0=0x01806C00`, walked + remaining = `0x30E180 + 0x23F80` = `0x332100` = `wS`. One memset of the whole work area; `slot6` merely lives inside it. ## ★★★★★ 2026-08-20 (c) — **PCSX2 A/B: the `+14000` fields are IDENTICAL on hardware. The divergence is `slot6+0`/`slot6+4`.** First PCSX2 ground truth of Stage 5.17. Same object address on both sides (`dec = 0x01B12CC0`), confirmed live against a running SLUS-21442. | | PCSX2 | our runtime | |---|---|---| | `dec+14000..14012` | `0, 0, -3, 1` | `0, 0, -3, 1` — **same** | | slot list | `4bf220,4bf258,4bf2a0,4befe8,0,0,4bf780,4bf040,4bf748` | **same** | | slot6 vtable | `0x4bf780` | `0x4bf780` | | **slot6+0 / slot6+4** (`f7984`/`f7988`, `d8392`/`d8396`) | **`1` / `1`** during decode | **`0` / `0`** | ★★★ **The `+14000` line is closed.** Real hardware leaves those four words at their constructor values *while the movie plays correctly*. Runs 74–82 were measuring normal behaviour. `sub_168320` is dead on the PS2 too. **Object model confirmed against hardware:** slot stride **68**, vtable at **+12**, slot base `dec+7984`, object size `0x4000` (`dec+64`). **The live chain, from a PCSX2 backtrace (not inference):** ``` 0x3E0E60 loadscreen_tick -> 0x3E10D0 -> 0x3E0E60 -> 0x421830 state_byte_transition_l_3 (vt40 of state vtable 0x4faf70) -> 0x113C60 -> 0x14F518 -> 0x14F428 -> 0x1669D0 -> 0x166A28 -> 0x166B10 -> 0x1663D0 -> ctor ``` Entry is method **+0x40** of the *current* state vtable (`0x420FC0` for state `0x4faeb0`, `0x421830` for `0x4faf70`) — not +0x38 as first guessed. The live path uses `0x14F518`/`0x14F428`/`0x1669D0`, not the `0x166850` branch. **The gate that decides the write** (`mem_fill_z_9`): ``` 0x166c48 lw $s2, 14000($s0) # s2 <- dec+14000 ... 0x166e48 beq $s2, $zero, 0x166e94 # skip if zero 0x166e54 jal 0x167b68 # dec+14000 = s2 ``` Self-referential — it can never bootstrap. Consistent with hardware leaving it 0. **Our runtime reaches the whole upper chain** (run 82 COVGAME, `run_probe.jsonl`): `0x3E0E60` ×5401, `0x3E10D0` ×5401, `0x421830` ×438, `0x420FC0` ×259, `0x3E2FF0` ×625. So `vt40` **is** being called hundreds of times. ➡️ **Next:** the break is below `0x200000`, where COVGAME does not dump. `PS2_COV_GAMELO=0x100000` fixes that — **env only, no rebuild** — and gives per-address counts for `0x113C60`, `0x14F518`, `0x14F428`, `0x1669D0`, `0x166A28`, `0x166B10`, `0x1663D0`. Read a **zero** with care: coverage counts table-dispatched calls only. ⚠️ COVGAME/COVTOP go to `run_probe.jsonl`, **not** `run_log.txt`. Grepping the console log for them returns nothing and looks like "coverage did not run". ## ★★★★★ 2026-08-20 (b) — **METHOD CHANGE. `sub_168320` is unreachable; runs 74–81 were spent on dead code.** New static tool `build_scripts/eeref.py`. **The retraction.** Runs 74–81 narrowed the movie data-supply path to `sub_168320 @ 0x168320` — the only writer of `dec+14004/14008/14012` and the only issuer of the method-13 broadcast. Run 81 proved it never executed. It *cannot* execute. Scanned read-only over all 4.2 MB of PT_LOAD in `ELF/SLUS_214.42`: | check | `0x168320` | control `0x168938` | control `0x16C620` | |---|---|---|---| | `jal`/`j` targeting it | **0** | 28 | 0 | | 32-bit word equal to the address | **0** | 0 | 1 — at `0x4bf7ac` | | `lui`+`addiu`/`ori` materialization | **0** | 0 | 0 | `0x4bf7ac` is the slot-6 vtable we had been reading all along, so the scan demonstrably finds real references. `0x168320` has none of any kind, yet `slot=yes` — it has a recompiled body. It is CRI library code linked in and never called. **Do not reopen it.** **Why it went unnoticed for eight run cycles:** the method. One probe field per build+run, and the decompile text was treated as an xref database — it also claimed `0x167B68` had zero callers, which is false (`jal` at `0x166E54`). **The real chain**, from the writer upward. Exactly **one instruction in the game** writes `dec+14000`: `sw` at `0x167BA8` in `sub_167B68`. ``` vtable[+8] index 2 0x420E70 (@0x4faee8) / 0x3E2E80 (@0x4f9aa8) -> 0x113AA0 obj_set_fields___30_0 | 0x113C60 wrap_wrap_obj_set_flags_clone_01 -> 0x14C8C8 -> 0x14C0D0 wrap_wrap_get_data_ptr -> 0x166850 -> 0x166A28 -> 0x166B10 mem_fill_z_9 -> 0x167B68 -> sw +14000 ``` `0x113AA0`/`0x113C60` sit beside `ra_save_alloc_d @ 0x113920` (MovieUpdate). The two roots are index **2** of two sibling vtables sharing one interface layout (base at `0x4faee0` / `0x4f9aa0`, `loadscreen_*` block immediately above them). **Every node on that chain has a dispatch slot** — checked statically, no run. There is no Stage 5.11 hole on this path. **New tool: `build_scripts/eeref.py`.** Static cross-reference engine over the EE image — `refs` / `up` / `down` / `field` / `vtable`. Answers “who calls X”, “who writes offset N”, “does X have a dispatch slot” in seconds instead of a build+run. Two limits are in its docstring and both are real: it sees the static image only (a table built at runtime is invisible), and `field` had to learn `sdl`/`sdr` before it could see the 64-bit store at `+14004` — a too-small opcode table reports “nothing writes this field”, which is the exact false negative the tool exists to prevent. **Priority order from here:** static (seconds) → PCSX2 ground truth (minutes) → our own runtime probe (hours). A probe is now the last resort. ## ★★★★★ 2026-08-20 (a) — **STAGE 5.16 CLOSED, run 69. The completion handshake was never broken — we were poisoning it ourselves.** New blocker (5.17) opened on the streaming stall. Run 69 (`RelWithDebInfo`, 300 s, `det=1`) validated the `ps2_iop.cpp` S-command decode-table fix. All four pre-declared exit tests passed: | test | run 68 | run 69 | |---|---|---| | `[srd] fn=187898 sceCdGetError ret=` | `0x1` | **`0x0`** | | `SRD: Drive Error` lines | 248 | **0** | | SRD status byte (`12ee20 postStat`) | parked at `0x2` | **`0x3` = DONE** | | `130b80 dvcidone cvfsStat` | `0x1` | **`0x3`** | **Root cause.** The cdvdman S-command handler memset the recv buffer and stamped `word[0] = 1` as a blanket "benign success" marker. The libcdvd EE wrappers (`sub_187898`, `sub_187930`, `sub_1879E8`, `sub_187AA0`, `sub_187B98`) all share the tail `v1 = MEMORY[0x20464440]; return v1;` — they return recv `word[0]` **verbatim as the command's value**. So `sceCdGetError` answered `0x1`, and SRD correctly concluded the drive had faulted. The GS, SRD and movie layers were never at fault; neither was the read path, which had been moving real bytes since run 63. Fix: per-fno decode table — fno 4 → `SCECdErNO` (0), fno 12 → `SCECdStatSpin` (`0x02`), fno 1 → genuine success flag (1), unknown → 1 **plus a loud `UNDECODED` census line**. The census is keyed on *distinct fno*, not a flat count cap, so it cannot go quiet the way a capped probe does ([[feedback_capped_probes_false_negatives]]). **Measurement note worth keeping.** `run_log.txt` is **UTF-16LE with a BOM**. Plain `grep` against it returns **zero matches for everything** — a total false negative that looks exactly like "the probe never fired". Decode first: `iconv -f UTF-16LE -t UTF-8 run_log.txt > /tmp/r69.txt`. Preferred path remains `analyze_run.py`, which handles the encoding itself. **Build note.** Cancelling a `cmake --build` mid-flight destroys MSBuild's CL tlog resume state and forces a full unity recompile ([[project_msbuild_unity_parallelism]]). The `iop_harness` / `ps2x_tests` `LNK2019`/`LNK1120` errors that scroll past at the end of `ALL_BUILD` are **normal by construction** ([[project_aux_target_link_failures]]) — they mean the build *finished*, not that it hung. Let it print and exit. ➡️ Next blocker is **Stage 5.17** — see the sub-phase tracker. ## ★★★★★ 2026-08-19 (j) — REAL PCSX2 cross-check says `0x126388`/`0x1263b8` may be the WRONG target entirely; real candidate found: `CAppCRISofdec_*` (0x3f9bd0 family). **Verification interrupted — PCSX2 crashed, needs relaunch.** Session used real PCSX2 (DebugServer, live SLUS-21442) as ground truth, independent of the recomp runtime's own `[adxpump]` probe (entry (i)). User set the 7 known addresses (`0x126388`, `0x1263b8` + others) as manual execution breakpoints. Findings, all measured on real hardware: 1. **Both addresses are correctly identified and well-formed** — live `pcsx2_disassemble` confirms `0x126388` is a thunk (`jal 0x126488` then `jal 0x1263b8`) and `0x1263b8` is the real gated pump body (`lw v0,0x50(s0)`, `beqzl`/`bnezl` on the pending sentinel). Not an address error. 2. **Zero execution hits across ~1.9B cycles**, including one full observed intro/movie loop. Breakpoint mechanism itself is proven working (two unrelated genuine hits landed correctly: a boot/reset pause, and a false positive caused by an IOP thread PC — `0011CCF0` — numerically coinciding with an EE address; EE and IOP are separate address spaces, the IOP hit means nothing here). 3. **`pcsx2_find_pattern` for the raw LE pointer bytes of both addresses (`88 63 12 00`, `B8 63 12 00`) across all of EE RAM (`0x00100000`– `0x02000000`) returned zero matches.** Nothing in live memory references either address as a stored pointer — this argues against even an indirect function-pointer-table dispatch, not just a missing direct `jal`. **⇒ On real hardware, at least within this observation window, `0x126388`/ `0x1263b8` were never reached and are not referenced anywhere in RAM.** This doesn't retract entry (i)'s recomp-side measurement (the pump is still correctly wired and still gets 0 calls there too) — but it reframes the question: this may not be dead-in-recomp-only code, it may be dead code / the wrong target *in general* for this playthrough. **New lead found via funcmap grep (static, not yet dynamically verified):** `build_scripts/funcmap/sdbz_func_map_merged.csv` has a `CAppCRISofdec_*` family — this is CRI **Sofdec** (the actual movie/video codec engine class, distinct from the ADXF audio-only interface the pump chain was assumed to belong to): | Symbol | Address | |---|---| | `CAppCRISofdec_Begin` | `0x003f9bd0` | | `CAppCRISofdec_Tick` | `0x003f9c10` | | `CAppCRISofdec_End` | `0x003f9d50` | | `CAppCRISofdec_Update` | `0x003f9d80` | | `CAppCRISofdec_Tick_clone_01` | `0x003fc250` | | `CAppCRISofdec_Tick_clone_02` | `0x00420780` | | `CAppCRISofdec_Tick_clone_03` | `0x004212b0` | **Notably, `CAppCRISofdec_Tick_clone_02` was already named in stage 5.5.1's notes** (this file, run 2026-07-27) as one of the landmark symbols seen in the watchdog PC trace band around the healthy per-frame loop (`0x421f10`, alongside `CAppCopyRight_0x4206c0`) — i.e. this function's *neighborhood* was already known to be near live per-frame code, independent of this session's work. That was read as an orientation landmark at the time, not investigated as a call target. **Session ended mid-verification: attempted to set fresh breakpoints on the `CAppCRISofdec_*` family via PCSX2 MCP directly (`pcsx2_clear_all_breakpoints` succeeded, first `pcsx2_set_breakpoint` on `0x3f9bd0` succeeded) — then the DebugServer connection dropped mid-batch (`ECONNRESET`), and a reconnect attempt got `ECONNREFUSED` on both DebugServer (21512) and Pine (28011). User confirmed PCSX2 itself crashed/closed, not just the plugin.** **Next step (needs PCSX2 relaunched with DebugServer):** set execution breakpoints on all 7 `CAppCRISofdec_*` addresses above, run through the intro/ movie sequence, and see which (if any) actually fire. If `CAppCRISofdec_Tick` or one of its clones fires while `0x126388`/`0x1263b8` still don't, that confirms Sofdec — not the ADXF pump chain — is the real movie-audio/video driver for this build, and Stage 5.16 should retarget around it instead. ### ★★★★★ CONFIRMED (same session, PCSX2 relaunched, one-breakpoint-at-a-time this time — see below): Sofdec IS the live movie driver. Stage 5.16 as originally framed (`0x126388`/`0x1263b8`) should be RETARGETED, not pursued further. Note on method: batching all 7 `pcsx2_set_breakpoint` calls in one tool round-trip crashed PCSX2's DebugServer **twice in a row** (`ECONNRESET` then `ECONNREFUSED` on both ports, user confirmed the PCSX2 process itself died both times). Setting breakpoints **one at a time** worked cleanly on the third attempt, all 7 armed with no crash. **Do not batch `pcsx2_set_breakpoint` calls against this DebugServer build — one call per round-trip only.** With all 7 armed and the game run from a fresh boot: 1. **First hit, cycle 59,765,856** (very early — well inside the boot-movie window): `CAppCRISofdec_Tick_clone_01` (`0x003fc250`), called from `loadscreen_reset` (`0x003e1280`, funcmap name). 2. **Second hit, cycle 1,393,119,062**: `CAppCRISofdec_Tick_clone_03` (`0x004212b0`), called from `loadscreen_tick` (`0x003e0e60`, funcmap name). 3. **Continued twice more from the clone_03 hit** — it re-hit at cycle 1,398,251,378 (Δ≈5,132,316) then cycle 1,403,130,179 (Δ≈4,878,801). Both deltas match the NTSC EE frame period (~4,919,595 cycles/frame @ 60 Hz) within measurement noise. **This is a genuine per-frame tick, confirmed by two consecutive consistent deltas — not a one-off or coincidental hit.** **Conclusion:** `loadscreen_reset`/`loadscreen_tick` own a `CAppCRISofdec` movie-decoder instance and pump its `Tick` method once per frame during movie/loadscreen playback. This is the real, live, per-frame movie-driving call chain on real hardware. Meanwhile `0x126388`/`0x1263b8` (the ADXF pump) had zero hits and zero RAM pointer references across the *entire* session, both before and after this test (see the entry above). **The two chains are not the same code path, and only one of them is actually alive.** **Recommendation — do not act on this without the user's sign-off, it reframes the stage:** Stage 5.16's blocker definition ("the ADXF pump at `0x126388`/`0x1263b8` never fires") should be retired as the wrong target. The real open question becomes: **does the recomp runtime's own `CAppCRISofdec_*` chain (same 7 addresses, in `runner/`) get called with the same `loadscreen_reset`→`loadscreen_tick` per-frame cadence as real PCSX2 does here?** That is the actual apples-to-apples test for whether the recomp movie path is broken — not `[adxpump]`'s 0-calls reading, which was measuring a chain that apparently isn't the load-bearing one even on real hardware. ### ✅ ACTED ON — `[sofdec]` probe written into `game_overrides.cpp`. **UNBUILT, needs run 65.** Static checks done first, so the run does not have to answer them: - **All nine bodies exist** in `ps2xRuntime/src/runner/` (`CAppCRISofdec_{Begin,Tick, End,Update}_0x3f9*.cpp`, `_Tick_clone_0{1,2,3}_0x{3fc250,420780,4212b0}.cpp`, `loadscreen_{tick,reset}_0x3e{0e60,1280}.cpp`). So this is **not** a Stage 5.13 dispatch hole. - **`loadscreen_tick` has no `jal 0x4212b0`.** Its recompiled body contains four *indirect* dispatches (`0x3e0f34`, `0x3e0fd8`, `0x3e101c`, `0x3e1060`) and two direct ones to unrelated targets. PCSX2's backtrace named `entry=0x3e0e60` as clone_03's caller, so the call is a **JALR through a function pointer** — i.e. through a vtable/fnptr the instance must own. - **Every branch in that body goes through `dispatchGuestBranch`** (checked in the generated source, not assumed), so `replaceFunction` wrappers *do* see them — [[feedback_registerfunction_bypass]] does not apply here. - `fn_forward_decls.h` is **not** perturbed: build.ps1's scan only matches `void fn__0x(uint8_t *rdram`, and the new code adds no such name. No 30 h rebuild. The probe wraps all nine addresses and reports `tag=/` from a **detached 5 s reporter thread**, not from a guest callback — an all-zero result is the most informative outcome here, so it must not depend on guest code running to be printed. `PS2X_SOFDEC=0` disables. Per-site emit cap is 24 with an explicit `[cap]` line ([[feedback_capped_probes_false_negatives]]). **Decision table for run 65 — reads straight off `[sofdec:stat]`:** | Reading | Meaning | Next move | |---|---|---| | `loadscreen_tick.3e0e60=0` | blocker is upstream of the movie code entirely | Sofdec is a red herring; go back up the caller chain | | `loadscreen_tick>0`, all `Tick*=0` | caller reached, **JALR target wrong** — the instance's function pointer was never populated | dump the fnptr the JALR reads; find its writer | | any `Tick*>0` | recomp *does* drive Sofdec per-frame | divergence is *inside* a tick, not in whether one happens — compare against the PCSX2 cadence above | | any `.../0` | that address is not in the dense table | invalidates that row before the run, not after | --- ### ✅ RUN 65 READ (2026-08-19, 306 s, det=1, RelWithDebInfo) — the probe fired; the answer is a **state machine that never leaves its idle arm** `[sofdec:stat]` final totals (`/` — every site installed, so no row is invalid): | site | calls | note | |---|---|---| | `loadscreen_tick.3e0e60` | **1870** | ~34 per 5 s = **6.8 Hz**. Real hardware ticks this at **60 Hz** | | `loadscreen_reset.3e1280` | 3 | a0 = 0x6330d0, 0x632da0, 0x632cf0 — **three distinct instances** | | `Tick_c3.4212b0` | **4** | ra0=0x3e0fe0 ⇒ the JALR at 0x3e0fd8, i.e. the `state==2` arm | | `Tick_c1.3fc250` | 1 | ra0=0x3e12a8, called from `loadscreen_reset` | | `Begin.3f9bd0` `Tick.3f9c10` `End.3f9d50` `Update.3f9d80` `Tick_c2.420780` | **0** | installed, never entered | **None of the four decision-table rows is a clean match — the disassembly explains why the table was mis-framed.** `0x3e0e60` disassembles to a dispatch on `byte[$s0+9]`: ``` 0x3e0e6c lbu $v0, -3514($gp) ; GLOBAL enable byte 0x3e0e70 beqz $v0, 0x3e0fb8 ; <-- if 0 the entire tick is a no-op 0x3e0e88 state = byte[$s0+9] 0x3e0ec8 state==2 -> 0x3e0fd0 -> lw $t9,0($s0); lw $t9,0x38($t9); jalr (= Tick_c3) 0x3e0ed8 state==3 -> 0x3e1014 -> jalr vtable[+0x44] 0x3e0ee8 state==4 -> 0x3e1058 -> jalr 0x3e0ef8 state!=5 -> 0x3e0f84 (exit path) ``` So `Tick_c3=4` does **not** mean the call site is broken — it means the object was in `state==2` on only 4 of 1870 ticks. The other 1866 took an arm we never instrumented, or died at the `$gp-3514` gate before reading the state at all. **Probing callees was the wrong measurement**; the state byte and the resolved `$t9` are the right one. (Same failure mode as `[[feedback_probe_gate_on_shape_not_address]]` — a probe aimed at guessed destinations reports confidently about the wrong thing.) **Two independent facts recovered in the same run:** 1. `[cdsearch]` shows the movie file **is** found: `name="\MOVIE\OKR.SFD;1" outLbn=0x1575ed outSize=0x250000 ret=0x1`. The data path is not the blocker. 2. `[movie]` is frozen for all 298 samples at `stat=1 objSt=1 objFile=0x4597b0 **objSrc=0x0**`. The Sofdec object never gets a source bound. 3. Exactly one `[guest-branch:missing-target]`: `JALR source=0x115334 target=0x0 a1=0x459770`. Disassembly: ``` 0x115320 lw $v0, 192($a0) ; obj->field_0xC0, non-null (branch not taken) 0x11532c lui $v0, 0x44 0x115330 lw $v1, -14008($v0) ; global fnptr @ 0x43c948 0x115334 jalr $v1 ; <-- NULL ``` A **global callback slot at 0x43c948 was never written.** `[adxgate] records=0` in the same run, consistent with the PCSX2 finding that the ADXF pump is dead on this path. ### ✅ RUN 66 READ — the state machine is EXPLAINED, and it is a symptom `[lstick:stat]` records=60, `[sofdec:stat]` records=61, **no `[cap]` for either tag** ⇒ absence is evidence. Final values: | field | value | meaning | |---|---|---| | `gateAddr` / `gate` | `0x5022b6` / **`0x1`** | derived from live `$gp`; the global enable byte is **OPEN**. The tick is not a no-op. | | state histogram | **`4:1972`**, `2:19`, `1:5`, `other:0` | the object is parked in **state 4**, 98.8% of ticks | | `f8` (byte[obj+8]) | `0x0` | `& 2` ⇒ 0, gate **passes** | | `w12` (word[obj+12]) | `0x0` | `& 0x80000000` ⇒ 0, gate **passes** | | `nullcb.115318` | `0/1` | installed correctly (it IS a function start), **0 calls this run** | State-4 body at `0x3e1058` advances `byte[obj+9] = 5` only if ALL THREE hold: ``` 0x3e1058 lw $t9, 0($s0) ; lw $t9, 64($t9) ; vtable[+0x40] 0x3e1060 jalr $t9 (a0 = obj) 0x3e1068 beq $v0, $zero, 0x3e0ef0 ; <-- ret 0 => stay in state 4 0x3e1070 lbu/andi byte[+8] & 2 -> bail ; MEASURED 0, passes 0x3e1080 lw/and word[+12] & 0x80000000 -> bail; MEASURED 0, passes 0x3e1090 sb $v0(=5), 9($s0) ; advance ``` Two of the three gates are measured open. **By elimination, `vtable[+0x40](obj)` returns 0, 1972 times in a row.** No new run was needed to resolve it: the vtables live in initialized data, so slot `+0x40` was read statically out of the ELF. | vtable seen | `+0x38` (st2) | **`+0x40` (st4)** | `+0x44` (st5) | |---|---|---|---| | `0x4fa400` | `0x3fbf70` | `0x3fc0c0` | `0x3e0c20` | | `0x4f99a0` | `0x3e1610` | `0x3e19a0` | `0x3e1c00` | | `0x4faeb0` | `0x420e70` | `0x420fc0` | `0x421130` | | `0x4faf10` | `0x4212b0` | `0x421420` | `0x421540` | | `0x4faf70` | `0x4216e0` | **`0x421830`** | `0x4219a0` | ⚠️ **`Tick_c3=4` in run 65 was a red herring.** `Tick_c3` is `vtable[+0x38]` of ONE object (`0x4faf10`). The vtable rotates across at least five loadscreen objects, so probing a fixed callee address measures object identity, not liveness. Same failure mode as `feedback_probe_gate_on_shape_not_address`. ⚠️ **`0x421830` is a load-screen animation/timer sub-machine** (dispatches on `word[obj+48]`, does float delta-time accumulation against `0x40e0` = 7.0f). It returning 0 means *"the load screen is still waiting"* — which is **correct behaviour**, not a bug. The loadscreen state machine is a **symptom**. Do not sink further runs into it. ### ⭐ THE ACTUAL ANOMALY — a global that nothing writes Unchanged and confirmed across runs 65 and 66: - `[cdsearch]` — `\MOVIE\OKR.SFD;1` found, `outLbn=0x1575ed outSize=0x250000 ret=0x1`. Data path OK. - `[movie]` — **298/298 samples identical**: `objSt=1 objFile=0x4597b0 **objSrc=0x0** objLen=0xfffff`. The file handle binds; **the source never does.** - `[guest-branch:missing-target]` — **exactly 1 record, uncapped**: `JALR source=0x115334 target=0x0 a0=0x175064 a1=0x459770 v0=0x440000 v1=0x0`. Note `a1=0x459770` is `0x40` below `objFile=0x4597b0` — same structure family. Static facts established this session about the slot `0x115330` loads (`0x440000 - 14008 = 0x43C948`): 1. It is **inside the loaded file image** (PT_LOAD vaddr `0x100000`, filesz `0x400680`), not past the end ⇒ **not a loader/BSS gap**. Its initial value in the ELF is a genuine `0x00000000`. 2. A full 4 MB instruction scan for immediate `0xC948` finds **exactly ONE** instruction: `0x115330 lw $v1, -14008($v0)`. **Zero stores.** The IDA decompile dump agrees — one textual hit, the read itself. 3. It sits in a **203-word contiguous zero run** (`0x43C63C .. 0x43C967`) ⇒ a runtime-populated table, written through a **computed base**, which is why no immediate-offset store exists. ➡️ **Working hypothesis:** `0x43C948` is a callback slot in a registration table that is never populated, so the one-shot notify at `0x115334` no-ops, so `objSrc` is never bound, so the movie never starts, so the load screen waits forever in state 4. Every link except the first is measured. ➡️ **The adjudicator is PCSX2, and it is one breakpoint.** Break at `0x115330`, read `$v1`. - `$v1 != 0` on hardware ⇒ a real recomp gap; find the registrar and port it. - `$v1 == 0` on hardware too ⇒ the null JALR is normal game behaviour and this whole thread is cleared; the blocker is upstream in whoever sets `objSrc`. This is cheap and decisive. **Do not spend another 300 s run on the loadscreen state machine.** ### ⭐ STAGE 5.16 REFRAMED — THE MOVIE **OPENS**; IT STALLS IN THE CVFS READ-COMPLETION POLL (2026-08-19) Found by **static analysis of `ida_scripts/decompiles_SLUS_214_42.txt` + re-reading the EXISTING `run_log.txt`**. Zero builds, zero runs. No new instrumentation was needed. #### ⚠️ RETRACTION — the previous framing was wrong "`objSrc` never binds / the file is found but the movie object never gets its source" is **false**. The live log (`run_log.txt`, 2026-08-19 16:50, a run that reached the `.SFD` open) shows both movies opening *successfully*: ``` [moviegate] fn=124dc8 seq=0 base=0x44bf18 ra=0x155a54 name="movie/atari.sfd" [moviegate] fn=124e38 seq=0 ... post.arm45=0x1 post.fptr50=0x1b173a8 post.hnd08=0x0 [moviegate] fn=130ef0 seq=0 ... ret=0x44f278 h24=0xb4000 cache=HIT name="movie/atari.sfd" [moviegate] fn=12cc20 seq=0 ... ret=0x54c060 name="movie/atari.sfd" [moviegate] fn=125898 when=post seq=5 ... b02=0x1 latch49=0x1 hnd08=0x54c060 fptr50=0x1b173a8 ... later, seq=1029/1031: b46/b48 set, then ALL ZERO -> atari.sfd CLOSED normally [moviegate] fn=124dc8 seq=1 ... name="movie/okr.sfd" -> identical successful open, hnd08=0x54c060 ``` `fptr50` **is** populated (`0x1b173a8`), `hnd08` **is** populated (`0x54c060`). The old `objSrc=0x0` reading came from records sampled *before* the arm, not from a failure. #### The decoded CRI chain (all names from the IDA dump) | Addr | Real identity | |---|---| | `0x124E38` | `ADXT_StartFname`-equivalent — sets `[obj+0x50]=fname`, `[obj+0x54]=arg`, **`[obj+0x45]=1`** | | `0x125898` | ADXT stream state machine — on `[+0x45]==1 && [+0x08]==0` calls the open | | `0x12CC20` | **`CVFS_Open`** (error strings `cvfsopen1..6`) — returns the handle `0x54c060` | | `0x130EF0` | **`DVCI_Open`** — `[dvci+1]=1` on cache hit | | `0x125470` | the **per-frame read pump** that `0x125898` calls every tick | | `0x12D0B8` | **`CVFS_ReqRd`** -> device vtable **`+0x20`** | | `0x12D240` | **`CVFS_GetStat`** -> device vtable **`+0x2C`**; returns **3** if handle/slot null | | `0x131190` | **`DVCI_ReqRd`** | | `0x131420` | **`DVCI_GetStat`** — literally `return *(char*)(dvci+2)` | | `0x131338` | **`DVCI_StopTr`** — spins <=655360 iters, then prints `"DVCI: DvciStopTr..."` | | `0x1322F0` | **`DVCI_SetReadMode(m0, m1, m2, syncFlag)`** — writes `byte_44F400/401/402`, **`dword_44F404`** | #### Where it actually stalls `sub_125470` (the pump) is: ```c v5 = CVFS_GetStat(*(int*)(a1 + 8)); // -> DVCI_GetStat -> [dvci+2] if (*(BYTE*)(a1 + 2) == 1) { // [a1+2] == "a read is IN FLIGHT" if (v5 != 1) { // not complete if (v5 == 3) { ...error... } // NOT taken - state never became 4 goto LABEL_23; // <-- WE SIT HERE FOREVER } ... consume buffer, advance ... } ``` `[adx:stream]` confirms it, frozen and unchanging from **t~47 s to t=298 s** (250+ seconds): ``` i=1 raw=0x1010201 act=1 st=2 pau=1 fd=0x54c060 cmd=0x0 bsy=1 f80=0x1b173a8 ``` WARNING: **the probe field named `pau` is mislabelled.** `[obj+2]` is not "paused" — it is **"read request outstanding"**. Rename it; the current name has been actively misleading. #### Prime suspect: `dword_44F404` `DVCI_ReqRd` (`0x131190`) branches on it: ```c if (DVCI_ReqRd issued OK) { if (dword_44F404 == 1) { ...wait, close, ... *(BYTE*)(dvci+2) = 1; } // SYNC -> DONE else { *(BYTE*)(dvci+2) = 2; // ASYNC -> READING return *(int*)(dvci+16); } } ``` **Nothing anywhere in the ELF sets `[dvci+2]` from 2 back to 1.** Only `DVCI_ReqRd`'s *sync* arm does. So if `dword_44F404 != 1`, `DVCI_GetStat` returns 2 forever and the pump can never advance — exactly the observed freeze. NEXT MEASUREMENT (cheap, two independent ways): 1. **Hardware:** read `0x0044F400` (16 B) on PCSX2 while a movie plays. `dword_44F404` is at `0x0044F404`. If hardware has 1 and we have 0, the stage is closed. 2. **Recomp:** wrap `0x1322F0` and log `a1..a4`, and dump `[0x44F404]` in the `[adx:stream]` record. Also add `[dvci+2]` (reached via `[cvfs_handle+4]`) to that record — today we sample the ADXT object but never the DVCI object the pump is actually polling. #### Negative results worth keeping (they eliminate whole classes) - **No dispatch holes on the chain.** `check_trace_addrs.py` over all 15 addresses (`0x1322F0 0x131190 0x131338 0x131420 0x131458 0x131068 0x130EF0 0x12D0B8 0x12D240 0x12D050 0x12CC20 0x125470 0x125898 0x124E38 0x115318`) -> **15 START, 0 holes.** Every body exists and every dispatch slot is populated. This is a *state* bug, not a missing-code bug. - **Guest `printf` IS visible in the log** as `[Deci2Call:kputs]`. Exactly one CRI string ever fired: `DVCI: File cache was not hit. "\MOVIE\ATARI.SFD;1"`. **No** `cvfsopen*`, **no** `cvfsgetstat*`, **no** `E00929*`, **no** `E0211050`, **no** `DvciStopTr` timeout. The stall is silent — CVFS is not erroring, it is patiently waiting. => absence of those strings is now a usable signal, not an unknown. - **`0x115318` is a consequence, not a cause.** With `[obj+2]==1` and `[+0x45]==0`, `sub_125898`'s *first* branch does exactly one thing per tick: call `sub_125470`. Hardware never reaches `0x115318` during boot because on hardware the read *completes* and the stream takes the other path. The null JALR is downstream of the stall (see the `0x43C948` refutation block below). #### Method note Found by **Step 0 of the approved plan** (`~/.claude/plans/frolicking-shimmying-sun.md`): static IDA work + re-reading logs we already had, before spending any build. It cost no runs. The generic env-driven tracer (Step 2) is still worth building, but it was **not** needed to get here — the prior sessions' loop of "guess an address -> hand-write a wrapper -> rebuild" had already collected the evidence; nobody had read it against the decompiled CRI source. --- ### ❌ RETIRED — STAGE 5.16 "SLEEPING CRI SERVER THREAD" (2026-08-19, refuted same day) > **DO NOT REOPEN.** Run 67's coverage census measured `0x130c48` at **727 calls**. The DVCI server tick runs, `0x11e9d8` (the CRI thread body) is entered and loops internally, and 354 reads went out with 354 RPC completions back. **Nothing on this path is asleep.** The chain below is kept only because its *static* call-graph and field offsets are still correct and were re-used by the run-67 analysis — the **conclusion** is dead. See the run-67 block below for what replaced it.
Refuted reasoning (kept for the call graph only) #### (former header) STAGE 5.16 — HARDWARE ADJUDICATED: THE COMPLETION IS DRIVEN BY A SLEEPING CRI SERVER THREAD Measured live on PCSX2 (DebugServer), game at the movie-init point. Supersedes the `dword_44F404` hypothesis in the block above. #### ❌ RETRACTED — `dword_44F404` is **0 on hardware too** Watchpoint `0x0044F400` (write) fired with `PC=0x00132304`, i.e. inside `DVCI_SetReadMode @ 0x1322F0`, one instruction past `sw a3, -0xBFC(v1)`. | evidence | value | |---|---| | `a0 a1 a2 a3` | `0 0 0 0` | | `mem[0x0044F404]` | `00 00 00 00` | | `ra` | `0x00126594` | Caller `0x12658C`: `jal 0x1322F0` with `a3 = *(s1+4)`, `s1 = 0x0054BE40`. `mem[0x54BE40] = {0x004b73a0, 0, 0, 0, 0}` ⇒ `a3 = 0`. ⇒ **Hardware runs the ASYNC arm.** `DVCI_ReqRd` sets `[dvci+2] = 2` (READING) on hardware exactly as it does for us. The sync/async fork is NOT the divergence. The earlier claim "nothing sets `[dvci+2]` back to 1" was wrong — see below. #### ⭐ FOUND — the async completion path ``` sub_130B80 @ 0x130B80 per-object completion check reads [obj+28] (pending request handle); switch on its status: case 3 -> [obj+2] = 1 <-- DONE. advances [obj+20], [obj+12] case 0 -> [obj+2] = 0 case 9 -> [obj+2] = 3 (error) then frees the request, [obj+28] = 0 sub_130C48 @ 0x130C48 DVCI server tick gated on dword_44E710; iterates 40 objects (dword_44E780, stride 72), calls sub_130B80 for each with [obj+0] == 1 ``` #### ⭐⭐ THE DRIVER — a thread that `SleepThread()`s PCSX2 breakpoint on `0x130C48`, backtrace: ``` #0 0x00130C48 DVCI server tick #1 0x0012D1E0 CVFS ExecServer (walks dword_54C1A0[32], indirect-calls each device server) #2 0x0011D708 thin wrapper #3 0x0013C4F8 (entered via tail `j` from 0x13C6B8, a0=4) #4 0x0011E9D8 CRI server thread body sp = 0x004451B0 <-- NOT the main stack (0x01FFExxx) ``` `sub_11E9D8` is a thread loop: ```c while (!qword_4419C8) { ... MEMORY[0x120000E0] = word_441A04; // GS BGCOLOR debug bar noop_wrapper___367(); // -> 0x13C6B8 -> 0x13C4F8 -> ... -> 0x130C48 ... wrap_syscall_stub_z(); // -> 0x00174BC0: li v1,0x32; syscall = SleepThread } ``` `0x00174BC0` = **syscall 0x32 = `SleepThread`**. PCSX2 EE thread list at the movie point: ``` TID 1: PC=0x00174bc8 status=2 waitType=0 (READY -- just woken) TID 4: PC=0x00174bc8 status=4 waitType=1 (WAIT, SLEEP) TID 6: PC=0x00174bc8 status=12 waitType=1 (WAIT|SUSPEND, SLEEP) ``` Three threads parked one instruction past the `SleepThread` syscall. Something calls `WakeupThread` (syscall 0x33 @ `0x00174BD0`) on them every frame. #### ➡️ THE HYPOTHESIS THIS REPLACES IT WITH > If the CRI server thread is never woken in the recomp, `0x130C48` never runs, `[dvci+2]` > stays 2 forever, `CVFS_GetStat` returns 2 forever, and `sub_125470` polls forever. > **That is precisely the observed freeze** — patient waiting, zero error strings. This is a **thread/scheduler** bug, not a CRI-logic bug. It is consistent with every negative result: no CRI error string ever printed, `[adx:stream]` frozen identical for 250+ s, and the det=1 dependence. #### ✅ NEGATIVE RESULT — the chain is fully present in the recomp `check_trace_addrs.py 0x11E9D8 0x13C4FC 0x11D708 0x12D1E0 0x130C48 0x130B80 0x131190 0x131420 0x11D728` -> 8 START, 1 FOLDED, 39 overrides registered. The one FOLDED (`0x13C4FC` inside `sub_13C658_tail0013C4F8 @ 0x13C4F8-0x13C5BC`) is a **false alarm**: `0x13C6B8` tail-jumps to `0x13C4F8`, which is the true entry (`sll v0,a0,3` consumes `a0` immediately). PCSX2's stack walker guessed `0x13C4FC` because that is where the `addiu sp,-0x40` prologue sits. **No dispatch hole.** #### ➡️ NEXT MEASUREMENT — one run, NO BUILD `PS2_COVERAGE=1` is env-armed and already compiled in. The full per-address `COVGAME` dump is gated on `addr >= kGameBandStart` (default `0x00200000`) — **the entire CRI chain is below that**, so the band floor must be lowered: ``` PS2X_DIAG=1 PS2_COVERAGE=1 PS2_COV_GAMELO=100000 (strtoul base 16) launch_recomp.ps1 -Exe -RunSeconds 90 # det=1, do NOT pass -Determinism 0 ``` Then `analyze_run.py --coverage --band game` and look for `0x130c48`, `0x12d1e0`, `0x11e9d8`. - `0x130c48` count **0** ⇒ hypothesis confirmed; the fix is in thread wakeup, not CRI. - `0x130c48` count **> 0** ⇒ the server does run, and the divergence is inside `sub_130B80`'s status switch (`get_field_val_z_109` never returning 3). ⚠️ Coverage counts **table-dispatched calls only**; zero proves "never dispatched", not "never executed". All nine chain addresses are registered (above), so a zero here is strong but should be confirmed by a second det=1 run before hardware time is spent.
### ⭐⭐⭐ RUN 67 (COVERAGE, 300 s) — **SLEEPING-CRI-THREAD HYPOTHESIS REFUTED.** THE WHOLE CHAIN RUNS (2026-08-19) `PS2_COVERAGE=1 PS2_COV_GAMELO=100000`, det=1, 300 s. Run reached the movie stage: `[cdsearch]` found **ATARI.SFD** and **OKR.SFD**, `[movie] wrkAdr=0x1806c00 stat=1` from t=121 s to t=294 s. 2478 distinct game addresses dispatched. Measurement is valid. **Pre-declared decision rule fired the "> 0" arm.** `0x130c48` = **727**. | addr | role | calls | |---|---|---| | `0x125898` | movie state machine | 1440 | | `0x130c48` | **DVCI server tick** | **727** | | `0x12f1e8` | SRD server tick (outer) | 727 | | `0x125470` | CVFS_GetStat poller | 709 | | `0x130b80` | DVCI async completion | 708 | | `0x12ee20` | **SRD state handler** (issues read, completes it) | 707 | | `0x12e950` | `get_field_val_z_109` = `*(char*)(h+2)` CVFS status read | 707 | | `0x131190` | DVCI_ReqRd | 354 | | `0x1875e8` | **`sceCdRead`** | 354 | | `0x186310` | **SIF-RPC end_function** for the read | 354 | | `0x186100` | end_function tail — clears `dword_463274` | 354 | | `0x12ecc0` | **SRD error check** (`sceCdGetError` wrapper) | 354 | | `0x187898` | `sceCdGetError` | 354 | | `0x11e9d8` | CRI server thread body | 2 | ⇒ The CRI server thread **is** being woken. Reads **are** being issued *and* completed (354 in, 354 RPC callbacks out). **Nothing is asleep.** The 2026-08-19 hardware-adjudication hypothesis ("`sub_11E9D8` never woken ⇒ `0x130c48` never ticks") is **dead** — retire it. **Zero-coverage nodes on the path** (all classify `START`, real bodies, so zero is meaningful): | addr | what | why zero is (probably) fine | |---|---|---| | `0x12f008` | SRD *streaming-mode* handler | gated on the SRD mode byte `byte_44D231 == 2`; mode is 1 | | `0x12d1e0` | CVFS ExecServer | second CVFS server, not on this path | | `0x186270` | `sceCdInitEeCB` | ⇒ `dword_463254 = 0` ⇒ no CD callback thread | | `0x1861a0` | CD callback thread body | never created (follows from `0x186270`) | ⚠️ I chased `0x1861a0`/`dword_463274` as "sceCdSync stuck BUSY" and **it does not hold**: with `dword_463254 == 0`, `0x186100` takes the `else` arm and clears `dword_463274` itself. `sceCdSync(1)` therefore returns **0 = DONE**. Recorded so nobody re-walks it. #### ➡️ NEW FRONTIER — the SRD completes into **ERROR**, not DONE Static chain, all confirmed against `ida_scripts/decompiles_SLUS_214_42.txt`: ``` sub_12EE20 (SRD tick, 707) status==1 -> sceCdRead (354) -> status = 2 status==2 -> v9 = sceCdSync(1); r = sub_12ECC0(a1); // 354 if (!v9) *(a1+2) = (r == 1) ? 9 : 3; // 9 = ERROR, 3 = DONE sub_12ECC0 (354) -> v3 = sceCdGetError() (0x187898, 354) v3 == 0 or -1 -> return 0 (ok) v3 == 32 -> return 0 (NOTREADY, retry) else -> printf "SRD: Drive Error (sceCdGetError = 0x%x)" ; return 1 ``` CVFS status 9 -> `sub_130B80` **case 9** -> DVCI `[obj+2] = 3` -> the movie layer retries -> **the same LBN is read again, forever.** That matches the standing symptom exactly, and it matches the 1:1:1 ratio of `sceCdRead` : `end_function` : `sceCdGetError` = 354. The rival reading is `r == 0` -> status 3 (DONE) and the failure is further up (`objSrc` never bound). Both survive the current data. **`sceCdGetError`'s return value is the one number that separates them** and it is not yet measured. **Next measurement (one probe, one build):** log `dword_44D2B4` (SRD's saved `sceCdGetError` result) and the `*(a1+2)` value written at the end of `sub_12EE20`. Decision: value **9** ⇒ error-retry loop, fix is in `sceCdGetError`/cdvdfsv completion status. Value **3** ⇒ reads succeed and the bug is above CVFS, in the movie source binding (`[movie] objSrc=0x0`). --- ### ➡️ ACTED ON — `[srd]` COMPLETION PROBE WRITTEN INTO `game_overrides.cpp`. **UNBUILT, needs run 68.** `ps2xRuntime/src/lib/game_overrides.cpp`, new block `applySdbzSrdProbe` (registered as "SDBZ SRD completion probe"). No headers, no `runner/`, no recompiler. Master switch `PS2X_SRD` — **defaults ON**, `PS2X_SRD=0` disables. Tag is `[srd]`. **Five wrappers, all measured as dispatched in run 67** (so a `0` in the install line is a broken probe, not a quiet guest): | addr | what | run-67 count | |---|---|---| | `0x12EE20` | SRD state handler — **the adjudicator** | 707 | | `0x12ECC0` | SRD error check — returns the ERROR/DONE verdict | 354 | | `0x187898` | `sceCdGetError` — **the decisive number, never yet measured** | 354 | | `0x186BB0` | `sceCdSync` — separates "never completes" from "completes wrong" | 1424 | | `0x130B80` | DVCI async completion — what the movie layer actually sees | 708 | #### The decision rule — pre-declared, three arms `sub_12EE20` ends with `sync = sceCdSync(1); chk = sub_12ECC0(obj); if (!sync) obj[2] = chk ? 9 : 3;` Read the `[srd] stat` line's `syncRet=` / `chkRet=` / `exitStat=` histograms: | observation | verdict | where the fix lives | |---|---|---| | `syncRet` has nonzero entries, `exitStat` stuck at `2` | **A** — the read never completes | `sceCdSync` / `dword_463274` | | `syncRet=0:*`, `chkRet=1:*`, `exitStat=9:*` | **B** — error-retry loop | `sceCdGetError` / cdvdfsv completion status | | `syncRet=0:*`, `chkRet=0:*`, `exitStat=3:*` | **C** — reads succeed | **above CVFS**, in the movie source binding (`[movie] objSrc=0x0`) | ⚠️ **The B/C trap.** `sub_12ECC0`'s entire body is gated on `dword_44D27C`. If that flag is `0`, the check returns `0` *without ever calling `sceCdGetError`* — which is indistinguishable from arm C unless the gate is read. The probe logs it as `gate44d27c=` on every `12ecc0` line. **Do not call arm C without checking that field** ([[feedback_probe_gate_on_shape_not_address]]). Rival-reading field: `raw44d230=` dumps the raw 8 bytes at the SRD object base on every `12ee20` detail line. IDA reads `+1` as the handler selector and `+2` as the status; a one-byte error in that reading would otherwise silently reframe every other field ([[feedback_degenerate_result_convicts_the_probe]]). Caps: `kSrdMaxDetail = 96` detail lines **per site**, each emitting an explicit `[cap]` line. The histograms are uncapped and a `[srd] stat why=shutdown` line is emitted via `atexit` regardless, so absence in the detail stream is never mistaken for absence of the event ([[feedback_capped_probes_false_negatives]]). #### ⚠️ Run-68 window — 300 s MINIMUM The movie system does not initialize until **t≈121 s**. Run 66 was voided by a 90 s window. Anything under ~150 s cannot observe this probe at all ([[feedback_run_window_false_negative]]). #### Run-68 exit test 1. `[srd] wrappers ... 12ee20=1 12ecc0=1 187898=1 186bb0=1 130b80=1 enabled=1` — else the probe is broken and nothing below is evidence. 2. `[srd] stat why=shutdown` present, with `12ee20` in the hundreds. If it reads `0`, the run ended before t=121 s — **void, re-run longer**, do not interpret. 3. Read `syncRet` / `chkRet` / `exitStat` against the three-arm table above. #### Run-68 command (det=1 is mandatory — [[feedback_determinism_gates_guest_progress]]) ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -RunSeconds 300 -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` Then: ```powershell python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag srd ``` ### ⚠️ RUN 66 (COVERAGE) — **VOID: RUN WINDOW ENDED 31 s TOO EARLY** (2026-08-19) `PS2X_DIAG=1 PS2_COVERAGE=1 PS2_COV_GAMELO=100000`, **`-RunSeconds 90`** — my error. **What the census said:** 1485 distinct game-band addresses, 859665 dispatched calls, 9 coverage dumps. Band floor took effect (addresses down to `0x00110000` present). **Zero** dispatches anywhere in `0x00120000`–`0x0013FFFF` — the entire CRI/CVFS/DVCI stack. **Why that proves nothing.** The run never reached the movie stage at all: | tag | run 66 (90 s) | run 65 `20260819-165027` (298 s) | |---|---|---| | `[cdsearch]` | **records=0** | 4 pairs, `ATARI.SFD` then `OKR.SFD` | | `[movie] wrkAdr` | `0x0` for all 89 records | `0x1806c00` **first at t=121 s** | | `[adx:stream]` | `nonzero=0/40` | streams live | **The movie system does not initialise until t≈121 s.** A 90 s window ends 31 s before the first event on the path under test. This is exactly `[[feedback_run_window_false_negative]]` firing again — the zero-coverage result is indistinguishable from "never happens" and must not be read as evidence for or against the sleeping-server-thread hypothesis. ✅ **Salvage:** the mechanism is proven working. `PS2_COV_GAMELO=100000` correctly lowers the `COVGAME` band floor (default `0x00200000` would have hidden the whole CRI range), and coverage needs no build. Re-run only needs a longer window. ➡️ **REDO with `-RunSeconds 300`.** Decision rule unchanged: - `0x130c48` count **0** ⇒ sleeping-CRI-server-thread hypothesis confirmed; fix is in wakeup. - `0x130c48` count **> 0** ⇒ server runs; divergence is inside `sub_130B80`'s status switch. ### ✅ PCSX2 HARDWARE ADJUDICATION — `0x43C948` HYPOTHESIS **REFUTED** (2026-08-19) The working hypothesis was: global `0x43C948` is a callback slot that hardware populates and we don't, so the one-shot notify at `0x115334` no-ops, `objSrc` never binds, the movie never starts. Measured on real PCSX2 (SLUS-21442), breakpoint armed across a full reset + boot through the Atari logo: | Check | Hardware result | |---|---| | Code at `0x115318`–`0x115344` | **Byte-identical to the ELF** — no overlay, no runtime patching | | `0x43C948` + its 203-word zero run | **All `0x00000000`**, before reset, after reset, and after the logo plays | | Write watchpoint on `0x43C948` | Fired **once**, inside the BIOS zero-fill loop at `0x8000e3c8` (`sq v0,(s0)`, `v0=0`) — an ELF-load/BSS artifact, **not** a registrar | | Full 4 MB scan for stores to `0x43C948` | **Zero stores**; exactly one `lw` (the one at `0x115330`) | | Exec BP at `0x115334` (the null JALR) | **NEVER FIRED** — including through the Atari logo | | Control BP at `0x17ED60` | Fired **instantly** ⇒ the trap mechanism is proven working | | Game health while armed | vsync spin on `INTC_STAT` bit 2 (VBLANK_START) at `0x175220`; PC later at `0x00081fc0` ⇒ running normally | ⭐ **Conclusion.** `0x43C948` is zero on hardware too, and hardware **never reaches `0x115318` during boot at all**. So the recomp's single `[guest-branch:missing-target]` (`JALR source=0x115334 target=0x0`) is **the recomp taking a path hardware does not take** — it is not a missing registrar we failed to port. Nothing to implement here. ⚠️ **Do not re-open this.** The exec-BP silence is evidence, not a broken trap: the `0x17ED60` control fired immediately in the same session ([[feedback_capped_probes_false_negatives]] discipline applied — the absence was proven meaningful before being used). ➡️ **Blocker moves upstream.** The question is no longer "who writes `0x43C948`" but **"who is supposed to set `objSrc`"** — `[movie]` is 298/298 identical with `objSt=1 objFile=0x4597b0 objSrc=0x0 objLen=0xfffff`, while `[cdsearch]` already resolves `\MOVIE\OKR.SFD;1` (`outLbn=0x1575ed outSize=0x250000 ret=0x1`). The file is found; the movie object never gets its source bound. ⚠️ **Secondary finding — why the recomp even lands in `0x115318`.** Hardware doesn't execute it during boot, so *something earlier* diverges. That divergence, not the null call, is the next thing to trace. **PCSX2 side-facts worth keeping:** - The PCSX2 **cycle counter wraps at 2^32** (observed 3952631813 → 2327157694 → 566052198). It is **not** usable as an elapsed-guest-time measure. - `temporary: true` breakpoints do **not** auto-delete — remove them explicitly or they linger. - A cleared watchpoint can stay live inside PCSX2's core even after `list_breakpoints` stops showing it, making `continue` a silent no-op. `clear_all_breakpoints` then re-arm one BP. - `0x459770` and `0x4597b0` are two adjacent 0x40-byte structs each ending in a **self-pointer** at `+0x3C` — heap/memory-pool descriptors, not movie objects. - `0x5e6b3c` (GameMode) reads `0x00` on hardware, but that is **indistinguishable from uninitialized RAM** — do not use it alone as a boot-success indicator. ### ➡️ ACTED ON — `[lstick]` probe added to the same block in `game_overrides.cpp`. **UNBUILT, needs run 66.** Extends `sofdecWrapper` with a `sofdecCapture()` that, on every `loadscreen_tick` entry, records: - `gateAddr` / `gate` — the `$gp-3514` byte, **derived from the live `$gp`** so a bad `$gp` shows up as a bad `gateAddr` rather than a confident wrong byte - `st=:,…` — a **histogram** of `byte[a0+9]` over all 1870+ ticks - `f8` `b18` `b19` `w12` — the other branch inputs (`byte+8 & 2`, the two `0x33` compares, the `0x80000000` bit in `word+12`) - `vt` / `vt38` / `vt44` — the vtable pointer and the two arm targets **as actually resolved** - new site `nullcb.115318` → `cbSlotAddr` / `cbSlot` (the 0x43c948 fnptr) and `cbA0C0` Emitted as its own `[lstick:stat]` tag on the same 5 s reporter thread. **Run 66 decision table:** | `[lstick:stat]` reading | Meaning | Next move | |---|---|---| | `gate=0x0` | one global byte disables the whole movie tick | find its writer; that is the blocker, full stop | | `st=` dominated by 0 or 1 | object never promoted past init | the promoter is `state==1 → 2` at 0x3e0e98; check `f8 & 2` and the `w12` bit-31 | | `st=` dominated by 3/4/5 | it *is* advancing; arms at 0x3e101c/0x3e1060 are the live ones | probe `vt44` target, not `Tick_c3` | | `vt38`/`vt44` = 0 or absurd | vtable never populated | find the ctor that writes `word[obj+0]` | | `cbSlot=0x0` | confirms the 0x43c948 global callback is unwritten | find its writer — likely the same init that binds `objSrc` | ⚠️ **`gate` / `f8` / `w12` / `vt*` are LAST-VALUE, not histograms.** They describe the most recent tick only. Read them together with `st=`; a single sample cannot tell "always 0" from "0 right now" ([[feedback_probe_the_final_value]]). ## ★★★★★ 2026-08-18 (i) — Run 64 read: `fn=1263b8`/`126388` (the ADX/movie pump) had **zero calls**, confirming it (not the RPC data path) is Stage 5.16's blocker. Traced its only static caller and added a matching probe pair; **UNBUILT, needs run 65**. Session `60eb8a71` (interrupted mid-trace by a VS Code Claude extension slowdown, see [reference_session_transcript_archive]) read run 64's exit test: - `sfdhd`/`ncmdnew` items were answered (see entry (h) for the setup). - `[adxpump]` (`fn=1263b8`/`126388`) — **records=0, calls=0** — despite the wrapper self-reporting `126388=1 1263b8=1` (i.e. correctly installed and ready to fire). The pump is wired right; it is simply never reached. Picked back up in this (CCD, not VS Code) session. Statically traced the pump's only caller: `sub_11CC28` (`ida_scripts/decompiles_SLUS_214_42.txt:21881`), found via its recompiled symbol name `noop_wrapper_unk_r`/`noop_wrapper___22` since address-grep for callers of `0x126388` found nothing (it's called indirectly, same as several other links already proven to fire in this subsystem — see the 124dc8 record in run 64's log). `sub_11CC28`'s own callers are likewise invisible to a static grep (0 xrefs by symbol name) — consistent with a CRI ADX callback-table dispatch, not a direct JAL. `sub_11CC28(a1, a2)` gates the pump call on a THIRD field, independent of the movie-gate struct (`+0x08`/`+0x50`) already tracked: ``` if ( (unsigned)*(int*)(a1 + 16) > 0x7FFFF9FFu ) { // "pending" sentinel noop_wrapper___152(*(int*)(a1 + 4)); // -> 0x126388 -> pump ... } else { return *(int*)(a1 + 16); // cached, pump skipped } ``` `a1` is a distinct ADX-file object (error strings on this call chain read "...ADXF...", i.e. CRI ADX File interface) — `*(a1+16)` is its status word, and `*(a1+4)` is the movie-gate base pointer the pump already expects. 0x7FFFF9FF (2147481599) means the pump only runs while the status reads as a small negative number (-1600..-1 signed) — "still pending". Everything else (status 0, a positive code, or -1601 and below) skips the pump. ### Probe added — `ps2xRuntime/src/lib/game_overrides.cpp` (UNBUILT) New wrapper pair `0x0011CBF0` (outer thunk) / `0x0011CC28` (the gate check itself), riding the same `PS2X_MOVIEGATE` master switch plus its own `PS2X_ADXGATE` opt-out — mirrors the existing `cdSearchEnabled()` pattern. Emits `[adxgate] fn=11cc28 ... preStatus=.. pumpBase=.. ret=.. postStatus=.. gate=PASS|blocked`. Registered inside `applySdbzMovieGate`; status line now also reports `11cbf0=` / `11cc28=` installed flags. ### Run-65 exit test 1. **`adxgate` tag, calls=0** → `sub_11CC28` itself is never invoked; the blocker is one level further back than this probe reaches (nothing ever asks for ADX status at all — likely need to trace ITS caller next, which is indirect/vtable-driven per the static dump). 2. **calls>0, `gate=blocked` on every record** → the status word never lands in the pending range, so the pump is skipped by design every time. Cross check `preStatus` against Stage 5.16's retry-loop finding (entry (h)) — plausible that the stuck `sceCdRead` retry never lets this field reach "pending". 3. **calls>0, `gate=PASS` present but `pump1263b8.calls` (from the existing moviegate wrapper) stays 0** → `pumpBase` (`a1+4`) is not the address we think it is; re-derive the struct offset. 4. Run with `python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag adxgate` — generic `--tag`, no analyzer changes needed. ### Run-65 RESULT: branch 1 — `adxgate records=0 shown=0`, no `[cap]` Both `11cbf0` (outer thunk) AND `11cc28` (the gate itself) fired zero times — not just `sub_11CC28`. Confirmed this isn't a `registerFunction`-bypass artifact ([[feedback_registerfunction_bypass]]): `sub_11CC28` has zero static JAL xrefs in the decompile dump, so there's no direct-call path that could skip our table-patched wrapper; every real invocation would have to be a JALR through `lookupFunction`, which we hook. Absence is real. Traced further back. Static analysis is now **fully exhausted** for this whole block: - Symbol-name search across `decompiles_SLUS_214_42.txt` for `sub_11CC28`/ `sub_11CBF0`: zero hits besides their own definitions. - Raw ELF literal search (`ELF/SLUS_214.42`, parsed via program headers, vaddr→file-offset mapped and sanity-checked against the known function prologue `27bdfff0`): the 4-byte LE words `28 CC 11 00` and `F0 CB 11 00` appear **nowhere** in the file. No static vtable/jump-table literal exists — the call target (if it ever fires) must be constructed at runtime (e.g. split LUI/ORI immediate), which only live tracing can resolve. ### Probe added (run 66, UNBUILT) — three neighbor wrappers `0x11C9F8` / `0x11CBB0` / `0x11CCF0` are the physically-adjacent real functions in the same code block (`sdbz_func_map_merged.csv` lines 438-450; IDA fills the gaps between them with `noop_wrapper___` stubs). None has a static xref either. Each logs `seq`/`a0`/`a1`/`ra` under tag `[adxneighbor]`, capped at 16 records/fn, gated by the same `adxGateEnabled()` switch (`PS2X_ADXGATE=0` disables these too). Registered in `applySdbzMovieGate`; status line now also reports `11c9f8=`/`11cbb0=`/`11ccf0=` installed flags. ### Run-66 exit test 1. **All three `calls=0`** → this whole code block is unreached in the 90s window; the ADX status-check chain is dead further upstream than static analysis can see at all. Escalate to live tracing (PCSX2/recomp debugger with the game running) — static tooling has nothing left to offer here. 2. **Any one fires** → read its `ra` field directly off the first record: that address is the real caller, the next probe wraps whatever function contains it. 3. Run with `python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag adxneighbor`. **Run-66 RESULT: branch 1.** `adxneighbor records=0 shown=0`, no `[cap]` — all three sibling probes (`11c9f8`/`11cbb0`/`11ccf0`) also never fired. The whole 0x11C9F8-0x11CCF0 block is unreached in the 90s window, not just the one gate function. Static analysis is now fully exhausted for this code path (no symbol xref, no ELF literal, no live neighbor hit). **Before escalating to live tracing — one cheap variable was never controlled.** Every run this session (63/64/65/66) used `-Determinism 0`. [feedback_determinism_gates_guest_progress] documents det=1 as *required* for reliable guest progress at Stage 5.15+ (5/5 runs progress at det=1, 0/4 at det=0) — measured on this exact game, this exact stage range. It is live and was never re-checked before handing off runs 63-66. The `calls=0` readings across three independent probes could mean "this code path is truly dead" OR "the guest never got far enough under det=0 to reach it." These are indistinguishable from the log alone. ### Run-67 exit test (det=1 re-run, same adxneighbor probe, before any live-tracing spend) 1. **Still all `calls=0`** → determinism is cleared as a variable; the block really is unreached by this call path. Escalate to live tracing next (PCSX2/recomp debugger, breakpoint on the block's address range or its probable caller). 2. **Any one fires under det=1** → the det=0 runs were false negatives all along (matches the established pattern from Stage 5.15). Read `ra` off the first record, same as branch 2 above. 3. Run: `& "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 1 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe"` then `python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag adxneighbor`. **Run-67 RESULT: branch 1 — still `adxneighbor records=0 shown=0` under det=1.** Determinism is cleared as a variable; this is not a "guest never got far enough" artifact. **Static analysis closed out completely** (ruling out the remaining theoretical gap before declaring it exhausted): the ELF has exactly 2 PT_LOAD segments — the first (`offset=0x80 vaddr=0x100000 filesz=0x400680`, already fully scanned) and a second that is empty (`filesz=0 memsz=0`), so nothing was missed. Re-ran the raw-literal search across all 5 target addresses (not just the original 2) — zero matches, all five. Additionally searched the whole code segment for the `lui`+`ori`/`addiu` split-immediate idiom (the standard way to materialize an absolute address into a register before an indirect call) for hi16 ∈ {0x11,0x12} paired with any of the 5 target low16 halves within a 40-instruction window — 26 `lui` candidates, **zero** completing pairs. Conclusion: none of these 5 addresses exist as a literal ANYWHERE in the ELF (code or data), constructed or otherwise, and none is ever called live under two independent determinism settings. This is no longer a static-tooling-blind-spot question — it needs a real dynamic trace. Static analysis has nothing further to offer this thread. ### Next: live trace via PCSX2 MCP (user pointed out PCSX2 has an MCP to connect to) Real PCSX2 (not `ps2EntryRunner`) is the right target for this specific question — "does the real game, under accurate PS2 hardware emulation, ever execute this address at all" is exactly what a dynamic breakpoint can answer regardless of how indirectly the target is computed, which is precisely what static analysis is blind to. Per [[feedback_mcp_servers_disabled_by_default]] the `pcsx2` MCP server is off by default and must be enabled only after PCSX2 itself (with DebugServer enabled, SDBZ loaded) is already running — asked user for setup status before attempting to connect. Per [[feedback_verify_pcsx2_target]], confirm which target (real PCSX2 vs ps2EntryRunner) any address readings come from before trusting them. --- ## ★★★★★ 2026-08-17 (h) — **`sceCdRead` IS SERVED (run 63): `ncmdrd=24`, `readOk=1`, `rpcmiss` 27→3.** But the guest **retries the identical read forever**, so Stage 5.16 is NOT closed. The blocker moved from "no data path" to "the completion handshake is not accepted." ### 1. What run 63 proved The `kIopSidCdvdNcmd` handler works. Every `fno=1` was served: ``` [iop:ncmd] fno=1 handled=1 lbn=1406084 sectors=360 buf=0x01868A40 secSize=2048 readOk=1 send=0x00463340/24 recv=0x00000000/0 [iop:ncmd] fno=14 handled=1 ... recv=0x004632C0/4 ``` `lbn=1406084` / `sectors=360` (= `0xB4000` bytes) is byte-exact against our own `[cdsearch] sendLbn=0x157484 sendSize=0xb4000` for `\MOVIE\ATARI.SFD;1`. The `0x80000595` misses vanished from the census (`rpcmiss` 27 → 3; only `0x80000592` and `0x80000006` remain). ### 2. What run 63 did NOT fix — and the number that says so | col | run 62 | run 63 | |---|---|---| | ncmd / ncmdrd | 0 / 0 | **48 / 24** | | rpcmiss | 27 | **3** | | maxst | 2 | **2 (unchanged)** | | `fn=126388` / `fn=1263b8` | 0 / 0 | **0 / 0 (unchanged)** | `[adx:stream]` slot `i=1` still ends `st=2 pau=1 bsy=1 cmd=0`, and both ADX pump wrappers are still at **zero calls**. Data arriving did not start the decoder. ### 3. ⚠️ The decisive detail: it is a RETRY LOOP, not a stream All 48 N-commands fell between **t=126 s and t=132 s** — a 6-second burst — and every single `fno=1` asked for **the same `lbn=1406084 sectors=360`**: the entire 720 KB file, re-read into the same buffer, ~4×/s. A stream would advance the LBN. ⚠️ And then the flat 48-record cap fired, blinding the probe for the remaining **106 s**. So "the LBN never changed" was *unprovable* exactly where it mattered — a textbook instance of [[feedback_capped_probes_false_negatives]]. Fixed in §4. ### 4. Probes added for run 64 (both in `ps2_iop.cpp`, no header touched) 1. **`dstHead`** — the first 8 bytes actually written into the guest buffer. `readOk=1` only means the reader returned true; a `.SFD` is an MPEG program stream, so a healthy head is `00 00 01 BA` ⇒ printed low half `BA010000`. New `sfdhd` column in `analyze_run.py` counts exactly that. 2. **`desc`/`descW0` (send+0x10) and `res`/`resWord` (send+0x14)** — `sub_1875E8` zeroes `dword_464400` and cache-writebacks it immediately before the call, i.e. it is a word it expects *somebody* to fill in. We never write it. Prime suspect for the retry; now measured instead of assumed. 3. **Cap replaced by a change-census** — always print while `(fno,lbn,sectors)` is NEW, then only a 1/2/4/8… heartbeat on repeats, with **per-fno slots** (the guest interleaves `14,1,14,1`, so a single slot would call every record new and suppress nothing). A retry loop now collapses to a few lines; a change of target can never be hidden. ### 5. Run-64 exit test 1. `sfdhd > 0` — if **0** with `readOk=1`, `ps2_iop_cdReadSectors` is returning true while writing nothing, and the bug is in the ISO reader, not the guest. 2. `ncmdnew` — if it stays at 2 (one read target + one status), the guest never advances and the completion handshake is the blocker. 3. `resWord` — if it reads `0x00000000` on every retry, writing a completion code there is the next fix to try. 4. `fn=1263b8` — the real pass/fail; still the gate on the ADX pump. ### 6. Parked, unchanged The single `[hole] target=0x0 source=0x115334` is a retail null (`0x43C948` is zero in the ELF and never stored to). Not a func-map hole. `holes=1` is a true count — no `[cap] tag=hole`. --- ## ★★★★★ 2026-08-17 (g) — **`0x186310` CONFIRMED FIXED. The game now opens TWO movies.** Run 62 names the next blocker with no ambiguity left: **cdvdfsv's N-command RPC (sid `0x80000595`) is unserved, so `sceCdRead` never moves a byte.** Run 62 (`run_log.txt`, 4,774,106 B, 237 s, `PS2X_DETERMINISM=1`). Every item of the run-62 exit test from entry (f) §6 was answered. ### 1. The `0x186310` fix works, and the decompile confirms the reading was right `[rpcend] fn=186310` fired **24 times** (its own cap) — `packet=0x464340 ra=0x1785d4`. Independent confirmation came from `sub_1875E8` in the IDA dump, which is the EE-side `sceCdRead` and passes `0x186310` **by name** as its `end_function`: ``` sceSifCallRpc(client=dword_464410, fno=1, mode=1 /*async*/, send=&dword_463340, ssz=24, recv=0, rsz=0, end_function=obj_set_fields___07 /* == 0x186310 */, end_arg=dword_464340) ``` `cache_writeback_range(dword_464340, 144)` in that same function matches the override's `0x10 + 0x40 + 0x40 == 0x90 == 144` copy-descriptor layout exactly. The layout was **measured, not assumed**, and it holds. Every `[rpcend]` reported `n1=0 n2=0 copied1=0 copied2=0`. That is not a failure: for this call the guest registers no copy-back entries, so the end_function's only job is the tail call to `sub_186100` with `a0=0x4632a0`. Which it does. ### 2. Forward progress that run 61 could not reach | Signal | run 61 | run 62 | |---|---|---| | `mvopen` | 1 | **2** | | `cdsrch` | 2 | **4** | | second movie | — | **`movie/okr.sfd`**, lbn `0x1575ed`, size `0x250000` | | `[movie]` work area | zeros | `wrkAdr=0x1806c00 wrkSiz=0x332100 obj=0x45f6e4 crt2=0x500730 objSt=1` | The game opened `ATARI.SFD` (handle `0x54c060`, `latch49=1`), ran it out, **released it** (`seq=1031 latch49=0 hnd08=0`), then opened `OKR.SFD` and latched that. It is walking its own movie sequence now instead of stalling on the first entry. ### 3. THE BLOCKER — `sid=0x80000595` (cdvdfsv N-command) is unserved ``` [sif:rpc-miss:new-sid] first unserved call to sid=0x80000595 at total=239 [sif:rpc-miss] sid=0x80000595 func=0x1 send=0x463340 ssz=0x18 recv=0x0 rsz=0x0 s[0]=0x157484 s[1]=0x168 s[2]=0x1868a40 s[3]=0x0 [sif:rpc-miss] sid=0x80000595 func=0xe send=0x0 ssz=0x0 recv=0x4632c0 rsz=0x4 (x12, capped) ``` Decoded against `sub_1875E8`'s own stores: - `s[0] = 0x157484 = 1406084` — **the exact LBN our own SearchFile handed back** for `\MOVIE\ATARI.SFD;1`. - `s[1] = 0x168 = 360` sectors × 2048 = **737,280 bytes = the exact size we reported.** - `s[2] = 0x1868a40` — the EE destination buffer. - `s[3] = 0` — mode; `mode[2]==0` ⇒ 2048-byte stride (1 ⇒ 2328, 2 ⇒ 2340, from the guest's own arithmetic). **The guest is asking us to read back the file we just told it about, and nothing answers.** `rsz=0` means there is no reply buffer — completion runs purely through `end_function` `0x186310`, which is why `[rpcend]` fired 24 times over an empty buffer. The completion path was already right; there was no DATA behind it. `func=0xe` is the status poll, from `sub_186B18`: `sceSifCallRpc(client, 14, 0 /*sync*/, 0,0, recv=dword_4632C0, 4)` then `return MEMORY[0x204632C0]`. Callers gate on `!= 6`, and `sub_1875E8` refuses to issue the read at all when it reads 6. Unserved, that word keeps whatever was already there. ⚠️ **`arkdSup` does NOT apply here.** The suppression window in `SIF.cpp:638` is `boundSid >= 0x500 && <= 0x503`. `0x80000595` is outside it, so `arkdSup=228` is just a running global counter riding the line — these misses are **genuinely unserved**. Do not re-read that field as "ARKD handled it". ### 4. The fix (written, UNBUILT) — `ps2_iop.cpp` New `kIopSidCdvdNcmd = 0x80000595` handler, placed immediately after the SearchFile block: - **fno 1 (`sceCdRead`)** — decodes `lbn/sectors/buf/mode` from the 24-byte send struct and calls `ps2_iop_cdReadSectors` (declared `extern`; defined in `CD.cpp`, the one TU that owns the live CD table). **Real ISO bytes** — same reader that backs every other CD path, and the same resolver that produced the LBN, so search and read cannot disagree. - **fno 14 (status)** — writes `0x02` (SPIN / ready). Deliberately **not** `6`, the one value the guest treats as "do not read". - **Any other fno falls THROUGH to the rpc-miss census.** Claiming the packet unconditionally would make the next blocker invisible the way this one was. `[[feedback_no_iop_faking]]` does not apply, for the reason already on the SearchFile handler: cdvdfsv lives in the **IOP BIOS image, not on the disc**, so there is no IRX to run in the R3000. ### 5. The remaining dispatch hole is NOT a func-map hole The new `[hole]` census reported exactly **one** distinct target all run, and `0x186310` was not it: ``` [hole] n=0 target=0x0 source=0x115334 ra=0x11533c op=JALR kind=IndirectCall codeRegion=no ``` `sub_115318` is `if (*(a1+192)) return dword_43C948(*(a1+192));`. `0x43C948` is **zero in the ELF image and never stored to anywhere in the main ELF** (verified two ways: an `imm==-14008` opcode scan and a full lui/addiu/ori value-tracking scan — both returned 0 hits). So the null pointer is the *retail* state, and the anomaly is that `*(a1+192)` is non-zero on our side. **Parked** — the run made progress straight through it. Do not chase it as a missing body; it is not one. ### 6. Run-63 exit test 1. `[iop:ncmd] fno=1 handled=1 ... readOk=1` present, with `lbn=1406084 sectors=360`. 2. `ncmdrd` column > 0 in `analyze_run.py --runs`. 3. `[adx:stream]` slot `i=1` leaves `st=2 pau=1 bsy=1` — **this is the actual pass/fail.** 4. `fn=126388` / `fn=1263b8` leave 0. Both were 0 in runs 61 **and** 62; if they are still 0 while `readOk=1`, the data arrived and the pump is gated on something else again. 5. Read the **whole** `[hole]` set, not the first line. ### 7. Other unserved services seen in run 62 (not yet blocking) - `sid=0x80000592 func=0x0 send/4 recv=0x464440/0x10` — cdvdfsv INIT, one call at `total=1`. - `sid=0x80000006 func=0x0 ssz=0x200 rsz=0x8`, send words spell **`"cdrom0:\"`** — FILEIO open. Two calls. Likely the `DVCI: File cache was not hit` fallback path. ### 8. Run-63 command ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" ` -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ` -RunSeconds 240 ``` `ps2_iop.cpp` + `analyze_run.py` changed. No `-Determinism 0`. --- ## ★★★★★ 2026-08-17 (f) — **STAGE 5.15 CLOSED. `MOVIE/ATARI.SFD` IS OPEN.** Run 61 proved the `sceCdSearchFile` fix end to end, and named the next blocker: a func-map hole at `0x186310`. Run 61 (`run_log.txt`, 6,727,776 B, launched 17:44:19, 240 s, `PS2X_DETERMINISM=1`, `PS2X_MOVIEGATE` cleared). The chain that has been open since 08-15 completed. ### 1. The whole `.SFD` chain now succeeds — every link measured, none inferred | link | run-61 record | verdict | |---|---|---| | IOP serves the RPC | `[iop:cdsearch] name="\MOVIE\ATARI.SFD;1" found=1 lbn=1406084 size=737280` | ✅ | | EE `sceCdSearchFile` returns | `[cdsearch] fn=1866a0 ret=0x1 recv840=0x1 outLbn=0x157484 outSize=0xb4000` | ✅ | | irq thunk passes it up | `[cdsearch] fn=130ad8 ret=0x1` | ✅ | | the open returns a handle | `[moviegate] fn=130ef0 ret=0x44f278 h24=0xb4000 h04=0xb4000 cache=HIT` | ✅ **first ever non-null** | | the player takes it | `[moviegate] fn=12cc20 ret=0x54c060` | ✅ | | the handle is latched | `[moviegate] fn=125898 post seq=5 latch49=0x1 hnd08=0x54c060 b02=0x1` | ✅ | | the stream slot arms | `[adx:stream] i=1 act=1 st=2 pau=1 fd=0x54c060 bsy=1 f80=0x1b173a8` | ✅ | `0x157484` = 1406084 and `0xb4000` = 737280 — the lbn and size travel intact from the ISO through the RPC into the guest's handle. The four-step exit test written into entry (e) §6 passes on steps 1–3. **`sceCdSearchFile` (sid `0x80000597`) served from `ps2_iop.cpp` is CORRECT and STAYS.** ### 2. Step 4 fails, and the reason is a dispatch hole — not the movie code The stream slot then sits at `st=2 pau=1 bsy=1 cmd=0`, **unchanged from t=129 s to t=238 s**. Both pump wrappers (`fn=126388`, `fn=1263b8`) show **0 calls**. The one-shot record names the cause: ``` [guest-branch:missing-target] kind=IndirectCall op=JALR source=0x1785cc target=0x186310 pc=0x186310 ra=0x1785d4 v1=0x8000000a ``` - `sub_178560` is the EE **SIF-RPC completion dispatcher**. `v1=0x8000000a` is `RPC_CALL_END`; it takes the client-data pointer from `packet[7]` and JALRs `client[7]` — the caller's **`end_function`** — with `client[8]` as its argument. - libcdvd registers `0x186310` there. - `0x186310` is a **func-map GAP**: `sub_186270` ends at `0x18630c`, the next mapped row is `sub_1863C8`. 0xb8 bytes with no dispatch slot. Stage 5.13 class exactly. So every async libcdvd request completed on the IOP side and **never signalled the EE**. `bsy=1` forever is the correct behaviour of a correct guest waiting on a callback that has no body. ### 3. Implemented for run 62 **`sdbzCdvdRpcEnd186310`** in `game_overrides.cpp`, registered at `0x0018630C` and `0x00186310`. Disassembled from the ELF, not read off the decompile: two byte-wise copy-outs from an inline payload in the RPC packet (count `+0x00` → dst `+0x08`, payload `+0x10`; count `+0x04` → dst `+0x0c`, payload `+0x50`), each store issued to both the uncached mirror and the cached alias, each count **reloaded every iteration** as the original does. Ends in a **tail call** (`j`, not `jal`) to `sub_186100` with `$a0 = 0x004632A0`, reached via `dispatchGuestBranch` because `sub_186100` is mapped and recompiled — it is not reimplemented. Full disassembly is quoted in the comment block above the function. ### 4. ⚠️ Probe defect fixed: `missing-target` could only ever name ONE hole per run `PS2Runtime::reportMissingFunction` gates its dump on `m_missingFunctionReported`, a **single global bool for the whole process** (`ps2_runtime.cpp:1645`). Once any hole fires, every later hole is silent. Run 61's "only one missing target" therefore meant *"the probe already fired"*, not *"there is one hole"* — and anything downstream of `0x186310` was invisible by construction. Added a **per-distinct-target census** in the same function: one short `[hole] n=` line per new target, capped at 64 with a self-announcing `[cap] tag=hole`. The heavy GPR/stack/trace dump stays one-shot. `analyze_run.py` gains a **`holes`** column; read that, not `missing-target`. ### 5. New probe: `[rpcend] fn=186310` A hole fix that changes nothing is indistinguishable from a hole fix that never ran. The new body logs its first 24 completions (`packet`, `n1/dst1`, `n2/dst2`, `copied1/copied2`) and announces its own cap. New `analyze_run.py` column: **`rpcend`**. ### 6. Exit test for run 62 1. `[rpcend] fn=186310 seq=0 …` present ⇒ the callback body runs at all. 2. `[hole] n=…` lines — **read the whole set now**, this is the first run that can show more than one. Any new target on the CD/movie path is the next item. 3. `[adx:stream]` slot i=1 leaves `bsy=1 cmd=0` — i.e. `cmd` or `pau` changes, or `maxst` moves past 2. 4. `[moviegate] fn=1263b8` / `fn=126388` call counts leave **0**. Those are the ADX pump; if they stay 0 while `[rpcend]` fires, the blocker is upstream of the pump and *not* the RPC completion. If (1) is absent, the registration did not take — check the `[moviegate] wrappers` banner exists and the exe timestamp postdates the edit before reading anything else. ### 7. Run-61 error scan — nothing else actionable `dispatch-miss` 0 · guest `E0xxxxxx` 0. Five `[sif:rpc-miss]` records, unserved sids `0x80000592` (cdvdfsv N-cmd, one call at init), `0x80000006` (FILEIO) and `0x80000595` (`arkdSup=228` — ARKD_DVD supersedes it, benign). None is on the movie path; still deferred. `[cap] tag=iop:import saturated at 6` — the default; raise `PS2X_IOP_IMPORT_MAX` to a **number**, never `0` (entry (e) §4). ### 8. Run command for run 62 Build first, then: ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" ` -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ` -RunSeconds 240 ``` Do **not** pass `-Determinism 0`, and make sure `PS2X_MOVIEGATE` is not set in the shell. 240 s because the `.SFD` open lands at t≈129 s at det=1. --- ## ★★★★★ 2026-08-17 (e) — `sceCdSearchFile` (sid `0x80000597`) IDENTIFIED and SERVED. Run 60 could not test it: ⚠️ **`PS2X_DETERMINISM=0` costs the movie path entirely.** Two independent results. The first is the Stage 5.15 answer; the second is a measurement-discipline finding that **invalidates the previous entry's perf advice.** ### 1. `sub_1866A0` is the EE `sceCdSearchFile`, and its RPC was never served Decompilation (`ida_scripts/decompiles_SLUS_214_42.txt`) and the 08-17 16:4x logs agree field for field — this is not an inference: | `sub_1866A0` decompiled | logged (`164414`, `165605`) | |---|---| | `sceSifBindRpc(&client_568880, 0x80000597)` | `[SIF:BIND] client=0x568880 sid=0x80000597` | | `sceSifCallRpc(…, 0, send 0x568700, 300, recv 0x568840, 4)` | `func=0x0 send=0x568700 ssz=0x12c recv=0x568840 rsz=0x4` | `0x12c` = 300 = the exact `cache_writeback_range` length in the decompilation. The call is logged as **`[sif:rpc-miss]`** — unserved. The function **returns `MEMORY[0x20568840]`, the recv word verbatim.** So an unserved RPC leaves recv untouched ⇒ returns 0 ⇒ `0x130AD8` returns 0 ⇒ `0x130EF0` takes its `E0092911 sceCdSearchFile` branch ⇒ returns 0 ⇒ the movie never opens. Silent throughout, because **every printf on that path is gated on `dword_463250`** (libcdvd verbosity), which is 0 — that is why 20+ runs showed nothing. Same failure class as Stage 5.12 (memory card) and 5.14 (SJX): **silence must be written, not omitted.** ### 2. Fix implemented — `ps2_iop.cpp` `handleRPC` now serves `0x80000597` Parses the filename at `send+0x24`, resolves it through the existing ISO-backed `ps2_iop_cdSearchFile` (`Kernel/Stubs/CD.cpp:25`), writes the `sceCdlFILE` result back into the **send** block (lsn/size/name — recv is only 4 bytes and carries the boolean). `MOVIE/ATARI.SFD` confirmed present on the CD root, 737,280 bytes. Not a `feedback_no_iop_faking` violation: **cdvdfsv ships in the IOP BIOS, not on disc**, so there is no IRX to run in the interpreter — the same justification already recorded for the IOP heap service at `ps2_iop.cpp:348`. Probes added, all in `game_overrides.cpp`: `0x130AD8` (irq-disable thunk) and `0x1866A0` → `[cdsearch]`; `[iop:cdsearch]` on the service side (**not** gated on `PS2X_MOVIEGATE`). The `jal` at `0x130B04` was verified to route through `dispatchGuestBranch`, so `replaceFunction` really intercepts. ### 3. ⚠️ Run 60 (08-17 17:31, det=0) DID NOT TEST ANY OF THAT `[moviegate] … 130ad8=1 1866a0=1 enabled=0 cdsearch=0` — `PS2X_MOVIEGATE=0` was still set in the shell from the A/B. But the run also never reached the code at all: `DVCI:` = 0, sid `0x80000597` = 0 occurrences, `[sif:rpc-miss] total` = 4 (vs **239**). The cause is **`PS2X_DETERMINISM`**, and the archive is unanimous: | det | non-fragment runs | reached `DVCI:` | |---|---|---| | `1` | 4 (`163801 164107 164414 165605`) | **4 / 4** | | `0` | 4 (`033619 033926 034232` + run 60) | **0 / 4** | `165605` was det=1 **with `PS2X_MOVIEGATE=0`** and still reached DVCI ⇒ the moviegate wrappers are not the variable. Determinism is. ⇒ **RETRACTION of (d)-era advice.** Entry (d) and the 08-17 handover told the user to run `-Determinism 0` to cure the "slow motion". That is true of wall clock (`vbl/s` avg 14.9 vs 5.5) and **false of guest progress** — det=0 reaches `maxstream=1`, det=1 reaches `4`. The wall-clock rate was measuring the vblank ISR, not the game. **Stage 5.15 work must run at `PS2X_DETERMINISM=1`.** Slow is the price. ### 4. ⚠️ `0` is NOT "unlimited" for every cap — entry (d) row 1 is wrong for one of them | Env var | `0` means | Code | |---|---|---| | `PS2X_SJX_ACK_MAX` | unlimited (`kAckMax != 0u` guard) | `Kernel/Stubs/SIF.cpp:1725` | | `PS2X_IOP_IMPORT_MAX` | **log nothing** (no zero case) | `ps2_iop_irx_loader.cpp:638` | Run 60 was launched with `PS2X_IOP_IMPORT_MAX=0` and duly printed `[cap] tag=iop:import saturated at 0` — the import census was blank by construction. Use a large number, not `0`, for that one. ### 5. Run-60 error scan — nothing new `dispatch-miss` 0 · `missing-target` 0 · no `E0xxxxxx` guest errors. 127 `[iop:unhandled]`, top: `intrman fid=17/18` (11 each), `libsd fid=5` (10), `loadcore fid=5` (10 — [documented as correct](ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp#L615), `RegisterLibraryEntries` returns 0 on success). Saturated: `ARKD:CALL@64`, `ARKD:run@32`, `sceSifSetDma:DTX@64`, `iop:import@0`. Still-unserved sids besides `0x80000597`: `0x80000592` (cdvdfsv N-cmd) and `0x80000006` (FILEIO, sends `"cdrom0:\"`). Neither is on the movie path; deferred. ### 6. Exit test for run 61 — the narrowest one Stage 5.15 has ever had Run at **det=1**, `PS2X_MOVIEGATE` unset: 1. `[iop:cdsearch] name="\MOVIE\ATARI.SFD;1" found=1` — the service was reached and hit. 2. `[cdsearch] fn=1866a0 … ret=0x1 recv840=0x1` — the EE saw the answer. 3. `[cdsearch] fn=130ef0 … ret=` **non-zero** — the DVCI open succeeded. 4. `pump1263b8.calls` finally leaves 0. Any step failing names the next question exactly. If (1) is absent while `DVCI:` is present, the sid guess is wrong; if (1) shows `found=0`, it is a path/leaf-matching bug in `registerCdFile`, not an RPC bug. ### 7. Housekeeping done this session - `analyze_run.py` `RUN_MARKERS` rewritten. The old `sfd` column used `DVCI|\.SFD|SFD;1` and scored **2 for a single printf**; replaced by `dvci` plus event columns `597 / mvreq / mvopen / cdsrch`. `maxst` was always correct. - `_run_logs` now skips the live log when an archived copy has its exact size — `launch_recomp.ps1` *copies* to archive, so the newest run was listed twice. - Fragment runs (<1 MB) flagged `*`; their zeros mean "died before boot", not "absent". - 9 evidence-free pre-instrumentation runs parked in `Logs/archive/old/` with a README. The three **det=0 perf baselines were deliberately left in place** — see §3. --- ## ★★★★★ 2026-08-17 (d) — `[moviegate]` RAN. The latch hypothesis is **DEAD**. The movie state object is never armed at all. Probe built and run (batch of 3, 120 s). Banner: `wrappers 125898=1 12cc20=1 130ef0=1 enabled=1`. No `[cap]` for the tag ⇒ **absence is evidence**. ### The measurement ``` [moviegate] fn=125898 when=pre seq=0 base=0x44beb8 ... b02=0 b46=0 b47=0 b48=0 latch49=0 hnd08=0 fptr50=0 arg54=0 [moviegate] fn=125898 when=pre seq=1 base=0x44bf18 ... (identical, all zero) ... seq=2..5, alternating the two bases, byte-for-byte identical [moviegate:stat] state.calls=2560 state.emit=6 open12cc20.calls=0 dvci130ef0.calls=0 ``` `state.emit` stays at **6** while `state.calls` climbs past **2560**. The record is change-gated, so that is not a sampling artifact: across ~2560 calls **not one watched field ever moved**. Two objects, `0x44beb8` and `0x44bf18` (= base + `0x60`, i.e. ADX stream-table entries 0 and 1 — same table `[adx:stream]` sweeps). ### What it closes 1. **`latch49=0` and `hnd08=0` on every call ⇒ the one-shot-latch / stale-handle hypothesis is DEAD.** Neither skip-gate is ever dirty. The 3-in-21 intermittency was never a latch. 2. **`open12cc20.calls=0`, `dvci130ef0.calls=0`** ⇒ decision-table row 1: the block is a gate inside `sub_125898`, upstream of the open. 3. **`fptr50=0` forever** ⇒ the decisive byte. `0x124E38` writes the filename pointer into `[+0x50]` **and** the arm byte in the same pass. A permanently-null `[+0x50]` means **`0x124E38` never runs**. Whether the gate byte is `+0x45` (static analysis) or `+0x48` (live disasm) is now moot — nothing arms the object either way. ⇒ **The state machine ticks ~2560 times against a permanently blank object.** Divergence is upstream of `sub_125898`, not inside it. ### Next question (narrow, answerable) **Who calls `0x124E38` (and `0x1263B8`), and why doesn't it fire?** Wrap both, log caller `$ra` + args. Cheap: same file, same rebuild. ### Also folded into this build (unbuilt as of this entry) The four hardcoded probe caps are now env-overridable, `0` = unlimited: | Tag | Was | Env var | File | |---|---|---|---| | `sjx:ack` | 32 (saturates every run) | `PS2X_SJX_ACK_MAX` | `Kernel/Stubs/SIF.cpp` | | `sif:rpc-miss` | 24 / sid | `PS2X_RPC_MISS_MAX` | `Kernel/Stubs/SIF.cpp` | | `adx:stream` | 600 (= 10 min @ 1 Hz) | `PS2X_ADX_STREAM_MAX` | `ps2_runtime.cpp` | | `movie` | 600 (= 10 min @ 1 Hz) | `PS2X_MOVIE_MAX` | `ps2_runtime.cpp` | Every `[cap]` line now prints its own limit and names its env var. `ps2_runtime.cpp` got an `envDec` sibling to `envHex` — counts are base-0, not hex, so `PS2X_MOVIE_MAX=1000` means 1000 and not `0x1000`. --- ## ★★★★☆ 2026-08-17 (c) — PCSX2 live trace names the real caller chain; the "silent cache-hit" hypothesis is **RETRACTED**; `[moviegate]` probe written (⚠️ **NOT COMPILED**) Session close state: **code written, nothing built, nothing run.** The next action is a build, and it is the user's. ### 1. The chain, from a live PCSX2 breakpoint (hardware ground truth) ``` sub_125898 movie state machine $s0 = $a0 = state object -- jal 0x12cc20 @ 0x125958 DirectCall, dispatch-routed sub_12CC20 CRI open wrapper -- jalr $v0 @ 0x12ccd8 IndirectCall, dispatch-routed sub_130EF0 DVCI file open ``` ⚠️ **Correction to every earlier note:** the last hop is a **`jalr $v0`**, not a `jal`. That is *why* no static caller for `0x130EF0` was ever found — the target is a runtime function pointer. Stop searching the ELF for one. ### 2. ⚠️ RETRACTION — the "our runs take a quiet cache-HIT branch" hypothesis is DEAD Hardware enters `0x130ef0` exactly **twice** per boot — `movie/atari.sfd` then `movie/okr.sfd`, ~7.6 s apart — and **both take the cache-MISS path** (`$a1 == *(h+0x24) == 0` at `0x130f78`). So `DVCI: File cache was not hit` is the **normal** path, not an anomaly. Combined with **ATARI = 0 in all 21 archived runs, including the 3 golden ones**: our runtime never opens the Atari logo movie at all. The divergence therefore begins strictly **upstream of the DVCI layer**, and probing `0x130ef0` alone can never see it. ### 3. The gate — `sub_125898`'s guard chain, disassembled live ``` 0x125924 bne $a0, 1 -> 0x125ac8 state must be 1 0x125930 bnez 0x49($s0) -> 0x125994 ONE-SHOT LATCH 0x125938 sb $a0, 0x49($s0) latch set to 1 0x125948 bnezl 0x08($s0) -> 0x125994 HANDLE ALREADY SET 0x125950 lw $a0, 0x50($s0) filename pointer 0x125958 jal 0x12cc20 THE OPEN 0x125964 sw $v0, 0x08($s0) store handle ``` Plus **three prologue gates found in the generated body**, previously unrecorded: `+0x02` must be 0 (`0x1258b4`), `+0x48` must be 1 (`0x1258c4`), `+0x47` is read at `0x1258cc`. `+0x49` is a one-shot: once nonzero the open never runs again for the rest of the run. `+0x08` is a second permanent skip. **Either one left dirty produces exactly the observed 3-in-21 behaviour.** That is the hypothesis the probe exists to measure — not to confirm. ### 4. `[moviegate]` — written into `game_overrides.cpp`, **UNBUILT** Three wrappers registered via `applySdbzMovieGate` (`PS2_REGISTER_GAME_OVERRIDE("SDBZ movie-open gate probe", …)`): | Address | Emits | |---|---| | `0x125898` | `base`, `b02 b46 b47 b48`, **`latch49`**, **`hnd08`**, `fptr50`, `arg54` | | `0x12cc20` | `a0/a1/a2`, `ra`, `ret`, filename string | | `0x130ef0` | same + `s1`, `h24`, `h04`, `cache=HIT/miss` | Read with `python build_scripts/analyze_run.py --tag moviegate`. **Decision table it produces:** | Observation | Conclusion | |---|---| | `125898` fires, `12CC20` does not | a `+0x49` / `+0x08` / prologue gate is the blocker — the record names **which byte** was wrong | | neither fires | the state machine never runs; the fault is further back still | | `12CC20` fires, `130EF0` does not | the fault is inside `0x12cc20`'s own argument checks | Design choices, each closing a known failure mode of this project: - **Verified all three inbound call edges emit `dispatchGuestBranch()` before writing a line** (`0x126410→0x125898`, `0x125958→0x12cc20`, the `0x12ccd8` JALR). A recompiled site that calls `fn_*` directly in C++ bypasses `replaceFunction` entirely — that is what silently hollowed out `[pktord]` in run 36. - **`std::cerr` + `[tag]` text, NOT `ps2x_probe_kv()`.** The KV sink writes only to `run_probe.jsonl`, which `analyze_run.py --tag` does not read. - **Change-gated** on `0x125898` (~5k calls/run), with **pre- AND post-body snapshots** — the latch flips *inside* the body, so a pre-only snapshot would miss it. - **Per-object-base keying** (8 slots) so a second movie object cannot masquerade as a transition of the first. - **Always-printed install banner** + explicit `[cap]` lines, so "0 records" can never be confused with "wrapper never installed" or "probe saturated". - **On by default**; `PS2X_MOVIEGATE=0` disables. The event is 3-in-21 — a run lost to a forgotten env var costs a whole cycle. ### 5. Next action — the user's, in order ```powershell & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 120 -Repeat 3 -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag moviegate ``` `-Repeat 3` is mandatory, not optional: at 3-in-21 a single run reads as a false negative. First thing to check in the log is the install banner `[moviegate] wrappers 125898=1 12cc20=1 130ef0=1` — a `0` invalidates the measurement *before* the run instead of after. ### 6. Still open / deliberately out of scope - **Phase 3 of the tooling plan is NOT done.** Four hardcoded caps still cannot be raised without a source edit: `sjx:ack` 32 (`Kernel/Stubs/SIF.cpp:1715`), `sif:rpc-miss` 24/sid (`SIF.cpp:665-667`), `adx:stream` 600 (`ps2_runtime.cpp:3260`), `movie` 600 (`ps2_runtime.cpp:3341`). Pattern to copy: `kCallMax` `SIF.cpp:756-771`, `kDtxMax` `SIF.cpp:1791-1805`. **`sjx:ack` is the one that actually saturates every run** — its silence past 32 remains worthless as evidence. All `.cpp`, so it folds into any incremental rebuild. - `sub_125898`'s state-object base on hardware was never pinned (`0x0054C060` is `sub_12CC20`'s frame, not it). Largely obsoleted — the probe reads `$a0` directly. ### 7. Learned patterns from this session - `X = "no static caller found for a function"` → `Y = the call site is a jalr through a runtime function pointer` → `Z = grep the generated body for dispatchGuestBranch with GuestBranchKind::IndirectCall, don't keep searching the ELF for a jal`. - `X = a probe reports nothing` → `Y = the wrapper may never have been installed, or the call may bypass the dispatch loop` → `Z = verify the call edge emits dispatchGuestBranch() BEFORE writing the probe, and always print an install banner`. - `X = an anomalous-looking log line ("cache was not hit")` → `Y = it may be the normal hardware path` → `Z = breakpoint the real console before building a hypothesis on it — this one cost the whole cache-hit theory`. --- ## ★★★★★ 2026-08-17 (b) — `0x80000597` IS NOT THE BLOCKER. The EE side is **bit-identical** in runs that open the `.SFD` and runs that don't. The divergence is entirely **downstream of SJX `fno=0x408`, on the IOP side**, and it is **intermittent (3/21 runs)**. Answered offline, from logs already on disk. **Zero new runs.** ### 1. The comparison was always possible — the archive already existed `launch_recomp.ps1` has been archiving to `logs/archive/` (`-KeepLogs 20` per stream) all along. **Run 57 was still on disk** as `run_log.20260816-115006.txt`, with a second run that also reached the open at `...-120214.txt`. Three run cycles were spent trying to *reproduce* a run that was sitting in the archive the whole time. ### 2. The EE-side setup is identical, call for call Ordered event stream around the 2nd `MovieCreate`, golden (11:50) vs run 60 — every SJX `fno`, every argument, every returned handle, every heap address **matches exactly**: ``` BIND sid=0x90000200 → fno=0x2 → 0x403 → 0x40c → AllocIopHeap(99904)=0x8900 → 6× (fno=0x422 → fno=0x400) → AllocIopHeap(2256) → fno=0x2 → AllocIopHeap(6208)=0x21840 → fno=0x40a → MovieCreate(Size) → MovieCreate(Adrs) → fno=0x408{2,0,0x5340,0x53C0} → 0x5640 ``` Then, and only then, they differ: | | golden 11:50 | run 60 | |---|---|---| | after `fno=0x408 -> 0x5640` | `DVCI: File cache was not hit "\MOVIE\OKR.SFD;1"` → `[SIF:BIND] sid=0x80000597` | **nothing** | | `[adx:stream]` maxst | 1 @t=68 → **4 @t=96** | 1 @t=79, **never advances** | `[iop:sjx]` is **uncapped in both**, so this absence is real. The SJX census is also identical: `0x2=4 0x3=4 0x400=12 0x401=12 0x403=2 0x408=2(nz) 0x409=2 0x40a=2 0x40c=2 0x40d=2 0x422=12 0x423=12`. ⇒ The EE issues the open command correctly in *both* cases. Whether the IOP's DVCI layer then performs the file lookup is what varies. **This is an IOP scheduling/race problem, not an unserved-RPC problem.** `0x80000597` only ever appears *after* DVCI — it is a **symptom of getting far enough**, never the cause of not getting there. ### 3. It is intermittent — which is why single runs kept "disproving" it `analyze_run.py --runs` across all 21 archived runs: **3 reached the open** (`20260812-061029`, `20260816-115006`, `20260816-120214`). Within the instrumented batch it is **2 of 7**. Three consecutive misses is entirely consistent with chance. ⇒ **RETRACTED:** the lean toward the `SIF.cpp` census edit having caused the divergence. The 08-16 morning went ✗ ✓ ✓ ✗ *across* that rebuild, and 08-12 went ✗ ✓ ✗ on one build. ### 4. Not the cause (ruled out this session) - **Movie object lifecycle** — run 60 creates/destroys it twice with the *same* addresses (`obj=0x45f6e4 crt2=0x500730 objFile=0x4597b0`) as golden, just ~11 s later. - **A timeout** — golden advances st 1→4 about 29 s after st=1; run 60 had 98 s of headroom and never moved. - **`[guest-branch:missing-target]`** in the golden log is `target=0x0` (a **null function pointer**, not a missing body) at t≈109 s, i.e. *after* the failed open. Downstream. ### 5. Next The question is now "why does the IOP DVCI lookup usually not run after SJX `0x408`", not "who serves `0x80000597`". Instrument the IOP side of `fno=0x408`, and use `-Repeat` to get a sample rather than a single run. --- ## ★★★★☆ 2026-08-17 (b2) — Diagnostic tooling: the encoding bug that has been poisoning results **`Tee-Object` differs between shells in TWO ways, and both bite.** 1. **Default encoding is host-dependent** — Windows PowerShell 5.1 writes UTF-16, pwsh 7 writes UTF-8. That is why every log in this project is UTF-16: **the user launches runs from 5.1.** Every reader in `build_scripts/` assumes UTF-16. 2. **`-Encoding` exists only in pwsh 6+.** 5.1 has no such parameter and *hard-errors* on it. ⚠️ Adding `-Encoding Unicode` unconditionally **killed three runs** on 08-17 — the pipeline errored before the exe started, so the game never ran. Verifying the parameter existed in pwsh 7 said nothing about the shell that actually runs the launcher. Fixed by passing `-Encoding` **only where supported**, via a splat: ```powershell $TeeArgs = @{ FilePath = $Log; Append = $true } if ((Get-Command Tee-Object).Parameters.ContainsKey('Encoding')) { $TeeArgs['Encoding'] = 'Unicode' } ``` 5.1 uses its Unicode default; pwsh 7 gets the explicit override. Both write UTF-16, matching the `Out-File -Encoding Unicode` header. **Verified under 5.1 itself** (`powershell.exe -NoProfile`), not under pwsh 7 — test PowerShell changes in the shell that will run them. **Two guards added because of that incident:** - **Empty runs are no longer archived.** The prune is by *count*, so the three failed launches evicted three real runs from `logs/archive/`. A log holding only `[runmeta]` is now left to be overwritten. - **The validity gate now fails on no guest output.** It previously printed *"run passes the validity gate"* for all three dead runs, because it only looked for known failure strings and a log with nothing in it contains none of them. **Shipped this session (no rebuild — Python + PowerShell only):** | Change | File | Why | |---|---|---| | `--runs` | `build_scripts/analyze_run.py` | Per-run inventory of how far the movie path got. Makes intermittency visible; reading one run at a time is what made a 2-in-7 event look like "never". | | `--compare A B` | `build_scripts/analyze_run.py` | Cross-run tag diff **with cap status on both sides** — an asymmetric cap is a false diff. | | `-Repeat N` | `launch_recomp.ps1` | N runs back-to-back + inventory. Requires `-RunSeconds > 0`. | | Provenance header | `launch_recomp.ps1` | `[runmeta]` lines: exe path + mtime + size, elf, and the full resolved `PS2X_*` env block. Logs previously could not be attributed to a build. | | Golden-run pinning | `launch_recomp.ps1` | A log containing `DVCI` is **never pruned**. Fail-safe: keeps the file if it cannot be scanned. Run 57 survived only by luck. | ⚠️ **Do not use `[^\n]*X[^\n]*` regexes on `run_log.txt`.** Records run to tens of KB with no newline, so those patterns backtrack catastrophically and hang (cost one killed 120 s process this session). Use index-based extraction or anchored tag splits, as `cmd_tag` does. ⚠️ **`analyze_run.py --tag` already existed and does this correctly** — encoding-agnostic, splits on the tag not newlines, prints the cap verdict. Hand-rolled `Select-String` greps were used instead for a whole session, producing two oversized dumps and two near-miss conclusions. **Reach for `--tag` first.** **Deferred (needs a rebuild):** four probe caps are still hardcoded and cannot be raised from the environment — `sjx:ack`(32) and `sif:rpc-miss`(24/sid) in `SIF.cpp:1715/667`, `adx:stream`(600) and `movie`(600) in `ps2_runtime.cpp:3260/3341`. `sjx:ack` is the one that actually saturates today. Follow the existing `kCallMax`/`kDtxMax` env pattern. --- ## ★★★★★ 2026-08-17 (a) — RUN 58 WAS ALREADY ON DISK AND UNREAD. The `[sif:rpc-miss]` probe **was** built and **did** fire — but it was blind over the entire movie window. `0x80000597` is **still unanswered as a question**. Session opened on `run_log.txt` (4.62 MB, 12:16 on 08-16), which is **newer than the exe** (`ps2EntryRunner.exe` 12:13) which is in turn **newer than the probe edit** (`SIF.cpp` 12:08). (e) §5's "EDIT MADE, NOT BUILT, UNVALIDATED" was already stale when it was written at 12:15. ### 1. The probe works. 97 records, 179 total misses. `bridgeHandled == false` means `ps2_iop::handleRPC` returned false. Semantics confirmed by reading the emit site (`SIF.cpp:608`), not assumed. ### 2. ⚠️ It answered nothing, for two independent reasons **(a) The cap saturated BEFORE the window of interest.** | log line | event | |---|---| | 2433 | `[cap] tag=sif:rpc-miss saturated at 96` | | 2825 | `MovieCreate: Use Work Adrs = 0x1806c00` | The flat 96-line cap was exhausted ~400 lines *before* the movie even started. Every unserved call in the window this probe exists to observe was invisible. **`0x80000597` never appearing in run 58 is not evidence of anything** — textbook [capped-probe false negative]. **(b) Most of what ate the cap was a FALSE POSITIVE.** The census was dominated by ARKD sids `0x500`/`0x501`/`0x502`/`0x503` (`func=0x101` send words decode to real asset paths: `font/5-2-4-2.`, `ply/exthit.h`, `dis/toon.pix`, `eff/e00/e00…`). `handleRPC` does not serve those — but **`ps2_iop_runArkdService` does**, in a separate block at `SIF.cpp:780`. So `!bridgeHandled` was never a sound test for "unserved". The probe was counting served traffic and starving itself with it. ### 3. Run 58 also never reached the movie attempt at all No `DVCI: File cache was not hit` line anywhere. `[adx:stream]` shows two entries appear at t=67 s and **stay at `st=1`** — `maxst` never leaves 1, vs run 57's `st=4` error latch at t=97. Ends stuck: t=120 `busy%=16 res/s=3 vbl/s=2 stuckSecs=9 pc=0x422660 ra=0x422660` `lastCall=0x172998`, versus run 57's healthy `res/s=130 vbl/s=35` at the same point. ⇒ Run-to-run divergence at the movie gate. Run 57 reaching `st=4` at t=97 s is the *late* edge of a 120 s window. Any future run must be **≥180 s** or it cannot distinguish "didn't happen" from "hadn't happened yet". ### 4. Fix applied — `SIF.cpp` census rewritten (`.cpp` only, no headers, no `src/runner/`) - ARKD sids `0x500..0x503` **excluded** from the census (served downstream), counted into a running `arkdSup=` that rides every line so the exclusion is never silent. - Cap is now **per-sid (24)**, not global — a chatty known service can no longer starve a novel one. - **First sighting of any sid logs unconditionally**, past any cap: `[sif:rpc-miss:new-sid] first unserved call to sid=0x…`. This is the line that answers the `0x80000597` question, and it is now structurally impossible to starve. - `[cap]` line is now per-sid and names which sid went blind. - Added `#include ` (`.cpp`-local; `` was already present). **NOT BUILT. NOT RUN.** ⚠️ Verify the exe mtime beats `SIF.cpp`'s before reading the next log. ### 5. Next action 1. `& "F:\SDBZ Recomp\build.ps1" RelWithDebInfo` *(user runs)* 2. Run **180 s**, not 120. 3. Read in this order: `[sif:rpc-miss:new-sid]` → any `sid=0x80000597` record and its `s[0..3]` → `[adx:stream] maxst` (did it reach 4 this run at all?). - `0x80000597` appears ⇒ implement the cdvd file service. - `maxst` never reaches 4 ⇒ the movie was never attempted this run; the probe is fine, the *run* is the false negative. Re-run before concluding. --- ## ★★★★★ 2026-08-16 (e) — RUN 57: the open IS attempted and **FAILS**. New suspect: cdvd RPC `sid=0x80000597`, which we serve with nothing. ⚠️ SUPERSEDES (d). Log preserved at `run_log_57.txt` (4.78 MB, 12:02). Exe 11:59 ⇒ new build, new run. Run 57 is run 56 with the `[adx:stream]` probe widened (peak-hold `maxst/maxcmd/maxfd`, `f80`/`f84`) and **the same 120 s window run to completion** — run 56's window ended ~30 s before the movie. ### 1. ⚠️ RETRACTION — (d)'s headline is wrong (d) said *"the open is never ATTEMPTED, not attempted-and-failed."* **Run 57 disproves it.** ``` [adx:stream] t=97s nonzero=2/40 maxst=4 maxcmd=0 maxfd=0x0 base=0x44beb8 stride=0x60 | i=0 raw=0x1000101 act=1 st=1 pau=0 fd=0x0 cmd=0x0 bsy=0 f80=0x0 f84=0x0 | i=1 raw=0x1000401 act=1 st=4 pau=0 fd=0x0 cmd=0x0 bsy=0 f80=0x1b173a8 f84=0x0 ``` - `st=4` = **the error latch** — the exact branch (d) predicted would prove an attempt. - `f80=0x1b173a8` = the filename pointer, and `0x01b173a8` is the SofDec work-area buffer the app-side strcpy chain fills. ⇒ writer `0x124E38` **ran**, so `[+69]=1` **was written**. - `maxcmd=0` all run: the 1 Hz peak-hold never caught `cmd=1`. That byte lives < 1 s (the writer spins until it clears). **Not evidence of absence** — the peak-hold is too slow for it. ⇒ The gate byte `[+69]` is **no longer the blocker**. Delete it from the suspect list. ### 2. ⚠️ RETRACTION — (d) §8 / the ADXT-AFS branch is RULED OUT `MovieCreate` at t=67 s prints `objSrc=0x1 objLen=0xfffff`. That is verbatim what `0x14F860` (the plain-filename path) stamps: `[107]=1, [108]=0, [109]=0, [110]=0xFFFFF`. The AFS path `0x14F8D0` sets `[107]=1` but fills 108..110 with **real** values. ⇒ **mwPly/SofDec path is armed.** `0x12A280` (`ADXT_StartAfs`) and the whole ADXT chain `0x12BC98 → 0x12AF58 → 0x1274E8 → 0x124DC8` are not on our path. The caveat recorded in (d) §8 — *"do not act on §8 until run 57 says which layer is armed"* — did its job. ### 3. The measured timeline | t | evidence | |---|---| | 67 s | `MovieCreate: Use Work Adrs = 0x1806c00` `wrkSiz=0x332100` `obj=0x45f6e4` `crt2=0x500730` `objSrc=0x1 objLen=0xfffff` `objFile=0x4597b0`. Two ADX entries appear, both `raw=0x1000101` st=1 | | 97 s | guest TTY: `[Deci2Call:kputs] DVCI: File cache was not hit. "\MOVIE\OKR.SFD;1"` (line 3048) | | 97 s | **next line, 3049**: `[SIF:BIND] client=0x568880 sid=0x80000597` | | 97 s | same second: ADX i=1 → `st=4`, `f80` set | | 111–118 s | `obj=0x0`, `nonzero=0/40` — torn down. The game **gives up and moves on**. | Note it is **OKR.SFD**, not ATARI.SFD. First time in any run that the game has named a movie file. `objFile=0x4597b0` is the same value hardware carries as the ADX entry's live fd. Liveness healthy throughout — t=97 watchdog: `busy%=86 res/s=130 vbl/s=35 progress=302247 gif/s=30 dma/s=184 stuckSecs=0 pc=0x421ed8`. No `RUN VALIDITY FAILURE`. `info=0` / `maxinfo=0` for the **entire** run ⇒ `0x54BE30` never set ⇒ render gate `0x113860` (`if (dword_54BE2C && dword_54BE30)`) never passes ⇒ logo skipped. Symptom fully explained downstream of the failed open. ### 4. The new suspect — an unserved cdvd file service | fact | evidence | |---|---| | `sid=0x80000597` binds **exactly once in the whole run** | `Select-String 80000597` → one hit, line 3049 | | …at the exact log second the guest asks for `OKR.SFD` | line 3048 immediately precedes it | | we serve **nothing** for it | `handleRPC` (`ps2_iop.cpp`) covers `0x80000593` only of the `0x8000059x` family, and serves it a canned `word[0]=1` | | no source anywhere mentions it | `grep -rn "0x80000597\|0x80000592"` over `ps2xRuntime/src` → **zero hits** | | the bind still *looks* successful | binds are **echo-completed generically** in `SIF.cpp`; every bind succeeds whether or not a service exists | | only ARKD_DVD.IRX is really loaded | `libs=12 stubs=57`; the guest's `Load Module cdrom0:\CRI_ADXI.IRX;1 OK!` and LIBSD/MCMAN/MCSERV lines are **faked loadfile completions** | | the failure is silent | **no CRI `E…` string prints anywhere in the log** | An unanswered RPC leaves the guest's recv buffer untouched. CRI reads that as a failed open. Nothing prints. **That is our symptom exactly.** ### 5. ⚠️ What is NOT yet proven, and the probe written to prove it I have **not** shown that a CALL followed that bind. The generic bridge in `SIF.cpp` recovers `boundSid = arkdLookupSid(client)` and calls `handleRPC(...)` but **logs nothing** unless `PS2X_MCSERV_TRACE` is set. Absence of a log ≠ absence of a call — the ungated/unlogged path is a false-negative source in its own right. **EDIT MADE, NOT BUILT, UNVALIDATED** — `[sif:rpc-miss]` census in `ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp` (~line 592), inside the generic-bridge block, fired unconditionally on `!bridgeHandled`: - prints `sid / func / client / send / ssz / recv / rsz` plus the **first four send words** (for a cdvd file service these carry the command and the path/LBA ⇒ *which* open is unanswered); - consecutive identical `(sid,func)` collapse so a retry spin cannot exhaust the cap; - a running `total=` rides every line so the collapse can never be misread as low volume; - cap 96 with an explicit `[cap] tag=sif:rpc-miss` line. `.cpp` only. No headers, no `src/runner/`. Includes verified present (`std::cerr`, `std::memcpy`, `std::atomic`, `getConstMemPtr(rdram, addr)` all already used in this file). ### 6. `[cap]` lines seen in run 57 — absence past these is NOT evidence `ARKD:CALL` 64 · `ARKD:run` 32 · `iop:import` 6 per stub (first at libsd fid=5 @`0x04ac1c`) · `sceSifSetDma:DTX` 64 · `sjx:ack` 32. ### 7. Next action 1. `& "F:\SDBZ Recomp\build.ps1" RelWithDebInfo` (user runs). 2. 120 s run — **let it run the full 120 s**; the window of interest is t=90–110. 3. Read: any `[sif:rpc-miss] sid=0x80000597 …` record and its `s[0..3]`. - **Fires** ⇒ implement the cdvd file service. Stage 5.15 stops being a CRI mystery. - **Doesn't fire** ⇒ the call is being answered by something, and the failure is inside `cvfsOpen`'s own layer; go back to `sub_12CC20`'s return path. --- ## ★★★★★ 2026-08-16 (d) — ⚠️ SUPERSEDED BY (e). STATIC TRACE: the `.SFD` open is never ATTEMPTED, and the gate is one byte > ⚠️ **Headline retracted at run 57.** The open IS attempted and fails (state 4); `[+69]` IS > written. §8's ADXT/`0x12A280` branch is ruled out. §1–§4 (the gate's location, the two > writers, the constructor value, base/stride) remain correct and useful. Read (e) first. Zero run cost. Read out of `ida_scripts\decompiles_SLUS_214_42.txt` (15.9 MB — always grep or slice, never full-read) after run 56 measured `cmd=0x0` on both live ADX entries. ### 1. The gate is a single line in `adxstmf_stat_exec` (`0x125898`) ```c v9 = *(char *)(a1 + 69); // byte [+0x45] -- our "cmd=" field if ( v9 != 1 ) goto LABEL_38; // returns the state, touches nothing v11 = cvfsOpen(*(int *)(a1 + 80), *(int *)(a1 + 84), 0); // = sub_12CC20 *(_DWORD *)(a1 + 8) = v11; // <- the fd if ( !v11 ) { *(_BYTE *)(a1 + 1) = 4; ... "E02110501 adxst..." } ``` Run 56 measured `cmd=0` for the full 40 s window on both entries. ⇒ **`cvfsOpen` is never called at all.** `fd=0` is *not* a failed open — failure would have latched state **4** and printed `aE02110501Adxst`; we saw state 1 and no print. This narrows run 56's §4 conclusion from "the CRI file layer fails" to "the CRI file layer is never asked". ### 2. Only two functions can set that byte, and both then SPIN on it | addr | preconditions | also writes | |---|---|---| | `0x124E38` | — (reached via `0x124DC8`; caller `0x124F20` then `while(*(a1+69))`) | `[+80]` = **filename ptr**, `[+84]` size, `[+12]`, `[+20]`, `[+16]` | | `0x1263B8` | requires `[+80] != 0` **and** `[+8] == 0` | pumps `state_probe_e(a1)` until it clears | Because both spin until the byte clears, a 1 Hz sampler *should* catch it latched. It never did — so neither writer is running. `0x1263B8` additionally depends on `[+80]`, which only `0x124E38` sets, so `0x124E38` is the true upstream step. ### 3. `raw=0x01000101` is the CONSTRUCTOR's value, untouched `sub_124A00` (the entry constructor) writes `*(_WORD *)(a1 + 1) = 1; ... *(_BYTE *)a1 = 1; *(_BYTE *)(a1 + 68) = 0;` — which composes to exactly the `0x01000101` we measure. Our two entries are freshly built and **nothing has written to them since construction**. ### 4. Base/stride confirmed a third time, independently `sub_125B58` (the tick): `v2 = (int)byte_44BEB8; LODWORD(v3) = 39; do { if (*(_BYTE *)v2 == 1) state_probe_e(v2); v3 -= 1; v2 += 96; }` — base `0x44BEB8`, stride 96, 40 entries. Matches the probe and PCSX2. ### 5. The app layer did NOT fail — and this is a REGRESSION FIX confirmed `MovieCreate` = `0x113AA0`. It has three failure printfs (`aMoviecreateCre`, `aMoviecreateCre_0`, `"MovieCreate: No enough memory 0x%x."`). `run_log_56.txt` contains **only the two success lines**, twice (lines 2633/2641 and 2854/2862): ``` MovieCreate: Use Work Size = 0x332100 MovieCreate: Use Work Adrs = 0x1806c00 ``` ⚠️ Compare run 53, which printed `E0100401: can't create PS2RNA of IOP` + `MovieCreate: Create movie handle failed`. **Those prints are gone.** The `0x408` mint fixed that, and it stayed fixed. So `dword_54BD90` and `dword_54BE2C` are both nonzero, and `sub_14F140` (`mwPlySetFile` → `0x14F860` → strcpy) ran — the exact PCSX2 chain. ⇒ **The filename gets set. The stream read never gets requested.** The missing step lives between those two facts. ### 6. The movie-player globals, and which one decides the render Fixed addresses out of `0x113AA0` / `0x113920` / `0x113860`: | addr | field | meaning | |---|---|---| | `0x54BD78` / `0x54BD7C` | work adrs / size | **positive control** — game printed `0x1806c00` / `0x332100` | | `0x54BD90` | SofDec object ptr | 0 ⇒ create failed (should be nonzero) | | `0x54BE2C` | second create result | 0 ⇒ create failed (should be nonzero) | | `0x54BE30` | info flag | set to 1 only once `mwPlyGetMovieInfo` returns a header — i.e. once the `.SFD` was actually read | | `0x54BE28` | status byte | poll treats `(status - 1) >= 2` as **finished** | Render gate at `0x113860` is `if ( dword_54BE2C && dword_54BE30 )`. Since `54BE2C` is nonzero, **`0x54BE30` is what makes the logo get skipped**, and it can't be set without the stream. That closes the loop back to §1. ⚠️ Naming trap again (same class as 5.11/5.15): the dump calls `0x125898` `state_probe_e`, `0x12CC20` `state_probe_unk` (it is `cvfsOpen` — proven by its own `aCvfsopen1Illeg` … `aCvfsopen6CanNo` strings), `0x124E38` `tagged_dispatch_c`, `0x14F140` `ptr_safe_deref_error_guard_f_0`. All are live CRI/SofDec functions. ### 7. Probe extended to measure exactly that gap — BUILT? NO. UNVALIDATED. Three `.cpp`-only edits to the 1 Hz watchdog in `ps2xRuntime/src/lib/ps2_runtime.cpp`: - `[adx:stream]` gains **`maxcmd=` / `maxfd=` peak-holds** (hoisted out of the `detailed < 4` guard so all 40 entries feed them) — a transient latch can no longer hide from a 1 Hz sampler. - `[adx:stream]` detail gains **`f80=` / `f84=`** — the discriminator: `f80` nonzero with `cmd=0` means the read *was* requested and cleared; both zero means `0x124E38` never ran. - New capped **`[movie]`** line reading the §6 table, with `wrkAdr`/`wrkSiz` as the positive control and a RAM-range-guarded deref of `obj+8` / `obj+444`. Env override `PS2_MOVIE_BASE`. ### 8. Walking UP from `0x124E38` — the chain, and the one call that fails SILENTLY `0x124E38` ← `0x124DC8` ← **`0x1274E8`** (its only caller, line 33025) ← **`0x12AF58`** ← **`0x12BC98`** (the ADXT server tick, switches on the ADXT object's own state byte `[+1]`; case 1 → `0x12AF58`). `0x12AF58`'s two guards: ```c if ( *(u8 *)(a1 + 2) < 2 && *(u8 *)(a1 + 172) == 1 ) // <- the arm flag { if ( adxstm_GetStat(*(a1 + 8)) == 2 ) return; // 0x125050 = *(char*)(entry+1) ... sub_1274E8(a1, *(a1+180), *(a1+184), *(a1+188), *(a1+192)); // sets [+69]=1 *(u8 *)(a1 + 172) = 0; } ``` `0x125050` is just `return *(char *)(entry + 1)` — the state byte. Ours reads **1**, not 2, so that early-out is **not** our problem. ⇒ the failing gate is **`[+172] == 1`**, the arm flag. `[+172] = 1` is written in exactly two places, both immediately after `[+180]`: line 32603 (`sub_12A280` @ **`0x12A280`**, = `ADXT_StartAfs`) and line 32641. And `0x12A280` contains the **only silent failure in the whole chain**: ```c result = adxf_GetPtInfo(a2, a3, *(int *)(a1 + 176), &v11, &v12, &v13); // 0x11CF30 if ( !result ) { ...set +180..+192, [+1]=1, [+2]=1, [+172]=1... } return result; // <- nonzero: returns with NO print, NO state change ``` Every other failure in this subsystem prints (`aE02080811AdxtS`, `aE8101202AdxtSt`, `aE02080848AdxtE`, `aE0071301AdxtEn`, `aE211151AdxfGet`, `aE02110501Adxst`). **This one does not.** Our symptom is precisely "nothing happens and nothing is printed". `0x11CF30` → `0x11CFC0` resolves a partition/file id: it indexes `dword_43E140[patid]` and requires `*(u8 *)(tbl + 15) == 1` — **the AFS partition must be loaded**. That is a disc-read dependency, and it is the leading suspect for the silent bail. ⚠️ Not yet proven that the movie uses this ADXT path rather than the mwPly one — `0x14F860` (the SofDec filename setter) writes its own 4-word signature instead. §7's `objSrc`/`objLen` distinguishes them. Do not act on §8 until run 57 says which layer is armed. Next: build, run 57. Read `f80` (did `[+80]` ever get a filename), `info` (`0x54BE30`, the render gate), and `objSrc`/`objLen` (did `0x14F860` run). --- ## ★★★★★ 2026-08-16 (c) — RUN 56: the drain deadlock is DEAD, and the ADX gate is `fd=0`, not the ack Run 56 (`run_log_56.txt`, 120 s, `PS2X_DIAG=1`, RelWithDebInfo exe built 11:30). Two probes answered at once. **No `RUN VALIDITY FAILURE`.** ### 1. `ackSjxStreamRingIfMatched` WORKS — Stage 5.15's first sub-blocker is closed ``` [sjx:ack] dest=0x8040 eeBase=0x45e700 len=0x880 ackAddr=0x45ef7c was=1 now=2 ra=0x130784 [sjx:ack] dest=0x20f80 eeBase=0x458840 len=0x880 ackAddr=0x4590bc was=1 now=2 ra=0x130784 ... was=31 now=32 ``` - Two independent rings, both advancing **monotonically 1→32**. The ack address is right. - **`0x1305e0` appears ZERO times in the whole log.** Run 55's `sub_1305B0` drain spin is gone. - Saturated: `[cap] tag=sjx:ack` at 32. Absence past that point proves nothing. ### 2. The `[adx:stream]` probe is VALIDATED and reproduces PCSX2's number first-party ``` [adx:stream] t=65s nonzero=2/40 maxst=1 base=0x44beb8 stride=0x60 | i=0 raw=0x1000101 act=1 st=1 pau=0 fd=0x0 cmd=0x0 bsy=0 | i=1 raw=0x1000101 act=1 st=1 pau=0 fd=0x0 cmd=0x0 bsy=0 ``` `raw=0x01000101` is **exactly** the value PCSX2 reported for our build. Base `0x44beb8` and stride `0x60` are confirmed correct — the rival-reading `raw=` field carried the proof. Stage 5.15's exit test is now measurable from inside our own runtime; it no longer needs PCSX2. ### 3. The game runs a RETRY LOOP, ~40 s per attempt — this is the "skip" mechanism | tick | event | |---|---| | ~t=60 | `0x40C` → 6×`0x400` → `0x40A` → `0x408`, acks start flowing | | t=65 | ADX entries 0 and 1 appear, `st=1 fd=0` | | t=65→104 | **40 s of nothing.** `st`, `fd`, `cmd`, `bsy` never change | | t=105 | entries torn down, `nonzero=0/40` | | ~t=105 | the entire sequence runs a **second** time, identical | Its own TTY confirms the movie subsystem is alive: `MovieCreate: Use Work Size = 0x332100 / Work Adrs = 0x1806c00` — printed **twice**, once per attempt. ### 4. ⇒ The gate is `[+8] fd = 0`, i.e. the .SFD is NEVER OPENED `st=1` with `act=1` means the stream object was constructed and is idle. Real hardware has `fd = 0x004597b0` at that point. **The stream cannot advance to 3 because there is no file behind it.** The blocker moved from the SJX/DTX ack channel to the **CRI file layer** (`clfile`-class open), which is a different subsystem. ### 5. The plan's branch-2 suspect (`0x40A`) is RETIRED — read the recv size Full RPC census this run, with `send/recv` byte counts: | fno | n | send | recv | verdict | |---|---|---|---|---| | `0x2` / `0x422` / `0x400` / `0x408` | 4/12/12/2 | 16/12/16/16 | **4** | handle-shape, minted ✅ | | `0x3` `0x401` `0x403` `0x409` `0x423` `0x40A` | — | 0–12 | **0** | fire-and-forget, **nothing to answer** | | `0x40C` | 2 | 4 (`arg0=0xBB80` = **48000 Hz**) | **12** | data-shape, refused | | `0x40D` | 2 | 0 | **8** | data-shape, refused | ⚠️ **`0x40A` has `recv=0`.** It was suspected of "returning `handle=0`" — it returns nothing at all, because the EE never reads a reply. It cannot be the gate. Only `0x40C` and `0x40D` are data-shape, and both stay refused per the no-IOP-faking rule. ### 6. Liveness at t=119 — healthy, and much deeper than run 52 `busy%=94 res/s=13 vbl/s=14 gif/s=10 dma/s=20 progress=403139 (advancing) pc=0x422660` `pc` is inside **`GameMain` (`0x422630`)** with a 25-deep render trace. Not a park. ### 7. Next — do NOT chase the SJX channel further The question is now *"who opens `movie/atari.sfd` and writes its handle into `[+8]`"*. Cheapest path: static read of `sub_125898` (`adxstmf_stat_exec`) in the IDA dumps to find the `state = 3` store and the condition guarding it. Only then decide whether a run is needed. Other caps this run (absence past them is not evidence): `ARKD:CALL` 64, `ARKD:run` 32, `iop:import` 6/stub, `sceSifSetDma:DTX` 64. --- ## ★★★★ 2026-08-16 (b) — Runs 53–55 RECOVERED from source comments; Stage 5.15 gets its first probe ⚠️ **Bookkeeping failure worth naming.** Runs 53, 54 and 55 happened on 2026-08-12 and were **never written into this file.** Everything they established survived only as comments inside `ps2_iop.cpp`, `SIF.cpp`, `Thread.cpp`, `Interrupt.cpp`, `Dispatcher.cpp` and `ps2_iop_irx_loader.cpp`. The upstream/GS side-quest then consumed two sessions, and `run_log.txt` was overwritten by the 08-16 test-dir run, destroying the only copy of their logs. The run-52 park recorded further down this file is **superseded** by what follows. ### What runs 53–55 actually established **Run 53** — park was `0x408 PS2RNA_Create` returning zero: ``` MovieCreate: Use Work Size = 0x332100 E0100401: can't create PS2RNA of IOP MovieCreate: Create movie handle failed ``` `0x408` was missed on the first SJX sweep because its caller prints no "can't creat" string of its own — it stores the reply at `+32` of the movie object and only the *outer* MovieCreate reports. The decompile settles the shape rather than inferring it: `cmd(8,&args,4,&reply,1)`, 4 words in / **exactly 1 out**, reply stored as `*(obj+32)`, and a later `cmd(9,&{*(obj+32)},1,0,0)` teardown. **A destroy that takes the reply back as its only argument can only mean the reply is an object handle**, so `0x408` joined the minted set ([ps2_iop.cpp:175](ps2xRuntime/src/lib/ps2_iop.cpp#L175)). ⚠️ `0x40C` was re-checked in run 53 and **must stay refused** — its caller stores the reply straight into the sample-rate/mode flag it later feeds back into `0x408`, with no error path. Zero is a legitimate value there; a minted number would be a silent lie. **Run 55** — the `sub_1305B0` stream-close deadlock, decoded field by field from `sub_130638` (the pump), which is the only writer of the in-flight flag: ``` 0x13066c lw $v0,20($s0) ; obj[20] = ring's uncached tail pointer 0x130674 lw $a0,60($v0) ; ack = *(obj[20] + 0x3C) 0x130678 slt $v1,$v1,$a0 ; consumer path iff cursor < ack 0x1306b8 sb $zero,1($s0) ; ... and ONLY there is byte1 cleared 0x130718 sw $v1, 8($s0) ; producer: cursor = cursor + 1 0x13071c sw $v1,60($v0) ; ack = THE SAME VALUE 0x130790 sb $v0, 1($s0) ; byte1 = 1, in flight ``` ⇒ the EE stamps `cursor == ack` on every produce, so the drain gate is false the instant a buffer is queued, and **only the IOP-side consumer in `CRI_ADXI.IRX` ever advances `ack`** — an IRX this runtime never loads. Measured consequence: **two produces in a 120 s run, one per open stream, both left `byte1 = 1` forever**, `sub_1305B0` spinning on the drain at `0x1305e0`. `obj[20]` is `(eeBase + wkLen - 0x40) | 0x20000000`, so the ack word is the ring's **last word**, at `eeBase + wkLen - 4`. ### The fix that is built but never evaluated `ackSjxStreamRingIfMatched` ([SIF.cpp:1549](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp#L1549)) increments that ack word when a `sceSifSetDma` matches a ring registered by `DTX_Create` (`ps2x_sjxLookupStreamByIopDest`, [ps2_iop.cpp:205](ps2xRuntime/src/lib/ps2_iop.cpp#L205)). It is an **acknowledgement, not a payload** — the only thing written is a count of transfers that genuinely were delivered, into a slot the IOP owns. No stream data is fabricated, so this is not the [[feedback_no_iop_faking]] case. **mtimes prove it is in the current exe:** `ps2_iop.cpp` 01:32, `SIF.cpp` 01:33, `build/ps2xRuntime/RelWithDebInfo/ps2EntryRunner.exe` **Aug 12 01:39**. There is no run log and no state-file entry showing it was ever exercised. Run 56 is therefore a **zero-build** experiment. ### Stage 5.15 finally gets instrumentation on its own exit test Until now the ADX stream table had **never been read from inside our own build** — "state 1, fd 0" was borrowed PCSX2 ground truth. Added a 1 Hz sweep to the watchdog block in [ps2_runtime.cpp](ps2xRuntime/src/lib/ps2_runtime.cpp), alongside the existing `gstate@` sampler, emitting `[adx:stream]`. Two design choices, each paid for by an earlier loss: - **Sweeps all 40 entries** (`0x0044beb8`, stride `0x60`) instead of sampling the inferred `0x44bf18`. A wrong base or stride now surfaces as `nonzero=0/40`, which convicts the probe rather than reporting confidently about the wrong bytes ([[feedback_probe_gate_on_shape_not_address]], [[feedback_degenerate_result_convicts_the_probe]]). - **Carries `raw=` next to the decoded fields**, so a mis-split of word 0 is visible in the same line. Word 0 packs `[+0]` active, `[+1]` state, `[+2]` pause little-endian — hardware reads `0x01000301` (state 3), we read `0x01000101` (state 1). - `maxst=` is a run-wide high-water mark: a 1 Hz sampler cannot see a state that comes and goes between ticks, so a stream that really did reach 3 briefly would otherwise be indistinguishable from one that never left 1. - The summary line is emitted **every tick including the all-zero case**, capped at 600 with the standard `[cap]` line, so the probe can never be quietly absent ([[feedback_capped_probes_false_negatives]]). Base/stride/count are env-overridable (`PS2_ADX_BASE` / `PS2_ADX_STRIDE` / `PS2_ADX_COUNT`), matching the `PS2_WATCH_ADDR` precedent — moving the probe costs a run, not a rebuild. ### Exit test for run 56 1. `[sjx:ack]` fires with `was=` reading **1, 2, 3 …** (tracking the produce count — if it does not, the ack address is wrong). 2. `pc=0x1305e0` is gone. 3. `[adx:stream]` shows `nonzero>0` (probe is alive) and `maxst` reaching **3**, with a nonzero `fd=` on that entry. That is the Stage 5.15 exit test. 4. Regression guard: `busy%` / `gif/s` / `dma/s` / `progress` must not collapse against run 52's 63 s of continuous rendering. Compare rates only at equal boot depth. If (1) holds but (3) does not, the drain was not the gate and the next suspect is `0x40A` returning `handle=0` into the shared recv buffer `0x54C600` — the 5th instance of *silence must be written, not omitted*. Decode its caller before minting: `nrecv` 1 = opaque handle (safe), 2–4 = data (must stay refused). ## ★★★★★ 2026-08-16 — **UPSTREAM + GS-REFACTOR TRACK CLOSED ✅.** `ps2EntryRunner` links in the test dir and the recompiled EE code executes. First run in which upstream's GS was ever exercised. User's binding instruction — *"add the GS refactor to the test, make sure all current upstream is included / then we can build entry runner"* — is **complete and empirically demonstrated**. - `git log HEAD..upstream/main` = **0 commits**. Test dir is fully current with `d74a3ce1`. - Upstream's GS is **in the build**. Our four GS files are out at [ps2xRuntime/CMakeLists.txt:420-425](ps2xRuntime/CMakeLists.txt#L420-L425): `ps2_gs_gpu.cpp` (6526 l), `ps2_gs_rasterizer.cpp` (3085 l), `ps2_gs_memory.cpp` (1417 l), `ps2_gif_arbiter.cpp` (113 l). - `F:\sdbz_recomp_test\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe` = **309,277,696 bytes, Aug 16 05:38**. ### The run — before vs after | | prior runs | this run | |---|---|---| | `pc` | `0x100008`, spinning | deep, **varying** call traces | | `busy%` | 0 | 97–102 | | `gif/s` | 0 | 32–33 | | `dma/s` | 0 | 64–67 | | `progress` | flat | 791,240 and climbing ~5.5 k/s | | `dispatch-miss` | flood | **0** | `missing-target` 0, `unrecompiled` 0, `[error]` 0, `assert` 0. `gstate@0x5e6b3c = 0,0,0,1` with `gchg=1`. 57 distinct tags incl. `[frametrace:*]`, `[ARKD:CALL]` 192, `[SifRpcPkt:SIG]` 241, `[iop:import]` 228, `[iop:unhandled]` 116. Run auto-stopped at 96.2 s wall on the `-RunSeconds 90` budget — it did **not** crash. `[cputime]` = CPU-bound, two hot threads (1984 @ 86.61 s, 4508 @ 41.00 s). **⇒ `gif/s≈33` sustained is the first-ever proof that upstream's `GS` + `GSCpuBackend` receives and consumes real GIF traffic without crashing.** Every earlier run was silent about the GS refactor because no guest instruction ever ran. ### ⚠️ Two probe corrections — do not reason past these 1. **`cov=` is NOT the liveness indicator.** This run reads `cov=0/0` while the game is unmistakably executing. I had told the user "`cov=` non-zero — the pass/fail line"; that was wrong. The real liveness set is **`busy%` + `gif/s` + `dma/s` + `progress`**, and the broken runs were identifiable by `busy%=0 gif/s=0 dma/s=0 pc=0x100008`. 2. **RUN VALIDITY FAILURE banner is present** — 4 probes saturated: `iop:import` @6/stub, `sceSifSetDma:DTX` @64, `ARKD:run` @32, `ARKD:CALL` @64. Per [[feedback_capped_probes_false_negatives]] the **absence** of those four tags later in this log proves nothing. Raise `PS2X_IOP_IMPORT_MAX` / `PS2X_SIFDMA_DTX_MAX` / `PS2X_ARKD_RUN_MAX` / `PS2X_ARKD_CALL_MAX` before any conclusion that depends on them. The single `Exception` line in the log is benign: PowerShell wrapping a `[loadfile-seed]` line the child wrote to **stderr** as a non-terminating `RemoteException`. Not a guest fault. ### Root cause of the `0x100008` dispatch-miss — TWO bugs, both closed **Bug 1 — the dispatch table was upstream's null placeholder.** `src/runner/register_functions.cpp` had been overwritten by upstream's 444-byte stub (`Base=0x0`, `End=0x01000000`, table `= {}` — every slot `nullptr`). Restored the real 33 MB file (`Base=0x100008`, `End=0x4e6c84`, `SlotCount=1022751`). The relink grew the exe by **+13 MB** — that table landing in `.data` was the entire size difference. **Bug 2 — the backup file kept a `.cpp` extension and re-entered the glob.** The backup had been named `register_functions.stub-bak.cpp`, which still matches `file(GLOB RUNNER_SRC_FILES CONFIGURE_DEPENDS "src/runner/*.cpp")`. The generator therefore emitted `void register_functions.stub-bak(...)` into `fn_forward_decls.h:25290` → 7 errors (C2146/C2059/C3927/C3484/C3613/C2182/C2365) failing `ps2_runtime.vcxproj`. Renamed to `register_functions.cpp.stub-bak` — **in both `src/runner/` and `output/`**; the `output/` copy was mandatory because [build_scripts/build.ps1:108](build_scripts/build.ps1#L108) syncs `output/` → `src/runner/` on **newer-wins** and would have re-seeded it (Aug 15 05:27 beats `output/register_functions.cpp`'s Jul 23 12:09). ⚠️ Bug 2 also masked a second, quieter failure: had it compiled, it would have duplicated all four `g_ps2RecompiledFunctionTable*` symbols, and under `target_link_options(ps2EntryRunner PRIVATE "/FORCE:MULTIPLE")` the linker could have **picked the all-nullptr definition and linked anyway** — silently reproducing the `0x100008` hang with a clean build log. Reminder: `LNK4075` + 2× `LNK4006` + `LNK4088` are the expected `/FORCE:MULTIPLE` set. No signal. ### Known capability lost to the GS swap **`[drawpath]` is now write-only.** VIF1 still stamps `ps2diag_gifpath::g_curSite`/`g_curSrc` from 8 sites, but the reader lived in our `ps2_gs_rasterizer.cpp:2803/2806`, which left the build. Any Stage 5.11-style draw-**order** question is unanswerable until that stamp is ported into upstream's rasteriser. The site-ID legend survives in `src/lib/ps2_gif_arbiter.cpp` (1–7 = VIF1 unpack / VIF1 DIRECT / GIF chain ×3 / GIF write ×2). ### Open decisions — all deferred to the user, none blocking 1. **Keep upstream's GS or revert?** Adopting it costs our CLUT/texture-page cache and ~5,500 lines of SDBZ-specific work; upstream's replacement is an uncached lookup marked `// TODO: clut cache` at `gs_cpu_backend.cpp:284`. 2. Port the `[drawpath]` stamp into upstream's rasteriser. 3. Adopt `Kernel/EeScheduler.cpp` or keep our `ps2sched::` layer (they share **zero** symbols — only the call sites are mutually exclusive). 4. **IOP heap:** our `0x01A00000` revert at `src/lib/Kernel/Stubs/Helpers/Support.h:39-42` is an **UNCOMMITTED working-tree edit** — any `git checkout`/`reset` destroys it. HEAD still carries upstream PR #203's `0x04000000`. Adopting upstream's layout wholesale also means porting `g_sifHeapStorage`. 5. `ps2xTest` CLUT-cache and `drawStat*` tests no longer compile — fix or delete. **Not yet measured:** what is actually on screen. `[present] nonblack` was retired as an indicator ([[project_stage512_memory_card]]), so display state needs the user's eyes. **Stage 5.15 (the `.SFD` movie gate) is untouched by this session** and remains the blocker. ## ★★★★★ 2026-08-15 — Stage 5.15 scoped by PCSX2 ground truth + upstream test-dir merge ### The symptom, stated exactly (runs 53–59) The intended boot logo order is: 1. memory-card check → 2. shenron disclaimer → 3. **Atari logo** → 4. funimation → 5. **okatron 5000** → 6. bandai Namco → 7. crafts & Meister → 8. softdec/Adx → 9. all rights reserved → 10. opening movie We skip exactly **#3 and #5** — and those are exactly the two short SofDec movies on disc (`MOVIE/ATARI.SFD` 737 KB, `MOVIE/OKR.SFD` 2.4 MB). Everything that plays is a static image out of `GAME.DAT`. > **`.SFD` ⇒ skipped. Static image ⇒ plays. One root cause.** ### Two settled negatives — PCSX2 ground truth, do not re-derive **1. The movie-open path has ZERO missing bodies.** Conditional breakpoint on `strcpy` (`0x00191780`) gated on the source string (`[a1] == 0x69766f6d`, "movi" LE). Three hits, three backtraces, 25 distinct addresses — **all 25 are func-map row starts**. Classified with the new [check_trace_addrs.py](build_scripts/check_trace_addrs.py) (START / FOLDED / ABSENT). ⚠️ **PCSX2's backtrace `entry=` field guesses one instruction early.** It reported `0x0019177c` and `0x0013c4fc` as function entries; both false. `0x0014f880` / `0x0014f1e0` *are* real prologues, but the actual call sites are `jal 0x0014F860` / `jal 0x0014F140` — the row starts, which we already have. Always confirm a suspected hole against the real `jal` target before counting it. ⇒ The dispatch-hole class (Stage 5.13/5.14, closed at run 52) is **not** what skips the movies. Step 8 of the governing plan (63 `missing-slot` holes) stays parked for its own reasons. **2. The `.SFD` open does NOT go through ARKD.** Breakpoint `0x001c0344` (ARKD open-by-name, gated `[0x005a97d4] == 0x69766f6d`) stayed armed across ~1.9 **billion** cycles covering the whole intro and **never fired**, while the strcpy net caught the movie path three times. CRI opens the stream through its own layer. Do not look for the movie in ARKD `func=0x101` again. ### The real movie-open chain (PCSX2, SLUS-21442, hardware) CRI side: `sub_11E9D8` → `0x13c4f8` → `return_zero_e` → `noop_wrapper___`(`0x11d4e8`) → `sub_11D510` → `noop_wrapper___`(`0x125b30`) → `sub_125B58` → **`sub_125898`** (= `adxstmf_stat_exec`) → `sub_12CC20` / `noop_sub_d2a8`(`0x12d2a8`) → `sub_130EF0` / `move_tick_dispatch_g`(`0x130dd8`) → `str_copy_n_03`(`0x1316b0`) → strcpy. App side (copies `movie/atari.sfd` into the SofDec work area `0x01b173a8`): `GameMain`(`0x422630`) → `GameUpdate`(`0x421ea0`) → `0x327f90` → `0x1bf820` → `loadscreen_tick`(`0x3e0e60`) → `0x3e10d0` → `0x420e70` → `0x113aa0` → `0x14f140` → `0x14f860` → strcpy. Source string table at `0x004DCD80`. ⚠️ **More misleading func-map names**, same trap as Stage 5.11: `noop_wrapper___`, `noop_sub_d2a8` and `irq_handler_push`(`0x12f688`) are all live CRI stream functions — not noops, not IRQ code. ### The ADX stream table — the state variable to watch - Base `0x44BEB8`, 40 entries × `0x60` stride. Entry 1 = **`0x44BF18`**. - Fields: `[+0]` active · `[+1]` state (1 = init/idle, 3 = streaming, 4 = error) · `[+2]` pause · `[+8]` fd · `[+0x45]` command · `[+0x49]` busy. Pump guard at `0x4407d8`. - Real hardware: `0x01000301, 0x004597b0, …` — **state 3, live fd**. - Ours: `0x01000101` — **state 1, fd 0**. The stream never starts. ### Therefore — the gate is the IOP audio / CRI side Only `ARKD_DVD.IRX` actually executes. `LIBSD.IRX` and `CRI_ADXI.IRX` are answered "OK!" but never loaded ⇒ no SPU2 ⇒ libsd returns 0 ⇒ `CRI_ADXI.IRX` can't run ⇒ SJX/DTX *data* commands stay refused ⇒ the ADX entry at `0x44BF18` never leaves state 1 ⇒ the EE never opens the `.sfd` ⇒ the logo is skipped. See `project_stage514_sjx_iop_heap` / `project_stage515_movie_sfd_gate`. **The open decision:** build the `ps2_iop_host` bridge so `ps2xIOP`'s `cri_dtx.cpp` can serve sid `0x90000200`, **or** write a from-scratch libsd/SPU2 backend. Not yet chosen. ### Upstream catch-up — inventory written to `.github/UPSTREAM_CATCHUP.md` Baseline `upstream/main = d74a3ce1` *Feature/gs refactor (#204)*, 2026-08-13; swept 08-15. - **TAKEN:** `ps2xIOP/` (21 files, 5,989 lines) — zero coupling to `ps2xRuntime`, wired with one `add_subdirectory` at [CMakeLists.txt:98](CMakeLists.txt#L98). **Builds standalone, links to nothing.** It does *not* give us an SDBZ profile (no sid `0x90000200`/`0x80000003`/`0x80000701` in `builtin_profiles.cpp`), and its `libsd.cpp` is an EE-RPC forwarder, **not** the IRX import stubs for fids `[4,5,6,11,17,18,19,20,26,28]` that `CRI_ADXI.IRX` imports. - **LEFT OUT:** the `ps2_iop_host` bridge (needs diverged headers), GS refactor #204, EE scheduler #184, VU1 refactor #191, `ps2_runtime.h` (**30-hour tripwire**), `MPEG.cpp`/`Audio.cpp` (a hunk-by-hunk merge, and downstream of the current blocker anyway), platform ports, tests. - **Our recompiler is ahead** — `ps2xRecomp/` diff is net-negative toward upstream. #194's MMI2 fix is already in our `instructions.h`; #168's fallthrough guard we hold in the `__entryPc` form. ### `F:\sdbz_recomp_test` — full upstream merge DONE Purpose of that directory (user): *bring the project up to speed with main as a test — show what the project could look like; everything current on main gets added.* It is **sacrificial**; the main tree at `F:\SDBZ Recomp` was verified untouched throughout. | step | result | |---|---| | clean | `build\`, `out\` (2.19 GB), `.vs\` removed → **32.1 GB / 122,908 files** | | baseline commit | `9457ccf6` — local working changes preserved before the merge | | merge | `git merge upstream/main -X theirs` → **`c6816af6`** | | scale | 147 files changed, **30,177 insertions**, 29,977 deletions, **63 files added** | | conflicts | **0** — the predicted 84 `add/add` conflicts were all auto-resolved by `-X theirs` | | deletions | **0** — no common ancestor, so upstream's removals are invisible to the merge | | sanity | `src/runner` and `output` both still **36,153 `.cpp`**; `config.toml` present | **★ The structural finding.** The merged `ps2xRuntime/CMakeLists.txt` is *upstream's*, and `src/lib/*.cpp` is **not** globbed (sources are listed explicitly). So our SDBZ-only files are on disk but **out of the build**: - `ps2_iop_cpu.cpp`, `ps2_iop_irx_loader.cpp`, `ps2_iop_mcman.cpp` — our whole R3000/ARKD path - `ps2_scheduler.cpp` — displaced by upstream's `Kernel/EeScheduler.cpp`, which **auto-compiles** via the `GLOB_RECURSE … src/lib/Kernel/*.cpp` at `ps2xRuntime/CMakeLists.txt:470` - `ps2_gs_gpu.cpp`, `ps2_gs_rasterizer.cpp` — displaced by `gs_frontend` / `gs_cpu_backend` - `game_overrides.cpp` is upstream's **183-line skeleton**; our 4,072 lines of Stage 5.x fixes are out of that copy (intact in the main tree and in `9457ccf6`) - `ps2_gif_arbiter.cpp` now exists **twice** — `src/lib/` (ours) and `src/lib/gs/` (upstream's). Duplicate-symbol hazard; one must go before any configure. ⇒ The test dir faithfully answers *"what does upstream main look like"*. It **cannot boot SDBZ**, so it cannot yet answer the perf question. Re-grafting our layer is a separate deliberate step, and its compile-error surface against upstream's changed `ps2_runtime.h` (67 added / 299 of ours removed) is itself the honest measure of the catch-up cost. ⚠️ The copied build tree was 100 % non-reusable (CMakeCache/vcxproj/tlog all bake absolute paths at `F:/SDBZ Recomp`) and has been deleted. **A `ps2EntryRunner` build in the test dir is a full 30+ hour build** (36,153 runner TUs). ✅ **But `--target ps2_runtime` is minutes** — it compiles only the hand-written layer + the Kernel glob (190 objects), and that is where every integration error lives. Use it for all catch-up measurements. ### 2026-08-15 (build 2) — GS is DONE compiling; `ps2xIOP` built for the first time Second `--target ps2_runtime`. **Every GS refactor file compiled clean** — `gs_frontend.cpp`, `gs_cpu_backend.cpp`, `ps2_gif_arbiter.cpp`, `ps2_gs_memory.cpp`, plus all of `Kernel/`, the ~120 `sub_*` bodies and `ps2_fiber.cpp`. The GS swap itself is no longer producing errors. ✅ **`ps2_iop.lib` linked** (`build/ps2xIOP/RelWithDebInfo/ps2_iop.lib`), 11 TUs including `cri_dtx.cpp`. That closes the Phase 1 open item "ps2xIOP has never been built" — in the test dir. The main tree still has not built it. **One file still fails: `ps2_iop_host.cpp`** — 5 undefined `ps2_stubs::` symbols (`isSifIopHeapAddress`, `isSifIopHeapRange`, `readSifIopHeap`, `writeSifIopHeap`, `zeroSifIopHeap`). Diagnosis below; this was NOT a missing-port oversight. #### ⚠️ The IOP heap divergence — read before touching `kIopHeapBase` Upstream PR **#203** (`8d7e8a5a`) moved the SIF IOP heap from `0x01A00000` to **`0x04000000`**, i.e. *outside* the 32 MB of EE RAM, and gave it a 5 MB side buffer `g_sifHeapStorage`. The five functions above are the escape hatch that change required: guest-memory walkers like `ps2_iop_host.cpp` would call `getMemPtr()` on such an address and fail, so upstream intercepts first and services the access from the side buffer. **We hold the heap at `0x01A00000`, inside EE RAM — a deliberate local revert of #203.** ⚠️ Correction: this revert is an **uncommitted working-tree edit** in the test dir, not a commit — `git status` shows `Support.h` modified, and HEAD still carries upstream's `0x04000000`. It will be lost by any `git checkout`/`reset` of that file. The substantive point (deliberate, load-bearing, do not "fix" it back to upstream) is unchanged. (`Kernel/Stubs/Helpers/Support.h:39`), and it is load-bearing: SDBZ's mcserv path hands the guest heap addresses that guest code dereferences directly (Stage 5.12), so the heap must be EE RAM the guest can actually see. ⇒ Under our layout the hatch is not just unnecessary, **porting it verbatim would be a silent bug** — every heap write would land in a shadow buffer the guest never reads, and the build would go green while the memory card quietly broke. So: - `isSifIopHeapAddress()` returns **false**, computed as `kIopHeapBase >= PS2_RAM_SIZE` rather than hardcoded, so adopting upstream's base re-arms the hatch instead of leaving it wrong. - `isSifIopHeapRange()` is upstream's real implementation (allocation-map only, layout-agnostic). - `readSifIopHeap`/`writeSifIopHeap`/`zeroSifIopHeap` return false — unreachable today, and failing sends callers to their correct EE-RAM path. `g_sifHeapStorage` deliberately NOT ported. Callers are unaffected: with the hatch off they fall through to `getMemPtr()` / `ps2ResolveGuestPointer()`, which handle `0x01A00000` correctly because it is ordinary EE RAM. Low blast radius: **nothing in our tree constructs `PS2IopHostAdapter`** — it is orphaned plumbing for the deferred Stage 5.15 IOP-audio work. Its only job right now is to link. ⚠️ Header touched: `Kernel/Stubs/SIF.h` (declarations). Internal runtime header, but it reaches runner TUs via `ps2_stubs.h`. **Free in the test dir** (no `ps2EntryRunner` objects there yet); this same edit in the main tree would cost the 30+ hour rebuild. **Open decision, unchanged:** whether to eventually adopt upstream's `0x04000000` layout wholesale. Doing so means porting `g_sifHeapStorage`, filling in the three accessors, and re-validating the memory-card path. Not on the critical path — do not do it as a drive-by. ### 2026-08-15 (later still) — first GS build: exactly 2 errors, both fixed The `--target ps2_runtime` build got through everything except two sites. Everything else in the swap compiled clean on the first attempt — including `gs_cpu_backend.cpp`, `ps2_runtime.cpp` (so the rewired `[gs-activity]` probe is right) and all of `Kernel/`. 1. **`gs_frontend.cpp:330,518` — `'vsyncTick': is not a member of 'GSRegisters'`.** Upstream's `GSRegisters` carries a 20th u64, `std::atomic vsyncTick`, right after `csr`; our merged `include/runtime/ps2_memory.h` still had the 19-member version. Added it in upstream's exact slot and bumped the `static_assert` 19 → 20. `gsRegPtr()` maps MMIO offsets **by name**, not by index, so inserting a member shifts nothing. - ⚠️ The only *writer* upstream has is `EeScheduler.cpp`, which we deliberately do not build — so the field would have sat at 0 forever, freezing the interlace field (`gs_cpu_backend.cpp:1780` derives it from bit 0) and pinning the debug frame index at 0. Fixed by publishing the tick from **our** vsync hook, `updateGsCsrFieldForVSync()` in `Kernel/Syscalls/Interrupt.cpp` — the same `tickValue` that sets `CSR.FIELD` two lines later, so the two cannot diverge. 2. **`ps2_iop_host.h:3` — cannot open `ps2x/iop/iop_host.h`.** Not a missing file: it is at `ps2xIOP/include/ps2x/iop/iop_host.h`, and that dir reaches consumers only through `ps2_iop`'s PUBLIC include dir. Upstream's link line is `target_link_libraries(ps2_runtime PUBLIC ps2_host_backend ffmpeg ps2_iop)`; ours omitted `ps2_iop`. Added. The source and the library travel together — keeping one without the other is not an option. - **Side effect, and a wanted one: this compiles `ps2xIOP` for the first time** (10 files), which is Phase 1's open item. No symbol collision with our own IOP modules — upstream's are in `ps2x::iop::detail`, ours in `ps2_iop_dbcman` etc. Header edit disclosure: `ps2_memory.h` changed. In the **test** dir that is free — no `ps2EntryRunner` objects exist there yet. It would cost 30+ h in the main tree. ### 2026-08-15 (later) — GS refactor ADOPTED in the test dir; awaiting its build User direction: *"add the GS refactor to the test, make sure all current upstream is included, then we can build entry runner."* Done in `F:\sdbz_recomp_test` only — main tree untouched. - `upstream/main` re-fetched: still `d74a3ce1`, **0 commits ahead of HEAD**. Nothing on disk is missing; "include all upstream" is purely a question of what the CMakeLists excludes. - **Swapped into the build:** `src/lib/gs/{gs_frontend,gs_cpu_backend,ps2_gs_memory,ps2_gif_arbiter}.cpp` - **Swapped out (on disk, unbuilt):** `src/lib/ps2_gs_gpu.cpp` (6526 l), `ps2_gs_rasterizer.cpp` (3085 l), `ps2_gs_memory.cpp` (1417 l), `ps2_gif_arbiter.cpp` (113 l) - **Consumer edits (7 include lines, 5 files):** `include/ps2_runtime.h`, `ps2_memory.cpp`, `ps2_runtime.cpp`, `vu/ps2_vu1_lower.cpp`, `Kernel/Stubs/GS.cpp` → `runtime/gs/…` paths - **`[gs-activity]` probe rewired**: upstream dropped `drawStatPrims/ImageBytes/LastFbp`; substituted `nativeImageUploadCount` / `nativePackedGIFPacketCount` / `getContextFrame(0).fbp` and **renamed the log fields** (`imgup=`/`gifpkt=`/`fbp0=`) so the line never claims a prim count it lacks - **Speculative addition:** `src/lib/ps2_iop_host.cpp` — upstream's `PS2IopHostAdapter`, referenced by nothing in our tree, built only to prove it compiles against **our** `ps2_runtime.h`, since it is the intended host for the Stage 5.15 CRI/DTX audio path. Drop the line if it errors. - **Deliberately still excluded:** `Kernel/EeScheduler.cpp`. Zero symbol overlap with our `ps2sched::` layer, so it would link — and be **silent dead code**, since our call sites call `ps2sched::`. Adopting it is a separate swap of the same shape as this one. ⚠️ **Size of what this trades away.** Ours 9,611 lines of GS (gpu+rasterizer) vs the refactor's 3,627. Upstream *pre*-refactor was 4,063, so ~5,500 lines is SDBZ-specific divergence — including the **CLUT + texture-page cache** (upstream's `gs_cpu_backend.cpp:284` reads `// TODO: clut cache` and does an uncached VRAM lookup) and the draw-stat probes. `ps2xTest` will not compile against this (CLUT-cache tests, `ps2_gs_rasterizer.h`); left alone — deleting those tests is a user call. **Build order matters:** `--target ps2_runtime` (minutes) must pass *before* `--target ps2EntryRunner` (30+ h), or the long build burns on a config we already know is broken. ### ★★★ 2026-08-16 — `ps2EntryRunner.exe` LINKS in the test dir. Both halves of the instruction met. **`F:\sdbz_recomp_test\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe`, 296,072,704 bytes, 01:40:41.** First binary this target has ever produced. Upstream is fully included (`git log HEAD..upstream/main` = 0) *and* the GS refactor is in the build. - **The 30+ h estimate was wrong: the full runner compile took 3h08m.** Unity batching (size 8) plus `/MP6` is why. Record it — future planning should use ~3 h, not 30. - **Incremental resume is proven.** The first attempt was killed at 3h08m with no exe and no error line; MSBuild prints one or the other, so *neither* means terminated, not failed. The re-run compiled only the TUs that had changed. **A killed runner build is not a lost day as long as the CL tlogs survive** — never delete `build/ps2xRuntime/ps2EntryRunner.dir/`. - **The OOM risk did not materialise.** 15.8 GB RAM completed a `/DEBUG` link over ~1.94 GB of objects with `/FORCE:MULTIPLE`. No `LNK1102`, no `LNK1318`. Retire that concern. - `ps2_iop.lib` also linked (8.6 MB, 11 TUs) — closes the Phase 1 item "ps2xIOP has never been built", **in the test dir only**. The main tree's `ps2xIOP` remains unbuilt. - Warnings `LNK4075` + 2×`LNK4006` + `LNK4088` are the expected `/FORCE:MULTIPLE` set (raylib vs. user32 `CloseWindow`/`ShowCursor`); identical to the 2026-07-29 main-tree build that produced a working exe. **No signal — do not investigate them.** **Two link errors had to be cleared to get here, and they are the same failure class:** 1. `ps2_debug_panel.cpp`, 21 compile errors — upstream renamed `GSZbufReg::zmsk`→`zmask`, `GSPrimReg::prim`→`type`, dropped the `u64` typedef, and turned TEST/ALPHA/TEX1/CLAMP/FBA from bitfield structs (`.data`) into raw `uint64_t`. Fixed with a local `decodeGsTest()` helper (ATE:0/ATST:1-3/AREF:4-11/AFAIL:12-13/DATE:14/DATM:15/ZTE:16/ZTST:17-18 per ps2tek). ⚠️ **MSVC truncated its own error list** — errors certainly present at lines 636-643 produced no diagnostic on the first pass. A clean-looking error list after a wide API rename is not a complete one. 2. `LNK2001` ×2 on `ps2diag_gifpath::g_curSite` / `g_curSrc` from `ps2_vif1_interpreter.obj`. Our `src/lib/ps2_gif_arbiter.cpp` **defined** them and went out of the build; upstream's `src/lib/gs/ps2_gif_arbiter.cpp` has no such namespace. Definitions moved into `ps2_vif1_interpreter.cpp:17-22` (the only consumer left in the build), initialisers preserved verbatim (`{0}` / `{0xFFFFFFFFu}`). > ★★ **The transferable rule.** Swapping a file out of the build silently orphans everything it > *defined* for anyone still building. You do not find these by reading — you find them one > `LNK2001` at a time. `g_curPath` produced **no** error precisely because its writer and its reader > left the build together; that asymmetry is how you tell a complete diagnosis from a partial one. ⚠️ **`[drawpath]` is now WRITE-ONLY.** VIF1 still stamps `g_curSite`/`g_curSrc` from 8 sites, but the only reader — `ps2_gs_rasterizer.cpp:2803/2806` — left the build in the same swap. The stores were kept, not deleted, so the probe can be revived by porting the stamp into upstream's rasterizer. Runs 33/34 depend on those site IDs; the legend survives in the unbuilt `src/lib/ps2_gif_arbiter.cpp:27-33`. **Any Stage 5.11-style draw-order question is unanswerable until that port happens.** **Linking is not running.** Nothing in the test dir has been executed. The exe is entirely unvalidated. ### ★★★ 2026-08-15 — Re-graft EXECUTED in `F:\sdbz_recomp_test`; it builds **Result: `ps2_runtime.lib`, 114.6 MB, 190 objects, RelWithDebInfo, 19:11.** Zero compile errors, zero link errors. Our entire hand-written runtime layer compiles unmodified inside upstream `d74a3ce1`. **The catch-up cost at the C++ level is zero.** | step | what was done | |------|---------------| | restore | **49 files** written from `9457ccf6` via `git show : > ` — never `git checkout` | | keep | **22 upstream-new files** left untouched on disk: `src/lib/gs/*`, `include/runtime/gs/*.h`, `ee_scheduler.h`, `EeScheduler.cpp`, `ps2_iop_host.{cpp,h}`, `ps2_iop_transport.h`, android/vita runtimes | | blocker (a) | dissolved — restoring **our** `ps2xRuntime/CMakeLists.txt` brings back the explicit `src/lib` list, so upstream's `src/lib/gs/*` is simply never named. No duplicate `ps2_gif_arbiter` | | blocker (b) | our `CMakeLists.txt` still `GLOB_RECURSE`s `src/lib/Kernel/*.cpp`, which would have swept in upstream's `EeScheduler.cpp`. Fixed with a `list(REMOVE_ITEM …)` + rationale comment at [ps2xRuntime/CMakeLists.txt:474-481](ps2xRuntime/CMakeLists.txt#L474-L481) | | blocker (c) | dissolved with (a) — our source list is complete by construction; all 28 named `src/lib` sources verified present | **★ The schedulers never collided.** Ours is `namespace ps2sched::` free functions + `extern "C" ps2x_*`; upstream's is `class EeScheduler` methods — **zero symbol overlap**. They are mutually exclusive only because they are consumed by the *same call sites* (`Kernel/Syscalls/{Interrupt,Sync,System,Thread}.cpp`, `ps2_runtime.cpp`). Grafting `ps2_scheduler.cpp` without also restoring those call sites would have made it silent dead code — a link-clean build that does nothing. Restoring the whole Kernel layer is what makes it live. **★ Verified content-identical to the main tree, with a positive control.** Hash comparison over `ps2xRuntime/{src/lib,include}` → 228 identical, **46 byte-different, 0 missing**. All 46 were then `git diff --no-index --quiet`-tested: **0 real content differences** — the 46 are pure LF-vs-CRLF (files written by `git show` land LF). Control: the loop confirmed it visited all 46, and a single-file check on `game_overrides.cpp` (4,072 lines both sides, 217,399 vs 221,725 bytes) returned exit 0. ⚠️ **Never conclude "differs" from a hash or a byte count alone on this repo** — line-ending skew makes every `git show`-restored file look changed. Header divergence from `9457ccf6` is **+2,044 lines across 12 files, 0 deletions** — all of it upstream's new `include/runtime/gs/*.h`. Our headers are untouched, so the feared "67 added / 299 of ours removed" `ps2_runtime.h` conflict never materialised: we kept our header. The main tree's only uncommitted header edit is +3 lines (`probeVu1MemoryOccupancy()` decl), and `9457ccf6` already carries it. **What this does and does not prove.** It proves our layer + upstream's toolchain, `ps2xIOP`, and root CMake coexist and compile. It does **not** prove the merged tree boots SDBZ — that needs the 36,153-TU `ps2EntryRunner` link, which is the 30+ hour build and is still unrun. It also leaves upstream's GS refactor *unadopted*: `gs/gs_frontend.cpp` + `gs/gs_cpu_backend.cpp` sit on disk, excluded, while our `ps2_gs_gpu.cpp` / `ps2_gs_rasterizer.cpp` build. Adopting the GS refactor is a separate, still-open decision. ### ⚠️ New hazard to respect **Never run `ps2_recomp.exe` with the live `config.toml`.** It writes new `sub_*` files into `output/`, which [build.ps1:108](build.ps1#L108) syncs into `src/runner/`, which changes `fn_forward_decls.h` — recompiling all ~4,520 TUs. Always regenerate to a **scratch** output dir and diff first. ### Next actions 1. Decide the IOP-audio approach: `ps2_iop_host` bridge → `ps2xIOP/cri_dtx.cpp`, vs a from-scratch libsd/SPU2 backend. Everything in Stage 5.15 is downstream of this. 2. `ps2xIOP` has **never been built** — the command was handed over; no compile claim until output is read. 3. ✅ **DONE 2026-08-15 — the graft compiles clean. The compile-error surface is EMPTY.** 4. Housekeeping: `build_scripts/build_pr137test.ps1` and `build_scripts/boot_test.ps1` still reference the now-deleted `F:\SDBZ-Recomp-pr137-test`. ## 2026-08-12 — Agent policy update (no code/build change, GitHub Copilot / Claude Sonnet 5) **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — a user-directed change to how this agent (Copilot) documents its own work going forward. - **This agent now writes only to this file (`PS2_PROJECT_STATE.md`) to record progress.** `.github/copilot-instructions.md` and `.github/COPILOT_PLAYBOOK.md` are read for orientation but are no longer edited by this agent unless the user asks for that file by name — avoids the two-file drift that made `copilot-instructions.md` go stale (last dated 2026-08-04, still describing the closed Stage 5.9 as the live blocker, while this file had already moved through Stages 5.11–5.14 to Stage 5.15/run 52). - **This agent has adopted Claude Code's full rule set** from `C:\Users\mwlab\.claude\projects\f--SDBZ-Recomp\memory\MEMORY.md` as binding — the hard rules (no runner/header edits, no destructive git, no self-run builds, no IOP faking, ps2xStudio out of scope) plus the measurement-discipline lessons (verify translations by decoding, gate probes on shape not an assumed address, read the missing-target record not the spin PC, capped probes are false negatives, hardware breakpoint first for "who wrote X" questions, stale-object-file check, `run_log.txt` is UTF-16, etc.) — see `/memories/repo/rules.md` (this agent's own persistent memory) for the full merged list. - Net effect: going forward, treat this file as the single running log for BOTH Claude Code and Copilot sessions on this repo; do not expect new Copilot-authored `.md` files elsewhere. ## ★★★★★ 2026-08-09 — **STAGE 5.11 CLOSED ✅. Root cause: an unrecompiled qsort comparator. The memory-card dialog now renders like PCSX2 — confirmed by eye.** ### The bug, in one paragraph `sub_002FF5C0` (the dialog/scene renderer) calls `qsort(base=0x5D6EC0, num=N, size=4, cmp=0x2FF580)` at `0x2ff800`, then walks the sorted pointer array calling each object's vtable slot `+0x2C` to append its primitives. **Draw order == qsort order.** But `0x2FF580` was *never recompiled*: it is never a `jal` target, appearing only as qsort's 4th argument, so the recompiler never emitted a body for it. The indirect `JALR` therefore hit `dispatchGuestBranch`'s missing-target path under `MissingFunctionPolicy::ContinueToTarget` (`policy=1`), which logs **once**, does not unwind, and resumes with `$v0` untouched — leaving `v0=0x1`. Every comparison answered "A > B", producing a deterministic garbage permutation. The full-screen background sorted to slot 9 and painted over the dialog panel every frame. **Same class of gap as `0x1c0170`.** Any address reached only as a function *pointer* has no generated body. Worth a systematic sweep. ### The fix — [game_overrides.cpp](ps2xRuntime/src/lib/game_overrides.cpp) `sdbzDrawOrderCompare2FF580` + `runtime.registerFunction(0x002FF580u, ...)`. Ascending sort on a float key at object offset `+0x18`: ``` keyA = (*a)->float[0x18]; keyB = (*b)->float[0x18]; if (!(keyA <= keyB)) return +1; // !(a<=b), not (a>b): a NaN key falls out as +1, if (keyA < keyB) return -1; // matching the ordered MIPS c.le.s / c.lt.s compares return 0; ``` An indirect JALR **does** consult the registry (via `dispatchGuestBranch → lookupFunction`), which is why `registerFunction` is the correct lever here — unlike a direct C++ `fn_` call, which bypasses it. ### Exit test — both halves PASSED (run 39) | Check | Before | After | |---|---|---| | `[guest-branch:missing-target] target=0x2ff580` | present | **0 hits** — and 0 missing targets of *any* kind in the whole log | | Background draw index | 9th (after 8 border strips + box) | **`bgat=0`** — first | | Box body | 8th | `boxat=1` | | Glyphs | 10+ | `glyphat=11` | The probe fired its own pre-registered pass branch: ``` [drawpath] boxat=1 boxpath=2 bgat=0 bgpath=2 glyphat=11 glyphpath=2 boxsite=2 bgsite=2 glyphsite=2 verdict=BACKGROUND-FIRST-matches-the-dump-so-submission-order-is-CORRECT-and-run34-was-a-gate-artifact ``` ### Three threads that closed as *consequences*, not independent facts - **VRAM allocation drift** (chased since 08-08). The 256×256 PSMT8 background now uploads to `dbp=0x2a00` — the first slot, matching PCSX2. Allocation follows submission order. It was never a separate bug. - **"Degenerate T4 index fetch ⇒ zero coverage ⇒ invisible text"** — DEAD. `[glyphfate] pix=0x80fffffe ... cc=1 abe=1`: glyph pixels write near-white at alpha `0x80` (= 1.0 on PS2), consistent with the white alpha-coverage-ramp CLUT. - **`[present] nonblack`** 9,180 → **64–65k** of 229,376, both buffers, `dispFbp` ping-ponging `0x0` ↔ `0x70` correctly. ### Two measurements that must NOT be re-read as open bugs - **CLUT base** `0x2b00/04/08/0c` live vs `0x2a48` in the dump. **Not a valid cross-run test** — PCSX2 was parked on a different memory-card page. Already established in `project_stage511_gsdump_replay.md`. - **`[glyphfate] unsound-seq glyphlast=… boxlast=… total=0`** — the probe declares itself unsound and accumulated zero samples. *A degenerate result convicts the probe.* This line is evidence of nothing. ### ⚠️ Func-map names that hid this path for ~38 run cycles Four SDK-signature false positives. A future session reading these names would dismiss the entire render path as string/memory helpers. **Trust the disassembly, not the name.** | Address | Verified role | func-map name | |---|---|---| | `0x002FF5C0` | **Dialog/scene render** — sorts, then authors the command buffer inline | `str_copy_n_e_clone_88` ❌ | | `0x002FF580` | **Draw-order comparator** (float key at obj+0x18) | **NO ENTRY, NO RUNNER BODY** | | `0x0018FCE8` | qsort | `sub_18FCE8` ✓ neutral | | `0x0019B010` | display-list enqueue (append to tail) | `sub_19B010` ✓ neutral | | `0x0019B110` | display-list flush/walk | `obj_set_fields___` ❌ | | `0x0019B710` | command-stream interpreter (jump table at `0x0019B868`) | `mem_compare_n_0_clone_06` ❌ | | `0x0010A940` | GIF packet writer | `fighter_track_advance_clone_01` ❌ | | `0x0010A940`… | callers `0x3280C0` / `0x421EA0` / `0x422630` | `GameState_Update` / `GameUpdate` / `GameMain` ✓ | Other addresses worth keeping: command buffer EE `0x01646400` len `0x324`; queue object `0x005E3620` (`next`@+0, `head`@+4, `tail`@+8); object-pointer array `0x5D6EC0`; count `N` at `gp-0x1ED4` with `gp = 0x503070`. ### Learned patterns - **An address that is only ever a function *pointer* gets no recompiled body.** The failure is silent under `ContinueToTarget`: one log line, then `$v0` garbage forever. Two occurrences now (`0x1c0170`, `0x2ff580`). - **A single wrong comparator return value is indistinguishable from a rendering bug.** ~38 run cycles went into the GS layer for a defect that was three levels upstream, in guest logic. - **Exonerating layers is not wasted work, but it has a stopping rule:** once the GS/DMA/VIF layers were all clear, the question had become a guest-execution question and no GS probe could ever answer it. Switching to PCSX2 as ground truth + reading the recompiled body found it in one cycle. ### ★★★ Stage 5.13 (opened 2026-08-10) — boot-screen spin at `0x132444`; missing body at `0x12F708` **Stage 5.12 is CLOSED, both items, user-confirmed run 44:** the memory-card screen was passed with a controller press. Card detected, text correct and in the right order, X accepted. The game advanced to the **Atari loading screen** and then spun. Console: ``` Error: dispatch-miss x1351500000 (still spinning, latest guest PC 0x132444) ``` **Do not chase `0x132444` — it is the fall-through, not the fault.** The single `[guest-branch:missing-target]` record (first-report-only, so it appears exactly once in `run_log.txt`) names the real address: ``` [guest-branch:missing-target] kind=DirectJump op=J source=0x13243c target=0x12f708 pc=0x12f708 ra=0x113f50 sp=0x1ffbeb0 codeRegion=yes policy=1 ``` **Mechanism.** `0x132430` is a 5-instruction tail-call thunk ending in `j 0x12f708`. `0x12f708` has **no recompiled body** — IDA folded the callee into the thunk and named the pair `noop_wrapper___270`, so the func map lists only `noop_wrapper___,0x00132430,0x00132444,0x14` and has a **hole** between `0x12f704` and `0x12f760`. So: 1. `dispatchGuestBranch(target=0x12f708, DirectJump)` → `hasFunction` false 2. `MissingFunctionPolicy::ContinueToTarget` (policy=1) sets `pc=0x12f708` and returns **true** 3. the generated thunk therefore runs its own last line, `ctx->pc = 0x132444u` 4. `0x132444` is the nop padding the thunk out to the next function — no table entry, so the dispatch loop misses, the miss does not advance `pc`, and it re-misses **1.35 billion times** This is the third instance of the boundary-discovery gap class (`0x1c0170`, `0x2ff580`), but the **first reached by a direct `j`** rather than a `jal`/`jalr`. New symptom shape: the spin address sits one instruction past a func-map *end*, and the missing function is never named by the dispatch-miss lines at all. **What `0x12F708` actually is.** All 21 words decoded field-by-field from `ELF/SLUS_214.42`: `SetHostLock(flag)` — stores `(int8_t)a0` to `0x0044D274` and `0x0044D2A4`, and when `flag == 1` tail-calls the varargs print helper at `0x177988` with `a0 = 0x004BA358` = `"SRD: Enable HostLock\r\n"`. The `sd`/`ld $ra` pair and the −16/+16 on `$sp` are net-neutral. **Fix (applied, build pending):** hand-translated `sdbzSetHostLock12F708` in [game_overrides.cpp](ps2xRuntime/src/lib/game_overrides.cpp) + `registerFunction(0x0012F708u, …)`. Registration is what makes it reachable — a direct `j` goes through `dispatchGuestBranch`, which consults the registry (unlike a direct C++ `fn_` call). **Exit test for run 45:** the `dispatch-miss … 0x132444` flood is gone. Secondary: the log should carry `SRD: Enable HostLock` if the flag ever goes to 1. If a *new* spin appears, read the `[guest-branch:missing-target]` line first — same class, different hole. #### RUN 45 (2026-08-11) — ✅ `0x12F708` fix confirmed; instance 4 at `0x13C4F8` `0x132444` is gone. The spin moved to `0x13c718` — and, exactly as predicted, that is again the *fall-through*, one word past the func-map end of the thunk at `0x13c700`. The one-shot record: ``` [guest-branch:missing-target] kind=DirectJump op=J source=0x13c668 target=0x13c4f8 ra=0x11e408 sp=0xfffb0 a1=0xc v0=0x3 codeRegion=yes policy=1 trace=0x174f50 -> 0x172e20 -> … -> 0x104b30 -> 0x104f20 ``` Func map jumps `0x13c4f4` → `0x13c5c0`, so `0x13c4f8` has no body. It is the shared tail of **eight** sibling thunks at `0x13c658`, `0x13c670`, … `0x13c700`, each of which only loads its own index into `$a0` (0 through 7) and jumps there. `0x13c718` is itself another real function sitting in the same gap. `0x13C4F8` is a **callback-list runner**, all 21 words field-decoded: - `0x0054E960 + idx*72` — six 12-byte `{fn, arg, pad}` records - `0x0045EFE8 + idx*4` — "a callback is running" flag, set 1 before each call, 0 after - `0x0045EFC8 + idx*4` — per-index invocation counter, bumped on the way out - returns the bitwise OR of every callback's return value Two decode traps: `addiu $s0, $s0, 0xc` sits in the **BEQ's delay slot**, so the record pointer advances even for an empty slot; and the loop-back is `bgezl` (branch *likely*), so its `lw $v0, 0($s0)` delay slot is skipped on the final not-taken test. **Fix (applied, build pending):** `sdbzRunCallbackList13C4F8` + `registerFunction(0x0013C4F8u, …)`. Registering the shared tail retires all eight thunks and the `0x13c718` spin with one override. #### The gap class is now measured — 144 missing bodies, not a one-off New tool: [find_dispatch_holes.py](build_scripts/find_dispatch_holes.py). It decodes every `j`/`jal` in the ELF, keeps those whose *source* lies inside a known function, and reports targets with no `g_ps2RecompiledFunctionTable` slot — the same test `hasFunction()` performs, so it finds these holes statically instead of one run cycle at a time. ``` python build_scripts/find_dispatch_holes.py --class missing-body → 207 holes total (144 missing-body, 63 missing-slot), 249 call sites ``` `missing-body` = target sits in a func-map gap, nothing was generated (needs an override). `missing-slot` = a body covers the address but no entry point is registered. It correctly re-finds all three already-fixed instances (`0x1c0170`, `0x12f708`, `0x13c4f8`), which is the confidence check on the method. **Only direct branches are visible this way** — indirect `jalr` targets (the `0x2ff580` class) come from runtime tables and cannot be enumerated statically. ⚠️ **Strategic consequence.** Fixing 144 bodies one spin per run is not a viable path. The options, for a decision before grinding further: 1. **Regenerate just the missing functions.** ✅ **CHOSEN AND EXECUTED 2026-08-11 — see "Stage 5.14 — tail-chunk recovery" below.** The stated blocker was wrong twice over: `config.toml` *does* exist at the repo root (it names the merged CSV as the recompiler's function list), and the root cause was never a translation gap at all — `export_func_map.py` dropped every IDA tail chunk from the map. This line said the option was closed for six runs. 2. **An R5900 interpreter fallback** for unregistered PCs, mirroring the existing R3000 IOP interpreter. Fixes all 207 at once *and* the indirect class, permanently — the largest piece of work, and the only option that closes the class rather than the instances. 3. **Continue hand-translating** only what the boot path actually hits. Cheapest per step, and the four so far were each ~20 instructions, but the run-cycle cost is unbounded. **Exit test for run 46:** no `dispatch-miss` flood at `0x13c718` or `0x13c4f8`. Expect the next stop to be either a new hole from the list above (read `[guest-branch:missing-target]` first) or, if the callback list contains unregistered pointers, an `IndirectCall` miss from inside `sdbzRunCallbackList13C4F8`. #### RUN 46 (2026-08-11) — ✅ `0x13C4F8` confirmed, and the frame loop came alive The `0x13c718` flood is gone, and this fix moved more than the spin. Watchdog deltas, run 45 → 46: | field | run 45 | run 46 | |---|---|---| | `busy%` | 0 | **99** | | `res/s` | 0 | **60** | | `vbl/s` | 0 | **60** | | `progress` | 214440 | **1715881** | | `gif/s` / `dma/s` | 0 | 0 (still) | The game is now actually running its frame loop at 60 Hz rather than idling. Nothing is being submitted to the GS yet. **Instance 5 — `0x116CD0`.** Console spun on `0x116d2c`; the one-shot record named the real target: ``` [guest-branch:missing-target] kind=DirectJump op=J source=0x116d24 target=0x116cd0 ra=0x120a64 sp=0x1ffbe90 a0=0x0 v0=0x43cb90 codeRegion=yes policy=1 ``` Func map confirms both halves of the pattern: ``` noop_wrapper_e, 0x00116c40, 0x00116cac <- gap 0x116cac..0x116ce0; 0x116cd0 is inside get_global_var_2, 0x00116ce0, 0x00116cec <- the GETTER for the same word wrap_wrap_mem_set_h, 0x00116cf0, 0x00116d2c <- 0x116d2c = one past END = the spin ``` The whole missing function is three instructions: ``` 0x116cd0 lui $v0, 0x44 ; $v0 = 0x00440000 0x116cd4 jr $ra 0x116cd8 sw $a0, -13428($v0) ; DELAY SLOT; 0x440000 - 0x3474 = 0x0043CB8C ``` It is the **setter** half of a pair — `get_global_var_2` at `0x116ce0` is `lui $v1,0x44 / jr $ra / lw $v0,-13428($v1)`, the same word. Two tail-call thunks reach it by direct `j`: `0x116cf0` (sets `$a0=0`, restores `$ra`, drops its frame) and `0x120a10` (pure pass-through). **Fix (applied, build pending):** `sdbzSetGlobalVar116CD0` + `registerFunction(0x00116CD0u, …)`. **★ The census predicted this one in advance.** `find_dispatch_holes.py` had already listed `0x00116cd0 missing-body n=2 j from 0x116d24, 0x120a1c` before the run happened — the tool is predictive, not just descriptive. #### The 144 are not equally hard — 53% are mechanical Each missing-body hole was decoded forward to its first `jr $ra` and bucketed on length and on whether the body contains any control flow: | bucket | count | share | meaning | |---|---|---|---| | trivial | **76** | 53% | ≤8 instructions, straight-line leaf — most are 2-instruction accessors | | small | 35 | 24% | ≤20 instructions | | medium | 22 | 15% | ≤60 | | large | 9 | 6% | >60 | | bad | 2 | 1% | unmapped or no return found | This reframes the three options above. A **single batch pass** over the trivial bucket — either 76 generated overrides, or one scoped straight-line R5900 interpreter registered at all 76 addresses — retires half the class at once instead of one hole per run cycle. Decision still open. #### ★ PCSX2 title-screen trace (2026-08-11) — the remaining gap is FOUR functions Rather than keep discovering holes one run at a time, we took ground truth from the real game. PCSX2 was paused on the title screen and read over MCP. The EE backtrace, nine frames: ``` #0 0x001751c0 #1 0x001721e0 #2 0x001712d0 #3 0x00104c00 #4 0x00199840 #5 0x00421ea0 #6 0x00422630 #7 0x0008fefc (pc 0x00100210) #8 ``` New tool: [path_holes.py](build_scripts/path_holes.py) walks the direct call graph (`j`/`jal`) to closure from those nine roots and reports every reachable target with no dispatch slot. ``` reachable functions walked: 772 holes on this path: 5 (4 missing-body, 1 missing-slot) ``` **Four.** Not 144 — the boot path we are stuck on and the title-screen path share almost everything, and one of the four (`0x13c4f8`) was already fixed this run. All are now handled: | addr | what it is | fix | |---|---|---| | `0x1748a0` | EE syscall stub, `addiu $v1,2 / syscall / jr $ra` (SetGsCrt). Called from `0x1720d0`+`0x172148`, both inside frame #1 | `sdbzSyscallStub1748A0` | | `0x1748c0` | same cell shape, syscall 4, from `0x17f438` | `sdbzSyscallStub1748C0` | | `0x1748e0` | same cell shape, syscall 6, from `0x17f410` | `sdbzSyscallStub1748E0` | | `0x100218` | two-word trampoline, `j 0x17f418 / nop`, from `0x18c5bc` | `sdbzTrampoline100218` | | `0x1bb460` | **missing-slot:** a body covers it (`sub_001BB450`) but no slot; `0x1bb450` is `j 0x1bb460`, so the body's first act is to dispatch to an address it has no slot for | alias via `lookupFunction(0x1bb450)` | | `0x180d30` | on the list but a **false positive** — already registered with its generated body | — | ⚠️ **`find_dispatch_holes.py` reads `register_functions.cpp` only**, so anything fixed from `game_overrides.cpp` still shows as a hole. Cross-check the overrides before translating. #### Two measurements that reshape the 144 **1. Check for a generated file before hand-translating.** Testing `runner/fn__0x.cpp` for all 144 missing-body holes: ``` generated file EXISTS (just needs registering): 8 truly absent: 136 ``` So mass registration is *not* the escape hatch — but eight were free, and `0x13c4f8` was one of them (hand-translated before checking; the override works, so it stays). The six not already covered are now registered directly: `0x1137b0`, `0x1c9980`, `0x1c9ab0`, `0x256960`, `0x341920`, `0x356cd0`. **2. The indirect-call class has no *known* holes.** Scanning every ELF data word for a 4-aligned pointer into the code range that the func map calls a real function start but that has no slot: **zero hits.** Every statically-tabled function pointer we can identify is registered. The `0x2ff580` class therefore comes only from pointers into func-map *gaps* — functions IDA never discovered — which cannot be separated from data by address alone. **Exit test for run 47:** no `dispatch-miss` flood at `0x116d2c` or `0x116cd0`. Watch whether `gif/s` / `dma/s` come off zero now that the frame loop runs at 60 Hz — `0x1748a0` is SetGsCrt on frame #1 of the real title-screen stack, so it is directly implicated in nothing being submitted. Per the trace, there should be **no more direct-branch holes** between here and the title screen; if run 47 still stops, the cause is a different class (indirect call, or a wrong translation). #### RUN 47 (2026-08-11) — ✅ dispatch-hole class CLOSED. The white screen is the game giving up. The trace's prediction held exactly: **zero `dispatch-miss`, zero `[guest-branch:missing-target]` in the whole run.** No direct-branch hole remains between the memory card and the title screen. The white Atari screen is a *different failure and a much better one* — the game is not stuck, it reached its own error handler and deliberately parked: ``` [SIF:BIND] client=0x44e370 sid=0x90000200 [Deci2Call:kputs] E0100301: SJX_Init can't allocate IOP Heap ``` ``` 0x13b838 jal 0x17cfd8 ; sceSifAllocIopHeap(0x8d0) 0x13b840 bne $v0, $zero, 0x13b878 ; nonzero = success 0x13b844 sw $v0, 0($s3) ; [0x45E454] = the IOP address 0x13b84c jal 0x120440 ; printf(0x4bc710) 0x13b86c beq $zero, $zero, 0x13b858 <- deliberate infinite loop ``` That loop is what the watchdog was reporting all along: `pc=0x13b858`, `busy%=99`, `res/s=60`, `vbl/s=60`, `progress` climbing ~1.7M/s, `gif/s=0 dma/s=0`, `stuckSecs=335`. The 60 Hz "frame loop" in the watchdog trace is only the **vblank interrupt handler** (`0x171320` = `j 0x11fa60`) firing into a thread that is spinning on six nops. GS *had* been working — `[gs:image] n=1698 dbp=0x2a00 psm=0x13` texture uploads are in the log right up to the failure. **Root cause:** `0x17cfd8` is the real libsif `sceSifAllocIopHeap`, and it does `sceSifCallRpc(cd=0x564980 /*sid 0x80000003*/, fno=1, send=0x564A00/4, recv=0x5649C0/4)` then returns `[0x5649C0]`. **We had no RPC service for sid `0x80000003`**, so the four-byte receive buffer was never written and the game read 0. This is the *third* instance of the shape recorded in [[project_stage512_memory_card]]: **the answer lives in the receive buffer, and a handler that writes nothing is indistinguishable from a legitimate failure.** **Fix** (`ps2xRuntime/src/lib/ps2_iop.cpp`, alongside the other kernel RPC services): serve sid `0x80000003` — fno 1 ALLOC, fno 2 FREE — from a bump allocator over IOP RAM `0x00008000..0x00040000`, 64-byte aligned. That slice deliberately sits **below** everything else this runtime puts in IOP RAM (ARKD_DVD image at `0x40000`, its `sceAllocSysMemory` arena at `0x60000..0x1d0000`, IOP thread stacks below `0x1efff0`), so it collides with nothing while still returning addresses inside the real 2 MB. Not IOP faking — the heap service ships in the IOP BIOS, there is no IRX of ours to run, same as the cdvdman S-command path next to it. **Cross-checked against PCSX2 before writing it.** The next gate, `0x130418`, validates that `(iopAddr + 0x40) & ~0x3F` is 32-byte aligned and stores it to `0x45E458`. PCSX2 at the title screen holds **`[0x45E458] = 0x00183940`** — a genuine in-IOP-RAM address, 64-byte aligned. Our answer has the same shape and clears the same check. **Exit test for run 48:** `[iop:iopheap] fno=1 arg=0x000008d0 -> 0x00008000` in the log, and no `SJX_Init can't allocate IOP Heap`. ⚠️ If `arg=0x00000000` the allocation size never reached us — that means the generic RPC bridge failed to recover the 4-byte send payload and fell back to the recv buffer, **not** that the heap is exhausted; the log line exists to tell those two apart. Expected next gate: sid `0x90000200` (SJX) has no handler either, and `0x13b8b0` has a second identical print-and-park at `0x13b8c0` (string `0x4bc740`). #### RUN 48 (2026-08-11) — ✅ iopheap exit test PASSED. Both predicted next gates hit, together. ``` [iop:iopheap] fno=1 arg=0x000008D0 -> 0x00008000 send=0x00564A00/4 recv=0x005649C0/4 [Deci2Call:kputs] DTX_Create: can't create DTX of server [Deci2Call:kputs] E0100302: SJX_Init can't create DTX watchdog: pc=0x13b8c0 (the second print-and-park, exactly as predicted) ``` The exit test passed verbatim — `arg=0x000008D0`, so the RPC bridge recovers a 4-byte send payload correctly. `E0100301` is gone. The spin moved forward by one gate, to the *second* infinite loop. **Both predicted gates turned out to be the same gate.** `SJX_Init`'s next call is `DTX_Create(id=0, eewk, iopwk, wklen)` at `0x130418`; it clears all four validators (the aligned `0x8040` we produced passes the 32-byte check) and then issues, from `0x130288`, a `sceSifCallRpc(cd=0x44E370 /*sid 0x90000200*/, fno=2, send=0x54C500/0x10, recv=0x54C600/4)`. **Fourth instance of the receive-buffer shape** — no handler for that sid, recv never written, handle reads 0, `DTX_Create` prints `0x4BA620` and returns 0, and `SJX_Init` parks at `0x13b8c0`. **The server for sid `0x90000200` is inside CRI_ADXI.IRX** (sid constant at file offset `0xd654` of the shipped module; scanned all seven disc IRXs, it is the only hit). There is no SJX or DTX IRX on the disc. **PCSX2 ground truth, DTX slot 0 at `0x44DAF0`** (stride 0x44, max 0x10 slots): | off | real value | meaning | |---|---|---| | `+0x00` | `0x00010101` | in-use / flags | | `+0x04` | **`0x0008BAB0`** | **the DTX handle — an IOP address** | | `+0x0c` | `0x0045E700` | eewk (EE work buffer, 64-aligned) | | `+0x18` | `0x00183940` | iopwk (= `[0x45E458]`) | | `+0x1c` | `0x00000880` | wklen | Also `[0x45E454] = 0x00183900` — real `sceSifAllocIopHeap(0x8d0)` returned `0x183900`. Ours returns `0x8000`. Both are valid IOP addresses and the value is only forwarded, never interpreted. **★ The DTX handle is opaque to the EE.** `0x8BAB0` is IOP RAM; the EE stores it at `slot+4` and echoes it back in later RPCs, and cannot dereference it. That is what makes serving this sid locally lossless, and it is why it is *not* the [[feedback_no_iop_faking]] situation — that rule came from inventing ARKD_DVD's payload, data the game actually read. **Decision (user, 2026-08-11):** serve sid `0x90000200` in `ps2_iop.cpp` now; revisit running the real CRI_ADXI.IRX once rendering is proven. Cost of the real path, for when we return to it: the `sceSifRegisterRpc` capture + dispatch machinery already exists (it is how ARKD's `0x500..0x503` work), but the loader is single-module throughout — one `g_arkdCpu`, one base `0x40000`, one arena, one service map — and CRI_ADXI needs 44 import stubs across 10 libs, 10 of them `libsd` (SPU2). **Fix:** handler in `ps2xRuntime/src/lib/ps2_iop.cpp` next to the IOP-heap one. `fno 2` mints a handle `0x4000 + id*0x100` (reserved slice of real IOP RAM below the heap arena, so it can never alias a `sifIopHeapAlloc` block), refusing `id >= 0x10` rather than minting past the window. Everything else answers zero. **All SJX calls share recv buffer `0x54C600`, so the handler zeroes the receive buffer on every call** — writing nothing would leave the previous reply in place to be read as a fresh answer. Silence has to be written, not omitted. **Exit test for run 49:** `[iop:sjx] fno=2 args={0x00000000,0x0045E700,0x00008040,0x00000880} -> handle=0x00004000`, and neither `E0100302` nor `DTX_Create: can't create` in the log. ⚠️ If the args come back all-zero with `send=…/16`, the 0x10-byte send payload did not reach us — a bridge problem, not a refusal. Audio stays silent by construction; that is expected, not a regression. #### RUN 49 (2026-08-11) — ✅ DTX exit test PASSED verbatim. GIF/DMA traffic appears for the first time. ``` [iop:sjx] fno=2 args={0x00000000,0x0045E700,0x00008040,0x00000880} -> handle=0x00004000 [watchdog] t=80s gchg=1 gstate@0x5e6b3c=0,0,0,1 busy%=107 res/s=66 vbl/s=65 progress=446358 gif/s=10 dma/s=20 stuckSecs=0 pc=0x136b30 ra=0x136b2c lastCall=0x136b30 trace=0x13c4f8 -> 0x136b30 -> 0x104b30 -> 0x1bfb80 -> 0x1bfa50 -> 0x171320 -> ... ``` **Every predicted arg matched, field for field.** `E0100302` and `DTX_Create: can't create` are both gone. ★ **`gif/s=10 dma/s=20` — run 48 had `gif/s=0 dma/s=0`.** This is the first run where the guest issues GS traffic at this stage, so the DTX gate really was holding back the render path and not merely the audio path. **The park moved up one layer, same shape.** `pc=0x136b30` is another deliberate nop loop; `ra` `0x136b2c` is the return of `jal 0x120440` (printf) at `0x136b24`, whose `a0` is `0x4BBE28` = `"E0110102: ps2rna_init_psj: can't creat SJUNI_CreaetRmt"`. The gate is `0x136b18`, `bne $v0, $zero` on the return of `0x13b278` = `SJUNI_CreateRmt`. **★ The generic command channel is now the blocker, and its surface is MEASURED, not guessed.** `0x130910(cmd, sendArr, nsend, recvArr, nrecv)` copies `nsend` words into `0x54C500`, issues `sceSifCallRpc(cd=0x44E370, fno=cmd+0x400, send=0x54C500, recv=0x54C600)`, copies `nrecv` words back, returns `[0x54C600]` — **the same shared buffers as fno 2**, so the zero-on-every-call rule stays load-bearing. A scan of the whole ELF for `jal 0x130910` finds **22 call sites / 21 distinct commands** (`build_scripts` scratch scan; command + `nsend` + `nrecv` recovered from the immediates). `ps2rna_init_psj` (`0x136980..0x136c64`) is a loop over `[0x455D80]` voices; per iteration it does `SJUNI_CreateRmt` → `0x13a228` → a vtable call → `SJX_Create` (`0x13b9d0`), each gated by its own `E01101xx` print-and-park. Settled globals: `0x455D80` count, `0x455D84` wk size, `0x455D8C` raw `sceSifAllocIopHeap` result (a **second** heap call, at `0x1369ec`), `0x455D90` the 64-aligned wk pointer bumped `0x100` per voice, `0x455DE0` the EE object array (stride `0x18`). **Fix:** mint opaque handles for the four *create* commands only — `0x400` SJX_Create, `0x420` SJUNI_Create, `0x421` SJUNI_CreateX, `0x422` SJUNI_CreateRmt — from a bump counter over IOP RAM `0x5000..0x8000`, stride `0x40` (above the DTX sub-window which tops out at `0x4F00`, below the heap arena at `0x8000`, so neither can alias). **The other 17 commands keep returning zero on purpose:** `0x40C`, `0x424`, `0x426` and friends receive two to four words of *data*, not a handle, and a plausible-looking invented number there would steer the game silently — strictly worse than the refusal it already knows how to print. The `[iop:sjx]` cap is raised to 512 so a single run reveals the whole boot-path command sequence instead of costing one build cycle per error code. **Exit test for run 50:** `[iop:sjx] fno=0x422 … -> handle=0x00005000` appears, `E0110102` does not, and the park at `0x136b30` is gone. ⚠️ Read the `[iop:sjx]` census in the log before doing anything else: any command logged with `handle=0x00000000` immediately before a new park names the next thing to decode. ⚠️ `handle=0x00000000` on a *create* means the `0x5000..0x8000` window was exhausted — that is a window-size problem, not a missing service. #### RUN 50 (2026-08-11) — ✅ SJX creates PASSED; the game left `ps2rna_init_psj`. New park is a DISPATCH HOLE, not an error loop. ``` [watchdog] t=88s cov=0/0 bssnz=41353 bsschg=7 gchg=1 gstate@0x5e6b3c=0,0,0,1 busy%=101 res/s=61 vbl/s=61 progress=3396435 gif/s=0 dma/s=0 stuckSecs=22 pc=0x14facc ra=0x14e598 lastCall=0x14facc trace=0x14facc -> 0x14facc -> ... (x32, all identical) Error: dispatch-miss x406300000 (still spinning, latest guest PC 0x14facc) ``` ★ **`x406300000` is a COUNT, not an address** — 406.3 million misses. `ps2_runtime.cpp:1526` prints a running total every 100000 once the first 8 reports are suppressed. A future reader will misparse this as a garbage jump target; it is not one. **This is the dispatch-hole class, back on a new code path** — Stage 5.13's class-closure held for the *title-screen* path, which is what the PCSX2 backtrace covered; the sound-init path was never in that trace. **Decode (spin PC is the fall-through, as always):** `jal 0x14fab8` @ `0x14e590` ⇒ `ra=0x14e598` — matches the watchdog exactly. `0x14fab8` is a 5-word thunk whose only job is `j 0x14fc88`; `0x14fc88` has no body (map runs `0x14fc48..0x14fc88` then jumps to `0x14fcb0`, leaving `0x40` bytes unclaimed), so dispatch falls through to the `nop` at `0x14facc` and re-dispatches it forever. **★ Swept the enclosing module instead of fixing the one hole.** `0x14fa58..0x150008` is a small flag module over four words at `0x00460F10..0x00460F1C`, and it holds **five** holes, every one a 2–5 instruction accessor. All five are translated into `game_overrides.cpp`: | hole (map gap) | real entry | body | |---|---|---| | `0x14fbfc..0x14fc28` | `0x14fc00` | normalise `$a0` → `[0x460F10] = a0 ? 1 : 0` | | `0x14fc34..0x14fc48` | `0x14fc38` | `[0x460F14] = $a0` | | `0x14fc88..0x14fcb0` | `0x14fc88` | `[0x460F10]=1`, `[0x460F14/18/1C]=0` ← **the blocker** | | `0x14fe84..0x14fe98` | `0x14fe88` | `[0x460F1C] = $a0` | | `0x14ff78..0x14ff80` | `0x14ff78` | `jr $ra / nop` — genuine no-op | ⚠️ Each hole's map start is a padding `nop` belonging to the *previous* function, real entry one word later. Callers may name either, so **both addresses are registered to the same body**. **Exit test for run 51:** the `dispatch-miss` flood and the `0x14facc` spin are gone, and `pc`/`ra` move somewhere new. ⚠️ If a *new* `dispatch-miss` flood appears, read `ra` first — it names the `jal` site, and the thunk at that target names the real hole. The spin `pc` never does. ⚠️ `gif/s=0 dma/s=0` this run is **not** a rendering regression — run 49 reached `gif/s=10` because it parked *later*; run 50 never got as far as the render loop. #### RUN 51 (2026-08-11) — ✅ flag-block sweep PASSED. Deepest boot yet; new park is a memset hole. Build chain verified fresh (overrides 03:12 → obj 03:29 → exe 03:29:41 → log 03:30:37). **Exit test passed verbatim:** the `0x14facc` spin is gone and the run has **zero** `dispatch-miss` lines. All five holes in the `0x460F10` flag module were correct. **Real GS traffic for the first time at this depth** — `gif/s=46 dma/s=59` at t=1s (run 49 peaked at `gif/s=10`). MCMAN / MCSERV / LIBSD all loaded OK; `CMemory::Init memsize : 0173fc00`. Then the EE thread died: t=165s shows `busy%=0 res/s=0 gif/s=0 dma/s=0 stuckSecs=104`, with `vbl/s=61` — ⚠️ the vblank handler alone, **not** the game. `pc=0x421ed8` is a dead-thread artifact and means nothing. **The whole run emitted exactly ONE structured record, and it names the fault directly:** ``` [guest-branch:missing-target] kind=DirectJump op=J source=0x1641a8 target=0x1443e0 ra=0x15df30 a0=0x560730 a1=0x0 ``` Caller decodes cleanly as `memset(0x560730, 0, 0x48)` via a **tail `j` after `ld $ra`**, which is why `ra` points past the jump site to the grandparent: ``` 0x164194 lui $a0, 0x56 0x16419c addiu $a0, $a0, 0x730 ; a0 = 0x560730 (matches the record) 0x1641a0 daddu $a1, $zero, $zero ; a1 = 0 (matches) 0x1641a4 addiu $a2, $zero, 0x48 0x1641a8 j 0x1443e0 ``` **Module sweep — two holes, both translated in one build:** | Map gap | Real entry | What it is | |---|---|---| | `0x1443dc..0x1444b0` | `0x1443e0` | **the blocker** — *reverse* byte `memset`: seeks to `dst+n`, fills downward | | `0x1440fc..0x1442d0` | `0x144100` | forward byte `memcpy`; 32 B/iteration | Fix: `sdbzMemFill1443E0` + `sdbzMemCopy144100` in [game_overrides.cpp](ps2xRuntime/src/lib/game_overrides.cpp), registered at all four addresses (hole start + real entry each). Translated **loop-for-loop, store-for-store** rather than as host `memcpy`/`memset` — every routine in the game leans on these two, so a version that gets the bytes right but an end-state pointer wrong would corrupt silently. ⚠️ **Only `0x1443e0` is proven reachable.** `0x144100` is module-sweep inference. If run 52 misbehaves in a *new* way, re-audit the memcpy first. ⚠️ **Misleading func-map names, again (4th time).** The eight `wrap_mem_copy*` thunks at `0x144040..0x1440fc` do **not** feed `0x144100` — they `j 0x18e250`. ★ The `n >> 5` block count was *measured*, not assumed: the loop body holds exactly 32 `sb` and exactly two `addiu $a0,$a0,0x10`. **Exit test for run 52:** the `[guest-branch:missing-target]` record for `target=0x1443e0` is gone, and the run either pushes past the point where `busy%` collapsed or parks somewhere new. #### RUN 52 (2026-08-11) — ✅✅ THE DISPATCH-HOLE CLASS IS CLOSED AT RUNTIME First run with the 135 recovered tail-chunk bodies present. Build chain verified fresh (`game_overrides.obj` 19:54:30 → exe 20:00:26 → log 20:05:07), 136 TUs compiled, zero runner TUs, `fn_forward_decls.h` mtime unchanged at 7/24. **Both exit tests passed, and by the strongest possible margin:** | check | run 51 | run 52 | |---|---|---| | `[guest-branch:missing-target]` records | 1 (`0x1443e0`) | **0** | | `dispatch-miss` lines | 0 | **0** | | static `missing-body` holes | 125 | **1** (`0x00257160`, the known data-blob case) | | seconds of live guest execution | ~1 | **63** | ★ **This is a different regime, not an increment.** Runs 45–51 each parked within a second or two. Run 52 ran **63 seconds of continuous rendering** — `gif/s` 45 → 8 → 15, `dma/s` 14–63, `busy%` ~100 throughout, `bsschg` 19–30 per second (the game mutating its own state, not idling). The recovered bodies are executing: nothing hand-registered covers that much of the boot path. **Where it stops now — a NEW class, not another hole.** At t≈63s the game runs a second-phase PS2RNA init, all of which *succeeds*: ``` [SIF:BIND] client=0x44e370 sid=0x90000200 [Deci2Call:kputs] PS2RNA: sceSifAllocIopHeap(99904) ret=0x00008900 [iop:sjx] fno=0x422 ... -> handle=0x00005000 (x6, each paired with) [iop:sjx] fno=0x400 ... -> handle=0x00005040 [Deci2Call:kputs] PS2RNA: sceSifAllocIopHeap(6208) ret=0x00021840 [iop:sjx] fno=0x40A args={0x6,0x00021840,0x00001840,0x0} -> handle=0x0 recv=0x0054C600/0 ``` …then creates **four EE worker threads** and stops dead: ``` EECREATE tid=0x3 entry=0x11e7e0 stack=0x441a10 size=0x800 EECREATE tid=0x4 entry=0x11e8d0 stack=0x443210 size=0x1000 EECREATE tid=0x5 entry=0x11e9d8 stack=0x444210 size=0x1000 EECREATE tid=0x6 entry=0x11eac8 stack=0x445210 size=0x2000 ``` **`EE threads created: 5, started: 0`.** From t=64s on: `busy%=0 res/s=0 gif/s=0 dma/s=0`, with `vbl/s` running away to **832** — the host IRQ thread free-running because no guest thread is consuming vblanks. `pc=0x421ed8` is the dead-thread artifact and means nothing (it reads the same during the *healthy* 63 seconds). ★ **`started: 0` is real evidence, not a missing probe.** The `EESTART` emit site exists ([Thread.cpp:511](ps2xRuntime/src/lib/Kernel/Syscalls/Thread.cpp#L511)) and every *silent* early return above it was ruled out: all four entries are in `register_functions.cpp` so the `hasFunction` bail cannot fire, and `launch_recomp.ps1:409` captures stderr via `2>&1 | Tee-Object`, so the absence of `[StartThread] … is not registered` and `create_fiber failed` is meaningful. **Syscall 0x22 was never issued.** The game stopped *between* `CreateThread` ×4 and `StartThread`. **Last guest call before the stall is `0x13c4f8`** — `sdbzRunCallbackList13C4F8`, the hand-written Stage 5.13 override. The frozen call ring ends `… 0x11e3b0 → 0x11e560 → 0x11f240 → 0x13c658 → 0x13c4f8`, i.e. the game's own thread-manager module (`0x11exxx`, which owns all four thread entry points) calling into the callback-list runner. Since that override dispatches arbitrary function pointers, and the run logged **zero** `IndirectCall` misses, every callback resolved — one of them entered and did not come back. **Exit test for run 53 (Stage 5.15):** find why syscall 0x22 is never reached. Two candidates, in order of cost: 1. The `0x40A` SJX command returns `handle=0` with `recv=…/0` — nothing written. Per the settled Stage 5.14 rule (*shared recv buffer ⇒ silence must be written, not omitted*), a caller polling that buffer sees whatever the previous command left. This is the 5th instance of that shape. 2. A callback invoked from `sdbzRunCallbackList13C4F8` blocks. Instrument its loop to log each `callback` address and whether it returned, rather than guessing which one. ⚠️ Do **not** read `gif/s=0` at t≥64s as a rendering regression. It is downstream of the stall, and run 51's `gif/s=46` was measured at a boot depth this run passed in its first second. ### ★ Stage 5.12 (opened 2026-08-09, ✅ CLOSED 2026-08-10 run 44) — memory card + controller The dialog text is legible and matches PCSX2. The game is asking for input and storage: ``` Checking the memory card (PS2) in MEMORY CARD slot 1. … No memory card (PS2) inserted in MEMORY CARD slot 1 or MEMORY CARD slot 2. A memory card (PS2) with at least 110KB of free space is required to create a Super Dragon Ball Z save data. … X button to continue. ``` Two work items, both **unstarted**: 1. **Memory card not detected.** Existing surface: [Kernel/Stubs/MemoryCard.cpp](ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard.cpp), [Kernel/Stubs/MemoryCard_Internal.h](ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard_Internal.h), [ps2_iop_mcman.cpp](ps2xRuntime/src/lib/ps2_iop_mcman.cpp). Needs triage: is McServ/mcman answering "no card", or is the RPC never reaching it? SDBZ needs ≥110 KB free. 2. **No way to press X.** — ✅ **BUILT + MECHANICALLY VERIFIED 2026-08-10 (run 40).** Input now reaches the guest. But **X still does not dismiss the dialog** — see the run-40 result block below. The pad was necessary, not sufficient; suspicion has moved to item 1. Item 1 has not been investigated — the file list above is an inventory, not a diagnosis. #### ★★★ Stage 5.12.2b — RUN 43 (2026-08-10): GetDir verified; pad ROOT CAUSE found (build pending) Built 09:28, ran 09:35. **GetDir fix confirmed:** ``` [iop:mcserv] GetDir port=0 slot=0 path='BASLUS-21442SDBZ' maxent=32 table=0x005EB000 -> entries=0 [iop:mcserv] GetDir port=1 slot=0 path='BASLUS-21442SDBZ' maxent=32 table=0x005EB000 -> entries=0 ``` The path decodes to the real SDBZ save-directory name, `maxent`/`table` come off the correct offsets, and `entries=0` is the truthful answer for a formatted card with no save. rpc histogram `164× 0x1, 4× 0xD, 2× 0xFE`. `strA` still reads `"Super Dragon Bal"` — no regression. User reports the memory-card text is now correct **and in the right order**, but still cannot get past the screen with any input. ##### Root cause of "no input" — decoded, not guessed The game's pad manager is `sub_109900` @ EE `0x109900`. Its shape: ```c switch ( scePadGetState(port, 0) ) { // 0x188548 case 1: case 5: return 1; // busy, wait case 2: case 6: /* state machine */ break; default: v9[2] = 0; return 0; // reset } // state v9[2] == 0 (no case matches, falls past the switch): v13 = scePadInfoMode(port, slot, MODECURID, 0); // 0x188970 -> half[101] >> 4 v9[3] = v13; // cached pad id if ( v13 ) { ... v9[2] = 70 (id 7) / 40 (id 4) / 99 ... } ``` **Blocker 1 (the fatal one).** `scePadInfoMode(MODECURID)` returns `half[101] >> 4`. Our push never wrote byte 101, so it returned **0**, `if (v13)` never fired, `v9[2]` stayed 0 forever, and the `scePadRead` at `LABEL_40` was **never reached**. Buttons were published into the buffer and never read. This is why run 40's mechanical checks all passed while nothing happened on screen — they proved the buffer was correct, not that anyone read it. **Blocker 2 (behind it).** The read site is `if ( scePadRead(port, slot, &buf) && !buf[0] )`. Byte 0 is the SIO2 frame validity byte and must be **0**; `readState()` was publishing `0x01`. **Fix (`ps2_pad.cpp`, build pending):** | Offset | Was | Now | Why | |--------|-----|-----|-----| | status `+0` | `0x01` | `0x00` | frame validity; `!buf[0]` gate | | half `+100` | unwritten | `1` | == `+114` ⇒ `scePadInfoMode` reports 0 for MODECUREXID/MODECUROFFS/MODETABLE — honest for a pad with no mode table, and keeps `v9[3]` at 7 | | half `+101` | unwritten | `0x73` | pad id byte; `>>4 == 7` ⇒ DualShock2, matches `status[1]` | `status[1]` stays `0x73` (analog, 6 bytes, **no** pressure). `0x79` would be wrong here: it makes the game run a second loop that overwrites its own "button held" bytes with our zeroed pressures. With id 7 the state machine goes 70 → 71 → 72 → 99; states 70/71/72 negotiate DS2 mode over padman RPCs we do not answer, and **every failure path funnels to state 99**, which is the steady read state. So no padman RPC work is needed for input to function. **Exit test for run 44:** X dismisses the memory-card screen. Mechanical fallback if it does not: the `[pad]` records are unchanged in meaning, so a new failure is downstream of `scePadRead` — check `cachedId == (buf[1] >> 4)` by probing `v9[3]`. #### ★★★ Stage 5.12.1 — RUN 42 (2026-08-10): GetInfo fix VERIFIED, memory-card gate is OPEN Built 09:05, ran 09:12. **Exit test passed on both halves.** - `[iop:mcserv] GetInfo port=0 slot=0 block=0x00569140 -> type=2 free=8192 format=1 result=0` — the block address is the one the guest itself supplied at `send+0x1C`, so the 192-byte-block reading is confirmed against the game, not assumed. - **The dialog text is gone.** `[fontgate] strA=0x89e400` read `"No memory card ("` in run 41; it now reads `"Super Dragon Bal"`. The panel is no longer the failure dialog. - GetInfo call count went 4 → **82** (the game polls the card once it believes one exists). - **`rpc=0xD` (GetDir) appears for the first time, ×2** — the game only browses the card after accepting it. That transition is the real proof the gate opened. - `[present] nonblack` still cycles 64188/65140. Not a contradiction: those are whole-frame pixel counts, and a text swap inside one panel moves far fewer than ~950 pixels. **Retire that field as the dialog-change indicator** — `strA` is the direct measurement and it moved. See `feedback_probe_gate_on_shape_not_address`. #### Stage 5.12.1b — GetDir has the SAME trap, fixed in the same file (build pending) `sceMcGetDir @ EE 0x18A078` packs its 1044-byte struct as `+0x00 port`, `+0x04 slot`, `+0x08 mode`, `+0x0C maxent`, **`+0x10 EE address of the caller's 64-B-per-entry table`**, `+0x14 name (1023)`, recv **4**. The `cache_writeback_range(table, maxent << 6)` right before the call is what pins the 64-byte stride. Our handler read the path at `+0x08`, `maxent` at `+0x08 + kMcMaxPathLen`, never read the table pointer, memcpy'd entries into the **4-byte recv buffer** (so `copyBytes` floored to 0), and never wrote the result code at all. Net effect: GetDir always answered "0 entries". That is a *plausible* answer for an empty card, so it did not block the gate — but it is wrong, and it would break save enumeration. Now fixed: correct offsets, entries written to the EE table, result code written to recv, plus an `[iop:mcserv] GetDir …` trace line. **Exit test for run 43:** `[iop:mcserv] GetDir port=0 slot=0 path='…' maxent=N table=0x……` with a non-negative `entries=` count, and no regression in `strA`. #### ★★★ Stage 5.12.1 — RUN 41 (2026-08-10): mcserv GetInfo ROOT CAUSE FOUND + FIXED (superseded by RUN 42 above) `PS2X_MCSERV_TRACE=1` on the **existing** exe (no rebuild) gave the decisive measurement. The RPC sequence is short and complete: ``` [iop:mcserv-route] sid=0x80000400 rpc=0xFE send=0x00568CC0/48 recv=0x0056A200/12 (Init, once) [iop:mcserv-route] sid=0x80000400 rpc=0x1 send=0x00568CC0/48 recv=0x0056A200/4 (GetInfo, x4) ``` **The RPC does arrive and we do handle it.** Both prior hypotheses are dead: it is not "the request never reaches mcserv", and it is not "the reply never completes" (the end callback path at `RPC.cpp:2630` does invoke `endFunc`). The bug is the **reply layout**. **Ground truth — the game's own libmc, read from `decompiles_SLUS_214_42.txt`:** - `sceMcGetInfo @ EE 0x189E88` fills the shared 48-byte command struct at `0x568CC0` as `+0x04 port`, `+0x08 slot`, `+0x0C wantFormat`, `+0x10 wantFree`, `+0x14 wantType`, `+0x1C = EE address of a 192-byte result block`. It then calls RPC **1**, mode **1** (NOWAIT), recv **4 bytes**, endFunc `0x189E30`, endParam = that same block. - `endFunc @ 0x189E30` ORs `0x20000000` onto the block and copies **`+0x00`→type, `+0x04`→free, `+0x90`→format** into the caller's out-pointers. - `sceMcSync @ 0x189D68` returns `*0x56A200` (the 4-byte result) as the call's return value. **So the card info never travels in the recv buffer.** Our handler had three faults: 1. `if (recvSize >= 12u)` — recvSize is **4**, so the branch never fired and we wrote **nothing at all**, not even the result code. `sceMcSync` returned the stale Init reply. 2. We never wrote the 192-byte result block, so type/free/format stayed whatever was there. 3. Port/slot were read at `+0x00`/`+0x04`; GetInfo puts them at `+0x04`/`+0x08`. The struct is shared across all commands, so `+0x00` held leftovers from a previous command. **Fix applied** in `ps2_iop_mcman.cpp` `case kRpcGetInfo:` — read port/slot/block from the real offsets, write the result code when `recvSize >= 4`, and write type/free/format into the result block at `+0x00`/`+0x04`/`+0x90`. Legacy inline triple kept only for the case where no block pointer is supplied. New trace line `[iop:mcserv] GetInfo …` (trace-gated). **Exit test for run 42:** `[iop:mcserv] GetInfo port=0 slot=0 block=0x……` with `type=2 free=8192 format=1 result=0`, and the dialog changes (`[present] nonblack` leaves its 64188/65140 two-value cycle). **Font side — checked, no blocker.** The glyph cache is generic, not per-dialog: `o5C=0x200` (512 slots) with `o2C=0x1d6` (470 free ⇒ 42 in use), `roles3` glyph draws climbing past 84k, one FFON sheet at `0x659400` (24×24 PSMT4) serving everything. The string buffer at `0x89E400` is already being rewritten in-run ("No memory card (" → "N "), so the text path re-renders on demand. Nothing to pre-load; the next dialog's text simply is not requested until the card check passes. #### Stage 5.12.2 — RUN 40 RESULT (2026-08-10): the pad path works; the dialog gate does not open Built 08:38, run 08:40. **Every mechanical check in the exit test passed:** | Check | Result | |---|---| | `[pad] first push port=0 slot=0 buf=0x502e80 half=1 ctr=1 btns=0xffff` | ✅ (port 1 also opened, `buf=0x502f80`) | | X/Space held ⇒ `btns=0xbfff` (active-low `PAD_CROSS=0x4000`) | ✅ 30+ transitions, halves alternating, `ctr` advancing 597→779+ | | `[pad] cap: change log capped at 64` | ✅ cap line present, so the change record is *saturated*, not exhaustive | | `[iop:PADMAN]` still 4 records | ✅ modversion / init / portopen ×2 | | **X dismisses the memory-card dialog** | ❌ **NO** — user confirms no input bypasses the check | The screen never moved: `[present] nonblack` flat at **65140** across the whole run, and the last `[drawpath]` still reports the same panel (`boxat=1 bgat=0 glyphat=11`). **Conclusion: the pad fix is correct and is retained. It was necessary but not sufficient.** The gate is item 1 (memory card), which is almost certainly re-arming the dialog every frame regardless of input. ⚠️ **Retracted mid-session:** an earlier read of run 40 concluded "no memory-card RPC ever reaches mcserv after Init." **That is not established.** Only `kRpcInit` (0xFE) has an *unconditional* log; `kRpcGetInfo` (0x01) and every other handled command are served **silently**, and both `[iop:mcserv] unhandled` and `[iop:mcserv-route]` are gated behind `PS2X_MCSERV_TRACE`. The single `[iop:mcserv] Init` record is therefore consistent with *either* "nothing else arrived" *or* "everything else arrived and was answered." Classic [[feedback_capped_probes_false_negatives]] — absence behind a gate is not a measurement. **What IS established for item 1 (static, no run needed):** - The game loads the real modules off the disc: `[iop:LOADFILE] path="cdrom0:\MCMAN.IRX;1"` and `"cdrom0:\MCSERV.IRX;1"` (also SIO2MAN, LIBSD, CRI_ADXI, ARKD_DVD, PADMAN). Only ARKD is actually executed; MCMAN/MCSERV are HLE'd. - Our SID matches: `IOP_SID_MCSERV = 0x80000400`, `IOP_SID_MCSERV_LEGACY = 0x80000080` ([ps2_iop.h:15](ps2xRuntime/include/runtime/ps2_iop.h#L15)). - **If GetInfo reaches us we answer "healthy card":** `McPortState::formatted` defaults to `true` ([MemoryCard_Internal.h:71](ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard_Internal.h#L71)), so `kRpcGetInfo` replies `cardType=2 (PS2)`, `freeBlocks=0x2000`, `format=1`, `result=0` — far above the 110 KB the game demands. So a *reaching-and-answered* GetInfo cannot by itself explain the dialog; the failure would have to be in reply/completion delivery. - The `sceMc*` stubs in [Kernel/Stubs/MemoryCard.cpp](ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard.cpp) are fully implemented **but are the same dead-code shape as `Kernel/Stubs/Pad.cpp`** if SDBZ statically links its own libmc — which the MCSERV.IRX load strongly suggests it does. Not yet confirmed. **Leading hypothesis (INFERRED, not measured — do not treat as fact):** structurally identical to the pad bug. Real libmc is asynchronous — `sceMcGetInfo` returns immediately and the game polls `sceMcSync` for completion. If our RPC reply never signals the guest's completion path, `sceMcSync` never reports done and the game falls back to "no card." Note `dispatchIopRpc` already carries a `signalNowaitCompletion` out-param, so the machinery partly exists. **NEXT STEP — zero-rebuild measurement.** `[iop:mcserv-route]` at [ps2_iop.cpp:69](ps2xRuntime/src/lib/ps2_iop.cpp#L69) logs sid + rpcNum for **every** mcserv RPC (cap 96) and is gated only on `PS2X_MCSERV_TRACE`. Re-run the *existing* exe with that env var set and read the RPC sequence. Only after that is it worth writing code. #### Stage 5.12.2 — pad input: root cause and the fix (written 2026-08-10, BUILT — see run 40 above) **Root cause: there was no input path to the guest at all.** Keyboard-vs-USB was never the variable. - [ps2_pad.cpp](ps2xRuntime/src/lib/ps2_pad.cpp) has a complete raylib keyboard + gamepad map, but it is wired to [Kernel/Stubs/Pad.cpp:302](ps2xRuntime/src/lib/Kernel/Stubs/Pad.cpp#L302). **SDBZ statically links its own libpad**, so it never calls our SDK stubs — that backend was dead code for this game. - The guest's real path is guest libpad → SIF RPC → IOP `padman`. Our HLE in [ps2_iop.cpp](ps2xRuntime/src/lib/ps2_iop.cpp) (sid `0x80000100`, rpcNum 1) answers `0x12` modversion → `0x0400`, `0x10` init → 1, `0x1` portopen → 1. `run_log.txt` proves the handshake completed: **4 records against a cap of 16**, so the absence of more is a real measurement, not a truncation. - **Real padman pushes the per-frame pad state IOP→EE asynchronously on vsync** (SIF CMD `0x80000019`). We never did. So the game's 256-byte pad buffer kept its portopen-time init forever: `+96` length 0, `+112` state 5 (EXECCMD), `+113` reqState 2 (BUSY), buttons `0xFF` ⇒ `scePadRead` returned 0 and every button read as released. **Dead end worth recording:** `0x187F68` is *not* a per-frame poll. Its only confirmed caller is `scePadSetActDirect @ 0x188B60` (vibration). There is therefore **no reliable per-frame EE→IOP DMA to hook** — the trigger had to be host-side. `registerFunction` is also unusable here: the game reaches `scePadRead` by `jal`, which the recompiler emits as a direct C++ `fn_` call that bypasses the dispatch registry ([[feedback_registerfunction_bypass]]). **libpad address table.** Every dump label is a misleading SDK-signature false positive — third occurrence of that failure mode after Stage 5.11's render path: | Address | Real role | `decompiles_SLUS_214_42.txt` label | |---|---|---| | `0x187ED8` | cmd `0x0F` sender | `fn_cond_call_00187ed8` | | `0x187F68` | request/actuator DMA sender | `mem_compare_unk` | | `0x188080` | `scePadPortOpen` | `module_obj_init_z_73` | | `0x188320` | internal half-picker + 128B copy | `mem_fill_z_24` | | `0x1884D0` | **`scePadRead`** | `mem_compare_unk_e` | | `0x188548` | **`scePadGetState`** | `mem_compare_unk_f` | | `0x1885F8` | `scePadSetReqState` | `fn_obj_exec_001885f8` | | `0x1886B0` | `scePadGetReqState` | `mem_compare_unk_g` | | `0x188B60` | `scePadSetActDirect` | `mem_compare_unk_j` | **Guest memory layout (decoded from the guest's own libpad, not guessed).** Pad table at EE `0x568990`, stride **112 B/port**, **28 B/slot**; entry = `0x568990 + port*112 + slot*28`: | Off | Meaning | |---|---| | `+0` | pointer to the game's 64-byte-aligned **256-byte** pad data buffer | | `+4` | 32-byte EE→IOP request buffer (`0x568A80 + port*128 + slot*32`) | | `+8` | IOP-side buffer address returned by portopen | | `+12` | pending DMA id | | `+16` | **open flag** (`== 1`) | The 256-byte buffer is **two 128-byte halves**. `0x188320` selects with `*(int*)(buf+88) < (__int64)*(int*)(buf+216)` — **strictly-less, so equal counters select half 0.** Within a half: | Off | Meaning | |---|---| | `+0..+31` | 32-byte pad status — exactly the format `PSPadBackend::readState` already emits | | `+88` | frame counter (signed `int`; picks the newer half) | | `+96` | data length (32); also `scePadRead`'s return value | | `+112` | state — **6 = PAD_STATE_STABLE** | | `+113` | reqState — **0 = COMPLETE, 2 = BUSY** | | `+114` | actuator ok flag (`scePadSetActDirect` needs `== 1`) | ⚠️ **`scePadGetState` special-cases `state==6 && reqState==2` and returns 5 (EXECCMD).** Writing `+112 = 6` alone is NOT enough — `+113` must be cleared to 0 as well. This trap was caught by reading `0x188548` before building, not by a run cycle. **The fix — 2 `.cpp` files, 0 headers:** - [ps2_pad.cpp](ps2xRuntime/src/lib/ps2_pad.cpp) — new `extern "C" void ps2x_pad_push_frame(uint8_t *rdram)`. Emulates one padman vsync push: walks ports 0–1 × slots 0–3, skips any entry whose open flag ≠ 1, validates the buffer pointer (non-zero, 64-byte aligned, `phys + 256 <= 0x02000000`), fills the **stale** half, then bumps that half's `+88` counter to `max(c0,c1)+1` **last**, behind a `std::atomic_thread_fence(release)` — the guest EE runs on a different host thread, so the flip must be atomic from its point of view. Counter wraps at `0x40000000` (signed compare). Port 0/slot 0 gets real host input; any *other* opened port gets a **valid but idle** pad rather than being left in portopen's BUSY state, so a guest loop waiting for all opened pads to reach STABLE cannot strand. - [ps2_runtime.cpp](ps2xRuntime/src/lib/ps2_runtime.cpp) — one call immediately after `EndDrawing()` in `PS2Runtime::run()`, plus the matching `extern "C"` beside the existing `ps2x_host_sampler_*` externs. **That site was chosen because `EndDrawing()` has just run raylib's `PollInputEvents()` on that same thread** — the vblank tick in `Kernel/Syscalls/Interrupt.cpp:642-662` is more faithful to padman's 60 Hz cadence but is a different thread and would race raylib. **Probe:** tag `[pad]` — first-push records (cap 8) and button-change records (cap 64), each with its own `[cap]` line per [[feedback_capped_probes_false_negatives]]. **Exit test (next session — START HERE):** 1. `& "F:\SDBZ Recomp\build.ps1" RelWithDebInfo` (user runs it; errors via `Select-String -Path "F:\SDBZ Recomp\build_log.txt" -Pattern "error"`). 2. Active Runner Command with `-Determinism 0`. 3. In `run_log.txt` (**UTF-16**, `Select-String -Encoding unicode`): - `[pad] first push port=0 slot=0 buf=0x… btns=0xFFFF` present ⇒ the table walk found an opened port and the buffer pointer passed validation. - Hold **X** or **Space** ⇒ `[pad] change … btns=0xBFFF` (PS2 buttons are **active-low**; `PAD_CROSS = 0x4000`). - `[iop:PADMAN]` record count still 4. 4. **End-to-end: pressing X or Space dismisses the memory-card dialog.** That is the real gate; the log lines only localise a failure if it doesn't. If `first push` never appears, the open flag at `+16` is not 1 — meaning portopen's EE-side bookkeeping never ran, which is a *different* bug from this one and should be triaged as such rather than by tweaking offsets. --- ## ★★★★★ 2026-08-09 (superseded by the closure above) — **The defect is a GS draw-ORDER inversion: the full-screen background paints over the dialog panel every frame.** Proved twice. Mechanism still being localised — the GIF arbiter has been tried and cleared. ### ⛔ `[giford]` run 31 — ✅ RAN. **The GIF arbiter is EXONERATED. Do not reopen it.** ``` drains=4096 mixed=0 moved=0 maxbatch=1 p1=0 p2=3922 p3=87 verdict=NO-MIXED-PATH-BATCH-the-sort-cannot-reorder-anything-so-look-elsewhere ``` All 7 records identical in shape. `maxbatch=1` — **every drain carries exactly one packet**, so the `stable_sort` in `GifArbiter::drain()` is a pass-through and cannot reorder anything, ever. The sort has been restored untouched; `PS2X_GIFNOSORT` is gone. The probe was built to be able to refute itself and did. Two facts worth keeping: - **`p1 = 0` always.** There is no PATH1/VU1 GIF traffic at all in this scene — everything is PATH2 (VIF1) and PATH3 (GIF channel DMA), roughly 45 : 1 by packet count. - Draws reach the GS **one packet at a time, in submission order**. So the inversion is decided strictly upstream of the arbiter. ⚠️ My run-30→31 write-up called the arbiter sort the root cause on code-reading alone. It was wrong, and the measurement caught it. The *inversion* below is measured and stands; the *mechanism* was inferred and did not. ### Where the mechanism must now be [ps2_memory.cpp:1847](ps2xRuntime/src/lib/ps2_memory.cpp#L1847) `processPendingTransfers()` drains three separate pending lists in a **hard-coded channel order — GIF, then VIF0, then VIF1** — not in the order the guest kicked them. Whichever channel carries the background, that fixed order decides whether it lands before or after the box group. The one link never measured is **which path each sprite rides**. Run 32 measures it. ### `[pixlog]` run 30 — ✅ RAN. Verdict `FETCH-IS-DEGENERATE` on every record, but the verdict was aimed at the wrong question. Run 30 named the run-29 "third party" black sprite by its texture identity. All records, both latch points (`pt=0` at 25,308 and `pt=1` at 147,308), both buffers, agreed exactly: | field | value | decode | |---|---|---| | `blktfmt` | `0x100413` | psm **0x13 = PSMT8**, tbw **4**, tfx 0 MODULATE, tcc 1, cpsm 0 | | `blktbp0` | `0x2A80` / `0x2A60` | two alternating VRAM allocations | | `blkcbp` | `0x2A48` / `0x2A44` | ditto | | `blktexel` | `0x80000000` | opaque black **as fetched, before blending** | | `blkval` | `0x80000000` | stored | | `blkalpha` | `0x44` | `(Cs−Cd)·As/128 + Cd`, As = 128 ⇒ source wins outright | The blender is exonerated again: at `pt=1` the glyph fetches `0x6affffff` and stores `0x6ad3d3d2` — (255−0)·106/128 = 211 = `0xd3`. Exact. ⚠️ **The `FETCH-IS-DEGENERATE` verdict is misleading and must not be inherited.** It only asked "is the texel black before blending". It is — but a full-screen menu backdrop being black at two sample points is perfectly ordinary. The verdict ladder had no branch for *"this draw is fine and merely happening at the wrong time"*, which is what the free dump analysis then proved. ### ★★★★ The actual defect — draw ORDER, proved twice from data already on disk (no build, no run) `dump.jsonl` frame 0 draw order is unambiguous, and identical in all 4 frames: ``` seq 0x0 clear prim 0x6 tme=0 0,0-512,448 seq 0x1 BACKGROUND psm 0x13 tbw 4 256x256 tbp0 0x2A00 0,0-512,448 seq 0x2 box body psm 0x13 tbw 2 128x128 tbp0 0x2B20 24,302-488,422 seq 0x3-0xA 8 border strips (same texture) seq 0xB+ 128 glyphs psm 0x14 tbw 4 tbp0 0x2B60 ``` `live.jsonl` frame 250, normalized by −1792,−1824 (XYOFFSET), same sprites by *rect*: | live seq | rect | = dump seq | |---|---|---| | 203E | 0,0–512,448 clear | 0x0 | | 203F–2046 | 8 border strips | 0x3–0xA | | 2047 | 24,302–488,422 **box body** | 0x2 | | 2048 | 0,0–512,448 **background** | 0x1 | | 2049+ | 147 glyphs | 0xB+ | **The background moves from position 1 to position 11 — behind the entire dialog box group.** - **100 / 100** live dialog frames have background AFTER box. **4 / 4** dump frames have it BEFORE. Perfectly deterministic; not a race, not a sampling artifact. - Confirmed by a **second, independent instrument**: `[pixlog]` measures the store order at the pixel and sees `clear → box(maroon) → background(opaque black) → glyph(white)`. The debug-history ring and the rasterizer agree. - Census matches otherwise: 1 clear / 9 box / 1 background / N glyphs on both sides. - `TEST` matches (`0x3000F` on 7.2% of dump draws, 7.3% of live). `alpha` is a single constant on **both** sides (dump `0x0`, live `0x44`) ⇒ **not measured**, not a divergence — see the plan's degenerate-column rule. - Tool cleared: `gsdump_draws.py` appends in stream order with `enumerate` for `seq`; no sort. **This alone explains the symptom.** The maroon dialog panel is painted, then the full-screen backdrop paints over it, then the glyphs land on top. VRAM ends every frame with white text on a black rectangle — which is exactly what run 29/30 measured. ### ★★★★ Mechanism — `GifArbiter::drain()` retroactively sorts the batch [ps2_gif_arbiter.cpp:40-52](ps2xRuntime/src/lib/ps2_gif_arbiter.cpp#L40-L52): ```cpp void GifArbiter::drain() { std::stable_sort(m_queue.begin(), m_queue.end(), ...pathPriority(a) < pathPriority(b)); for (...) m_processFn(...); } ``` `pathPriority(id) = (uint8_t)id`, and `Path1=1, Path2=2, Path3=3` — so **every Path3 packet is moved behind every Path1/Path2 packet queued in the same batch**. The queue accumulates because the two submitters disagree about draining: | submitter | path | `drainImmediately` | |---|---|---| | [ps2_memory.cpp:1857,1886,1905](ps2xRuntime/src/lib/ps2_memory.cpp#L1857) GIF-channel chain | **Path3** | **false** → sits in the queue | | [ps2_vif1_interpreter.cpp:439,649](ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp#L439) VIF1 | **Path2** | **true** → triggers the drain | So a Path3 background queued *first* is drained *after* the Path2 box sprites that followed it. **Why this is wrong on its own terms:** real GIF arbitration is an instantaneous tie-break at a transfer boundary — it decides which path acquires the bus *next* when several request it at once. It never reorders transfers that have already been handed over. Submission order already encodes the real-time interleaving; the sort destroys it. ⚠️ **Still inferred, not measured:** that the background sprite specifically arrives on Path3. Run 31 proves or kills that link rather than assuming it. ### ⛔ `[drawpath]` run 32 — ✅ RAN. **Every draw is `path=2`. The DMA channel theory is DEAD too.** ``` i=0..8 role=1 path=2 8x120, 8x8 x3, 8x120, 8x8, 464x8 x2, 464x120 tbp0=0x2a00 i=9 role=9 path=2 16x15 tbp0=0x2a60 (some frames) i=9/10 role=2 path=2 512x448 BACKGROUND tbp0=0x2a60/0x2a80 i=10/11+ role=3 path=2 17x17 glyphs tbp0=0x2b60/0x2b80 verdict=BOX-FIRST-ON-THE-SAME-PATH-so-the-guest-itself-submits-in-this-order ``` - **No PATH3 traffic at all** in these frames ⇒ the fixed GIF→VIF0→VIF1 drain order in `processPendingTransfers()` is irrelevant here. ⛔ Do not reopen it. - Counters are exact: `roles1` +540, `roles2` +60, `roles3` +8820 per record ⇒ **60 frames per record, 9 box + 1 background + 147 glyphs per frame**. Matches `live.jsonl` exactly. - Gates validated by span: `464x120` body, `8x120`/`464x8`/`8x8` borders, `512x448` background, `17x17` glyphs. The roles are measuring what they claim. - The `16x15` `role=9` sprite also appears in the 165015 PCSX2 dump — the captures are comparable. **Order is per-frame `box×9 → background → glyphs`, with no glyph before the background.** If the true order were background-first, that subsequence would be impossible. The inversion is real and sits upstream of every runtime layer tested so far. ### ⛔ Depth-sort hypothesis — DEAD, refuted for free from the dump (no build, no run) Extracted per-draw Z straight from the PCSX2 dumps via `gsdump_draws`' own parser (scratchpad `dumpz.py`): | draw | Z | |---|---| | clear, **background**, box body, all 8 borders | **0** | | glyphs | 8388500 | All the background/box sprites share `Z = 0`, and `ZTST = ALWAYS` on every draw — nothing is depth-tested or depth-sorted. Order is pure submission order. ⛔ Do not revisit Z. ### Exact permutation, by rect (not by size — sizes are ambiguous, four 8x8s are interchangeable) Live frame 250 in dump-index terms: **`[3, 9, 7, 8, 4, 10, 5, 6, 2, 1]`** vs dump `[1..10]`. Background goes **first → last**; the 9-slice is regrouped into pairs (right+top-right, bottom-left+bottom-right, left+top-left, top+bottom, body+background). Close to a reversal but *not* an exact one, so a simple list-walk-backwards is not sufficient explanation on its own. ### ★★★ 2026-08-09 — PCSX2 EE-side ground truth: the guest's chain is background-first Measured live against a running PCSX2 (DebugServer + Pine, SDBZ paused on the memory-card dialog), **not** from a GS dump. Method: `find_pattern` for the box body's first `XYZ2` word. - XYOFFSET is **(1792, 1824)** on PCSX2 — identical to our live run, so the assumption we have been normalising with is confirmed against the real machine. - Box body first vertex `x=0x7180 y=0x84E0` → 4 hits, two double-buffered display lists 0x64000 apart. - **Each DMA chain link is exactly one sprite**: GIFtag(NLOOP=1,NREG=1,A+D) → `PRIM=0x56` (SPRITE|TME|ABE) → GIFtag(NLOOP=2,NREG=3,REGLIST,{RGBAQ,ST,XYZ2}) → 2 vertices. 6 qwords + 1 tag. - **Address order IS chain order** — DMAtag at `0x00771a60` decodes `QWC=6 ID=2(next) ADDR=0x00771ad0`, and every subsequent tag links strictly ascending. Ordered layout of the dialog frame in EE RAM: | link addr | sprite | |---|---| | `0x00771470` | full-screen clear, untextured | | `0x007717d0` | **background 512×448** (v0 = `0x7000/0x7200` at +0x40) | | `0x00771a00` | **box body 464×120 @ (24,302)** (v0 = `0x7180/0x84E0` at +0x40) | | `0x00771a70` … `0x00771bc0` | the 8×120 / 464×8 borders | ⇒ **The guest's own source chain puts the background before the box.** This is now established from the guest's input data, independently of the GS dump that showed the same order at the output. Sprite-link header signature for future searches: `01 80 00 00 00 00 00 10 0E 00 00 00 00 00 00 00`; within a link, `XYZ2` v0 is at **+0x40** and v1 at **+0x58**. Note the untextured clear does *not* match this pattern (different NREG), so its absence from a hit list is expected, not a miss. ⚠️ `read_memory` on `0x10009000` (D1 CHCR/MADR/QWC/TADR) returned all zeros — the MCP does not map EE hardware registers. Do not read DMA channel state that way; walk the tags in RAM instead. ### ★★★ 2026-08-09 — run 34 RESULTS — **the defect is on the EE side.** GS/DMA layer closed `[drawsrc]` ran. 20 records, 20 emits, no `[cap]`. Unanimous: ``` [drawsrc] bodyat=8 bodysrc=0x7d5b50 bgat=10 bgsrc=0x7d5fb0 pcsx2bg=0x7d57d0 pcsx2body=0x7d5a00 [drawsrc] verdict=GUEST-CHAIN-IS-INVERTED-...-the-bug-is-on-the-ee-side ``` | | background link | box body link | order | |---|---|---|---| | **PCSX2** | `0x7d57d0` | `0x7d5a00` | bg **before** body ✅ | | **live** | `0x7d5fb0` | `0x7d5b50` | bg **after** body ❌ | 18 of 20 records are buffer B; 2 read `0x771b50`/`0x771fb0` — buffer A, the `−0x64000` twin. Both buffers show the same inversion, so this is a property of **the builder**, not of one buffer. **What this closes.** Combined with run 33's `BOX-FIRST-SAME-PATH-SAME-SITE`, every layer below the guest is now exonerated: GIF arbiter, DMA channel drain order, VIF1 submit-site divergence, and now the chain walk itself. Our runtime transports the display list **faithfully**. The recompiled EE code hands it a list that is already in the wrong order. ⛔ Do not reopen the GS/DMA/VIF transport path for this defect. **The remaining question is one level up:** which guest function fills the sprite-link slots, and why does it assign the background a later slot than PCSX2 does. Both runs lay links out at a strict `0x70` stride and both walk ascending — only the *contents* of each slot differ. Sprites are identical in geometry and format and merely permuted (`[3,9,7,8,4,10,5,6,2,1]` vs `[1..10]`), which reads far more like a **sort with a bad key/comparator** than like a broken linked list. Unproven — name the function first. **Static grep is a dead end here** (checked): `decompiles_SLUS_214_42.txt` contains no literal `0x771xxx`/`0x7d5xxx`/`0x64000` — the buffers are allocated dynamically, so the builder cannot be found by searching for its destination address. ### 2026-08-09 — run 35: HWWATCH the misplaced link. **No new probe code, no plumbing** Per `feedback_hardware_data_breakpoint`, "who wrote X to Y" goes to DR0, not to greps. The machinery in `game_overrides.cpp` is already fully env-driven — `PS2X_HWWATCH_ADDR` arms DR0 on a fixed guest address from the traced-slot wrapper, which is the one site guaranteed to run in a MainMenu-only session. Target `0x7d5fb0`: the slot the background **actually** landed in. Whoever stores there is the builder that decided the background goes last. Two edits to `game_overrides.cpp`, both about not letting the probe lie: | edit | why | |---|---| | `kHwWatchMaxHits` 64 → **2048** | it is a **hard cap, not a ring**. The builder rewrites this slot every frame; the dialog is frames ~199–299. At 64 the dump would have contained only boot-time writers and reported them with total confidence. | | `HWSTAT` heartbeat gains `seen` + `drop` | `drop = seen − hits` is how much of the run is invisible. Per `feedback_capped_probes_false_negatives`, a saturated probe must convict itself instead of looking like "it never happened". | **Reading contract.** `hits>0` → `.hwwatch.txt` backtraces name the producer; a single consistent writer across the hits is the display-list builder. `hits=0, skipped>0` → stores land but the value filter ate them; re-run with `PS2X_HWWATCH_VAL=0xFFFFFFFF` (which run 35 already sets). `hits=0 AND skipped=0` → nothing ever writes that address, meaning the resolved source address is wrong and the probe is at fault, not the runtime. **`drop>0` invalidates the absence of any writer from the list.** ⚠️ HWWATCH costs ~19s CPU in a 96s run (~20% wall clock). Diagnostic only — do not read timing off this run, and leave `PS2X_HWWATCH` unset afterwards. ### ★★★ 2026-08-09 — run 33 RESULTS + `[drawsrc]` run 34 — narrowing to one address comparison **Run 33 ran. Both probes returned data, 9 emits, no `[cap]`.** `[drawpath]` returned `BOX-FIRST-SAME-PATH-SAME-SITE` on **every** emit — same PATH2, same submit site (2 = the VIF1 DIRECT path) for box, background and glyphs. So neither the GIF arbiter, the channel drain order, nor our two VIF1 submit sites can be reordering anything: everything arrives by one route. Also read off run 33, and it corrects a working assumption: the **464×120 box body is `i=8`, the LAST of the nine role-1 draws** — the eight borders come first. Earlier notes had the body first. `[chainord]` returned `CHAIN-WALK-NOT-MONOTONIC`, **and that verdict was wrong** — a probe defect, not a finding. The chain walks strictly forward at a 0x70 stride (`0x7d5840, 0x7d58b0, … 0x7d5bc0`); the 4–6 "descending" steps are all REF tags jumping to their texture payloads (`0xa21800` qwc=4096 = the 64KB background texture, `0xa21400` qwc=64 = its 1KB CLUT, `0x8a6440` qwc=2048). A `ref` tag is one qword, so the chain resumes 0x10 later — e.g. `0x7d5c30` (qwc=0, a tag) → `0xa21800` (the payload) → `0x7d5c40` (the next link). Counting raw address deltas called that backwards. Verdict retired in run 34. Addresses are the same list PCSX2 showed, in the **buffer-B** copy (`+0x64000`): our `0x7d5840…0x7d5bc0` = PCSX2's `0x771840…0x771bc0`. **What run 33 could not settle.** The 64-entry ring held only the *tail* of a ~5,520-append frame, so the background link fell outside the window, and matching the nine role-1 draws onto the nine sprite links required assuming the Nth draw came from the Nth link. That is a positional guess of exactly the kind [[feedback_probe_gate_on_shape_not_address]] warns about, and the two candidate readings disagree about which layer is at fault — so it must not be guessed. **Run 34 removes the guess.** Every GS draw now carries the EE address it was read from: | file | change | |---|---| | `ps2_gif_arbiter.cpp` | new `ps2diag_gifpath::g_curSrc` | | `ps2_memory.cpp` | ring 64 → **1024**; new `ps2diag_chainord::resolveSrc(pos)` maps a byte offset in the gathered buffer back to its EE address | | `ps2_vif1_interpreter.cpp` | `dsSrcAt(pos)` — exact arithmetic when the buffer is EE RAM, ring lookup when it is a gathered copy; published at both submit sites | | `ps2_gs_rasterizer.cpp` | `g_dpSrc[kDpRing]` recorded per draw | | `ps2_gs_gpu.cpp` | `src=0x…` on every `[drawpath] i=` line, plus a new `[drawsrc]` summary | The box body is picked out **by shape** (`464×120`), not by position in the role-1 run. | `[drawsrc] verdict=` | meaning | |---|---| | `GUEST-CHAIN-IS-CORRECT-…` (`bgsrc < bodysrc`) | chain order matches PCSX2; something **downstream** reorders → our runtime, our fix | | `GUEST-CHAIN-IS-INVERTED-…` (`bgsrc > bodysrc`) | our recompiled **EE code** built a different display list → the bug is on the EE side | | `UNRESOLVED-…` | one of the two draws carries no address; the lookup is broken, not the runtime | Reference values printed on the line: `pcsx2bg=0x7d57d0 pcsx2body=0x7d5a00`. ### ★★★ 2026-08-09 — `[chainord]` run 33 addition — our walk vs theirs Folded into the same build as run 33. `ps2diag_chainord` (defined in `ps2_memory.cpp`) records the `srcAddr` / `qwc` / gathered-offset of every chunk our VIF1 source-chain gather appends (`appendData`, ps2_memory.cpp:1501), in a 64-entry ring. `ps2_gs_gpu.cpp` snapshots it **at the `[drawpath]` emit site**, so both windows describe the same frame — deliberately not gated on any address. | verdict | meaning | |---|---| | `CHAIN-WALK-ASCENDING-…` | our gather matches PCSX2; the inversion is introduced **downstream** of the gather (VIF1 interpreter / arbiter / rasterizer) — our bug, ours to fix | | `CHAIN-WALK-NOT-MONOTONIC-…` | we visit links out of order, or the recompiled guest built a different chain; compare the `addr=` values to `0x7717d0` and `0x771a00` | | `DEGENERATE-EVERY-CHUNK-SAME-ADDRESS-…` | probe is wrong, not the runtime | | `NO-CHAIN-CHUNKS-IN-WINDOW-…` | VIF1 was fed by some path other than the chain gather | This is a **more direct** test than Fork A's submit-site stamping — it compares EE addresses against a known-good reference rather than inferring a culprit from which call site fired. Both ship in one build; read `[chainord]` first. ### ★★★ 2026-08-09 — `[drawpath]` run 33 (written, not yet run) — the last fork, plus a causality test **Fork A — who reorders?** Everything is PATH2, but there are *two* `submitGifPacket(Path2, …)` call sites in the VIF1 interpreter. `ps2diag_gifpath::g_curSite` now stamps which one carried each packet (1 = image/unpack path, 2 = DIRECT, 8 = DIRECTHL). Run 31 proved drain is synchronous with submit, so a plain global is current at draw time. | new verdict | meaning | |---|---| | `BOX-FIRST-AND-SUBMIT-SITES-DIFFER` | **our VIF1 layer reorders** — a runtime bug, fixable here | | `BOX-FIRST-SAME-PATH-SAME-SITE` | **the guest itself submits background-last** — a recompiled-code divergence, and the next step becomes RE of the guest's sprite emitter | **Fork B — is the order the whole story?** `PS2X_SKIPBG=1` drops the full-screen background sprite entirely (one `thread_local` test at the top of `writePixel`, off by default). It is **not a fix** — it deliberately leaves the rest of the screen with no backdrop. Its only job: with the background gone, the dialog must come back as a **maroon panel with legible white text**. If it does not, a second fault exists downstream and the whole order investigation was only half the bug. ### 2026-08-09 — `[drawpath]` run 32 design (superseded by the result above) — stamp every draw with its DMA path `ps2_gif_arbiter.cpp` publishes `ps2diag_gifpath::g_curPath` around each `m_processFn` dispatch; the rasterizer stamps it onto every textured sprite at the existing shape-gate site. No header touched — the extern is declared locally in each `.cpp`. Roles are **texture identity only**, no address and no rect constant, so a sprite that moves or resizes is still classified. Span is recorded alongside, so a mis-shaped role convicts the gate instead of quietly reporting about the wrong sprite: | role | gate | expected | |---|---|---| | 1 | PSMT8, tbw 2 | box body + its 8 border strips | | 2 | PSMT8, tbw 4 | full-screen background | | 3 | PSMT4, tbw 4 | glyph atlas | | 9 | any other textured sprite | — | Capture window is **one frame**, opened by the glyph→box role transition (shape-derived, so it needs no present hook or frame counter) and closed after 16 draws — enough for 9 box + background + 6 glyphs. Output: a census line, 16 `i= role= path= span= tbp0=` lines in submission order, then a verdict. | verdict | meaning | |---|---| | `BACKGROUND-FIRST` | this frame is ordered correctly — the inversion is intermittent after all | | `BOX-FIRST-AND-PATHS-DIFFER` | **the fixed GIF→VIF0→VIF1 drain order is the cause**; read `boxpath`/`bgpath` | | `BOX-FIRST-ON-THE-SAME-PATH` | the DMA layer is innocent — the guest itself submits in this order, which is a much deeper problem | | `NO-BACKGROUND-IN-WINDOW` / `NO-BOX-IN-WINDOW` | the role gate is wrong — fix the probe, not the runtime | ### 2026-08-09 — `[giford]` run 31 design (superseded by the result above) — convict or clear the sort All in [ps2_gif_arbiter.cpp](ps2xRuntime/src/lib/ps2_gif_arbiter.cpp), no header touched. Snapshots the batch's path sequence **before** and **after** the sort, so the probe can refute itself: a batch that was never mixed, or a sort that never moved anything, clears the hypothesis. Fields (`[giford]`, two lines per 4096 drains): - `drains= mixed= moved= maxbatch= p1= p2= p3=` — `mixed` counts batches holding Path3 *and* non-Path3 (the only case the sort can matter); `moved` counts batches the sort actually changed. - `lastmixedn= firstp3pre= firstp3post= seqpre= seqpost= nosort=` — the most recent mixed batch's path sequence as digits (e.g. `32222` → `22223`), truncated to 24 chars, bracket-free. Verdict ladder: | verdict | meaning | |---|---| | `NO-MIXED-PATH-BATCH` | sort cannot reorder anything — **hypothesis dead**, look elsewhere | | `SORT-IS-A-NOOP` | batches already in priority order — **hypothesis dead** | | `SORT-DEMOTES-PATH3` | `firstp3pre < firstp3post` — **convicted**, see `seqpre` vs `seqpost` | | `SORT-REORDERS-but-not-by-demoting-path3` | real reorder, different shape — read the sequences | **`PS2X_GIFNOSORT=1` skips the sort entirely** — the candidate fix, env-gated so ONE build gives both the evidence run and the A/B run. With it set the probe still records, and `moved` must drop to 0; if it does not, something else reorders too. ## ★★★★ 2026-08-08 — live-vs-dump diff rebuilt on FORMAT+GEOMETRY. The live path draws the dialog correctly; the only divergence is CLUT **allocation**. ### The old diff's headline was an artifact, not a finding The first `gsdump_diff.py` report said the live path "never emits" 3 of the dump's 4 TEX0/CLUT combos and flagged `alpha dump=0x0 live=0x44` as a divergence. **Both claims were tool defects.** Four of them, each a textbook instance of a probe-discipline rule already in memory: | Defect | Effect | Fix | |---|---|---| | `tbp0`/`cbp` inside the bucket key | any VRAM-allocation difference shatters every bucket → false "live never emits this" | key on **format** `(prim,tme,psm,tbw,tw,th)`; addresses reported *inside* a bucket | | `x0/y0/x1/y1` captured, never used | the strongest signal was invisible | XYOFFSET auto-normalized, bbox histograms compared | | no frame alignment | 4-frame dump vs 301-frame live compared in aggregate; window chosen by trial-and-error `PS2X_GSHISTORY_FRAMES` tuning | `--align` (default on) scores every live frame's format census against the dump's | | no degenerate-column guard | `alpha` is a single constant on **both** sides (dump `0x0` ×556, live `0x44` ×16185) — provably unmeasured, reported as a divergence | constant columns listed under **NOT MEASURED** | Also: `records[0]` was compared instead of the whole bucket, and `tme=0` draws were keyed on **stale TEX0 register contents**, splitting identical untextured draws apart. `PS2X_GSHISTORY_FRAMES` never needs tuning again — window selection moved from the expensive side (a game run) to the cheap side (Python). ### What the rebuilt tool actually reports `python build_scripts/gsdump_diff.py dump.jsonl live.jsonl` → auto-aligns to live frame 230, **4 matched buckets / 0 dump-only / 0 live-only**: ``` [MATCH] prim=0x6 tme=0x1 psm=0x14 tbw=0x4 256x256 <- PSMT4 glyph atlas draws dump=128 live=147 tbp0 dump=0x2B60 live=0x2B60 same cbp dump=0x2B08 live=0x2A48 REMAPPED d=-0xC0 bbox sizes dump=17x17:128 live=17x17:147 identical ``` | role | dump frame 0 | live frame 249 | |---|---|---| | glyph atlas 256×256 psm 0x14 tbw 4 | tbp0=**0x2B60** cbp=0x2B08 ×128 | tbp0=**0x2B60** cbp=0x2A48 ×147 | | 128×128 psm 0x13 tbw 2 | tbp0=0x2B20 cbp=0x2B04 ×9 | tbp0=0x2A00 cbp=0x2A40 ×9 | | 256×256 psm 0x13 tbw 4 | tbp0=0x2A00 cbp=0x2B00 ×1 | tbp0=0x2A60 cbp=0x2A44 ×1 | | **dialog box rect** | **464×120 @ (24,302)** | **464×120 @ (24,302)** | `--find-rect 464 120` locates the dialog on **both** sides at normalized `(24,302)` — dump frames 0..3, live frames 199..299. Same formats, same per-role draw counts (9v9, 1v1), same 17×17 glyph cell, pixel-identical screen rect. **The live EE path emits the dialog with the right formats, right counts, right geometry. The only real divergence is where the CLUTs live in VRAM** — a constant ≈0xC0-block shift, expected because the two runs reached the screen by different paths and the game allocates VRAM dynamically. ⛔ Cross-run CLUT *address* equality is not a valid test and must not be cited as a finding again. ### Upstream check — nothing waiting upstream fixes this Upstream `main` carries three headers we lack: `ps2_gs_psmt4.h`, `ps2_gs_psmt8.h`, `ps2_gs_psmct32.h` — exactly the formats the failing draws use. Extracted and swept against ours: - All four swizzle tables **byte-identical** to `BlockTableP4`/`ColumnTable4`/`BlockTableP8`/ `ColumnTable8` at `ps2xRuntime/src/lib/ps2_gs_memory.cpp:111-194` (32/512/32/256 values each). - Address math (`ps2xRuntime/include/runtime/ps2_gs_memory.h:368-437`, the `PageId`/`BlockId`/`ColumnId`/`Address` composition) vs upstream's closed-form `addrPSMT4`/`addrPSMT8`: swept bw 0–8, bp ∈ {0,31,32,33,0x2A00,0x2B20,0x2B60,0x2B80}, x/y to 256. - PSMT4 14976/138528 mismatches, PSMT8 22464/138528 — **exclusively at tbw ∈ {0,1}**. - **0 mismatches at tbw=2 or tbw=4** — the only widths the memory-card draws use. - Root cause of the tbw≤1 divergence: our `PageId` computes `(bw*64)/page_extent.x` with **no clamp**, where upstream uses `pagesPerRow = (width>>1) ? (width>>1) : 1`. **Verdict: a real but latent edge-case bug in our address math, NOT the Stage 5.11 blocker.** Our current issue is not already fixed upstream. PR #132 (GS rasterizer rework) stays deferred on its own merits. ### `gsdump_diff.py` CLI as it now stands ``` python build_scripts/gsdump_diff.py dump.jsonl live.jsonl [options] --dump-frame N / --live-frame N pin a frame instead of auto-aligning --all-frames compare whole files (old aggregate behaviour) --xyoffset auto|none|X,Y default auto: subtract each side's own min x0/y0 --find-rect W H locate draws by SHAPE on both sides, no address needed --top N how many alignment candidates to print ``` ### CLUT *config* is identical too — only the base moved Pulled straight from the JSONL for the `psm=0x14 tbw=4` atlas bucket: | field | dump frame 0 | live frame 249 | |---|---|---| | `cpsm` / `csm` / `csa` / `cld` | `0x0` / `0x0` / `0x0` / `0x2` | `0x0` / `0x0` / `0x0` / `0x2` **identical** | | `test` | `0x30003` | `0x30003` **identical** | | textured glyph draws (`tme=1`) | 128 | 147 | So the live atlas draw asks for a CT32 palette, CSM1, csa=0, cld=2 — exactly like the authentic dump. Only `cbp` moved. (The lone live `abe=0` draw in that psm/tbw slice is the `tme=0` 512×448 backdrop carrying **stale TEX0**; `key_of` already collapses it. Not a divergence.) ### ★ NEXT — the one question this leaves open **Is the LIVE CLUT self-consistent?** Does the palette data at live `cbp=0x2A48` correctly serve the atlas at `tbp0=0x2B60`? Intra-run question; needs a C++ probe + rebuild. Plan written: `C:\Users\mwlab\.claude\plans\stage511-live-clut-self-consistency.md` — `[clutlive]` probe, one file (`ps2_gs_gpu.cpp`), no header edit, gated on the atlas *format* signature (`tme=1 psm=0x14 tbw=4 256×256`), never on `cbp`. Its core experiment: emit `cache0_15` (the 16 words `ReadClutCache` will hand the sampler) beside `vram0_15` (the same 16 words re-read from VRAM at draw time with the identical CSM1 scatter). Same data, two read times — they agree iff the cache is fresh. ⛔ Must not re-litigate the CLUT read chain (closed below). This targets the live run's palette at `0x2A48` and its load *timing*, which the read-chain proof does not cover. ### ★★★ Dump-side ground truth extracted (2026-08-08, free — no build). Hypothesis ranking CHANGED. Ran `gsdump_draws.py --uploads` + a frame-0 event-order trace on `snaps/...SLUS-21442_20260808165007.gs.zst`. Two results: **1. Upload PRECEDES TEX0 — the stale-cache hypothesis is DEMOTED.** ``` 18 BITBLTBUF dbp=0x2B60 dbw=4 dpsm=0x14 <- glyph atlas (T4) 19 BITBLTBUF dbp=0x2B08 dbw=1 dpsm=0x00 <- its palette (CT32, 8x2 = 16 words) 20 TEX0_1 tbp0=0x2B60 tbw=4 psm=0x14 256x256 cbp=0x2B08 cpsm=0 csm=0 csa=0 cld=2 21 draws +28 (total 39) [TEX0 re-written identically at 22/24/26: +36/+32/+32] ``` The palette is in VRAM before the TEX0 write consumes it. Our eager reload-on-TEX0-write is correctly ordered for this pattern. **2. ★ The glyph palette is an ALPHA COVERAGE RAMP, not colours.** All 16 entries are RGB = (255,255,255) **white**; only alpha varies, linearly `0, 8, 17, 25, 34, 42, 51, 59, 68, 76, 85, 93, 102, 110, 119, 128`. Entry 0 is `0x00000000`, fully transparent. `nonblack = 15/16`. What this implies: - The T4 index is an **antialiasing coverage level**, not a colour index. Text is white, composited over the maroon box by alpha blend. - Max alpha `0x80 = 128` is **1.0 on PS2** (alpha is 0..128, not 0..255). Treating it as 0..255 halves text opacity. - **Index 0 is fully transparent** → a degenerate T4 fetch returning 0 everywhere makes the text 100% invisible and leaves a flat maroon box. That *is* the red-box symptom. - Live `alpha=0x44` decodes to A=Cs, B=Cd, C=As, D=Cd → `(Cs-Cd)*As + Cd`, the **standard, correct** alpha blend. The blend equation is exonerated; what matters is `As` and the index feeding it. **Revised ranking:** (1) degenerate T4 index fetch → coverage all 0 → invisible text; (2) alpha scale 0..128 vs 0..255; (3) stale cache (demoted). `idxhist16` is now the probe's **primary** field, and the 16 words above are an exact expected value for `cache0_15`. ### ★★★ `[clutlive]` round 2 RAN (2026-08-08). **The CLUT is fully exonerated.** 131 records. At-write snapshot (not present-time), so this answers "what did *that* draw sample". ``` atw white=2932 black=0 other=0 sum=2932 hits=2932 firstblack none (no all-black palette was ever sampled at an atlas TEX0 write) cbpcensus slots=2/4 0x2a4cx1444 0x2a48x1488 atwrite0_15=0x0,0x8ffffff,...,0x80ffffff <- correct white coverage ramp, every sample ``` - `white == hits` exactly, `black == other == 0`, on **every** substantive sample. This is the pre-registered branch "palette exonerated; next probe goes in the rasterizer". - Two live CLUT bases exist (`0x2A4C`, `0x2A48`) — round 1's single latch was hiding one. Both serve correct ramps, so the second base is not a finding. - The present-time all-black `cache0_15` was correctly withheld: it belongs to a **later** texture in the shared 1KB CLUT window, not to an atlas draw. - `[cap] tag=clutlive` now appears in the analyzer footer — the round-1 false "no [cap] for this tag" is fixed. ⛔ Do not reopen the CLUT. Atlas index data, palette in VRAM, palette at the draw, alpha test, depth test (`test=0x30003` = both ALWAYS on **both** sides), and blend (`alpha=0x44`) are all measured-correct. ### ★★★ 2026-08-09 — `[pixlog]` run 29 — ✅ RAN. **The 4-step cycle. Runs 26-28's contradiction dissolves; a new bug appears.** 161 records, `-- no [cap]`. Latched pixel `25,308`, fbp 0 **and** 112, ~30 glyph hits per interval, `n ≈ 118 ≈ 4 × 30`. The cycle is **100% regular** in both buffers on every interval: | step | who | writes | over | prim | |---|---|---|---|---| | 1 | other | `0x00000000` | `0x80fffffe` | `0x6` | | 2 | box | `0x66200e64` maroon | `0x0` | `0x56` | | 3 | **other** | **`0x80000000` opaque black** | `0x66200e64` | `0x56` | | 4 | glyph | `0x80fffffe` white | `0x80000000` | `0x56` | `prim` decode (`[0:2]` type, `[4]` TME, `[6]` ABE): `0x6` = SPRITE, TME=0, ABE=0 → the **untextured frame clear** (step 1 of the *next* frame — the ring cut the cycle mid-loop). `0x56` = SPRITE, TME=1, ABE=1 → textured + blended, shared by **all three** other writers, so prim does not discriminate between them. **Three results:** 1. ✅ **Runs 26/27's "disjoint destinations" were never a contradiction.** Box always follows the clear (`0x00000000`); glyph always follows the black sprite (`0x80000000`). There is no wipe-between-passes. The whole "hunt the wipe" line from run 28 is **closed as a non-issue** — it was a 4-step cycle that no sprite-gated probe could see. 2. ✅ **The glyph is genuinely the last writer.** White `0x80fffffe` survives to end of frame in **both** buffers. ⚠️ The 7 `THIRD-PARTY-OVERWRITES` verdicts are a **ring-boundary artifact** (window closed on step 1); the 4 `GLYPH-IS-LAST-WRITER` records are the true reading. The verdict was under-specified — it could not tell "overwritten" from "the ring wrapped". 3. 🆕 **NEW BUG — step 3.** An **opaque black textured sprite paints over the maroon dialog interior**, between box and text. To reach exactly `0x80000000` through blend `0x44` (`(Cs−Cd)·As/128 + Cd`) the source must be `Cs=0, As=128` — fully opaque black. So VRAM ends each frame holding **white text on a BLACK box**, not maroon. ⚠️ Caveat on the sample: latched pixel `25,308` is the **min corner** of the glyph rect (`25,306-480,377`) — plausibly a border decoration from the same atlas rather than body text. Run 30 adds a second, spatially separated latch to settle it. ### ★★★ 2026-08-09 — `[pixlog]` run 30 (written, not yet run) — name the black sprite Two additions to the run 29 probe, same tag: - **Texture identity per ring entry:** `texel` (= `t_lastTexel`, the value **as fetched, before blending**), plus `tbp0`, `cbp`, and a packed `tfmt` (`psm | tbw<<8 | tfx<<16 | tcc<<20 | cpsm<<24`) and `alpha`. `texel` is the field that matters — it splits the two remaining explanations and neither can hide behind the other: - texel **black**, stored black → the **fetch** is degenerate (T4 index or CLUT); blender innocent - texel **coloured**, stored black → fetch fine, the **blend** destroys it - **Second latch point.** `pt=1` latches a glyph store ≥120px to the right of `pt=0`, so it is a different glyph by construction. If it never arms it says so loudly rather than repeating `pt=0`'s story. The step-3 writer is isolated by `tag == other && prim & 0x10` (textured), which excludes the untextured clear. `tbp0`/`cbp`/`tfmt` are directly comparable against **`dump.jsonl`**, the PCSX2 ground truth for this exact screen. | verdict | reading | |---|---| | `GUARD-not-latched…` / `GUARD-ring-empty…` | dialog not up, or no second glyph far enough right | | `NO-GLYPH-IN-RING…` | latched pixel rarely covered — read a later record | | `NO-TEXTURED-THIRD-PARTY…` | the black sprite did not recur at this pixel — it is local, not global | | `THIRD-PARTY-IS-NOT-BLACK-HERE` | `blkval` differs from run 29 — the sprite is not uniformly black | | `FETCH-IS-DEGENERATE` | **texel black before blending** — texture/CLUT bug; `blktbp0`/`blkcbp` name the draw | | `BLEND-DESTROYS-IT` | texel has colour, stores black — blender bug; read `blkalpha` | ### 2026-08-09 — `[pixlog]` run 29 design (superseded by the result above) — one address, every writer, in order Run 28's `[fbsplit]` re-query closed the phase question and left a contradiction that **no sprite-gated probe can resolve**: ``` glyph: fbp0=159810 fbp112=159810 | box: fbp0=1670400 fbp112=1670400 gtopfbp=0 btopfbp=0 | glyphord=49574803 boxord=49532385 | dstor=0x80000000 boxpix=0x66200e64 ``` - Both sprites in **both** buffers, matched rates ⇒ **out-of-phase is dead**. Shared memory is now a per-fbp fact, not an aggregate inference. - `glyphord > boxord` in **10 of 11** records ⇒ within a buffer, **glyphs store after the box**. - `1670400 / 55680 = 30.0` exactly ⇒ 30 clean full box draws per fbp per interval. - Yet glyph destination is `0x80000000` and box destination is `0x00000000`, always. Neither sees the other; the box does not even see its own maroon from the previous frame. Two different reset values in one buffer, between two passes that provably share it. The only thing that can do that is a **writer outside both gates** — so run 29 drops the sprite gate. Design (`[pixlog]`, [ps2_gs_rasterizer.cpp](ps2xRuntime/src/lib/ps2_gs_rasterizer.cpp) globals + log site, emit in [ps2_gs_gpu.cpp](ps2xRuntime/src/lib/ps2_gs_gpu.cpp)): - **Latched coordinate, not a constant.** The first glyph-*white* store of each interval latches its own `x,y,fbp` via CAS. Guarantees the followed address is one the text actually covers — a hard-coded pixel could land in the gap between two glyphs and read as "the glyphs never drew" (`feedback_probe_gate_on_shape_not_address`). Re-latched per interval; printed, so it never has to be trusted. - **Ring of 12, not a counter.** The question is *which order, by whom* — the last writer decides the colour. A ring holds the most recent whole frames and **cannot saturate** (`feedback_capped_probes_false_negatives`). `ord == 0` means "slot never filled". - Each entry carries `who` (box / glyph / **other**), `val`, `dst`, `prim` — `prim.tme == 0` on a third-party writer identifies it as a clear rather than another sprite. Verdict ladder: | verdict | reading | |---|---| | `GUARD-no-glyph-store-latched…` / `GUARD-ring-empty…` | dialog not up — read a later record | | `NO-GLYPH-IN-RING…` | latched pixel rarely covered; ring is box+others only — read a later record | | `THIRD-PARTY-OVERWRITES-THE-TEXT` | **the wiper exists**; `otherprim`/`otherval` name it | | `BOX-OVERWRITES-THE-TEXT` | the box sprite lands after the glyphs — contradicts the ord data, believe the ring | | `GLYPH-IS-LAST-WRITER` | white survives in VRAM ⇒ fault is **downstream** — display/present, not the rasterizer | ### ★★★ 2026-08-09 — `[fbaddr]` run 28 — ✅ RAN. **Same target on all three terms. Address divergence is dead.** ``` gn=138502 bn=1548396 gpsm=0x0/0x0 bpsm=0x0/0x0 gfbw=0x8/0x8 bfbw=0x8/0x8 | verdict=SAME-TARGET-AND-OVERLAPPING-so-memory-is-reset-between-the-passes gfbp=0xe00/0x0 bfbp=0xe00/0x0 gmsk=0x1/0x1 bmsk=0x1/0x1 grect=25,306-480,377 brect=24,302-487,421 overlap=1 ``` 11 stable records, 7 leading `GUARD-` (dialog not up yet — normal, 5th occurrence). `-- no [cap] for this tag`. | term | glyph | box | constant? | |---|---|---|---| | `psm` | `0x0` PSMCT32 | `0x0` | yes (OR==AND) | | `fbw` | `0x8` = 512 px stride | `0x8` | yes | | `fbp` | `{0, 0xe00}` | `{0, 0xe00}` | alternates — double buffer, as designed | | `fbmsk` | `0x1` | `0x1` | yes | - `fbp=0xe00` reconciles run 26's "fbp 0 and 112": raw FBP register 112 × 32 = 3584 = `0xe00` blocks. Same two buffers, different unit. 512×448 @32bpp ÷ 64 px/block = 3584 exactly. - **Geometry is pixel-correct.** Box rect `24,302–487,421` = **464×120 at (24,302)** — identical to the PCSX2 dump reference. Glyph rect `25,306–480,377` sits **fully inside** it. - ⇒ `DIFFERENT-PSM` / `DIFFERENT-FBW` / `DIFFERENT-FBP` / `NO-SPATIAL-OVERLAP` are all dead. Runs 25-27 were **not** mis-aimed. The text is drawn in the right place, right buffer, right format. - Side effect worth keeping: `fbmsk != 0` always ⇒ `frmw` is true unconditionally, so every destination reading in runs 26/27 was a genuine VRAM read regardless of blend state. ⚠️ **The verdict is aggregate-limited — it is not yet a per-frame claim.** An interval-wide OR/AND cannot distinguish: 1. **shared buffer** — glyph and box both write buffer A in frame N, both B in N+1 (something wipes between the passes); from 2. **out of phase** — glyph writes A while box writes B *in the same frame*. Both aggregate to `OR=0xe00 / AND=0x0` on both sprites. Scenario 2 fits the symptom exactly (displayed buffer got the box but not the glyphs → flat maroon box). Closing this needs per-fbp per-sprite counts, which **run 28's own log already contains** under `[fbsplit]` — query it before writing another probe. Park, do not chase: `fbmsk = 0x1` masks bit 0 of red only. Visually nil, but an odd value for a game to set. ### 2026-08-09 — `[fbaddr]` run 28 design (superseded by the result above) — do the two sprites share memory at all? Run 27's verdict said `CLEARED-BETWEEN`. **The verdict is not the finding.** The finding is the pair of reset values: | sprite | destination OR | meaning | |---|---|---| | glyph white stores (run 26) | `0x80000000` | opaque black | | box stores (run 27) | `0x00000000` | **every bit zero, alpha included** | One cleared buffer cannot hand one sprite `A=0x80` and the other `A=0x00`. Either the two sprites write **different memory**, or a wipe between the passes clears to a third value. Run 28 separates those, and it closes a gap that has been open since run 24. ⚠️ **The gap.** The framebuffer address is built from *three* terms ([ps2_gs_rasterizer.cpp:1502-1504](ps2xRuntime/src/lib/ps2_gs_rasterizer.cpp#L1502-L1504)): `fbp = framePageBaseToBlock(ctx.frame.fbp)`, `fbw`, `fpsm`. **Every probe from run 24 onward recorded only `ctx.frame.fbp & 0x1FF`.** `fbw` and `fpsm` have never been sampled on either sprite, and the `0x1FF` mask discards any high bit `framePageBaseToBlock` would have consumed. So "both sprites hit fbp 0 and fbp 112" never meant "both sprites write the same bytes" — it constrained one of three terms. Run 28 samples all three, per sprite, at the store site. Verdict ladder (guards first, every branch actionable): | verdict | reading | |---|---| | `VARIES-fbw-or-psm-moved-mid-interval` | a value moved within the interval; no cross-sprite comparison is valid — ignore the rest | | `DIFFERENT-PSM` / `DIFFERENT-FBW` / `DIFFERENT-FBP` | **the addresses diverge** — the two sprites never shared memory and the whole occlusion enquiry was mis-aimed. This is the bug. | | `NO-SPATIAL-OVERLAP` | the rects never shared a pixel — the premise behind runs 25-27 was wrong | | `SAME-TARGET-BUT-FBMSK-DIFFERS` | shared memory, but one sprite's writes are masked | | `SAME-TARGET-AND-OVERLAPPING` | shared memory confirmed ⇒ **something resets it between the passes**; hunt the wipe | Method notes worth keeping: constancy is tested as `OR == AND` (exact, two words). `fbw`/`fpsm` are *required* stable; `fbp` is **not** — it alternates with the double buffer, so demanding it constant would trip a guard in every record and the probe would report nothing forever. `fbp` is instead compared as an OR/AND *pair* between sprites. The XY extents are carried because every reading above assumes the rects overlap, and that came from a bbox note rather than the store site — hence `NO-SPATIAL-OVERLAP` being a real branch instead of a silent assumption. ### ★★ 2026-08-09 — `[boxover]` run 27 — ✅ RAN. Sum identity held; `bwhite = 0`. ``` bstores=3340800 bwhite=0 bblack=3340800 bother=0 gtotal=314293 | verdict=CLEARED-BETWEEN... bdstor=0x0 bdstwhite=0x0 balpha=0x44 babe=1 bsrca=102 btotal=3340800 ``` - Self-check passed in every record (`3340800 = 0 + 3340800 + 0`; transition record `1715436 = 0 + 1715328 + 108`). Classifier sound, fields readable. - `bstores = 55680 × 60` — the box paints the full dialog rect ~60×/interval, matching the glyph side's `gtotal = 5327 × 59`. Equal rates, as run 26 found. - **The box never lands on a white pixel** across 3.3 M samples. With run 26's reciprocal, neither sprite ever sees the other. - Record 8 (the frame the dialog appears) shows `bdstor=0x802f474a` — real game content, alpha `0x80` — then it drops to exact zero and stays there. `babe=1`, `balpha=0x44`, `bsrca=102` confirm the box *was* blending, so it was never entitled to hide the text opaquely. ⚠️ Do **not** cite `CLEARED-BETWEEN` as a conclusion. It is one of two readings and run 28 decides between them; the reset-value mismatch above is the durable result. ### 2026-08-09 — `[boxover]` run 27 design (superseded by the result above) — does the box land ON the text? Run 26 answered "which buffer" and the answer is **the same buffer, both of them, every frame**. The remaining question is order, and run 27 answers it *without an ordering counter* — by sampling the destination under the **box**, the exact reciprocal of run 26's `dstor`. | outcome | reading | |---|---| | `bwhite > 0`, near `gtotal` | **BOX PAINTS OVER TEXT** — text drawn first onto a cleared buffer, box laid on top. Draw order inverted, or the box should blend and doesn't. | | `bwhite == 0`, `bblack == bstores` | **CLEARED BETWEEN** — the box is innocent; a wipe between the text pass and the box pass destroys the glyphs. Different bug, different fix site. | Pre-registered self-check, read **first**: `bwhite + bblack + bother == bstores`. If that identity fails the classifier is broken and no other field on the line is trustworthy. `babe`/`balpha`/`bsrca` only matter in the first branch — they say whether the box was *entitled* to occlude. `ALPHA 0x44` with `abe=1` and source alpha `0x66` (102/128 ≈ 80 %) should leave ~a fifth of the text visible, which is **not** what the screen shows; `abe=0` would be a straight opaque overwrite and would explain a perfectly flat box. ### ★★★ 2026-08-09 — `[fbsplit]` run 26 — ✅ RAN. **No split, no offscreen. Same buffer, same rate.** ``` glyph: fbp0=154483 fbp112=159810 | box: fbp0=1614720 fbp112=1670400 gtopfbp=112 btopfbp=112 disp1=112 disp2=112 | dstor=0x80000000 boxpix=0x66200e64 ``` The counts are exact multiples, which is why this data can be trusted: - glyph white stores `85232 / 154483 / 159810 / 165137` = **5327 × 16 / 29 / 30 / 31** - box stores `1614720 / 1670400 / 1726080` = **55680 × 29 / 30 / 31** (55680 = 464 × 120) So **5327 white pixels = one complete dialog's worth of text**, drawn the same number of times as the box, into **both** framebuffers in near-equal proportion. One box + one full text pass per frame. Normal double buffering. ⚠️ **Run 26's `verdict=` field is unsound — do not cite `SPLIT-TARGET` or `OFFSCREEN` from it.** It compared a single argmax fbp per sprite, and the buckets are near-ties in every record (`154483` vs `159810` is a coin flip). Third probe-field defect in three runs, same family as run 25's `boxafter`. **Fixed in place**: split is now a property of the *sets* (`overlapFbp`) and "shown" of *any* text-bearing fbp (`anyGlyphShown`) — both argmax-free. The interval-granular `glyphord`/`boxord` branch was **deleted**; those stamps span ~60 frames and can never resolve within-frame order. Do not restore it. **The field that decided it: `dstor=0x80000000`** — the OR of the destination across every white glyph store (314,293 samples/interval), exactly black-opaque. Not one white glyph pixel ever landed on maroon, and `boxpix=0x66200e64` (r=100 g=14 b=32) **is** the maroon. The two sprites overlap in space (glyph bbox `25,306-491,378` inside box rect `24,302-488,422`) and share buffers at the same rate — yet the text never sees the box beneath it. ⇒ **The text is drawn first, onto cleared black, and something covers it afterwards.** Run 27 distinguishes "the box covers it" from "a clear wipes it". ### 2026-08-09 — `[fbsplit]` run 26 design (superseded by the result above) — which buffer, and in which order Run 25 (below) removed the last way for the text to be *destroyed*. It is drawn, it is white, it is in the right rect, and nothing discards it. Two fields say where to look next: | field | value | what it forces | |---|---|---| | `dst=0x0` | destination under a white glyph pixel is **black** | the maroon box has **not** been painted into that pixel when the glyph lands | | `fbpmin=0 fbpmax=112` | glyph stores span **two** destination framebuffers | `[dispfb]` already records FRAME.FBP alternating `0x0`/`0x70` while DISPFB stays `0x1070` (= fbp **112**) | **Leading hypothesis:** the box and the text land in *different* framebuffers in the same frame, and only the box's buffer is presented → a flat maroon box, exactly the symptom. ⚠️ **Run 25's `boxafter` is unsound and must not be cited.** It alternated 0/1 with two perfectly constant deltas (272089 / 294656), which no real ordering change produces. Cause: both stamps come from `g_writeSeq`, which the `[fbdest]` emit `exchange(0)`'s — a reset landing between the box store and the glyph store inverts the comparison. `total=0` in the same line is the same defect seen directly (`[fbdest]` had already taken the counter). The branch has been **deleted** from `[glyphfate]`'s verdict chain; `[fbsplit]` re-measures ordering on its own never-reset counter. Pre-registered reading, stop at the first line that answers: | # | field | reading | |---|---|---| | 1 | `glyph:` / `box:` both empty | GUARD — dialog not up this interval, read a later record | | 2 | fbp lists **disjoint** | `SPLIT-TARGET` — split-buffer bug, done | | 3 | shared fbp ≠ `disp1`/`disp2` | `OFFSCREEN` — whole dialog drawn where DISPFB never points | | 4 | `boxord` > `glyphord` | `BOX-AFTER-TEXT` — real draw-order bug, now on a sound counter | | 5 | else | `SAME-BUFFER-TEXT-LAST` — fault moves to the present/copy path | | — | `dstor=0` while `boxpix` is maroon | corroborates 2/3: the two sprites never met | Per-fbp **populations** for both sprites, not min/max — min/max cannot distinguish one stray pixel from half of them, and cannot be compared against the box at all. ### ★★★ 2026-08-09 — `[glyphfate]` run 25 — ✅ RAN. **Nothing kills the glyph pixels.** ``` gfdraws=91728 gfin=26509392 gfscis=0 gfate=0 gfz=0 gfstored=26509392 | white=3324048 dark=20589504 srcamax=128 | bbox=25,306-491,378 inrect=1 | verdict=TEXT-STORED-WHITE-IN-RECT-check-fbp-vs-presented-buffer pix=0x80fffffe dst=0x0 test=0x30003 cc=1 abe=1 | fbpor=112 fbpmin=0 fbpmax=112 | sc=0,0-511,447 ``` - **Zero losses at every exit:** `gfscis`, `gfate`, `gfz` all exactly 0, and `gfstored == gfin` (26,509,392 of 26,509,392). Scissor, alpha test and depth test kill nothing. - **The stored word is white:** `pix=0x80fffffe` — A=0x80 (=1.0 on PS2), RGB ≈ 0xFFFFFE. `srcamax=128` confirms full source coverage is reached. - **In the right place:** bbox `25,306-491,378` against the dump's dialog rect `(24,302)-(488,422)`. - **Scissor is full-screen** (`0,0-511,447`), so no clip is involved. - `dark=20589504` (89%) is *expected* — index-0 texels are transparent by design in a coverage ramp. - `cc=1` ⇒ the `COLCLAMP == 0` blend-discard bug found in `writePixel` is **not on this path**. ⛔ Do not reopen fate: scissor, alpha test, depth test and the stored pixel value are all closed. The fault is in **placement/target/order**, not in any computation. ### 2026-08-09 — `[glyphfate]` run 25 design (superseded by the result above) — fate + ordering, no arithmetic left Run 24 (below) closed **every arithmetic link** in the text pipeline. Nothing about the texture stages is still in doubt. What is left is *where the pixels land* and *what buries them*. The reasoning that forces this probe: - Sampler output is verified correct: white RGB, coverage alpha up to `amax=128` (= 1.0). - `test=0x30003` decodes to **ATE on / ATST ALWAYS** and **ZTE on / ZTST ALWAYS** — both always-pass. - Blend `0x44` = `(Cs−Cd)·As/128 + Cd`; with `Cs` = white this yields **white text over maroon**. So by every measured value these pixels *should be on screen*. `[glyphfate]` rides the same `t_boxDraw` mechanism with a new `t_glyphDraw`, set from the **identical shape predicate run 24 validated on 115 M samples**, and counts fate at each exit of `writePixel`. Pre-registered bisect: | result | conclusion | |---|---| | `gfdraws == 0` | gate dead — no T4 256×256 tbw4 sprite reached `drawSprite` | | `gfin == 0`, `gfdraws > 0` | sprite gated but pixel loop empty — fully clipped rect | | `gfscis == gfin` | every glyph pixel scissored; compare `sc=` against `24,302-488,422` | | `gfate > 0` or `gfz > 0` | **contradicts `test=0x30003` ALWAYS** — `classifyAlphaTest` mis-decodes | | `white == 0`, `srcamax == 128` | stored but dark — blend/pack destroys it; check `cc=` (COLCLAMP) | | `inrect == 0` | white text stored at the **wrong screen position** — sprite XY bug | | `boxafter == 1` | **box drawn after the text and paints over it** — draw-order bug ← leading | | all pass | correct white text in the right rect ⇒ compare `fbpor` vs the presented buffer | **Bug found in passing, NOT fixed** — `ps2_gs_rasterizer.cpp` `writePixel`, the `COLCLAMP == 0` branch computes `br/bg/bb` and then throws them away (`r &= 0xFF` keeps the *unblended* source). Should be `r = br & 0xFF`. Not the invisible-text cause (it would make text too bright, not absent), and `cc=` in this probe will say whether that path is even taken. Needs approval to fix. --- ### ★★★ 2026-08-09 — `[texfetch]` run 24 — ✅ RAN. **The T4 fetch AND the CLUT lookup are CORRECT.** Final interval of the run: ``` [texfetch] tfall=454239936 tft4=115043964 tfsamp=115043964 tfchk=1797562 tfdis=0 | u=0..232 v=0..128 | idxmax=15 oor=0 bin0=93409752 histsum=115043964 | a0=93409752 amax=128 white=21634212 | verdict=gate-matched-values-below-are-valid [texfetch] cfg tbw=4 tw=256 th=256 tbp0=0x2b80 cpsm=0x0 csa=0 [texfetch] hist16=93409752,0,0,65669,277570,312774,415001,2546874,251844,176697,299234,1623446,319544,0,642473,14703086 [texfetch] vramtruth=60638,0,0,22,75,83,104,416,68,68,63,365,108,0,158,3368 ``` Every pre-registered branch resolves **in favour of the fetch**: - **Not degenerate.** `bin0` is 81 % of samples, not 100 %. VRAM truth is 92.5 %; we sample glyph cells rather than the whole page, so slightly less empty is exactly right. - **Histogram matches VRAM rank-for-rank.** Both sides order `0 > 15 > 7 > 11 > 14 > {12,6} > 5 > {4,10} > 8 > 9 > 3`, and bins **1, 2 and 13 are zero on both sides**. The sampler is reading the real atlas, not noise. - **Masking correct.** `idxmax=15`, `oor=0`. - **Different glyphs address different cells.** `u=0..232`, `v=0..128` — kills the "one repeated cell" branch that run 23 could not answer (its `u256`/`v256` were the *last* draw only). - **Page cache agrees with VRAM.** `tfdis=0` over `tfchk=1797562` rival `ReadVram` reads — a large sample, so this is a real pass and not a silent probe. - **CLUT lookup exact to the digit.** `a0 == bin0` (93 409 752) and `white == tfsamp − bin0` (21 634 212). Every ink texel returns white; every index-0 texel returns alpha 0. `amax=128`. Side fact: `white == tfsamp − bin0` also says our live CLUT **entry 0 is `(0,0,0,0)`**, where the dump's is `(255,255,255,0)`. Harmless — blend `0x44` with `As=0` discards RGB entirely. ⛔ **Do not reopen the T4 fetch, the page cache, or the CLUT lookup.** Suspects #7/#8/#9/#11 in the texture path are closed by this run. The `tbp0=0x2b80` here differs from earlier runs' `0x2B60` purely because VRAM allocation moves between runs — not a finding. --- ### 2026-08-09 — `[texfetch]` run 24 design (superseded by the result above) **Everything except one link is now measured.** Proven correct, each against PCSX2 ground truth: atlas index data in VRAM; palette in VRAM; palette *at the draw*; alpha test; depth test; blend equation `0x44`; geometry/formats/draw counts; and — new from run 23 below — vertex ST/Q, FST decode, per-corner UV, sprite size. A static audit added: `applyTexa` is a **pass-through** for T4 (`default: break;`), `combineTexture` MODULATE is `>>7`, the blender is `((A-B)*C >> 7) + D`. ⛔ **The "alpha is 0..128 not 0..255" suspect is DEAD** — every stage already uses `>>7`. **The one unmeasured link:** ``` gs->ReadTexturePageCache(GS_PSM_T4, tbp0, tbw=4, sampleU, sampleV) // ps2_gs_rasterizer.cpp ``` …plus the T4 CLUT lookup right after it. `[clutlive]` **cannot** cover this — its own SCOPE LIMIT note says it reads VRAM and the CLUT cache *host-side* and "cannot see what the rasterizer's sampler computed." **Why it was never measured.** `[texfetch]` was gated `if (tex.psm == GS_PSM_T8)` and then `if (tbp0 == kTfTbp)` — wrong format, then an address constant. It has never observed a single T4 fetch. Third instance of `feedback_probe_gate_on_shape_not_address`, same file as `[uvspan]`. **The change** — gate is now shape-only, `psm==T4 && tbw==4 && texW==256 && texH==256`, with guards `tfall` (any paletted sample) → `tft4` (any T4 sample) → `tfsamp` (glyph shape) outside it. `kTfTbp` is deleted. New fields: - **`hist16`** — bins by index **value** 0..15 (was `out>>4`, meaningless for a 4-bit index), so it is directly comparable to the VRAM ground truth, which the probe now **prints alongside it**: `60638,0,0,22,75,83,104,416,68,68,63,365,108,0,158,3368`. - **`u=` / `v=`** min..max across *every sample of every glyph*. Run 23's `u256`/`v256` were the **last draw only**, so "do different glyphs address different cells?" is still open. A span of ~24 texels means all ~98k draws read one cell; a wide span means they don't. - **`oor` / `idxmax`** — catches `ReadTexturePageCache` failing to mask to a nibble. - **`a0` / `amax` / `white`** — the post-CLUT texel *as the sampler returns it*. Separates "index fetch broken" from "CLUT lookup broken". `amax` is PS2 alpha, so **128 == 1.0**. - **`tfdis`** — rival `ReadVram` read at 1-in-64, now masked `& 0xF` for T4. - `verdict=` string with the two named failure signatures baked in. **Pre-registered bisect:** | result | conclusion | |---|---| | `bin0 == tfsamp` | **PSMT4 nibble fetch is broken** — every texel index 0 = transparent = invisible text | | `oor > 0` or `idxmax > 15` | fetch isn't masking to a nibble; index runs off a 16-entry CLUT | | `u`/`v` span ≈ 24 texels | every glyph reads the same atlas cell — fault is upstream of the fetch | | `u`/`v` span collapses to 1–2 | per-pixel interpolation between (correct) corners is broken | | hist healthy but `a0 == tfsamp` | indices fine, **T4 CLUT lookup broken** — check `csa`/`cpsm` | | hist healthy, `amax==128`, `white==tfsamp` | fetch *and* lookup correct; loss is in `writePixel` | | `tfdis == tfchk` with `badv2 == 0` | convicts **`ReadVram`** (no PSMT4 handler), not the cache | Full 5-branch reading table lives at the counter definitions in `ps2_gs_rasterizer.cpp`. ### ★★★ 2026-08-08 — `[uvspan]` RE-POINTED at the glyph sprites (run 23) — ✅ RAN, ALL CLEAN **The dump-side finding that motivates it** (`gsdump_draws.py` on `snaps/...20260808165007.gs.zst`, free, no build): every glyph sprite is ``` prim=0x056 sprite tme=1 abe=1 iip=0 ***fst=0*** tbp0=0x2B60 tbw=4 psm=T4 256x256 tcc=1 tfx=0 cbp=0x2B08 cpsm=CT32 cld=2 vtx0 uv=(0,0) st=(0.101961,0.000000) q=1.0 rgba=(0,0,0,0) vtx1 uv=(0,0) st=(0.196078,0.094118) q=1.0 rgba=(128,128,128,128) ``` `fst=0` **and `uv=(0,0)` on both vertices** — the game addresses the atlas *only* through ST/Q and never writes UV for these draws. If our vertex assembly loses `s/t/q`, or `prim.fst` decodes as 1, every glyph samples texel (0,0) = T4 index 0 = alpha 0 = **fully transparent**. Invisible text over a bare maroon box — the exact symptom. **Why this was never measured.** The existing `[uvspan]` probe was gated on `psm == T8 && tbp0 == 0x2A80`. The glyphs are `T4 @ 0x2B60 tbw=4`. The gate matched **zero** glyph sprites; `analyze_run.py --tag uvspan` returns `draws=0 fst=4294967295` on every record of the last run. Another instance of `feedback_probe_gate_on_shape_not_address`. **The change** (`ps2_gs_rasterizer.cpp` counters + gate, `ps2_gs_gpu.cpp` externs + emit): - Gate is now **shape only**, no address: `psm==T4 && tbw==4 && tw==8 && th==8` (tw/th are log2). - Rival guards `allspr` (any textured sprite) and `t4any` (any T4 sprite) sit **outside** the gate, so a `draws=0` result is diagnosable instead of silent. - `fst0`/`fst1` are **counters**, not a last-observed value — the primary question. - `qbad` counts corners with `|q| < 0.015`. Needed because `fabsQ()` substitutes 1.0 below 1e-8, so an exact-zero Q survives the divide and otherwise looks healthy. - `du256`/`ds64k` are min..max across all gated draws, so "all glyphs flat" is distinguishable from "one glyph happened to be flat". - Emit carries an explicit `verdict=` string so a zero cannot be misread as "renderer is fine". **Expected on a healthy run:** `fst0==draws`, `fst1==0`, `qbad==0`, `q64k=65536..65536`, `ds64k` and `du256` both ≈ **6169** (= 0.094118 × 65536; they must agree numerically because `u = s × texW` and texW is 256), `wide==draws`, `flat==0`, `rawu=0..0`. **RESULT (2026-08-09) — every pre-registered field matched. Nothing here is broken.** ``` [uvspan] allspr=105016 t4any=97902 draws=97902 | fst0=97902 fst1=0 qbad=0 flat=0 wide=97902 | du256=6168..6168 ds64k=6168..6168 | verdict=gate-matched-values-below-are-valid [uvspan] last fst=0 | u256=13364..19532 v256=20046..26214 | s64k=13364..19532 t64k=20046..26214 | q64k=65536..65536 | rawu=0..0 rawv=0..0 | spanx=17 spany=17 ``` - Guards all nonzero — the gate is on the glyph sprites (97,902 of them). - `fst0 == draws`, `fst1 == 0` ⇒ PRIM decode correct. ⛔ **The FST/ST-vs-UV hypothesis is DEAD.** - `du256 == ds64k == 6168` vs **6169** predicted (float truncation), min == max over all draws. The independent `u = s × texW` cross-check passes exactly. - `qbad=0`, `q64k=65536..65536` ⇒ Q is exactly 1.0, the divide is a no-op, as the dump says. - `rawu=0..0 rawv=0..0` matches the dump's `uv=(0,0)`; `spanx=spany=17` matches its 17×17 rect. ⚠️ **One thing this did NOT answer:** the `u256`/`v256`/`s64k`/`t64k` fields are prefixed `last` — they are the **final draw's corners**, not accumulators. So "do different glyphs address different atlas cells?" remains open, and `[texfetch]` run 24 above now measures it across all samples. Full reading table (5 numbered branches) lives at the counter definitions in `ps2_gs_rasterizer.cpp` — read it, not this summary. ### `[clutlive]` round 1 RAN (2026-08-08). Top hypothesis REFUTED. Palette + atlas both clean. 79 records. Gate silent for the first 7 samples (pre-dialog), then `hits` climbing ~240/sec (≈4 atlas TEX0 writes/frame) at `tbp0=0x2b80 tbw=4 cbp=0x2a4c cpsm=0 csm=0 csa=0 cld=2`. **1. ⛔ The degenerate-T4-fetch hypothesis is DEAD.** ``` idxnz=4898/65536 idxhist16=60638,0,0,22,75,83,104,416,68,68,63,365,108,0,158,3368 ``` Index 0 = 60638 (transparent background), index 15 = 3368 (solid glyph interiors), ~1500 spread across the antialiasing levels 3–14. Identical in every single sample. **The atlas holds real coverage data.** It is not all-zero, so "no text can composite because coverage is 0" is refuted at the data layer. **2. ✅ The live palette in VRAM is byte-exact against the authentic PCSX2 dump.** ``` vram0_15=0x0,0x8ffffff,0x11ffffff,0x19ffffff,0x22ffffff,0x2affffff,0x33ffffff,0x3bffffff, 0x44ffffff,0x4cffffff,0x55ffffff,0x5dffffff,0x66ffffff,0x6effffff,0x77ffffff,0x80ffffff ``` That is the dump's white alpha ramp exactly. The upload path is correct. `mismatch=0/16` on the matching samples also proves `ReadClutCache` and `ReadVram` agree on the CSM1 scatter, and `distinct=16/16` on every sample means the probe is not degenerate. **3. ⚠️ NOT a finding yet — `cache0_15` alternates with an all-black ramp.** Roughly every other sample returns `0x0,0xa000000,0x13000000,…,0x77000000` — `nonblack=0/16`, RGB black, alpha ramp `0,10,19,…,119` (a *different* ramp: 15 steps ending at 119, not 128). One late sample held a third palette again. **This cannot be called stale yet.** The PS2 CLUT buffer is one shared 1KB window and every TEX0 write with `cld≠0` reloads it. The round-1 consumer samples at **present** time, long after the glyph draw, so a black ramp there may be a later texture's palette sitting there legitimately. A black ramp is a plausible drop-shadow pass over the same atlas. **Where that leaves it:** both *inputs* to the glyph draw are now proven correct in memory (atlas data ✅, palette in VRAM ✅). The fault is in what the draw actually samples, or downstream of it. Black glyphs at ≤50% alpha over a maroon box would be near-invisible — which fits the symptom — so "does the atlas draw sample the black ramp?" is now the whole question. **Round 2 probe written, unbuilt:** snapshot the CLUT cache *inside* the producer, immediately after `ReloadClutCache` returns, and classify every snapshot white/black/other over the whole run (`atw white= black= other=`), plus `firstblack cbp=`, plus a 4-slot `cbpcensus` (the round-1 latch was last-write-wins on one `cbp` and would hide two contending palettes). Reading: `black==0 && white==hits` → palette fully exonerated, next probe goes in the rasterizer. `black>0` → the glyph draw samples an all-black palette, and that is the bug. **Tooling defect found and fixed:** `analyze_run.py --tag` detects caps via `\[cap\] tag=(\S+)` and by requiring the tag name inside the `[cap]` chunk. Round 1 emitted `[clutlive] [cap] gate never matched…` with no `tag=`, so the analyzer printed **"no [cap] for this tag: absence IS evidence"** over a run that had capped 7 times. The probe now emits `[cap] tag=clutlive …`. A cap line the cap detector cannot see is worse than no cap line at all. ### Learned pattern (this session) - **A diff tool keyed on run-dependent state manufactures divergences.** Addresses, frame indices, and allocation bases differ between two runs *by construction*; keying on them turns every comparison into "only in A / only in B". Key on what the content determines (format, geometry, counts); report what the run determines (addresses) as a *value inside* the match. - **Any field that is constant across a whole side is unmeasured on that side.** Report it as NOT MEASURED, never as a divergence. Generalizes `feedback_degenerate_result_convicts_the_probe` from probes to analysis tooling. --- ## ★★★★ 2026-08-08 — GS dump replay harness confirms the RASTERIZER renders the box correctly New evidence source: a **real PCSX2 GS dump** of the memory-card dialog screen (`...165007.gs.zst`, converted via `gsdump_parse.py --emit-replay`, replayed through our own `ps2xTest` `ps2_gsdump_replay_tests.cpp` harness — same rasterizer/blender code as the live game, but fed authentic PCSX2-captured GS commands instead of our EE's emitted ones). **Visual result** (`gsdump/replay_fbp70.bmp` → PNG): two maroon/dark-red PS2 memory-card dialog boxes, gold border, legible white pixel-font text — "Checking the memory car[d]... MEMORY CARD slot 1. PLE[ASE]..." This is the genuine BIOS memory-card warning screen. **Counters**, box rect `24,302 464x120`: - `[boxtex] idxsamp=890880 idxmin=50 idxmax=50` — same truth index as run 22. - `[boxtex] texel=0x6629127e` == `ent50` — correct CLUT entry fetched. - `[texred] storedblack=0` — nothing in the red-sampled population ended up stored black. - `[gsdump] rect ... redish=30043-32424 / 55680` (~55-58%) on **both** `fbp=0x70` and `fbp=0x0` — vs `redish=266-288` on the old (wrong) battle-frame dump. Box is clearly present and colored in the final framebuffer. **Interpretation — narrows, does not by itself close, Stage 5.11:** Given authentic GS state (real TEX0/CLUT/draw commands from PCSX2), our own rasterizer+sampler+blender pipeline reproduces the box correctly, text and all. Run 22's live finding (`[texred]`'s gate `tb<0x20` blind to `tb=0x29`, suspects #7/#8/#9/#11 re-opened) was about the **live game's own emitted GS state**, not the rasterizer's ability to render a correct box given correct input. Combined: - The rasterizer/blender code path is now demonstrated correct on ground-truth input. - This shifts remaining suspicion toward **what GS state the live EE/game path actually emits** (TEX0/CLUT/draw-command differences between our live run and this authentic dump) rather than the rendering math itself. - Suspects #7/#8/#9/#11 (alpha/depth/blend/overdraw) are not yet cleared for the *live* path — only shown non-fatal when fed the dump's real GS commands. **Not yet done:** confirm the Stage 5.11 box rect geometrically matches one of the two visible dialog boxes in this image (two boxes are cut off at the left edge — could be real or just this capture's crop); diff this dump's actual TEX0/CLUT GS commands against what the live recomp run emits at the equivalent frame, to find where the live path diverges from this authentic one. **Tooling added 2026-08-08 for that diff (not yet run against a live capture):** - `gsdump_draws.py --emit-jsonl PATH` — dump-side ground truth as JSONL, one line per draw, honors existing `--frame/--sprites/--rect` filters. `-` for stdout. Verified against a real snap: 556 draws, box draw at seq 2 matches rect (24,302)-(488,422). - Live side: `ps2_gs_gpu.cpp` now drains the existing (previously print-only) `GSDebugHistoryEntry` ring into the same JSONL schema. Opt-in via two env vars, both required: `PS2X_GSHISTORY_DUMP=` + `PS2X_GSHISTORY_FRAMES=`. Flushes once after N vsyncs elapse (own tick-transition counter, not `m_debugFrameIndex`, which resets on every per-transfer ring drain) then goes quiet. No header edit — all in the .cpp's anonymous namespace, hooked via an RAII guard in `processGIFPacket` so it fires on every return path. `N` needs tuning by trial to land on the memory-card screen; not yet determined. - `build_scripts/gsdump_diff.py dump.jsonl live.jsonl` — ⚠ **this description is SUPERSEDED.** The `(prim,tme,tbp0,cbp,psm)` key described here was the defect that produced the first (false) live-vs-dump report. See the top section for the rewritten format+geometry tool and its real finding. - ⚠ `PS2X_GSHISTORY_FRAMES` **no longer needs tuning** — the rewritten diff auto-aligns frames in Python. Capture broadly (≈300 frames) and slice afterwards. --- ## ★★★ 2026-08-08 run 22 — `[boxtex]` READ. **The sampler is fully exonerated.** Index 50 is fetched correctly and the texel is RED. The loss is downstream, and `[texred]` was blind to the box. ### What `[boxtex]` measured ``` all=106759 draws=680 cenovf=0 tex0=0x40054805dd30aa00 tbp0=0x2a00 tbw=2 psm=0x13 tw=128 th=128 tcc=1 tfx=0 cbp=0x2a40 cpsm=0x0 csm=0 csa=0 cld=2 rect x0=24 y0=302 spanx=464 spany=120 <- exactly the PCSX2 box rect idxsamp=151407024 idxmin=50 idxmax=50 idxhist=0,0,0,,0,... ent50=0x6629127e ent8=0x3f000000 texel=0x6629127e ``` Unpacked the way the pixel loop does (`tr=texel&0xFF`, `tg=>>8`, `tb=>>16`, `ta=>>24`): | | R | G | B | A | |---|---|---|---|---| | ours `0x6629127e` | 126 | 18 | 41 | 102 | | dump `0x8017176F` | 111 | 23 | 23 | 128 | Both dark red. **The blender receives a red texel.** ### Suspects #16 and #17 are DEAD — and so is the "we fetch index 8" premise - `idxmin == idxmax == 50`, 100 % in bucket 3 — **exactly** the dump's ground-truth index. **#17 dead**: `ReadTexturePageCache`'s block math is correct. - `texel == ent50`, non-black, red. **#16 dead**: a wrong TEX0 cannot yield both the right index and a red entry. - ⛔ The old "we fetch index 8 / hist16 100 % bucket 0" reading was the **backdrop**, seen through the dead `tbp==0x2A80` gate. Do not cite it again. Census `tbp/cbp` differ from the dump (`0x2A00/0x2A40` vs `0x2B20/0x2B04`), but our census *does* contain the dump's `0x2A00,cbp=0x2B00`. Different VRAM residence for the same content in a different session — the index and the colour are what convict, and both acquit. ### ⚠ The real finding: `[texred]` structurally could not see the box `ps2_gs_rasterizer.cpp:1059` gates on `tr >= 0x20 && tg < 0x20 && tb < 0x20`. The box texel's **`tb = 0x29` fails by nine**, so `t_redPixel` was false for every box pixel. `killate=0 / killz=0 / storedblack=0` were therefore measured on a sample that **excluded the defendant**. Same failure class as the `0x2A80` gate, one layer down. ### Corrected ledger | # | Suspect | Status | |---|---|---| | 16 | TEX0 selection / staleness | **dead** (run 22) | | 17 | `ReadTexturePageCache` block math | **dead** (run 22) | | 7 | alpha test | **RE-OPENED** — acquitted by a probe blind to the box | | 8 | depth test | **RE-OPENED** — same | | 9 | blender | **RE-OPENED** — same | | 11 | overdraw after the box | **RE-OPENED** — same | Loss is downstream of the sampler: `combineTexture`, or one of `writePixel`'s two uninstrumented early returns. ### Run 23 edits — 4 edits, 2 `.cpp`, no header, **UNBUILT** Rather than a new probe, the existing `[texred]` field set was re-scoped onto the box: 1. `ps2_gs_rasterizer.cpp:~617` — declare `g_trBoxSamp`. 2. `ps2_gs_rasterizer.cpp:~1059` — `colourRed || t_boxDraw` admits the box by SHAPE (the gate `[boxtex]` proved lands on it); `g_trRedSamp` still counts only colour hits so the two populations stay separable. 3. `ps2_gs_gpu.cpp:~82` — `extern g_trBoxSamp`. 4. `ps2_gs_gpu.cpp:~2126` — emit `boxsamp=`, plus the pre-registered reading table below. ### `[texred]` reading table — run 23 | # | Field | Reading | |---|---|---| | 1 | `boxsamp == 0` | box never reached `writePixel`. Loss is upstream in `drawSprite`'s clip or its caller. **Read no other field** | | 2 | `killate` dominant | **#7 convicted** — read `test` (ATE/ATST/AREF) | | 2 | `killz` dominant | **#8 convicted** — read `ztst`, bits 17-18 of `test` | | 2 | `stored ≈ storedblack` | **#9 convicted** — the blender; compare `alpha` against `dstrgb` | | 2 | `stored` large, `storedblack == 0` | red IS in VRAM; **#11** is last standing → next instrument is an overdraw counter on the bbox, not another read-back | ### ★ NEXT ACTION — user's, in this order ```powershell & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag texred ``` ### Learned pattern (run 22) - **A colour-threshold gate is an address gate wearing a disguise.** `tb < 0x20` was an inferred constant exactly like `tbp == 0x2A80`, and it excluded the target by nine units while reporting four confident zeroes. When a probe reports all-zero fates, verify the target satisfies the probe's own predicate **before** crossing suspects off. --- ## 2026-08-08 (earlier) — CLUT read chain proven correct against a PCSX2 dump *(index-fetch conclusion below is SUPERSEDED by run 22)* ### The decisive measurement — our CLUT readback vs PCSX2 ground truth A PCSX2 GS dump of the memory-card screen was decoded with `build_scripts/gsdump_draws.py` and compared byte-for-byte against our `[shadow]` readback: ``` CLUT 0x2B00 pos0..7: 00000000 12000000 1A000000 21000000 3F000000 43000000 4A000000 5F000000 CLUT 0x2B04 pos0..7: 00000000 0A000000 13000000 1A000000 21000000 28000000 30000000 38000000 our [shadow] read : 00000000 12000000 1A000000 21000000 3F000000 43000000 4A000000 5F000000 ``` **Byte-identical to CLUT `0x2B00`.** The deswizzle / cache / CSM1 machinery works, verified against independent ground truth. ⛔ Do not reopen the CLUT read chain. ### What that leaves — we fetch index 8, the truth is index 50 - `0x80000000` (the black we paint) is exactly **position 8 of CLUT `0x2B04`**. - `[texfetch]`'s hist16 put **100 % of samples in bucket 0** (indices 0–15). Truth (index 50) is bucket 3. Two independent corroborations of the same conclusion. - Likely cause: **addressing**. Both prior probes were gated on `tbp == 0x2A80` — a base the frame **never contains** — so they measured the 512×448 backdrop, not the box. ### PCSX2 ground truth for this frame (do not re-derive) Exactly three textured combos exist: | tbp | psm | cbp | draws | |---|---|---|---| | `0x2B60` | T4 | `0x2B08` | 852 | | `0x2B20` | T8 | `0x2B04` | 36 | | `0x2A00` | T8 | `0x2B00` | 4 | `0x2A80` is **never sent**. The box = draw [2], screen rect `(24.0,301.5)-(488.0,421.5)` = **464 × 120**; draws [3][4][5] are its 8 px 9-slice border strips; the backdrop is 512 × 448. Expected box values: `tbw=2 psm=0x13 tw=7 th=7 cpsm=0 csm=0 csa=0 cld=2`, `index=50`, `clutentry=0x8017176F` (red). ### Code written this session — 6 edits, 2 `.cpp` files, no header touched, **ALL UNBUILT** `ps2_gs_rasterizer.cpp` 1. `kTfTbp` `0x2A80u` → **`0x2B20u`** (line ~305). Revives `[texfetch]` + `[uvspan]` onto the real box texture at zero cost. 2. `[boxtex]` counter block + 6-section reading table + `bxCensus()` (lock-free 12-slot CAS table), in `namespace ps2diag_fbstat`. 3. Index capture in `samplePoint` — **not** gated on T8, so a box arriving as T4 stays visible. 4. Capture block in `drawSprite`, **gated on SHAPE** (`spanX 400..500 && spanY 90..150`), not on tbp. Trusting a tbp constant is exactly what wasted runs 20–21. 5. Texel capture + `t_boxDraw` reset in the sprite pixel loop. `ps2_gs_gpu.cpp` 6. `extern` declarations + a five-record `[boxtex]` emit block. No literal `[` in any field (respects `analyze_run.py`'s record-splitting regex). `boxtex` needs no tag registration. Pre-verified before writing (a compile error costs 48 min): `GSTex0Reg::data` exists (`ps2_gs_gpr.h:448-513`); `ReadClutCache(u32 psm, u8 index, u32 csa)` is public (`ps2_gs_gpu.cpp:4703`, declared `ps2_gs_gpu.h:344`) and is the **same call the sampler uses**; `std::ostringstream` already in scope. ### `[boxtex]` reading table — read IN THIS ORDER | # | Field | Reading | |---|---|---| | 1 | `all == 0` | probe describes nothing — read no other field | | 1 | `draws == 0` | the rect gate missed. **NOT** a statement about the box → go to (2), still valid | | 2 | census matches the 3 combos above | **suspect #16 dies**; fault is below TEX0 → go to (4) | | 2 | census has tbp outside that list | **#16 CONVICTED** — bug is GIF register decode / context selection | | 2 | right tbp, wrong cbp | CLD/CBP handling picks the wrong palette → `ReloadClutCache`'s `cld==2` path | | 2 | `cenovf > 0` | table truncated; absence proves nothing | | 3 | decoded TEX0 field wrong | localises to that field | | 4 | `idxmax <= 15` (all weight bucket 0) | **#17 CONVICTED** — `ReadTexturePageCache` block math, `ps2_gs_gpu.cpp:4599` `(tbp0 + page_id * 32) & 0x3FFF` | | 4 | `idxmin..idxmax` brackets 50, weight in bucket 3 | addressing right → go to (5) | | 5 | `ent50 == 0x8017176F` | palette correct **and** correctly addressed | | 5 | `ent50 == 0x80000000 && ent8 == 0x80000000` | wrong CLUT, or never reloaded for this draw | | 5 | `ent50` some other colour | right base, wrong ordering. ⚠ the dump says **both** candidate orderings give red, so a *black* `ent50` cannot be a swizzle bug | | 6 | `ent50` red but `texel` black | loss is in `applyTexa` or `combineTexture` | **One run separates #16 from #17 with no follow-up build.** ### Suspect ledger — 15 dead, 2 live | # | Suspect | Status | |---|---|---| | 1 | `resolveClutIndex` (rasterizer ~318) | dead — call site passes the raw index; it is dead code | | 2 | `ReadClutCache` unmasked `csa*64+index*4` | dead — `csa=0` everywhere (dump) | | 3 | `ReloadClutCacheCSM2` `cou*16` doubling | dead — `csm=0` everywhere (dump) | | 4 | `applyTexa(tex.psm)` | dead — T8 hits the index branch | | 5 | CSM1 source/dest shift | fixed; bit-identical at `csa=0` | | 6 | whole CLUT read chain | **dead — our read exactly reproduces CLUT `0x2B00`** | | 7 | alpha test discarding the box | dead — `killate=0` | | 8 | depth test discarding the box | dead — `killz=0` | | 9 | blender zeroing the pixel | dead — `storedblack=0` | | 10 | presentation / `copyDisplaySource` | dead — same buffer loses red in VRAM | | 11 | `clearFramebufferRect` | dead — exactly one full rect/frame | | 12 | `ReadPageToLinearBufferP8/P4` | dead — 1.29 M checks, 0 mismatches | | 13 | `TexturePageCache::buffer` size | dead — 16 KB, sufficient | | 14 | UV/ST generation in `drawSprite` | dead — PCSX2 sends the same 1-texel span | | 15 | GIF/VIF vertex assembly | dead — vertices match PCSX2 exactly | | **16** | **TEX0 selection / staleness — wrong `tbp`/`cbp` reaching the draw** | **LIVE** | | **17** | **`ReadTexturePageCache` block math (`ps2_gs_gpu.cpp:4599`)** | **LIVE** | ### ★ NEXT ACTION — user's, in this order ```powershell & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo ``` ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ```powershell python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag boxtex ``` ⚠ `-Exe` is **mandatory** (defaults to the Debug exe). Build is good **iff no error line names `ps2EntryRunner.vcxproj`** — `iop_harness` / `ps2x_tests` `LNK1120` on `fn_*` is structural and expected every build. If the build fails, read `build_log.txt`; `build.ps1`'s progress bar swallows the real `C2039`-style lines. ### Open, unscoped - `ps2x_tests.exe Observability clut` → 9 tests, **1 fails**: `"clut-cache: CSM1 T4 reload deswizzles physical VRAM layout into linear cache order"`. Known-bad **expectation**, not a runtime bug — the dump comparison above proves the runtime path is right. Offered to correct the test; user has not answered. - `launch_recomp.ps1` writes one fixed `-Log` path with **no archiving** — every run destroys the previous log. Mentioned, never scoped in. - `PATH_NAMES` in `build_scripts/gsdump_parse.py` lacks `3: "PATH1(new)"` (cosmetic). ### Learned patterns (2026-08-08) - **An external ground-truth dump ends a suspect ledger faster than any probe.** Six sessions of CLUT probes were settled in one afternoon by decoding a PCSX2 GS dump and diffing bytes. Reach for independent ground truth *before* building the next probe. - **Gate diagnostic probes on SHAPE or on geometry, never on an address constant you inferred.** Two full run cycles were spent measuring the backdrop because `tbp=0x2A80` was assumed. A gate whose constant is wrong produces a confident, plausible, entirely wrong reading. - **Give every probe a section that survives its own gate failing.** `[boxtex]`'s census is gated only on "is a textured sprite", so `draws == 0` still answers suspect #16. A probe with one gate has one way to tell you nothing. - **Verify member/method existence before writing a probe when the build is 48 minutes.** Checking `GSTex0Reg::data` and `ReadClutCache`'s signature cost two Reads and saved a cycle. - **A `\`-prefixed comment line in grep output is a display artifact, not a syntax error.** Confirmed by reading the raw files. Cost a false alarm; do not re-chase it. --- ## ★★★ 2026-08-05 — **STAGE 5.9 CLOSED ✅ (asset load works).** New blocker: **Stage 5.10 — GS rasterizes, presented frame is still black.** ### 5.9 root cause — the IOP heap arena was too small, so ARKD's real load buffer never allocated Chain, in order: 1. `sysmem` fid 7/8 (`QueryMaxFreeMemSize`) had been answering a canned value. Once it answered honestly, `InitLoadBuffers` took its real branch and set `dword_B490 = 983040 (0xF0000)` instead of `0x800`. 2. One correct init round then needs `0x800 + 0x800 + 0x40000 + 0x18000 + 0xF0000 = 0x149000` — **more than the 1.125 MB arena (`0x60000..0x180000`) held**. `AllocSysMemory` failed twice, `dword_B304` (TOC base) came back NULL, the descrambler's `if (*(BYTE*)dword_B304)` gate left the table all-zero, every lookup returned `size=0 -> SKIP`, and nothing ever loaded. 3. Fix, both in `ps2_iop_irx_loader.cpp`: enlarge the arena to `0x60000..0x1D0000` (1.44 MB, still 128 KB clear below the deepest guest stack at `kIopStackTop - 0x4000*4 = 0x1EFF00`), **and** recycle the cursor back to `kIopAllocBase` exactly once on the first post-init `AllocSysMemory`, releasing the pre-run scaffolding. ### 5.9 exit test — **PASSED**, every projected number matched | Evidence | Value | |---|---| | Heap recycle | `cursor 0x168000 -> 0x060000` — fired **once** | | Game's own printf | `LoadBuff Addr:0x000b9000 Size:0x000f0000` — matched the projected `0xB9000` exactly | | TOC | `[ARKD:toc] base=0x0a1000 maxFiles=2048 count=1930 (expect 1930) end=0x0b7a10` | | First-letter buckets | identical to `build_scripts/arkd_toc.py` ground truth | | `AllocSysMemory FAIL` | **absent** (was 2) | | `cdread-total` | 1→2→4→8→16→32 reads, 47→555 sectors, 96 KB→1.13 MB, `fails=0` | | `size=0 -> SKIP` | **gone** | | Real SIF DMAs | `[ARKD:sifdma] iopSrc=0x0b9000 eeDest=0x01670c00 size=0x530e0 -> copied` | | Named worker jobs | `font/5-24-2.fon`, `ply/exthit.hit`, `dis/toon.pix`, ~25 `eff/e00/*.ebz`, `shd/shd.{mdl,pix}`, `ply/p00/p00x.pmt`, many `dis/d0*/*.pix` — all `halted=1 B300=30000000` | ### ★ Stage 5.10 (opened 2026-08-05) — pixels are drawn, the presented frame is black ``` [gs:frame] px=33365775 nonblack=3757356 textured=19832591 maxrgb=255 primmask=0x40 imagebytes=107830592 prims=135519 [present] has=1 w=512 h=448 nonblack=0 dispFbp=0x0 srcFbp=0x0 ctx0.fbp=0x70 ctx1.fbp=0x70 pmode=0x7f27 dispfb1=0x1000 [blackwho] untex=13533184 texzero=16075177 texcol=232 | last black store: prim=0x6 texpsm=0x14 tbp=0x2b60 cbp=0x2a48 fbmsk=0x1 alpha=0x44 texel=0x0 xy=0x1bf01ff fbp=0x70 [psmt4] pre n=800 eeAddr=0xffffffff dbp=0x2b80 dbw=4 rrw=256 rrh=256 total=65536 sizeBytes=32768 srcNonZero=0 [psmt4] post n=800 addr0=0x570000 consumed=32768 copied=0 total=0 readback=0000000000000000 [gifsrc] n=1970 path=2 region=copy eeAddr=0xffffffff bytes=32768 nonZero=0 [vu:frame] vif1calls=59 vif1bytes=7675488 mscal=0 vu1runs=0 xgkicks=0 ``` Deltas vs the 08-04 record: `textured` 0 → 19.8M, `maxrgb` 0 → 255, `nonblack` 0 → 3.76M. ### ⚠️ CORRECTIONS to the 08-04 entry below — do not act on those lines - **"DISPFB page-flip DEAD" is WRONG as of 08-05.** `dispfb1` now toggles `0x1070` ↔ `0x1000` and `ctx0/ctx1.fbp` swap with it. The flip is **ALIVE**. - **The `PS2X_HWWATCH_ADDR=0x89d400` watchpoint is MOOT.** Its premise was that the asset never landed in RAM; assets now load. Do not build it. - **`[psmt4] copied=0 total=0` is NOT a bug.** `GS::EndTransfer()` does `m_transferState = {}`, so a *completed* transfer always reports zero counters afterwards. `consumed=32768` with `rrw*rrh = 65536 = 2 * sizeBytes` says the unswizzle loop ran to completion correctly. The readback is zero **because the source bytes were zero** (`srcNonZero=0`) — not because `WritePixelP4` is broken. Measurement artifact; the unswizzle path is exonerated. ### ★★★ Stage 5.10 update — 2026-08-06/07: the font decoder is **never called** `[fontgate]` (read-only sampler in `game_overrides.cpp`, change-gated, cap 24, `PS2X_FONTGATE=0` disables) ran clean — 12 records over 36k samples, **no `[cap]` line**, so absence is evidence. Exactly one field diverged from real hardware: | Field | Ours | PCSX2 (main menu) | | |---|---|---|---| | `o1C` (+0x1C) | `0x0` | `0x32` | ★ only mismatch | | `o3C` / `o44` / `o48` / `o50` | `0x7` / `0x1a1a1818` / `0x9` / `0x51` | identical | ✅ | | `tbl0` @ `0x503670` | `0x2b600001`↔`0x2b800001` | `0x2b600001` | ✅ | | `tblNz` | 272/768 stable | — | ✅ | | gates | `gateA=1 ready0=1` | open | ✅ | | FFON | `addr=0x659400 w=24 h=24 fmt=4 count=1` | same | ✅ | | `srcNz` / `clutNz` / `payloadNz` | `0/512` / `60/64` / `0/4096` | — | untouched BSS | **Conclusion:** `sub_00113200` reads `lw v0,0x1C(s5)` at `0x00113284`, sees 0, and early-outs at `0x001132D0`. **`sub_00111530` (the glyph decoder) is never invoked.** The zero payload is untouched BSS, not a bad decode — the decoder body and the `0x503670` descriptor table are both exonerated. #### The text-layer struct (from `ida_scripts/decompiles_SLUS_214_42.txt`) 28 dwords, stride `0x70`, array base `dword_54BC80`, indexed `&dword_54BC80[28*(id-1)]`, valid ids 1..2 (`if (a1 && a1 < 3)`). | Off | HW | Meaning | |---|---|---| | +0x04 | `0x800` | char-buffer capacity | | +0x08 | `0x22f` | chars used | | +0x10 | `0x100` | max lines | | **+0x14** | **`0x12`** | **lines queued — the discriminator** | | +0x18 | `0x12` | lines to render (loop bound) | | +0x1C | `0x32` | glyphs built — a **per-frame counter**, not a flag | | +0x28 | `0x32` | secondary glyph count | | +0x6C | `1` | gate B (ready) | Chain: menu code → `sub_00112940` (enqueue/printf API — bumps +0x14, appends to +0x08) → `sub_00112750` per frame (`sub_00111DA0`, then `sub_00112030` × +0x18) → `sub_00113200` → `sub_00111530`. `sub_00112750` zeroes +0x1C/+0x28 every frame and **bails to `LABEL_12` when +0x14 == 0**, skipping the whole glyph build. So `+0x1C` alone cannot separate "nothing was ever printed" from "printed but the build failed" — it reads 0 in both. **`+0x14` can.** Full call path: `0x00422630 → 0x00421ea0 → 0x003280c0 → 0x00199fb0 → 0x00112750 → 0x00113200 → 0x00111530`. #### ✅ ANSWERED 2026-08-07 — **branch B fired. Text IS queued; the glyph build fails.** Revised `[fontgate]` built + run, 12 records, **no `[cap]`**. Result: | field | ours (12 samples) | HW | reading | |---|---|---|---| | `o14` lines queued | **4 → 3 → 2 → 1** | `0x12` | ★ nonzero — enqueue works | | `o18` lines to render | 4 → 1 | `0x12` | tracks `o14` | | `o1C` glyphs built | **0, always** | `0x32` | ★ the failure | | `o28` secondary count | **0, always** | `0x32` | ★ same failure | | `srcNz` / `payloadNz` | 0/512, 0/4096 | — | downstream of `o1C` | | `clutNz` | **60/64** | — | CLUT IS loaded | | `tblNz`, `tbl0` | 272/768, `0x2b600001` | `0x2b600001` | descriptor table IS built | | `ffonAddr/WH/Fmt` | `0x659400` / `0x1818` / `4` | same | **FFON asset is resident, correct fmt** | So the font data is all present and `o14 ≠ 0` — `sub_00112750` does **not** take its `LABEL_12` bail. It runs the glyph build and the build produces nothing. ⚠️ **`analyze_run.py` truncated every field after `obj=`** — its record splitter cuts on `[`, and the dump was `obj=[...]`. The data was in `run_log.txt` all along. Recovered with a direct regex; the probe now emits `obj=<...>` instead. Generalises [[feedback_capped_probes_false_negatives]]: **a field absent from the analyzer is not a field absent from the log.** #### ★★ Root-cause candidate — `sub_00111DA0` rejects the font header, leaving a garbage descriptor Static read of the IDA dump (`mem_fill_m @ 0x111DA0`, `reg_save_stub_z_33 @ 0x112750`): ```c // sub_00112750, with +0x14 != 0 (our case) *(v9+40)=0; *(v9+28)=0; // zero +0x28, +0x1C if (!*(v9+20)) goto LABEL_12; // +0x14 == 0 -> bail. NOT taken. if (mem_fill_m(v9)) // sub_00111DA0 = THE GLYPH BUILDER v10 = reg_save_stub_z_36(v9,256,256) != 0; // = sub_00113200, the blit else v10 = 0; if (!v10) v6 = 2; // <- failure code, no glyphs ``` `+0x1C` (`a1[7]`) and `+0x28` (`a1[10]`) are incremented **only inside `sub_00111DA0`**, and that function opens with: ```c v12 = resolve(a1[13]); // obj +0x34, a HANDLE if (*(u32*)v12 == 0x4E4F4646 /*'FFON'*/ // magic && (u16)*(u32*)(v12+12) == 256 // hdr+0x0C low16 == 0x100 && byte(v12+0x0E)==0 && byte(v12+0x0F)==0) { v47[0..3] = ...; } // build font descriptor // NO else -- on failure v47[] stays UNINITIALISED STACK ... reg_save_stub_z_32(v47, v21, &v48) // per-char glyph lookup ``` If that header check fails, `v47[]` is uninitialised stack, every per-character lookup misses, and neither `+0x1C` nor `+0x28` is ever incremented — **exactly** our signature. The only other route to the same signature is an empty line string (`charBuf + *(u32*)(line+24)` is NUL, so the inner `while(*v21)` never runs). ★ **Key structural fact discovered:** the small values in the `obj` dump (`9, a, b, c, d, e, f, 7, 4`) are **resource handles, not pointers**. `sub_00103E10` → `sub_00110260` resolves them: ``` addr = (h == 0) ? 0 : *(u32*)( *(u32*)( *(u32*)0x005030D0 ) + 16*(h - 1) ) ``` Every earlier attempt to read these fields as addresses was reading nonsense. #### ❌ RETRACTED 2026-08-07 (run 3) — the run-2 conclusion was wrong; the PROBE was broken Run 2 concluded "the resource table never points at the font." **That was an artifact of my own resolver, not a fact about the game.** `altB` — included in run 3 precisely to test this — came back `0x659400`, the FFON. `fontGateResolve()` took **one deref too many**. The IDA text at `0x103E10` reads `vtable_table_lookup(dword_5030D0, h)` with **no cast**, while its neighbours `0x103E20` / `0x103E30` both cast `(unsigned int)dword_5030D0`. The cast-free form is IDA printing the **address** of the global, so `sub_00110260`'s `*a1` dereferences `0x005030D0` itself. Correct formula: ``` addr = *(u32*)( *(u32*)0x005030D0 + 16*(h-1) ) // one deref, not two ``` With it, `resolve(obj[13]=4)` = `0x659400`, whose first word is `'FFON'`. Fixed in `game_overrides.cpp`. **Both of these are now dead and must not be re-derived:** - ~~the resource table is unpopulated~~ — it is fine; `resMgr=0x646400` **is** the array base - ~~`sub_00111DA0` rejects the descriptor~~ — it **passes** the header check > Lesson, now costed twice: `res=` was read as "the game is broken" when it > equally meant "my formula is broken." A degenerate probe result is evidence about the probe > first. The rival-reading fields (`altB`/`altS`) are what caught it — keep including them. #### ✅ ANSWERED 2026-08-07 (run 3) — the loop body never runs for a single character From the decompile of `sub_00111DA0` (`mem_fill_m @ 0x111DA0`): ```c v15 = resolve(a1[0]); // char buffer base v14 = resolve(a1[3]); // line records, stride 28 v21 = v15 + *(u32*)(v14 + 24); // this line's text while (*v21) { ... a1[10]++ ... } ``` `a1[10]` (`o28`) is incremented on **both** character paths — the newly-allocated-glyph branch and the already-cached branch. It reads `0` in **every** record. So the `while` body never executed even once. Only two causes exist: 1. **`*v21 == 0`** — the text pointer lands on a NUL (nothing to draw, or a bad line offset) 2. **`reg_save_stub_z_32` returns < 0 for every char** — the char→glyph decode fails ~~`o18` (`a1[6]`, line count) does go `4→3→2→1`, so the **outer** loop is entered.~~ **Struck 08-07 (run 4):** `a1[6]` is written by the *line-queueing* code, not by the builder, so its movement says nothing about whether `sub_00111DA0` ran. Both causes stayed live. #### ✅ ANSWERED 2026-08-07 (run 4) — cause 1 is dead. The text is there. ``` str=<4e,6f,20,6d,65,6d,6f,72,79,20,63,61,72,64,20,28> == "No memory card (" bufNz=180/256 lineOff=0x0 line0=<19,132,3f800000,12,12,ffffffff,0> hdrOK=1 hdr10=0x28 hdrA=0x659400 hdrM=0x4e4f4646 res=<89e400,89f400,8a2400,8a1400,8a4400,8a3400> all six handles distinct ✓ o1C=0 o28=0 still, in every record ``` `v21 = v15 + *(u32*)(v14+24)` = `0x89e400 + 0` = the `'N'`. **Not a NUL.** The resolver fix is also confirmed sound: six handles now give six distinct addresses, and the FFON header passes. Two possibilities remain, and they are *not* the old pair: 1. **`reg_save_stub_z_32 @ 0x111A00` returns −1 for every char** — its two lookup tables live inside the FFON asset, so a zero-filled asset body produces exactly this. 2. **`sub_00111DA0` is never called at all** — `o1C`/`o28` staying 0 is equally consistent with the builder not running, now that `o18` no longer argues otherwise. The decode, for ASCII `c < 0x80` (`v47[1] = hdr+32`, `v47[2] = hdr+160`): ```c page = *(u8 *)(v47[1]); if (!page) return -1; idx = *(u16 *)(v47[2] + 2*(c - 32 + 96*(page-1))); if (!idx) return -1; return idx - 1; ``` #### ✅ ANSWERED 2026-08-07 (run 5 data) — the decode works. Both run-4 causes are dead. ``` dcCh=0x4e dcPage=0x1 dcSlot=0x2f dcRes=46 pgNz=40/128 gmNz=252/512 ffonNz=1431/4096 ``` The FFON body **is** loaded and the char→glyph decode resolves `'N'` → slot 46. So `reg_save_stub_z_32 @ 0x111A00` does not return −1, and the asset is not a zero body. **⚠️ Retract as evidence: `o1C=0` / `o28=0` in every record proves nothing.** The caller `reg_save_stub_z_33 @ 0x112750` writes `*(a1+28)=0` and `*(a1+40)=0` **on entry** and clears `+8/+14/+18/+1C` again at its `LABEL_11` exit. Both counters are zero everywhere *by design*. Textbook [[feedback_probe_the_final_value]]. ##### ★★ The persistent witness — `obj[11]` has never moved `obj[11]` (offset `+0x2C`) is the **glyph free-list stack pointer**. `sub_00111DA0` decrements it on every allocation (`a1[11] = --v26`), and `sub_00112750` never clears it. It reads **`0x200` in all 13 records, n=2000 → 36000**. Zero glyphs have ever been allocated. ##### Why allocation is skipped — the `0x8000` seed ```c v23 = glyphMap + 2*glyphIdx; v24 = *(u16*)v23; if (v24 & 0x8000) { ...allocate from free list, build glyph... } else if (*(u8*)(cacheMap + 4*v24 + 2) != 2) ++a1[10]; // no-op ``` `0x8000` is the *"slot empty — go allocate"* marker. A **zero-filled `glyphMap` has the bit clear**, so every character falls into the else branch, silently treats slot 0 as a cache hit, allocates nothing and builds nothing — which is exactly `payloadNz=0/4096`. The only writer of `0x8000` we have found is the aging loop at the tail of `sub_00112750` (`*(u16*)(v11 + 2*…) = 0x8000`, `v11 = resolve(a1[9])` = `glyphMap`), and it is gated on `obj[11] < obj[23]` == `0x200 < 0x200` == **false**. It never runs. If nothing else seeds `glyphMap`, the cache can never fill. #### ✅ ANSWERED 2026-08-07 (run 6 data) — glyphMap is entirely zero, and the seeder is a memset Run 6, 13 records, n=2000→36000, **every record identical on these fields**: ``` o2C=0x200 o5C=0x200 o6C=0x1 gmapW=0x0 gmap8k=0/512 gmapNz=0/1024 ``` `glyphMap @0x8a1400` is **all zero across all 1024 bytes** and the free-list SP never moved. The `gmap8k == 0` branch is taken: the map was never seeded. **★★ The seeder is found — and it is a `memset`, not a `0x8000` loop.** My earlier claim that "the only `0x8000` writer is the aging loop in `sub_00112750`" was too narrow: it was a literal grep for `0x8000`. The real seed is in the **object-create routine** (decompile line 14490 — the one whose tail is `*(u32*)(v18+44) = *(u32*)(v18+92)`, i.e. exactly our `obj[11] = obj[23] = 0x200`): ```c v22 = resolve(*(int*)(v18+36)); // obj[9] = glyphMap v24 = resolve(*(int*)(v18+48)); // obj[12] = freeList v23 = resolve(*(int*)(v18+52)); // obj[13] = font if (*(u32*)v23 == 'FFON' && hdr check ok) v13 = v23; module_obj_init(v22, 255, 2 * *(u32*)(v13 + 24)); // ← memset(glyphMap, 0xFF, len) do { *(u16*)v24 = --count; v24 += 2; } while (...); // ← free-list descending fill *(u32*)(v18+28) = 0; *(u32*)(v18+44) = *(u32*)(v18+92); // ← obj[11] = 0x200 ``` `module_obj_init @0x18E408` is a `memset` (`pcpyh`/`pcpyld` + `sq` stores). So `glyphMap` is supposed to come out **`0xFFFF`-filled**, and `0xFFFF` carries the `0x8000` "slot empty" bit. That is the seed. It did not happen. ⚠️ Note `v13` is an **uninitialized stack variable** unless the FFON header check passes, so the memset length `2 * *(u32*)(v13 + 24)` is the prime suspect for being zero. #### ✅ ANSWERED 2026-08-07 (run 7 data) — branch 3: the init ran, the length was real, the memset vanished 11 records, all identical on the new fields: ``` hdr18=0x482 fl0=0x1fe01ff flNz=766/1024 ctNz=0/1024 cmNz=1/2048 gmapNz=0/1024 ``` `flNz=766/1024` is a **byte-perfect** match for the descending free-list fill `0x1ff,0x1fe,…,0x000` — 510 nonzero low bytes (all but `0x000` and `0x100`) + 256 nonzero high bytes (`0x100`…`0x1ff`) = 766 — and `fl0=0x1fe01ff` is exactly `0x01ff,0x01fe` little-endian. Three things follow, and they close three open questions at once: 1. **The create routine ran.** The free-list fill is its work, verbatim. 2. **`fontGateResolve()` is correct.** Handle `0xe` → `0x8a4400` provably holds the game's own data. The resolver is no longer a suspect for *any* handle. 3. **The memset length was not zero.** `hdr18 = 0x482` ⇒ `2 * 0x482 = 0x904` = 2308 bytes. So `module_obj_init(glyphMap, 255, 0x904)` — which sits *immediately before* the free-list fill, in the same basic block — did not land at `0x8a1400`. The fill worked; the memset did not. **Ruled out this session, statically:** - `translatePCPYH` — `_mm_set_epi16(h,h,h,h,l,l,l,l)` from `_mm_extract_epi16(src,0)` / `(src,4)`. Correct. - `PS2_PCPYLD` — `_mm_unpacklo_epi64(rt, rs)`, i.e. `rs` upper / `rt` lower. Correct. - A globally broken `sq` would take the whole game down, not one buffer. Also retired: the aging-loop gate `obj[11] < obj[23]` is correct behavior (a full free-list has nothing to reclaim), and the `v13`-uninitialized-stack-var worry is dead — `hdr18` is real. #### ✅✅✅ ROOT CAUSE FOUND 2026-08-07 (run 8) — `translatePCPYH` reads `rs`, must read `rt` Run 8 returned **`ffAt=0x0 ffLen=0` in all 13 records** — branch 1. The memset stored no `0xFF` anywhere in the 128KB arena. That was answerable **statically**, no further run needed: 1. `decompiles_SLUS_214_42.txt:14574` — `module_obj_init(v22, 255, 2 * *(v13+24))`, where `v22 = wrap_vtable_table_lookup_b(*(v18+36))` = `resolve(obj[9])` = glyphMap. The free-list fill at :14583 resolves `v24` through the **same wrapper** and demonstrably works, so the target address was never in doubt. 2. `decompiles_SLUS_214_42.txt:110279` — `sub_18E408` is a memset that **cannot return without writing**: even when the 16-byte-aligned vector fast path is skipped, the byte tail loop at :110319 (`for (i = a3-1; i != -1; ++v3) *v3 = a2;`) covers every byte. `a3 = 0x904 ≠ 0`. ⇒ the fault is in **our** translation, not the guest. 3. `ps2xRuntime/src/runner/sub_0018E408_0x18e408.cpp:80-83` — `// 0x18e434: 0x70081ee9 pcpyh $v1, $t0` emits `GPR_VEC(ctx, 0)`. **It broadcasts `$zero`.** **Decode of `0x70081ee9`:** op=`0x1C` (MMI), **rs=0**, **rt=8 (`$t0`)**, rd=3 (`$v1`), sa=`0x1B` (PCPYH), func=`0x29` (MMI3). PCPYH is encoded `rd, rt` with **rs unused (always 0)**. `translatePCPYH` formatted `inst.rs`, so every `pcpyh` in the game produced **0**. **The data fits byte-for-byte.** `0x904` = 2308 = 72×32 + 4. The vector loop *zero*-fills 2304 bytes; the byte tail (`sb $a1`, correct) writes 4 bytes of `0xFF` at offset `0x900`. Hence `gmapNz=0/1024` (the probe window is the first 1024 bytes — all zeroed) and `ffAt=0` (the only `0xFF` run is 4 bytes, far below the 256 threshold). This is the seeder for the `0x8000` glyph-cache marker, so a zero-filled glyphMap ⇒ every char fakes a slot-0 cache hit ⇒ `payloadNz=0/4096` ⇒ `[present] nonblack=0`. **The whole Stage 5.10 chain closes on one operand.** **Blast radius: 4 emission sites, 2 distinct guest functions** (`grep -rl pcpyh` over `runner/`, counts only) — `0x18E408` (memset) and `0x18E0D8` (`0x700a46e9` = `pcpyh $t0, $t2`, rs=0, rt=10 — same defect). Both are libc mem routines. Each has two coexisting naming generations (`fn_*` + `sub_*`/`mem_compare_n_b_*`), which is why 2 functions give 4 files. **Fix applied:** `ps2xRecomp/src/lib/mmi_translation_helpers.cpp:574` — `inst.rs` → `inst.rt`, with a comment recording the encoding. `translatePCPYUD`/`translatePCPYLD` take both `rs` and `rt` and were already verified correct; `PS2_PCPYLD` (`ps2_runtime_macros.h:727`) is correct. **`registerFunction` cannot deliver this fix** — `sub_18E408` is invoked as a direct C++ `fn_` call from recompiled code ([[feedback_registerfunction_bypass]]), so an override wrapper would never be entered. It must come from **regeneration**. **Regeneration is cheap here.** `PS2Recompiler::writeToFile` (`ps2_recompiler.cpp:2199`) has a content-compare guard: it re-emits ~30,000 TUs but skips writing any file byte-identical to what is on disk, so timestamps — and therefore the incremental rebuild — are confined to files that genuinely changed. Expect ~2–4 changed TUs, minutes not hours. **This is not a clean.** #### ✅✅✅ FIX VERIFIED 2026-08-07 (run 9) — every predicted field moved, glyph pipeline is ALIVE The regen ran, the runtime rebuilt, and **all six pre-registered verification fields hit their predicted values.** This is the strongest confirmation the project has produced: the predictions were written down *before* the run, and they were exact, not approximate. | field | run 8 (broken) | predicted | run 9 (observed) | |---|---|---|---| | `ffAt` | `0x0` | `0x8a1400` | **`0x8a1400`** (n=2000/4000) | | `ffLen` | `0` | `2308` | **`2308`** — `2*0x482 = 0x904` exactly | | `gmapNz` | `0/1024` | `1024/1024` | **`1024/1024`** | | `gmap8k` | `0/512` | `512/512` | **`512/512`** | | `payloadNz` | `0/4096` | non-zero | **`621/4096`** | | `o2C` (free-list SP) | `0x200` frozen | must decrement | **`0x1d6`** | **The consequence chain now runs end to end.** `pcpyh` broadcasts `$t0` → `sub_18E408` `0xFF`-fills 2308 bytes → glyphMap carries the `0x8000` cache-empty marker → cache misses are *real* → glyphs allocate. `0x200 - 0x1d6 = 0x2a` = **42 glyphs allocated**, and `o28=0x2a` independently reports the same 42. The dependent tables came alive with it: `ctNz` `0/1024`→`41/1024` (charTab), `cmNz` `0/2048`→`83/2048` (cacheMap), `srcNz` `0/512`→`58/512` (source pixels), and `gmap8k` correctly *decays* `512/512`→`470/512` as 42 slots are consumed. `ffAt` likewise walks forward `0x8a1400`→`0x8a14b4` with `ffLen` `2308`→`2128` — the untouched tail of the `0xFF` fill, shrinking by exactly the region the allocator claimed. Nothing here is a coincidence of one counter; six independent fields agree on the same 42. **Stage 5.10's root cause is closed.** `translatePCPYH` reading `inst.rs` instead of `inst.rt` was the whole blocker, and the fix is confirmed live in the regenerated runners. #### ✅ `[present] nonblack` ANSWERED — non-zero. Stage 5.10's discriminator PASSED. The run-9 log was queried; no rebuild, no re-run. ``` [present] has=1 w=512 h=448 nonblack=0 ... (first 7 records — early boot) [present] has=1 w=512 h=448 nonblack=9180 dispFbp=0x70 pmode=0x7f27 [present] has=1 w=512 h=448 nonblack=9794 dispFbp=0x0 pmode=0x7f27 ... alternating 9180 / 9794 for the remaining 9 records, in lockstep with the page flip ``` **`nonblack` went `0` → `9180`/`9794`.** The pre-registered branch was binary and it resolved to "non-zero ⇒ the frame is drawing". `pmode=0x7f27`, `w=512 h=448`, and `dispFbp` alternating `0x70`↔`0x0` in step with `dispfb1/2` `0x1070`↔`0x1000` — the page flip is alive and each buffer carries its own stable pixel count. That is a real double-buffered frame, not a stuck one. **Honest bound on what this proves.** `nonblack` counts non-black pixels in the presented buffer; it does not identify *which* pixels. 9180 of 512×448 = **4.0%** coverage, which is the right order of magnitude for a line of menu text on a black screen but is not by itself proof the glyphs are the source. The chain is now measured end to end at every link — glyph alloc (42), payload (`payloadNz=621`), present (`nonblack≈9.2k`) — so the remaining question is cosmetic verification, not diagnosis: **look at the window.** **`[psmt4]` is uninformative here and must not be read as contradiction.** All 20 records are `n=0…7, 400, 800` — the probe stopped firing long before glyphs were allocated (that begins around `n≈14000`). `copied=0 total=0 readback=0` at `n≤800` describes the pre-glyph era only. Per [[feedback_capped_probes_false_negatives]] and the known `EndTransfer()` artifact behind `copied=0`, this tag says nothing about the post-fix state. Its one useful field is consistency: `dbp` alternates `0x2b80`/`0x2b60`, matching `[fontgate] tbl0=0x2b800001`/`0x2b600001` exactly. ⚠ Do **not** re-derive the glyph chain. It is measured and settled. #### ✅✅✅ STAGE 5.10 CLOSED 2026-08-07 (run 10) — visual confirmation, TEXT IS ON SCREEN Ran with `-Determinism 0 -RunSeconds 90 -NoDebugger -Exe …\RelWithDebInfo\ps2EntryRunner.exe`. **The memory-card text renders and is legible.** Exit test PASSED. `translatePCPYH`'s `rs`→`rt` fix carried the whole chain from `pcpyh` through glyph allocation to visible pixels. Cumulative fix entry: `translatePCPYH` read `inst.rs`; PCPYH is encoded `rd, rt` with `rs` unused. See [[project_stage58_vu1_microcode_missing]] for the full evidence chain. #### ★ STAGE 5.11 (new, opened by the same run) — the red text box is now MISSING Same run: the text appeared, **and the red box that used to be behind it disappeared.** Before the fix the observed state was "flashing red text box with invisible text"; after it is "legible text, no box". A clean inversion. **Do not assume this is a regression.** The 08-04 archive note already reads: *"The flashing text box with invisible text is a blank PSMT4 glyph sheet."* If that framing was right, the red field was never a UI element — it was the all-zero glyph sheet being rendered as one solid quad, and its disappearance is the artifact being *fixed*, not a new bug. That hypothesis is plausible but **not established**, and the rival reading (SDBZ genuinely draws a dialog background here, and we have lost a real draw) is equally consistent with what was seen on screen. Do not pick one by reasoning. **The pixel count already says the box is genuinely absent, not merely dim.** `[present] nonblack≈9180`. A dialog background covering even a modest 400×100 region would contribute ~40,000 non-black pixels on its own — 4× the entire observed count. ~9.2k is the right magnitude for *glyph pixels alone* (a ~40-char line of 24×24 glyphs at realistic coverage). So no box pixels are reaching the framebuffer at all. ⚠ One mechanism worth *ruling out* rather than assuming: "CLUT[0] is opaque red, so the blank sheet drew solid." That does **not** survive arithmetic — `payloadNz=621/4096` means the payload is still overwhelmingly index 0, so if CLUT[0] were opaque red we would now see a red box *with* text on it. We do not. Whatever produced the red field, it was not simply CLUT[0] on a zero payload. **Discriminator — a reference screenshot, not a probe.** Boot the same scene in PCSX2 and look at whether a red/coloured box sits behind "No memory card". That settles correct-vs-regression in one step and costs nothing to instrument. - **PCSX2 shows no box** ⇒ our frame is correct; close 5.11 immediately, the artifact was the bug. - **PCSX2 shows a box** ⇒ a real draw is missing. Then, and only then, instrument: reopen `[psmt4]`'s change-gate past `n=800` so it samples the glyph era, and compare `[gs:frame] nonblack` against `[present] nonblack` to find whether the box is rasterized and lost, or never submitted at all. #### ✅ 5.11 BRANCH RESOLVED 2026-08-07 — user confirmed the box is real ("it sits behind *No memory card*"). Branch B. **And the run-10 log already contains the answer — no rebuild was needed.** `[fbscan]` (already live from Stage 5.7, change-gated, uncapped) scans **both** halves of the double buffer per latch with byte-identical decode, varying only `fbp`. Its 43 records over the 90 s run: ``` [fbscan] fbp=0x0 ... nonblack=0 (boot, both halves) [fbscan] fbp=0x0 ... nonblack=9487 <- text only. fbp=0x70 stays 0 for a long stretch [fbscan] fbp=0x70 ... nonblack=9487 <- second half finally starts receiving the text [fbscan] fbp=0x0 ... nonblack=64664 <- ★ LAST TWO RECORDS ONLY ``` **The box IS rasterized.** `64664 − 9487 = 55,177` non-black pixels appear on top of the text — the right magnitude for a ~550×100 dialog background, and it matches the independent `[vramcensus]` evidence exactly: pages `0x51…0x56` and `0x59,0x5a` report `nonblackwords=2048/2048`, i.e. **fully saturated** GS pages. At `fbw=8` those are 64×32 blocks at page-row 10 cols 1–6 and page-row 11 cols 1–2 — a solid horizontal band, not scattered glyph pixels. Two probes, independent mechanisms, same rectangle. **Two facts that constrain every downstream hypothesis:** 1. **`64664` appears only in the final ~2 records of a 90 s run**, and only at `fbp=0x0`. The box is drawn *late*. The user's on-screen observation may simply predate it. 2. **`[present] nonblack` never exceeds 9794.** It reported `9180`/`9794` throughout — text-only magnitude. So at no sampled moment did the presented frame carry the box. **Ruled out — the box is not going to a lost render target.** `[gs:frame-change]` has 1097 records and `frame.fbp` is **only ever `0x70` or `0x0`**. Everything draws into the two display buffers. Also `primmask=0x40` for the whole run: every rasterized primitive is a **sprite** (prim 6). Pure 2D. **Not yet distinguished (do not pick by reasoning):** - (a) The box simply appears late and the 90 s window ended before `[present]` could sample it — in which case nothing is broken and 5.11 closes. - (b) The box lands in `fbp=0x0` while presentation is locked to the other half, so it would never show no matter how long the run — a real flip/present bug. **★ NEXT ACTION — a longer run. Zero code change, zero rebuild.** Re-run at 150 s and re-query the two tags. This separates (a) from (b) in one shot: ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 150 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ```powershell python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag fbscan python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag present ``` Pre-registered reading: | observation | conclusion | |---|---| | `[present] nonblack` reaches ~64k | ✅ box presents. 5.11 was a timing artifact — CLOSE | | `[fbscan]` shows 64664 in **both** `fbp`, `[present]` still ~9.5k | loss is inside `copyDisplaySource`, downstream of the scan | | `[fbscan]` 64664 **only** at `fbp=0x0` for many records, `[present]` ~9.5k | flip/present half-selection bug — chase `dispFbp` vs `srcFbp` | | `64664` still only in the last ~2 records | box is drawn once at a fixed late point; re-time the question | ⚠ Do **not** rebuild `[psmt4]` or build `[vifsrc]` for this. Both were pre-registered against the old "never submitted" hypothesis, which `[fbscan]` has now falsified — the box *is* submitted and *is* rasterized. Instrumenting the upload path would answer a question that is already closed. --- #### ✅ 5.11 RUN 11 RESULT (150 s, 2026-08-07) — none of the four rows. Root cause reframed. The 150 s run genuinely ran longer (`prims` 231,883 vs run 10's 145,673; `imagebytes` 180 MB vs 115 MB; `[fbscan]` 61 records vs 43) and **the `64664` never recurred** — max `nonblack` is 9487. Run 10's log is gone (`launch_recomp.ps1` writes a single fixed `-Log` path, no archiving), so that event is unrepeatable. `-Determinism 0`, so it was a one-off. **Row 3 is falsified — there is no flip/half-selection bug.** Every `[present]` record carrying `nonblack=9794` has `dispFbp` equal to the `[fbscan]` half that holds 9487. Presentation always picks the populated buffer. **What the run *did* settle, from two probes that were already compiled in:** `[fbdest]` — `lastnbpct=99`, and the black/non-black split is near-identical on both halves (`fbp0x0=2227980/14770920`, `fbp0x70=2227980/14770920`). Colour reaches both buffers. Nothing is being misaddressed. `[blackwho]` — the decisive line: ``` [blackwho] untex=13762560 texzero=15759420 texcol=19860 tail=0,0..511,447 | last black store: prim=0x6 texpsm=0x14 tbp=0x2b60 cbp=0x2a48 fbmsk=0x1 alpha=0x44 texel=0x0 srcrgb=0x0 xy=0x1bf01ff fbp=0x0 ``` - **`texcol=19,860`** — three orders of magnitude below the others. **Blending, TEXFUNC and FBMSK are exonerated.** Colour that arrives at the pixel pipeline is not being eaten there. - **`texzero=15,759,420` is the LARGEST bucket** — larger than the screen clear itself. These are textured sprites whose texel decodes to RGB 0 and which then **store that black over the background**. `texpsm=0x14` = PSMT4. On real GS those pixels are discarded by the alpha test or blended to `Cd`; here they are written. That mechanism explains every observation at once: only the last-drawn layer survives, which is why `[fbscan]` sits at exactly 9487 (the text) and why the box — drawn *before* the text — is gone by present time. Run 10's `64664` was `[fbscan]` catching the frame between the box draw and the text draw that erased it. ⚠ **Do not conclude from `prim=0x6` in that snapshot.** `0x6` decodes to prim=6, TME=0, ABE=0 — an untextured clear. The `snap*` set is overwritten by *every* black store, and the full-screen clear always lands last in an interval, so it permanently masks the texzero population. That snapshot describes the clear, not the offender. **★ NEXT ACTION — `[texzero]` probe. Two `.cpp` files, no headers.** Added `ps2_gs_rasterizer.cpp` (capture, on the texzero branch only) + `ps2_gs_gpu.cpp` (emit). Fields: `abeon`/`abeoff`/`ateon` counts, texzero-only `bbox`, and a snapshot of `prim`, raw `test`, `alpha`, `srca`, `texel`, `texpsm`, `tbp`, `cbp`, `dstrgb`, `fbp`. Pre-registered reading — **`abeoff` vs `abeon` first, everything else follows**: | observation | conclusion | |---|---| | `abeoff` >> `abeon` | game relies on the alpha TEST to discard index-0 texels and we are not discarding them. Read `test` (bit0 ATE, bits1-3 ATST, bits4-11 AREF, bits12-13 AFAIL) and `srca` — **`srca` nonzero on a `texel` whose alpha is 0 means the PSMT4/CLUT decode is dropping the alpha channel**, which is the fix site | | `abeon` >> `abeoff` | the blend ran and still produced black — compare `alpha` against `dstrgb`; if `dstrgb` is nonzero the blend equation is wrong, if zero the background was already lost upstream | | `ateon` ≈ 0 | ATE is genuinely off in the guest's TEST register; then the discard must come from `TEX0.TCC`/alpha, not the alpha test | | `bbox` is glyph-sized, not `0,0..511,447` | these are UI text sprites, matching the "text erases the box" story | Build, then run and query: ```powershell cmake --build "F:\SDBZ Recomp\build" --config RelWithDebInfo ``` ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ```powershell python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag texzero ``` ##### ✅ BUILD STATUS 2026-08-07 — `[texzero]` IS LINKED INTO `ps2EntryRunner.exe`. Run is PENDING. The build the user ran finished with two `LNK1120` failures and three `C4838` warnings. **Neither blocks the run.** Triaged read-only: - **`iop_harness.exe` (5 × LNK2019) and `ps2x_tests.exe` (1 × LNK2019)** — unresolved `fn_180D30_0x180d30`, `fn_1A4500_0x1a4500`, `fn_1BF2E0_0x1bf2e0`, `fn_22C8F0_0x22c8f0` (from `applySdbzKernelThunkFixes`) and `singleton_get_camera_0x199db0` (from `sdbzPoolBaseProbe199DB0`). All six are **generated `runner/` bodies**. Those two targets link `ps2_runtime.lib` but **not** the runner objects, so these symbols are unresolvable there **by construction** — a pre-existing structural property of the target topology, not a regression. **No error line names `ps2EntryRunner.vcxproj`.** ⚠ Expect these two failures on every future build; do not spend a session on them. - **`C4838` at `ps2_gs_gpu.cpp(1305–1307)`** — narrowing `Bitfield` → `uint32_t` in the pre-existing `[fbscan]` `const uint32_t candidates[4]` initializer. Not the new probe. Harmless. - **Timestamp chain proves the probe shipped** (source → obj → lib → exe, strictly increasing): `ps2_gs_rasterizer.cpp` 6:09:51 → `ps2_gs_gpu.cpp` 6:10:17 → both `.obj` (RelWithDebInfo) 6:14:31/6:14:33 → `ps2_runtime.lib` 6:16:32 → **`ps2EntryRunner.exe` 8:32:01, 309,074,944 bytes**. Only two `ps2_gs_gpu.obj` exist under `build\` (Debug 8/6, RelWithDebInfo above) — no stale third. **~~Next session STARTS HERE~~ — DONE. Result below.** Historical placeholder note: the previous revision of this block ended with a literal `-Exe ` placeholder, which is a PowerShell parse error (`<` is reserved). Always paste the full path from [[command_log]]: ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` - **Text visible** ⇒ Stage 5.10 CLOSED. Advance the stage number and pick the next gate. - **Screen non-black but no legible text** ⇒ the 9.2k pixels are something else (background, HUD). Then, and only then, the narrow follow-ups are: rebuild `[psmt4]` with its change-gate opened past `n=800` so it samples the glyph era, and build the `[vifsrc]` probe at the head of `PS2Memory::processVIF1Data`. `clutNz=60/64` is already known-good. --- #### ✅ 5.11 RUN 12 RESULT (2026-08-07) — `[texzero]` came back. **Row 2 fired, and the texzero hypothesis is DEAD.** The run had already happened: `run_log.txt` (11:22) postdates the probe-carrying exe (08:32). `analyze_run.py --tag texzero` → **22 records, no `[cap]` line, so absence is evidence.** ``` [texzero] abeon=15,759,420 abeoff=0 ateon=15,759,420 bbox=0,0..511,447 family A: prim=0x56 test=0x30003 alpha=0x44 srca=0x00 texel=0x0 texpsm=0x14 (PSMT4) family B: prim=0x56 test=0x3000f alpha=0x44 srca=0x80 texel=0x80000000 texpsm=0x13 (PSMT8) ``` **`abeoff=0`.** Row 1 of the table is dead — the game does not rely on the alpha test here. Row 4 is dead too: `bbox` is full-screen, not glyph-sized. Row 2 fired. **But the answer is stronger than row 2 predicted: family A is an identity write.** Decoded, not inferred, per [[feedback_verify_translations_by_decoding]]: - `alpha=0x44` → a=0 b=1 c=0 d=1 → **A=Cs, B=Cd, C=As, D=Cd** → `(Cs − Cd)·As + Cd`. - `ps2_gs_rasterizer.cpp:610-613` implements exactly that: `br = ((Cs − Cd)*cAlpha >> 7) + Cd`, with `cAlpha = a` when `csel==0`. **The blender matches the GS spec. It is not the bug.** - Family A carries **`srca = 0`** ⇒ the expression collapses to `br = dr`. Those 15.7M stores **write the destination back unchanged**. They are classified black only because `Cd` was already black (`dstrgb=0x0` on every snapshot). - `test=0x30003` → ATE=1 but **ATST=1 = ALWAYS**, ZTE=1/ZTST=ALWAYS. Nothing is discarded, and nothing needs to be — the stores are no-ops. **⛔ All three `[blackwho]` buckets are now exonerated.** `texcol` was 3 orders too small, `texzero` is a no-op, `untex` is the expected clear. **Nothing in the black-store census destroys colour.** Run 11's "textured sprites bury the background" story is retracted. **Surviving suspect #1 — clear ordering.** `[fbdest] lastnbpct=99` with `tail=0,0..511,447`: after the final coloured pixel there is a full-screen black run, ~1% of the sequence ≈ one screen-clear. ⚠ **But `[fbdest]` is emitted on the ~1/sec THROTTLED interval, so its `seq`/`lastnb` span ~60 frames.** It cannot distinguish "a clear runs after the draws every frame" (a bug) from "one clear landed at the end of the interval" (not a bug). This is also why `[blackwho]`'s `snap*` set was permanently owned by the clear. **Unresolvable with the current probes** — hence `[frameord]`. **Surviving suspect #2 — the PSMT8 family.** `texpsm=0x13`, `texel=0x80000000` = alpha 0x80 with **RGB 0**, `test=0x3000f` → ATST=7 NOTEQUAL / AREF=0, so alpha 0x80 passes the test. Same `alpha=0x44` blend, but with `As=0x80` the result is ≈ `Cd/2` — it genuinely halves the destination on every pass. A CLUT lookup yielding zero RGB with nonzero alpha is the signature of a PSMT8/CLUT decode dropping the colour channels. **Look at this only after ordering is settled.** --- #### ✅ 5.11 RUN 13 RESULT (2026-08-07) — `[frameord]` fired **row 3**, and the cross-check kills ordering. ``` [frameord] boot: frames=60 wiped=60 maxtrail=13,074,432 (= 57 screens — the black boot) [frameord] steady: frames=60 wiped=8..20 (13-33%) maxtrail=229,423 lasttrail=0 or 47 [present] steady: nonblack=9794 / 9180 on EVERY latch — never 0 [fbdest] steady: fbp0x0 ≈ 2.2M nonblack / 14M black, same for fbp0x70 ``` - `maxtrail = 229,423 = 229,376 + 47`, and `229,376 = 512×448` **exactly** — the wiping run is precisely **one** screen-clear, never a stack of them. - `lasttrail` is **0 or 47** on the sampled latch ⇒ that frame ended on **coloured** pixels. - **`[present] nonblack` is never 0.** The presented buffer always carries ~9,800 coloured pixels, and the count is *constant* across 20 s ⇒ a static image — the memory-card glyphs from 5.10. **⛔ Suspect #1 (clear ordering) is DEAD.** A per-frame clear-after-draw bug requires `wiped ≈ frames` *and* `nonblack = 0`. We have neither. ~20% of frames merely happen to be sampled between the clear and the first draw. The screen is **not** black — it is missing one layer. **The arithmetic names that layer.** Per interval (60 frames): | bucket | per interval | per frame | reading | |---|---|---|---| | `untex` | 13.76 M | 229,333 | ≈ **1×** full screen — the expected clear | | `texzero` | 15.76 M | 262,667 | ≈ **1×** full screen — a full-screen **textured** quad whose every texel is black | | survives to present | — | 9,794 px | the glyphs only | So every frame draws a **full-screen textured quad that samples nothing but black texels**. That is the red box. It is not being erased — **it is being drawn black.** **⚠ `[texzero]` is a doubly-selected population** (`ps2_gs_rasterizer.cpp:713-756`: the branch is entered only when the store is black **and** textured **and** `texel & 0xFFFFFF == 0`). It therefore can **never** prove "all texels are zero" — [[feedback_degenerate_result_convicts_the_probe]]. What carries the weight is the *volume*: 262k such samples per frame ≈ one full screen. Full `[texzero]` fields, now including the pointers: ``` PSMT8 (0x13): tbp=0x2a00 cbp=0x2a40 texel=0x80000000 srca=0x80 test=0x3000f PSMT4 (0x14): tbp=0x2b80 cbp=0x2a4c texel=0x00000000 srca=0x00 test=0x30003 ``` **Decoded, not inferred:** `applyTexa()` (`ps2_gs_rasterizer.cpp:163-191`) takes its `default:` branch for T4/T8 — it does **not** synthesise alpha. So the `0x80` in `0x80000000` came **straight out of the CLUT**. The palette entry really is *opaque black*. Both families reduce to the same statement: **every sampled texel resolved to a black palette entry.** Two ways that happens, needing different fixes: - **(a)** the CLUT is empty/black, or - **(b)** the CLUT is fine and every *index* is 0 because the texture never landed in VRAM at `tbp`. Defects found by reading the decode path (real, but **not yet convicted** as this bug): 1. `ps2_gs_rasterizer.cpp:841` passes `tex.psm` (T4/T8) to `applyTexa`, not the **CLUT** format — compare line 830/835 which pass the texture's own format. 2. Same line: a **CT16 CLUT entry skips `Rgba5551ToRgba8888`**, while a CT16 *texture* gets it (line 835). A raw 5551 word used as 8888 yields B=0, A=0. 3. `ReloadClutCacheCSM2` (`ps2_gs_gpu.cpp:4116` + `:4123`) multiplies `texclut.cou` by 16 **twice**. Only reachable when `CSM=1`. --- #### ✅ 5.11 RUN 14 RESULT — `[vramcen]`, 2026-08-07. **Row 4 fired. Both buffers are populated.** 21 records, no `[cap]`. First 7 are pre-boot zeros (`vramnz=0`) — ignore them. Steady state, two recurring texture/palette pairs: | tbp | texnz | cbp | clutnz | cvnz | clut0 | clut1 | vramnz | |---|---|---|---|---|---|---|---| | `0x2a00` (PSMT8) | 10188/16384 | `0x2a40` | 954/1024 | 954/1024 | `0x0` | `0x0a000000` | 301,163 | | `0x2b80` (PSMT4) | 4107/16384 | `0x2a4c` | 1004/1024 | 716/1024 | `0x0` | `0x08ffffff` | 235,883 | | `0x2b60` (PSMT4) | 4107/16384 | `0x2a48` | 1004/1024 | 1004/1024 | `0x0` | `0x08ffffff` | 233,846 | **Verdict — the last row of the table, unambiguously:** - `clutnz` = **954–1004 of 1024 bytes nonzero (93–98%)**. A mostly-black palette cannot produce that; a black entry contributes at most 1 nonzero byte (its alpha). **The palette is colourful.** - `texnz` = 4107–10188 of 16384. **The texture page is uploaded.** - `vramnz` = 233k–447k of 1M words. **Rival-reading guard passes** — VRAM is genuinely populated, so no zero above can be blamed on "nothing was ever uploaded". - `cvnz` ≈ `clutnz` on two of three pairs ⇒ the cache faithfully mirrors VRAM at `cbp`. (The `0x2a4c` mismatch, 1004 vs 716, is not decisive: the probe scans the 1 KB at `cbp` **linearly** while GS CLUT storage is block-swizzled, so `cvnz` is only a presence guard, never a byte-for-byte comparison. Do not read more into it than that.) ⛔ **Suspects (a) "CLUT empty" and (b) "texture never uploaded" are BOTH DEAD.** Cross-check that seals it: the PSMT8 `[texzero]` texel was `0x80000000`, and cache entry 0 is `0x00000000`. If the index were 0 the texel would have been `0x00000000`. **The T8 index is not zero** — the sampler is reaching a real, varied index and still landing on an opaque-black result. Remaining layer: **index → entry**, i.e. `ps2_gs_rasterizer.cpp:842` → `GS::ReadClutCache` (`ps2_gs_gpu.cpp:4125`) vs `GS::ReloadClutCacheCSM1` (`:4157`). Read this session, **not yet convicted** — do not fix any of these speculatively: 1. **`resolveClutIndex` (`ps2_gs_rasterizer.cpp:318`) is dead code.** Line 842 calls `ReadClutCache` with the raw index, so `swizzleClutIndexCSM1` and the T4 `csa<<4` merge never run. May be correct by construction (the CSM1 loader already de-swizzles on the *write* side, storing entries linearly from `csa*16`), but it is the single most likely place for the two conventions to disagree. 2. **`ReadClutCache` bounds.** CT32 path indexes `m_clut_cache[csa*64 + index*4]` **unmasked**, while the loader masks with `& 0x3FF`. `CSA` is a 5-bit TEX0 field; `csa=31, index=255` → offset 3004, far past a `std::array`. Out-of-bounds read of adjacent members. Needs the real `csa`. 3. `ReloadClutCacheCSM2` doubles `cou*16` (`:4225` then `:4232`). CSM2 only, still unconfirmed as live. 4. Line 842 passes `tex.psm` (T4/T8) to `applyTexa`, not the CLUT format. **Provably a no-op for a CT32 CLUT** (T4/T8 take `default:` and `a` is returned unchanged), so this cannot be the cause here unless `cpsm` is CT16 — which `[clutmap]` now reports. --- #### ★ NEXT ACTION — `[clutmap]` probe (run 15). Two files, one hot-path store. Written 2026-08-07. Captures the **raw pre-CLUT index** (`t_lastTexIndex`, set at `ps2_gs_rasterizer.cpp:842`) plus `csa`/`csm`/`cpsm`, and dumps the first 8 CT32 cache entries and a count of how many of the 256 entries have nonzero RGB. All accumulators are written inside the already-cold `texzero` branch; the hot path pays one thread-local store. Pre-registered reading, **in order**: | observation | conclusion | |---|---| | `idxsamp` tiny | **rival-reading guard — read this FIRST.** The other fields are not a distribution; nothing below may be concluded | | `idxor` = 0, `idxnz` = 0 | every index is 0 despite `texnz>0` ⇒ the texture **fetch** is broken. Fix `ReadTexturePageCache` / `ReloadTexturePageCache` addressing (`base_block`, `pitch`, `page_width2`, stale single-page cache) | | `idxor` ≥ `0xF0`, `idxnz` large | indices are real and varied ⇒ the **lookup** is wrong. Convicts suspect #1 or #2 above; compare `csa`/`csm`/`cpsm` against `ent0..ent7` | | `idxor` narrow but nonzero (e.g. `0x0F` on a T8 texture) | only the low nibble survives ⇒ T4/T8 expansion bug in `ReadPageToLinearBufferP8` | | `rgbnz` small (< 32/256) | ⚠ **retracts the run-14 verdict.** The palette really would be mostly black and the `clutnz` byte-count reading was wrong. Go back to the CLUT upload path | | `cpsm` = CT16/CT16S | promotes defect #4 (missing `Rgba5551ToRgba8888` on CLUT entries) from unconvicted to live | --- #### ✅ 5.11 RUN 13 → `[vramcen]` probe (SUPERSEDED by run 14 above, kept for the address math) Written 2026-08-07 into the existing throttled emit block, right after `[frameord]`. `self->m_vram`, `self->m_vramSize` and `m_clut_cache` are all already in scope there (`PresentProbe` is a local class inside a `GS` member function), so this is a once-per-second scan of buffers that already exist. `TBP0`/`CBP` are **256-byte block units**: `0x2a00 × 256 = 0x2A0000`, inside a 4 MB VRAM. The rival reading (8192-byte pages) gives 88 MB and is impossible — **the address math has exactly one admissible interpretation**, so a zero result cannot be blamed on it. Pre-registered reading, **in order**: | observation | conclusion | |---|---| | `clutnz` = 0 | **(a)** — the CLUT cache was never populated. Fix in `ReloadClutCache` / `CSM1` or its `CLD` gating, **not** the sampler | | `clutnz` > 0 but `cvnz` = 0 | **(a)** — the cache holds stale junk copied from empty VRAM at `cbp` | | `clutnz` > 0, `cvnz` > 0, `texnz` = 0 | **(b)** — palette is real, texture page is empty, so every index is 0. Fix in the GIF image-mode / `TRXDIR` upload path | | `clutnz` > 0 **and** `texnz` > 0 | both present ⇒ the fault is the index→entry mapping: `csa` offset, `cpsm`, or defect #2 above | | `vramnz` tiny | **rival-reading guard.** Nothing is uploaded anywhere; `texnz=0` alone must never be trusted without this field | --- #### ~~NEXT ACTION — `[frameord]` probe~~ — DONE, result above. Written 2026-08-07 into `ps2_gs_gpu.cpp` only. The rasterizer already publishes `g_writeSeq`, `g_lastNonBlackSeq` and the `g_tail*` trailing-black bbox, so no capture-side edit was needed. - **Sampler** — in `PresentProbe::~PresentProbe`, placed **before** the `armed` check so it runs on **every latch, unthrottled**. `trail = g_writeSeq − g_lastNonBlackSeq` = stores since the last coloured store. A latch counts as `wiped` when `trail ≥ 114688` (half of 512×448 — only a full-screen fill can be that big). - **Emit** — `[frameord]` in the existing throttled block, right after `[texzero]`. - ⚠ Known bias, stated up front: on the single latch right after the throttled reset, `lastNb` can still read 0 and inflate `trail`. That is 1 latch in ~60 and **cannot** manufacture a "wiped on every frame" result. Pre-registered reading — **`wiped/frames` FIRST**: | observation | conclusion | |---|---| | `wiped` ≈ `frames` | every frame ends with a full-screen black fill after the last coloured pixel ⇒ **clear-ordering bug**, and it fully explains why only the last-drawn layer (the 9487-pixel text) survives. Fix site is where the clear sprite is issued relative to the draw list, **not** the rasterizer | | `wiped` = 0 | no per-frame erasure; `lastnbpct=99` was a single end-of-interval clear. **Ordering exonerated** ⇒ go to suspect #2, the PSMT8 `texel=0x80000000` family, and instrument the CLUT/PSMT8 decode | | 0 < `wiped` < `frames` | intermittent — cross-check `wiped` against how many `[present]` records report `nonblack=0` | | `maxtrail` huge but `wiped` tiny | one giant wipe (mode change / shutdown clear), not a per-frame bug | | `wipebbox` ≠ `0,0..511,447` | the wiping run is a large sprite, not a screen clear — reopen `snap*` with a texzero-style branch-local capture | Build, run, query: ```powershell cmake --build "F:\SDBZ Recomp\build" --config RelWithDebInfo ``` ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ```powershell python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --tag frameord ``` ⚠ Expect `iop_harness` + `ps2x_tests` `LNK1120` again — structural, see the build-status block above. The build is good iff **no error line names `ps2EntryRunner.vcxproj`**. --- Historical note on the sequence that produced this: the regen was affordable only because `PS2Recompiler::writeToFile` content-compares before writing (`ps2_recompiler.cpp:2199`), confining the rebuild to the genuinely changed TUs. **A regen is not a clean.** If `analyze_run.py` looks short, read the log directly -- it is UTF-16 and has **no newlines**: ```powershell python -c "import re; d=open(r'F:\SDBZ Recomp\run_log.txt','rb').read().decode('utf-16','replace'); [print(r.strip()) for r in re.findall(r'\[fontgate\].*?(?=\[[a-z]|\Z)', d, re.S)]" ``` #### ⚠️ Measurement caveat — PCSX2 EE write watchpoints miss stores here A write watchpoint on `0x0054BC9C` caught both zero-stores (`0x001127b4`, `0x0011291c`) but **never** the incrementer, despite the field provably reading `0x32` mid-frame. Do not trust watchpoint-*absence* on this address. The struct semantics above were derived statically from the IDA dump instead. (Related: `reference_pcsx2_debugger_quirks` — hit counts and `last_PC` stay 0 even when a watchpoint fires.) ### Stage 5.10 — the older `[vifsrc]` lead (still valid, lower priority) `[psmt4]`/`[gifsrc]` both report `eeAddr=0xffffffff region=copy`, because the PATH2 image packet is a `std::vector` built in `ps2_vif1_interpreter.cpp` — by the time the GS sees it the guest address is gone. `[vifsrc]` (added 08-05, **UNBUILT**) sits at the head of `PS2Memory::processVIF1Data`, the last point holding the real guest pointer. For every VIF1 buffer ≥ 16 KB it logs the EE address, the whole-buffer non-zero count, and the **longest zero run + its offset**: - mostly non-zero buffer with one long zero run → **our VIF walk is landing at the wrong offset** (`vifops=30000:8000` already hints the opcode histogram is odd). - wholly zero buffer → the **producer never wrote the texture**; move upstream of VIF1. ### Upstream status, checked 2026-08-05 Upstream tip is `f49ca4ed` (#191 VU1 refactor). Full merge **deferred** — 150 files, +8,642/−32,323 across `ps2xRuntime/` + `ps2xRecomp/`, rewriting every file we hold live edits in (`ps2_runtime.cpp`, `ps2_memory.cpp`, `ps2_vu1_core.cpp`, `ps2_vu1_lower.cpp`, `ps2_vif1_interpreter.cpp`) and deleting our ps2xStudio/RecompDebugger tree, which upstream does not carry. Revisit only after the render path is green, as its own rebase session. Per-commit triage: | PR | Side | Status | |---|---|---| | #149 `81f2a7f5` — `sceGifPkRefLoadImage` A+D nloop double-count | runtime | **already present** in our `GS.cpp:509` + `Support.h:1693` | | #168 `905b4edf` — advance `ctx->pc` on fallthrough functions | recompiler | **already present** at `function_emitter.cpp:267` (it is our own contribution) | | #194 `d87bff42` — MMI2 opcode map for PMADDH/PHMADH/PMSUBH/PHMSBH | recompiler | **APPLIED 08-05** to `instructions.h`. Correct per ps2tek (`0x10/0x11/0x14/0x15`, not `0x14/0x15/0x18/0x19`). Header lives in `ps2xRecomp/`, **not** included by `runner/*.cpp`, so no mass rebuild — but it only takes effect on regeneration. | ### Learned patterns (2026-08-05) - **A counter read after its own teardown reports the teardown, not the work.** `[psmt4] copied=0` cost a full framing because `EndTransfer()` zeroes the struct the probe then read. Sample completion state *before* the terminator, or log the terminator itself. - **A probe that cannot name a guest address cannot assign blame.** Both `[gifsrc]` and `[psmt4]` saw `region=copy`, so neither could distinguish "RAM was blank" from "we read the wrong bytes". Put the census at the last site holding the real pointer. - **Honest hardware answers can break a guest that was surviving on a canned lie.** Fixing `QueryMaxFreeMemSize` is what *caused* the arena overflow — the guest immediately asked for 960 KB it had never asked for before. Expect a fixed stub to enlarge the demands on everything downstream of it. - **Check upstream commits against the local tree before planning to apply them.** Two of the three candidates were already in, one of them authored here. --- ## ★★★ 2026-08-04 (session close) — STAGE 5.9 RE-FRAMED: this is an **asset-load** failure, not a GS/render failure. Watchpoint written, UNBUILT. ### The finding that moved it — `[gifsrc]`, 44 lines ``` [gifsrc] n=1 path=3 region=rdram eeAddr=0x89d400 nearFrom=0x88d400 bytes=262144 nonZero=0 nearBytes=393216 nearNonZero=0 ``` Repeats on every odd `n` to 31. A **256 KB Path3 upload out of EE RAM `0x89d400` that is entirely zero**, and the whole ±64 KB neighbourhood (`0x88d400`–`0x8fd400`, 393,216 B) is zero too. Per the probe's own design contract `nearNonZero≈0` means **the asset never landed in RAM** — it is not a producer writing a blank sheet. - Healthy class for contrast: `n=0,2,…22` → `bytes=96 nonZero=12..14 nearNonZero=405..407`. - PSMT4 glyph sheets (`n=1221…1971`) arrive `path=2 region=copy bytes=32768 nonZero=0`. `region=copy` has **no recoverable guest address** — `GifArbiter::submit` memcpy's the payload (`ps2_gif_arbiter.cpp`). Do not try to trace a copy packet back to EE RAM. ⇒ The GS is faithfully drawing empty source data. The flashing text box with invisible text is the PSMT4 glyph sheet being blank, not a font/colour bug. ### Two hypotheses killed in the same run - **DISPFB page-flip — DEAD.** `[gsreg]` 128 lines: DISPFB1 and DISPFB2 each toggle `0x1070` ↔ `0x1000`, 32 occurrences of all four combinations, in lockstep and in phase with FRAME.FBP. `[present]` agrees. Frames are not being dropped. - **`GS::writePrivReg` GIF path — irrelevant.** `[dispfb]` = **0 lines for a second consecutive run**. DISPFB is written *only* by plain stores through `gsRegPtr()`. `Support.h:1920` and `GS.cpp:1082` need no further probing. ### `tme=0` was a SAMPLING ARTIFACT — closed, do not reopen `[gs:frame-change]` prints `tme=0` while `[gs:frame] textured=20,235,240`. The two probes read **different variables**: the change-triggered probe snapshots the PRIM *register* and catches the untextured `prim=0x6` clear sprites (TME is bit 4); `[blackwho]` reads the per-primitive `prim.tme`. The rasterizer honours TME at every site. ### Code written this session — ALL UNBUILT 1. **`game_overrides.cpp` — `PS2X_HWWATCH_ADDR` fixed-target mode.** The DR0/VEH machinery already existed but could only follow guest-published addresses (rpc_call's stack slot, the BUG-009 client pointer); neither is guaranteed to run in a MainMenu-only session. - new `hwWatchStaticAddr()` (≈:1515) reads the env var and **overrides** `guestAddr` inside `hwWatchArm` (≈:1534). - unconditional arm inside `sdbzFrameTraceWrapper` (≈:1575) so the armer thread comes up regardless of which guest paths execute. 2. **`launch_recomp.ps1` — console cleanup + post-run tag census.** Splits concatenated `RUNTIME_LOG` records onto one line each (`$TagSplit`), collapses repeats to `... xN more [tag]`, colours by severity, and prints a raw-regex tag count table after the run. Parse-checked OK. `[HWSTAT]`/`[HWWATCH` added to `$Important`. 3. **`.github/copilot-instructions.md` rewritten + new `.github/COPILOT_PLAYBOOK.md`.** The old instructions were stale from 07-30 and still asserted `tme` never 1, `tex0.tbp` always 0, and BUG-009 as the live lead — that is why Copilot "made weird choices". Playbook carries ordered Plans A–E with exit tests. ### ★ NEXT ACTION — build, then run Plan A ```powershell cd "F:\SDBZ Recomp" .\build.ps1 RelWithDebInfo 6 ``` ```powershell $env:PS2X_HWWATCH = '1' $env:PS2X_HWWATCH_ADDR = '0x89d400' $env:PS2X_HWWATCH_VAL = '0xFFFFFFFF' & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger ` -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` | `HWSTAT` result | Meaning | Do this | |---|---|---| | `hits>0` | someone writes it | read the backtrace in `.hwwatch.txt`, follow the producer | | `hits=0, skipped>0` | writes land but the value filter rejected them | you forgot `PS2X_HWWATCH_VAL=0xFFFFFFFF` — re-run | | `hits=0 AND skipped=0` | **the region is never written** | Plan B: trace the CD/IOP asset-load path | ⚠️ **Clear those three env vars before any run whose timing matters** — the armer cost **19.06 s of CPU in a 96 s run**. Perf numbers from a HWWATCH run are void. The armed-and-silent case is only readable because `HWSTAT` heartbeats `{guest, skipped, hits}` every 40 ticks — that is what makes "nothing happened" a *result* instead of a dead probe. ### Run validity carried forward 4 saturated probes, so their absences prove nothing: `iop:import` (6), `ARKD:run` (32), `ARKD:CALL` (64), `[gsreg] dispfb2` (64). Raising these caps is Plan D. `[cputime] wall=96.41s cpu=98.09s` = 101.7 % of one core → CPU-BOUND. ### Learned patterns - `ForEach-Object` runs its body in a **child scope** — plain assignments are discarded. Any cross-iteration counter in a pipeline needs `$script:`. Cost: a silently-never-incrementing collapse counter. - Two probes printing the same-named field can be reading **different variables**. Before calling a contradiction a bug, read both emit sites. (`tme=0` vs `textured=20M`.) - A stale agent-instruction file is a bug with a long blast radius. `.github/copilot-instructions.md` must be re-dated whenever a stage closes, or the next agent re-litigates closed threads. --- ## ★★★ 2026-08-04 — STAGE 5.8 ✅ PASSED ITS EXIT TEST. New stage 5.9 opened: colour is written to the framebuffer with RGB already zero. ### 5.8 exit test — **PASSED**, both halves now measured Exit test was: *one frame with `tme=1` **AND** `tex0.tbp != 0`*. - `tme=1` — confirmed 08-03d via `[gs:ad] addr=0x0 data=0x56` (PRIM=SPRITE, IIP=0, TME=1, ABE=1). - `tex0.tbp != 0` — confirmed 08-04. **Every** `[gs:frame-change]` line in the 156 s run carries `tex0.tbp=0x2b60 tex0.tbw=4 tex0.psm=0x14`. This was the missing half; `[gs:ad]`'s 1-in-1000 sampling had simply never caught a TEX0 write ([[feedback_capped_probes_false_negatives]]). Corroborating: `[gs:frame] textured=20,235,240`, `imagebytes=208,560,192`, `[vramcensus]` top-8 pages all in the texture region `0x150`–`0x159`. **Textures upload, bind, and sample. 5.8 is closed.** ### ★ Stage 5.9 (opened 2026-08-04) — the frame buffer receives writes whose RGB is already 0 `[gs:frame] px=33,997,800 nonblack=3,886,920` but `[present] nonblack=0` and `[fbscan] nonblack=0` on **both** fbp candidates. The `[zbuf]` probe run settled where the loss happens: | Probe reading | Kills | |---|---| | `[zbuf] ctx=0/1 frame.fbp=0x0 zbuf.zbp=0xe0 zmsk=0 ztst=2 **alias=0**` | **Z-aliasing.** ZBP=0xe0 starts one page past the 112-page buffer at 0x70 — no overlap. | | `[vramcensus] ctx0.fbmsk=0x1 ctx1.fbmsk=0x1` | **FBMSK.** Masks bit 0 of red only. | | `[zbuf] rawwords page0x0=0 **page0x70=2048** page0x150=1838` | **Addressing.** Page 0x70 is 2048/2048 words non-zero = 100% occupied, yet `[fbscan] fbp=0x70` scanned the full 512×448 and found zero RGB. | So: **writes land at the correct VRAM address, and every stored word carries only alpha.** The value is black *before* the store. Audited and cleared on the way down: `pack32` ([ps2_gs_rasterizer.cpp:46](ps2xRuntime/src/lib/ps2_gs_rasterizer.cpp#L46)) puts RGB in the low 24 bits and alpha in the top byte; `WritePixelCT32` ([ps2_gs_memory.cpp:506](ps2xRuntime/src/lib/ps2_gs_memory.cpp#L506)) is a plain unmasked 32-bit store; `GSInternal::framePageBaseToBlock` is used identically by the colour write, the Z write, and the presentation read, so a base-conversion mismatch is impossible. **Unit conversions that decode these numbers** (write them down, they were re-derived twice): - FBP / ZBP page unit = **8192 bytes** (2048 32-bit words) — what `[vramcensus]` and `rawwords` bucket by. - TBP / DBP block unit = **256 bytes**. `dbp=0x2a00` → 2,752,512 B → page **0x150**. - A 512×448 CT32 buffer = 917,504 B = **112 pages**. FBP=0x0 spans pages 0x00–0x6F; FBP=0x70 spans 0x70–0xDF. ### ★★ `[fbdest]` — BUILT + RUN 2026-08-04. **Result: colour IS stored, then buried.** Steady-state reading (~20 of 25 lines identical; each line is a **per-interval delta** — every counter is read with `exchange(0)`): ``` [fbdest] seq=33,997,570 lastnb=33,496,335 lastnbpct=98 fbp0x0=1,943,140/15,055,530 fbp0x70=1,943,370/15,055,530 ``` | Reading | Verdict | |---|---| | ~3.89 M **coloured** pixels stored per interval | ★ **The alpha-blend hypothesis (row 3 below) is DEAD.** The final stored `pixel` is genuinely non-black. Do not reopen [ps2_gs_rasterizer.cpp:467-511](ps2xRuntime/src/lib/ps2_gs_rasterizer.cpp#L467-L511). | | Colour split ~evenly across `fbp=0x0` and `fbp=0x70` | Row 1 dead. FBP latch / context selection is fine. | | `lastnbpct=98` | Last coloured pixel lands 98% through the interval; the final ~680 k writes (≈3 full 512×448 screens) are all black. | | `[fbscan] fbp=0x0 nonblack=0` **and** `fbp=0x70 nonblack=0`, same run | None of those 3.89 M coloured pixels survives to present. | **Cross-check that seals it:** `[gs:frame] nonblack=3,886,690` (sampled *pre*-mask) ≈ `[fbdest]` coloured total 3,886,510. Pre-mask and post-mask agree → nothing between the stat sample and the store destroys colour. **The loss is draw ordering, not the pixel pipeline.** → Row 2 wins. Also from the same run: 89% of all rasterized pixels (30.1 M of 34.0 M) are **black at store time**, and `textured=20,235,010` — so most of the black is *textured*. Leading sub-hypothesis: PSMT4 (`tex0.psm=0x14`) / CLUT decode returns index 0, painting black over the artwork. Note: this run's `[present]` reads `dispFbp=0x0 srcFbp=0x0 ctx0.fbp=0x70 ctx1.fbp=0x70 dispfb1=0x1000` (→ DISPFB FBP=0, FBW=8) — the alternating-buffer phase differs from the earlier `dispFbp=0x70` line. Both buffers scan black, so this does not change the verdict. ### `[blackwho]` probe — ✅ BUILT + RUN 2026-08-04. **Verdict below.** ``` untex = 13,762,560 (45.7%) texzero = 16,348,260 (54.3%) texcol = 240 ( 0.0%) tail = 0,0..511,447 <- full screen last black store: prim=0x6 (SPRITE, tme=0, abe=0) texpsm=0x14 tbp=0x2b80 cbp=0x2a4c fbmsk=0x1 alpha=0x44 texel=0x0 srcrgb=0x0 xy=511,447 fbp=0x0 ``` Reading it against the decision table: - **`texcol` = 240 ≈ 0 → the blend / TEXFUNC / FBMSK row is DEAD.** `srcrgb=0x0` confirms it independently: the colour is already black *entering* `writePixel`, so nothing downstream ate it. - **`texzero` dominant (54%) → primary.** Textured sprites at `tbp=0x2b80`, `psm=0x14` (PSMT4), `cbp=0x2a4c` are sampling to RGB 0. Fix target: the PSMT4 unswizzle, the CLUT upload, or `ReadClutCache`. - **`untex` 46% → secondary.** Full-screen untextured black sprites. Plausibly *legitimate* clears if they land on the back buffer — but `[fbscan] nonblack=0` on **both** `fbp=0x0` and `fbp=0x70`, and a correct double-buffered clear cannot blacken both. **Not an anomaly:** consecutive `[blackwho]` lines are byte-identical because the counters use `exchange(0)` (every line is a per-interval delta) and MainMenu is a static, deterministic workload. `13,762,560 + 16,348,260 + 240 = 30,111,060` matches the `[fbdest]` black total on both lines. Probe is sound; magnitudes are trustworthy. Run validity: the 3 saturated caps (`iop:import`, `ARKD:run`, `ARKD:CALL`) are all non-GS, so the GS data is valid. ### Stage 5.9 ROOT CAUSE — found 2026-08-04 by `[gsdump]` + `[gs:image]` **PSMT4 image transfers issue but do not land in VRAM.** | VRAM region | uploaded as | census result | |---|---|---| | `tbp=0x2a00` | dpsm=0x13 (PSMT8) | **48085 / 65536 bytes nonzero** — lands | | `tbp=0x2b60` | dpsm=0x14 (PSMT4) | **0 / 65536** — nothing lands | | `tbp=0x2b80` | dpsm=0x14 (PSMT4) | 0 (inside the 0x2b60 window) | - `[gs:image]` proves the transfers are *issued*: `dbp=0x2b60 dpsm=0x14 dbw=4 trxreg=256x256 sizeBytes=32768` **× 434**, and `dbp=0x2b80` **× 440**. - CLUT is **exonerated**: `clut_0x2a48.txt` = 255/256 non-black, a valid palette (`08ffffff`, `44ffffff`, …). - This explains `texzero=16,348,260` (54.3% of black pixels) exactly: the sprites sample a texture that is not there. - `[vramcensus]` agrees — busiest pages are `0x150`–`0x159` (blocks `0x2A00`–`0x2B40`); **`0x2b60` sits just past the populated region**. - PCSX2 A/B: a real TEX0 in EE RAM decodes to `TBP0=0x2b60, PSM=0x14, TBW=4, 256×256` — so **`0x2b60` is the correct address**; the draw side is right, the upload side is broken. **Open ambiguity — must be closed before fixing:** the census is swizzle-blind but *not* zero-blind. It cannot distinguish "never written" from "written all-zero nibbles". Resolve by logging the first 16 source bytes in the `GS_PSM_T4` case of `processImageData` ([ps2_gs_gpu.cpp:3343](ps2xRuntime/src/lib/ps2_gs_gpu.cpp#L3343)) — if the source is non-zero, the fault is in `WritePixelP4` → `LookupPixelAddressP4` ([ps2_gs_memory.cpp:556](ps2xRuntime/src/lib/ps2_gs_memory.cpp#L556)). Suspect list, in order: `LookupPixelAddressP4` returning an out-of-range or mis-scaled nibble address; `m_transferState.total_pixels` for 4-bit (the T4 loop advances `copied_pixels` by 2 per byte); `EndTransfer()` firing early. ### Stage 5.9 tooling — added 2026-08-04, **BUILT + RUN ✅ — item 1 found the root cause on its first run** Counters answer one yes/no per build+run cycle (~96 s of run plus a link). Three tools to stop paying that: 1. **`[gsdump]`** (`ps2_gs_gpu.cpp`, opt-in via `PS2X_GSDUMP=`) — writes the already-decoded `[fbscan]` framebuffers as `fb_0x*.tga`, the 256 CLUT words at `cbp` as `clut_0x*.txt`, and a swizzle-blind per-256-byte nonzero census of 64 KB at `tbp` as `tex_0x*.txt`. The census is deliberately independent of the unswizzle code under suspicion: zero bytes stay zero either way, so it separates *"texture never arrived"* from *"arrived but decodes to 0"* in one run. 2. **`analyze_run.py --gs`** — parses the GS tags out of `run_log.txt` (UTF-16, tag-split not line-split) and prints the black-pixel attribution with percentages. Ends the per-session hand-written regex. Verified working against the 08-04 log. 3. **PCSX2 A/B — sequenced AFTER 1.** ⚠️ PCSX2 MCP reads **EE RAM, not GS VRAM**, so it cannot dump the reference texture directly; ground truth needs PCSX2's own *Save Single Frame GS dump* at MainMenu. Only needed if `tex_0x2b80.txt` comes back all-zero. ### `[blackwho]` probe — as originally specified Follows `[fbdest]`. Same header-avoidance pattern (counters in `namespace ps2diag_fbstat` in `ps2_gs_rasterizer.cpp`, `extern` in `ps2_gs_gpu.cpp`; **no header touched**). Splits every **black** store three ways, and names the burying draw: - `untex` — PRIM.TME=0 → a plain black fill/clear ran after the artwork → **draw/clear ordering**. - `texzero` — textured, texel RGB sampled as 0 → **PSMT4 / CLUT decode** returning index 0. - `texcol` — texel had colour but we stored black → blending / TEXFUNC / FBMSK ate it. - `tail=x0,y0..x1,y1` — bbox of the *trailing* run of black writes, reset by every coloured write. `0,0..511,447` ⇒ the burying draw is full-screen. - `last black store: prim= texpsm= tbp= cbp= fbmsk= alpha= texel= srcrgb= xy= fbp=` — GS state snapshot at the last black store, i.e. inside the burying draw. `srcrgb` is the colour *entering* `writePixel` (captured before blending mutates r/g/b), so `srcrgb!=0` with a black store localises the kill to the blend/mask stage. Edits: 5 in `ps2_gs_rasterizer.cpp` (namespace atomics + `diagSrcRgb` capture + classification block + two `t_lastTexel` stores at the textured `writePixel` call sites), 2 in `ps2_gs_gpu.cpp` (externs + the `[blackwho]` line beside `[fbdest]` in the `PresentProbe` destructor). **Decision table for the next run:** | `[blackwho]` shows | Verdict → fix target | |---|---| | `untex` dominant, `tail` full-screen | Draw/clear ordering — find the black full-screen sprite issued after the artwork (GIF packet order / context switch) | | `texzero` dominant | PSMT4 4-bit unswizzle or CLUT lookup returns 0 → `sampleTexture` / `ReadClutCache` / the tiny CT32 CLUT uploads at `dbp=0x2a40..0x2a4c` | | `texcol` dominant | `combineTexture` (TEXFUNC) or the blend stage — `srcrgb` in the snapshot tells which | --- ### `[fbdest]` probe — as originally specified (2026-08-04, now answered above) 4 edits, 2 files, **no header touched** (counters live in a named namespace defined in `ps2_gs_rasterizer.cpp`, consumed via `extern` in `ps2_gs_gpu.cpp` — the standard header-avoidance pattern; adding members to `GS` would recompile 30,000+ TUs). - `ps2_gs_rasterizer.cpp` — `namespace ps2diag_fbstat` (4 atomics) + a classification block placed **immediately before the colour `WriteVram`**, so it measures the **final `pixel`** value. This permanently closes the pre-mask stat hazard: `[gs:frame]`'s counter samples `r`/`g`/`b` *before* the fbmask merge, so its `nonblack=3.8M` may never have described what was stored. - `ps2_gs_gpu.cpp` — externs + a `[fbdest]` report inside the `PresentProbe` destructor. Emits `[fbdest] seq= lastnb= lastnbpct= fbp0xNN=/ …`. **Decision table for the next run:** | `[fbdest]` shows | Verdict → fix target | |---|---| | Colour counts on an fbp that is **not** 0x0/0x70 | Drawing into a buffer we never display → FBP latch / context selection | | Colour on the right fbp, `lastnbpct` well below 100 | Artwork drawn then **buried by a later black full-screen sprite** → draw/clear ordering | | All buckets `0/` (zero coloured pixels ever stored) | ★ current favourite — the alpha-blend block at [ps2_gs_rasterizer.cpp:467-511](ps2xRuntime/src/lib/ps2_gs_rasterizer.cpp#L467-L511) zeroes RGB after the `[gs:frame]` sample. Matches "alpha survives, colour doesn't" exactly. Fix = `pickRGB`/`cAlpha` selector decode | | Colour on the right fbp, `lastnbpct` ≈ 100 | Value is non-black at store time yet VRAM reads black → back into `WriteVram`/`LookupPixelAddressCT32` despite the raw-word evidence | Also note `page0x0=0` vs `page0x70=2048`: one alternating buffer is **totally untouched**, the other totally full. Worth explaining whichever branch wins. ### Next action (user's, both of them) ```powershell & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo 6 & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` ### Corroborating context from the 08-04 run - `[present] has=1 w=512 h=448 dispFbp=0x70 srcFbp=0x70 pmode=0x7f27 display1=0x1bf9ff0203327c` → decodes to exactly 512×448. **Display geometry is correct — stop re-checking it.** - `primmask=0x40` = bit 6 = prim type 6 = **SPRITE**. All rendering is 2D, consistent with `gstate@0x5e6b3c=0` (MainMenu). VU1 idle (`mscal=0 vu1runs=0 xgkicks=0`) is therefore **correct, not a bug**. - VIF1 healthy: `[vu:vifbuf] size=127424 endpos=127424 cmds=108 bad=0 badpos=-1`, `vifops=30000:80001`. - `[hostprof]` — `writePixel` 25.8% / 28.45 s, `WriteVram` 3.3%, `LookupPixelAddressCT32` 3.3%. The rasterizer is genuinely doing the work. - `[cputime] wall=156.32s cpu=189.61s = 121.3% of one core — CPU-BOUND`, auto-stopped. - RUN VALIDITY FAILURE named 3 saturated probes: `iop:import`, `ARKD:run`, `ARKD:CALL`. **None are GS-related.** All GS probes (`[fbscan] [present] [gs:frame] [vramcensus] [zbuf]`) are uncapped, so every graphics conclusion above stands. - User visually observed the **memory-card loading bar flashing on then off** — independent evidence that *something* briefly reaches the screen. Consistent with the "buried by a later black sprite" branch. ### Learned patterns (2026-08-04) - **A probe that refutes its own hypothesis can still break the case open.** `[zbuf]` was built predicting `alias=1` and returned `alias=0`. Its *secondary* reading — `rawwords` — is what eliminated three hypotheses at once. Build probes that report neighbouring facts, not just a yes/no on the hypothesis. - **A statistic sampled mid-pipeline describes a value that may never be stored.** `[gs:frame]` counts pre-mask `r/g/b`; `[fbdest]` counts the final `pixel`. When "the counter says X but the memory says Y", check *where in the pipeline the counter reads* before theorising. - **100% non-zero words + 0% non-zero RGB is a decisive signature.** It separates "wrong address" from "wrong value" in a single reading. Always pair an RGB-masked scan with a full-32-bit scan. --- ## ★★★ 2026-08-03d — ROOT CAUSE FOUND AND FIXED. VIF1 desync was a DMA chain-assembly bug. `textured` goes 0 → 19.5M. **The bug:** `ps2_memory.cpp:1666` injected the DMAtag's upper 64 bits (which on VIF0/VIF1 are two VIFcodes) only for `id == 1|2|5|6|7`, **omitting id 3/4 (REF/REFS)**. Every REF link's payload therefore entered the VIF FIFO with no VIFcode framing it, desynchronising `processVIF1Data` for the remainder of the buffer. One omission produced the entire Stage 5.8 signature. **The fix (applied + built + run):** replaced the `compactVifLocalTag` id-gate with a plain `vifChannel` check so the 8-byte tag-upper header is appended for *every* id, and simplified the payload append to `appendData(dataAddr, tagQwc)`. Byte-identical for ids 1/2/5/6/7 (there `dataAddr == tagAddr + 16`), so the three known-good links could not regress. **Verification — round 5 dump re-walked, three independent axes:** | Signal | before | after | |---|---|---| | `[vu:vifbuf]` on 133 KB buffers | `bad=662 badpos=964` | **`bad=0 badpos=-1`** | | `vifops` census | ~57 opcodes, 11 nonexistent | **`30000:80001`** = NOP, FLUSHA, DIRECT, DIRECTHL only | | `mscal` / `mscalpc` | 30/frame @ `0x1b9d0` (imm 14138, illegal) | **0** — the phantoms are gone | | `datanz` | 6610 | **0** | | `[gs:frame] textured` | **0** | **19,494,762** | | `primmask` | `0x41` | `0x40` | Offline reference walker on `vif1_buf2.bin` (67,640 B): `26 commands, end_pos=67640 / 67640, NO INVALID OPCODE`. The 67 KB previously read as "compressed asset junk" is a single legitimate `DIRECTHL qwc=4096` payload that simply had no VIFcode in front of it. **TME=1 is confirmed.** `[gs:ad] addr=0x0 data=0x56` → PRIM = SPRITE, IIP=0, **TME=1**, ABE=1. `tex0.tbp` is NOT yet confirmed — `[gs:ad]` samples 1-in-1000 writes and caught no TEX0 write, so per [[feedback_capped_probes_false_negatives]] that absence is not evidence. **Stage 5.8 exit test is half-proven, not passed.** ### What the now-trustworthy data says 1. **VU1 is genuinely idle, and that reading is now reliable.** `mscal=0 mpg=0 vu1runs=0 xgkicks=0` with a provably-synchronised parser and a 4-opcode census. The game sends *no VU1 work at all* over VIF1 in this state — only PATH2 GIF transfers. With `gstate@0x5e6b3c=0` (MainMenu) that may simply be correct: a menu is 2D. **Do not resume hunting a "broken 3D path" until the game is in a state that should render 3D.** 2. **New, sharper contradiction — the next thread.** `[gs:frame] nonblack=3,627,102` but `[present] nonblack=0`. Draw and display targets alternate correctly (`ctx0.fbp=0x0` / `dispFbp=0x70`, then swapped), `fbw=8` → 512 px matching `w=512 h=448`, `pmode=0x7f27` — yet the presented surface reads black. **The game is drawing; we are not reading what it drew.** This is far more concrete than the VU1 thread and should be the next target. ### Retired hypotheses — do not reopen - "A variable-length VIF handler (MPG / DIRECT / UNPACK) mis-advances `pos`." **Disproven.** `processVIF1Data` parses its input correctly; its *input* was malformed. - "The game never uploads a microprogram." Superseded — it sends no VU1 work here at all. - The `ps2_vif1_interpreter.cpp:761` bare-`continue` desync amplifier is real but was a symptom magnifier, not the cause. Still worth hardening later. ### Open follow-up (deliberately deferred) CHCR **TTE (bit 6) is never read anywhere in `ps2_memory.cpp`.** Line 1460's `tieEnabled` is bit 7 (TIE), a different flag. The tag-upper append is therefore unconditional. The three proven-good links show TTE is effectively on for this game, so this was left alone rather than changing two things at once. --- ## ★★★ 2026-08-03c — ROUND-3 VU1 PROBE RAN. Branch A confirmed, and the framing changes: the VIF1 command stream is being MIS-PARSED. Build from 08-03b completed clean (exe 17:51, newer than all 7 round-3 sources; `codenz=`/`mscalpc=` string-verified present in the binary — no stale-object-file repeat). Ran the canonical command, 90 s, det=0, RelWithDebInfo. 21 `[vu:frame]` records. Steady state: ``` vif1calls=61 vif1bytes=7938176 mscal=30 mscnt=0 vu1runs=30 xgkicks=0 xgkickbytes=0 mpg=0 mpgbytes=0 vu1instrs=1966080 endebit=0 endlimit=30 endrange=0 codenz=0 datanz=6610 mscalpc=0x1b9d0 vifops=8189a288d87fcdef:ffaf03a71e6fffff ``` **Pre-registered branch A is confirmed: `codenz=0`.** `probeVu1MemoryOccupancy()` is called at `ps2_runtime.cpp:1042`, immediately before `m_vu1.execute(...)` — so this is sampled *at the moment of every MSCAL*, not at some unrelated time. VU1 code memory is 100 % zeros when the microprogram is invoked. **The bug is upstream, in the upload path. It is not the decode cache.** Two independent confirmations that the downstream/decode-cache branch is dead: - `m_vu1Code` **is** allocated (`ps2_memory.cpp:371-373`), so MPG is *not* being silently dropped by the null guard at `ps2_vif1_interpreter.cpp:449`. - The mapped-`0x11008000`-window store path **does** call `markVU1CodeModified()` (`ps2_memory.cpp:940/981/1043/1096/1137`), so the "EE wrote it through the window and the generation counter never bumped" theory cannot hold either. Both halves of the round-3 hypothesis space collapse onto the upload path. ### ★ The new finding — `mscal=30/frame` is NOT 30 real MSCALs `mscalpc=0x1b9d0`. The emit site is `startPC = imm * 8` (`ps2_vif1_interpreter.cpp:375`), so `imm = 0x373A = 14138`. **Valid MSCAL immediates are 0…2047** (16 KB code RAM / 8 B per instruction). An entry point 7× past the end of VU1 micro memory cannot come from real game code. The MSCALs we have been counting for two rounds are mis-decoded bytes that merely happen to carry `0x14`/`0x15` in the opcode field. `vifops` corroborates it. Decoding the census (`opHi` = opcodes `0x40-0x7F`, `opLo` = `0x00-0x3F`): ~57 distinct opcodes were seen, including `0x40 0x41 0x42 0x43 0x45 0x46 0x47 0x48 0x4B 0x4E 0x4F`, **none of which exist in the VIF ISA**. A correctly-synchronized parse sees ~15 opcodes total. ⚠️ **Therefore the MPG bit in `vifops` is worthless as evidence.** `MPG 0x4A` = bit 10 of `opHi`, and that bit *is* set — but in a census this polluted it is indistinguishable from noise. Do not report "MPG was seen in the stream." This is the [[feedback_capped_probes_false_negatives]] failure mode inverted: a polluted probe manufacturing a false *positive*. ### What this re-frames The blocker is no longer "the game never uploads a microprogram." It is **"we never parse the stream well enough to find the upload."** A desynchronized parser explains every symptom at once: MPG is never located (`mpg=0`, `codenz=0`), garbage MSCALs still fire 30×/frame burning the full 65536-cycle budget on zeros (`vu1instrs = 30 × 65536` exactly, `endlimit=30`, `endebit=0`), `xgkicks=0`, and `primmask=0x41` — while UNPACK still lands *something* in data memory (`datanz=6610`), which is why the path looked half-alive. DMA1 chain-following itself looks healthy — `[dma:chain] chan=0x10009000` shows well-formed `id=2` (NEXT), `id=3` (REF), `id=7` (END) tags with sane qwc. One tag worth a second look: `tag=0x8a644030000800 id=3 qwc=2048 addr=0x8a6440 dataAddr=0x8a6440 data0=0x0` — a 32 KB REF whose first quadword is zero. Volume is ~130 KB of VIF1 data per frame (~61 calls / 8 MB per 60-present interval), delivered via the assembled `chainData` path (`ps2_memory.cpp:1878`). ### Round 4 — ✅ BUILT AND RAN 08-03d. Probe worked; see the 08-03d section above for the result. **Status: built, run, and it did its job** — `[vu:vifbuf] bad=662 badpos=964` is what localised the desync, and `badpos=-1` is what confirmed the fix. Three edits, all applied: - `ps2xRuntime/include/runtime/ps2_pipeline_stats.h` — `isValidVif1Opcode()` (written independently of the parser's if-chain, so the probe cannot inherit the parser's own mistake), `struct Vif1Snap`, and a 3-state claim/publish/take slot (`claimVif1Snap` / `publishVif1Snap` / `takeVif1Snap`). Only 5 runtime TUs include this header — verified — so no mass rebuild. - `ps2xRuntime/src/lib/ps2_vif1_interpreter.cpp` — three hooks: claim + copy first 128 bytes at entry, validity check at the decode point, publish `endPos` at exit. - `ps2xRuntime/src/lib/ps2_gs_gpu.cpp` — new `[vu:vifbuf]` line beside `[vu:frame]`, same interval: `size= endpos= cmds= bad= badpos= badcmd= head=`. **How to read it:** `badpos` is the first byte offset provably wrong; the command *before* it is the one that advanced `pos` by the wrong amount. `badpos=-1` means that buffer parsed clean. `endpos < size` means the loop bailed early. `badpos=0` means the buffer doesn't start on a VIFcode at all — a framing problem, not a handler bug. ⚠️ New finding while drafting: `ps2_vif1_interpreter.cpp:761` swallows unrecognized opcodes with a bare `continue`, advancing only 4 bytes. A desynced parser can therefore neither recover nor complain — which is exactly why round 3 read as "healthy but empty" rather than "broken." Original rationale below. Counters are exhausted; the question is now positional. The cheapest decisive probe is a **hex dump of the first 128–256 bytes of the buffer handed to `processVIF1Data`**, once per report interval, plus the `pos` at which the parser first hits an opcode outside the valid VIF set. Walking one real buffer by hand settles in one run whether (a) the buffer starts mid-packet, (b) an opcode handler advances `pos` by the wrong amount and desyncs everything after it, or (c) the buffer contains non-VIF data entirely. Prime suspects for (b) are the variable-length handlers — `VIF_MPG` (`pos += mpgBytes`, unconditional even when the copy is skipped), `VIF_DIRECT`/`DIRECTHL`, and `UNPACK`. ## 2026-08-02 Handoff Update - The live tree at `F:\SDBZ Recomp` is the authoritative runtime tree for all boot-blocker work. The worktree copy under `F:\SDBZ Recomp.worktrees\claude-memory-sdbz-recomp-plan` is historical and should not be used for runtime fixes. - The ARKD cdvdman fid=5 settle-poll hook is now present in the live tree at `ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp`. It returns `1` for the `sub_AC90` poll loop, which is the semantics implied by the call-site loop shape in the IRX decompile, and it emits the first four `[ARKD:cdsettle]` traces. - Last run evidence shows the earlier ARKD completion-path fix is working: `initThreads` created 3 workers, `SET_SREG` mirrored to the EE-side `0x5618B0` path, `mcserv Init` answered, and `gstate` reached `0,0,0,1`. The earlier memcard-check theory is no longer the lead. - Immediate next step: re-run the build and launch scripts with the watch set armed so we can confirm whether the fid=5 hook clears the `sid=0x500 fno=0x1` service halt and whether `CAppInit` advances beyond state 11. - Known follow-up bugs / leads: - `0x5E5924` (`CAppInit` state) remains the primary boot-state probe. If it stalls at 11, inspect the audio-slot gate at `0x5D6B80` / `0x5D6BA4` next. - The old `gstate` pointer watch is not a trustworthy stall signal by itself; use the actual state word and the audio-slot table instead. - Keep `PS2X_HWWATCH` unset; it adds overhead and has not been needed for this blocker. - Handoff goal: verify whether the fid=5 hook ends the service spin cleanly; on success, pivot immediately to the next state-11/audio-gate diagnosis rather than revisiting the memcard path. ## 2026-08-02 Update 2 — ★★★ CAppInit RETIRES (state 0x3e8), matches real hardware exactly. New lead: first-ever texture upload + TEXFLUSH observed. Pasted watch/GS log (partial — starts mid-run, ends on user-initiated window close, not a crash/hang). - **`appinit_state 0x5e5924` climbed the full remaining ladder and RETIRED:** `...0x8→0x9→0xa→0xb→0xc→0xd→0xe→0x3e8`. `0x3e8` (1000) is the exact terminal value measured on real PCSX2 hardware in 5.8 measurement #2 Finding 9. **State 11 is no longer a blocker — this sub-thread of 5.8 is CLOSED.** - **`req12 0x5618b0` changed `0x30000000 → 0x3000c270`** — the SET_SREG EE-mirror fix from measurement #4 is confirmed live (exit test from that section: MET). - **`snd00 0x5d6ba4` (slot-0 audio status) cycled `0x0→0x3→0x1`** — confirms Finding 11's audio-gate mechanism resolved; state 11's `str_queue_msg`/slot-wait passed for real. - The fid=5 cdvdman settle-poll hook (2026-08-02 note above) is implicated as part of what unblocked this, but this paste doesn't include the early-run `[ARKD:cdsettle]`/`sid=0x500` lines to confirm directly — re-check the full log, not just this excerpt. - **New, never-seen-before signal:** near the end of the run, `[gs:image] n=16 dbp=0x2a00 dpsm=0x13 dbw=4 trxreg=256x256 sizeBytes=59520 (DBP CHANGED)` — a real 256×256 texture upload to VRAM — followed immediately by `[gs:ad] n=6000 addr=0x3a data=...` (GS reg `0x3a` = TEXFLUSH, the standard "texture upload just completed, invalidate cache before drawing with it" idiom). This is the first texture transfer seen in the project's history. Every `[gs:frame-change]`/`[gs:frame]` line in this paste still shows `tme=0`/`textured=0`/`tex0.tbp=0x0`/`primmask=0x40` (SPRITE-only) — so no textured draw has landed yet, but the pipeline immediately upstream of one just fired for the first time. - Run ended via `[run] window close requested` (user closed it) at `wall=96.44s`, not a timeout/crash — so it's unknown whether a textured frame would have followed within seconds. `cpu=99.4%`, CPU-bound as expected (GS software raster, per the 07-29 co-piolet findings — not a new concern). - **Next action:** rerun and let it finish its own timeout (don't close the window early) to see whether `tme` ever flips to 1 / `tex0.tbp` goes non-zero after this TEXFLUSH — that is the literal 5.8 top-level exit test (line ~63 above). If it does, 5.8 closes and the project moves to whatever renders after that. If it doesn't, capture the full log (not just a tail paste) and check what gates the actual draw call using this new texture. ## 2026-08-02 Update 3 — Periodic stall (progress frozen 3s, busy%=0/all-zero throughput) then small burst. Debugger bp_hit ruled out. - User reports intermittent "4-5 pixels flashing every once in a while" during long runs (4:30+). Watchdog paste showed `progress` frozen at exactly `21991` for 3 straight seconds (t=337-339) with `res/s=0 vbl/s=0 gif/s=0 dma/s=0 busy%=0`, then a burst at t=340 (`vbl/s=5 gif/s=1 dma/s=2 busy%=330`). This is a genuine stall (busy%=0 means the guest fiber truly isn't scheduled, not spin-polling) — matches the earlier `[cputime]` finding of a thread spending 43.63s/96s in `Wait/ExecutionDelay`. - **Ruled out: debugger breakpoint stall (`g_bp_hit`).** `main_gui.cpp` (where `g_bp_hit=true` gets set) compiles into `RecompDebugger.exe`, a separate executable from `ps2EntryRunner.exe` (confirmed in `ps2xRuntime/CMakeLists.txt`). The Active Runner Command uses `-NoDebugger`, which per `launch_recomp.ps1` skips launching `RecompDebugger.exe` entirely AND unsets `PS2X_DEBUGSHM`, so `recomp_debug_writer.cpp`'s `bp_hit=1` writes (gated on that env var) can't fire either. Not the cause. - **Ruled out: IRQ worker sleeps.** `Kernel/Syscalls/Interrupt.cpp:659-670` sleeps 250-500us per vblank tick, capped at 8 iterations under determinism — orders of magnitude too small to explain a 3s+ freeze. - **Still open:** the stall's `pc=ra=0x421f10` (self-loop), `lastCall=0x172998`. `recomp_function_table.txt` only has a raw address list, no symbol names — could not identify the function without Ghidra (no GhydraMCP tool available this session). Next step: user to paste the Ghidra disasm for `0x421f10` (and its caller at `0x172998`) so the wait condition it's polling can be identified directly, per the project's "user pastes disasm, don't search for it" convention. - Also still open from Update 2: whether `tme` ever flips to 1 / `tex0.tbp` goes non-zero (5.8 exit test) — need an uninterrupted run past the point where the `[gs:image]`/TEXFLUSH pair was seen. ## Game Info - **Title:** Super Dragon Ball Z (US NTSC) - **Disc ID:** SLUS_214.42 - **ELF Path:** `F:\SDBZ Recomp\ELF\SLUS_214.42` - **Repo:** `F:\SDBZ Recomp\PS2Recomp\` - **Config:** `F:\SDBZ Recomp\config.toml` - **Branch:** work/laptop-session-0519 ## Active Runner Command ```powershell & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 1 -RunSeconds 200 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` **Corrected 2026-08-22 — two long-standing wrong defaults in this stored command.** - `-Determinism 0` → **1**. This line contradicted [[feedback_determinism_gates_guest_progress]] from Stage 5.15 onward (det=1 → 5/5 runs reach the `.SFD` open, det=0 → 0/4). Anyone copying this block verbatim was running the one setting the memory says never to use. - `-RunSeconds 90` → **200**. The Stage 5.17 events of interest occur at **t=121–138** and **t=171–192**. A 90 s run ends before both and reports them as "never happens" ([[feedback_run_window_false_negative]]). **Changed 2026-07-28 — use the RelWithDebInfo exe for everything now.** The A/B proved it does **109×** the guest work of the Debug build for the same CPU seconds (`progress` @ t=89: 12 101 → 1 320 490), so a Debug diagnostic run covers ~1 % of the guest execution for the same wall-clock cost. The Debug exe still exists and must not be deleted — it is the control arm — but there is no longer a reason to *run* it. Raw form, if the launcher is not wanted: `& "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" "F:\SDBZ Recomp\ELF\SLUS_214.42"` (corrected 2026-07-13c — `PS2Recomp\out\build\...` no longer exists; build.ps1's real output tree is `F:\SDBZ Recomp\build\...`) ## Agent Runner Script `F:\SDBZ Recomp\run_game_agent.bat` (generated 2026-06-22, paths verified against above) — usage: `run_game_agent.bat [timeoutSec] [logName]`, logs to `F:\SDBZ Recomp\logs\`. ## Build Command ``` & "F:\SDBZ Recomp\build.ps1" ``` (Always use build.ps1 — never raw cmake/MSBuild) ## Upstream Sync (2026-07-17) WIP checkpointed as commit `9957294f`. Checked all 7 new upstream commits vs HEAD — #149/#153/#155/#158/#167/#168 already present in tree (functional content confirmed via `git apply --reverse --check`); only `#170` (IOP refactor) remains deliberately held. Tree is current with upstream/main modulo #170. **#170 conflict re-check (2026-07-19):** now hard-conflicts — 15 conflicts on dry-run merge (`git merge-tree HEAD upstream/pr/170`). It **moves all IOP logic into a new `ps2xIOP/` module** — deletes `ps2_iop.cpp` and `include/runtime/ps2_iop.h` (modify/delete conflicts) plus content conflicts in SIF.cpp, ps2_runtime.cpp, RPC.cpp, game_overrides.cpp, ps2_debug_panel.cpp, Common.h, ps2_runtime.h, ps2_syscalls.h, CMakeLists.txt. These are exactly the files our live ARKD/`sceCdSearchFile` blocker work sits in. **Verdict: keep HELD** until the disc-driver blocker is closed; porting it now would rebase all in-flight IOP work onto a different module layout mid-fix. **#179 landed upstream (2026-07-22, merged as `f3687c5a`) — scope assessed:** `Feature/random fixes platform support`. Adds memcard IOP + Interrupt/GS/MPEG tweaks + Android/Vita platform scaffolding. **#179 alone is small (~1,132 ins / 145 del)** but is structurally welded to #170 — it edits `ps2xIOP/include/ps2x/iop/iop_host.h` and depends on the `ps2xIOP/` module, so **taking #179 requires taking #170 first**. Real cost = #170 (9,658 ins / 3,955 del, full IOP re-architecture) + re-homing our ARKD bridge into the new plugin API. - Our live work sits precisely in what #170 removes/rewrites: `ps2_iop_irx_loader.cpp` (1,138 lines, **ours only — not upstream**: the whole ARKD bridge + `ps2_iop_runArkdService` + `[ARKD:diag]`), modified `ps2_iop.cpp` (244 lines; upstream deletes the 105-line original), modified `SIF.cpp` (1,690 lines; upstream rewrites +53/−53, #179 +2 more). Upstream has **no** real-R3000-interpreter ARKD path — they stub/reimplement modules as plugins. So it's not a merge, it's a **re-port of our interpreter bridge into a new plugin architecture**. - **Effort estimate ≈ 5–8 days:** merge #170 + resolve ~15 conflicts (1–2d) · re-home ARKD bridge into `ps2xIOP` plugin model (3–5d, risky — rewrites the code path we're one measurement run from cracking) · layer #179 on top (~1d; memcard IOP useful @ stage 5.5, Android/Vita/`.suprx` binaries = skip). - **Verdict: keep BOTH HELD.** Close the ARKD `eeDest=0x0` blocker on the current architecture first, then take #170+#179 together at **stage 5.5** when memcard IOP (#179's payoff) actually becomes relevant. #176 (test-only sema determinism) can be ported anytime, independent of this. ## Deferred Build-Config Items - **Static CRT (`/MT`) for a portable exe — deferred to ship/packaging time (noted 2026-07-19).** Not enabled now: our ffmpeg static libs are almost certainly built against the dynamic CRT (`/MD`); mixing `/MT` exe with `/MD` libs risks LNK warnings + heap-corruption crashes that would masquerade as game bugs mid-blocker. When packaging: set `CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>"` AND rebuild ffmpeg to match, together. Not the current blocker. - **Opt-in ASan flag on the IOP interpreter** — considered as a debugging aid for the hand-written R3000 memory code (out-of-bounds/UAF). Only wire it if the cdRoot re-run still spins with no clear cause; make it a CMake flag, off by default. ## Current Phase **Phase 5 — in progress, started 2026-05-27.** **Memory-card-prompt goal ABANDONED/deprioritized (2026-08-02)** — repeated attempts across many sessions could not get the memcard prompt to populate no matter what was tried; continuing to target it stopped being productive. The active goal is now whatever the current sub-phase tracker below says (currently **5.15: get the `.SFD` movies to play**). Boot-to-title-screen/main-menu remains the longer-term Phase 5 target once 5.8 closes. **Provenance note (2026-08-02):** the 2026-08-02 Handoff/Update 2/Update 3 entries above were written from a GitHub Copilot ("fables") session, not a Claude session — flagged here in case terminology, address claims, or tone in those entries need reconciling with this project's conventions. User indicated these can be cleaned up if needed. ### Sub-phase tracker (established 2026-07-20g) **✅ 5.6 CLOSED (2026-07-28)** — build-config A/B ran: RelWithDebInfo does **109×** the guest work for the same CPU (`progress` @ t=89: 12 101 → **1 320 490**; `gif/s` 3–5 → **47–49**, i.e. normal PS2 frame rates). Debug build was the entire performance story. **Throughput is no longer a variable — always measure on the RelWithDebInfo exe.** (5.6.1 render-path DMA-kick census and 5.6.2 watchdog `busy%`/`res/s`/`vbl/s` fields both shipped; 5.4.2 CLEARED, 5.5.1/5.5.2/5.5.3 padman chain drained and demoted as blocker candidates, both parked leads triaged and dismissed — all 2026-07-27) **✅ 5.15 CLOSED (2026-08-17, run 61)** — `MOVIE/ATARI.SFD` **opens**; handle `0x54c060` latched at `0x125898`. The blocker *was* sid `0x80000597` (`sceCdSearchFile`), served from `ps2_iop.cpp handleRPC`. Requires `PS2X_DETERMINISM=1` ([[feedback_determinism_gates_guest_progress]]). The func-map hole at `0x186310` closed at run 62; sid `0x80000595` (cdvdfsv N-command) is served and working as of run 63 (`ncmdrd=24 readOk=1`). **✅ 5.16 CLOSED (2026-08-20, run 69)** — **the read always completed; our own RPC reply said otherwise.** The cdvdman S-command handler in `ps2_iop.cpp` stamped a blanket `word[0] = 1` "benign success" into every recv buffer. But the libcdvd EE wrappers return recv `word[0]` **verbatim as the command's value**, not as a success flag — so `sceCdGetError` (fno 4) answered `0x1`, which SRD reads as a DRIVE ERROR. Replaced with a per-fno decode table (fno 4 → `SCECdErNO`=0, fno 12 → `SCECdStatSpin`=0x02, fno 1 → genuine success flag 1) plus a census-by-distinct-fno probe. **Exit test passed 4/4 in run 69:** `sceCdGetError ret=0x0`; **zero** `SRD: Drive Error` lines (was 248); `12ee20 preStat=0x1 postStat=0x3` — the status byte leaves 2 and reaches **3 = DONE**; `130b80 dvcidone cvfsStat=0x3` — completion propagates to the movie layer. Reads advance across distinct LBNs (`0x157484` → `0x1575ed`) instead of retrying one forever. > **Transferable rule (new).** A shared RPC recv buffer is not a status channel. Before writing *any* reply word, read the EE-side wrapper's tail: if it does `v1 = MEMORY[recv]; return v1;`, that word **is the return value**, and a placeholder "success" flag silently becomes a bogus result. Same family as the Stage 5.12 memcard rule and the Stage 5.14 rule (*silence must be written, not omitted*) — all three are "we answered, and the answer was wrong", not "we did not answer". > **Loose end carried into 5.17.** The census reported `[iop:cdscmd] fno=34 UNDECODED -> word0=0x00000001` — a **guessed** reply. It originates from the variable-fno wrapper `sub_187AA0` (send buffer `0x464880` matches; its fno comes from `dword_464DE8` at runtime), one 32-bit arg in, one 32-bit result out. `fno=1` and `fno=4` decoded correctly. **`fno=12` was never reached in run 69**, so the inferred `0x02` status value is still unverified — harmless today, but do not cite it as tested. `sub_1879E8` (fno 22) was *not* reached either, contrary to the pre-run expectation. ### 5.17 narrowed — runs 72/73/74 (2026-08-20) **The loadscreen is NOT broken.** `sub_3E2FF0`'s 83.0 accumulator is the **timeout arm** of a wait-for-the-movie-to-end; the skip arm is `if (MovieUpdate() == 1) acc = 230.0`. `[st4b:stat]` shows the accumulator advancing exactly `dt=1/60` per call, `over83=0`. `MovieUpdate` = `0x113920` (shares globals with `0x113AA0`, which prints `"MovieCreate: Use Work Size"` — 6x in the log, so the handle exists) and returns 1 only once status leaves `{1,2}`. `[movie] stat=1 maxstat=1` on **every** record of runs 72/73/74. The movie never reports completion, so the loadscreen correctly waits. **Run 74 killed the CRI-server-guard theory.** `[crisrv:stat] bail=0 ent=0:1121` — the `0x11D510` tick ran 1121x and its step-numbered guard `dword_4407D8` was 0 on every single entry. Healthy. **Where it actually dies.** `srd_obj` @`0x44D230` reads **all zero**: `devtype=0 stat=0 lsn=0 secs=0 buf=0`. `sub_12F1E8` dispatches the pump only on `devtype` 1 (HST) or 2 (DVD) — field names taken from `sub_12F598`, the game's own `"SRD Info"` printer — so with 0 in there the pump is skipped forever. `devtype` is written in exactly one place: **`sub_12E7B0` (srd_read)**, whose only caller is **`sub_131190` (CVFS read)**, which has **no `jal` callers at all** — it is reached purely through a device-driver function pointer. The disc path itself is fine: 3 reads issued, 3 completed, consecutive LBNs (`0x157484`+`0x168` -> `0x1575ed`), `syncRet=0` always, `12ee20` exit stat always 3. **Probe added for the next run:** site `cvfsrd.131190` -> `[cvfs:stat]`. Splits *never called* from *called and rejected*, with one counter per rejection path (`badObj`/`nsctNeg`/`bufNull`/`stat2`/ `nsct0`) and `devAfter` proving whether `12E7B0` armed the object. `12E7B0` is deliberately NOT wrapped: `sub_131190_0x131190.cpp` has zero `dispatchGuestBranch` calls, so `replaceFunction` there would be bypassed ([[feedback_registerfunction_bypass]]). **Cap fixes validated, and both prior numbers were wrong:** `STACKOOB` 16 -> **>=64**; `DEFERINL` 64 -> **>=4096**. **Retracted:** the `CRITSEC escapes=10` figure reported earlier is not present in any run log. **🔵 ACTIVE: 5.17 (opened 2026-08-20)** — ⚠️ **run 88 (2026-08-23) retracted the sreg-gate and "never reaches step 0xd" theories; read the 08-23 handoff at the top of this file, not the analysis below.** The exit test is unchanged. — **the movie opens, its reads complete, and then nothing asks for more data.** With 5.16 fixed the SRD layer reports DONE, but only **2** SRD reads issue in a full 300 s run (~1.4 MB) — nowhere near enough to feed 85 s of video. The EE then goes nearly idle waiting on something downstream. Run 69 watchdog shape, both movies identical: > > | window | state | > |---|---| > | t=1–106 s | `busy%≈100`, normal execution | > | t=119 s | movie opens, `[movie] stat` 0→1 | > | t=121–196 s | **`busy%` collapses to ≈10**, `stuckSecs` climbs to 76 | > | t=206 s | movie layer gives up, `stat` 1→0 | > | t=211–241 s | `busy%` back to ≈100, game resumes | > | t=248 s | second movie opens — same shape, still parked at run end | > > End-of-run state: `[adx:stream] st=2 bsy=1 fd=0x54c060`, `[movie] maxstat=1 objSrc=0x0`. **Exit test:** SRD read count exceeds ~2 per movie (a genuine streaming cadence), `busy%` stays near 100 through the movie window, and `[movie] objSrc` becomes nonzero. **First two candidates:** (a) decode `fno=34` above — the only guessed reply left on the path; (b) find who is supposed to re-arm the next read after `cvfsStat=0x3` and confirm it runs at all (`PS2_COVERAGE=1` first — see the "measure the count before building the theory" rule). **✅ 5.10 CLOSED (2026-08-07)** — `pcpyh` read `rs` instead of `rt`. Memory-card text legible on screen. *(Historical framing below.)* — **the GS rasterizes but the presented frame is black, because every PSMT4/PSMT8 payload arrives all-zero.** `[gs:frame] textured=19,832,591 maxrgb=255 nonblack=3,757,356` vs `[present] nonblack=0`. **Exit test:** `[present] nonblack > 0`. **Next action:** build + run the `[vifsrc]` probe (written 08-05, UNBUILT) at the head of `PS2Memory::processVIF1Data`. Decision table + full evidence are in the **2026-08-05 section at the top of this file — read that first.** **✅ 5.9 CLOSED (2026-08-05)** — IOP heap arena too small for ARKD's real 960 KB load buffer; arena enlarged to `0x60000..0x1D0000` + one-shot cursor recycle. Assets load: TOC count=1930, 1.13 MB read, `fails=0`. **✅ 5.8 CLOSED (2026-08-04)** (opened 2026-08-01) — **the guest state machine never advances to a textured draw.** Replaces 5.7's framing, whose surviving lead (BUG-009) is now **CLOSED — benign**. Everything underneath works and is measured: frame loop healthy (~46–49 fps, `vbl/s`≈49, `dma/s`≈98), asset streaming proven end-to-end (1024 reads / 2.1 MB / **zero fails**), GS proven innocent (BUG-028 fixed + verified twice). Yet `primmask=0x40` (SPRITE only), `tme` **never** 1, `tex0.tbp` always 0, `nonblack=0` across ~24 M pixels/interval. **Exit test:** a single frame in which `tme=1` and `tex0.tbp != 0`. **Method (this is the change that matters):** stop reasoning from our own log in a vacuum — *difference against real PCSX2 + the real ISO*, which reaches the memory-card prompt in 7–8 s, using `mcp__pcsx2__*`. Six retractions in this file all came from inferring semantics instead of measuring them against ground truth. First steps: (1) arm `PS2_COVERAGE` — built, documented, and **not armed since 2026-07-29** (last run `cov=0/0`) — it answers "which game-band addresses does the EE actually dispatch" directly, instead of hand-reading 2800-line generated `switch` bodies; (2) watch `0x44D26C` (SRD test-and-set gate: `sub_12F1E8`/`sub_12F7E0` tick only if they win it, `sub_12EB28` can set it to 1 after a 1000-iteration spin — if it sticks at 1 all five SCMD wrappers silently stop being reached, with no log output), `0x463294` (libcdvd bind latch), `0x5AA7D0` (the CCD reply word `sub_1C0A30` actually branches on), `0x5e6b3c` (GameMode); (3) breakpoint `0x1C0A30` on PCSX2 and read `MEM[0x5AA7D0]` per hit — settles whether our `0x30000000` is the advancing answer or the keep-waiting one. **That single measurement may be the whole blocker.** > ⚠️ **The "First steps" above are HISTORICAL — they were carried out, and measurements #1–#4 below supersede them.** 5.8 has since narrowed all the way from "never advances to a textured draw" to one named gate: `CAppInit` parks at state 11 waiting on an ARKD completion that four separate breaks prevented from ever being published. **Read § 5.8 measurement #4 first; it holds the root cause, the fix (built, unrun), and the exact next command.** #### ★★ 5.8 measurement #1 (2026-08-01, two runs) — the blocker is localized to ONE variable: `CAppInit` state at `0x5E5924` Two 90 s runs with `PS2_COVERAGE=1`, the second with `PS2_COV_GAMELO=0x100000` (env-overridable at `ps2_runtime.cpp:2795`, **no rebuild**) to census the whole `0x10xxxx–0x1Cxxxx` band the default `kGameBandStart=0x200000` had been silently excluding. **Finding 1 — the executed set is CLOSED.** Reproduced independently in both runs: | run | `distinct` across 9 dumps | `calls` | |---|---|---| | A (band 0x200000) | 1374 → **1375, then frozen ×8** | 476,257 → 2,536,872 (5.3×) | | B (band 0x100000) | 1309 → **1313, then frozen ×8** | 473,282 → 2,503,343 (3.4×) | The EE enters **zero new functions** for ~80 s while call volume climbs 3–5×. This is *positive* proof that the guest state machine never advances — far stronger than the negative `tme=0` evidence, and it is now a reusable regression signal: **any real fix must make `distinct` climb.** **Finding 2 — libcdvd/CCD is boot-only and idle, not stuck.** Full-band census: `CD_Init` (`0x186EC8`) = 2, `_sceCd_scmd_prechk` (`0x186CC0`) = 3, `sub_1C0A30` (CCD state machine) = **2**. They ran at boot and were never re-entered. Meanwhile `sub_327810` = 3959, `0x421EA0` = 3959, `0x421F10` = 3959 (once per frame, every frame). **Finding 3 — the SRD disc-streaming subsystem never executes at all.** Region histogram: `0x12xxxx` = **1 address, 1 call**; `0x121000–0x135FFF` (84 KB, containing `sub_12EB28`/`sub_12F1E8`/`sub_12F7E0`) is entirely unentered. Consistent with `srd_gate 0x44D26C` showing **zero writes** all run — nobody ever *contends* the gate. **The 07-31 "stuck test-and-set gate" hypothesis is therefore dead:** the gate is untouched, not held. **Finding 4 — falsified my own candidate before acting on it.** `0x463294` showed one transition `0xFFFFFFFF -> 0x0` @ `pc=0x186dfc`; I read it as a stuck in-flight latch. The decompile (3 refs in 15.9 MB) shows `if (dword_463294 < 0)` is a **lazy bind-once guard** and `sub_186CC0` returns `result = 1` on *both* paths. One transition is correct, healthy behaviour. Condition 3 of the `sub_186CC0` bail list is now **tested and exonerated**. **Finding 5 — `ccd_reply` (`0x5AA7D0`) oscillates `0x0 ↔ 0x30000000` forever**, writer `pc=0x100160`, never `0x40000000`. But `sub_1C0A30` returns 1 for `0x30000000` **and** `0x40000000` — both branches pass, so this is not obviously a gate either. Probe **saturated at exactly its 16-record `should_log` cap**, so 16 oscillations and 16,000 are indistinguishable → fresh evidence for the cap-disclosure tooling item. `gamemode 0x5E6B3C`: zero transitions. `bind_ready 0x464DE4`: one, `0x0 -> 0x5`. **★ Finding 6 — the localization.** `sub_327810` is **`CAppInit::Update`**, a staged app-init state machine (`decompiles_SLUS_214_42.txt:428822`), ticked 3959× and never advancing. Its object is constructed in place by `wrap_obj_register_d_0` @ `0x4E5050`: ```c dword_5E5920 = (int)dword_4F2FC0; // vtable -> object base = 0x5E5920 dword_5E592C = 0; // object+12 = substate counter ``` so the state word `*(_WORD *)(a1 + 4)` that every `case` label increments is at a **fixed known address, `0x5E5924`** (`+6` = saved stat, `+8` = error code, `+12` = substate). State 1 has exactly one gate: ```c case 1LL: if ( !wrap_noop_wrapper_unk_z_z_196() ) // 0x1BFF40, a thunk goto LABEL_63; // stay in state 1 forever ++*(_WORD *)(v4 + 4); ``` The failure path is also self-announcing: state `9900` prints `"%s::Init() ERROR!! Stat=%d Code=%08X"`. **We never see that string**, so `CAppInit` is *waiting*, not erroring. ⚠️ All six state-handler callees (`0x1BF340`, `0x1BFF40`, `0x3273D0`, `0x327550`, `0x327730`, `0x2FF4C0`) read `--NEVER dispatched--`, but they are thunks and **coverage counts table-dispatched calls only** (`ps2_runtime.cpp:404-408`) — a direct `fn_*` call bypasses `lookupFunction`. This is *not* proof they never ran, and must not be reported as such. Read `0x5E5924` directly instead. **Next:** watch `0x5E5924:2` + `0x5E592C:4` (zero rebuild) → which state, and does the substate counter move; then read `0x5E5924` on PCSX2 at the memory-card prompt for the first exact divergence-table row. #### ★★★ 5.8 measurement #2 (2026-08-01) — PCSX2 differential: **every nominated suspect MATCHES; the only divergence is `CAppInit` itself** First ground-truth differential in the project's history. PCSX2 `d75a0ad`, DebugServer 21512 + Pine 28011 both live, `SLUS-21442` UUID `de2df62d`, paused at EE `pc=0x175220`, GameMode already `0`. | Addr | What | **PCSX2** | **Ours** | Verdict | |---|---|---|---|---| | `0x5E5920` | `CAppInit` vtable | `0x004F2FC0` | (same by construction) | **MATCH** — confirms the `0x4E5050` object-layout derivation on real hardware | | **`0x5E5924`** | **`CAppInit` state** | **`1000` (0x03E8)** | **stuck; `Update` ticks 3959×/run** | **★ THE DIVERGENCE** | | `0x5E5926` | saved stat | `0` | TBD | — | | `0x5E592C` | substate | `0` | TBD | — | | `0x5E5944` | `CAppFinal` state | `0` | TBD | never entered on either side | | `0x5AA7D0` | CCD reply word | `0x30000000` | `0x30000000` | **MATCH — ARKD exonerated** | | `0x44D26C` | SRD test-and-set gate | `0` | `0` (never written) | **MATCH — lead dead both sides** | | `0x463294` | libcdvd bind latch | `0` | (exonerated by decompile) | **MATCH** | | `0x5E6B3C` | GameMode | `0` | `0` | MATCH — `0` is not yet a discriminator | **★ Finding 7 — `0x1BFF40` is NOT a "noop wrapper". It is a one-instruction tail-jump into `0x1C0A30`.** Native PCSX2 disasm: `0x001bff40: j ->$0x001C0A30` + `nop`. So the sole gate on `CAppInit` state 1 *is* the CCD boot state machine, and its gate in turn is the reply content at `0x5AA7D0`. **Second func-map name in two days that meant the opposite of what it said** (after `rpc_handle_valid` = `sceSifCheckStatRpc`). See the 2026-08-01 Learned Patterns entry. **★ Finding 8 — the whole gate chain evaluates TRUE for us.** `CAppInit::Update` state 1 → `0x1BFF40` → `0x1C0A30` → returns 1 iff `(MEM[0x5AA7D0] & 0xF0000000)` ∈ `{0x40000000, 0x30000000}`. We measured `0x30000000` on **both** machines, and `sub_1C0A30` returns 1 for either value. **So if we were in state 1, we would advance.** Therefore either (a) we are stuck in a *different* state, or (b) `sub_1C0A30` never reaches its final check in our build. Our coverage shows `sub_1C0A30` dispatched only **2×** against `CAppInit::Update`'s 3959 — which favours (a) — **but `0x1BFF40` is a thunk and coverage counts table-dispatched calls only, so that count cannot decide it.** Reading `0x5E5924` decides it. Do not guess. **★ Finding 9 — on real hardware `CAppInit::Update` is RETIRED.** Breakpoint at `0x327810`, resumed, ~1.34 × 10⁹ EE cycles (≈4.5 s) elapsed with **zero hits**. State `1000` is the terminal state: init completed and the object stopped being ticked entirely. Ours is ticked 3959× in 90 s and never leaves. This is the cleanest positive success signal the project has had — better than any of our negative ones. ⚠️ **The plan anticipated this outcome and it is a result, not a dead end:** all four addresses nominated for the differential match real hardware. The blocker is not in any of them; `CAppInit`'s own state word is now the instrument. **Next:** the pending zero-rebuild run reads `0x5E5924`. Then the fix target is whichever state it names — and the reference answer (`1000`, retired) is now known, so 5.8 finally has a **positive** exit test: `0x5E5924` reaches `1000` and `sub_327810` stops being dispatched. #### ★★★ 5.8 measurement #3 (2026-08-01) — **the blocker is named: `CAppInit` state 11, waiting on the 16-slot audio table** The run above landed. `appinit_state` at `0x5E5924` produced **10 changes, well under the 16-print cap, so the sequence is complete and trustworthy**: `0 → 1 → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10 → 11`, then **stops at 11 (0xb)** for the rest of the 90 s. So Finding 8's hypothesis **(a) is correct** — we are stuck in a *different* state, not state 1. `sub_1C0A30`, the CCD gate, was passed long ago. **BUG-009's whole neighbourhood is now doubly dead.** `appinit_sub` (`0x5E592C`) produced **exactly 16** changes = saturated at `should_log(c,16,600)`. Interleaving both watches in log order shows every one of those 16 fell **inside state 8** (the `3↔4` oscillation at `pc=0x327640`/`0x327710` is `sub_327550`'s 31-entry sound-bank load loop), which then completed normally and let the state advance 8→9→10→11. **Substate values after state 8 are unknown — the probe went silent, it did not stop changing.** This is the third time a saturated bounded probe has had to be reasoned around; see the deferred cap-disclosure tooling item. **★ Finding 10 — `CAppInit::Update` case 11 is a wait-for-audio gate, and its func-map name is the FOURTH one to mean the opposite of what it says.** ```c case 11LL: if ( *(_DWORD *)(a1 + 12) == 1 ) { // substate 1 if ( input_device_get_button_map_clone_01(0x10u) ) { // <-- NOT an input read *(_DWORD *)(v4 + 12) = 0; v10 = 1; goto LABEL_52; } // advance to state 12 } else { if ( *(_DWORD *)(a1 + 12) ) { v10 = 0; goto LABEL_52; } // substate >=2 => WEDGED FOREVER str_queue_msg(0, (unsigned int)aSndStdeff); // queue "snd/stdeff.ase" ++*(_DWORD *)(v4 + 12); // substate 0 -> 1 } v10 = 0; ``` `input_device_get_button_map_clone_01` is `sub_2FCDF0 @ 0x2FCDF0`, and reading its body settles it — it touches no pad state at all: ```c sub_2FCDF0(a1): if (a1 != 16) return LOBYTE(dword_5D6BA4[10*a1]) < 2; // one slot's status < 2 for (i = 0; i < 16; i++) // a1 == 16 (0x10) => ALL slots if (slot[i].byte36 != 1 && slot[i].byte36) return 0; // any status >= 2 => not ready return 1; ``` It is **"are all 16 audio slots idle?"**. It is byte-for-byte identical to `input_port_is_available @ 0x2FCE70`, which is plainly where the auto-labeller cloned the bogus "input_device / button_map" name from. The `0x10` argument is not a button mask, it is the **slot count**. Add to the Learned Patterns tally: `rpc_handle_valid`→`sceSifCheckStatRpc`, `wrap_noop_wrapper_unk_z_z_196`→tail-jump, and now `input_device_get_button_map_clone_01`→`all_sound_slots_idle`. **★ Finding 11 — the reference audio table, read live from PCSX2.** The array is at `0x5D6B80`, **40-byte stride**, filename at `+0`, **status byte at `+36`** (`dword_5D6BA4 == 0x5D6B80 + 0x24`). At the memory-card prompt: | slot | name | status | |---|---|---| | 0 | `snd/stdeff.ase` | **1** | | 2 | `snd/comeff.ase` | 0 | | 3 | `snd/comvoi.ase` | 0 | | 5 | `stg/s01/s01.ase` | 0 | | 7 | `snd/etceff.ase` | 0 | | 8 | `snd/etcvoi.ase` | 0 | | 10 | `ply/p04/p04.ase` | 0 | | 11 | `ply/p12/p12.ase` | 0 | | 1,4,6,9,12–15 | (empty) | 0 | All ∈ `{0,1}` → the gate returns 1 → state 11 passes on hardware. Slot 0 being `snd/stdeff.ase` at status 1 **independently confirms** the `str_queue_msg(0, aSndStdeff)` → slot-0 linkage. **Two candidate mechanisms, and the pending run distinguishes them without a rebuild:** - **(A)** substate reached 1, `str_queue_msg` queued `stdeff`, and some slot is parked at status ≥2 because our audio backend never completes the load. Signature: **slot watches show transitions**. - **(B)** substate was ≥2 on entry to state 11 (leftover from state 10's handler), which per the `else` branch above is an **unrecoverable wedge with no path back to 0**. Signature: **no slot watch ever fires**, because `str_queue_msg` is never reached. Watching all 16 status bytes decides it: `str_queue_msg` only runs at state 11 substate 0, so *any* slot-0 transition proves substate reached 1. `appinit_sub` was deliberately **not** re-armed — it spends all 16 prints inside state 8 and tells us nothing new. #### ★★★ 5.8 measurement #4 (2026-08-01) — **root cause found: the ARKD completion path was never built, and the sreg it publishes to is EE libsifcmd's own array** Answer to #3's A/B: **neither, as stated.** The chain broke further upstream than both candidates assumed. **Retraction first.** The intermediate framing "the sid `0x501` reply is empty" is **wrong and withdrawn**. The reply was never produced at all: the `fno=0x101` handler **never returned** (`retired=4000000` = full interpreter budget, `halted=0`), so `$v0`/`rsz`/`delivered` were zeros *by construction*, not by measurement. SIF transport is **exonerated** — do not go back to it. **★ Identity correction that made the fix findable.** `0x5618B0` has been described for several sessions as "entry 12 of an async status table at `0x561880`". It is **EE libsifcmd's `_sif_sreg[]` array** — 32 entries, installed by the game's own `sceSifInitCmd` at EE `0x177B00` (stored to `cmd_data+0x1C` = `0x5616F4`). `0x561880 + 4*12 == 0x5618B0`, so `SET_SREG index=0xc` and the `req12` watch are **the same word**. Readers: `0x177AB8` = `return dword_561880[a1]`; `0x177AD0` = `sceSifSetReg`; `0x177A88` = the SET_SREG system-command handler. Fifth entry in the misleading-name tally. **Four independent breaks, all measured, all on one path:** | # | Break | Evidence | |---|---|---| | 1 | `sub_6920` (+0x6920) never called — the loader ran only `InitLoadBuffers`, skipping the module's own init `+0x30` (`RpcReplyRetryLoop → sub_6920 → sub_9EAC → InitLoadBuffers`) | log `threads=4`, all four RPC servers (`0x4a4dc/0x4a554/0x4a5cc/0x4a644`); `0x042c90` absent from every `CreateThread` | | 2 | job worker `+0x62A4` never runs — state-11 dispatchers set `dword_B334=0x10000000` + `SignalSema(dword_B338)`, nothing consumes it | no IOP scheduler; `B334` never leaves the `0x1` band | | 3 | publisher `+0x2C90` never runs — it is the only caller of `sub_A3C0(12, dword_B334)` | `sendcmd=1` for the whole run | | 4 | the one SET_SREG we did emit landed nowhere — it wrote host-side `g_sifSregs`, but the game never calls `sceSifGetSreg`; it reads `dword_561880[i]` out of guest RAM | `set_sreg=0` | `sub_6920` also seeds `dword_B334 = 0x30000000` (ready) and creates the job semaphore. The single `SET_SREG value=0x10000000` seen in the 3c run was the **dispatcher's busy stamp**, correct for that instant, with nothing present to advance it. **Fix written (not yet built), all in `ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp`** — no headers, no `runner/`, no `fn_*`: run `+0x6920` at load after the generic thread pass; tick `+0x62A4` then `+0x2C90` per service call under bounded halt rules modelled on the existing `+0xAEDC` idle-worker rule; mirror SET_SREG into EE RAM. All three created bodies are `while(1)` loops, hence ticking rather than running once. **⚠️ The fix is INERT unless `PS2_SIF_EE_SREG_BASE=0x561880` is set** (the env var `RPC.cpp:134` already defines). The log self-discloses: `<-- NOT MIRRORED (set PS2_SIF_EE_SREG_BASE)`. **Exit test:** `[ARKD:sendcmd] index=0xc value=0x3xxxxxxx (CHANGED)`, `req12` leaves `0xF0000000`, `appinit_state` leaves `0xb`. **Two runs, per standing rule.** **Bundled in the same rebuild** — the generalization of this bug class, since an unhandled import silently returning `$v0=0` is what caused break 4 *and* the 3b-4 `sceSifSendCmd` spin: - `[iop:unhandled]` — every `(lib, fid)` that falls through to the default is censused; first hit announced; full census dumped whenever a service run fails to halt (i.e. exactly when something is spinning). - `[iop:import]` cap is now env-overridable (`PS2X_IOP_IMPORT_MAX`) and prints a `[cap]` saturation line, joining `PS2X_ARKD_CALL_MAX` / `PS2X_ARKD_RUN_MAX`. **Still open, explicitly unmeasured:** `thsemap fid=6/8` and `sifman fid=7/8` semantics (deliberately *not* guessed from an ordinal table — the new `[iop:unhandled]` census will name them if they matter); the `cdvdman fid=5` (`a0=6`) spin on `sid=0x500 fno=0x1`. **✅ BUILD SUCCEEDED 2026-08-01 (`ps2EntryRunner.exe`, RelWithDebInfo, +04:02).** All of the above — the Phase 3d fix and the bundled `[iop:unhandled]` census — is now **in the exe and completely unrun.** Nothing in this section has been validated against a run; everything is code-proven or decompile-proven only. **→ THE SINGLE NEXT ACTION IS THE RUN.** It must carry `PS2_SIF_EE_SREG_BASE=0x561880` or the fix does nothing: ```powershell $env:PS2_IOP_TRACE = "1" $env:PS2_ARKD_STATE = "1" $env:PS2_SIF_EE_SREG_BASE = "0x561880" $env:PS2X_ARKD_CALL_MAX = "100000" $env:PS2X_ARKD_RUN_MAX = "100000" $env:PS2X_WATCH = "0x005A9380:4:pkt_snd,0x005A9358:4:pkt_ccd,0x005618B0:4:req12,0x005D6BA4:4:snd00,0x005E5924:2:appinit_state" & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger ` -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` Read, in this order — the first two are informative **whether or not the fix works**: 1. `[iop:irx] initThreads run: … created=3 B334=30000000` → did break 1 actually close? 2. `[iop:unhandled] …` first-hit lines, and any post-non-halt census → names the next spin, if there is one. 3. `[ARKD:tick] job: … pub: … B334=…` → breaks 2 and 3. 4. `[ARKD:sendcmd] SET_SREG index=0xc value=0x3xxxxxxx -> EE[0x5618b0] (CHANGED)` → break 4, and the exit test. 5. Validity gate before reasoning from any absence: `[watch] armed 5` present **and** no `[cap]` line for the tag in question. **⚪ 5.7 (superseded 2026-08-01, folded into 5.8)** — **the `0x421f10` / `sceDmaSync` (`0x172998`) plateau is a correctness gate, not slowness.** At ~47 fps the guest still never leaves it in 90 s (≈2.7 h of Debug-equivalent work) while PCSX2 reaches the memory-card prompt in 7–8 s. `dma/s`=92–98 and `gif/s`=47–49 prove DMA *is* completing, so the guest is either polling a completion flag the runtime never sets or syncing a channel that is never serviced. **Exit test:** watchdog `pc` leaves `0x421f10` and `lastCall` leaves `0x172998` under its own power. First steps: (1) one `-RunSeconds 300` confirmation run, ~~(2) disassemble `0x421f10` — it is `pc` *and* `ra`, a tight self-loop, and has never been decoded despite being the plateau address for weeks~~ **(2) RETRACTED 2026-07-29 (co-piolet) — `0x421f10` is `jal sub_00199840`, call site 10 of a 14-`jal` fan-out inside the per-frame driver `sub_00421EA0` (real extent `0x421EA0–0x422168`, sole caller `sub_00422630` @ `0x422658`); it is not a loop and `pc==ra` there is a dispatch-boundary sampling artefact. This agrees with stage 5.5 below and supersedes the wording here. Replacement step (2): stop treating `pc` as the plateau signal and instead find why `gstate@0x50227c` never changes — the fan-out is running to completion every frame, so the stall is in what those subsystems read, not in reaching them.**, (3) walk the stable trace tail `0x174f20 -> 0x172e20 -> 0x172a90 -> 0x172998`, (4) A/B the same point against real PCSX2. | # | Stage | State | Exit test | |---|---|---|---| | 5.1 | Clear the EE stack-frame derail | ✅ **CLEARED** (built + run 2026-07-22): zero `[frametrace:ALARM]`/`[frametrace:TABLE]` — no escape to packet-pool `0x20561900`. Exit test as written was **mis-keyed**: the surviving `IMBAL delta=-0xa0` lines are normal recompiler `jal`-split dispatch (entry `0x178068` → exit mid-body `0x1780c0` → next slot resumes + restores), NOT corruption. | ~~No `IMBAL delta=-0xa0`~~ → superseded by: no ALARM/TABLE escape to `0x20561900` | | 5.2 | Prologue-skip class (`fn_178428` + `fn_178068` jalr paths) | ⚫ **ABSORBED / N/A** — the imbalances that would have triggered this are now proven to be benign jal-split false positives (see 5.1). No separate derail class exists to fix. | (moot) | | 5.3 | ARKD RPC returns real data | ✅ **CLEARED (2026-07-24)** — `memcpy` thunk (`0xAE24`) no longer no-ops; `[ARKD:diag] post-func BF50=0x01652400` (was `0x0`); `[ARKD:sifdma] eeDest=0x01652400 size=0x18000 -> copied`. TOC DMA lands at the real EE address, not `0x0`. | `func=0x2` reply lands + real eeDest ✅ MET | | 5.3.1 | AudioSysInit ARKD `func=0x2` completion | ⚪ not yet re-measured post-5.3 fix — watchdog never logged `0x422454`/`0x422480` this run (see 5.3.2 derail below, which likely preempts it) | `AudioSysInit` returns; watchdog pc leaves `0x422454` | | 5.3.2 | New derail: EE PC collapses to `0x1` after real TOC DMA lands | ✅ **CLEARED (2026-07-25, fix 3, VERIFIED).** Root cause was fix (2)'s *fixed-absolute* `kSifReplyScratchStackTop` — SLOTWATCH showed the real depth=0 dispatcher chain descends ~0x4110 bytes, blowing through the fixed top's clearance and landing within 0xC0 bytes of the real caller's saved-`$ra` slot regardless of re-entrancy depth (so the "second untracked writer" chased in 07-24g/07-25b/c was this same mechanism, just not bracketed by the depth-keyed SLOTWATCH probes). Fix: `scratchTop = savedSp - kSifReplyScratchHeadroom(0x8000) - depth*0x2000` — anchored to the live `$sp` at call time instead of a fixed RAM address, in `SIF.cpp` only. **Verification run:** `scratchTop = savedSp - 0x8000` confirmed on 20+ sampled `[SifRpcReply]` lines, zero `$ra=0x1`/`PC=0x1` anywhere before the new blocker (line 2889 of that run's log) — ARKD calls, LOADFILE, dozens of BIND/CALL round-trips all succeed. Full details + fix text: [[reference_ps2_sif_boot]] 2026-07-25 entry. ~~07-24e hypothesis RETRACTED (07-24f) — `$s2` overflow in `sub_00177EB0_0x177eb0` is DISPROVEN by direct code read.~~ Full function body read line-by-line: `$s2` is reset to `0` (delay slot of `blez $a1` at `0x177ef8`) or `1` (`0x177f0c`) at every fresh entry, and the function is a **single straight-line pass** (no loop-back edge exists to `label_177f40`/`label_177f4c` from below `0x177f9c`) — its sole caller `fn_177FE8_0x177fe8.cpp` invokes it exactly once with `$a1=0`. `$s2` therefore never exceeds ~2 within this call, so it cannot index `sp+0xC0`; the "s2≈12" arithmetic in the retracted entry was coincidental, not causal. **Do not resurrect this hypothesis.** The real, previously-measured mechanism (2026-07-23d/24, still the live lead, see below) is: `0x177eb0`'s SIF DMA syscall (`0x175060/0x175070` → `sceSifSetDma`) has no real IOP, so it calls `deliverSifRpcReply` (`SIF.cpp`), which nested-dispatches `0x178068` **on the same EE stack** to fake the reply — and that nested subtree is what overwrites `rpc_call`'s (`0x178be8`) saved-`$ra` slot `0x1ffbeb0`. Two scratch-stack isolation fixes for this were already shipped in `SIF.cpp` (Fix 1: single scratch top 07-23; Fix 2: per-depth scratch bands, `kSifReplyScratchStackTop - depth*0x2000`, 07-24) and build-verified, but **the falsification-first verification gate for Fix 2 is still unmet after 3 separate runs** (07-24, 07-24e, 07-24f = this session) — every run shows `[SifRpcReply] depth=0` for all entries (44/35/36 lines checked), meaning re-entrancy (depth≥2) is simply never exercised by these particular runs, so Fix 2 remains unfalsified but also unconfirmed. **Open problem:** this session's fresh run (07-24f) still shows `RAFORK fork=B(mid-body-clobber)` / PC=`0x1` on `0x178be8`/`0x1baf90` **despite depth staying 0 throughout** — meaning whatever is stomping `0x1ffbeb0` this run is NOT going through the tracked `deliverSifRpcReply` re-entrancy path at all (that counter never left 0), so it must be a **second, untracked writer** with the same blast radius. Next step: add a raw SLOTWATCH-style write-guard directly on `0x1ffbeb0` (or extend the existing `kSdbzFrameTraceSlots` probe) that logs the writer's PC/callstack on ANY write to that address, not just at wrapped-function entry/exit boundaries — the current SLOTWATCH only samples at call boundaries of pre-selected slots (`0x178068`/`0x177eb0`/`0x177fe8`), so it can bracket "somewhere inside this subtree" but not pinpoint the exact instruction. **07-24g (this session, fresh rebuild+run, after fixing an MSVC mtime-staleness bug that had silently prevented `game_overrides.cpp` from recompiling across ~3 prior "successful" builds):** full IMBAL/RAFORK/SLOTWATCH set captured. Real call order (reading the `trace=` tail forward, oldest→newest): `... → 0x1bc5a0×n → 0x171f10 → 0x1baf90 → 0x178de8×4 → 0x178be8 → 0x178428 → 0x17ed60 → 0x17edb0 → 0x177fe8 → 0x177eb0 → 0x1781b0 → 0x175060 → 0x178068 → 0x178068 → 0x175090 → 0x178560 → 0x1784d0 → 0x1 (collapse)`. Key data points: - **SLOTWATCH #1** (`callee=0x178068`, first entry): `expected=0x1bb0b0 before=0x1 now=0x1` — slot is **already `0x1` before `0x178068` runs at all**. This **falsifies the `0x178068` copy-loop-overrun hypothesis as proximate cause** for this run — `0x178068` only re-observes an already-poisoned slot, it doesn't poison it. - **SLOTWATCH #3/#4** (`0x177eb0` then `0x177fe8`): `before=0x1bb0b0 now=0x1` on #3, still `now=0x1` on #4 — the actual clobber is caught in the act, bracketed between `0x177eb0`'s and `0x177fe8`'s checks (note: #3/#4 fire in `0x177eb0→0x177fe8` order in the log despite `0x177fe8` being `0x177eb0`'s caller per the call trace — consistent with #3/#4 being watch-checks made at nested-call boundaries inside that subtree, not top-level entry order). - **RAFORK**: both `0x178be8` (`entryRa=0x1bb0b0`) and `0x1baf90` (`entryRa=0x2fcb84`) exit with `exitRa=0x1`, `fork=B(mid-body-clobber)`, at adjacent stack addresses `0x1ffbec0`/`0x1ffbee0` (only `0x20` apart) — same clobber, aliased across two frames. - **IMBAL** on `0x1baf90`: `delta=-0x20`, `[0x5616d8]=0x20561600`, **`countByte=0xffffffff`**, `a0=0x20561900` (the same SIF RPC packet buffer seen in the preceding `sceSifSetDma`/`SifRpcPkt:SIG` lines), `a2=0x178560`, `a3=0x20561600`. `countByte=0xffffffff` (i.e. -1 unsigned) is a strong signal that whatever byte-derived count/index this function computes from `[0x5616d8]` is garbage — consistent with a bad/OOB pointer read feeding an unbounded copy or index, not a hand-tuned small overrun. - **Conclusion:** the "second, untracked writer" is real and is upstream of `0x178068`; best current lead is `0x1baf90` and/or its callee chain (`0x178de8`, `0x178be8`, `0x178428`) reading a garbage count from `[0x5616d8]` and using it to write out of bounds into the `0x1ffbe.0`-`0x1ffbee0` stack region. **Next step: decompile/re-examine `0x1baf90` and `0x178de8` for any indexed/counted `sq`/`sw` loop keyed off a byte or word near `[0x5616d8]`**, analogous to the retracted `0x178068` hypothesis but now pointed at the right function. Do not re-chase `0x178068` itself as the writer — it is confirmed a victim, not the source, in this run. | No `RAFORK ... fork=B(mid-body-clobber)`; EE PC does not collapse to `0x1` | | 5.4 | Game data load chain | 🟡 **`0x1bf2e0`/`0x22c8f0`/`0x22cbe0` all build-VERIFIED fixed (2026-07-25g)** -- none appear in run_log.txt anymore, boot goes much further into the SIF RPC completion path. **Unfixed blocker, still narrowing:** 07-25g's `sub_178560` callback-`jalr` hypothesis was DISPROVEN by measurement (07-25h) -- that call's branch was skipped this crash, `0x178560` exits clean. Corruption re-traced into `0x178be8` (`rpc_call`)'s own SLOTWATCH/SLOTENTRY chain; 07-25i (SLOTENTRY probe, chronologically corrected) brackets the `$ra`-slot stomp to between `0x177eb0`'s entry (clean) and `0x178560`'s entry (dirty) -- inside `0x177eb0`'s body or an unwrapped intermediate call (`0x1781b0`/`0x175060`/`0x178068`/`0x175090`). No code fix applied yet -- see [[reference_ps2_sif_boot]] 07-25i entry. ~~07-25j: WRITER IDENTIFIED -- `pc=0x102894` ... is the ONLY PC that ever writes `0x1ffbeb0`.~~ **RETRACTED 2026-07-25k -- the entire stack-overlap premise is measured false, see 07-25k section. This stage's line of inquiry is CLOSED; the live fault moved to 5.4.1.** | Game proceeds past whatever it does with GAME.DAT contents | | 5.4.1 | Bad ctor function pointer (`0x30`) reaching an unguarded `jalr` in `array_call_ctor_dtor` | ✅ **CLEARED (2026-07-27, EIE gate run).** Exit test met: **zero `pc=0x30`** anywhere in the run sink (all 8 `MISS` records are `pc=0x1`), and the run's only derail is a single bracketed `$ra`-slot event. **Cause was not the `jalr` guard — it was the scheduler preempting the guest inside its own `di`/`ei` critical sections.** With the EIE gate honoured, the `0x30` fnptr never materialises; `0x171d04` was left unguarded and did not fire. Caveat on the record: the `0x30` and `pc=0x1` derails previously alternated non-deterministically, so "zero `0x30`" is one run; if `0x30` reappears after 5.4.2 is fixed, reopen this stage rather than re-deriving it. Original (now superseded) analysis:
~~414 derails, all `guest PC 0x30`, zero `pc=0x1` in the run. Trace tail: `... 0x171f10 -> 0x3a2f30 -> 0x3a2c60 -> 0x171c30` -> `0x30`. `0x171c30` = `array_call_ctor_dtor` (a `__cxa_vec_ctor`); its ctor `jalr $s5` at `0x171d04` is **not** null- or range-guarded (the dtor `jalr $v0` at `0x171cdc` IS null-guarded at `0x171cb0`). Immediate caller `0x3a2c60` passes valid args (`a1=0x3a2ef0`, `a2=0x3a2d20`, `a3=8`, `t0=2`), so the bad pointer arrives from elsewhere in the chain or from the frame scratch slots (`sp+0x94..0xa0`, held in `$s0/$s1/$s6/$s7`).~~ | No `guest PC 0x30`; static C++ array construction completes ✅ MET | | 5.4.2 | `$ra` slot `0x1ffbeb0` overwritten with `0x1` across the `0x175060`→`0x175090` stack switch | ✅ **CLEARED (2026-07-27, verified by build + run).** **Root cause: EE syscall `0x73` `SetVSyncFlag` was implemented as a *persistent* registration; it must be *one-shot*.** The guest SDK's `GsSyncV` (`0x1751c0`) registers `sp+0`/`sp+8` of its **own 0x20 stack frame** as the vsync flag/tick pointers, spins one vblank, then restores `$ra` from `16($sp)` and pops — **with no `SetVSyncFlag(0,0)` deregister path anywhere in the function** (verified by full disassembly). Our runtime kept the registration live, so the vblank IRQ worker (a plain host `std::thread`) stamped `1` into that abandoned frame on **every** vblank, forever, asynchronously. When `rpc_call` (`0x178be8`) later reused that stack depth, its saved `$ra` became `0x1`. This also explains the long-standing non-determinism: the derail depended on wall-clock racing between a host thread and guest stack reuse, which is why `-Determinism 1` never reproduced it. **Fix:** `signalVSyncFlag` now clears `g_vsync_registration.flagAddr/.tickAddr` under the existing mutex immediately after capturing them (`Kernel/Syscalls/Interrupt.cpp`). Blast radius checked — `g_vsync_registration` has no other consumer. **Evidence (before → after):** `signalVSyncFlag` writes to the watched slot every-hit → **1 of 64** (and that one is *correct* — it targets the live registration `0x1ffbf60`); VSYNCREG registrations `5` → **160**; end-of-run `progress` `0x67c` → **`0x1f39`**; `RASLOT` **6/6 `ok`** with `saved == entryRa`. Game now runs `GameUpdate_0x421ea0` / `EngineUpdate` → `SyncFrame` → `GS_DispatchPending` on a fiber for 48 s with `progress` climbing. Method note: found with a **hardware data breakpoint** (DR0/DR7 + VEH + dbghelp symbolization, `game_overrides.cpp`) filtered to `newval==1` — the first tool in this investigation that named a writer instead of a suspect. ~~Superseded analysis: The sole surviving derail once the EIE gate removed the noise. One event, one thread (`tid=0x13a6`), progress `1732`, zero progress ticks between clobber and consumption. Bracket is now **one step wide**: `SLOTENTRY seq=276 callee=0x175060 entrySp=0x1ffbd70 before=0x1bb0b0` (clean) → `seq=277 callee=0x175090 entrySp=0x1ff3cd0 before=0x1` (dirty). `$sp` drops ~`0x8000` between those two — a stack switch. Then `RASLOT seq=279 slot=0x178be8 at=0x1ffbeb0 saved=0x1 entryRa=0x1bb0b0 entrySp=0x1ffbec0 liveRa=0x1`, then `MISS seq=280 pc=0x1` ×8. **Both `saved` and `liveRa` are `0x1`**, so the epilogue read the poisoned slot — this is a frame being read from the wrong place, not a stray store. The code that runs inside the bracket is **`0x178068`**, which is exactly the already-recorded [[project_stale_frame_ra_read]] mechanism: `0x178068` dispatched with stale `ctx->pc=0x175064`, missing its entry switch, falling through the prologue and exiting mid-body without restoring `sp`. The recorded open question — why is `ctx->pc` stale? — is now the whole blocker.~~ **That framing was wrong: the writer was never guest code at all, and `ctx->pc` was never stale. Do not re-open the `0x178068` stale-pc line of inquiry.** | No `RASLOT ... bad`; no `MISS pc=0x1`; `0x178068` enters at its own entry `pc` ✅ **MET** (6/6 RASLOT `ok`, zero `MISS pc=0x1`) | | 5.5 | Memory card IOP stack responds | 🔵 **ACTIVE — but entry state must be baselined first (2026-07-27).** Post-5.4.2 the game reaches a **stable frame loop and holds it**: from `t=5s` to `t=48s` the watchdog sits at `pc=0x421f10` (a `jal 0x199840` site inside the per-frame function `0x421ea0`–`0x421f40`, which ends in `jr $ra` — so this is a *called-every-frame* body, not a spin), with `progress` climbing **~115/sec, monotonically, for 43 s straight**. `lastCall=0x172998` is a **timeout-guarded busy-wait**: `$s1 = 0x1000000` countdown polling `*(a0) & 0x100`, with `jal 0x177988` (message at `0x4c01a8`) + `jal 0x173130` on expiry — i.e. the frame loop is waiting on a hardware-busy bit each frame, which is normal, not a hang. `stuckSecs` is a **false positive** by construction (pc is pinned to a jal boundary while progress advances). Run health is otherwise clean: exactly **one** unimplemented syscall in 5145 log lines (`0x6b` at `PC=0x174f70`, 1 hit), zero errors. MCMAN/MCSERV both `Load Module ... OK!` but **no memcard RPC is ever issued**, so the 5.5 exit test cannot be evaluated yet. Disc activity stops early (`[ARKD:cdread]` 8 hits, all before log line 2107 of 5145) while `[SifRpcPkt:SIG]` continues to line 4808 — the game is *running*, not *loading*. **First task: a clean baseline run** with the now-obsolete 5.4.2 diagnostics off (`PS2X_TRAPVAL=0`, `PS2X_HWWATCH_VAL` unset, `PS2X_FRAMETRACE=0` — all env-gated, no rebuild). The old `trapval` watch on `0x1ffbeb0` fired **189 times with ~60-deep stack captures**; that address is dead post-fix and the capture cost is a large, pointless perf drag on exactly the frame-rate measurement we now need. Baseline must answer: (a) what is on screen, (b) what frame rate, (c) does the game state ever advance — before assuming memcard is the gate rather than a render/GS gap. ~~MCMAN/MCSERV load, HLE handlers exist, never exercised.~~ **Planned pivot point: take upstream #170+#179 here** — #179 ships a memcard IOP stack that supersedes our HLE handlers; the re-architecture is worth doing once the ARKD blocker is closed and memcard IOP is the active target (see Upstream Sync #179 note). | Memcard init RPCs complete without error | | 5.5.1 | libpad ↔ padman version handshake fails → **pad init is skipped entirely** | 🔵 **ACTIVE (opened 2026-07-27).** The only game-emitted error in a 5145-line run: `[Deci2Call:kputs] libpad: Module version mismatch [libpad.a = 4.0, padman.irx = 0.0]`. **This is NOT cosmetic — it is a hard early-out, proven by disassembly of `0x187d10`–`0x187d94`:** `jal 0x189110` (get padman version) → `$s1 = $v0`; `sra $s0, $s1, 8` (major); `beq $s0, 4, 0x187d78` → the **only** path that reaches the real init `jal 0x187de0`. On mismatch it prints the two messages and falls to `0x187d70`/`0x187d74` → `$v0 = 0`, returning **without ever initialising the pad**. Version source (`0x189110`): builds an RPC — cmd `0x12` into send buf `0x568b80`, `jal 0x178be8` (`rpc_call`) with `ssz/rsz = 0x80` — then reads the version from **`lw $v0, 12($s0)`**, i.e. *recv buffer + 0xC*. On `rpc_call < 0` it returns `0` (`bgezl` at `0x189158`). **Root cause: there is no IOP-side padman RPC server in our runtime at all.** `Kernel/Stubs/Pad.cpp` has ~30 EE-side `scePad*` stubs, but grep shows zero padman/SIO2MAN IOP RPC handling — and the module never loads: the run's Deci2 tty shows exactly **five** `Load Module ... OK!` lines (MCMAN, MCSERV, LIBSD, CRI_ADXI, ARKD_DVD) with **no PADMAN and no SIO2MAN** (the game expects those from `rom0:`, which we do not provide). So the bind/call fails, recv+0xC stays `0`, major = `0` ≠ `4`. Note `scePadGetModVersion` in `Pad.cpp:420` returns `0x0200` — **major 2, which would fail this same check even if it were on this path**; if a fix routes through it, it must report major **4** (e.g. `0x0400`). **Confidence split — do not conflate:** *pad init is skipped* is **code-proven**. *This is what holds the black screen* is **plausible but unproven** — the memcard prompt is X-gated (see [[reference_iop_bug_log]]), so an uninitialised pad is a credible stall, but no measurement yet ties the frame loop's wait to pad state. Verify before declaring, per the 5.4.2 lesson. **★ 2026-07-27 PCSX2 GROUND TRUTH (live, paused at the memcard screen, SLUS-21442 / PCSX2 d75a0ad) — the sids are no longer a guess, they are literal in the code at `0x187cb0`–`0x187d24`:** `lui $a1,0x8000; ori $a1,0x100; jal 0x178a08` binds **sid `0x80000100`** with client data `0x568940`; `lui $a1,0x8000; ori $a1,0x101; jal 0x178a08` binds **sid `0x80000101`** with client data `0x568968` (= first client + `0x28`, so `sizeof(SifRpcClientData) == 0x28`). **Client-data offset `0x24` is the bound-server pointer** and the game *spins until it is non-zero* (`lw $v1,0x24($s0); beqz $v1, 0x187c88` and `lw $v1,0x4c($s1); beqz $v1, 0x187ce0`). Live PCSX2 read confirms both: `[0x568940+0x24] = 0x00182dac`, `[0x568968+0x24] = 0x00182e8c`. **Corollary — our binds already succeed.** Our run prints the mismatch, which is *downstream* of both spin loops, so the runtime's SIF layer is already claiming these binds generically; the failure is narrower than first written: **only the cmd-`0x12` call is unserved.** Version query uses client `0x568940`, i.e. **sid `0x80000100`**. The full version word is dead after the check (`0x187d78` passes only `$a0 = $s2`), so **returning `0x0400` is behaviourally exact**, minor byte irrelevant. **Scope warning — passing the check is stage 1, not the whole fix.** `0x187de0` (the real init, disassembled live) then: zeroes a 4-entry × `0x1c` state table, issues a **second RPC with cmd `0x10`** on the same client (`sw $v0,-0x7480($a1)` → `0x568b80`, `jal 0x178be8`, `bgez $v0` else return 0), then `jal 0x17ed60` and registers a SIF **CMD handler for `0x80000019`** with callback `0x187d98` — i.e. padman pushes pad data to the EE asynchronously over SIF CMD, it is not pure request/response. A working pad therefore needs cmd `0x12` + cmd `0x10` + the port/read commands + the `0x80000019` CMD push. Confirmed by grep that **nothing in `ps2xRuntime` references `0x80000100`, `0x80000101`, or any padman sid** — this subsystem does not exist yet. **★ PROBE IMPLEMENTED 2026-07-27 (built? NO — awaiting user build+run).** Deliberately scoped to cmd `0x12` only, to settle the unproven half of the confidence split above (does a dead pad actually hold the black screen?) before committing to the full subsystem. Three edits, all in `src/lib`, no header touched, no recompiler run: (1) `ps2_iop.cpp` `handleRPC` — new `sid == 0x80000100 && rpcNum == 1` branch; if send word[0] == `0x12`, zero the recv buffer and write `0x0400` at recv+`0xC`, set `resultPtr = recvBufAddr`, return `true`. Any other cmd logs `[iop:PADMAN] UNSERVED cmd=…` and falls through. Sid `0x80000101` logs only. (2) `Kernel/Stubs/SIF.cpp` — **bind recording made unconditional**. It was gated behind `PS2_ARKD_TRACE`, and since a CALL packet carries the rpc func in WORD[8] rather than the sid, without the client→sid table a non-ARKD service can never be identified at CALL time. Added a bounded `[SIF:BIND] client=… sid=…` census of every bind. (3) `Kernel/Stubs/SIF.cpp` `kSifCmdRpcCall` branch — new padman bridge that runs *before* the ARKD observe-only bridge: looks up the bound sid, and for `0x80000100`/`0x80000101` calls `handleRPC` **for real** (not observe-only), so the reply lands in the guest recv buffer before the echo synthesis signals the parked waiter. Send payload comes from `sifFindSendPayload(w[9], w[5])` with a fallback to the recv address (for this service send and recv are the same EE buffer, `0x568b80`). Logs `[PADMAN:CALL] … handled=0|1`. **Routing note — this was the non-obvious part:** the game's `rpc_call` (`0x178be8`) does **not** reach `ps2_iop::handleRPC` via the `sceSifCallRpc` syscall (`RPC.cpp:1934`); it rides the SIF CMD packet echo path in `SIF.cpp`, where the only pre-existing bridge was ARKD-gated and observe-only. A handler added to `ps2_iop.cpp` alone would never have fired. Verified the reply survives: `RPC.cpp:2526` only copies `resultPtr`→`recvBuf` when they differ, and only zeroes recv when `!handled`. **Expected outcomes, all informative:** no `[SIF:BIND] sid=0x80000100` line ⇒ the binds do *not* ride this path and the PCSX2-derived corollary above needs revisiting; `handled=1` + mismatch message gone ⇒ probe worked, read how far the boot then gets; mismatch gone but no visible change ⇒ pad was **not** the black-screen gate, and 5.5.1 should be demoted rather than expanded. **★★ PROBE RUN 2026-07-27 — 5.5.1 EXIT TEST PASSED.** Both conditions met, measured not inferred. (a) `[SIF:BIND] client=0x568940 sid=0x80000100` and `client=0x568968 sid=0x80000101` appear in the log at **exactly the client addresses read live from PCSX2** — the binds do ride the SIF CMD echo path, and the PCSX2-derived corollary is confirmed rather than revisited. (b) `[iop:PADMAN] version query served … version=0x0400` + `[PADMAN:CALL] client=0x568940 sid=0x80000100 func=0x1`. (c) `Module version mismatch` — **0 hits** in the whole run (was the only game-emitted error). (d) Proof the version check was passed and control entered the real init: `[iop:PADMAN] UNSERVED cmd=0x10` — cmd `0x10` is issued *only* from `0x187de0`, which is reachable *only* via the `beq $s0,4` at `0x187d38`. **Boot advanced materially.** Previously PC was pinned at `0x100008` cycling the SIF-RPC dispatcher. Now at t=45s: `pc=0x421f10`, `progress=7191`, `stuckSecs=0–1`, and the watchdog trace is app-level game code (`0x2ca270 → 0x1c22e0 → 0x19e620 → 0x1bdfb0 → 0x23fd20 → 0x240780`), with `0x421f10` sitting in the `CApp*` band (cf. `CAppCopyRight_0x4206c0`, `CAppCRISofdec_Tick_clone_02_0x420780`). **Still unproven:** whether the pad was the black-screen gate — the boot moved, but no on-screen change has been confirmed by the user yet. Do not upgrade that claim without a visual observation. **→ 5.5.2 opened** (cmd `0x10`). | **5.5.2 — padman init (cmd `0x10`)** | ⬜ IMPLEMENTED 2026-07-27, **built? NO**. Reply convention now established from three call sites: **`recv+0xC` is the single return slot, `1` = success** — `0x189110` (cmd `0x12`) returns it, `0x187de0` (cmd `0x10`) returns it, `0x187ed8` (cmd `0x0F`) compares it against `1`. `scePadInit` (`0x187cb0`) returns whatever `0x187de0` returns, so `recv+0xC = 1` makes init report success. Send layout for cmd `0x10`: `+0x00 = 0x10`, `+0x10 = 0`, `+0x14 =` global read from `0x464E14`. One edit, `ps2_iop.cpp` only — the cmd-`0x12` special case was generalised into a `switch (cmd)` that fills a `reply` word (`0x12 → 0x0400`, `0x10 → 1`); unrecognised cmds still log `UNSERVED` and fall through. **No CMD-handler work needed on our side:** on a non-negative `rpc_call` the *guest* registers the `0x80000019` async handler itself (`0x187de0` → `jal 0x177de8` with `a0=0x80000019, a1=0x187d98`). **HLE-vs-IRX rule note:** the standing "never hand-write IOP outputs, run the real IRX" rule was written for `ARKD_DVD` (a disc-format driver whose logic matters). Padman drives SIO2 controller silicon that does not exist on the host, so there is nothing to interpret and pad state must come from the host gamepad regardless — HLE is the correct layer here, not a shortcut. Recorded explicitly so a later session does not read this as a rule violation. **Exit test:** `scePadInit` returns 1; no `UNSERVED cmd=0x10`; the next unserved padman cmd is revealed by the log so the port/read commands can be built against real traffic. **★ RUN 2026-07-27 — 5.5.2 EXIT TEST PASSED.** `[iop:PADMAN] served cmd=0x10 (init): recvBuf=0x00568B80 recvSize=128 reply=0x1`, no `UNSERVED cmd=0x10`, and the next unserved command was revealed: **`UNSERVED cmd=0x1`, repeating**. Watchdog at t=147s: `pc=0x421f10`, `progress=18279`, `stuckSecs=0` — the game is *running*, not hung; `progress` climbed 7191 → 18279 between runs, so this is a live loop, not a spin. **Screen unchanged (user-observed): magenta briefly, then black.** ⇒ **The pad is NOT the black-screen gate at this depth.** The 5.5.1 "plausible but unproven" claim is now measured and **negative for the current stage** — clearing version + init alone changes nothing visible. It stays open only in the weaker form: the pad may still gate the *memcard prompt* (X-press) further downstream. Do not spend more on the pad than the next command costs without a second reason. **→ 5.5.3 opened** (cmd `0x1`). | **5.5.3 — padman port open (cmd `0x1`)** | ⬜ IMPLEMENTED 2026-07-27, **built? NO**. Issuer identified by scanning the ELF for `sw ?, -29824(?)` (the `0x568b80` send-buffer store, encoding mask `0xFC00FFFF == 0xAC008B80`, 14 hits) and disassembling each: **`0x1880e4` = `scePadPortOpen`**. Confirms the send/recv protocol is a shared `0x80`-byte buffer at `0x568b80` on client `0x568940` for *every* padman command, so the `switch (cmd)` shape is the right one. Send layout (`0x188180`–`0x1881a0`): `+0x00 = 1`, `+0x04 = port`, `+0x08 = slot`, `+0x10 =` EE-side user buffer. **Reply needs two words, not one** — the first command where `recv+0xC` alone is insufficient: `0x188230 lw $v0,12($s0)` is the return value, **and `0x188204 lw $t0,20($s0)` reads `recv+0x14`**, which is stored into the pad state table at `+8` (`0x188220 sw $t0,8($a3)`; table base `0x568990`, stride `port*0x70 + slot*0x1c`). **What that word is:** `scePadRead` (`0x187f68`) loads it back (`0x187ffc lw $s0,8($v1)`) and uses it as the **`sceSifSetDma` *dest*** for a `0x20`-byte **EE→IOP** transfer, indexed as a double buffer (`andi $v0,$a2,1; sll $v0,5; addu $s0,$s0,$v0` — so `0x40` bytes total, alternating on a counter kept at EE `[state+4]`). Descriptor order verified against `Ps2SifDmaTransfer` (`SIF.cpp:62`: `src, dest, size, attr`) — `sp+0 = src = EE`, `sp+4 = dest = IOP`, so it is genuinely EE→IOP, a *request* push, not a data pull. Therefore `recv+0x14` must be non-zero and plausible; the EE never reads it, and our `sceSifSetDma` already accepts non-EE dests and skips the copy (`SIF.cpp:1576`). One edit, `ps2_iop.cpp` only: `case 0x1u` writes `reply = 1` at `recv+0xC` and `recv+0x14 = 0x001E0000 + port*0x100 + slot*0x40` (distinctive base so it is unmistakable in SIF DMA logs); generalised the reply writer with a `haveReply14` flag. **Exit test:** no `UNSERVED cmd=0x1`; `served cmd=0x1 (portopen)` twice (the two ports); and the log reveals the next unserved padman cmd. **Known next piece (not this stage):** actual per-frame pad state. The real padman pushes it asynchronously over SIF CMD `0x80000019` into the handler the guest already registered (callback `0x187d98`); nothing in our runtime emits that yet, so buttons will still read as idle after 5.5.3. **★ RUN 2026-07-27 — 5.5.3 EXIT TEST PASSED, and the padman thread is now DRAINED.** `served cmd=0x1 (portopen) … reply=0x1 iopbuf=0x001E0000` and a second `… iopbuf=0x001E0100` (ports 0 and 1), no `UNSERVED cmd=0x1`. **Critically, there is no new `UNSERVED cmd=` line at all** — the guest issues `0x12 → 0x10 → 0x1 → 0x1` and then stops asking. So the padman request/response chain is complete as far as the game drives it; the only remaining pad work is the async `0x80000019` push, which the game does not block on. **Screen still unchanged (magenta → black).** Combined with 5.5.2's negative, this closes the pad line: **the pad is not the black-screen gate, at any depth reachable by serving commands.** 5.5.1/5.5.2/5.5.3 are ✅ done as *subsystem* work and ⛔ **demoted as blocker candidates** — do not open a 5.5.4 for pad state unless a downstream stage (memcard X-press) specifically demands it. **→ Next investigation is NOT padman.** The frame loop at `pc=0x421f10` runs healthily (`progress` climbing, `stuckSecs=0`) yet nothing reaches the screen, so the live question is a **render/GS gap or an unfinished load**, not an input gap. Two parked leads inherit priority, in this order: (1) **ARKD disc I/O stops ~t=20s** (`[ARKD:cdread]` 8 hits, all before log line 2107 of 5145) while SIF traffic continues — the game may be waiting on data that never arrives; (2) **unimplemented syscall `0x6b` at `PC=0x174f70`** (1 hit, the run's only unimplemented syscall). **★ BOTH PARKED LEADS TRIAGED AND DISMISSED 2026-07-27 (same day, before any build).** (1) **"ARKD disc I/O stops ~t=20s" is a LOGGING ARTIFACT, not a stall — retract it.** `[ARKD:cdread]` is capped at 8 lines by `if (seen < 8u)` (`ps2_iop_irx_loader.cpp:644`) and `[ARKD:sifdma]` by the same cap at `:751`; `[ARKD:search]` is capped at 16 (`:620`), `[ARKD:strtol]` at 6 (`:702`). The reads never stopped, the *printing* did. Positive evidence ARKD stays healthy to the end of the run: `fno=0x101` (open-by-name) keeps arriving with real asset paths — `font/5-24-2.fon`, `ply/exthit.hit`, then a run of `eff/e00/e000{12,13,14,16}.ebz` — each one running the IOP worker to a clean `halted=1` (`retired=1201/1251/1301/1680`), with monotonically advancing EE destinations (send word[0] = EE dest: `0x0167dc00`, `0x0167ec00`, `0x0167fc00`, `0x01680c00`). ARKD's last line is 2973 of 3637. **The disc/streaming pipeline is working.** (2) **`0x6b` dismissed:** `0x174f70` is one entry in a flat syscall-trampoline table (`addiu $v1,N; syscall; jr $ra` repeated for `0x66,0x6b,0x6c,0x6d,0x6e,0x6f,0x70…`), `0x6b` falls in a documented gap in the EE syscall table (nothing between `0x64` FlushCache and `0x70` GsGetIMR), and it fires **once** — it cannot be what holds a frame loop. **→ 5.6.1 opened: the render path itself.** | **Blocks 5.5**: MCSERV's sid `0x80000080` (`IOP_SID_MCSERV`, `ps2_iop.h:17`) is **never bound** this run — only game-private ARKD sids `0x500`–`0x503` appear — so `handleMcServRpc` (`ps2_iop_mcman.cpp:87`) is ready and never reached, and 5.5's exit test cannot be evaluated until the game actually asks for the memcard. | `scePadInit` reaches `0x187de0`; no `Module version mismatch` in the log | | 5.6 | Prompt renders + accepts input | ⚪ not reached | Prompt on screen, X press advances it | | 5.6.1 | Does anything reach the GS at all? (DMA-kick census) | 🔵 **ACTIVE (opened 2026-07-27).** Everything upstream of the screen now measures healthy — boot derail cleared (5.4.2), pad chain drained (5.5.3), disc streaming alive to end of run (see 5.5.3 triage) — yet the screen is magenta then black. **The blind spot is that the run log contains zero render-side output:** a tag histogram of all 3637 lines yields 45 distinct tags and not one is GS/GIF/VIF. So "the render path is broken" is currently an assumption, not a measurement. **What the frame loop actually does each frame** (disassembled, not inferred): `0x421ea0`–`0x421f40` is the per-frame body; within it `0x172e20` = **`sceDmaSend`** — it physical-maps the packet (`jal 0x172a90`), calls `0x172998` = **`sceDmaSync`** (countdown of `0x1000000` polling `*(chcr) & 0x100` = CHCR.STR, with `libdma: sync timeout` at string `0x4c01a8` on expiry), writes the packet address to `chcr+0x30` (TADR), zeroes `chcr+0x20` (QWC), then stores `(chcr & ~0xC) | 0x105` = **STR | chain-mode | from-memory**. **`libdma: sync timeout` has 0 hits in the run**, so STR *is* clearing — DMA is not deadlocked, which is itself a useful negative. **Probe (implemented 2026-07-27, `ps2_memory.cpp` only, no header touched, no recompiler run):** a bounded env-gated `[dmakick]` census at `PS2Memory::writeIORegister`'s single convergence point for channel starts (`address >= 0x10008000 && < 0x1000F000`, `(address & 0xFF) == 0`, `value & 0x100`). Logs channel base, CHCR, mode, dir, MADR, QWC, TADR, plus `vram/cb/arb` — the three flags that decide whether a GIF/VIF1 kick is actually drained (`m_gsVRAM` null skips the whole block; GIF needs `m_gifPacketCallback \|\| m_gifArbiter`). Prints the first N kicks then a heartbeat every 1000. **Why a counter and not `PS2X_TRAPVAL`:** the existing value-trap matches the stored word *exactly*, and libdma ORs `0x105` into whatever bits the channel already carried, so a trap would silently miss. Arm with `PS2X_DMATRACE=40` (unset ⇒ one relaxed load per kick, no output). **Exit test / decision tree:** *no `ch=0x1000A000` or `0x10009000` kicks at all* ⇒ the game is not submitting display lists — walk back up from `0x421ea0` to find what gates it. *Kicks present with `vram=0` or (`cb=0` and `arb=0`)* ⇒ packets are built and dropped on the floor; the fix is runtime wiring, not guest logic. *Kicks present with all three flags 1* ⇒ packets reach the rasterizer and the fault is inside GS/VU1 — a different and much narrower search. **★ CENSUS RUN 2026-07-27 — branch 1 is DEAD, the render path is alive.** 40-line `[dmakick]` grep: **#0–#31** GIF (`ch=0x1000A000`, `chcr=0x00000101`, mode=0 normal, dir=1) alternating `madr=0x01FFBDF0 qwc=6` (register setup) with `madr=0x0089D400 qwc=16384` (a 256 KB texture/VRAM upload), repeating. **#32 onward** switches to `mode=1` (**chain**) with real `tadr` (`0x00503310`, `0x00503270`), interleaved with **VIF1** (`ch=0x10009000`, `chcr=0x1C5`/`0xF00001C5` = STR\|chain\|TTE\|TIE, `tadr=0x00771400`/`0x007D5400`). That is a genuine per-frame display-list submission loop. ⇒ **"the game is not submitting display lists" is eliminated.** `vram/cb/arb` were never read — console truncated the pasted lines at ~110 chars, clipping the trailing fields (terminal artifact, not missing output); superseded by `gif/s` below, do not re-run that grep. **★ NEW ANOMALY — the rate, not the presence.** The probe prints the first N kicks then a heartbeat every 1000th; the log contains **no heartbeat line**, so the run did **fewer than 1000 DMA kicks in 70+ seconds**. Watchdog at t=70s: `pc=0x421f10 ra=0x421f10 lastCall=0x172998 (sceDmaSync) stuckSecs=29 progress=10266`. A frame loop that runs but grinds — or a stall inside the sync spin; those two readings disagree and must not be resolved by argument. **★ PCSX2 REFERENCE BASELINE (user-measured 2026-07-27): real PCSX2 reaches the memcard auto-save in 7–8 seconds.** At ~60 Hz NTSC that is **≈420–480 frames** (*derived from the standard vblank rate, not measured* — what was timed is a wall-clock milestone). Our run was still not there at t=70s ⇒ ~10× over budget with no arrival. At the ~2 chain kicks/frame the census showed, <1000 kicks/70 s ≈ <500 frames — i.e. roughly the *right amount of work* spread over 10× the wall clock. **★ FRAME-RATE INSTRUMENTATION WRITTEN, NOT BUILT, NOT RUN** — see [[project_framerate_instrumentation]]. Two edits in `ps2_runtime.cpp` only (no header, no recompiler): the existing 1 Hz `PS2_PC_WATCHDOG` thread now deltas four pre-existing public counters and prints `dma/s gif/s vif/s gsw/s gifTot` on the `[watchdog]` line, before the long `trace=` field so truncation cannot clip them. **`gif/s` is the load-bearing number** — it counts packets *actually handed to the rasterizer*, not packets kicked. **Read it as a trend across `t=` samples, not a single value:** flat-low ⇒ genuinely slow (find what eats the time); **decaying to 0 while `stuckSecs` climbs** ⇒ the loop entered `sceDmaSync` and never came out (CHCR.STR never clearing — a different bug); `gif/s`=0 while `dma/s` climbs ⇒ packets queued then dropped at the GIF-only drain gate, [ps2_memory.cpp:1723](ps2xRuntime/src/lib/ps2_memory.cpp#L1723) — runtime wiring, not guest logic (this case also settles the clipped `cb`/`arb` question). Caveat: `vif/s`/`gsw/s` count *register writes*, far higher frequency than frames — never read them as a frame rate. | A `[dmakick]` census exists; VIF1/GIF kick counts and the vram/cb/arb flags are known ✅ **MET** (census captured; branch 1 eliminated) → **superseded by: is the frame rate slow or stalled? (`gif/s` trend)** | Legend: 🔵 active · ⚪ not started · ✅ passed exit test · ⛔ blocked **Notes on the tracker:** - 5.1 CLOSED 2026-07-22: the real derail (escape to `0x20561900`) is gone; the `IMBAL` exit test was a false-positive signal (normal jal-split dispatch). Do not re-chase `delta=-0xa0` — it is expected. - 5.2 CLOSED as N/A: the imbalances proved benign, so there is no prologue-skip derail class to fix. Reopen only if an ALARM/TABLE escape reappears. - 5.6: stalling **at** the prompt is SUCCESS. It is input-gated on real hardware (X press), not a hang. - **5.4–5.6 are named destinations, not mapped routes.** Real PCSX2 has 31 IOP modules loaded by that screen; we load 7. That gap may hide sub-phases only discoverable by reaching them. Expect 5.4 to split. - **2026-07-26: a parallel METHOD track is now in force alongside 5.4.1** — plan `gentle-launching-dusk.md`, session entry immediately below. Phases A (determinism) and B (JSONL probe sink + `analyze_run.py`) are done and passed; C (invariant guards) is code-complete and awaiting a build. Diagnose 5.4.1 **through that tooling**, at `-Determinism 0`, not with new hand-written log greps. **Tracker maintenance rules (user-directed 2026-07-20g):** 1. **Notify the user when a stage passes its exit test** — state the stage number and the evidence. 2. **Notify the user when a stage needs to extend.** If new information subdivides a stage, add decimal children (`5.1.1`, `5.1.2`) and break down further as needed. Do not silently widen a stage's scope. 3. Update the State column and the `🔵 ACTIVE` marker as work moves. 4. A stage is only ✅ when its exit test is verified against real build/run output — never on code-complete alone. ## Session 2026-07-29 (co-piolet) — ⚠️ CORRECTION: `0x421ea0` is NOT a spin loop and is NOT 0xa4 bytes. Stale-worktree analysis retracted; 5.7's step (2) premise was wrong. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — read-only address resolution against `F:\SDBZ Recomp\Logs\studio_callgraph.csv`. **What happened.** A watchdog paste (t=57–59s, `busy%=44–61`, `vbl/s=48–50`, `gif/s≈48`, `dma/s≈96`, `progress` +~12 000/s, `gstate@0x50227c` byte-identical for 22 s, `stuckSecs=20→22`, `pc=ra=0x421f10`, `cputime` 97.2 % of one core) was analysed in a **stale git worktree** (`F:\SDBZ Recomp.worktrees\watchdog-performance-analysis`, HEAD `c68adaf4` = 2026-05-27). That worktree's `CSV Map\map.csv` claims `fn_421EA0` spans `0x421EA0–0x421F44` (0xA4 bytes), which led to the conclusion "`0x421f10` is +0x70 into a 0xa4-byte function, `pc==ra` ⇒ tight self-contained loop, that is the CPU sink." **That conclusion is wrong and is retracted in full.** **Measured correction** (from `Logs\studio_callgraph.csv`, the reconstructed function map — authoritative, `caller,callee,address`): - `sub_00421EA0` has call sites out to **`0x0042213c`**; the next function is `sub_00422170`. Real extent ≈ **`0x421EA0–0x422168`**, ~0x2C8 bytes — **not** 0xA4. The worktree `map.csv` boundary is stale and must not be trusted for this address (or, by extension, anywhere in the 0x42xxxx region). - `0x421f10` is **`jal sub_00199840`** — one entry in a straight-line run of 14 consecutive `jal`s at `0x421ebc, ec4, ed0, edc, ee8, ef0, ef8, f00, f08, f10, f18, f20, f28, f30` (targets `0x199800, 0x1aedb0, 0x327f90, 0x3280c0, 0x328160, 0x2fcc00, 0x1bad10, 0x1bb450, 0x1997e0, 0x199840, 0x1bfb00, 0x1bddc0, 0x1bda00, 0x1bf3a0`). That is a **subsystem-update fan-out**, i.e. the per-frame tick driver — exactly what the 5.5 note at the tracker already said. There is no loop instruction at `0x421f10`. - Sole caller: **`sub_00422630` at `0x422658`** — one call site, consistent with a main-loop body. - The pasted `trace=` tail corroborates this and nothing else: `0x1bb450 -> 0x1bbdf0 -> 0x177ab8 ... -> 0x1997e0 -> 0x19ae60 -> 0x104af0 -> 0x421f10 -> 0x199840 -> 0x104c00 -> ...` walks the fan-out **left to right in call-site order** (`0x1bb450` @f00, `0x1997e0` @f08, then `0x421f10`/`0x199840` @f10). The frame driver is *progressing through its call list*, not spinning. **Consequence for the 5.7 plan.** Step (2) of 5.7 as written — *"disassemble `0x421f10` — it is `pc` and `ra`, a tight self-loop"* — is **based on a false premise and should not be actioned as stated.** `pc==ra==0x421f10` is a **sampling artefact**: the fiber/dispatch loop parks `ctx->pc` at the return address of the `jal` it is currently inside, and because `0x421ea0` is re-entered every frame at ~48 fps the watchdog lands on the same `jal` boundary essentially every sample. Line 70 of this file (stage 5.5, 2026-07-27) **already recorded this correctly** — *"a `jal 0x199840` site inside the per-frame function … so this is a called-every-frame body, not a spin"* and *"`stuckSecs` is a **false positive** by construction"*. **The 5.7 wording at line 52 contradicts 5.5 and 5.5 is the correct one.** Corrected inline; see the 5.7 entry. **Therefore the 97.2 % CPU burn needs a different explanation than "guest spin at `0x421f10`".** Do not re-derive it from the `pc` field. The two live threads in the `cputime` dump (`24336` = 33.16 s, `22232` = 27.77 s, each ≈ half of `wall=66.07s`) are consistent with **runtime host threads** — the fiber executor plus the vblank/IRQ worker — not with one guest hot loop. `busy%=44–61` is itself the runtime's own on-CPU accounting and is *below* 100 %, which contradicts a guest spin as the whole story. **What is actually still true and unchanged from 5.7:** the guest renders every frame (`vbl/s`/`gif/s`/`dma/s` nominal) while `gstate@0x50227c` never changes and `bsschg=7–16` — the state machine does not advance. **That** is the blocker. `lastCall=0x172998` (`DMAC_WaitIdle`, the `0x1000000`-countdown poll on `*(a0) & 0x100` with the `0x4c01a8` error message on expiry) is a per-frame timeout-guarded busy-wait and is normal, per 5.5. **Prior-art check (requested):** searched `_ClaudeMemory\`, `Logs\`, `Session Summary Logs\`, `Solutions Log\`, `INDEX_INDEX\` for `0x421f10`/`0x421ea0` — **no doc hits**; only `Logs\studio_callgraph.csv` and two 04-29 debug logs. The nearby `0x421c84` **was** worked before and is written up in `ps2xRuntime\src\lib\game_overrides.cpp` (`sdbzRegisterHandler104BF0`, the 2026-07-19 `pc=0x104bf0 ra=0x421c84` boot hang) — same caller region, different mechanism (recompiler truncation), not related to this. **Housekeeping / traps found:** - The worktree at `F:\SDBZ Recomp.worktrees\watchdog-performance-analysis` is **~2 months stale** (2026-05-27) and its `_ClaudeMemory\project_state.md`, `CSV Map\map.csv` and `ghidra_scripts\labels.csv` all predate the Ghidra map reconstruction of 2026-07-21. **Any analysis done from that worktree must be re-checked against `F:\SDBZ Recomp` before it is believed.** It also still names `C:\SDBZ Recomp` and `Laptop_Build.ps1` as the build path; neither exists. Live root is `F:\SDBZ Recomp`, build is `build.ps1`, run is `launch_recomp.ps1`. - Corollary rule for the Learned Patterns list: **`pc == ra` does not imply a loop.** In this runtime it is the normal steady state at any `jal` boundary. A loop claim requires the actual instruction at that address, or a function whose measured extent makes a backward branch possible — and function extents must come from `Logs\studio_callgraph.csv`, not `CSV Map\map.csv`. ## Session 2026-07-29 (co-piolet, part 2) — ⚠️ SECOND CORRECTION: `gstate@0x50227c` is 4 unrelated static singleton pointers, not a game-state struct. "Frozen `gstate`" is not evidence of a stall. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — read-only: `ps2xRuntime\src\lib\ps2_runtime.cpp` (watchdog implementation) + `decompiled_dump_full.txt` (full Ghidra decompile of `SLUS_214.42`, present only in git history at commit `eabd4112`, retrieved via `git show eabd4112:decompiled_dump_full.txt`). **What `gstate=` actually is.** The watchdog's default watch address is `0x005e6b3c` (`ps2_runtime.cpp` ~line 2913, the community "GameMode" byte), overridable via the `PS2_WATCH_ADDR` env var. The `0x50227c` telemetry pasted this session was already running with that override — it was never the default and was never documented anywhere before now. At whatever address is watched, the probe (`ps2_runtime.cpp` ~lines 2973–2979) blindly reads **4 consecutive raw 32-bit words** and prints them as `gstate@ADDR=w0,w1,w2,w3`. It has no concept of struct layout — it is a flat memory dump, not a state-machine read. **What those 4 words are at `0x50227c` specifically** (found in `decompiled_dump_full.txt`): - `0x50227c` (`piRam0050227c`) — pointer to a **polymorphic C++ object**; called every relevant tick via `(**(code**)(*this+0x28))(this, dx*scale, dy*scale)` from `FUN_0034c040` with controller-stick-scaled deltas → best-guess **camera/field-pan controller**. - `0x502280`/`0x502284` (`iRam00502280`/`iRam00502284`) — a **paired pair of object pointers**, both tested with identical bitmask logic (`&0x10`, `&0x3d0` on fields `+0x22`/`+0x1e`) in the same conditional blocks → best-guess **Player 1 / Player 2** entities. - `0x502288` (`iRam00502288`) — used in `effect_spawn_colored(...)` calls and vtable slots `+0x10`/`+0x18`. **Confirmed static**, not just guessed: the literal assignment `iRam00502288 = 0x63fe40;` appears verbatim in the decompile, and `0x63fe40` is exactly `w[3]` in every line of the pasted telemetry. - Exhaustive search for any runtime write to any of the 4 addresses (`...227c =`, `...2280 =`, `...2284 =`, `...2288 =`) across the full 454k-line decompile: **zero hits.** These are `.data`-section pointers fixed once at init/link time and never reassigned. **Consequence.** Watching these 4 raw pointer *values* for 20+ seconds and seeing them "byte-identical" tells you nothing — they are singleton object pointers, expected to never change for the life of the process, the same way `this` doesn't change. The 5.7 conclusion "`gstate@0x50227c` never changes ⇒ the state machine does not advance" (line 108 above) is now **suspect and should not be relied on as-is**. It is the **second** false-positive diagnostic found in two consecutive sessions, after the `0x421f10`/`pc==ra` one above. A real liveness probe needs to **dereference** these pointers and watch a field *inside* the objects that is expected to tick every frame (e.g. whatever `piRam0050227c`'s vtable slot `+0x28` writes on the camera object, or the `+0x22`/`+0x1e` flag fields on the two paired objects) — not the pointer values themselves. **Not yet done / next step if picked up:** identify concrete in-object offsets that are known-dynamic (found by reading what the `+0x28` vtable function and the `+0x22`/`+0x1e` flag-check code actually touch), then re-run the watchdog with a probe that dereferences into the object rather than reading the raw pointer. Object identities (camera/P1/P2/effects) are inferred from code-pattern context only, not from an authoritative symbol/label source — treat as a strong hypothesis, not confirmed, unless cross-checked against Ghidra/IDA labels. The original 97.2%-of-one-core CPU burn from the same telemetry paste is still fully unexplained; no replacement theory exists yet. ## Session 2026-07-29 (co-piolet, part 3) — new `[hostprof]` capture confirms the CPU cost is GS pixel math (retracts "unexplained" claim in part 2); `gchg=0` window is consistent with the static-singleton theory, not new evidence of a stall. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — read-only analysis of a `[watchdog]`/`[hostprof]`/`[cputime]` capture the user pasted (t≈84–89 s window inside a `wall=96.09s cpu=108.53s =113%` run, `PS2_WATCH_ADDR` still overridden to `0x50227c`). **Correction to part 2 (line 130 above):** part 2 said *"the original 97.2%-of-one-core CPU burn … is still fully unexplained; no replacement theory exists yet."* **That is wrong — it was already explained** in the very next section of this file (2026-07-28 later / Claude Code, "Hot code is still GS pixel math…", line 152, and the RelWithDebInfo table above it). This new `[hostprof]` paste independently reconfirms it with fresh numbers: - tid 25544 (44.33 s CPU, 84.4% in `ps2EntryRunner`): dominated by `GSRasterizer::writePixel` (30.7%), `GS::WriteVram`, `GSMem::LookupPixelAddressCT32`, `GS::ReadVram`, `GSMem::ReadPixelAddressCT32`, `GSInternal::bitsPerPixel`, `GSMem::LookupPixelAddressZ32` — the software GS rasterizer doing real per-pixel work. - tid 1224 (39.44 s CPU): dominated by `GS::copyFrameToHostRgbaUnlocked`, `` `anonymous namespace'::countNonBlackPixels ``, `GS::latchHostPresentationFrameUnlocked`, `` `anonymous namespace'::blendPresentationChannel `` — the host presentation/compositing pipeline, already flagged as a ~12%-of-thread cost in the 07-28 section (line 180) and as a `hwWatchArm()`-style env-gate candidate. - **Conclusion: the CPU burn has never been a spin/bug at any point in this project's history.** It is legitimate software-rendering cost (worse in Debug, ~109× better in RelWithDebInfo per the A/B above). Stop treating it as an open question — it is closed and cross-confirmed by two independent captures on two different sessions. **`gchg=0` this window is not new information.** The paste still watches the override address `0x50227c` (four static singleton pointers, per part 2 above), and this window happened to start after whatever one-time init write sets them — so `gchg=0` here is exactly what the static-singleton theory predicts, not a fresh sign of a stalled state machine. **The recommended default-address (`0x5e6b3c`) rerun from part 2 has still not been done** and remains the next actual test. **Still true and unchanged:** `pc=ra=0x421f10`, `lastCall=0x172998` (`sceDmaSync`), `stuckSecs` climbing then resetting — the same plateau documented in the 07-28 section's "Next action" (line 166 above: *why does the guest never leave the `0x421f10`/`sceDmaSync` loop*). That is still the real open blocker, unaffected by anything in this entry. ## Session 2026-07-29 (co-piolet, part 4) — new capture shows **~9× faster `progress` and full 60 `vbl/s`** vs. the part-3 capture; this looks like a genuine full-rate frame loop, not a stall. Visual confirmation (pink screen still present?) is now the key open question. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — read-only analysis of a fresh `[watchdog]`/`[hostprof]`/`[cputime]` paste (`wall=96.14s cpu=103.06s =107.2%`, `PS2_WATCH_ADDR` still `0x50227c`). - **`progress` rate jumped ~9×:** t=81→94 (13 s) goes `5,319,065 → 6,446,535`, ≈86.7 k/s. The part-3 capture over a similar 5 s window (t=84→89) only moved ≈9.7 k/s. Same build, same watch address, same machine — the only explanation is the guest is doing far more real work per wall-second in this run. - **`vbl/s` is pinned at 60–61 and `busy%` at 99–101%** throughout this whole capture (vs. 36–86% / variable `vbl/s` in every prior capture this project has seen). A steady 60 `vbl/s` at ~100% busy is what a fully-running PS2 title's main loop looks like — one vblank serviced per real vblank, CPU saturated doing the frame's rendering work, not idling or blocked. - **`gstate@0x50227c` still frozen (`gchg=0` the whole window)** — expected and unremarkable per part 2/3 (static singleton pointers, not live state). - **`pc=ra=0x421f10` / `lastCall=0x172998` and `stuckSecs` cycling 0→9→0 are unchanged** — per the Learned Patterns entries above, this is the documented false-positive shape (dispatch-loop `jal` boundary + `pc`-stability heuristic), not evidence against the "real frame loop" reading above. The two readings are consistent: a steady per-frame fan-out revisits the same `jal` site every frame, which is exactly what a healthy 60 vbl/s loop would do. - **`hostprof` composition is materially the same as part 3** (`GSRasterizer::writePixel` still the #1 cost, `countNonBlackPixels` / `copyFrameToHostRgbaUnlocked` / `blendPresentationChannel` still active in the presentation thread) — so `countNonBlackPixels` is being called every frame, meaning the presentation path is actively checking real frame content, not a frozen/blank buffer. - **This is the strongest evidence yet that the boot is not hung at all in this capture** — it reads like a title's ordinary steady-state main loop under heavy software-rasterizer cost, not the earlier "stuck" narrative built around `stuckSecs`/`pc==ra`. - **Open question, needs the user's eyes, not more telemetry:** is the on-screen output still the "pink screen" described in Phase 5's blocker, or is real content now visible? `countNonBlackPixels` being hot suggests non-trivial pixel content is present, which would be new information Phase 5's blocker doesn't yet account for. **Next step: confirm visually** (screenshot/recording from this run) before revising the Phase 5 blocker status. ## Session 2026-07-29 (co-piolet, part 5) — ⚠️ CORRECTION: `[present]` probe cannot fire in any build to date — `RUNTIME_LOG` is compile-time disabled, `PS2X_DIAG=1` alone does nothing for it **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run — read-only investigation into why `Select-String -Path run_log.txt -Pattern "\[present\]"` came back with **zero matches** after the user set `$env:PS2X_DIAG="1"` and reran (per part 4's recommendation above, which was wrong). **Root cause found:** `RUNTIME_LOG(x)` ([ps2_log.h:107-126](ps2xRuntime/include/ps2_log.h)) expands to `do {} while(0)` — a total no-op, string literal and all — unless the CMake option `PS2X_ENABLE_RUNTIME_LOGS` is `ON` at **configure** time. That option ([ps2xRuntime/CMakeLists.txt:17](ps2xRuntime/CMakeLists.txt)) defaults `OFF` and has been `OFF` in `build\CMakeCache.txt` (confirmed by direct read) for the entire history of this build tree — never set anywhere in this repo (grepped, zero hits outside its own declaration). `PS2X_DIAG=1` only gates `ps2_diag::enabled()`, which the `[present]` probe checks to decide whether to *populate* the already-compiled-out macro — it has no effect when the macro itself was never compiled in. **Verified three ways, not just theorized** (per the Learned Patterns "zero-record probe" entry): exe mtime (1:33:33) > source mtime (1:27:44) > confirms build is current, not stale; `findstr /M /C:"[hostprof]"` against the 354 MB `ps2EntryRunner.exe` returns exit 0 (control marker present, findstr itself works); `findstr /M /C:"[present]"` and `findstr /M /C:"present] has="` both return exit 1 — the literal string is absent from the binary. This is airtight: the probe cannot have fired in ANY run analyzed this session (parts 1-4 above), because it was never in the exe to begin with. **Corrects part 4's recommendation.** The `$env:PS2X_DIAG="1"` rerun command given in part 4 was necessary-but-not-sufficient — it will never produce `[present]` output on the current build regardless of how long or how many times it's run. **Actual fix (requires a CMake reconfigure the user must run, per PROHIBITION #4 — never cmake directly):** ```powershell cmake -S "F:\SDBZ Recomp" -B "F:\SDBZ Recomp\build" -DPS2X_ENABLE_RUNTIME_LOGS=ON & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo ``` **Cost warning:** `RUNTIME_LOG` is used in ~22 TUs (`ps2_gs_gpu.cpp`, `ps2_runtime.cpp`, `ps2_memory.cpp`, `game_overrides.cpp`, several `Kernel/Stubs/*.cpp`, etc.) — this is a build-option flip, which CMake will treat similarly to a widely-included header change: expect a partial-but-wide recompile of every TU that includes `ps2_log.h`, not a quick relink. Not prohibited (it is not a clean build), but budget real time for it. **Still open:** the magenta→black visual question from part 4 remains unanswered — once this rebuild lands, rerun with `PS2X_DIAG=1` (now meaningful) and grep `run_log.txt` for `[present]` as originally planned. ## Session 2026-07-29 (co-piolet, part 6) — reconfirms part 5; two more captures pasted, no new root cause; **HANDOFF POINT** **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** No build, no run, no code change — read-only analysis of two more pasted captures plus a repeat `Select-String -Pattern "\[present\]"` (again zero matches, run against the still-unmodified build). **Finding 1 — `[present]` reconfirmed absent, CMake reconfigure from part 5 still not run.** The user's final command in this turn (`Select-String -Path run_log.txt -Pattern "\[present\]"`) returned no output again. This is expected and is **not a new bug** — it is the same root cause as part 5 (`PS2X_ENABLE_RUNTIME_LOGS` still `OFF`). Nothing changes here until the user actually runs the two-line reconfigure+rebuild given in part 5. **Finding 2 — `[hostprof]` again shows GS pixel math as the dominant cost, consistent with part 3.** Two worker threads: tid 23192 (45.80s CPU) is 85.1% inside `ps2EntryRunner`, top symbol `GSRasterizer::writePixel` at 30.3%/13.88s, with `GS::WriteVram`, `GSMem::LookupPixelAddressCT32/Z32`, `WritePixelCT32/Z32` filling out the rest — a real rasterizer, not a spin loop. tid 23676 (38.33s CPU) is dominated by `GS::copyFrameToHostRgbaUnlocked` (15.8%), `countNonBlackPixels` (6.2%), `blendPresentationChannel` (5.7%) — the host-presentation path, also real work. No new symbol or anomaly vs. part 3's writeup. **Finding 3 — one capture shows a sharp stall onset worth flagging for the next session.** In the t=81-90s window, `busy%` ran 57-74% (healthy) through t=89, then **t=90 shows `busy%=2, res/s=3, vbl/s=1, progress` frozen at 869787 (vs 869786 the tick before)** — i.e. the frame loop essentially stopped advancing in that single sample, with `pc/ra` still `0x421f10` but `lastCall` shifting to `0x1bda00` and the trace tail changing shape (`0x104c00 -> 0x102420 -> ... -> 0x1bddc0 -> 0x1bda00`, cut off mid-trace in the paste). This single-tick cliff (perfectly healthy → near-zero throughput in the same watchdog interval) is a different shape than the previously-documented gradual `stuckSecs` climb, and has not been analyzed before. **Not yet root-caused** — could be a real stall onset, a paste/output truncation artifact (the trace was visibly cut off at the end of the paste), or the watchdog printing mid-transition. Flagging only; no conclusion drawn. **Run-to-run variance persists.** The other capture in this turn (t=57-59s) again shows the older, previously-documented "stuck" shape (`busy%` 44-61%, monotonic-ish `stuckSecs` 20→22, no reset) — same `-Determinism 0` nondeterminism already logged in part 4/earlier sessions, not new. **No new BUG-0xx filed this turn** — findings 1-3 above are reconfirmations/refinements of already-open threads (part 5's rebuild-pending item, and the pre-existing GS-cost / magenta-black open question), not new distinct defects. If finding 3's single-tick cliff recurs with a *complete, untruncated* trace, it should be promoted to a BUG_LOG.md entry. **★ HANDOFF — next session/tool, start here:** 1. **Blocking action still on the user:** run the part-5 reconfigure, in order: ```powershell cmake -S "F:\SDBZ Recomp" -B "F:\SDBZ Recomp\build" -DPS2X_ENABLE_RUNTIME_LOGS=ON & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo ``` then rerun with `$env:PS2X_DIAG = "1"` set and grep `F:\SDBZ Recomp\run_log.txt` for `\[present\]`. This has now been requested 3 sessions running (parts 4, 5, 6) and is the only unblocking step left for the magenta→black question. 2. **Once `[present]` lines exist:** read `nonblack=`, `dispFbp=` vs `ctx0/1.fbp=`, `pref=` fields — see "next steps" logic already written in part 5/earlier summaries. 3. **Secondary, lower-priority thread:** if a *complete* trace ever shows finding 3's healthy→busy%≈0 single-tick cliff again, capture the full untruncated watchdog line and file it as a new BUG_LOG.md entry — do not chase it further off a truncated paste. 4. **Do not re-litigate:** `pc==ra`, `gstate@0x50227c` frozen, and the GS-pixel-math CPU cost are all closed/explained (parts 1-3, and the retractions above them). Re-reading those threads from scratch wastes a session — check this file's Session 2026-07-29 entries first. ## Session 2026-07-29 (co-piolet, part 7) — user ran the part-5/6 reconfigure; build FAILED with 2 real (pre-existing, unrelated) compile errors — **FIXED**, rebuild is now the next action **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** User ran `cmake -DPS2X_ENABLE_RUNTIME_LOGS=ON` + `build.ps1 RelWithDebInfo` for the first time and pasted a bare `Failed (exit 1) after +24:11` with no visible error (build output scrolled past). Found the real errors by reading `F:\SDBZ Recomp\build_log.txt` (MSBuild's own log, freshly written 3:00 AM, matches the failure). **Root cause — exactly the risk flagged in part 5's cost warning:** flipping `PS2X_ENABLE_RUNTIME_LOGS` from OFF→ON makes the compiler actually parse/type-check `RUNTIME_LOG(...)` argument expressions for the first time in a long while (they'd been compiling to `do {} while(0)` — arguments discarded, never type-checked). Two of those expressions had bit-rotted: - `ps2xRuntime\src\lib\ps2_gs_gpu.cpp:2389` — `m_registers.prim.type` — **`GSPrimReg` has no `.type` member**, the primitive-type bitfield is named `.prim` (`ps2xRuntime\include\runtime\ps2_gs_gpr.h:282`, `Bitfield prim`). A local `const GSPrimReg prim = m_registers.prim;` was already in scope one line above (line 2340) and used correctly as `prim.prim` elsewhere in the same function (line 2343) — the `RUNTIME_LOG` call just never got the memo. - `ps2xRuntime\src\lib\ps2_gs_rasterizer.cpp:551` — `gs->m_prim` — **`GS` has no `m_prim` member**; the primitive register lives at `gs->m_registers.prim` (`GS::m_registers` is a `GSGpr`, `ps2xRuntime\include\runtime\ps2_gs_gpu.h:155/384`). **Fixed both** (`ps2_gs_gpu.cpp:2389` → `prim.prim`; `ps2_gs_rasterizer.cpp:551` → `gs->m_registers.prim.tme`). **Also fixed 2 more latent instances of the identical bug in the same file, still dormant behind a *different* compile-time gate** (`PS2_IF_AGRESSIVE_LOGS`/`PS2X_ENABLE_AGRESSIVE_LOGS`, still `OFF`, not touched this session) — these did **not** cause today's build failure but would explode the instant someone enables that flag too, so fixed proactively while already in the file: - `ps2_gs_rasterizer.cpp` lines 257-261 (`[gs:prim]` block) and 322-326 (`[gs:copy-prim]` block): `gs->m_prim.type/tme/abe/fst/ctxt` → `gs->m_registers.prim.{prim,tme,abe,fst,ctxt}`. - `ps2_gs_rasterizer.cpp` lines 279-281 and 342-344 (`texclut=(...)` fields in the same two blocks): `gs->m_texclut.{cbw,cou,cov}` → `gs->m_registers.texclut.{cbw,cou,cov}` (`GS` has no `m_texclut`; it's `GSGpr::texclut`, same pattern). - Verified via a repo-wide grep for `->m_prim\b|->m_texclut\b|\.prim\.type\b` — zero remaining hits after the fixes. - `gs->m_vtxQueue[...]` (also referenced in the same blocks) was checked and is **correct as-is** — `GS::m_vtxQueue` is a real member (`ps2_gs_gpu.h:395`), not part of this bug. **Not yet verified by a real build** — these are source edits only; the assistant does not run cmake/MSBuild (standing rule). **User must rebuild.** **★ HANDOFF — next action is still the user's, in order:** 1. Rerun the build (reconfigure already succeeded; only recompile is needed now): ```powershell & "F:\SDBZ Recomp\build.ps1" RelWithDebInfo ``` 2. If it fails again, paste the *full* error — prefer reading `F:\SDBZ Recomp\build_log.txt` directly (`Select-String -Path "F:\SDBZ Recomp\build_log.txt" -Pattern "error"`) over trusting the console tail, since `build.ps1`'s progress bar swallows the actual `C2039`-style lines (this is the second time that log has been the only way to see the real error — worth remembering as a pattern). 3. Once it builds clean, proceed exactly as parts 5/6 describe: rerun with `$env:PS2X_DIAG = "1"`, grep `run_log.txt` for `\[present\]`, and analyze `nonblack=`/`dispFbp=`/`ctx0/1.fbp=`/`pref=`. ## Session 2026-07-29 (co-piolet, part 8) — ★ BUG-027 VERIFIED: rebuild succeeded, `ps2EntryRunner.exe` built with `PS2X_ENABLE_RUNTIME_LOGS=ON`. **Next action: `$env:PS2X_DIAG=1` run + grep for `[present]`.** **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** User reran `build.ps1 RelWithDebInfo` after the part-7 fixes. Build completed: `ps2EntryRunner.vcxproj -> F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe`, `Done in +05:48`. Only warnings emitted (`LNK4075`/`LNK4006`/`LNK4088`, all pre-existing/benign — raylib vs. user32 duplicate-symbol shadowing and the expected `/FORCE`-link note, unrelated to BUG-027). **BUG-027 is now CLOSED/VERIFIED** — the `RUNTIME_LOG`/`PS2_IF_AGRESSIVE_LOGS` member-reference fixes from part 7 compile cleanly with `PS2X_ENABLE_RUNTIME_LOGS=ON`. **★ HANDOFF — next action is still the user's:** 1. Run the game with runtime diagnostics enabled: ```powershell $env:PS2X_DIAG = "1" & "F:\SDBZ Recomp\launch_recomp.ps1" ``` (or whatever the standard launch command is — see earlier parts/run command in project header.) 2. Grep the fresh `run_log.txt` for the `[present]` probe: ```powershell Select-String -Path "F:\SDBZ Recomp\run_log.txt" -Pattern "\[present\]" ``` 3. This is the **first time** `[present]` can actually fire (previously blocked by BUG-027's build failure, and before that by `RUNTIME_LOG` being compile-disabled per part 5). Paste whatever it prints — even if empty, that itself is new information (would mean the probe site never executes, a different bug). 4. Once `[present]` output exists, analyze `nonblack=`, `dispFbp=` vs `ctx0/1.fbp=`, and `pref=` to determine whether the drawn frame is genuinely near-black, DISPFB points at a stale page, or fallback frame-selection picks the wrong candidate. **Do not re-litigate:** `pc==ra` (dispatch-boundary artifact), `gstate@0x50227c` frozen (static singleton pointers), GS-pixel-math CPU cost (real rasterizer work) — all closed in parts 1-3. The part-6 single-tick busy%-cliff (74→2) remains unexplained but low-priority; only chase it with a complete untruncated trace. ## Session 2026-07-29 (co-piolet, part 9) — ★★ `[present]` FIRED FOR THE FIRST TIME: `nonblack=0` confirmed — the presented frame is genuinely solid black, and `[gs:pixels]` shows every draw is color `(0,0,0,0)`. Root cause shifts upstream to color-source, not presentation/DISPFB. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** **Detour that delayed this (now closed, for the record):** the first several `$env:PS2X_DIAG=1` runs still produced zero `[present]` lines even after BUG-027's fix, which looked like a runtime-gating bug. Root cause was mundane: `launch_recomp.ps1`'s default `-Exe` points at `build\ps2xRuntime\Debug\ps2EntryRunner.exe`, last built **2026-07-28 23:37**, while `ps2_runtime.cpp` was edited **2026-07-29 00:47** — after that Debug build. The part-7/8 rebuild that was verified only produced `RelWithDebInfo\ps2EntryRunner.exe` (built 2026-07-29 14:58). Every run in parts 5-8 executed the **stale Debug exe**, which predated the fix entirely. Lesson: **always pass `-Exe` pointing at whichever config was just rebuilt, or rebuild Debug too** — `launch_recomp.ps1` does not default to the most-recently-built config. (Aside, also resolved: my own PowerShell tool's view of `F:\SDBZ Recomp\run_log.txt` was reading a stale/disconnected copy relative to the user's live session for several turns — I stopped trying to self-check the log and relied on the user's own `Select-String` output, which is correct going forward. Also confirmed the runtime source in `F:\SDBZ Recomp` — a different git worktree/branch, `work/laptop-session-0519` @ `81a0dbc` with local uncommitted edits — is what's actually built; this session's own worktree, `agents/watchdog-performance-analysis` @ `c68adaf`, is a **different branch** and must not be assumed to match the build tree's source without checking.) **The fix that worked:** ```powershell $env:PS2X_DIAG = "1" & "F:\SDBZ Recomp\launch_recomp.ps1" -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" -RunSeconds 90 ``` **Captured `[present]` lines (2 samples over the run):** ``` [present] has=1 w=512 h=448 nonblack=0 dispFbp=0x0 srcFbp=0x0 pref=0 ctx0.fbp=0x70 ctx0.fbw=8 ctx0.psm=0x0 ctx1.fbp=0x70 ctx1.fbw=8 ctx1.psm=0x0 pmode=0x7f27 dispfb1=0x1000 dispfb2=0x1000 display1=0x1bf9ff0203327c display2=0x1bf9ff0203227c [present] has=1 w=512 h=448 nonblack=0 dispFbp=0x70 srcFbp=0x70 pref=0 ctx0.fbp=0x70 ctx0.fbw=8 ctx0.psm=0x0 ctx1.fbp=0x70 ctx1.fbw=8 ctx1.psm=0x0 pmode=0x7f27 dispfb1=0x1000 dispfb2=0x1000 display1=0x1bf9ff0203327c display2=0x1bf9ff0203227c ``` Interleaved `[gs:pixels]` samples (dozens across the run, `n=1008000000` through `n=1056000000`) all report `r=0 g=0 b=0 a=0` at varying `x`/`y`, with `tme=0` (untextured) and `frame.psm=0x0` (PSMCT32). `[gs:frame-change]` shows the game alternating draw target between `frame.fbp=0x0` and `frame.fbp=0x70` (double-buffering), both PSMCT32/fbw=8 (512px wide, matches `w=512` above). **Analysis:** - `nonblack=0` is real, not a symptom of watching the wrong buffer: `[gs:pixels]` independently confirms every sampled draw pixel is `(0,0,0,0)`, i.e. fully transparent black, regardless of which buffer (`0x0` or `0x70`) is being drawn to at that moment. - The previously-known DISPFB mismatch (`dispfb1=0x1000` vs. `ctx0/1.fbp=0x70`) is confirmed real and the fallback path (`pref=0`, so `srcFbp` follows `ctx.fbp` instead of trusting `DISPFB`) is confirmed active — but this is **not the root cause**, since the buffer the fallback correctly picks (`0x70`) is *also* black. - **Root cause has moved upstream of presentation entirely**: the question is now why every GS draw's output color is `(0,0,0,0)`. Candidates: (a) RGBAQ/vertex-color register read as zero due to a GIFtag/VIF unpack bug before the primitive reaches the rasterizer, (b) alpha-blend/test state incorrectly zeroing color, or (c) the game is genuinely intentionally drawing black at this exact boot moment (e.g. a pre-fade-in black screen) and the real bug is that it never advances past this state (a hang/stall bug, not a color bug) — needs a longer capture across multiple seconds to see if `frame.fbp`/pixel colors ever change. **★ HANDOFF — next action:** 1. Get a longer `[present]`/`[gs:pixels]` capture (`-RunSeconds 30`+ with the same `-Exe RelWithDebInfo` override) to see whether `nonblack` ever goes above 0, or whether the game is simply stuck drawing black indefinitely (would point back to a stall/derail rather than a color bug). 2. If it's confirmed to stay black indefinitely: trace the color source for `prim=6` draws — check RGBAQ register state and the GIFtag unpack path feeding this primitive, comparing against what the guest ELF's boot-splash code is expected to set. 3. Remember for every future run: pass `-Exe` explicitly pointing at whichever build config was just rebuilt (currently `RelWithDebInfo`), since `launch_recomp.ps1`'s default `-Exe` (`Debug`) is not being kept in sync. **Do not re-litigate:** presentation/DISPFB fallback logic (confirmed working as designed, not the bug), the missing-`PS2X_DIAG`-env-var hypothesis (ruled out — the var was correctly inherited; it was the stale exe), and everything closed in parts 1-3. ## Session 2026-07-29 (co-piolet, part 10) — longer capture RE-CONFIRMS solid black across ~48M `n` (many draws/frames); GS register dump shows nothing that would force color to zero — prime suspect is now specifically **RGBAQ / vertex color source**, which has not yet been observed in any capture. **Source of this entry: co-piolet (GitHub Copilot CLI in VS Code).** **New data pasted this segment:** a much larger excerpt of `run_log.txt` (still same `-Exe RelWithDebInfo` corrected launch). Confirms and extends part 9: - `[gs:pixels]` `n` counter spans **`n=1008000000` → `n=1056000000+`** in this excerpt alone (with the user noting "there's a lot more, can't fit it all") — this is tens of millions of counted draws, not a one-off transient, all still `r=0 g=0 b=0 a=0`. Strongly favors **"stuck black" over "transient black before fade-in"**, since a real fade-in would show non-zero color within this many samples. - Two more `[present]` lines, still `nonblack=0`, `dispFbp`/`srcFbp` alternating `0x0`/`0x70` in step with `ctx0.fbp`/`ctx1.fbp=0x70` as before. `pmode=0x7f27`, `dispfb1=dispfb2=0x1000` unchanged — consistent with part 9, no new drift. - `[gs:frame-change]` continues normal `frame.fbp` alternation `0x0`⇄`0x70`, `frame.psm=0x0` (PSMCT32), `tex0.tbp=0x0`/`tme=0` throughout — still flat-color (untextured) fills, never textured draws. - New: `[gs:ad]` (direct GS privileged-register writes) samples decoded: - `addr=0x4a data=0x0` / `addr=0x4b data=0x0` → **FBA_1/FBA_2 = 0** (alpha-correction bit off, not a factor). - `addr=0x4e data=0x1300000e0` → **ZBUF_1** configured (has a real base pointer + PSM, not the all-zero/uninitialized state). - `addr=0x47 data=0x30003` → **TEST_1**: `ATE=1, ATST=ALWAYS` (alpha test enabled but always passes — does not discard pixels), `ZTE=0` (z-test **disabled**, so depth cannot be silently failing these draws either). Nothing here would force output color to black. - `addr=0x1a data=0x1` → **PRMODECONT=1**, matches the `prmodecont=1` already seen per-primitive; context-mode-from-PRIM is active as expected, not a stray fixed-mode override. - `[gs:giftag] n=280000 lo=0x1000000000008004 hi=0xe nloop=4 eop=1 flg=0 nreg=1` → a normal small GIFtag (4-loop, 1-register PACKED format, `hi=0xe` = register selector `0xE`=`A+D` mode), consistent with these being register-write packets, not the actual vertex/color payload for the black primitives. **Analysis / what this rules out:** none of the decoded GS state registers (FBA, ZBUF, TEST, PRMODECONT) would force a drawn pixel to `(0,0,0,0)` — alpha test always passes, z-test is off, FBA is a no-op. This keeps the leading hypothesis on the **vertex/primitive color source itself**, i.e. whatever sets `RGBAQ` (GS register `0x01`) before each `PRIM`/kick — and notably **no `[gs:ad] addr=0x01` (RGBAQ) line has appeared in any capture pasted so far**. Either RGBAQ genuinely never gets written with a non-zero color for these draws (real bug), or RGBAQ writes go through a different, unlogged path (e.g. packed GIFtag registers other than A+D mode, such as PACKED-mode RGBAQ fields embedded directly in a vertex-format GIFtag rather than as a discrete `[gs:ad]`-style register write) that the current probes don't capture. **★ HANDOFF — next action (refined from part 9):** 1. Add/confirm a probe on RGBAQ specifically — either extend `[gs:ad]` to flag `addr=0x01` distinctly, or add a `[gs:rgbaq]` probe at the point PACKED-mode GIFtags set the primitive color, so we can see what color value (if any) is actually being loaded before each black draw. 2. Once RGBAQ's value is visible: if it's genuinely `0x00000000` going into the draw, the bug is upstream in whatever guest code / GIFtag unpack path is supposed to set the boot-splash color — trace that write path next. If RGBAQ is non-zero but the rasterizer still outputs black, the bug is in the recompiled rasterizer's color-application path (`GS::` pixel write in `ps2_gs_gpu.cpp`) instead. 3. Longer-capture confirmation already strongly suggests "stuck black," but keep an eye out in future captures for any moment `nonblack` becomes nonzero (would falsify the stuck-black theory and point back to a timing/derail issue instead). 4. Continue passing `-Exe` explicitly (`RelWithDebInfo\ps2EntryRunner.exe`) per the part-9 lesson — no change needed here, just carrying the reminder forward. **Do not re-litigate:** everything closed in parts 1-9 (dispatch-boundary `pc==ra`, frozen `gstate@0x50227c` singleton pointers, GS-pixel-math CPU cost, `PS2X_DIAG` env var, DISPFB/ctx.fbp presentation fallback, the stale-Debug-exe root cause). FBA/ZBUF/TEST/PRMODECONT register state is now also closed as a suspect per this entry — do not re-decode these registers again without new evidence. ## Session 2026-07-29 (co-piolet, part 11) — ★★★ ROOT CAUSE FOUND: **GIF IMAGE-mode payload was dropped entirely.** Every VRAM image upload transferred **0 bytes**. Also: two process bugs corrected — the assistant CAN read `run_log.txt` directly, and the `[gs:pixels]` probe was sampling-biased and produced the wrong conclusion in parts 9-10. ### ★ PROCESS CORRECTION 1 — `run_log.txt` is directly readable by the assistant. The prior claim that it isn't was wrong. Verified this session: `F:\SDBZ Recomp\run_log.txt` (1,870,926 bytes) is fully readable and greppable, and whole-log frequency analysis runs in a single command. **Parts 1-10 were all reasoned from hand-pasted few-hundred-line excerpts while whole-log statistics were available the entire time.** Going forward: the user runs the game and says so; the assistant reads and aggregates the whole log itself. Do not go back to the paste loop. ### ★ PROCESS CORRECTION 2 — the `[gs:pixels]` probe was sampling-biased. Part 10's RGBAQ conclusion rested on it and is **demoted**. `[gs:pixels]` logged 1 sample per **2,000,000** pixel writes. A 512x448 framebuffer clear is ~229k pixels at ~50Hz ≈ 11M cleared pixels/sec, so essentially every sample landed on a black screen-clear sprite. Whole-log truth: the entire run contains exactly **16** `[gs:pixels]` lines. "Every draw is black" was 16 samples of a black clear — not evidence about geometry at all. The probe has been **replaced** (see fix 2 below). ### ★ ROOT CAUSE — `GS::processGIFPacket` had no state for IMAGE payload spanning DMA packets Whole-log evidence: - **`[gs:image]` — every image transfer moved zero bytes:** `dbp=0x0 dpsm=0x0 dbw=16 trxreg=1024x64 sizeBytes=0`, ~10 occurrences, then never again. `[gs:giftag]` shows exactly one IMAGE tag: `flg=2 nloop=16384 nreg=16 eop=1` → 16384 qw = **262,144 bytes requested, 0 delivered**. `1024x64 PSMCT32 = 262,144 bytes` — the sizes agree exactly. - **`[gs:ad] addr=0x51` (TRXPOS) sweeps DSAY = 0, 64, 128, 192, 256, 320, 384, 448** — eight 1024x64 strips = one **1024x512** upload to VRAM base 0, split into 8 strips. All 8 delivered nothing. - **82 of 88 logged GIFtags are all-zero garbage** (`flg=0 nloop=0 nreg=16 eop=0 hi=0x0`) — the parser was chewing through the dropped image payload and misreading it as tags. Mechanism (`ps2_gs_gpu.cpp`, IMAGE branch of `processGIFPacket`): ```cpp uint32_t imageBytes = nloop * 16; if (offset + imageBytes > sizeBytes) imageBytes = sizeBytes - offset; // clamps to 0 when the tag ends the buffer processImageData(data + offset, imageBytes); ``` The DMA chain delivers the IMAGE GIFtag as the **last qword of one packet** and its 16384 qwords of pixel data in the **following** packets. `offset == sizeBytes` → `imageBytes = 0` → payload silently discarded, **and** the next packet's pixel data was then parsed as GIFtags (hence the 82 zero-tags). `m_transferState` correctly persisted the *destination* cursor across IMAGE tags, but nothing persisted the *source* byte debt. ### ★ WHAT THIS EXPLAINS - No texture data ever reaches GS VRAM. - All 4,579 `[gs:frame-change]` events are only two states: `prim=6 (SPRITE) tme=0 tex0.tbp=0x0`, alternating `fbp 0x70 <-> 0x00`. **The game never issues a single textured draw.** The render loop is healthy and double-buffering correctly — it just clears to black forever. - `[gs:ad] addr=0x1` (RGBAQ) = `0x3f80000000000000` → Q=1.0f, R=G=B=A=0. RGBAQ *is* black — but that is the correct color for a screen clear, which is all that is being drawn. Not the bug; a symptom. - Geometry decode cross-check: `addr=0x05` (XYZ2) = `0x8e009000` → X=2304.0, Y=2272.0; `addr=0x18` (XYOFFSET) = `0x720000007000` → OFX=1792.0, OFY=1824.0 → **512 x 448**, exactly the screen. Full-screen clear sprites, confirmed. ### ★ CLOSED THIS SESSION - **Presentation path — not the bug.** `[present] has=1 w=512 h=448 pmode=0x7f27 dispfb1=0x1000`. Correct geometry, valid pmode; it is faithfully presenting an empty buffer. Stop investigating DISPFB/present. - **RGBAQ — not the bug.** Demoted from part 10's "prime suspect". It is black because clears are black. ### ★ FIXES APPLIED (co-piolet, needs user build + run to verify) 1. **`m_pendingImageBytes` (new `GS` member, `ps2_gs_gpu.h`)** — persists the IMAGE payload byte debt across `processGIFPacket` calls. `processGIFPacket` now drains any outstanding debt from the head of each incoming packet *before* parsing GIFtags, and the IMAGE branch records `requested - delivered` instead of discarding it. Debt is decremented *before* dispatching to `processImageData`, because `processImageData` can call `EndTransfer()` mid-drain (that would otherwise underflow the counter). `EndTransfer()` deliberately does **not** clear the debt — remaining declared qwords are padding that must still be consumed as payload, never re-parsed as tags. Cleared on `GS::reset()` and on each `TRXDIR` write (new transfer supersedes any stale debt). 2. **`[gs:pixels]` replaced by `[gs:frame]`** (`ps2_gs_rasterizer.cpp` + present probe in `ps2_gs_gpu.cpp`) — per-interval aggregates instead of 1-in-2M sampling: `px=` pixels written, `nonblack=` pixels with any non-zero RGB channel, `textured=` pixels drawn with TME=1, `maxrgb=` brightest channel seen, `primmask=` bitmask of primitive types rasterized, plus running `imagebytes=` / `prims=`. Counters reset at each report, so **`nonblack=0` on this probe genuinely means nothing coloured was rasterized** — unlike the sampled probe it cannot miss draws. **★ HANDOFF — next action:** 1. Build with `build.ps1 -Config RelWithDebInfo` (**not** `Laptop_Build.ps1` — that script and the `C:\SDBZ Recomp` root are dead, see the stale-worktree note above) and run with `PS2X_DIAG=1` and an explicit `-Exe` at the freshly built config. Then read `run_log.txt` directly (do not paste). 2. Check exactly three things: does `[gs:image] sizeBytes` go **non-zero**; does `[gs:frame] textured` go **non-zero** (i.e. any TME=1 draw at all); does `[gs:frame] nonblack` / `[present] nonblack` go **non-zero**. 3. If image bytes now land but still nothing textured is ever drawn, the blocker is upstream of GS entirely — pivot to the IOP/SIF side. The log is dense with `SIF_DIAG` (210), `ARKD:CALL` (192), `SifRpcPkt:SIG` (180), `iop:import` (156); determine whether IOP-side asset loading ever completes. 4. Watch the all-zero-GIFtag count (`flg=0 nloop=0 nreg=16 eop=0 hi=0x0`, was 82/88). It should collapse toward zero if the carryover fix is correct — that is the cheapest single confirmation signal. ### ★★ VERIFIED 2026-07-29 20:15 (co-piolet) — BUG-028 fix CONFIRMED WORKING; black screen persists for a *different*, now-isolated reason Built `RelWithDebInfo` (clean, zero errors; the LNK4075/4006/4088 warnings are pre-existing `/FORCE:MULTIPLE` noise from `ps2xRuntime/CMakeLists.txt:636`) and ran 45 s with `PS2X_DIAG=1`. Read `run_log.txt` directly. Results against the four signals: | # | signal | before | after | verdict | |---|--------|--------|-------|---------| | 1 | `[gs:image] sizeBytes` | `0` on **every** transfer | `262144` on **all 16** transfers | ★ **FIXED** | | 4 | all-zero garbage GIFtags | **82 of 88** | **0 of 33** | ★ **FIXED** | | 2 | `[gs:frame] textured` | 0 | **still 0** | unchanged | | 3 | `[gs:frame] nonblack` | 0 | **still 0** | unchanged | - **BUG-028 is genuinely fixed and is now closed.** Two independent signals confirm it. The garbage-GIFtag count going 82/88 → 0/33 is the decisive one: the runtime is no longer mis-parsing pixel payload as tags, which proves the cross-packet carryover works. Total delivered `imagebytes=4194304` = 16 x 262144 = the full 1024x1024 PSMCT32 upload, exactly as declared. Nothing regressed; the log is *cleaner* than before. - **The new `[gs:frame]` probe (BUG-029 fix) works and is now the primary GS instrument.** Sample line: `px=24313856 nonblack=0 textured=0 maxrgb=0 primmask=0x40 imagebytes=4194304 prims=479`. Because it aggregates rather than samples, `nonblack=0` here is *authoritative*: across ~24 M rasterized pixels per interval, **not one pixel with a non-zero RGB channel was ever written, and not one TME=1 pixel was ever drawn.** This is no longer an inference. - **`primmask=0x40` = bit 6 only = `GS_PRIM_SPRITE` and nothing else, for the entire run.** The game issues exactly one primitive type. `[gs:frame-change]` has only **three** distinct states in 2104 events, all `prim=6 tme=0 tex0.tbp=0x0` — differing only in `fbp` (0x0 / 0x70) and `prmodecont`. Confirms pure double-buffered black clears. - **The 16 image uploads all happen once at boot and never recur** (`imagebytes` is cumulative and flatlines at 4194304). They target `dbp=0x0 dbw=16`, i.e. a 1024x1024 block at VRAM base — this is a **VRAM initialization/clear**, not asset content. **No texture is ever uploaded after boot init.** - Watchdog is unchanged and still spinning: `t=44s ... stuckSecs=43 pc=0x421f10 ra=0x421f10`, `vbl/s=46`, `gif/s=44`. The guest is alive and pumping frames, it just never advances past the clear loop. **Conclusion — the blocker is definitively upstream of the GS.** GS was innocent of everything except BUG-028, and BUG-028 was real but not sufficient. The EE never *asks* for a textured draw: no `TEX0` is ever programmed to a non-zero `tbp`, `TME` is never set, and no image upload other than the boot VRAM clear ever occurs. Something before the GS — asset load, or the code path that would issue the first textured primitive — never runs. **★ HANDOFF — next action (supersedes the list above):** 0. **★ METHOD: use `ps2xTest`, not the game loop.** The project has a full MiniTest suite that drives `GS` directly — `ps2xTest/src/ps2_gs_tests.cpp` is 198 KB with 66 `processGIFPacket`/`GIF_FMT_IMAGE`/`TRXDIR` hits, and `ps2_sif_rpc_tests.cpp` / `ps2_sif_dma_tests.cpp` are 56 KB each and cover the *next* lead. A Debug test build is minutes, versus ~48 minutes for `ps2EntryRunner` plus a game run. `build.ps1` now has a **`-Test`** switch (added this session) targeting `build\ps2xTest\ps2x_tests.vcxproj`: ```powershell & "F:\SDBZ Recomp\build.ps1" Debug -Test & "F:\SDBZ Recomp\build\ps2xTest\Debug\ps2x_tests.exe" ``` Reserve full runner builds for confirming end-to-end behaviour, not for iterating on a hypothesis. **★ Gotcha found while enabling this (co-piolet, 2026-07-29):** `ps2x_tests` had been **unlinkable since the generated-body overrides were added** — the on-disk exe was stale from 7/24. `ps2_runtime` (STATIC) contains `src/lib/game_overrides.cpp`, which does `registerFunction(0x00180D30u, &fn_180D30_0x180d30)` and three more. Those bodies live in `ps2xRuntime/src/runner/*.cpp`, which CMake globs **only into the `ps2EntryRunner` executable** (`ps2xRuntime/CMakeLists.txt:484-499`), never into the library — so they resolve for the runner and for nothing else, and every other consumer of `ps2_runtime` dies with LNK2019. Fixed by adding **`ps2xTest/src/recomp_override_stubs.cpp`** (abort-on-call stubs for the four symbols) to the `ps2x_tests` executable sources. Linking the real runner TUs was rejected: the transitive `fn_*` closure is most of the generated codebase and would make the test exe as slow to build as the runner. **Every new `game_overrides.cpp` entry pointing at a generated body adds another unresolved symbol here — add a matching stub.** **★ STATUS 2026-07-29 21:22 (co-piolet): this method is now PROVEN.** The test build links and runs. First real payoff: `ps2x_tests.exe PS2GS "BUG-028"` → 3/3 passed, unit-confirming the BUG-028 fix in a ~5-minute loop. Two things you must know before using it: - **`ps2x_tests.exe` now accepts filters:** `ps2x_tests.exe [suite-substring] [test-substring]` (case-insensitive, empty = all). - **A bare `ps2x_tests.exe` HANGS** in `PS2RuntimeKernel`'s semaphore test, and because suites run **alphabetically** (`std::map`), everything after it — `PS2SifRpc`, `SifDma`, `PS2Vu1`, `Pad`, ~35 `Scheduler*` — never runs. **The SIF suites are reachable only via the filter.** Details under BUG-028 below. - There are ~14 pre-existing baseline failures unrelated to any of this (clut-cache CSM1 T4, T4HL planes, TEX2 CLUT, PABE, ALPHA FIX, `sceGsExec*Image`, `sceGsResetGraph`, `sceGsSyncV` parity, VIF1 DIRECT continuation, `SetVSyncFlag` tick, `_realloc_r`, ghidra map starts). **None are regressions.** Do not chase them without an explicit decision. 1. ~~**Pivot to IOP/SIF asset loading.** ... Note `SifRpcReply` (88) < `SifRpcPkt:SIG` (180) — roughly half the RPC signals appear to go unanswered. **That ratio is the first thing to check** ...~~ **⛔ RETRACTED 2026-07-29 21:40 — the ratio is a log-cap artifact (both counters saturated at 24+64 vs an uncapped SIG counter). See BUG-030. Do not chase it.** The rest of that item is also answered: **IOP asset loading is PROVEN to work end-to-end** (disc → IOP RAM → EE RAM, real ISO lookups, real sectors, `IsReady` = `0x30000000`) — see the 21:40 entry below. Tag census retained for reference only: `SIF_DIAG` 210, `ARKD:CALL` 192, `SifRpcPkt:SIG` 180, `iop:import` 156, `sceSifSetDma:DTX` 89, `SifRpcReply` 88 (capped), `ARKD:run` 55, `Sema:Signal` 32. **For the current entry point, skip to ★★ HANDOFF 22:00 at the end of this session block.** 2. Cross-reference against BUG-009 and the `dword_441A00` lead (see the 2026-06-21/22 entries) rather than starting fresh — this may be the same root cause finally surfacing with clean instrumentation behind it. 3. `run_log.txt` is **directly readable by the agent** (~1 MB). Do not ask the user to paste logs. The only thing the user must do is build and run. **Do not re-litigate:** all of parts 1-10, plus (new this session) the presentation path, the RGBAQ colour source, the GIF IMAGE carryover path (BUG-028, fixed and verified), and the claim that "every draw renders black is a rendering bug" — it is not; nothing coloured is ever *submitted*. ### ★★ 2026-07-29 21:40 (co-piolet) — the "RPC reply deficit" lead is REFUTED, and IOP asset loading is PROVEN TO WORK. Two dead ends closed from `run_log.txt` alone, no build required. **1. `SifRpcReply` (88) < `SifRpcPkt:SIG` (180) is a LOGGING-CAP ARTIFACT, not a real deficit. This lead is dead — do not chase it again.** The 88 is the sum of two *saturated* counters: | log line | observed | cap in source | |---|---|---| | `[SifRpcReply] deliver cid=...` | **24** | `s_replyLogs ... < 24u` — [SIF.cpp:752](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp:752) | | `[SifRpcReply] depth=...` | **64** | `s_depthLogs ... < 64u` — [SIF.cpp:849](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp:849) | 24 + 64 = 88, both exactly at their caps. Meanwhile `[SifRpcPkt:SIG]` is **deliberately never rate-limited** ([SIF.cpp:1619](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp:1619)). Comparing an uncapped counter against two saturated ones produces a "deficit" out of thin air. The signal/reply cids are also perfectly consistent: SIG is `0x8000000a` x173 + `0x80000009` x7; deliver is `0x8000000a` x14 + `0x80000009` x10 — same two cids, no orphan server. **Generalize this:** before treating any log-frequency ratio as evidence, check whether either counter is capped. Several probes in this codebase cap at 6/8/16/24/64. **2. `[ARKD:CALL] handleRPC -> false` (64x) is ALSO a red herring.** That call site is explicitly labelled *"Stage 1 observe-only bridge ... result never written back to the guest ... (expected: false -> the Stage 2 gap)"* ([SIF.cpp:600-608](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp:600)). The *real* service path is the separate `PS2_ARKD_SERVICE` bridge below it, and `launch_recomp.ps1` defaults both `PS2_ARKD_SERVICE=1` and `PS2_ARKD_IRX_RUN=1`, so it was active. `handleRPC -> false` says nothing about whether assets load. **3. ★ IOP asset loading WORKS end-to-end. Disc → IOP RAM → EE RAM is a proven, live path.** From the same run: ``` [ARKD:search] "\INFO.DAT;1" -> found lbn=1048576 size=0x18000 [ARKD:search] "\GAME.DAT;1" -> found lbn=1048625 size=0x2ba29000 [ARKD:cdread] lbn=1048576 sectors=47 iopDest=0x060000 -> ok (+7 more, all ok) [ARKD:sifdma] iopSrc=0x060000 eeDest=0x01652400 size=0x18000 -> copied [ARKD:sifdma] iopSrc=0x078000 eeDest=0x01670c00..0x01673c00 size=0x800 -> copied (x7) ``` Real ISO lookups resolve, real sectors are read, and the bytes are memcpy'd into **EE RAM**. `[ARKD:run] sid=0x503 fno=0x2` (IsReady) returns `00 00 00 30` = `0x30000000` = ready, matching the June `ccdReply` finding exactly. **The premise "determine whether IOP-side asset loading ever completes" is answered: it starts and it delivers.** **4. What is still open, and it is now much sharper.** Only **8** `[ARKD:cdread]` and **8** `[ARKD:sifdma]` lines appear — and both probes cap at `seen < 8u` ([ps2_iop_irx_loader.cpp:644](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp:644) and [:751](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp:751)). So the log **cannot distinguish** "streaming stopped right after the TOC" from "streaming ran the whole 0x2ba29000-byte GAME.DAT". That distinction is now the single most valuable unknown, and it was unanswerable purely because of a log cap. **Instrumentation added (co-piolet, BUILT 2026-07-30 — needs a RelWithDebInfo run to read):** running totals that never saturate, emitted at power-of-two event counts (dense early, sparse later — so an early stop is always visible and a full stream costs ~19 lines) — - `[ARKD:cdread-total] reads= sectors= bytes= fails= lastLbn=` - `[ARKD:sifdma-total] dmas= bytes= skipped= lastEeDest=` The first-8 detail lines are unchanged. Read the **last** `-total` line of each: if `dmas` stalls in the single/double digits, asset streaming genuinely stops after the TOC and *that* is the blocker. If `bytes` climbs into the megabytes, assets are loading fine and the failure is in the EE-side consumer that should turn loaded data into a texture upload. ~~**Surviving lead (unchanged, still the best one): BUG-009 `rpc_handle_valid`.**~~ **RETRACTED 2026-08-01 — BUG-009 is benign, see the CLOSED table in the 07-29 22:00 handoff. Historical only.** June proved the boot state machine `sub_327810` is frozen at state 1, gated by `wrap_rpc_handle_valid()` over 4 client handles (`0x5A9330`/`0x5A9358`/`0x5A9380`/`0x5A93A8`), *not* by `ccdReply` content (which reads the passing `0x30000000`). `rpc_handle_valid` (`0x178de8`) is `v1 = *a1; return v1 && a1[1] == *(u32*)(v1+24) && (*(u32*)(v1+16) & 1);`. A `rpcValid=` 4-bit diagnostic was written in June but **grep confirms it emits nothing in the current `run_log.txt` — it is not in the build.** Re-instating that measurement identifies which handle fails and which of the three sub-conditions (null backing pointer / id mismatch / ready bit clear) is at fault, which points straight at what `SifBindRpc` ([RPC.cpp](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/Kernel/Syscalls/RPC.cpp)) fails to populate. Note the related `dword_441A00` thread is **CLOSED as a dead end** (2026-07-06c) — do not restart it. ## ~~★★ HANDOFF 2026-07-30 — BUG-009: `SifBindRpc` is dead code, HWWATCH now armed on `pkt_addr`~~ — **SUPERSEDED 2026-08-01. Do not action.** > **RETRACTED.** BUG-009 is benign (see the 2026-08-01 CLOSED entry in the 07-29 handoff below). The `pkt_addr` HWWATCH run described here **must not be run** — it costs ~20 % CPU and answers a question that no longer matters. The one durable finding here is the `SifBindRpc`-has-zero-call-sites fact, which stands. `PS2X_HWWATCH` / `PS2X_HWWATCH_CLIENT` should be **unset** for all runs going forward. **Read this before the 07-29 HANDOFF below — it changes the BUG-009 plan of attack.** Two sequential fix attempts this session (one in `SifCallRpc`, one a bind-reply-packet stamp in `SifBindRpc`) both **failed and were retracted** — fresh runs still show all 5 client handles (`0x464dc0`, `0x5a9330`, `0x5a9358`, `0x5a9380`, `0x5a93a8`) with `pktAddr=0x0` on every `[rpcValid]` poll. **Root cause of both failures: `SifBindRpc`/`sceSifBindRpc` has zero call sites anywhere in `src/runner/`** — confirmed by grepping the entire generated directory for both `sceSifBindRpc` and the bare substring `BindRpc` (targeted content grep, not a listing — rule-compliant). This is stronger than a dispatch-bypass: the SDK's bind logic was statically inlined into the ELF's translated MIPS (same pattern as `rpc_handle_valid_0x178de8.cpp`), so nothing in `RPC.cpp` is ever reached for it. **Do not attempt further fixes in `SifBindRpc`/`SifCallRpc` — that code path is unreachable for this game's binary.** **Per the project's own rule** ("for who wrote value X to address Y, use the hardware data breakpoint, not log-greps or store-site bisection — the latter produced four retractions in ten days") — this session just produced its own two retractions the same way. Switched tools. **Wired 2026-07-30:** `sdbzDiagRpcHandleValid178DE8` in `game_overrides.cpp` (the existing `[rpcValid]` hook at `0x178de8`) now arms the existing HWWATCH infra (built 2026-07-27 for the `rpc_call` `$ra` hunt — VEH + background armer thread, unchanged) on `client->hdr.pkt_addr` (offset 0) for client `0x464dc0` specifically, to catch the real writer or prove there is none. Not yet run. **Next run, hand the user:** ```powershell .\build.ps1 RelWithDebInfo 6 $env:PS2X_HWWATCH = "1" $env:PS2X_HWWATCH_VAL = "0xFFFFFFFF" # record every store, not just value 1 — filter is unknown for this address & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" ``` Check `run_log.txt.hwwatch.txt` for symbolized hits. `PS2X_HWWATCH_CLIENT=0x...` overrides which of the 5 clients gets watched (default `0x464dc0`) — the watchpoint only tracks one address at a time, so if this run comes back empty, re-run against a different client before concluding pkt_addr is never written at all. **Cost: ~20% CPU while armed (measured 07-27) — do not trust perf numbers from this run, and unset `PS2X_HWWATCH` for anything else.** --- ## ★★ HANDOFF 2026-07-29 22:00 — **SINGLE ENTRY POINT. Read this before any other 07-29 entry.** Everything above this line in the 2026-07-29 block is history, including handoffs that point at leads since refuted. This is the only current one. ### Where the blocker is **The EE never submits a textured draw.** `primmask=0x40` (SPRITE only) for an entire run, `tme` never 1, `tex0.tbp` always 0, `nonblack=0` across ~24 M rasterized pixels per interval. The render loop is *healthy* — ~46 fps, double-buffering `fbp 0x0 ↔ 0x70` — it just clears to black forever. The fault is in the guest state machine, **upstream of the GS and upstream of the IOP**. ### CLOSED — do not reopen, do not re-derive | Thread | Verdict | |---|---| | BUG-028 GIF IMAGE cross-packet carryover | **FIXED + VERIFIED twice** (live run: `sizeBytes` 0 → 262144 on all 16 transfers, garbage GIFtags 82/88 → 0/33; unit: `ps2x_tests.exe PS2GS "BUG-028"` 3/3) | | BUG-029 `[gs:pixels]` sampling bias | **FIXED** — replaced by aggregate `[gs:frame]`, now the primary GS instrument | | BUG-030 capped-counter evidence | **Both instances refuted/handled** — see registry | | "RPC reply deficit" 88 vs 180 | **Log-cap artifact.** No deficit exists | | `[ARKD:CALL] handleRPC -> false` (64×) | **Red herring** — that site is the labelled observe-only Stage-1 bridge; the real path is `PS2_ARKD_SERVICE`, which was active | | IOP asset loading | **PROVEN WORKING** end-to-end, disc → IOP RAM → EE RAM | | Presentation / DISPFB / RGBAQ colour source | **Innocent** — faithfully presenting an empty buffer; RGBAQ is black because clears are black | | `pc==ra=0x421f10`, frozen `gstate@0x50227c`, GS CPU burn | All false positives, closed parts 1–3 | | `dword_441A00` | Dead end, closed 2026-07-06c | | **BUG-009 `rpc_handle_valid` / `pkt_addr` / `SifBindRpc`** | **CLOSED 2026-08-01 — BENIGN.** `rpc_handle_valid` (`0x178de8`) is `sceSifCheckStatRpc`: it answers *"is this async RPC still **busy**?"*, not *"is it valid?"*. Our always-0 answer means "not busy", which **passes** both consumers. Four sessions + two retracted fixes were spent on a misleading func-map name. Evidence table in the 2026-08-01 handoff. **Do not reopen. Do not attempt further `SifBindRpc`/`SifCallRpc` fixes.** | ### ★ CLOSED 2026-08-01 — BUG-009 is benign; `rpc_handle_valid` is a busy-check, not a validity-check The 07-29 "surviving lead" and the 07-30 HWWATCH plan below are both **retracted**. `rpc_handle_valid` (`0x178de8`) is `sceSifCheckStatRpc`. Both known consumers use it as a **busy**-check, so our constant-0 return is the *permissive* answer: | Consumer | Code | Effect of our always-0 answer | |---|---|---| | `sub_1C0A30` (boot/CCD state machine) | `while (wrap_rpc_handle_valid(client)) ;` × 8 | loops exit **immediately** → passes | | `sub_186CC0` `_sceCd_scmd_prechk` | `if (busy) { SignalSema; return 0; }` | reports "not busy" → **lets execution through** | Sources: `ida_scripts/decompiles_SLUS_214_42.txt:152804` (full `sub_1C0A30` body) and the 2026-07-30b `sub_186CC0` finding. They agree. **`pkt_addr` never being armed cannot gate anything.** **The real gate in `sub_1C0A30` is the RPC reply *content* at EE `0x5AA7D0`, not the handle:** ```c mem_fill_z_369(client=0x5A9358, func=2, mode=3, send=0x5A97D0, 0, recv=0x5AA7D0, 16, 0, 0); if ((MEM[0x205AA7D0] & 0xF0000000) == 0x40000000) { ... result = 1; } else result = ((MEM[0x205AA7D0] & 0xF0000000) == 0x30000000); ``` Our ARKD returns `0x30000000`. Whether that is the *advancing* answer or the *keep-waiting* answer has **never been checked against real hardware**. That is the new thread → stage 5.8. ### ★ CLOSED 2026-07-30 05:18 — the one open measurement is ANSWERED: asset streaming does NOT stop after the TOC Real RelWithDebInfo run (96.3s, `Determinism=0`), `-total` lines finally observed (the first attempt used a stale exe — Debug -Test never builds `ps2EntryRunner`; corrected by rebuilding RelWithDebInfo directly, verified via source/exe mtime ordering before trusting the run): ``` [ARKD:cdread-total] reads=1024 sectors=1070 bytes=2191360 fails=0 lastLbn=1075481 [ARKD:sifdma-total] dmas=1024 bytes=2156160 skipped=0 lastEeDest=0x017b5c00 ``` Both counters doubled cleanly across all 11 samples (1→2→4→...→1024) with **zero fails, zero skips**, `lastLbn` and `lastEeDest` climbing continuously the entire run — **2.1-2.2 MB streamed, no stall at the TOC.** IOP asset loading is proven not just to start (prior finding) but to **run continuously and successfully for the full session.** **Verdict: asset streaming is innocent.** The blocker is 100% EE-side: loaded data is never turned into a textured draw (`tme=0` unchanged all run). This closes the load-path investigation — **do not re-open it.** The only surviving lead is BUG-009 below. ### ~~The surviving lead — BUG-009 `rpc_handle_valid`~~ — **RETRACTED 2026-08-01, see the CLOSED entry above. Historical only.** June proved the boot state machine `sub_327810` is frozen at **state 1**, gated by `wrap_rpc_handle_valid()` over 4 client handles (`0x5A9330` / `0x5A9358` / `0x5A9380` / `0x5A93A8`) — *not* by `ccdReply` content, which reads the passing `0x30000000`. `rpc_handle_valid` (`0x178de8`) is: `v1 = *a1; return v1 && a1[1] == *(u32*)(v1+24) && (*(u32*)(v1+16) & 1);` A `rpcValid=` 4-bit diagnostic was written in June but **is not in the current build** (grep-confirmed: zero hits in `game_overrides.cpp`, zero in `run_log.txt`). Re-instating it names **which handle** fails and **which of the three sub-conditions** (null backing pointer / id mismatch / ready bit clear) — which points straight at what `SifBindRpc` ([RPC.cpp](ps2xRuntime/src/lib/Kernel/Syscalls/RPC.cpp)) fails to populate. ### Method — use `ps2xTest`, not the game loop ```powershell & "F:\SDBZ Recomp\build.ps1" Debug -Test & "F:\SDBZ Recomp\build\ps2xTest\Debug\ps2x_tests.exe" Sif ``` Minutes, versus ~48 min for `ps2EntryRunner` plus a game run. Reserve full runner builds for end-to-end confirmation, not hypothesis iteration. Three hard rules for this path: 1. **Always pass a filter** — a bare `ps2x_tests.exe` hangs and silently skips ~40 suites (BUG-031). 2. **Always pass `-Exe`** pointing at the config you just built — `launch_recomp.ps1` defaults to `Debug` and does not track the newest build. This has burned three sessions. 3. **Every new `game_overrides.cpp` entry that points at a generated body needs a matching stub in `ps2xTest/src/recomp_override_stubs.cpp`**, or `ps2x_tests` stops linking. `run_log.txt` (~1–2 MB) is **directly readable by the agent**. Do not paste logs; run the game, say so, and let the agent aggregate the whole file. ### Test baseline as of 22:00 `ps2x_tests.exe Sif` → **27 / 28 passed**. The one failure is **BUG-032** (`sceSifSetDma` multi-descriptor validation not atomic), newly observed because the SIF suites had never been reachable before. Not triaged, probably latent. ~14 other pre-existing baseline failures exist elsewhere in the suite (clut-cache CSM1 T4, T4HL planes, TEX2 CLUT, PABE, ALPHA FIX, `sceGsExec*Image`, `sceGsResetGraph`, `sceGsSyncV` parity, VIF1 DIRECT continuation, `SetVSyncFlag` tick, `_realloc_r`, ghidra map starts) — **none are regressions; do not chase without an explicit decision.** ### Low priority, flagged only The part-6 single-tick `busy%` cliff (74 → 2 in one watchdog interval) is unexplained. Only chase it with a **complete, untruncated** trace; do not reason from the truncated paste. --- ## Session 2026-07-28 (later, Claude Code / Opus 5) — ★ RelWithDebInfo `ps2EntryRunner.exe` BUILT. Build was unfinishable for three reasons, all found and fixed. **A/B measurement run is the single next action.** **Read this first if you are picking the project up.** A full agent handoff written for GitHub Copilot lives at [.github/copilot-instructions.md](.github/copilot-instructions.md) — durable rules, repo layout, and the "what do I do next" pointer. This section is the live state it points at. ### ★★★ THE A/B RAN — 109×. Stage 5.6 is CLOSED. Run: `-Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile -Exe ...\RelWithDebInfo\ps2EntryRunner.exe` | metric | Debug control arm | RelWithDebInfo | delta | |---|---|---|---| | **`progress` @ t=89** | **12 101** | **1 320 490** | **109×** | | `gif/s` | 3–5 | **47–49** | normal band is 30–60 | | `vbl/s` | 3–6 | **47–49** | | | `[cputime]` | wall 96.52 s · cpu 99.53 s · 103.1 % | wall 96.33 s · cpu 102.84 s · 106.8 % | **same CPU** | | `busy%` | (field post-dates this arm) | 60–73 | headroom remains | | `_RTC_CheckStackVars` | 7.0 % / 8.5 % | **absent** | ⇒ correct exe was launched | | busiest threads | 46.14 s / 44.42 s | 49.05 s / 42.25 s | unchanged shape | **Same CPU seconds, 109× the guest work.** The build configuration was the entire performance story, exactly as hypothesised, and by a much larger factor than the 5–20× Debug→Release rule of thumb — which is itself the lesson: per-pixel `constexpr` accessor math is the pathological case for an unoptimised build. Hot code is still GS pixel math but under its real post-inlining names — `GSMem::LookupPixelAddressCT32` / `ReadPixelAddressCT32` / `ReadPixelCT32`, `GSRasterizer::writePixel`, `GS::WriteVram`, `GS::copyFrameToHostRgbaUnlocked`. `PixelStorageTraits` is gone from the profile because `/O2` inlined it. ### ★ What this actually buys us: "slow" and "stuck" are now separate problems **The game is no longer slow. It is still stuck.** The watchdog holds `pc=0x421f10` / `ra=0x421f10` / `lastCall=0x172998` (`sceDmaSync`) with `stuckSecs` climbing 45→53 — **the same plateau as the Debug arm**, but now running at ~47 fps with the call trace churning healthily every tick. 90 s at this rate is **≈2.7 hours of Debug-equivalent guest work**. Real PCSX2 reaches the memory-card prompt in 7–8 s of wall time at comparable frame rates. We do not reach it at all. ⇒ **This is a correctness gate, not a throughput problem.** Every prior caveat of the form *"we can't tell whether it's blocked or merely too slow to observe"* is now void. Throughput is solved and must stop being treated as a variable. **All subsequent diagnostic runs should use the RelWithDebInfo exe** — they now cover ~100× more guest execution per wall-second. **Resolved by elimination:** the t≈89 s watchdog breakout out of the `0x421f10`/`0x172998` plateau — which reproduced **2-for-2** under Debug and was logged in the previous session as an unexplained lead — **did not occur on this run**. It was a Debug-timing artifact, not a milestone being approached. The queued `-RunSeconds 300` run drops from "cheap follow-up lead" to "run once for completeness". ### Next action — the blocker, at the new speed The question is now sharply posed: **why does the guest never leave the `0x421f10` / `sceDmaSync` (`0x172998`) loop, despite the GIF moving 47 packets/s and vblank ticking at 47/s?** Starting points, in order of cheapness: 1. **One `-RunSeconds 300` run** on the RelWithDebInfo exe to confirm the plateau is genuinely terminal and not a very long wait. ≈9 hours of Debug-equivalent work. If it never leaves, the loop is unconditional. 2. **Identify `0x421f10`.** It is `pc` *and* `ra` — a tight self-loop. It has been the plateau address across every run for weeks and has never been disassembled. Do that. 3. **`0x172998` is `sceDmaSync`.** With `dma/s` at 92–98 and `gif/s` at 47–49, DMA is demonstrably completing. So either the guest is polling a completion flag the runtime never sets, or it is syncing on a channel that never gets serviced. Compare against PCSX2 at the same point. 4. The recurring trace tail `0x174f20 -> 0x172e20 -> 0x172a90 -> 0x172998` is stable across ticks — that's the caller chain into the sync. Walk it. ### Smaller findings from the fast run - **~30 % of tid 39824 is now spent waiting, not computing** — `ntdll!NtWaitForSingleObject` 8.6 %, `ZwDelayExecution` 6.5 %; tid 14552 spends 15.2 % in `ZwWaitForAlertByThreadId`. Under Debug this was swamped by pixel math. The scheduler/fiber handoff is now a legitimate thing to examine. - **Our own probes cost ~12 % of the presentation thread**: `` `anonymous namespace'::countNonBlackPixels `` 2.47 s (6.3 %) + `` `anonymous namespace'::blendPresentationChannel `` 2.20 s (5.6 %). Candidates for the same env-gate treatment `hwWatchArm()` got in BUG-025. Not urgent — there is headroom (`busy%` 60–73). - `tid 36884` burns 6.22 s in `Wait/ExecutionDelay` and does not appear in `[hostprof]` — the same unexplained pattern as Debug's tid 9288. Still not chased. ### Build artifacts — verified on disk, not assumed | | | |---|---| | `build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe` | **354,563,584 bytes**, 2026-07-28 **17:42:18** (Debug exe is 730 MB — the halving is `/O2` + no `/RTC1`) | | unity objs | **4520** + 1 quarantined = 4521, 2.33 GB | | build window | 14:0x → 17:42 (**~3 h 40 m**), ~21 obj/min | | prior attempt for contrast | 04:14 → 06:36 at `/m:2`, reached 808 objs, ~5.7 obj/min ⇒ **≈11 h** projected | ### Why the build could not finish — three independent causes **(1) `/m:$Jobs` was never going to help.** MSBuild's `/m` is **project**-level parallelism. `ps2EntryRunner` is a single `.vcxproj`, and MSBuild's CL task hands every source of one project to **one** `cl.exe` via a single response file, which compiles them **serially**. All 4520 unity TUs ran on one core at `/m:2` and would have at `/m:12`. The source-level knob is `cl /MP`. **(2) `src/runner/sub_0022F200_0x22f200.cpp` is an `/O2` optimizer bomb.** **278,316 lines / 12.9 MB** in one recompiled function — ~**350×** its unity siblings (5.9–37.7 KB). It sat at 100 % of one core for **2 h 09 m** and emitted a **37,164,827-byte** object while surrounding unity TUs averaged ~7 s each. It was never deadlocked; it just could not be waited out, and being inside a unity batch it serialized the entire target. This is the `unity_3680`/`unity_3681` stall the user reported at 12:04→13:26. **(3) There are no CL tlogs, so no build ever resumes.** MSBuild's per-source up-to-date check reads `CL.read.*.tlog` / `CL.write.*.tlog` / `CL.command.*.tlog`. **This target has none.** Every build re-passes all 4520 sources to `cl`. ⛔ **This retracts the claim in the previous session section that "the 808 objs are valid and MSBuild resumes past them"** — they were silently recompiled from scratch. **A partial obj count is not progress and must never be quoted as an ETA.** ### The fix — three edits, `ps2xRuntime/CMakeLists.txt` only No header touched, no recompiler run, no `runner/*.cpp` touched. 1. **Cache variables** (`PS2X_RUNNER_UNITY_BUILD_BATCH_SIZE` = 8, `PS2X_RUNNER_CL_JOBS` = 6) so both knobs are tunable without editing logic. 2. **Optimizer-bomb quarantine** — `SKIP_UNITY_BUILD_INCLUSION ON` plus `COMPILE_OPTIONS "/Od;/Ob0"` on `sub_0022F200_0x22f200.cpp`. 3. **`target_compile_options(ps2EntryRunner PRIVATE /FS /MP${PS2X_RUNNER_CL_JOBS})`** — `/MP` needs `/FS` alongside `/Zi`; `/FS` was already there. Capped at 6 rather than bare `/MP` (= all logical processors) because `cl` peaked at 3.6 GB on the worst TU and 12 × that exhausts a 16 GB box. **How to verify these survived CMake generation** (do this before committing to another multi-hour build — grep `build/ps2xRuntime/ps2EntryRunner.vcxproj`): - `/MP6` becomes **two** elements and you need both: `true` **and** `6`. Confirmed present in all four configurations (lines 103/174/247/321 and 106/177/250/324). Bare `MultiProcessorCompilation` without `ProcessorNumber` would mean all cores. - The quarantine shows as the bomb's `` entry **lacking** the `IncludeInUnityFile="true" CustomUnityFile="true" UnityFilesDirectory="…"` attributes that every neighbour carries, plus `Disabled` + `Disabled` per config. Confirmed at line 34411 for Debug, Release, MinSizeRel **and** RelWithDebInfo. `build.ps1` builds the `.vcxproj` directly and never calls `cmake`, but MSBuild's `Checking Build System` / `generate.stamp` step re-runs CMake by itself when `cmake.verify_globs` is newer than `generate.stamp.depend` — so a manual reconfigure is not required, and a Ctrl-C'd one is harmless. ### BUG-026 (open, low severity, 2026-07-28) — guest function `0x22f200` is unoptimized in every configuration A consequence of the quarantine above, recorded deliberately rather than hidden. `sub_0022F200_0x22f200.cpp` now compiles `/Od /Ob0` in Debug, Release, MinSizeRel **and** RelWithDebInfo. - **Not expected to matter for the current A/B:** the `[hostprof]` profile put the cost in the *host* software-GS (`PixelStorageTraits`, `GSRasterizer::writePixel`), not in guest code, and `0x22f200` has never appeared in a profile. - **But it is the first thing to rule out** if the RelWithDebInfo numbers come back oddly flat. - **Independently suspicious:** a 278,316-line recompiled function is itself an anomaly — plausibly a recompiler function-boundary failure that swallowed a large span of code, in the same family as [[project_truncated_function_bug]]. Flagged, never investigated. Cheap first check: disassemble `0x22f200` and see whether the original MIPS function is remotely that large. - **Reversal is one line** if it ever matters: drop the `COMPILE_OPTIONS` while keeping `SKIP_UNITY_BUILD_INCLUSION` — the quarantine alone stops it serializing the target, at the cost of a 2 h tail on the critical path of every full rebuild. ### BUG-027 (FIXED + VERIFIED by clean rebuild, 2026-07-29, co-piolet) — `[gs:frame-change]`/`[gs:pixels]`/`[gs:prim]`/`[gs:copy-prim]` `RUNTIME_LOG`/`PS2_IF_AGRESSIVE_LOGS` probes referenced nonexistent members; broke the build the instant `PS2X_ENABLE_RUNTIME_LOGS=ON` was first tried. Rebuild with the fixes applied completed successfully (`Done in +05:48`, warnings only, no errors) — closed. **How found:** user tried the part-5 reconfigure (`PS2X_ENABLE_RUNTIME_LOGS=ON`) for the first time ever, and `build.ps1` failed with a bare `Failed (exit 1)` and no visible error — the real `C2039` lines were only in `build_log.txt`, not the console tail. **Root cause:** `RUNTIME_LOG(x)`/`PS2_IF_AGRESSIVE_LOGS(x)` compile to `do {} while(0)` by default, so their arguments are never type-checked while the corresponding CMake option is `OFF`. Four call sites across two files had silently bit-rotted behind these no-op macros, referencing struct members that no longer exist: - `ps2_gs_gpu.cpp:2389` — `m_registers.prim.type` (no such member; the field is `.prim`, `GSPrimReg::prim` is a `Bitfield`) → fixed to use the already-in-scope local `prim.prim`. - `ps2_gs_rasterizer.cpp:551` (the one that actually broke the build, gated by `RUNTIME_LOG`) — `gs->m_prim` (no such member on `GS`; it's `gs->m_registers.prim`) → fixed. - `ps2_gs_rasterizer.cpp:257-261` and `322-326` — same `gs->m_prim.{type,tme,abe,fst,ctxt}` bug, ×2, still dormant behind the separate `PS2_IF_AGRESSIVE_LOGS`/`PS2X_ENABLE_AGRESSIVE_LOGS` gate (still OFF, untouched) — fixed proactively so enabling that flag later doesn't hit the same wall. - `ps2_gs_rasterizer.cpp:279-281` and `342-344` — `gs->m_texclut.{cbw,cou,cov}` (no such member on `GS`; it's `gs->m_registers.texclut`), same two dormant blocks — fixed. - `gs->m_vtxQueue[...]` in the same blocks was checked and is correct (`GS::m_vtxQueue` is real) — not part of this bug. **Status:** source-fixed, verified with a repo-wide grep (`->m_prim\b|->m_texclut\b|\.prim\.type\b`, zero hits remaining) but **not yet confirmed by an actual rebuild** — assistant does not run builds (standing rule). Next action is the user re-running `build.ps1 RelWithDebInfo`. If it succeeds, this closes; if there are further errors, paste `build_log.txt`'s error lines, not just the console tail. ### BUG-028 (★ FIXED AND VERIFIED 2026-07-29 by co-piolet — CLOSED) — GIF IMAGE-mode payload spanning DMA packets was silently discarded; **every VRAM image upload transferred 0 bytes** **How found:** whole-log analysis of `run_log.txt` (not a pasted excerpt). Every `[gs:image]` line reads `trxreg=1024x64 sizeBytes=0`; the single logged IMAGE GIFtag is `flg=2 nloop=16384` = 262,144 bytes requested vs 0 delivered. 82 of 88 logged GIFtags were all-zero garbage — the parser reading dropped pixel data as tags. **Root cause:** `GS::processGIFPacket`, `GIF_FMT_IMAGE` branch. `imageBytes` was clamped to `sizeBytes - offset`, which is **0** when the IMAGE GIFtag is the last qword of its DMA packet — the normal framing for a large upload, where the payload arrives in the following packets. Nothing carried the outstanding source-byte count across `processGIFPacket` calls, so the payload was both dropped *and* re-parsed as GIFtags. (`m_transferState` persisted the *destination* cursor across IMAGE tags correctly; the *source* debt had no equivalent.) **Fix:** new `GS::m_pendingImageBytes`. `processGIFPacket` drains outstanding debt from the head of each packet before tag parsing; the IMAGE branch records `requested - delivered`. Debt is decremented *before* dispatching to `processImageData` (which can call `EndTransfer()` mid-drain and would otherwise underflow it). `EndTransfer()` intentionally does not clear the debt — declared-but-surplus qwords are padding and must still be consumed, never parsed as tags. Cleared on `GS::reset()` and on each `TRXDIR` write. **Verify by:** `[gs:image] sizeBytes` becoming non-zero, and the all-zero GIFtag count (`flg=0 nloop=0 nreg=16 eop=0 hi=0x0`) collapsing toward zero. **VERIFIED 2026-07-29 20:15 (RelWithDebInfo, `PS2X_DIAG=1`, 45 s run):** `[gs:image] sizeBytes` = **262144 on all 16 transfers** (was 0 on every one); all-zero GIFtags = **0 of 33** (was 82 of 88); `[gs:frame] imagebytes=4194304` = the complete 16 x 262144 upload. Both verification signals passed. **Bug closed.** **Caveat — fixing this did NOT fix the black screen.** `textured` and `nonblack` remained 0. The uploads that now succeed are a one-time boot VRAM clear at `dbp=0x0`, not asset content; no texture is ever uploaded afterwards and `TME` is never set. This bug was real, but the Phase 5 blocker is upstream of the GS. See the part-11 verification section for the IOP/SIF pivot. **Known limitation (accepted, unverified):** `m_pendingImageBytes` is global, not per-GIF-path. `processGIFPacket` is reachable from PATH3 (DMA) and PATH1 (VU1 XGKICK). A PATH1 packet interleaving mid-IMAGE-transfer would be misconsumed as payload. Real hardware holds the GIF bus for PATH3 during an IMAGE transfer, so this is believed safe, but it was not checked against `m_gifArbiter`. **Regression-tested 2026-07-29 (co-piolet).** Three tests added to `ps2xTest/src/ps2_gs_tests.cpp`, inserted just before the existing split-IMAGE test: 1. *"GS IMAGE payload arriving in a later packet than its GIFtag is not dropped (BUG-028)"* — packet A is a bare 16-byte IMAGE tag with no payload; packets B and C are raw payload. **Fails on the pre-fix code.** 2. *"...partially trailing its GIFtag carries only the shortfall (BUG-028)"* — guards the debt arithmetic. 3. *"...byte debt is dropped when a new transfer is started (BUG-028)"* — guards the `TRXDIR` clear. **Why the existing suite missed this:** `ps2_gs_tests.cpp` already had *"GS PSMT4 host-local upload keeps position across split IMAGE packets"*, but each of its packets carries **its own IMAGE tag alongside its own payload** — repeated-tag framing. It never covered the game's actual framing: **tag at the end of a packet, payload in the packets that follow.** Worth remembering when judging whether a path is "covered". **★ UNIT-CONFIRMED 2026-07-29 21:22 (co-piolet).** `ps2x_tests.exe PS2GS "BUG-028"` → **3 / 3 passed, 0 failed.** BUG-028 is now proven twice: end-to-end against a live 45 s run, *and* at unit level with no game, no ELF, and a ~5-minute build. The emitted probe lines confirm the exact framing that broke in the game — tag-only packet then `sizeBytes=16` + `sizeBytes=16` (carryover), and `nloop=3` then `sizeBytes=16` + `sizeBytes=32` (shortfall carried). **★ GOTCHA worth not rediscovering — `EndTransfer()` zeroes the state you are about to assert on.** The first run of these three tests *failed*, and the failure was entirely in the tests, not the fix. `GS::EndTransfer()` ([ps2_gs_gpu.h:480](F:/SDBZ%20Recomp/ps2xRuntime/include/runtime/ps2_gs_gpu.h:480)) is two lines: `m_registers.trxdir.xdir = 3;` and `m_transferState = { };`. That second line wipes `copied_pixels`, `x`, `y`, `total_pixels`. `getDebugSnapshot()` reads *live* state, so **`transferCopiedPixels` is always 0 for a COMPLETED transfer** — an assertion like `Equals(copied, 8u)` after completion is unsatisfiable no matter how correct the code is. `m_transferState.copied_pixels` is likewise reset on every `TRXDIR` write ([ps2_gs_gpu.cpp:2114](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/ps2_gs_gpu.cpp:2114)), next to `m_pendingImageBytes = 0`. Assert transfer counters **mid-flight**, or assert the new debt field instead. *Diagnostic heuristic that cracked it, reusable:* when a test fails, look at **which** assertions failed versus which passed. All three tests failed on exactly one assertion each while their *strongest* assertions (byte-for-byte VRAM comparison) passed. That pattern means the **observation** is wrong, not the code under test. **New instrumentation added while confirming this (co-piolet):** - `GSDebugSnapshot::pendingImageBytes` — exposes `m_pendingImageBytes`, the outstanding IMAGE byte debt, so it is directly assertable rather than inferred. Declared in [ps2_gs_gpu.h](F:/SDBZ%20Recomp/ps2xRuntime/include/runtime/ps2_gs_gpu.h) (~190), populated in `getDebugSnapshot` ([ps2_gs_gpu.cpp](F:/SDBZ%20Recomp/ps2xRuntime/src/lib/ps2_gs_gpu.cpp) ~607). The rewritten tests now verify the debt shrinks by exactly the bytes consumed per packet — strictly stronger than the assertions they replaced. - **`ps2x_tests.exe` now takes filters:** `ps2x_tests.exe [suite-substring] [test-substring]`, case-insensitive, empty = match all. Prints a `[filter]` banner and a "Skipped by filter" summary. Added in [MiniTest.h](F:/SDBZ%20Recomp/ps2xTest/include/MiniTest.h) (`Run()` kept as a no-arg overload) and [main.cpp](F:/SDBZ%20Recomp/ps2xTest/src/main.cpp) (`main(argc, argv)`). **★ BLOCKER for anyone running the full test suite — `ps2x_tests.exe` HANGS in `PS2RuntimeKernel`.** Test *"semaphore syscalls return sid on success (EE BIOS convention)"*, sub-case E ([ps2_runtime_kernel_tests.cpp:542-584](F:/SDBZ%20Recomp/ps2xTest/src/ps2_runtime_kernel_tests.cpp:542)): a worker thread blocks in `WaitSema`, the main thread deletes the semaphore, then calls `worker.join()` **unconditionally**. If `DeleteSema` does not wake the waiter with `KE_WAIT_DELETE`, this never returns. (The 500 ms `waitUntil` timeout above it does *not* protect the `join()`.) Pre-existing; unrelated to BUG-028; unknown whether it is a genuine `DeleteSema` runtime bug or a test-harness race. **Blast radius is larger than it looks:** `MiniTest::m_cases` is a `std::map`, so **suites and tests run in alphabetical order, not registration order.** Everything sorting after `PS2RuntimeKernel` therefore never executes — `PS2SifRpc`, `SifDma`, `PS2Vu1`, `Pad`, and ~35 `Scheduler*` suites. Use the new filter to reach them until the hang is fixed. ### BUG-029 (diagnostic-quality bug, source-fixed 2026-07-29 by co-piolet) — the `[gs:pixels]` probe was sampling-biased and caused two wrong root-cause conclusions `[gs:pixels]` sampled 1 in **2,000,000** pixel writes. A 512x448 clear is ~229k pixels at ~50Hz ≈ 11M cleared pixels/sec, so effectively every sample landed on a black full-screen clear sprite. The whole run produced only **16** samples — and parts 9 and 10 of the session log concluded from them that "every draw renders black" and that RGBAQ was the prime suspect. Both conclusions were artifacts of the sampling rate. **Fix:** replaced with `[gs:frame]`, a per-interval aggregate emitted from the present probe: `px` / `nonblack` / `textured` / `maxrgb` / `primmask` / `imagebytes` / `prims`, reset at each report. `nonblack=0` on the new probe is a real statement about every rasterized pixel in the interval. **VERIFIED WORKING 2026-07-29:** emits ~1/s, e.g. `[gs:frame] px=24313856 nonblack=0 textured=0 maxrgb=0 primmask=0x40 imagebytes=4194304 prims=479`. It immediately paid for itself: `primmask=0x40` (SPRITE only, whole run) and an authoritative `nonblack=0` across ~24 M pixels/interval are both facts the sampled probe could never have established. **Bug closed. `[gs:frame]` is now the primary GS instrument — use it, not per-pixel sampling.** **Generalize this:** a sampled probe on a path dominated by one high-volume event measures only that event. On GS pixel paths, **aggregate — do not sample.** **Lesson (generalize):** a sampled probe on a path dominated by one high-volume event measures only that event. On GS pixel paths, aggregate — do not sample. ### BUG-030 (diagnostic-quality bug, source-fixed 2026-07-29 by co-piolet — same family as BUG-029) — capped probe counters were used as quantitative evidence, manufacturing two phantom findings Sibling of BUG-029: BUG-029 was *sampling* bias, this is *saturation* bias. Several probes in this codebase stop printing after a fixed count, so their line count is a **cap, not a measurement** — and two separate root-cause leads were built on comparing a capped counter against an uncapped one. **Instance 1 — the phantom "RPC reply deficit" (`SifRpcReply` 88 vs `SifRpcPkt:SIG` 180).** REFUTED. The 88 is the sum of two *saturated* counters: | log line | observed | cap in source | |---|---|---| | `[SifRpcReply] deliver cid=...` | **24** | `s_replyLogs ... < 24u` — [SIF.cpp:752](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp#L752) | | `[SifRpcReply] depth=...` | **64** | `s_depthLogs ... < 64u` — [SIF.cpp:849](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp#L849) | 24 + 64 = 88, both exactly at their caps, while `[SifRpcPkt:SIG]` is **deliberately never rate-limited** ([SIF.cpp:1619](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp#L1619)). The cids are also perfectly consistent (SIG = `0x8000000a`×173 + `0x80000009`×7; deliver = `0x8000000a`×14 + `0x80000009`×10 — same two cids, no orphan server). **There is no deficit. Do not chase this ratio again.** **Instance 2 — ARKD streaming extent is unmeasurable.** `[ARKD:cdread]` and `[ARKD:sifdma]` both cap at `seen < 8u` ([ps2_iop_irx_loader.cpp:644](ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp#L644) and [:751](ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp#L751)). "Streaming stopped right after the TOC" and "streamed the whole 0x2ba29000-byte GAME.DAT" produce **byte-identical logs**. This also means the 2026-07-27 "ARKD disc I/O stops ~t=20s" retraction was the *same* bug surfacing earlier. **Fix VERIFIED WITH REAL DATA 2026-07-30 05:18** (RelWithDebInfo run, 96.3s) — non-saturating running totals emitted at power-of-two event counts, in `ps2_iop_irx_loader.cpp`: - `[ARKD:cdread-total] reads= sectors= bytes= fails= lastLbn=` → last line: `reads=1024 sectors=1070 bytes=2191360 fails=0 lastLbn=1075481` - `[ARKD:sifdma-total] dmas= bytes= skipped= lastEeDest=` → last line: `dmas=1024 bytes=2156160 skipped=0 lastEeDest=0x017b5c00` **Result: Instance 2 is now RESOLVED, not just unblocked.** Both counters climbed cleanly for the entire run (1→2→4→...→1024, zero fails/skipped) — streaming does not stop after the TOC, it runs continuously (~2.1MB delivered). See ★★ HANDOFF for the implication (blocker is EE-side, not load-path). The first-8 detail lines are unchanged. **Gotcha hit while verifying this:** `build.ps1 Debug -Test` does **not** build `ps2EntryRunner` (only the runtime lib + test exe) — the first game run after "building" used a stale exe and showed the old capped 8/8 lines. Always confirm exe mtime > source mtime before trusting a measurement run; `build.ps1 RelWithDebInfo` (no `-Test`) is what actually links the game exe. **Standing rule (generalize):** **before treating any log-frequency count or ratio as evidence, grep the probe's source for its cap.** Known caps in this codebase: 6, 8, 16, 24, 64. A counter sitting exactly on a round number is the tell. Cross-reference [[feedback_measure_dont_infer_rates]]. ### BUG-031 (open, 2026-07-29) — `ps2x_tests.exe` with no filter hangs forever, silently skipping ~40 suites Promoted from an inline note to its own entry because it gates the whole test-driven method. **Hang site:** suite `PS2RuntimeKernel`, test *"semaphore syscalls return sid on success (EE BIOS convention)"*, sub-case E ([ps2_runtime_kernel_tests.cpp:542-584](ps2xTest/src/ps2_runtime_kernel_tests.cpp#L542)). A worker thread blocks in `WaitSema`; the main thread deletes the semaphore, then calls `worker.join()` **unconditionally**. If `DeleteSema` does not wake the waiter with `KE_WAIT_DELETE`, `join()` never returns. The 500 ms `waitUntil` above it does **not** protect the `join()`. **Blast radius is larger than the hang itself:** `MiniTest::m_cases` is a `std::map`, so suites run **alphabetically, not in registration order**. Everything sorting after `PS2RuntimeKernel` never executes — `PS2SifRpc`, `SifDma`, `PS2Vu1`, `Pad`, and ~35 `Scheduler*` suites. That is why the SIF suites had never been run before 2026-07-29. **Workaround (use this, don't fix it mid-blocker):** always pass a filter — `ps2x_tests.exe [suite-substring] [test-substring]`, case-insensitive. **Root cause not established:** unknown whether this is a genuine `DeleteSema` runtime bug (real, would matter to the game) or a test-harness race (cosmetic). Deciding that is the first step if it is ever picked up. Pre-existing; unrelated to BUG-028/029/030. ### BUG-032 (open, 2026-07-29, first observation) — `sceSifSetDma` multi-descriptor validation is not atomic **Surfaced by the first-ever run of the SIF suites** (reachable only via BUG-031's filter workaround), so it is a **newly-observed failure, not necessarily a regression** — it is not in the ~14-item baseline-failure list and has simply never been executed before. `ps2x_tests.exe Sif` → **27 / 28 passed, 1 failed.** Failing test: `PS2SifDma` / *"sceSifSetDma rejects invalid descriptors without partial writes"*, two assertions: - `sceSifSetDma should fail when any descriptor is invalid` - `failed multi-descriptor sceSifSetDma should not partially write earlier descriptors` Observed behaviour: given descriptor 0 valid (`src=0x21100 dest=0x21200 size=0x8`) and descriptor 1 with an invalid dest (`dest=0xe0000100`), the call **copies descriptor 0 and returns a nonzero id** (`[sceSifSetDma:OK] dmat=0x21000 count=2 pending=2 -> nonzero id`) instead of validating the whole list first and failing atomically. The `count=33` guard path works correctly (`[sceSifSetDma:GUARD] reject`), so only per-descriptor address validation is affected. **Not investigated, not triaged against the live blocker.** Relevance is unknown: the game's own transfers are single-descriptor in every log seen so far, so this may be latent. Do not spend on it unless the `rpc_handle_valid` lead points here. ### Deferred, worth doing before the next long build `sccache not found; continuing without compiler launcher` appears in every configure. No compiler cache is in play, so every full build is from scratch. With a 4520-TU target and no working tlogs, `sccache` is the single highest-leverage build improvement available. Not done now — it would have delayed the A/B. ### Minor A/B contaminant, flagged The reconfigure pulled new upstream **imgui** commits via FetchContent (`a9e7a8c..ff135cf master`) that were **not** in the Debug control-arm build. imgui is overlay/debug UI and is not in either hot thread, so it should not affect the comparison — but it is a real difference between the two arms, and it is the first suspect if the RelWithDebInfo build had failed to link. Pre-build steps reported `0 file(s) updated` and `fn_forward_decls.h unchanged (no recompile triggered)`, so **no recompiler regeneration occurred** — the runner sources are byte-identical to the control arm. That part of the A/B is clean. ### Still open, unchanged by this session - **The `pc=0x1` clobber on `0x178be8`** is non-deterministic and still live. It derailed one of the three baseline attempts. It is a correctness blocker tracked separately, not a perf issue — see [[reference_ps2_sif_boot]]. - **The t≈89 s watchdog breakout** out of the `pc=0x421f10` / `lastCall=0x172998` (`sceDmaSync`) plateau reproduced **2 for 2**, with different addresses but the same shape, right as the time box expires. We have never seen past it. **Do one `-RunSeconds 300` run after the A/B** — a longer box now would change the CPU-second comparison. - **tid 9288** burns 6.97 s in `Wait/ExecutionDelay` yet never appears in `[hostprof]`. Unexplained, not chased. --- ## Session 2026-07-28 (agent handoff: GitHub Copilot / Claude Sonnet 5) — verified prior Claude Code session's HostSampler work, no build/run performed **Context:** picked up mid-handoff from a Claude Code session (compacted transcript) investigating why the recomp guest runs ~5 orders of magnitude slower than native PCSX2 (native reaches the memcard prompt in 7-8s; recomp needs 90s+ CPU-bound at ~118% of one core with a black screen). That session had written `ps2xRuntime/src/lib/Kernel/HostSampler.cpp` (CPU-time-weighted sampling profiler, Suspend/GetThreadContext/GetThreadTimes/Resume) and wired it into `ps2_runtime.cpp` (start/stop calls, namespace-scope `extern "C"` decls, no header touched) and `launch_recomp.ps1` (new `-HostProfile` switch, `PS2X_PROFILE`/`PS2X_PROFILE_SECS` env set/clear, `[hostprof]` added to `$Important`, auto-stop grace widened 2s→6s) but had **not yet built or run it**, and had not yet reported any of this to the user. **This session (Copilot) did:** read the user's governing rules for Claude and recorded them to persistent repo memory (`/memories/repo/rules.md`) so they bind this agent too — most importantly *"never run build.ps1/builds directly, always give the user the command to run themselves"* and *"never modify runner/*.cpp or .h files"*. Verified (read-only, no edits) that all three files described in the handoff summary match exactly: `HostSampler.cpp` exists with the documented design (samples dropped when CPU-time delta is 0, self-reports on its own timer, `Kernel/` placement rides the existing `CONFIGURE_DEPENDS` glob so no CMakeLists edit is needed), `ps2_runtime.cpp:44-45/2520/2995` has the three wiring edits, `launch_recomp.ps1:20/136-143` has the `-HostProfile` param + env block. **No code changes made this session** — this was a verification-only continuation. Build/run deliberately deferred at user's direction ("start them later"). **Handoff back to user — commands to run when ready (per the "never run builds directly" rule):** ```powershell & "F:\SDBZ Recomp\build.ps1" & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile ``` This will produce a `[hostprof]` report naming the actual functions/modules the ~47s and ~43s host threads are burning CPU in, plus the 16.56s `Wait/ExecutionDelay` thread (suspected `Sleep()` in the det=0 vblank tick loop, `Kernel/Syscalls/Interrupt.cpp:625-676`). Paste the `[hostprof]` output back and I'll pick up the diagnosis (RecompDbg's shared-memory writer is already exonerated — verified live at ~118% CPU with the gate on across 3 runs). **Also confirmed:** the previous Claude Code session (transcript `6ec32f85-bf4b-441a-831b-36319f7f3fd2.jsonl`) hit its usage rate limit immediately after the user asked it to "update memory bugs and handoff" — that request was never carried out, which is why this handoff arrived only as a compacted summary. Nothing else from that session was lost; its last real work (the `HostSampler.cpp` build) is exactly what this entry already covers. **First real run + bug found + fixed (GitHub Copilot, same session, later same day):** user ran the build+run and pasted a `[hostprof]` report — sampler weighting is confirmed correct (per-thread totals matched `[cputime]` closely: 46.48s↔50.23s, 37.41s↔39.92s, 18.55s↔19.72s across the three real burner threads), but **every sample resolved to `module ?` with zero per-symbol lines on all threads** — the report was naming nothing. Root-caused (read-and-reason, not guesswork) to a **dbghelp double-init**: `game_overrides.cpp`'s hardware-watchpoint armer (`hwWatchArmerMain`, started unconditionally from `hwWatchArm()` every time `rpc_call`/`0x178BE8` true-enters — a leftover always-on hook from the 2026-07-27 VSync bug hunt) calls `SymInitialize(GetCurrentProcess(), nullptr, TRUE)` with no matching `SymCleanup`. dbghelp allows only one live `SymInitialize` per process handle; `HostSampler::report()`'s own later call therefore fails with `ERROR_INVALID_PARAMETER`, `haveSyms` reads `false`, and both the module and per-symbol resolution paths silently degrade to `"?"`/skipped. **Fix applied (`HostSampler.cpp` only, no header, no CMakeLists change):** treat `SymInitialize` failing with `ERROR_INVALID_PARAMETER` as success rather than failure — the pre-existing process-wide init (with `fInvadeProcess=TRUE`, so every module is already loaded) is still fully usable; there was never a reason to require *this* call site to be the one that succeeded. **Not yet verified — needs a rebuild + rerun** (one `.cpp` changed, incremental, `Kernel/` glob so no CMakeLists edit needed): ```powershell & "F:\SDBZ Recomp\build.ps1" & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile ``` **Also worth a look independent of the symbol fix:** at `t=89s` of that same run, the watchdog broke out of the `pc=0x421f10`/`lastCall=0x172998` steady frame-loop plateau it had been pinned to since ~t=5s (`stuckSecs` reset `7→0`) into unrelated new code — trace tail `0x1c29b0 -> 0x398db0/0x1d3b30/0x2ae860/0x1d48d0/0x3d1550/0x2b65e0/0x2b83a0/0x2ca010 -> 0x1c29b0` (repeating dispatch-like pattern, distinct callee each iteration) `-> 0x1c4280 -> 0x19e620 -> 0x1bdfb0 -> 0x1a07c0 -> 0x23fca0 -> 0x240780`. The run auto-stopped (`wall=96.32s`) shortly after, so what this leads to is unknown. First time this specific breakout has been observed in the state file. Flagged, not yet chased. **BUG-024 false-alarm check (same session):** an earlier paste from a *different, shorter* run (`wall=38.26s`) showed `run_log.txt` cutting off mid-`[frametrace:IMBAL]` with no further watchdog ticks and no `[hostprof]` — pattern-matched against the open `BUG-024` (`STATUS_STACK_OVERFLOW`/`0xC00000FD`, found via `ps2xTest`, never root-caused in real gameplay). Chased via `$LASTEXITCODE` (first attempt read a stale value from the wrong terminal/cwd — user corrected to `C:\WINDOWS\system32` being the accidental cwd, not a file-location bug; no stray files existed there or in the user profile root, nothing was deleted). **Resolved as a false alarm, not BUG-024 recurring:** the very next full run (the one analyzed above) completed cleanly to auto-stop with a valid `[hostprof]` report and no crash — the short run was an incomplete/truncated paste, not a real early exit. Not chased further unless it recurs with a confirmed exit code. **Second finding, same session — real perf root cause identified, fix not yet applied (needs a decision + a build/run, not code-complete):** with symbol resolution fixed, the `[hostprof]` report named real hot code for the first time. The two busiest threads (43.8s + 40.6s CPU of a 96s run) are both dominated by `ps2EntryRunner!GSMem::PixelStorageTraits<0>::{PageId, Address, Read, Write, BlocksPerPage, BlockExtent, PixelsPerPage}` (`ps2xRuntime/include/runtime/ps2_gs_memory.h` — a header, **read-only per the standing rule, not edited**) — the software GS pixel-storage addressing math, called per-pixel from `GSRasterizer::writePixel`/`GS::copyFrameToHostRgbaUnlocked`. A third thread (19.06s) is our own `hwWatchArmerMain` toolhelp-enumeration overhead (instrumentation, not game logic). `_RTC_CheckStackVars` alone is 6-7% of each hot thread. **Root cause:** `build.ps1`/`launch_recomp.ps1` default to a **Debug** build (confirmed: `build.ps1`'s usage comment lists `Debug|RelWithDebInfo`, `launch_recomp.ps1` points at `...\Debug\ps2EntryRunner.exe`). In Debug, MSVC neither inlines nor constant-folds the `constexpr` `PixelStorageTraits` accessors (called millions of times/frame), and `/RTC1` stack-check instrumentation adds further per-call overhead on top. Confirmed via the runtime's `CMakeLists.txt` that `RelWithDebInfo` only adds `/SUBSYSTEM:WINDOWS` + `/ENTRY:mainCRTStartup` on top of CMake's default `/O2 /DNDEBUG` for that config — MSVC disallows `/RTC` together with optimizations, so `RelWithDebInfo` should both inline the hot math and drop the `_RTC_CheckStackVars` overhead entirely. This is a **build-configuration hypothesis, not yet tested** — no code or config was changed to test it. **Caveat flagged before testing:** `/SUBSYSTEM:WINDOWS` means the process may not get an attached console by default, so `[hostprof]`/`[watchdog]`/`[cputime]` prints could stop appearing in the terminal even if the process runs fine — check for this after the build. **Next step (commands prepared, not run by this agent per the "never run builds directly" rule):** ```powershell cd "F:\SDBZ Recomp" .\build.ps1 RelWithDebInfo ``` then: ```powershell cd "F:\SDBZ Recomp" & "F:\SDBZ Recomp\launch_recomp.ps1" -Determinism 0 -RunSeconds 90 -NoDebugger -HostProfile -Exe "F:\SDBZ Recomp\build\ps2xRuntime\RelWithDebInfo\ps2EntryRunner.exe" "EXITCODE: $LASTEXITCODE" ``` **Session summary — files actually changed this session (GitHub Copilot / Claude Sonnet 5, 2026-07-28):** `ps2xRuntime/src/lib/Kernel/HostSampler.cpp` (one fix, see above) and this file (`PS2_PROJECT_STATE.md`, handoff/session logging). No other source files were edited. No builds were run by the agent; all builds/runs were run by the user per the standing rule. Governing rules for this repo were recorded to persistent agent memory at `/memories/repo/rules.md` (outside the repo tree, survives across sessions for this agent). ### BUG-025 (open, 2026-07-28) — the hardware-watchpoint armer is always-on and costs ~20% CPU `hwWatchArm()` (`game_overrides.cpp:1388`) spawns `hwWatchArmerMain` **unconditionally** on every `rpc_call`/`0x178BE8` true entry — there is no env gate. The armer then loops forever at 4 Hz doing a full `CreateToolhelp32Snapshot` plus `SuspendThread`/`GetThreadContext`/`SetThreadContext`/`ResumeThread` on **every thread in the process** (`hwWatchSweep`, `:1227-1270`). **Measured cost: 19.06s of CPU in a 96s run** (third-busiest thread in the `[hostprof]` report). This **supersedes an earlier measurement of "~0.3s over 62s", which was wrong.** Two consequences: 1. It is a ~20% tax on every run, for a probe whose investigation (RAOUT / SetVSyncFlag) closed on 2026-07-27. 2. It **suspends the very threads being profiled, 4×/s** — it perturbs any performance measurement it coexists with, including the RelWithDebInfo A/B queued above. 3. Its unpaired `SymInitialize` is what broke `HostSampler`'s symbol resolution (fixed on the sampler side, but the underlying double-init is still there). **★ FIX APPLIED + BUILT 2026-07-28 (Claude Code session).** Debug build completed 09:26:09 with zero errors (`game_overrides.obj` 09:25:22, `ps2_runtime.lib`, then a full relink of the 730MB exe). Verified at the binary level: the exe now contains the `PS2X_HWWATCH` string alongside the pre-existing `PS2X_HWWATCH_VAL`. **VERIFIED at runtime 2026-07-28**: the gated-off baseline ran and the armer thread is absent from `[hostprof]`. It cost more than its own 19.06s — the two real threads rose 43.8+40.6s → 67.84+65.47s CPU (total 139s → 156.7s, 163% of one core), i.e. suspending every thread 4×/s was throttling the workers by ~55%. Symbol resolution is also correct for the first time now that the unpaired `SymInitialize` is gone, so this profile supersedes earlier attributions. **★★★ CONTROL ARM CAPTURED — third attempt, 2026-07-28, VALID.** Armer gated off, dispatch-miss log capped and built, and this time the guest did **not** derail: the watchdog held `pc=0x421f10`/`lastCall=0x172998` with `stuckSecs` cycling and resetting, `progress` climbing to **12101** at t=89. Baseline to compare RelWithDebInfo against: | metric | Debug baseline | |---|---| | `[cputime]` | wall 96.52s · **cpu 99.53s** · 103.1% of one core (auto-stopped) | | tid 6388 (presentation) | 46.14s — `GS::copyFrameToHostRgbaUnlocked` / `latchHostPresentationFrameUnlocked` | | tid 20108 (rasterizer) | 44.42s — `GSRasterizer::writePixel`, `PixelStorageTraits<48>`, `::Write` | | tid 9288 | 6.97s `Wait/ExecutionDelay`, absent from `[hostprof]` — unexplained, not chased | | watchdog `progress` @ t=89 | **12101** (vs 8523 on the derailed run — +42% guest work in the same time box) | | `gif/s` / `vbl/s` | 3–5 / 3–6 (normal 30–60 ⇒ still ~10× slow) | | `PixelStorageTraits` share | **≈48%** of tid 6388, **≈25%** of tid 20108 | | `_RTC_CheckStackVars` | 7.0% / 8.5% — Debug-only, `/O2` deletes it outright | **`progress` is the throughput metric, not wall clock** — `-RunSeconds 90` fixes the wall time by construction. **Retraction:** the earlier reading that gating the armer "bought ~55% more throughput" (43.8+40.6 → 67.84+65.47s) was wrong — that rise was the log-spam livelock, not worker throughput. On the clean run the two threads sit at 42.6+41.6s, statistically identical to the armer-on run. The gate bought the armer's own ~19s plus correct symbols, nothing more. **Also reinstated:** with the unpaired `SymInitialize` gone, symbols are trustworthy, and **both** hot threads really are `PixelStorageTraits`-dominated — the claim retracted while symbols were suspect is now confirmed on clean data. **Historical — the middle baseline was void (fix applied + built + verified).** The guest derailed to `pc=0x1` at t≈32s (`stuckSecs=57` at t=89; `frametrace #10901 func=0x178be8 exitPc=0x1 exitRa=0x1` — the known non-deterministic `0x1` clobber). For the remaining ~64s the dispatcher retried the bad target in a tight loop, and the **unbounded** `std::cerr` at `ps2_runtime.cpp:1289` emitted a ~1KB line per iteration: **225MB of `run_log.txt`** and **65.47s of CPU — 42% of the entire run** — in `ntdll!ZwWriteFile` + `MSVCP140D` iostream formatting, on a thread doing no guest work at all. That made the run look CPU-bound on emitted code when two thirds of it was our own diagnostics. Fix: hoisted the existing `s_missProbes` counter above the `cerr`, kept the first 8 lines, then silence plus a running total every 100000 (a bounded counter with no heartbeat cannot distinguish a spin from a stall). The probe sink 30 lines below had been capped at 8 for this exact reason long ago; the human-readable line beside it was simply missed. **Rebuild Debug and redo the baseline before starting the RelWithDebInfo arm** — editing `ps2_runtime.cpp` touches 1 obj in the 47-obj `ps2_runtime` lib and does not invalidate the 808 finished RelWithDebInfo unity objs. Also note `-RunSeconds 90` time-boxes the run, so wall clock is fixed at ~96s by construction: compare **CPU seconds and the per-thread profile**, never wall time. The hold-until-after-the-A/B plan was reversed, deliberately: it contradicts point 2 above. Leaving the armer on during the A/B does not keep the variables separable — it taxes **both** arms by ~20% and suspends the profiled threads 4×/s while doing it. Because the fix is an **env gate**, the two variables stay separable *at runtime* instead of across builds, which is strictly better: one binary can produce a gated-off perf run and a gated-on writer hunt, and no build is ever spent to switch between them. Two edits, no header, no recompiler run: 1. `game_overrides.cpp` — new `hwWatchEnabled()` (function-local `static` so `getenv` runs once, never on the hot path) and an early-out at the top of `hwWatchArm()`. Gating the *entry point* rather than the `detach()` at `:1403` means the address publishes stop too, not just the thread spawn. 2. `launch_recomp.ps1` — new `-HwWatch` switch, mirroring `-HostProfile`. Sets `PS2X_HWWATCH=1` and prints a yellow warning that perf numbers from that run are untrustworthy; **clears the var when absent**, since env vars persist across runs in the same shell (the same trap `-HostProfile` already documents). Default is **off**. `PS2X_HWWATCH_VAL` (the value filter) is unchanged and still only read once the armer is enabled. **Side effect, wanted:** with the armer off, the unpaired `SymInitialize` in point 3 no longer runs at all, so `HostSampler` gets a clean first init. Its `ERROR_INVALID_PARAMETER`-tolerant workaround stays in place and still covers the `-HwWatch` case. **Rebuild cost is small — this does *not* touch the stalled runner build.** `game_overrides.cpp` is in the `ps2_runtime` STATIC lib (`CMakeLists.txt:415`, 55 objs), not the 4520-TU `ps2EntryRunner` unity target. One obj recompiles; the 808 finished `RelWithDebInfo` runner objs are untouched and the build resumes rather than restarting. ### RelWithDebInfo build — it is already 18% done, and the default `-Jobs 2` is the real cost (2026-07-28, Claude Code) The A/B build was **already started and stalled**, not never-begun. Measured from the build tree, not assumed: | | | |---|---| | Ran | 04:14 → 06:36 today (2h22m), no build process alive since | | Progress | **808 of 4520** unity TUs (`ps2EntryRunner.dir/RelWithDebInfo`, 497 MB) | | Order | descending — newest obj `unity_3707`, oldest `unity_4500` | | Rate | ~5.7 obj/min at `/m:2` | | Remaining at that rate | **≈11 hours** | **`build.ps1` defaults to `$Jobs = 2` (line 7). This machine is 6 cores / 12 threads.** Nothing in the build is serialized by design — the job count is just an unexamined default. Passing `6` should bring the remainder to roughly **3–4 h**. Disk is not a constraint (168 GB free; the full obj set projects to ~2.8 GB from the 497 MB/808 sample). **Do NOT clean.** Two reasons, the second decisive: 1. The 808 objs are valid and MSBuild resumes past them — a clean pays for them again. 2. A clean also destroys the **Debug** tree (4520 objs + a working 730 MB exe from 03:38). That tree is the **control arm** of the A/B. Without it there is no baseline to compare against, and re-creating it is a second multi-hour build. The comparison is the entire point of the exercise. **The `/SUBSYSTEM:WINDOWS` caveat is real but does NOT apply to this launcher — stand down on it.** `ps2xRuntime/CMakeLists.txt:595-596` does add `/SUBSYSTEM:WINDOWS` + `/ENTRY:mainCRTStartup` for `RelWithDebInfo`, so the process gets no console of its own. But `launch_recomp.ps1:281-283` runs the exe through a **pipeline** (`& $Exe $Elf 2>&1 | Tee-Object -FilePath $Log`), and a piped child inherits the redirected stdout handle regardless of subsystem — the CRT still writes to it. `[hostprof]`/`[watchdog]` output will reach both `run_log.txt` and the console. No CMakeLists edit is needed. (`run_log.txt` remains the authority if the console looks thin.) ## Session 2026-07-27 — ★★★ 5.4.1 PASSED: guest EE COP0 `Status.EIE` is now honoured by the fiber scheduler. Derail collapses to one bracketed event → 5.4.2 **The fix.** The cooperative fiber scheduler was preempting the guest inside the guest's own interrupt-disabled critical sections. The guest uses `cop0 0x39` = `di` (`DisableIntr`, `0x17ED60`) and `cop0 0x38` = `ei` (`EnableIntr`, `0x17EDB0`); the runtime ignored both. Two changes, no header touched: - `ps2_scheduler.cpp` — `ps2x_guest_intr_disable_enter/leave/depth` + counters, and a gate at the top of `ps2sched::yield_point()` that refuses to yield while the guest has interrupts off. - `game_overrides.cpp` — hooks those two guest addresses in the wrapper and emits the `CRITSEC` probe. **Critical design point: a single bit, NOT a nesting counter.** `DisableIntr` returns the *previous* EIE state, and the guest idiom is `old = DisableIntr(); …; if (old) EnableIntr();`. Enter and leave therefore **do not balance** — a counter drifts upward forever and pins the gate on. The first attempt was a counter and it read `depth=0x2` permanently. `redundant`/`stray` counters exist precisely to make the unbalanced calls visible instead of corrupting the state. **Escape valve:** `kIntrDisableYieldEscape = 4096` samples. A guest that never re-enables must not deadlock the host. **`escapes` staying at `0` is a pass condition** — a non-zero value means the valve is carrying the run and the gate is not really working. ### Verification run (`-Determinism 0`) — all six criteria pass | Field | Required | Measured | |---|---|---| | `redundant`/`stray` printed | present (= new binary) | ✅ present | | `depth` | `0` at the `ei` hook | `0x0` every record | | `sections` | must climb | `0x1 → 0x2 → 0x3` | | `escapes` | must stay `0` | `0x0` (was `0xa9`) | | `stray` | `0` | `0x0` | | `redundant` | non-zero (the idiom) | `0x2` | Only 3 `CRITSEC` records total, which is correct: the probe self-suppresses once `sections > 3` and neither `escapes` nor `stray` moves. **`DEFERINL` went 16 records → 0.** All 16 had been `cause=0x5` (SIF0) with `depth=0x2`. The inline `is_guest_thread()` DMAC dispatch path — reachable synchronously from `sceSifSetDma`/`sceDmaSend`/`drainCompletedDmacHandlers` and *not* covered by the host-worker yield gate — no longer fires inside a critical section. **That path is closed without needing a deferral queue.** It had been flagged as "a real uncovered path awaiting re-measurement"; it is now measured and clean. **The derail did not vanish — it lost its camouflage.** Old runs churned to progress ~692,915 with the escape valve carrying everything. This run: 288 records, 6 families (`SLOTENTRY` 264, `MISS` 8, `RASLOT` 7, `GSENTRY` 5, `CRITSEC` 3, `EECREATE` 1), progress span 646–1732, one derail at 1732. `RASLOT` #1–#6 (progress 681–684) are all `bad=0x0` with `saved == entryRa == liveRa`; only #23 is bad. Note `at = entrySp - 0x10`. Full bracket → stage **5.4.2** in the tracker. **`RASLOT --bad` is now trustworthy.** It returns exactly one record. Its empty result on the three stale-binary runs was meaningless, as suspected at the time. ### Do not act on the trapval hits 6 `[trapval]` hits appeared, all at `addr=0x1ffbeb0` — the exact bad slot: hit 1/2 `size=16 pc=0x102894`, 3/4 `size=4 pc=0x171d14`, 5/6 `size=16 pc=0x171c3c`. `0x171d14` sits `0x10` past the old 5.4.1 `jalr $s5`, which is seductive. **Ignore it.** `game_overrides.cpp:1003` already records *"`PS2X_TRAPVAL` is RETIRED; it produced only noise"*, and `launch_recomp.ps1:56` defaults it to `'0'`, which fails the arming condition at `ps2_runtime.cpp:2529` — so these hits' provenance is unconfirmed. This is the same shape as the retracted 07-25j "only one PC writes `0x1ffbeb0`" claim, which rested on a 2-hit sample. ### Tooling notes - `analyze_run.py` has **no `--list`**. Bare invocation is the summary. Options: `-f --probe --bad --derail --threads --signature --limit`. Exit 0 = query answered, 2 = sink missing/empty. - **The stale-object-file bug bit for the third time** and cost three consecutive runs. Discriminator used to catch it: old binary → `CRITSEC` prints **3** fields with `depth=0x2`; new binary → **5** fields incl. `redundant`/`stray`, `depth ∈ {0,1}`. Fix is force-touching both source mtimes. **Always verify a probe's field set matches the source before believing a run.** ### Learned patterns - *A PS2 guest's `DisableIntr` returns the previous state, so `di`/`ei` pairs are deliberately unbalanced. Model interrupt-disable as a single bit; a nesting counter drifts and pins on.* - *An escape valve on a correctness gate must be instrumented, and a zero count must be part of the pass criteria — otherwise the valve silently becomes the mechanism and the gate looks like it works.* - *Removing scheduler noise does not create a new bug; it uncovers the one that was always there. The derail moving from 414 records at progress 692,915 to 1 record at progress 1,732 is the fix working, not a regression.* - *Before trusting any run, check that each probe emits the field set the current source emits. A stale object file reads as a valid measurement and has cost 3 runs, three separate times.* - *`SLOTENTRY`'s `before=` field brackets a clobber to a single call-to-call step; `RASLOT` only reports it after the damage. Prefer the field that changes at the violation.* ## Session 2026-07-26 — ★ METHOD RESET: determinism + structured probe sink + invariant guards (plan `gentle-launching-dusk.md`) **Why this session exists.** Four declare-then-retract cycles on one symptom family in ~10 days (07-19c, 07-20f, 07-23d, 07-25j) is a measurement problem, not an effort problem. Every conclusion so far rested on n=1 runs, against our own expectations rather than a known-good execution, with probes that fire where damage is *observed* instead of where the invariant *breaks*, extracted by hand-written `Select-String` over a UTF-16 console log. The 5-phase plan replaces that pipeline. Phases A and B are DONE and passed their exit tests; C is code-complete and unbuilt; D and E are not started. ### Phase A — determinism ✅ PASSED - `PS2X_DETERMINISM` in `ps2_scheduler.cpp` + vblank pacing off guest progress (`PS2X_DET_VBLANK_QUANTUM`, default 20000 ticks/vblank); `-Determinism 0|1` parameter on `launch_recomp.ps1` (precedence: parameter > pre-set env var > default 1). - Harness: `build_scripts/repeat_run.ps1`, `-Scope derail|full`. - **Exit test: 5/5 identical derail signature at `-Determinism 0`, derail scope.** - ⚠️ **`PS2X_DETERMINISM=1` DOES NOT DERAIL AT ALL** (runs to 800,748 progress ticks). A det=1 run produces an empty `--derail` report and reads as healthy when it is only quiet. **Every diagnostic run must be `-Determinism 0`** (dies at 1,756 ticks). The launcher prints a yellow warning when det=1. - Deviation from plan: the watchdog OS thread was found already lock-free, so no gating was applied; it was given a `progress=` field instead. ### Phase B — one probe sink, one parser ✅ PASSED - `ps2x_probe_kv(name, n, keys, vals)` in `game_overrides.cpp` writes **ASCII JSONL** to `run_probe.jsonl` on its **own file descriptor** — never through the console pipe, so UTF-16 and console line-wrapping are permanently eliminated as error sources. Path via `PS2X_PROBE_FILE` (set by the launcher); opened `"w"`, so each run owns its sink. - **Schema rule: every value is a hex string, without exception, including counters** — the reader does `int(v,16)` uniformly and never has to know which key is an address. Auto fields on every record: `seq`, `tid`, `progress`, `probe`. - Parser: `build_scripts/analyze_run.py` — `--derail` (standing query), `--threads`, `--probe NAME [--bad]`, `--signature`, `-f`. Exit 2 = sink missing/empty. - **Exit test met:** the known `RASLOT #23 BAD` record reproduced straight from JSONL with zero hand-grepping. ### ★ What the sink measured that we never had before ``` RASLOT seq=275 progress=1756 tid=0xfdd5 n=23 slot=0x178be8 at=0x1ffbeb0 saved=0x1 entryRa=0x1bb0b0 entrySp=0x1ffbec0 liveRa=0x1 MISS seq=276 progress=1756 tid=0xfdd5 pc=0x1 codeRegion=0x0 gap: 1 record, 0 progress ticks ``` - **`entryRa=0x1bb0b0`** — the caller of `0x178be8`. New information; never captured before. - **The clobber→miss gap is 0 progress ticks.** The bad `$ra` is consumed by the very next return, not a delayed fuse. This proves RASLOT measures *damage*, not *violation* — exactly the justification for Phase C. - `0x1baf90` (the SPDELTA frame) and `entryRa=0x1bb0b0` are ~0x120 apart, plausibly the same function. **First concrete lead the new tooling produced; not yet followed.** - **Only one thread ID appears in the det=0 sink: `tid=0xfdd5`.** ### Phase C — invariant guards ⬜ CODE-COMPLETE, NOT BUILT, NOT RUN Changed `Kernel/Syscalls/Thread.cpp` (4 sites) and `game_overrides.cpp` (2 call sites + 1 file-scope decl). Three new probe families: - **`EECREATE`** — the guest's *declared* `entry/stack/stackSize/gp/prio/attr` at `CreateThread`. Emitted even when `stack==0`, because that is the precondition for the fallback below. - **`EESTART`** — the *resolved* `sp`, plus `callerSp` and a **`borrowed`** flag. `borrowed=1` means `info->stack` was still 0 after both fixups, i.e. the fiber is running on the creating function's stack pointer — the `Thread.cpp:398` latent bug, now self-reporting instead of arguable. - **`STACKOOB`** — per-tid stack-bounds guard, registered at `StartThread`, checked at wrapped-slot entry (`site=0`) and exit (`site=1`). **Bounded to 16 reports** (an OOB `sp` stays OOB, so unbounded would reproduce the 89,000-line flood). Borrowed-stack threads register `hi=0` = "unknown, do not check" rather than a fabricated range — a guard firing on invented bounds is worse than no guard. Self-disabling for threads that never went through `StartThread`, so it is safe to leave armed in every run. - **Deviation from plan, deliberate: the frame-ownership guard was NOT built.** The det=0 sink shows exactly one thread, and a cross-fiber stack stomp is impossible with one live fiber. `--threads` gives the real EE thread count; build the guard only if it comes back >1. - Stack ranges live in a file-scope `std::unordered_map` in `Thread.cpp`, **not** as a `FiberContext` field — adding one would be a `.h` edit (§3 prohibition 3, 30h rebuild). **Next session starts here:** ``` cmake --build "F:\SDBZ Recomp\build" --config Debug --target ps2EntryRunner (user runs, under vcvars64) & "F:\SDBZ Recomp\launch_recomp.ps1" -NoDebugger -Determinism 0 python "F:\SDBZ Recomp\build_scripts\analyze_run.py" --threads ``` Both changed files are in `ps2_runtime` → incremental, minutes, not the 30h path. Expect the false hang after the derail (see below); Ctrl+C once the console goes quiet. ### Corrections this session puts on the record - **The clobber value is `0x1`, NOT `0x20561900`.** `0x20561900` is a legitimate EE uncached-mirror alias of `0x00561900` and is not corruption at all. `PS2X_TRAPVAL` therefore now **defaults OFF** — it was keyed to `0x20561900` and had been matching nothing. Do not simply set it to `0x1`: storing the literal 1 is overwhelmingly common in normal guest code and the trap would fire constantly. - **`0x174ca0` is `CreateSema`, not `CreateThread`** — it is `syscall` with `$v1 = 0x40`; `Dispatcher.cpp:166` maps `0x40 → CreateSema` (`0x20` is CreateThread). Previously asserted wrong and nearly recorded as fact. - **EE thread creation was completely invisible in the logs** until Phase C — the only `CreateThread` lines anywhere in `run_log.txt` were `[iop:import]` ones. We have been running guest fibers on guest stacks for weeks with no record of where those stacks are. - `0x178be8` is exonerated as a self-writer; `0x178428` is exonerated. The `sw $v0,48($s0)` hypothesis and the allocator hypothesis are both disproved. - `LEAFENTRY` emits 0 records in both det modes — unexplained, not chased. ### The false "hang" — read this before cancelling anything After the derail, the dispatcher jumps to the bad target forever and emits `Error: No exact recompiled function for guest PC` **~89,000 times in 25 s (47 MB of log)**. `$Mute` in the launcher suppresses it from the **console only**, so the terminal looks frozen while the process is still writing hard. **This has been misread as a hang twice.** Anything parsing `run_log.txt` must stream (`StreamReader`), not slurp — and in a line-oriented parser the "continuation" branch must be tested **last**, because that error line has no `[` prefix and will otherwise glue ~88k lines onto one record. ### Phases D and E — not started - **D ★ (the oracle):** PCSX2 boots this game correctly and is confirmed running. Breakpoint the 46 addresses already in `build_scripts/boot_chain_addrs.txt` via `mcp__pcsx2__*`, emit `(pc, sp, ra, tid)`; emit the same schema from the recomp through the Phase B sink; `build_scripts/diff_trace.py` names the **first** divergence. This converts "which probe do I write next?" into "the machine names the first instruction where we differ." **First test case should be the `0x178be8` `$ra` question.** - **E:** `build_scripts/measure.ps1` — incremental build → deterministic run → parse JSONL → verdict → exit code. ### Learned patterns - *A non-deterministic run cannot support a causal claim; fix reproducibility before doing any more diagnosis.* Four retractions traced to n=1 evidence. - *A probe that fires on observed damage measures the victim, not the writer.* Prefer guards that abort at the first invariant violation. - *Structured output on a dedicated fd removes an entire class of wrong answers.* Two prior wrong conclusions came from the extraction layer (UTF-16 switching, line-wrap record gluing), not from the data. - *`extern "C"` is legal only at namespace scope* — never inside a function body. The `.h` prohibition workaround is a file-scope `extern "C"` re-declared in each consuming `.cpp` (precedent: `ps2FrameTraceRecord`, `ps2x_guest_progress`, `ps2x_probe_kv`, `ps2x_stack_check`). - *Bound every new diagnostic emitter.* The unbounded dispatch-miss path produces 47 MB in 25 s and looks exactly like a hang. ## Session 2026-07-25n — `deliverSifRpcReply` `entryRa=0` fix VERIFIED not the cause; stale-object-file bug found + fixed mid-session - **Hypothesis tested:** 48 LEAFENTRY samples all showed `deliverSifRpcReply` entering the nested guest dispatcher (`0x178068`) with `$ra` never set (`entryRa=0x0`). Wrote 3 edits: (1) `game_overrides.cpp` LEAFENTRY filter excludes `entryRa==0` so its 48-line budget isn't wasted on this known case; (2)/(3) `SIF.cpp` — capture `savedRa` before the nested run, set `$ra=savedPc` (a real in-range sentinel outside `[kSifDispatcherFn,kSifDispatcherEnd)` so the mini dispatch loop's break condition still fires), restore `savedRa` in the epilogue alongside `pc`/`sp`. - **Result: does NOT fix the derail.** Rebuild+run still shows `LEAFEXIT #1 callee=0x178be8 entrySp=0x1ffbf70 entryRa=0x1c0728 exitRa=0x1` — same slot, same corruption. Zero `LEAFENTRY entryRa=0` lines this run (confirms that path is now closed off, but it was never the writer). **`entryRa=0` nested-dispatcher entry is a real, now-fixed defect, but it was not the source of the `0x1ffbf60`/`0x1c0728→0x1` clobber.** Do not revisit this hypothesis. - **Stale-object-file bug (separate, found mid-session):** after the above rebuild, `SLOTWATCH` log lines were missing their `expected=`/`before=`/`now=` fields even though the current `game_overrides.cpp` source (the `oss <<` chain at the SLOTWATCH emit site) clearly includes them. Verified byte-exact (not a `Select-String` line-wrap artifact — raw UTF-16 bytes end right after `slot=0x...`, LEN=92). The exe's timestamp was newer than the source file's, but MSVC apparently relinked from a stale object for this translation unit without recompiling it (same failure class previously logged in the 07-24g note: *"after fixing an MSVC mtime-staleness bug that had silently prevented `game_overrides.cpp` from recompiling across ~3 prior 'successful' builds"* — this is a recurrence, not a one-off). **Fix applied:** touched `game_overrides.cpp`'s mtime (`(Get-Item ...).LastWriteTime = Get-Date`) to force the build system to see it as changed. User has not yet rebuilt/re-run since the touch — **next session must verify the touch fixed it** (SLOTWATCH lines should carry full fields) before trusting any SLOTWATCH-based conclusion from a build following a "did this rebuild" doubt. - **Standing lesson:** when a probe's log output is missing fields that are unambiguously present in current source, suspect a stale object file before doubting the logic. Check exe mtime vs source mtime, but don't trust exe-newer-than-source as proof of a real recompile — relink-without-recompile is possible and has now happened twice. - **Next step (unchanged from before this session):** the LEAFEXIT/RAFORK/SLOTWATCH chain still localizes the clobber to `0x178be8`'s (`rpc_call`) own body/subtree, consistent with the pre-existing 07-25h/i/j(retracted)/k narrowing. After confirming the stale-object fix took, re-run and read full SLOTWATCH `before=`/`now=` fields — this is the data needed to bracket the writer to a specific frame instead of "somewhere in the subtree." ## Session 2026-07-25d — ★★★ 5.3.2 `$ra=0x1` SIF stomp RESOLVED+VERIFIED (fix 3, savedSp-relative scratchTop). New blocker: func-map gap `0x1bf2e0` - **Root cause of fix (2)'s failure, finally pinned:** the per-depth scratch bands (07-24) were anchored to a *fixed absolute* `kSifReplyScratchStackTop` near RAM-top. SLOTWATCH measurement this session showed the real (non-reentrant, `depth=0`) `deliverSifRpcReply` dispatcher call chain descends **~0x4110 bytes** — enough to blow through the fixed top's available clearance *and* the 8KB per-band size, landing within `0xC0` bytes of the real caller's saved-`$ra` slot even at depth 0. This explains why 07-24g/07-25b/c kept finding "a second untracked writer" outside the tracked re-entrancy path — it was the same scratch-stack collision, just not depth-gated the way the SLOTWATCH probes assumed. - **Fix (3), `SIF.cpp` only:** replaced the fixed top with a headroom relative to the *live* `$sp` captured at `deliverSifRpcReply` entry: `scratchTop = savedSp - kSifReplyScratchHeadroom(0x8000) - (depth * kSifReplyScratchStackSize(0x2000))`. This is structurally safe regardless of how deep the real stack already is or how far the dispatcher descends — the scratch region can never overlap the live frame chain. Diagnostic log extended with `savedSp=0x...`. - **Build + run this session confirmed:** `scratchTop = savedSp - 0x8000` on every sampled `[SifRpcReply]` line (20+, `depth=0` throughout this run), **zero `$ra=0x1`/`PC=0x1` symptoms** anywhere before the new blocker. ARKD `[ARKD:CALL]`/`[ARKD:run]` completes (`retired=46 halted=1 delivered=1`), `[SifRpcReply:LOADFILE] func255 <- "3000"` fires, dozens of BIND/CALL round-trips succeed with climbing seq numbers. **Boot goes further than any prior run.** Stage 5.3.2 is CLOSED. Full detail in [[reference_ps2_sif_boot]]. - **NEW LIVE BLOCKER (5.4):** `[guest-branch:missing-target] kind=IndirectJump target=0x1bf2e0 pc=0x1bf2e0 ra=0x171d0c codeRegion=yes` → `Error: No exact recompiled function for guest PC 0x1bf2e0`. `sdbz_func_map_merged.csv` confirms a genuine 44-byte coverage gap (`sub_1BF290` ends `0x1bf2d4`, `pool_entry_push` starts `0x1bf300`, nothing covers the range between). Same class as the already-fixed `0x180d30`/`0x1a4500`/`0x1c0170` gaps — a function Ghidra's static pass never found as a distinct entry (likely reached only via this indirect/vtable dispatch, no direct-call xref to anchor it). Not a stomp/corruption bug; once dispatch fails here execution can't recover and the log tail degrades into an infinite `pc=1`/`ra=1` watchdog spin (superficially resembling the old bug — it isn't). - **Next step:** disassemble/decompile the `0x1bf2d4`-`0x1bf300` gap (IDA/Ghidra or `mips_r5900_disassembler.py` on the ELF) to recover the real function body, then gap-fill via the established pattern (`registerFunction` in `game_overrides.cpp` if a runner body exists, or a hand-translated native override per the `0x1c0170`/`sdbzLeaf1C0170` precedent if it doesn't). ## Session 2026-07-25k — ★★★ 07-25j RETRACTED IN FULL. GS exonerated by measurement; real derail is `pc=0x30`, a bad ctor fnptr **The 07-25j section below is kept only as a record of a disproven line. Do not act on it.** **What was measured (GSENTRY probe, built + run this session).** The 07-25j SLOTENTRY probe could never have logged GS at all — it sits behind a `slotWatchAddrPre != 0u` gate, i.e. it only emits while `rpc_call` (`0x178be8`) has a watch armed, and `GS_DispatchPending` runs outside that window. The empty `SLOTENTRY.*102870` grep was a **probe-design artifact**, not evidence. Replaced with an ungated `GSENTRY` probe in `game_overrides.cpp` (bounded to 64 lines, keyed on `kSdbzFrameTraceSlots[I].funcStart == 0x00102870u`). **Result — all 5 GS entries identical:** ``` [frametrace:GSENTRY] #1..#5 tid=0xbd24 entryPc=0x102870 entrySp=0x1ffbf00 entryRa=0x104c9c ``` Branching per 07-25j's own stated exit test: - `entryPc == 0x102870` on every hit → **real entry**, not resume-slot aliasing. - `entrySp` **stable at `0x1ffbf00`** across all 5 → not a stale/handed-down `sp` (that would vary). - `entryRa = 0x104c9c` → inside `SyncFrame` (`0x104c00`-`0x104cd4`, funcmap-confirmed) → a legitimate caller. `GS_DispatchPending` is behaving correctly. It is not the writer and there is no frame overlap. **Two further falsifications of 07-25j:** 1. **Single OS thread throughout.** tid histogram over all 285 wrapper lines: `tid=0xbd24` x285, plus five `0x10`-`0x14` singletons that are startup noise. The "two execution contexts sharing one guest stack" reading does not hold. 2. **The trapval band was measuring noise.** Stacks grow DOWN, so GS entering at `0x1ffbf00` and pushing to `0x1ffbeb0`/`0x1ffbec0` is the main thread's *ordinary* frame usage. Trapping the value `0x1` in `0x1ffbe80`-`0x1ffbf00` catches routine `sq`/`sw` register pushes that happen to hold 1 — 151 hits of nothing. 07-25j's headline "exactly one PC ever writes `0x1ffbeb0` — `pc=0x102894`" rested on a run with only **2** hits; this run shows **six** writers (`0x171c38` x12, `0x171c3c` x10, `0x171c5c` x4, `0x171d14` x3, `0x102894` x2, `0x2b6038` x1). **Drop `PS2X_TRAPVAL` from future runs — it only bloats the log.** **Correction to the reported symptom: the derail PC is `0x30`, not `0x1`.** Histogram of every `No exact recompiled function for guest PC` line in this run: | Count | Derail PC | |---|---| | 414 | `0x30` | | 1 | `0x830` | | 1 | `0x6` | Zero `0x1`. (The last two are interleaved-stderr garbling, not real derails.) The `pc=0x1` spin quoted at the start of this session came from the **previous** run's log. First derail at raw log line 2705. **The new lead (stage 5.4.1).** Trace tail immediately before the first derail: ``` ... 0x171f10 -> 0x327908 -> 0x3a27a0 -> 0x3a2430 -> 0x171c30 -> 0x3a2760 -> 0x3a2760 -> 0x171f10 -> 0x3a2f30 -> 0x3a2c60 -> 0x171c30 -> PC = 0x30 ``` `0x171c30` = `array_call_ctor_dtor` (`0x00171c30`-`0x00171d54`), a `__cxa_vec_ctor`-style helper: `a0`=array base, `a1`=ctor fnptr, `a2`=dtor fnptr, `a3`=elem size, `t0`=count. Two indirect calls: ``` 0x00171cdc jalr $ra, $v0 ; dtor, from a2 -- null-guarded at 0x171cb0, NOT range-guarded 0x00171d04 jalr $ra, $s5 ; ctor, from a1 -- NOT guarded at all ``` A C++ array constructor is being invoked through the value `0x30`. The immediate caller `0x3a2c60` sets up valid arguments (`a1=0x3a2ef0` ctor, `a2=0x3a2d20` dtor, `a3=8`, `t0=2`), so the bad pointer is **not** statically obvious at that site — it arrives corrupted, or a later loop iteration reads it back from the frame scratch slots `sp+0x94`/`0x98`/`0x9c`/`0xa0` (held in `$s1`/`$s7`/`$s6`/`$s0`), which sit above the saved-register area inside the `0xb0` frame. Also noted at the caller: `0x3a2c6c beq $v1,$zero,0x3a2cac` with `lui $a0,0x61` in the **delay slot**, redundantly repeated at `0x3a2c74` on the fallthrough — a translation pattern worth checking if the scratch-slot theory doesn't pan out. **Standing caution:** the ~50 repetitions of `0x38afb0` in the pre-derail trace are the watchdog's **global cross-thread history**, NOT a call chain. Do not read them as recursion depth. **NEXT SESSION STARTS HERE:** disassemble the other `0x171c30` call sites in the chain (`0x3a2430`, and whatever `0x3a2f30`/`0x327908` feed) to find which one supplies `0x30` as `a1`, or instrument `array_call_ctor_dtor` entry to log `a1`/`a2`/`t0` per call. ### Session 2026-07-25r — 5.4.1 correction: two probe threads falsified, `$a1`-at-entry is the surviving lead Three claims recorded earlier in 5.4.1 are **wrong** and must not be re-chased: 1. **"`0x30` is `t0`/`$s3` (the element count), not `a1`."** Backwards. `$s5 = $a1` at `0x171c50` and `jalr $s5` at `0x171d04`, so `PC=0x30` means **`$a1` was `0x30` on entry**. That `0x30`=48 is also count-shaped is the *clue*, not a refutation: something is putting a count where a function pointer belongs. 2. **"The ctor `0x2b9300` bails out before its epilogue."** Dead. The ctor never runs: [array_call_ctor_dtor_0x171c30.cpp](ps2xRuntime/src/runner/array_call_ctor_dtor_0x171c30.cpp) L280 `if (jumpTarget == 0u) { ctx->pc = 0x171D0Cu; }` skips the `jalr` outright on a null target — no `lookupFunction`, no wrapper, no callee. 3. **"`$s5` is zeroed mid-loop / the indirect-call return path is systemically broken."** Falsified by reading the function. `0x171c44` `sq $s5,0x50($sp)` saves the **caller's** `$s5` *before* `0x171c50` overwrites it, and `0x171d34` `lq $s5,0x50($sp)` restores it. So the S5ZERO signature (`exit $s5=0`, `spDelta=0xb0`, `exitRa==exitPc`, `s3->0x4b6dfe`) is a **normal epilogue + return**. All 8 "ZEROED-HERE" records were healthy. The "8 different damaged ctor pointers" reframe is retracted with it. **Why this went unnoticed:** the `VECCTOR` probe prints `a1`/`t0` **after** `original(...)`. Those registers are caller-saved, so its "`a1` always small (0x2–0x1f)" readings were exit garbage, never entry values. **Live probe:** `A1SITE` in `game_overrides.cpp` — true-entry only (`entryPc==0x171c30`), sampled **before** the body, logging `caller(entryRa) a0 a1 a2 a3 t0` + an `a1kind` verdict, budget 96. The dead `CTOREXIT` and `S5ZERO` blocks were deleted in the same edit. ### Session 2026-07-25t — the derail is a corrupt `$ra`, and `0x30` was never real **`0x171c30` is exonerated. Stage 5.4.1's whole premise is retracted.** `CTORTGT` ran with the probe provably alive (same run: `VECCTOR`=73 records, `A1SITE`=0, so the new binary was live) and emitted **zero** records — every `jalr` target through `0x171c30` was a valid code pointer. With `A1SITE`'s 96 clean caller records, that function neither receives nor dispatches a bad value. **`0x30` is not a constant of this bug.** This run's derail PC is `0x1` (301 hits) + `0x11` (2). `0x30` does not appear at all. Every hypothesis that read meaning into `0x30` specifically (element count, element size) was chasing a value that changes run to run. **What it actually is** — from the fault's own dump: [guest-branch:missing-target] kind=IndirectJump op=dispatch source=0x0 target=0x1 pc=0x1 ra=0x1 `ra == pc == 1`. Not a bad *call* target — a **`jr $ra` with a corrupt `$ra`**. The dispatcher is the victim, not the crime scene. Every probe so far watched call targets, which is why none of them saw anything. **The cycle, all four members read from the ELF and all innocent:** `0x32bca0` (getter) → `0x191780` (strcpy) → `0x32bbb0` (ctor) → `0x32bca0` → `0x191898` (strlen). - `0x32bbb0`'s two `jalr $t9` sites both emit `SET_GPR_U32(ctx, 31, )`. - vtable `0x4F3250[+8]` == `0x32bca0` — the indirect target is correct. - `gp_field_get_z_396_0x32bca0` correctly does `ctx->pc = $ra`. Nothing here *creates* `ra=1`. A function is **entered** with `$ra` already corrupt; the damage only surfaces when it later reaches its own `jr $ra`. **Live probe:** `RABAD` — global, fires on any callee entered with an `$ra` that cannot be a return address (`entryRa == 0` excluded; that thread is closed). Logs callee/entryPc/ra/sp/gp/v0/a0/t9. Budget 48. **Reading it:** the `callee=` of the first record is where corrupt `$ra` first becomes observable; `sp=` says whether a frame shift is involved — which would connect this to the parked stale-frame/`0xa0`-sp-shift thread rather than to anything in the `0x171c30` region. ### Session 2026-07-26 — RABAD proven negative; string stubs exonerated; RAOUT probe installed (BUILD PENDING) **1. RABAD is a real zero, not a dead probe.** Run produced 1475 derails (1467 at `PC = 0x1`), `VECCTOR` = 76 records (control marker alive), `RABAD` = **0**. Liveness proved three ways: exe mtime > src mtime and log mtime > exe mtime; control marker present in the same run; `findstr /M /C:"RABAD"` hits inside the exe. ⇒ **No callee is ever ENTERED with a corrupt `$ra`.** The corruption happens *inside* a function body. The entry/exit wrapper structurally cannot see that, so the instrument had to move to the exit side. **2. `strlen 0x191898` / `strcpy 0x191780` are EXONERATED.** `register_functions.cpp` shows the LIVE bodies are `strlen_0x191898.cpp` / `strcpy_0x191780.cpp` — the `fn_191898_*` / `fn_191780_*` files are a DEAD generation. Both live bodies are thin **stub forwarders** into `ps2_stubs::strlen` / `ps2_stubs::strcpy`; neither the forwarder nor the stub writes GPR 31 or corrupts `ctx->pc` (the forwarder only *reads* `$ra`). Corollary: the earlier MMI byte-zero-detection analysis was done on the dead generation and is irrelevant. **3. RAOUT probe written into `game_overrides.cpp` (NOT YET BUILT OR RUN).** Placed immediately after `original(rdram, ctx, runtime);` in `sdbzFrameTraceWrapper`, before the `isVecCtor` block. Fires when exit `GPR_U32(ctx,31)` is insane (not `0`, and outside `0x00100000`–`0x00600000`). Budget 48. Logs `callee / entryPc / entryRa / raOut / exitPc / entrySp / exitSp` — the sp pair is there deliberately to test the parked `0xa0` stale-frame theory (memory `project_stale_frame_ra_read`). **NEXT SESSION STARTS HERE — run, then interpret:** ``` & "F:\SDBZ Recomp\build.ps1" & "F:\SDBZ Recomp\build\ps2xRuntime\Debug\ps2EntryRunner.exe" "F:\SDBZ Recomp\ELF\SLUS_214.42" Select-String -Encoding unicode -Pattern 'RAOUT' "F:\SDBZ Recomp\run_log.txt" -Context 0,2 | ForEach-Object { ($_.Line + ' ' + ($_.Context.PostContext -join ' ')) } | Select-Object -First 20 ``` If RAOUT is ALSO silent, re-prove liveness the same three ways before concluding anything. Stage 5.4.1 passes only when a specific address is named, backed by a probe record from a real run plus that function's disassembly — never by inference. **Plan file `swirling-bubbling-pearl.md` is SUPERSEDED** — its premise (`$a1 = 0x30` is an element count) was falsified by A1SITE + CTORTGT. Do not re-execute its Steps 1–3. ### Session 2026-07-25s — A1SITE result: no caller supplies `0x30` `A1SITE` ran and produced 96 clean records. **Every one is `a1kind=CODEPTR`** (`0x1a4500`, `0x2fe7b0`, `0x38a370`, `0x1bfdb0`, …). Zero `SMALL-BAD`. So both branches of the 5.4.1 discriminator are refuted at once: - **"One bad caller passes a count in `$a1`"** — dead. - **"Argument-register shift / second entry ABI"** — dead. It does confirm the diagnosis of the earlier bad data: `VECCTOR`'s "`a1` always 0x2–0x1f" readings were `$t0`-shaped **counts**, sampled after `original()` clobbered the caller-saved registers. In the A1SITE records those same small values sit in `t0`, exactly where a count belongs. **Two flaws in the A1SITE run itself, both now fixed:** 1. **Wrong slots.** It gated on `entryPc == 0x171c30`, i.e. true entry only. But `jalr $s5` at `0x171d04` splits the function — the recompiler re-enters at `0x171d00`/`0x171d0c` with whatever `ctx` holds. If `$s5` goes bad *after* `0x171c50` latches it, no caller is involved and A1SITE structurally could not see it. 2. **No filter.** All 96 records were spent on healthy calls in one thread, exhausting the budget long before the derail. **Live probe:** `CTORTGT` — watches every entry slot, reads the value the `jalr` will actually use (`$a1` pre-`0x171c50`, `$s5` at the resume slots), and logs **only when that value is not a plausible code pointer**. Budget 64. **Reading it:** `slot=RESUME` records with a small `target` ⇒ `$s5` is being lost across the split; investigate the re-entry path, not the callers. **No records at all** is also a result — it means `0x30` never passes through this function's `jalr`, and 5.4.1's premise that `0x171c30` is the derail source is wrong. **Superseded discriminator (2026-07-25r), kept for history:** group records by `caller=`. Every site small `a1` ⇒ argument shift / `0x171c30` mis-identified or second entry ABI. One site small, others `CODEPTR` ⇒ that caller is the culprit; disassemble it. ## Session 2026-07-25j — ⛔ RETRACTED (see 07-25k) — `$ra`-slot WRITER "IDENTIFIED": `GS_DispatchPending` prologue, frames overlap by 0x10 **Method:** `PS2X_TRAPVAL="0x1:0x1ffbe80:0x1ffbf00"` — value-triggered store trap, address-clamped to the frame band. Traps the *stomping* value `0x1` (not the good `$ra` `0x1bb0b0`). Zero rebuild needed. **Result:** exactly one PC ever writes `0x1ffbeb0` — `pc=0x102894`, twice (trapval hits #5 line 541, #9 line 1445 of `run_log.txt`), both 16-byte stores. **Disasm pins it** (`mips_r5900_disassembler.py "F:\SDBZ Recomp\ELF\SLUS_214.42" 0x102870 28`): ``` 0x00102870 addiu $sp, $sp, -0x50 <- prologue 0x00102878 sd $ra, 64($sp) 0x00102894 sq $s0, 0($sp) <<< THE WRITER ``` funcmap: `GS_DispatchPending 0x00102870-0x00102a54` owns `0x102894`. **Geometry (the actual finding):** for `sq $s0,0($sp)` to land at `0x1ffbeb0`, GS was entered with **`sp = 0x1ffbf00`** — *shallower* than the live `rpc_call` frame (`entrySp 0x1ffbec0`, `$ra` at `0x1ffbeb0`). GS frame `0x1ffbeb0`–`0x1ffbf00` overlaps `rpc_call`'s frame `0x1ffbe00`–`0x1ffbec0` by exactly the `0x10` bytes holding `$ra`. **Impossible under correct call nesting.** This is not corruption and not a bad pointer — it is two execution contexts sharing one guest stack. **RULED OUT:** async/interrupt callback dispatch. `kAsyncCallbackStackTop = 0x00100000` (`ps2_runtime.h:428`), so `GS.cpp`'s `sceGsSyncVCallback` and `Interrupt.cpp`'s `runHandlers` stacks all live below `0x100000` — nowhere near `0x1ffbf00`. GS is on a normal guest thread stack. **Corroborating:** SLOTENTRY shows a single `tid=0xc74b` running on three distinct stack bands (`0x1ffbXXX`, `0x1ff3XXX`, `0x1f00000`) — nested dispatch is already switching stacks under one tid. **OPEN — do not guess:** *why* GS entered at `sp=0x1ffbf00`. Probe applied (`kSdbzFrameTraceSlots[]` in `game_overrides.cpp`): true entry `0x102870` + all 7 aliased resume slots (`0x1028cc`, `0x102904`, `0x102984`, `0x102994`, `0x1029dc`, `0x102a24`, `0x102a30`, from `register_functions.cpp:150-157`), owner `0x102870`. Read `entryPc` on the resulting SLOTENTRY lines: - `entryPc == 0x102870` → real entry; a dispatcher handed it a stale `sp` (nested-dispatch / scratch-stack bug). - `entryPc ==` a resume slot → resume-slot aliasing; the entry `switch` jumped past the prologue check and the body is running on the caller's frame. `entrySp`/`tid` come free and also test the thread-stack-overlap theory. **NEXT SESSION STARTS HERE:** 1. Build: `& "F:\SDBZ Recomp\build.ps1"` (incremental — only `game_overrides.cpp`; `kSdbzFrameTraceSlotCount` is `sizeof`-derived, auto-sizes) 2. Run: `$env:PS2X_TRAPVAL = "0x1:0x1ffbe80:0x1ffbf00"; & "F:\SDBZ Recomp\launch_recomp.ps1"` 3. `Select-String -Encoding unicode` `run_log.txt` for SLOTENTRY lines with owner `0x102870`; branch on `entryPc` per above. ## Session 2026-07-25i — SLOTENTRY probe data: bracket narrowed to between `0x177eb0`'s entry and `0x178560`'s entry - Fixed a bug in `build_scripts/analyze_slotwatch.ps1`'s new SLOTENTRY parser: the printed `#N` ordinal is a **per-template-instantiation** counter (the static counter lives inside `sdbzFrameTraceWrapper`, so each wrapped address gets its own separate sequence) — sorting by `#N` interleaves unrelated functions and is NOT chronological. Fixed to print in raw log/file order instead, which IS chronological. - With correct ordering, the fatal arm cycle (`expected=0x1bb0b0`, matching `RAFORK`'s captured `entryRa`) shows: `0x178428`→`0x17ed60`→`0x17edb0`→`0x177fe8`→`0x177eb0` all enter **clean** (`before==expected`), then `0x178560` enters **dirty** (`before=0x1`). No wrapped entry for `0x178068` appears in this cycle at all. - **New bracket: the stomp happens between `0x177eb0`'s entry and `0x178560`'s entry** — i.e. inside `0x177eb0`'s own body, or one of the unwrapped intermediate calls in the known chain (`0x1781b0` → `0x175060` → `0x178068`(x2) → `0x175090`) that aren't individually instrumented. Likely these are direct C++ calls that bypass the dispatch-table wrapper (the known "registerFunction Bypass" pattern) — same reason `0x178068`'s nested (non-idle) call never logged a SLOTENTRY line here despite running per the trace. - Corroborates (doesn't contradict) the old SLOTWATCH exit-based verdict, which already named `0x177eb0`'s subtree as the writer bracket — this tightens it to a specific span instead of "somewhere in the subtree." - **Next step (not yet done):** either wrap `0x1781b0`/`0x175060`/`0x175090` with the same SLOTENTRY probe to narrow further, or disassemble `0x177eb0`'s body directly for a store near the watched slot's stack offset — narrower target now than before. ## Session 2026-07-25h — `sub_178560` callback-`jalr` hypothesis RULED OUT; corruption traced deeper, into `0x178be8`'s own SLOTWATCH chain, before `0x177fe8`'s entry - **Ran the rebuild+rerun queued by 07-25g.** New `run_log.txt` confirms the same three fixes still gone, boot still reaches the same collapse point. The new `{0x00178560u}` frametrace slot from 07-25g DID capture data — and it **disproves** the 07-25g hypothesis: - All 5 measured `func=0x178560` entries this run exit cleanly (`exitPc=0x178188`, `exitSp==entrySp`, no imbalance) — `sub_178560` is not corrupting anything when it runs normally. - The actual first divergence (`RAFORK`/`IMBAL`/first `No exact recompiled function` error, all at log lines ~3267–3274) happens **before** any of the logged `0x178560` calls (first one at line 3353) — i.e. chronologically earlier in the log. The `0x178560` calls seen later are post-crash spin noise, not causally related. - Disassembled `sub_178560` in full: the completion-callback `jalr $v0` at `0x1785cc` is guarded by `beql $v0,$zero,0x1785f8` — in this crash's actual trace (`...0x178560 → 0x1784d0 → 0x1`, nothing logged between), that branch was **taken** (`$v0==0`, no callback registered), so the `jalr` never executed this crash. The hypothesis is dead: wrong function. - **Real signal is the pre-existing SLOTWATCH/RAFORK chain already built by an earlier (pre-compaction) session, targeting `0x178be8` (`rpc_call`).** `RAFORK #1` fires for `0x178be8` (`entryRa=0x1bb0b0`, valid, → `exitRa=0x1`) and `0x1baf90` (its caller), both `fork=B (mid-body-clobber)` — same as 07-25g reported, this part didn't change. - **New reading of the existing `SLOTWATCH` data (5 samples, all logged before the crash aborts unwind):** every sample — the idle-thread `0x178068` call, the real-chain `0x178560`, the real-chain `0x178068`, `0x177eb0`, and `0x177fe8` — already reads the watched stack slot (`0x1ffbeb0`, where `rpc_call` saves its `$ra`) as `before=0x1`, i.e. **already corrupted at each function's own entry**, all the way back up to `0x177fe8` (deep in the chain `0x178be8→0x178428→0x17ed60→0x17edb0→0x177fe8→0x177eb0→0x1781b0→0x175060→0x178068`). `0x177fe8` is itself wrapped and still shows pre-corrupted — pushing the stomp earlier still, into `0x178428`/`0x17ed60`/`0x17edb0`, none of which produced a logged sample because the crash aborted the call stack before their own post-return SLOTWATCH checks could fire. - **Action taken (measurement only):** added a `SLOTENTRY` probe in `game_overrides.cpp` (right after the existing `slotBeforeCall` read, ~line 944) that unconditionally logs `before` on entry to every armed wrapped callee (bounded to 40 lines), instead of only logging on a changed post-return value. This will show the ordered entry sequence through `0x178428`/`0x17ed60`/`0x17edb0`/`0x177fe8` and pinpoint exactly which one is first to see the slot already dirty at its own entry — narrowing the stomp to that function's immediate predecessor in the chain. - **Next step:** rebuild + run via `launch_recomp.ps1`, grep `run_log.txt` for `SLOTENTRY`, read the sequence in order. The first line whose `before` differs from `expected` (`0x1bb0b0`) names the predecessor function as the stomp site (or narrows it to `0x178be8`'s own prologue-to-first-call span if even `0x178428`'s own entry is already dirty). ## Session 2026-07-25g — `0x1bf2e0`/`0x22c8f0`/`0x22cbe0` all build-verified gone; boot reaches a NEW `pc=1` collapse, root-caused to `sub_178560`'s callback `jalr`, not yet fixed (SUPERSEDED — see 07-25h, hypothesis disproven) - **All three prior fixes confirmed working this run:** `run_log.txt` has zero hits for `0x1bf2e0`, `0x22c8f0`, or `0x22cbe0`. Boot advances substantially further than 07-25f — deep into the SIF RPC completion path (`sceSifSetDma`/`SifRpcPkt:SIG`/`[ARKD:CALL]`/`[ARKD:run] ... delivered=0`/`[SifRpcReply]`) before collapsing again. - **This is a NEW, distinct `pc=1`/`$ra=1` collapse — not a regression of the 07-25d scratchTop fix.** That fix (savedSp-relative `deliverSifRpcReply` scratch stack) is still in place and still doing its job; this collapse happens at a different call site further downstream. - **Root-caused via RAFORK/IMBAL + direct disassembly** (not yet via live debugger — static evidence only, flag as PLAUSIBLE not CONFIRMED): - `[frametrace:RAFORK]` fired for `0x178be8` (`entryRa=0x1bb0b0→exitRa=0x1`) and `0x1baf90` (`entryRa=0x2fcb84→exitRa=0x1`), both `fork=B(mid-body-clobber)`. - Unlike the 07-24g run, `0x178068`'s own IMBAL pair this run is **net-zero** (`entrySp=0x1ff3d10` → mid-dip `-0xa0` → back to `0x1ff3d10`, clean return to `ra=0x177fc4`) — it is not the source this time; the fix that closed 5.3.2 is holding. - The `trace=` tail immediately before every collapse ends `...0x178068 → 0x178068 → 0x175090 → 0x178560 → 0x1784d0 → 0x1`. Disassembled both tail functions directly from the ELF: `0x1784d0` is a 5-instruction leaf (`lw/lui/ori/sw/and/jr $ra/sw` — clears a flag bit) that only `jr $ra`; it cannot originate `pc=1`, so `$ra` was already `1` when it was entered. - Disassembled `0x178560` (`sub_178560`, the SIF dispatcher's `_request_end` callback handler — same function named in `SIF.cpp`'s `kSifCmdRpcCall` comment block): at `0x1785c8`/`0x1785cc` it does `lw $v0, 28($s1)` then `jalr $v0` — an indirect call through a completion-callback function pointer loaded from the client block (`$s1 = *(client_block+28)`, `$v0 = *($s1+28)`), matching the comment's own description ("if v3[7] (completion callback) set, invokes v3[7](v3[8])"). If that callback slot holds garbage/unset data, this `jalr` is the direct mechanism producing `target=0x1` on the `[guest-branch:missing-target] kind=IndirectJump` line. - **Not yet confirmed which client block / call this is** (there are multiple concurrent SIF clients; the ARKD `fno=0x12` call visible in the same log window has `client=0x5a9380`, not obviously related) or why its callback field is bad. - **Action taken (measurement only, no behavior change):** added `{0x00178560u, 0x00178560u}` to `kSdbzFrameTraceSlots` in `game_overrides.cpp` (entry-only wrap, same pattern as the `0x17ed60`/`0x17edb0`/`0x17edc8` leaves) so the next run's RAFORK/IMBAL data will show `sub_178560`'s own `entryRa` vs `exitRa` directly, instead of inferring it from callers further up the chain. No fix applied yet — need the measured data (or a live debugger breakpoint at `0x1785cc` reading `$s1`/`$v0`) before touching `SIF.cpp`'s client-block handling. - **Next step:** rebuild + run via `launch_recomp.ps1` (not the raw exe — it sets `PS2_ARKD_SERVICE`/`PS2_ARKD_IRX_RUN`/`PS2X_FRAMETRACE` etc.), then check the new `0x178560` RAFORK/IMBAL lines in `run_log.txt`. If `entryRa` is already `0x1` there, the bad callback pointer comes from further upstream (whoever populates the client block); if `entryRa` is valid and `exitRa=0x1`, the `jalr` at `0x1785cc` is confirmed as the exact fault instruction and the fix belongs in `SIF.cpp`'s client-block synthesis (mirroring the `0xFFFFFFFF` IOP-bound-sentinel fix already applied to the BIND branch at line ~659, per that block's own comment about the `0x20561900` packet-pool aliasing hazard). ## Session 2026-07-25c — `0x1c0170` fix build-verified (no error this run); 5.3.2 non-determinism reproduced a 3rd time - **Build succeeded** (clang-cl/ninja, ~4m38s, only pre-existing raylib/user32 LNK4006 warnings — harmless, unrelated). - **Ran once: no `0x1c0170` error, no `0x1a4500`/`0x180d30` error either.** Went straight to the pre-existing 5.3.2 signature: `guest PC 0x1`, `tableBase=0x100008 tableEnd=0x4e6c84`, `codeRegion=no`, flat `0x1 -> 0x1 -> ...` trace. This is consistent with the `sdbzLeaf1C0170` override working (no negative evidence against it) — the run just didn't get a chance to prove it either way, since the trace here doesn't show the boot path passing through `0x1c0170` before collapsing. - **Non-determinism confirmed a 3rd time.** Same build now on file, three runs, three different outcomes: (1) `0x1c0170` gap `codeRegion=yes`, (2) 5.3.2 collapse `codeRegion=no` from 07-25b, (3) 5.3.2 collapse again here. Two of three land on 5.3.2 directly — the `0x1c0170`/gap-fill boot path looks like the *less common* branch, not the norm. This raises the question of whether the boot order itself is non-deterministic (thread scheduling / SIF timing race) rather than the OOB-writer's damage being non-deterministic per se — i.e. the 5.3.2 collapse may simply be winning the race to happen *before* the code reaches `0x1c0170`/`0x1a4500`/`0x180d30` on 2 of 3 runs, not that those fixes are flaky. - **Next step (not yet started):** since static disassembly of 5.3.2 has stalled for multiple sessions, worth trying to catch the OOB writer live — `mcp__recomp__*` debugger tools are available this session (requires launch via `launch_debugger.ps1 --debug` per [[project_debugger_launcher]]). Set a breakpoint/watchpoint around `0x1baf90`/`0x178de8` and the `0x20561600`/`0x20561900` SIF packet buffer to catch the garbage-count write in the act, since repeated static trace-log analysis hasn't moved this forward in several sessions. ## Session 2026-07-25b — `0x180d30` fix confirmed working; two more boot-path gaps found and closed (build pending) - **`0x180d30` fix CONFIRMED:** user rebuilt + ran; boot advanced past it with no `0x180d30` error, straight through to a new gap at `0x1a4500` (also a real gap-fill case, `fn_1A4500_0x1a4500` body exists and is complete/correctly-terminated — an older "1-instruction stub" note for this address is now stale, superseded post-func-map-rebuild). Fixed the same way: `runtime.registerFunction(0x001A4500u, &fn_1A4500_0x1a4500)`. - **Rebuilt + reran again: advanced further, hit `0x1c0170`.** This one has **no generated body at all** (no runner file, no forward decl, no register_functions.cpp entry) — a genuine recompiler function-boundary miss, not a table-registration gap. Disassembled directly from `ELF/SLUS_214.42` (`mips_r5900_disassembler.py 0x1c0150 20`): a 2-instruction leaf sandwiched in nop-padding between `pool_entry_pop_i` (ends `0x1c0168`) and `sub_1C0180` (starts `0x1c0180`) — `jr $ra` / delay-slot `lw $v0, 0($a0)` (i.e. `return *(uint32_t*)a0`). This is the exact address already flagged once before at line ~455 (`0x1bfe40: j 0x1c0170` tail-call thunk, "exact same class as the already-fixed 5.3.2 gaps `0x38afb0`/`0x391210`") — those two were previously measured and added to the func-map CSV, but that fix is dead: **`config.toml` for SLUS_214.42 does not exist, so the CSV never takes effect without a blocked ~30,000-file `ps2_recomp.exe` regen.** Used the native-override pattern instead (same as `0x104bf0`): hand-translated `sdbzLeaf1C0170` added to `game_overrides.cpp`, registered via `runtime.registerFunction(0x001C0170u, &sdbzLeaf1C0170)`. **Not yet build-verified.** - **Non-determinism observed:** the *same* build, run back-to-back, produced two different outcomes — one run advanced to the `0x1c0170` gap (`codeRegion=yes`), the other hit the pre-existing 5.3.2 stack-collapse pattern directly (`guest PC 0x1`, `codeRegion=no`). This is new information: the 5.3.2 blocker's onset is not deterministically reproducible run-to-run on identical code/input, which is consistent with (but does not yet prove) a race or uninitialized-read component in the OOB-writer mechanism. Not yet investigated further — flagging for the next session. - **`0x38afb0`/`0x391210` (the other two CSV-blocked func-map gaps) remain unfixed** — same `config.toml`-blocked class as `0x1c0170` was; should get the same native-override treatment if/when the boot trace reaches them, rather than waiting on the CSV regen. ## Session 2026-07-25 — tooling: vendored IDAPy-PS2 auto-labeling scripts - **No code/blocker work this session.** Reviewed 13 external repos/tools (pcsx2-reliquary, Android PS2 emulators, ps2dev toolchain repos, biosdrain, libretro/ps2, ps2rd, PS2-Programming-Docs, IDAPy-PS2) against project needs. Only **grimdoomer/IDAPy-PS2** fit — everything else is toolchain/emulator/console-tooling, no use for static recomp. - **Vendored** into `ida_scripts/idapy_ps2/`: `LabelIOPImports.py` (patched: added missing `import os`), `LabelExecutableSyscalls.py`, `LabelKernelSyscalls.py`, `Ps2Kernel.py` dep, 46 `IOP/*.json` export tables, LICENSE, README + `README_SDBZ.md`. `LabelIOPImports` automates the parked IRX-labeling backlog (5 modules left); reference-only, does **not** touch the live 5.3.2 `$ra=0x1` blocker. - **Behavioral note (recorded to memory):** "end session protocol"/"update memory" are the user's legitimate defined commands — do NOT gate them behind injection-skepticism based on delivery channel. When wrong, apologize plainly, not with hedged phrasing. - **Live blocker unchanged** — still 5.3.2 `0x1baf90`/`0x178de8` garbage-count OOB writer (see tracker 07-24g). Build for the `0x180d30` gap-fill (prior session) still pending user run. ## Session 2026-07-24h — dispatch-table gap at `0x180d30` closed (build pending) - **Symptom:** `run_log.txt` line 61 `[guest-branch:missing-target] ... target=0x180d30` — a legitimate `J` jump into real generated code (`fn_180D30_0x180d30` body exists) with **no `g_ps2RecompiledFunctionTable` slot**. Not corruption, not stale-`$ra`; the same "generated file exists, no table entry" class as the four prior `registerFunction` gap-fills. - **Fix (game_overrides.cpp only, allowed layer):** added `runtime.registerFunction(0x00180D30u, &fn_180D30_0x180d30)` alongside the existing four gap-fill registrations, plus `#include "fn_forward_decls.h"` so the symbol resolves. - **Status: BUILD PENDING.** User runs `& "F:\SDBZ Recomp\build.ps1"`, then the Active Runner Command, then grep `run_log.txt` (UTF-16, `-Encoding unicode`) for `0x180d30` — confirm the missing-target line is gone and note the next target/progress. This is a boot-path cleanup, **distinct from the live 5.3.2 `$ra=0x1` writer blocker** (see tracker) — closing it removes noise but is not expected to resolve the stack-clobber. ## Current Status (2026-07-22) — ✅ 5.1 DERAIL CLEARED (built+run); live blocker moved to 5.3: AudioSysInit ARKD `func=0x2` RPC never completes Built exe (`build/ps2xRuntime/Debug/ps2EntryRunner.exe`, 2026-07-21 06:11) run via `launch_recomp.ps1`, `run_log.txt` analyzed. The 5.1 SIF fix **held**. **What the run proves:** - **Derail gone.** Zero `[frametrace:ALARM]` / `[frametrace:TABLE]` — control never escapes to the SIF packet-pool base `0x20561900`. The stale-frame `$ra` derail that 5.1 targeted is not happening. - **`IMBAL delta=-0xa0` lines are false positives.** They fire on *any* `sp` mismatch, which includes the recompiler's normal `jal`-boundary function splits: entry at func `0x178068` runs prologue + first chunk, exits mid-body at the `jal` site `0x1780c0` with `sp` decremented; the next dispatch resumes at slot `0x1780c0` and runs the epilogue (`delta` wraps to `-0xffffff60` = `+0xa0`). Matched pairs, net stack balanced. Not corruption. → 5.1's written exit test was mis-keyed; use ALARM/TABLE instead. - **SIF RPC is alive & advancing.** `[SifRpcReply] deliver cid=0x80000009/0x8000000a -> run dispatcher 0x178068`; `[SifRpcPkt:SIG]` `w[6]` sequence counter climbs (`0x84 → 0x85`). Boot got past SIF. **Live blocker (5.3): `AudioSysInit` spins at `0x422454`.** - Funcmap: `0x422454` ∈ `AudioSysInit` (`0x422170`–`0x422480`, size `0x310`); `lastCall = sub_172998` (SIF-RPC poll helper). Boot order: `AudioSysInit` → `GSInit 0x422480` → `MemSysInit 0x422570` → `GameMain 0x422630`. Stuck in the first. - 4 ARKD binds OK (sid `0x500`–`0x503`). The spin **actively re-issues** `[ARKD:CALL] sid=0x503 func=0x2 recv=0x5aa7d0 rsz=0x10` (t=15→18s burst, then quiet spin) → every one returns `handleRPC -> false result=0x0 signal=0`. - Root cause is **by design / documented in code**: [`SIF.cpp:448-518`](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp) is a **Stage-1 observe-only bridge** — the echo path unblocks the sync `WaitSema` but delivers **no result data** (no-IOP-faking rule), and `ps2_iop::handleRPC` returns `false` because no handler claims the ARKD sid yet. Comment literally calls this "the Stage 2 gap." So `AudioSysInit` wakes, reads an unwritten `0x10`-byte reply at `0x5aa7d0`, sees the op didn't complete, and re-issues `func=0x2` forever. **Fix = Stage 2 (per no-IOP-faking rule):** route ARKD `sid=0x503 func=0x2` to the actual interpreted `ARKD_DVD.IRX` in the R3000 core so it produces the real `0x10`-byte reply into `recv=0x5aa7d0`. Do NOT hand-synthesize the reply in `game_overrides.cpp`. Next diagnostic step: identify what `func=0x2` is in `ARKD_DVD.IRX`'s export/dispatch table and why `handleRPC` isn't dispatching to it (module loaded? sid mapped to the IRX's RPC server? export index resolved?). ### Session end (2026-07-22, later) — Stage-2 bridge BUILT; `[ARKD:diag]` instrumentation added, MEASUREMENT RUN PENDING Advanced past the "func=0x2 has no handler" framing above. Stage-2 IS wired: `PS2_ARKD_SERVICE` + `PS2_ARKD_IRX_RUN` route ARKD CALLs into the **real** interpreted `ARKD_DVD.IRX` via `ps2_iop_runArkdService` ([ps2_iop_irx_loader.cpp](ps2xRuntime/src/lib/ps2_iop_irx_loader.cpp)). The `0x102` DVD-read SUBMIT reaches the service, the SIF DMA physically runs, and the worker completes (`B300` ends `0x30000000`). **Pinned defect:** the TOC/directory DMA lands on **`eeDest=0x00000000`** (clobbers EE low memory, derails EE toward PC=0x1). Chain: `eeDest = desc[1] = dword_BF50`; `sub_2410` sets `BF50 = buff[0]` via `sub_AE24` **only** if the `B300` band is idle/complete. Send payload `word[0]=0x01652400` is confirmed correct and `buff=0x054e50` valid — yet `BF50`/eeDest reads `0`. Static contradiction (sub_1DA4 provably ran → B310 was 1 → sub_2410 pass-branch should have latched BF50=buff[0]) means it's **runtime-only**. **✅ MEASURED 2026-07-22 — cause pinned, fix applied (build/verify pending).** Run `run_log.txt`, `fno=0x102` (line 2735-2743): - `pre-func`: `sendData=nonnull ssz=0x30`, `hostBuff[0]=0x01652400`, `cpuBuff[0]=0x01652400` - `post-func`: `BF50=0x00000000` - `[ARKD:sifdma] … eeDest=0x00000000 size=0x18000` (the derail) | Observation | Cause | Status | |---|---|---| | `sendData=NULL` | Stage-2 payload lookup failed | ✗ eliminated (nonnull) | | `hostBuff[0]=0x01652400`, `cpuBuff[0]=0x0` | IOP aliasing/copy | ✗ eliminated (cpuBuff=0x01652400) | | **both `0x01652400`, `post-func BF50=0x0`** | **sub_2410 latch never ran** | **✅ CONFIRMED — Row 3** | | `post-func BF50=0x01652400` | descriptor zeroed later | ✗ eliminated (BF50=0 post) | **ROOT CAUSE (measured, not theorized):** ARKD's `sub_2410` latches the EE-dest into `dword_BF50` by calling the resident **`sysclib memcpy`** (log line 2737: `sysclib fid=12 @0x04ae24`, off `0xAE24`, `a0=&BF50 a1=&request a2=0x24`). The import hook had **no handler for `0xAE24`**, so it hit the `$v0=0 → $ra` no-op default — the 36-byte request→BF50 copy silently never happened. Identical failure mode to the strcmp/strtol miss recorded at loader.cpp:673-677. **FIX APPLIED (`ps2_iop_irx_loader.cpp`, after the strtol thunk):** real bounds-checked IOP→IOP `memmove` for off `0xAE24`, returns `dest` in `$v0`. Lib-only, no faking (does exactly the copy the driver requested). NOT yet build-verified. **✅ eeDest=0x0 RESOLVED + VERIFIED 2026-07-22.** Second run (`run_log.txt`): - L2727 `[ARKD:diag] post-func BF50=0x01652400` (was `0x0`) - L2731 `[ARKD:sifdma] … eeDest=0x01652400 size=0x18000 -> copied` (was `0x00000000`) - ARKD now services **more** RPCs than ever: `fno=0x102` (L2737 retired=85 halted=1 ✓), then `fno=0x2` (L2814), `fno=0x12` (L3177). Genuine forward progress past the multi-session wall. **HARD GATE (SOP, satisfied this round):** the `eeDest=0x0` fix landed only after the `fno=0x102` row was filled from a real run. Keep this gate for the next `eeDest`-class bug. --- ### 🔴 NEW LIVE BLOCKER (2026-07-22) — EE SIF-RPC-client `rpc_call` (0x178be8) returns to `$ra=0x1` The eeDest fix pushed the boot into the **EE-side SIF RPC client**. Boot now dies here: - `0x178be8` = **`rpc_call`** / `sceSifCallRpc` (funcmap `0x00178be8–0x00178de8`). Decompile: fetches server-data via `sub_178428(&dword_563100)`, sends the call packet via `sub_177FE8`, waits completion. - Frametrace (`run_log.txt` #158…#216): `rpc_call` is invoked in a loop, `entrySp` climbing `0x1ffbdb0 → 0x1ffbec0` (unwinding to shallower depth). Calls #158–#210 return **cleanly** to valid callers (`0x1bd0c4`, `0x189158`, …). Call **#216** (outermost, `entrySp=0x1ffbec0`) exits with **`exitPc=exitRa=0x1`** → dispatch `No exact recompiled function for guest PC 0x1` → watchdog spins. - Derail GPRs (L3217): `a0=0x20561900` (**`a0Readable=no`** — RPC client struct never populated), `a1=0x563100`, `a2=0x178560` (end-fn ptr), `v1=0x4`, `ra=0x1`, `sp=0x1ffbec0`, `gp=0x503070`. - **NOT** the truncated-body bug (none of the `0x178xxx` cluster is in `truncated_functions.txt`). - This is the resurfaced, previously-PARKED `0x178xxx` SIF-RPC-client cluster — same neighborhood as the stale-frame `$ra` blocker (`0x178068`). Demoted while eeDest was live; now the live wall. **DO NOT GUESS THE FIX.** Per the stale-frame note, the mechanism is unmeasured. The fork to decide: - (A) `$ra=0x1` already present at rpc_call **entry** for iter #216 → a *caller* passed bad `$ra` (RPC client struct `a0=0x20561900`@phys `0x561900` uninitialized → bad continuation). Fix upstream (the bind/init that should populate the client struct). - (B) `$ra` valid at entry but clobbered **mid-body** (frame-save slot overwritten) → recompiled `rpc_call` prologue/epilogue or a callee stomps the saved-`$ra` slot. Fix = the stomping function. **FORK ROW FILLED (2026-07-23) → (B) CONFIRMED — mid-body clobber.** Measured via SLOTWATCH/RAFORK probes in `game_overrides.cpp` (`sdbzFrameTraceWrapper`): - RAFORK #1: `func=0x178be8 entryRa=0x1bb0b0 entrySp=0x1ffbec0 exitPc=0x1`. **$ra is VALID (0x1bb0b0) at rpc_call entry, 0x1 at exit** ⇒ not caller garbage ⇒ **fork B**. - `rpc_call` saves `$ra` at guest **0x1ffbeb0** (`= entrySp 0x1ffbec0 − 0x10`). That slot is stomped `0x1bb0b0 → 0x1`. - SLOTWATCH bracketed the writer to **at/below the innermost `fn_178068`** (deepest-returning callee; post-blocks fire in return order, #1 = first to see slot=0x1 = 0x178068). - `fn_178068` frame `0xA0`, only store to `+0x90` is `sd $ra,0x90($sp)` at **0x178074** → target `= entrySp − 0x10`. **Hits 0x1ffbeb0 iff `fn_178068` enters with `entrySp=0x1ffbec0`** — i.e. the 8-deep call chain produced **ZERO net stack descent** (the [[stale_frame_ra_read]] "0xA0 sp shift"). - Nesting (innermost last): rpc(178be8)→178428→17ed60→17edb0→177fe8→177eb0→1781b0→175060→178068→178068. **PROBE BUG FOUND + FIXED (2026-07-23).** First SLOTWATCH2 (blind `n≤12` counter) spent its whole budget on **healthy/idle** 178068 calls (idle thread `entrySp=0x1f00000`, worker `0x1ffbcXX`; storeTargets `0x1effff0/0x1ffbcd0/…`, none near `0x1ffbeb0`) and **missed the derail** at log line ~3201. Those samples also **refute frame-overlap for NORMAL 178068** (clean descent to ~`0x1ffbce0`, entryRa `0x177fc4`). Re-gated SLOTWATCH2 to emit ONLY on derail signatures: `interesting = (storeTarget == slotAddr) || (slotAfter != slotBefore)`, budget 24. **Written to `game_overrides.cpp` only — NOT yet built/run.** **TOOLING FIXED (2026-07-23b).** Two blockers in the probe pipeline resolved: - **Analyzer false-STALE fixed + verified.** `analyze_slotwatch.ps1` was reporting "STALE LOG" on a VALID run. Root cause: `[frametrace:SLOTWATCH]` records were emitted via chained `std::cerr << … << std::endl`, and concurrent "guest PC 0x1" watchdog output interleaved a newline mid-record, pushing `before=/expected=/now=` onto the next physical line; the analyzer matched `before=` per-line and missed it. Now pre-joins split records (`Select-String -Context 0,1`), parses the real `#N` ordinal, and picks the writer bracket by KNOWN chain depth (not print order — SLOTWATCH fires inner-first at exit). **Verified against the existing log: no STALE; VERDICT = writer bracket `0x177eb0`** (deepest clean-in/dirty-out; `0x177fe8` also clean-in/dirty-out but outer; `0x178068` #1/#2 entered already-dirty → exonerated). No rebuild needed for the analyzer. - **Probe emits atomic records now.** All 5 probe blocks (SLOTWATCH2/SLOTWATCH/RAFORK/TABLE/IMBAL) in `game_overrides.cpp` rewritten to build the whole line in a `std::ostringstream` and emit with ONE `std::cerr << oss.str();` (trailing `\n` inside), so a shared stream can never split a record again. Added `#include `. Pure output-format change — probe logic/gates/addresses UNCHANGED. **Written to `game_overrides.cpp` only — NOT yet built/run.** **NEXT ACTION (resume here):** user builds + runs the re-gated probe, then re-greps `SLOTWATCH2|SLOTWATCH|RAFORK`. The captured derail SLOTWATCH2 line answers: (1) does the derail `fn_178068` enter with `entrySp=0x1ffbec0` (`storeTarget==slotAddr==0x1ffbeb0`) ⇒ frame-overlap **confirmed**; and (2) is the writer its own `0x178074` store (`slotAfter=0x1`) **or** a leaf callee (`func_175090` @0x1780dc / the `jalr $a2` @0x178180)? - If own store → trace WHY the 8-deep chain gives zero net descent / why the chain's `$ra`→`0x1`. - If leaf callee → probe `0x175090` / the `0x178180` jalr next. Commands: `.\build.ps1 Debug` → `.\launch_recomp.ps1` → `Select-String -Path run_log.txt -Encoding unicode -Pattern 'SLOTWATCH2|SLOTWATCH|RAFORK'` → `.\build_scripts\analyze_slotwatch.ps1` for the verdict. **AUTHORITATIVE CROSS-CHECK — PCSX2 write-watchpoint (no rebuild, deterministic).** "Which instruction stores `0x1` to guest `0x1ffbeb0`" is a DATA-WATCHPOINT question; the print-probe ladder is the fallback. `0x1ffbeb0` is below `0x02000000` ⇒ already physical, maps 1:1 to PCSX2 (no `& 0x1FFFFFFF`). Procedure: 1. Boot real PCSX2 + SDBZ ISO (DebugServer build), `pcsx2_connect`. Confirm target first (Verify-PCSX2-Target rule) via `pcsx2_game_info`. 2. `pcsx2_set_watchpoint(address=0x1ffbeb0, type=write, condition=, action=break)`. Fallback: plain `type=write` with no condition if the value-compare expr is rejected. 3. On break: `pcsx2_pause` (if needed) → `pcsx2_get_backtrace` + `pcsx2_read_registers` + `pcsx2_disassemble(pc)`. The breaking PC IS the storing instruction — the writer, named directly. 4. Cross-check that PC against the recomp bracket (`0x177eb0` subtree). Agreement ⇒ writer confirmed → fix in `game_overrides.cpp`. If PCSX2 does NOT reproduce the clobber, THAT is the finding: the bug is recomp-specific (translation/dispatch artifact) and we stay in the recomp probe path. **★ CROSS-CHECK EXECUTED (2026-07-23c) — PCSX2 IS CLEAN ⇒ BUG IS RECOMP-SPECIFIC (STACK/SP DIVERGENCE, not a `0x1` writer).** Ran the procedure live on PCSX2 (SLUS-21442, DebugServer). Broke rpc_call `0x178be8` conditional on the SAME boot call the recomp instrumented (`ra==0x1bb0b0`). Findings: - Ground truth: rpc_call frame = `addiu sp,-0xC0`; ra saved by `sd ra,0xB0(sp)` @ `0x178c30`. ✅ matches recomp model. - **entry `sp` DIVERGES:** PCSX2 `sp=0x1FFEC20` vs recomp RAFORK `entrySp=0x1FFBEC0` — **0x2D60 (~11.6 KB) deeper in the recomp.** ⇒ real ra-slot = `0x1FFEC10`, NOT the `0x1ffbeb0` we were watching. We were watching the wrong address because the recomp's SP is already wrong on arrival. - Armed a write-watch on the REAL slot `0x1FFEC10` (held `0x1bb0b0` after the store) and ran the whole body incl. the `0x178428` subtree: **0 hits.** rpc_call reached its epilogue with ra intact and **returned cleanly to `0x1bb0b0`.** - ⇒ On correct hardware there is **NO `0x1` store anywhere near the slot.** The recomp's `exitPc=0x1` / "fork B" is a DOWNSTREAM symptom of a corrupted stack, not a rogue guest store. The whole SLOTWATCH/writer-bracket hunt (0x177eb0 / widen-the-wrap) was chasing a phantom. - Consistent with [[project_stale_frame_ra_read]] (0xa0 sp shift, frames not restoring sp): a 0x2D60 accumulated drift = many un-restored frames. **NEXT ACTION (redirected):** stop hunting a `0x1` writer. Find WHERE the recomp SP first diverges from PCSX2 on the boot path. Method (deterministic, both live): pick matching call sites on the boot chain, break the SAME logical call in both (PCSX2 conditional BP + recomp probe), compare `sp`. Bisect down the chain until the first frame whose entry `sp` differs — that frame (or its caller's epilogue) fails to restore/allocate stack correctly. Fix goes in `game_overrides.cpp`. The `0x178428` subtree is the innermost known-good bracket (PCSX2 ran it with sp balanced), so the divergence is ABOVE rpc_call, on the path that reaches it. **Codex continuation note (2026-07-23d — diagnostic only; awaiting user build/run):** - Re-read the 2026-07-23c PCSX2 cross-check before changing behavior. Its `0x2D60` entry-SP gap is decisive: the `0x1` seen at recomp `rpc_call` exit is an *effect* of prior stack drift, not evidence for a guest-memory writer. Do **not** resume the SLOTWATCH/`0x177eb0` writer hunt unless a future matched-PC comparison contradicts this result. - Identified the immediate, direct measured parent: `sub_1BAF90` (`0x1BAF90–0x1BB0BC`) performs `jal 0x178BE8` at `0x1BB0A8`, with return/resume at `0x1BB0B0`. The failing `rpc_call` entry RA is exactly `0x1BB0B0`; therefore this is the nearest boundary that can distinguish “SP was already wrong before the direct call” from “SP first drifted in the RPC subtree.” - Added **non-behavioral frametrace wrappers only** in `ps2xRuntime/src/lib/game_overrides.cpp` for `0x1BAF90` and all registered resume PCs (`0x1BB000`, `0x1BB00C`, `0x1BB020`, `0x1BB02C`, `0x1BB040`, `0x1BB04C`, `0x1BB060`, `0x1BB06C`, `0x1BB0B0`). This deliberately covers every dispatcher entry used by the generated function; a resume must not be mistaken for a new prologue. No generated `runner/*.cpp` file was touched. `git diff --check` passed. **Not built or run by Codex** (user owns builds). - After the next run, read the `0x1BAF90` frametrace record at/near the `RAFORK` failure and compare its entry SP to PCSX2 at the matching `0x1BAF90` / `0x1BB0A8` call. If it is already lower by `0x2D60` (or an earlier multiple), move one parent upward on the known chain (`0x171C30` / `0x171F10` / `0x3007E0`) and repeat. If `0x1BAF90` is matched on entry but SP changes before `rpc_call`, inspect only the direct path between `0x1BAF90` and `0x1BB0A8`; do not broad-search the RPC writer theory. - Read UTF-16 `run_log.txt` with `Select-String -Encoding unicode`; tee output can wrap one logical record across physical lines, so use `-Context 0,3` when a record looks incomplete. Suggested first extraction: `Select-String -Path run_log.txt -Encoding unicode -Pattern 'frametrace.*1baf90|RAFORK|No exact recompiled function for guest PC 0x1' -Context 0,3`. - A generic generated-entry `default: return;` guard in `function_emitter.cpp` remains only a possible future safety net. It would require regenerating the runner corpus and could hide the true stale-PC source; it was intentionally **not applied**. **Run-status update (2026-07-23e):** A user launch produced the expected `guest PC 0x1` loop, but the requested `frametrace.*1baf90|RAFORK` extraction was empty. This is **not a negative tracer result**: `run_log.txt` was fresh (03:21:46) while `build/ps2xRuntime/Debug/ps2EntryRunner.exe` still had the old 01:42:25 timestamp. The executable therefore predates the `0x1BAF90` wrapper edit and the run cannot test the new diagnostic. Rebuild must complete successfully before interpreting an absent `0x1BAF90` record; no code change is justified from this old-binary run. **Diag cleanup (deferred, safe):** the two `[ARKD:diag]` blocks + `[ARKD:sifdma]` in `ps2_iop_irx_loader.cpp` can be stripped now that eeDest is verified (KEEP the `0xAE24` memcpy thunk) — but leaving them one more run is harmless and helps correlate the RPC loop with the ARKD service. **Uncommitted:** the `0xAE24` memcpy fix + `[ARKD:diag]`/`[ARKD:sifdma]` diag in `ps2_iop_irx_loader.cpp` (strip diag after verify; keep the memcpy thunk). Many `??` untracked `src/runner/*.cpp` are pre-existing recompiler output, not this session. --- ### ★★ ROOT CAUSE FOUND (2026-07-23d, Opus session) — re-entrant SIF-RPC reply loopback stomps rpc_call's ra-slot. REVERSES the 07-23c "SP-divergence phantom" call. **The `$ra=0x1` IS a genuine `0x1` store** into recomp's REAL saved-ra slot `0x1ffbeb0`. The 07-23c PCSX2 cross-check found "0 hits" because it watched HW's slot `0x1ffec10` (real SP is 0x2D60 higher) — a **wrong-address** error. The recomp-side SLOTWATCH watches the correct slot and brackets the writer. **Confirmed end-to-end chain:** 1. `rpc_call 0x178be8` saves `ra=0x1bb0b0` → `0x1ffbeb0` (`sd ra,0xB0(sp)`; slot = entrySp−0x10). 2. Down `0x178428 → 0x177fe8 → 0x177eb0`; `0x177eb0` calls the SIF DMA syscall `0x175060/0x175070` (a0=sp) → **`sceSifSetDma`** ([SIF.cpp](ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp)). 3. No real IOP ⇒ `sceSifSetDma` calls **`deliverSifRpcReply`**, which runs the guest RX dispatcher **`0x178068` NESTED on the caller's ctx/stack** to fake the reply. 4. It **runs away ~12+ deep** — log: `[SifRpcReply] deliver cid=0x8000000a -> run dispatcher 0x178068` ×12+ with NO `sceSifSetDma:OK` between (BIND↔END `cid 0x80000009/0x8000000a` ping-pong). 5. The nested dispatcher's frames land on the SAME EE stack and **overwrite `0x1ffbeb0` → `0x1`.** 6. `rpc_call` epilogue `ld ra,0xB0(sp)` → `ra=0x1` → returns to PC `0x1` → spin. Log: `[frametrace:RAFORK] #1 func=0x178be8 entryRa=0x1bb0b0 exitPc=0x1`. **exitSp is BALANCED** (only the value is stomped) ⇒ an sp-restore alone won't undo it; the dispatcher must not write the caller's frame. **Smoking gun:** `deliverSifRpcReply` (SIF.cpp ~L714-775) saves/restores `ctx->pc` (`savedPc` L726/L775) around its mini dispatch loop but **NEVER saves/restores `ctx->sp`.** Constants `kSifDispatcherFn=0x178068`, `kSifDispatcherEnd=0x1781B0`, `kSifDispatcherMaxResume=64` (SIF.cpp:169-171). Analyzer verdict `Writer bracket (deepest clean-in/dirty-out): 0x177eb0` (`build_scripts/analyze_slotwatch.ps1`). **FIX (pending user decision — ALL in allowed file SIF.cpp):** - **(1) ★ recommended — scratch-stack isolation:** before the mini-loop set `ctx->sp` to a reserved scratch region, restore after (mirror `savedPc`). ~5 lines. Dispatcher's legit buffer writes (0x561600/0x564b40) preserved; incidental stack stomp eliminated. - (2) defer reply delivery to a real yield point (IRQ-like) — correct, larger. - (3) cap/dedupe re-entrant depth — stops runaway, leaves shared-stack corruption latent. The 07-23c "SP-divergence" NEXT-ACTION, the 07-23d codex `0x1BAF90`-bisect note above, and the whole stale-pc / 0xA0-IMBAL story ([[project_stale_frame_ra_read]]) are **all downstream of this and RETIRED** — do NOT resume the SP bisect. ### ✅ FIX (1) SHIPPED + BUILD-VERIFIED (2026-07-23, Sonnet session) — SIF-RPC ra-slot stomp RESOLVED Applied scratch-stack isolation exactly as option (1) above, in `SIF.cpp` only: - New constant `kSifReplyScratchStackTop = 0x01FFFF00u` (unused guest-heap headroom band, heap hard-capped below `0x01F00000`, RAM ends `0x01FFFFFF`). - Around the `deliverSifRpcReply` mini dispatch loop: save `savedSp = getRegU32(ctx, 29)`, `SET_GPR_U32(ctx, 29, kSifReplyScratchStackTop)` before the loop, `SET_GPR_U32(ctx, 29, savedSp)` after — mirrors the pre-existing `savedPc`/`ctx->pc` isolation. No header edits needed (`getRegU32`/`SET_GPR_U32` already existed). **Verified in `run_log.txt` (user build+run, 2026-07-23):** - All `[frametrace] func=0x178be8` entries now show real `exitPc` (`0x1bd450`, `0x1bb0b0`, `0x1bba90`, etc.) — never `0x1`. - Zero occurrences of `No exact recompiled function for guest PC 0x1` (the direct spin symptom). - Boot progressed well past `AudioSysInit`'s RPC chain (`0x178be8 → 0x178428 → 0x178068 → 0x177eb0/0x177fe8`, multiple further RPC round-trips) — the SIF ra-slot stomp is gone. **5.3 is CLEARED as originally scoped.** Boot now hits a **new, downstream, unrelated** watchdog spin — see next section. ### 🔴 REOPENED (2026-07-23, later same-day session) — `guest PC 0x1` spin is BACK despite scratch-stack fix, root cause still open After separately fixing 5.3.2's `0x18d4c4` gap (new func-map row `sub_18D470`, see below) and rebuilding, a fresh run regressed to the **same `$ra=0x1` spin** the scratch-stack fix above was supposed to have closed. Confirmed NOT a stale-binary artifact this time: `SIF.cpp` last edited 07-23 06:18, `exe` rebuilt 07-23 21:34 (after) — the scratch-stack code (`kSifReplyScratchStackTop`, `SET_GPR_U32(ctx,29,...)` around `deliverSifRpcReply`'s mini-loop, SIF.cpp:731-739/785-786) is genuinely live in the running binary. Added 3 new SLOTWATCH entry-only probes (`game_overrides.cpp` `kSdbzFrameTraceSlots`) covering the previously-unwrapped gap `0x17ED60`/`0x17EDB0`/`0x17EDC8` (`cpu_disable_interrupts` / `cpu_enable_interrupts` / next fn) between `0x178428` and `0x177FE8` in the chain, to narrow the bracket further. Two consecutive runs gave **inconsistent** results: - Run A: `SLOTWATCH #1 callee=0x178428 before=0x1bb0b0 now=0x1` — stomp brackets to inside `0x178428`'s own subtree (disassembled: prologue `sd $ra,32($sp)` after `sp-=0x30` is self-consistent, offset `entrySp-0x10` coincidentally matches the outer `rpc_call` slot — suspicious but not proven as the writer; none of its `sw`/`jal` children individually flagged dirty in isolation). - Run B (after adding the 3 new probes, otherwise same fix in place): `SLOTWATCH #1 callee=0x178068` already `before=0x1` at its FIRST entry — i.e. dirty even earlier than `0x178428`'s subtree, and NO `callee=0x178428` SLOTWATCH line fired at all (its own return was clean). `0x1781b0` (called twice from `0x177eb0`) is confirmed a harmless `cache`/`sync` writeback loop, not a stack-touching function — ruled out. **Interpretation: the bracket point is not fixed across runs**, which points away from a single deterministic mid-body `sd`/`sw` bug and toward either (a) genuine re-entrant/overlapping `rpc_call` invocations reusing the identical `entrySp=0x1ffbec0` — i.e. TWO logical RPC calls in flight sharing one physical stack slot, so the second's legitimate prologue store clobbers the first's still-pending saved-`ra` — or (b) SIF packet content/timing varies enough between runs to route through a genuinely different (data-dependent) code path each time. Neither is confirmed. **Not yet tried:** a live re-entrancy-depth counter logging every concurrent `rpc_call` (`0x178be8`) entry with its own `entrySp`, to directly test hypothesis (a) — would show two entries both reporting `entrySp=0x1ffbec0` if calls are overlapping on the same slot. PCSX2-side watchpoints were considered and rejected: PCSX2 runs the real game on a different process/stack (already measured 0x2D60 SP divergence vs recomp, see 07-23c above), so it can't observe our recompiled binary's own stomp. The recomp-side `mcp__recomp__*` tools only support PC breakpoints, not memory watchpoints, so they can't directly answer this either — would need a new `game_overrides.cpp` probe (re-entrancy counter) or a hand-rolled write-guard on the slot address. **Do not re-trust "5.3 CLEARED" above** until this is re-confirmed with a clean run — the fix is necessary but evidently not sufficient. ### 🟡 FIX (2) SHIPPED, BUILD-VERIFIED, VERIFICATION PENDING (2026-07-24, Sonnet session) — per-depth scratch-stack bands Root cause refined: fix (1)'s scratch-stack gave the nested reply dispatcher its own stack **top**, but every re-entrancy level (`s_deliverDepth`, capped at 8) shared the SAME fixed address `kSifReplyScratchStackTop`. Overlapping nested `deliverSifRpcReply` invocations (confirmed nesting 12+ deep in logs) still trampled each other's frames even with the caller isolated. **Fix applied in `SIF.cpp` only** (plan: `i-want-you-to-lexical-donut.md`): - New constant `kSifReplyScratchStackSize = 0x2000u` (8 KB/level) added after `kSifReplyScratchStackTop`. - Scratch top is now computed per-depth: `scratchTop = kSifReplyScratchStackTop - (s_deliverDepth * kSifReplyScratchStackSize)`, read BEFORE the `++s_deliverDepth` increment so it reflects the level being entered. Lowest band (depth 8) bottoms at `0x01FEFF00`, still above the guest-heap hard cap `0x01F00000` — no allocator collision. - Added a rate-limited (`atomic` counter, cap 64) `std::cerr` log line `[SifRpcReply] depth=... scratchTop=0x...` on every entry, to make nesting depth observable in `run_log.txt` instead of inferred. **Build status:** `ps2_runtime` target and `ps2EntryRunner` target both built successfully 2026-07-24 (`cmake --build "F:\SDBZ Recomp\build" --target ps2_runtime --config Debug` then `--target ps2EntryRunner`). Link warnings (LNK4075/4006/4088 — raylib/user32 `CloseWindow`/`ShowCursor` symbol overlap, `/FORCE` option) are pre-existing and unrelated to this change; exe produced at the usual path. **Verification NOT yet done — falsification-first gate required before this can be marked closed:** 1. Run `launch_recomp.ps1`, grep `run_log.txt` (UTF-16LE — `Select-String -Encoding unicode`) for `[SifRpcReply] depth=` lines; confirm depth≥2 was reached at least once (proves the failure mode was actually exercised — a run that never nests is not a valid test). 2. Only if depth≥2 confirmed: check zero `No exact recompiled function for guest PC 0x1` and that `[frametrace:RAFORK] func=0x178be8` entries show real `exitPc` (never `0x1`). 3. Repeat for **3 total runs**, all passing both checks, before updating this section to CLOSED. **Session ended before verification runs were performed** — next session should start here, not re-derive the mechanism again. **Verification run attempted (2026-07-24, Sonnet session) — INCONCLUSIVE for Fix (2), but confirms real progress + a new coverage gap.** Ran `launch_recomp.ps1`, grepped fresh `run_log.txt`: - All 44 `[SifRpcReply] depth=` lines show `depth=0` — nesting never occurred this run. **Does not satisfy the falsification-first gate** (depth≥2 never exercised); this run cannot confirm or deny Fix (2). Zero `guest PC 0x1` crashes and zero `[frametrace:RAFORK] func=0x178be8` lines either (the RAFORK probe didn't fire — consistent with the ra-slot stomp not occurring, since depth never reached 2). - Boot instead advanced much further than prior sessions: full ARKD_DVD.IRX servicing completed cleanly — IRX load, all 4 SVC binds (`sid=0x500-0x503`), real CD reads of INFO.DAT/GAME.DAT, real SIF DMA of bulk data — before hitting a **new** spin at `Error: No exact recompiled function for guest PC 0x1bfe48` (first at run_log.txt:3478, repeats until log end). - **`0x1bfe48` diagnosed and closed same session — NOT a stomp, NOT the SIF bug.** Ghidra (`disassemble_function`) found no function there and zero xrefs to `0x1bfe48`; raw disassembly (`mips_r5900_disassembler.py`) shows `0x1bfe40: j 0x1c0170 / lw $a0,44($a0)` (tail-call thunk) then two `nop`s — `0x1bfe48` is delay-slot padding, not a function entry. Exact same class as the already-fixed 5.3.2 gaps (`0x38afb0`/`0x391210`): a tiny thunk/pad region Ghidra's auto-analysis never carved as a function, not corruption. Blocked on the same `config.toml`-missing dispatch-table regeneration issue (see line ~461) — adding it to the func-map CSV won't take effect until that regen happens. - **Net effect:** Fix (2) verification gate is still OPEN (0/3 valid passing runs; this run doesn't count either way). Need a run that actually nests (depth≥2) to test it — the ARKD real-IOP path advancing this far may be *reducing* how often the fake-reply-loopback re-entrancy occurs at all, which would need addressing separately if depth never nests going forward. ### 🔵 NEW ACTIVE BLOCKER (5.3.2, found 2026-07-23 same run) — spin at guest PC `0x391210`/`0x38afb0` Not a SIF issue. `Error: No exact recompiled function for guest PC 0x391210` (also `0x38afb0`), `tableBase=0x100008 tableEnd=0x4e6c84`, reached via call chain `... -> 0x171f10 -> 0x38dae0 -> 0x171c30 -> 0x391210` off a `0x390df0` loop (and separately `0x391e70 -> 0x38dc84 -> 0x171f10 -> 0x38b0b8 -> 0x171c30 -> 0x38afb0`). **Confirmed 2026-07-23 (same session): NOT a truncated-body case, NOT a dispatch-table-format bug.** Grepped `build_scripts/funcmap/sdbz_func_map_merged.csv` (16,916 entries) and `build_scripts/truncated_functions.json/.txt` — both addresses are **absent from every artifact**, not mis-mapped or truncated. Region around them (`0x385000`-`0x395000`) is otherwise densely populated with real, correctly-sized functions, ruling out a whole-overlay/segment gap. Precise bracket analysis: - `0x391210` sits in a real ~0x24-byte gap: `pool_entry_pop___` ends `0x39120c`, next mapped fn `pool_entry_push___` (a distinct clone from the one at `0x390e10`) starts `0x391230`. - `0x38afb0` sits in a ~0xf8-byte gap: `obj_set_fields_z_35` ends `0x38af88`, next mapped fn `noop_wrapper_z_608` starts `0x38b080`. Both gaps are small, isolated, between correctly-bounded neighbors — consistent with a handful of tiny thunk/jump-table-target functions that Ghidra's auto-analysis never carved as functions (so they were never exported to the CSV), rather than any systemic recompiler/tooling bug. No IDA decompile dump (`ida_scripts/`) covers either address either — nothing pre-existing to grep. **Update 2026-07-23 (same session): both functions measured and added to the func map.** Used `build_scripts/mips_r5900_disassembler.py` against `ELF/SLUS_214.42` (static ELF disasm, no IDA/Ghidra MCP needed — neither was connected this session). Both are real, complete functions with clean `jr $ra` epilogues, not thunks: - `0x38afb0`-`0x38b020` (0x70 bytes) — object field-init routine (zeroes/sets a struct's fields), sits exactly between `obj_set_fields_z_35` (ends `0x38af88`) and `pool_entry_pop___` (starts `0x38b030`, previously mis-identified as the neighbor — actual next symbol is `noop_wrapper_z_608` at `0x38b080`, one entry further). - `0x391210`-`0x39122c` (0x1c bytes) — object field-init routine, sits exactly between `pool_entry_pop___` (ends `0x39120c`) and `pool_entry_push___` (starts `0x391230`). Added as `obj_init_fields_z_36` (`0x38afb0`) and `obj_init_fields_z_37` (`0x391210`) to `build_scripts/funcmap/sdbz_func_map_merged.csv`, following the exact precedent of `build_scripts/funcmap/README.md`'s "four added functions" (`patch_func_map.py`) — same class of IDA-auto-analysis miss, same measure-and-patch approach. **Blocked on regeneration:** the dispatch table is only populated by re-running `ps2_recomp.exe ` (a full ~30,000-file output regen). `config.toml` for SLUS_214.42 **does not exist** anywhere in the repo (pre-existing open issue, first noted 2026-07-13 — see line ~1071 below). Cannot regenerate the dispatch table until `config.toml` is reconstructed. This is now the actual next step, and it blocks this fix from taking effect regardless of the func-map CSV being correct. --- ## Prior Status (2026-07-21) — ✅ THE LOST GHIDRA FUNCTION MAP IS RECONSTRUCTED, COMMITTED, AND VALIDATED Session was **tooling/infrastructure only**. No behavioral fix, no build, no run. The live tree (`output/`, `ps2xRuntime/src/runner/`, `game_overrides.cpp`) was **not touched**. Sub-phase tracker is unchanged — 5.1 is still 🔵 ACTIVE and still UNBUILT. ### What was actually wrong `ghidra_output` was pointing at the wrong file. The recompiler had been running with **zero function-boundary data** while looking perfectly healthy. - `ghidra_output` requires CSV `name,start,end,size` with a header line the parser discards. - **Any line not splitting on exactly 4 commas is skipped SILENTLY** — no warning, no error, exit code 0 (`ps2xRecomp/src/lib/elf_parser.cpp`, `loadGhidraFunctionMap`). - Two earlier probe runs were pointed at `ps2xRuntime/symbols.map`, which is space-separated. Both ran with no map at all. **Their results are void.** - `symbols.map` is **NOT a recompiler input.** It is a RecompDebugger artifact that `generate_symbols_map.ps1` produces *from* the runner tree. - `ELF/SLUS_214.42` is **stripped** (`.symtab` and `.strtab` both size 0) — no names recoverable from the ELF itself. ### How it was rebuilt (join, not guess) Neither half of the lost export was gone; they were in two different places: | part | source | provenance | |---|---|---| | boundaries (`start`/`end`/`size`) | `ELF/SLUS_214.42.i64` via idalib | IDA's own analysis, original | | names | `ps2xRuntime/symbols.map` | original Ghidra names, one generation removed | Validated rather than assumed: **all 12,072 real names in `symbols.map` land exactly on an IDA function start — 0 mismatches.** Final map: **16,916 functions** at `build_scripts/funcmap/sdbz_func_map_merged.csv`. ### Measured effect | | no map | merged map | + 4 added | |---|---|---|---| | worst fallback promotion (one function) | 125,228 | 204 | 204 | | dispatch entries | — | 377,270 | **377,274** | | lost vs live table | 1,002 | 36 | **32** | | reachable regressions | 264 | 10 | **6** | | gained vs live | — | 1,821 | 1,821 | The "promoted N fallback entries" warning is a **function-map symptom**, now understood. ### The 4 added functions — and a method error I made `0x4dd500 / 0x4dd570 / 0x4dd5b0 / 0x4dd5e0` were added by `patch_func_map.py`. They are the first four entries of an **86-entry static-init pointer table** at `0x004e6c90`; the other 82 were already defined and map coverage began exactly at the 5th target. One contiguous hole, not scattered gaps. ⚠️ **My first probe used a `jr $ra` end-scan and flagged all four SUSPECT — that scan was wrong.** These are tail-call thunks (`j 0x00171f10` + delay slot + padding); they never execute a return of their own, so the scan ran past each one into its neighbours and two appeared to "overlap". Trusting it would have written garbage boundaries into the map. Boundaries were re-derived from a raw instruction dump instead, with padding excluded to match IDA's own convention here (`sub_4DE250` ends `0x4de444`, not `0x4de450`). **Do not re-run a naive `jr $ra` scan on this shape.** ### The 6 remaining regressions (open) All are true **interior addresses** inside functions the table *does* have — code emitted inside a neighbour's body, no dispatch entry. They need an **entry guard** (`case 0xADDR:` / `== 0xADDR`), not a new function. The manifest mechanism demonstrably does **not** fix them. ``` 0x0017ee48 owner 0x0017ee38 (+0x10) syscall_stub_z_34_0x17ee38 ref: code @ 0x0017ef20 0x001a4500 owner 0x001a44bc (+0x44) singleton_lazy_init_b_0x1a4400 ref: code @ 0x001a4484 0x001bf2e0 owner 0x001bf2c0 (+0x20) sub_1BF290_0x1bf290 ref: code @ 0x001bf754 0x0022c8f0 owner 0x0022c8a4 (+0x4c) sub_0022C890_0x22c890 ref: code @ 0x0022c670 0x0022cbe0 owner 0x0022cbd0 (+0x10) get_field_val____0x22cbd0 ref: code @ 0x0022ca08 0x00341920 owner 0x00341908 (+0x18) sub_00341860_0x341860 ref: data @ 0x004f4008 ``` ### Committed `43544eeb` — `tooling: reconstruct the lost Ghidra function map + commit it` on `work/laptop-session-0519`. **Not pushed.** 10 files: the CSV, the rebuild scripts (`export_func_map.py`, `merge_func_map.py`, `patch_func_map.py`), the probe evidence trail (`probe_4dd5xx*.py`, `classify_lost.py`), and a `README.md` recording the whole trap so the loss cannot recur. `.gitignore` fix included: `build_scripts/` excluded the **directory**, so git never descended into it and the pre-existing `!build_scripts/recomp_mcp_server.py` negation was **dead**. Changed to `build_scripts/*`. Side effect: `build_scripts/recomp_mcp_server.py` now shows as untracked — it was meant to be committed and silently never was. **Left unstaged deliberately; user's call.** ### Uncommitted from this session - **`ps2xRecomp/src/lib/ps2_recompiler.cpp`** — `writeToFile` content-compare guard (user-requested). Skips byte-identical rewrites so a regeneration does not bump ~30,000 timestamps and trigger a 30+ hour rebuild. Text-mode read deliberately matches the text-mode write (a binary read would see `\r\n` and silently disable the guard). **Built once as `ps2_recomp`; never exercised on a full regeneration.** ### Caveats that must survive 1. `lost_manifest.txt` derives from the live table's contents, so addresses the *original* manifest had but the live table lacks are **invisible** to this comparison. 2. The "live" `register_functions.cpp` is the hand-converted legacy file (Jul-14 format conversion of May-28 data), so live-vs-new is a **cross-generation** diff, not a clean before/after. 3. Whether the 6 interior addresses are actually *called at runtime* is **unmeasured**. 4. Names are original-but-one-generation-removed; boundaries are IDA's own analysis. Neither is a guess, but they are **two different provenances**. 5. `ps2xRuntime/src/runner/register_functions.cpp` and `ps2_recompiled_functions.cpp` are **gitignored** (`.gitignore:18-19`) — the live dispatch table is not under version control. ### Next session — highest value first 1. **Body diff pass-2 output vs `output/`** for the ~16,900 shared functions. User already approved this ("actually lets follow your recommendation first") and it was never started. Scratch-only, no build. Would expose what the lost `stubs`/`skip`/`patches` config was doing. 2. **Re-verify the `sub_00463180` `unhandled-instruction` flood is gone** with the corrected map. Predicted (it was a boundary artifact — walking into `.rodata` and decoding ASCII as MIPS), **not confirmed.** 3. **Resolve the 6 interior addresses** via entry guards. 4. **Reconstruct `config.toml`** — `config_manager.cpp:319-338` shows a config *writer* exists. Required before any regeneration that touches the live tree. 5. **Run the built `ps2EntryRunner.exe`** — the 5:03 build succeeded but has **never been run**. 6. Consider adding `PS2_ARKD_SERVICE` to `launch_recomp.ps1`'s `$Tracers` (a `.ps1` edit, no build). ## Prior Status (2026-07-20f) — ROOT CAUSE OF THE `0x20561900` DERAIL IS MEASURED **The derail is a stack-pointer shift, not a corrupted pointer.** Measured causal chain (frametrace instrumentation, `PS2X_FRAMETRACE=1`, run of 2026-07-20f): 1. `0x177fbc jal 0x175060` sets `$ra = 0x177fc4`. `0x175060` is a 3-instruction syscall stub; `0x175064` is its `syscall` (`$v1 = 0x77` → `sceSifSetDma`). 2. The dispatch loop then invokes **slot `0x178068` while `ctx->pc` is still `0x175064`** (stale). 3. `0x175064` is not a case in `fn_178068`'s entry `switch (ctx->pc)`, so `default: break;` **falls into the prologue** → `sp -= 0xa0`. 4. The body exits mid-loop at `0x1780c0` without restoring `sp`. Measured: `[frametrace:IMBAL] #1 func=0x178068 entryPc=0x175064 delta=-0xa0`. `0xa0` is exactly `0x178068`'s prologue adjustment. 5. Every outer frame then runs `ld $ra, N($sp)` / `ld $sN, N($sp)` against a stack shifted by `0xa0`, restoring stack *data* into callee-saved registers. Fault-dump corroboration (`[gpr]` + `[stack]` in `reportMissingFunction`): - `s0=0x20561900 s2=0x177fc4 s3=0x20561900 ra=0x20561900` - `$s2` is the small array index in `0x177eb0` (`sll $a1, $s2, 4`) — it should be 0/1/2 and instead holds a **return address**. `$ra` is not specially corrupted; it is one of four casualties of the same bad restore. - The stack region is provably `0x177eb0`'s 16-byte entry array: every entry's `+0xc` word is `0x44`, matching `0x177f88 addiu $v0,$zero,0x44 ; sw $v0,0($a0)`. - The correct `0x177fc4` sits at `sp-0x40` and `sp+0x10`; `0x20561900` sits at `sp-0x30`, `sp-0x10`, `sp+0x20`, `sp+0x40` — the `0xa0` shift, visible directly. - Trace shows `0x178068` twice per cycle: once via the syscall path with stale `pc`, once normally. ### ❌ REFUTED this session (do not revisit) - **"`0x20561900` is corruption."** It is the uncached mirror of `0x561900`, a legitimate SIF packet-pool pointer this code passes around normally. `Ps2FastRead8/32` mask with `PS2_RAM_MASK` (`ps2_runtime_macros.h:162,187`), so `0x2xxxxxxx` data reads are valid. The 155 store-traps found nothing because **nothing ever wrote a bad value.** - **Copy-loop overrun in `0x178068`.** Measured `[0x5616d8] = 0x20561600`, which matches `[SIF_DIAG] rx=0x20561600` exactly — it is the SIF RX buffer pointer, not a trip count. No oversized count exists. - **Syscall routing.** Syscall `0x77` is fully handled in `SIF.cpp:1259` (`sceSifSetDma`) and `return true`s from `Dispatcher.cpp:309`. It never dispatches into `fn_178068`. ### 🔴 THE ONE OPEN ITEM — start here next session **Why does the dispatch loop invoke slot `0x178068` while `ctx->pc` still holds `0x175064`?** - `0x175064` is **not** in the dispatch table (only `0x175060` is, index 119830) — so this is not a mid-body table alias. - Something calls the slot without refreshing `pc` first. **Not guessed. Unmeasured.** - Next action is read-only, no build cycle: read the dispatch loop and syscall-return path in `ps2xRuntime/src/lib/ps2_runtime.cpp` and `Kernel/Syscalls/Dispatcher.cpp` and find where `ctx->pc` is left unadvanced after a `syscall`. - Fix shape follows the answer: either a `default:` guard in `ps2xRecomp/src/lib/function_emitter.cpp:110-123` (systemic, recompiler-side, affects every emitted entry switch) or a targeted `game_overrides.cpp` override. ### Collateral — this run went materially further than any prior run `ARKD_DVD.IRX` loaded (entry `0x04a6bc`, 12 libs, 57 stubs) → `Disk Media TYPE : DVD.` → `INFO.DAT` read ok (`lbn=1048576 sectors=47`) → `GAME.DAT` located (`lbn=1048625 size=0x2ba29000`) → 4 services registered (`sid=0x500/0x501/0x502/0x503`) → `[ARKD:BIND] client=0x5a9330`. Only **3** frame imbalances in a 242 MB log — a specific bug, not an endemic one. ### Instrumentation notes (for whoever picks this up) - `PS2X_FRAMETRACE=1` arms a 64-entry ring dumped by `reportMissingFunction`; `PS2X_TRAPVAL=addr:lo:hi` arms the store trap (`ps2_runtime.cpp:2384-2408`). - `sdbzFrameTraceWrapper` is a **template per slot**, so each instantiation has its OWN `static` counter — the `#N` in `[frametrace:IMBAL]` is per-slot, not global. Three `#1` lines are three different slots. - `run_log.txt` is UTF-16 **and** has console line-wrapping baked in at ~120 chars. `Select-String` needs `-Context 0,3` to recover a full logical record. - `RUNTIME_LOG` is a compiled-out no-op here (`PS2_RUNTIME_LOGS` undefined) — diagnostics must use `std::cerr`. - `RecompiledFunction` is a **nested** alias: `PS2Runtime::RecompiledFunction` (`ps2_runtime.h:476`). Unqualified use in `game_overrides.cpp` = 12 build errors from one line. ## Previous Status (2026-07-20) — ✅ `0x104bf0` OVERRIDE CONFIRMED WORKING. `0x20561900` derail re-opened and narrowed by measurement to a **stale-frame `$ra` READ**. (Superseded above: the stale-frame read is confirmed as the *surfacing mechanism*, but its cause is the `0x175064` entry-switch fall-through, not a mid-body dispatch of `0x178428`.) **Standing user constraint this session: "go no guess work please."** Every claim below is backed by a read or a log grep; the one open item is explicitly labelled as unmeasured. ### ✅ Resolved this session — `0x104bf0` truncated-function override - `fn_104BF0_0x104bf0.cpp` emitted **1 of 4** real instructions (`lui $v0,0x50` only), no `jr $ra`, no terminal `ctx->pc` → infinite re-dispatch. - Fix in `game_overrides.cpp` (`sdbzRegisterHandler104BF0`, registered via `replaceFunction(0x00104BF0u, …)`): stores `$a0` → `0x00503230`, sets `$v0=1`, returns via `$ra`. - **Build-verified: `0x104bf0` appears NOWHERE in the post-build `run_log.txt`.** First confirmed forward progress in several sessions. ### ❌ Truncated-function bug is NOT the `0x20561900` cause — DEMOTED Grepped `build_scripts/truncated_functions.json` for all six derail-trace addresses (`1540704|1568096|1568176|1631752|1541128|1541672`) → **zero matches**. The bug is real (86 bodies / 76 addrs) but unrelated to this derail. Still open, needs a recompiler run. ### The `0x20561900` derail — write-side is now FULLY excluded Fault signature: `pc == ra == target == 0x20561900` (the SIF packet-pool base), `sp=0x1ffbc40`, then 60+ s of watchdog spin. Trace: `0x178260 -> 0x17ed60 -> 0x17edb0 -> 0x18e408 -> 0x178a08 -> 0x178428 -> 0x17ed60`. `PS2X_TRAPVAL` (plan `rippling-swimming-walrus.md`) was armed on `0x20561900`. **155 hits, ALL legitimate SIF pool-pointer stores** (writing PCs clustered `0x177exx`–`0x178cxx`). `0x1780cc` recurs on a strict 7-hit period writing `0x1ffbb84/0x1ffbbd4/0x1ffbc24` — all **below** `sp`, all legitimate. The suspect slot is `sp+0x20 = 0x01FFBC60`. It holds `0x20561900` at fault time with **no trapval hit ever recorded for that address.** Every candidate writer eliminated by measurement: | Candidate | Verdict | Evidence | |---|---|---| | Guest store via `WRITE*` macros | ❌ | Zero trapval hits at `0x1ffbc60` across 155 hits | | ARKD `sub_ADD4` SIF DMA (`ps2_iop_irx_loader.cpp:710-744`) | ❌ | **Zero `[ARKD:sifdma]` lines** in run_log — never executed | | `guestRealloc` memmove (`ps2_runtime.cpp:1945`) | ❌ | `kGuestHeapHardLimit = 0x01F00000` (line 190), clamped at lines 613/981/1628/1634. Fault addr `0x01FFBC60` is **~1 MB ABOVE** the heap ceiling — unreachable | | All other runtime `memset`/`memcpy` into rdram | ❌ | Write zeros or small constants (`ps2_runtime.cpp:929/1850`, `ps2_iop.cpp:232/236`, `ps2_iop_cl.cpp:215/495`, `ps2_iop_sdrdrv.cpp:77/189/301`, `ps2_iop_mcman.cpp:292/293`) | **Plan outcome #3 (runtime-side memcpy) now has ZERO surviving candidates. Nothing wrote that slot.** ### 🔬 SOLE SURVIVING HYPOTHESIS (unmeasured) — stale-frame `$ra` read in `0x178428` `fn_178428_0x178428.cpp` (`// Address: 0x178428 - 0x1784d0`, 48-byte frame): - Prologue at `0x178428`/`0x178434`: `addiu $sp,$sp,-0x30` then **`sd $ra, 0x20($sp)`** → `sp+0x20` IS its `$ra` save slot. Confirmed by read. - **Entry `switch (ctx->pc)`** `goto`s `label_178440/178458/17849c/1784b8` — **skipping the prologue entirely** (no `sp` decrement, no `$ra` save). - `label_1784bc` runs the epilogue **unconditionally**: `ld $ra, 0x20($sp)` then `addiu $sp,$sp,0x30`. - ⇒ On a prologue-skipped entry, `sp` is the *caller's* `sp`, so `sp+0x20` points into the caller's frame — which legitimately holds SIF packet payload `0x20561900`. `jr $ra` then sets `ctx->pc = 0x20561900`. This is a **LOAD from an unwritten slot**, which is exactly why 155 store-traps found nothing. It explains all four otherwise-contradictory facts: no trapval hit, the value being exactly the SIF pool base, `pc==ra==target`, and **no `jalr` anywhere in the function**. Second route (same failure): the duplicate `sub_00178428_0x178428.cpp` (identical body, newer codegen) additionally has a **post-`jr` `switch (jumpTarget)`** that `goto`s back into the body *after* `sp` was popped by +0x30 — re-entry on a dead frame. ⚠️ **The entry switch exists in BOTH variants**, so the prologue-skip route is live regardless of which is registered. ### Excluded as originators (read, confirmed `$ra`-transparent) - `0x17ed60` (`cpu_disable_interrupts`) and `0x17edb0`: pure leaves — no prologue, no epilogue, no `$sp` access, no `$ra` save. They *surface* a bad `$ra`, cannot originate one. - `0x178260`: intact, 64-byte frame, `$ra` at `$sp+48`, save slot **clean** at fault time. ### Memory is NOT generally corrupt `sp+0x10`=`0x177fc4`, `sp+0x30`=`0x564b40`, `sp+0x90`=`0x178018`, `sp+0xa0`=`0x178d1c` — all valid. The `0x20561900` values in-frame are genuine SIF packet-descriptor payload (interleaved with lengths `0x40`/`0x44`, flags `0xffffffff`, buffers `0x5688a8`/`0x5a9380`). ### NEXT (handoff, in order) 1. **Measure the stale-frame hypothesis — do NOT fix on assumption.** Instrument entry/exit `sp` for `0x178428` from `game_overrides.cpp` or the runtime layer (**never** the runner file — [[feedback-no-runner-file-patches]]). Look for a call that exits with `sp` HIGHER than it entered, or a second pass through `label_1784bc`. 2. **Determine which `0x178428` variant is dispatch-registered** — `fn_178428_0x178428` vs `sub_00178428_0x178428`. Decides only whether the *second* route is also live. Same question open for `0x17ed60` and `0x178260`. 3. Narrow `PS2X_TRAPVAL` to `[0x1ffbc00, 0x1ffbd00)` to cut the 155 false positives per run. 4. Read the two unexamined trace functions: `0x178a08`, `0x18e408`. 5. Build: `cmake --build "F:\SDBZ Recomp\build"` under vcvars64 (user runs). ### Open minor anomalies - **Misaligned `sq`:** trapval logged `addr=0x1ffbb84 size=16` — `0x84 & 0xF = 4`, not 16-byte aligned. Real R5900 `sq` masks the low 4 bits. Either the trap logs a pre-mask effective address, or `WRITE128` in `ps2_runtime_macros.h` is missing the mask. **Unverified.** - **Unimplemented syscall**, run_log line 490: `[Syscall TODO] encoded=0x0 v1=0x6b v0=0x503070 a0=0x1 a1=0x0 a2=0x178560 a3=0x20561600 pc=0x174f70` (RA=`0x17e238`). - **Duplicate generated files** covering identical address ranges from different recompiler generations, confirmed at `0x178260`, `0x17ed60`, `0x178428`. Which variant is registered is unknown for every one. - **Stale comments** referencing `0x20561900` in `Kernel/Stubs/SIF.cpp` lines 247/627/630/662 — dead commentary from the disproven `pkt[5]` hypothesis, no live code. Safe to delete. - Run-to-run nondeterminism (boot depth varies on identical logic) — unexplained. ### DISPROVEN — do not resurrect Dispatch-table poisoning · unsanitized `pkt[5]` · stack-frame overrun in the `lq`/`sq` packet copy (disproven twice) · a *guest store* clobbering saved `$ra` · the `starts`-set theory for `0x104bf4` · null `eeDest` · worker pump ordering · `sub_1DA4` DMA before `BF50` populated · ARKD `sub_ADD4` SIF DMA · `guestRealloc`. **Note on the IOP/ARKD thread below (07-19d):** that work is code-complete and unbuilt. It is a *separate* blocker from this EE-side derail; the 07-19c run showed the `0x20561900` derail gone under `PS2_SIF_DIAG`, but it has recurred in the current build. Both threads are live. --- ## Prior Status (2026-07-19d) — ⏳ Phase A + B CODE-COMPLETE, UNBUILT. Plan `hidden-tinkering-rossum.md` in flight. Awaiting user build+run to get Phase B diagnostics. **Blocker model (unchanged, root-caused 07-19c):** EE sits in a steady SIF-RPC CALL poll (sid `0x503`, func `0x2`, recv `0x5aa7d0` sz `0x10`, send-seq `w[6]` climbing). The real R3000 service func-2 (`sub_42700`, ARKD func `0x042700`) runs clean and faithfully returns `recv=00 00 00 20` = `dword_B300=0x20000000` (**bulk transfer IN PROGRESS**) every poll. `[ARKD:search] "\INFO.DAT;1" -> found` works but `[ARKD:cdread]` never fires and worker queue `dword_B310=0`. **Root cause:** on real HW the transfer runs on ARKD's own IOP thread concurrent with the EE poll; our interpreter only runs the polled func-2, never pumps the in-flight transfer body to completion → B300 frozen mid-transfer → poll spins forever. **Phase A (done, code) — de-noise syscall 0x68:** `Kernel/Syscalls/Dispatcher.cpp` — added `case 0x68: case (uint32_t)-0x68:` → bounded-logged no-op returning 0 (guest discards the result; `fn_104F20` GS field-flip red herring). Removes the repeating `[Syscall TODO] v1=0xffffff98`. **Phase B (done, code) — transfer diagnostics in `ps2_iop_irx_loader.cpp`:** - `[ARKD:worker] run:` log extended → now prints `off=+0x%06x` (module-relative finalPC) + `<-- INCOMPLETE (mid-transfer)` flag when `B300 & 0xF0000000 == 0x20000000` at halt. - New `[ARKD:idle]` diag in the WaitSema `0xAEDC` idle-halt path (fires ≤16×, always-on): logs when the worker declares itself idle (`B310==0`) while B300 is still `0x20000000` — the suspected premature transfer cut-off. **Hypothesis to test with Phase B output:** the transfer routine clears B310 and loops back to WaitSema BEFORE finishing the read+DMA, so the idle-halt freezes B300 mid-transfer and no cdread ever fires. Phase B output pins the exact stall PC → Phase C wires the pump precisely there. **Phase C (NOT started):** pump the pending ARKD transfer routine / worker `sub_21B0` in the R3000 so ARKD's own code advances B300 `0x20000000→0x40000000|result` and DMAs read data to the EE recv buffer. No runner edits, no faking. Depends on Phase B run output. **NEXT (handoff):** 1. User builds: `cmake --build "F:\SDBZ Recomp\build" --config Debug --target ps2EntryRunner` (RecompDebugger LNK1104 file-lock = harmless). 2. User runs with `$env:PS2_ARKD_IRX_RUN=1; $env:PS2_ARKD_SERVICE=1; .\launch_recomp.ps1`. 3. Grep (UTF-16): `Select-String -Path .\run_log.txt -Pattern '\[ARKD:(worker|idle|cdread|sifdma|search|run)\]'` — look for `INCOMPLETE`+`off=`, `[ARKD:idle]`, whether `[ARKD:cdread]`/`[ARKD:sifdma]` fire. 4. Wire Phase C at the stall point Phase B reveals. Plan detail: `C:\Users\mwlab\.claude\plans\hidden-tinkering-rossum.md`. --- ## Prior Status (2026-07-19c) — ✅ 0x20561900 SIF DERAIL IS GONE (Phase 1 diagnostic, `PS2_SIF_DIAG`). Boot advanced far past it; new terminal blocker is EE-side. **Phase 1 (`hidden-tinkering-rossum.md`) ran — instrumentation-only, env-gated `PS2_SIF_DIAG`. Findings from run_log.txt (2522 lines):** - ✅ **`[SIF_DIAG:DERAIL]` = 0. "No exact recompiled function for guest PC 0x20561900" = 0.** The 0x20561900 derail the plan targeted **no longer happens** — the 07-17h `pkt[10]=0xFFFFFFFF` sentinel + loopback fixes already cleared it. Every `0x20561900` in the log is just `src=0x20561900` (the *normal* EE packet-pool source addr, never a jump target) — that spam masked the fact the crash was already fixed. **Phase 2 is effectively satisfied; no SIF.cpp behavioral edit needed for the derail.** - ✅ SIF_DIAG dump confirms: user/server table is empty (`usrCnt=0`); the synthesized `pkt[2]=0x80000008` (negative) routes the **system** table (`sysBase=0x561700 sysCnt=0x20`, sys[0]=0x177aa8 sys[1]=0x177a88) which is valid. All 24 BIND/CALL deliveries dispatch cleanly. ARKD sids **0x500–0x503 each BIND once**. - ✅ Boot now runs the **full** ARKD path: IRX load → `_start` (halt) → 4 SIF-server threads (halt) → InitLoadBuffers → **64 `[ARKD:run]` service CALLs** → 1 worker → 1 sifdma. Furthest ever. - ⚠️ **InitLoadBuffers still spins** (`retired=4000000 finalPC=0x00040930 halted=0`) BUT `[ARKD:cdread]`=0 — it spins at module **+0x930 BEFORE issuing any cdvd read**, and the loader **continues past it anyway** (services run afterward). So this is wasteful, **not** the terminal stall. Phase 3 (IOP cdRead completion) is **de-prioritized** — it is not what blocks boot. **NEW TERMINAL BLOCKER (EE-side steady-state loop, not a hang):** - Watchdog `stuckSecs` keeps resetting to 0 — EE is **alive**, cycling the SIF dispatcher (`0x17ed60→0x177eb0→0x1781b0→0x178068→0x178560→0x178de8`) + main loop `0x422454`, `lastCall=0x172998`. - EE repeatedly issues **SIF-RPC CALL** (`cid=0x8000000a`, `w[8]=0x2`, send buf `w[10]=0x5aa7d0` sz `0x10`) with **incrementing seq `w[6]=0xa9→0xaa→0xab…`** — a live poll loop (retry/timeout cadence). - Between each CALL it hits an **unimplemented EE syscall at `pc=0x174f50`**: `[Syscall TODO] encoded=0x0 v1=0xffffff98` → dispatcher gets raw `0xffffff98` = **-0x68 (syscall 0x68 / 104)**. No `case 0x68` **or** `case -0x68` in `Kernel/Syscalls/Dispatcher.cpp` (it enumerates specific ±cases, e.g. `-0x70`=iGsGetIMR; there is no global negate) → falls to `TODO()` (warns, does nothing). Call chain: `0x104f20 → 0x174f50(syscall) → 0x172e20 → 0x172a90 → 0x172998`. RA=0x105000, v0=0x7000. **NEXT (Phase 1 done; propose new direction — user to steer, ideally after `/compact`):** 1. **Identify EE syscall 0x68 / -0x68** authoritatively (ps2tek table or read the generated fn @ 0x174f50 — read-only, never patch). Likely a needed kernel op the poll loop waits on; implement it in `Dispatcher.cpp`/`System.cpp` (a `case 0x68`/`-0x68`), or a game_override if it's a game-specific thunk. 2. **Determine if the SIF CALL sid `w[8]=0x2` poll is genuine progress or a stall** — is the game waiting on a CALL reply, or on the syscall side effect? The seq-incrementing CALL smells like a timeout-retry around the unhandled syscall. 3. Parked non-fatal: 16× `No exact recompiled function for guest PC 0x1bfdb0` (unrelated missing EE dispatch entry, recovers). `PS2_SIF_DIAG` instrumentation left in place (env-gated, ≤24 emissions, zero cost when unset). Full plan detail: `C:\Users\mwlab\.claude\plans\hidden-tinkering-rossum.md`. --- ## Prior Status (2026-07-19b) — SERVICE-DATA HYPOTHESIS DISPROVEN. Real blocker re-root-caused: EE SIF dispatcher `fn_178068` derails into PC `0x20561900` on an ARKD **BIND** loopback. The `PS2_ARKD_SERVICE` bridge is a dead end for these CALLs (they carry `rsz=0`, no reply expected). Widened gate = inert (no regression, no fix). **07-19b LONGER-RUN RESULT (full run to crash, from run_log.txt, 2.6M lines):** - ✅ Disc search + read still clean (`[ARKD:search] "\INFO.DAT;1" -> found`, `cdvdman fid=13` reads fire). All 4 services register. - ❌ **IOP-side:** `InitLoadBuffers run: retired=4000000 finalPC=0x00040930 halted=0` — spun the full 4M-instruction cap and **never halted**. It's an async cdvd read-wait poll whose completion flag/sema is never signaled by our `sceCdRead` HLE. (Independent sub-bug; may or may not gate the EE crash.) - ❌ **EE-side (the crash):** the ARKD CALLs are **`func=0x0, rsz=0x0`** (log line 411/432: `send=0x20561900 ssz=0x10 recv=0x5aa3d0 rsz=0x0`). rsz=0 ⇒ the `if (recvPtr && recvSize)` guard in the service bridge is **false** ⇒ `ps2_iop_runArkdService` never runs. So delivering reply data is NOT the mechanism the EE waits on. The widened `sid 0x500..0x503` gate (this session's edit) is therefore inert — correct-when-needed, but not this blocker. - ❌ After CALL 0x500 → CALL 0x501 → **BIND 0x502**, `[SifRpcReply] deliver cid=0x80000009 -> run dispatcher 0x178068`, then `Error: No exact recompiled function for guest PC 0x20561900` **spins to EOF**. The 0x20561900 loop (thought fixed 07-17h) is **BACK** under the real-IRX-run path. **ROOT CAUSE (newly pinned, deeper than 07-17h):** - `fn_178068` (`ps2xRuntime/src/runner/fn_178068_0x178068.cpp`) is the EE's **RPC-server request dispatcher** (sceSifRpc server loop). `$s1` = an RPC_SERVER_DATA struct: `+0xC`=func-table base, `+0x10`=func count, `+0x14`=2nd table base, `+0x18`=2nd count. - Path 0x178150→0x178180: `lw $v1,0x14($s1)` (table base) → index by `rpcnum*12` → `lw $a2,0x0($v0)` (handler ptr) → `jalr $a2`. The loaded handler ptr = **`0x20561900`** (the SIF packet-pool buffer) → EE executes packet data as code. - i.e. our loopback of the game's own outbound **BIND** into the EE RX queue makes `fn_178068` treat it as an **incoming EE-server request** and dispatch through a server table that was never validly populated for these sids → garbage func ptr = the packet buffer. The 07-17h `pkt[10]=0xFFFFFFFF` sentinel (client_block[5]) IS still applied (`dest=0xffffffff` in log) — it fixed a *different* field; this crash is via the `+0x14` server-table read. **NEXT (needs a plan pass — user to decide direction; do NOT blind-edit `fn_178068`, it's a runner file):** 1. **Decide the seam.** The fix must live in `SIF.cpp` (allowed) or `game_overrides.cpp` (allowed). Question: should a BIND loopback route to `fn_178068`'s server-dispatch path at all? On real HW the IOP registers the server; the EE only holds a client handle. Options: (a) for ARKD BINDs, deliver the register-completion in a way that does NOT re-enter the server-dispatch jalr path (route purely to `_request_end` register branch, verify `fn_178068` doesn't fall through to 0x178150); (b) suppress the loopback-run of `fn_178068` for ARKD BINDs once the WaitSema flag is set directly. 2. **Separately, the IOP `InitLoadBuffers` 4M-spin:** our `sceCdRead` HLE must signal read-completion (the flag/sema the IOP polls at `finalPC=0x00040930`) so the loop halts cleanly. Check `ps2_iop_cdReadSectors` / cdvdman completion path. 3. Re-run + grep `0x20561900|ARKD:BIND|InitLoadBuffers|deliver cid`. **Note:** this is the long-standing "SIF-RPC bind ping-pong" blocker ([[reference_ps2_sif_boot]]), now characterized at instruction level. Prior 07-19 note (below) assumed the EE reaches a 0x503 DVD-read CALL needing service data — **that premise is wrong**; the EE never gets there, it derails on the 0x502 BIND dispatch first. **(prior 07-18c wiring notes retained below)** S2.2c/d WIRED behind env gates, BUILT clean. **What was wired this session (all gated `PS2_ARKD_IRX_RUN=1` + `PS2_ARKD_SERVICE=1`, default boot path byte-for-byte unchanged):** - **Root cause found:** cdvdman `fid=10` = `sceCdSearchFile` (`sub_ACA8`, off 0xACA8) is a filename→LBN resolver that **gates every read**. ARKD spins `while(!sceCdSearchFile(fp,"\INFO.DAT;1")) delay()`. Default hook returned 0 → infinite spin → `dword_B300` stuck `0x20000000` (reading), game polls fno 0x2 forever. The real read (`sub_AC98`, off 0xAC98) is downstream and was never reached. - **Fix (2 edits, both compiled clean):** - `Kernel/Stubs/CD.cpp`: added external-linkage forwarder `ps2_iop_cdSearchFile(name, &lbn, &size)` that calls the existing `registerCdFile(ps2Path, CdFileEntry&)` primitive → search-LBN and read-LBN come from the SAME pseudo-LBN table (honors no-IOP-faking: real ISO table + real sectors). - `ps2_iop_irx_loader.cpp`: new import hook `off==0xACA8u` — reads the filename string from IOP RAM (a1), calls `ps2_iop_cdSearchFile`, writes `{lbn@0, size@4}` into the `sceCdlFILE` struct at IOP RAM (a0), sets `$v0=1` found / `0` miss, returns to `$ra`. Logs `[ARKD:search] "name" -> found/MISS lbn size`. - Pre-existing loader hooks (unchanged): `off==0xAC98u` cdvd read→`ps2_iop_cdReadSectors`; `off==0xAEDCu` WaitSema/worker-halt; `off==0xADD4u` SIF DMA IOP→EE. **BUILD RESULT (user ran `cmake --build`):** `CD.cpp` + `ps2_iop_irx_loader.cpp` compiled clean; `ps2_runtime.lib` + **`ps2EntryRunner.exe` linked successfully** (benign LNK4006/4075/4088 only). The only failure was `RecompDebugger.vcxproj` → `LNK1104: cannot open file ...RecompDebugger.exe` = **harmless file-lock** (that exe was running/open); unrelated to ARKD and unrelated to the runner target used for the game. Close it or ignore. *(07-18c disco-re-run checklist resolved — see the 2026-07-19 status block above; search + read confirmed working.)* --- ## Prior Status (2026-07-18b) — ✅ S2.2c DISCOVERY COMPLETE. The full ARKD DVD-read worker chain is now mapped end-to-end (live harness trace + IDA decompile of ARKD_DVD.IRX). The three prior unknowns (which sema, which thread waits, which cdvdman fid reads) are resolved. **S2.2c DISCO RESULT (headless `iop_harness.exe`, real send header, fno 0x0/0x102/0x2):** - **The 0x503 dispatcher = `sub_2700`** (func 0x042700). fno→handler map (fno = a1): - fno 4 → `sub_1A10` (SetString "cd_root") - fno 1 → `sub_16A4`; **fno 2 → `sub_1714` (POLL: returns `dword_B300` verbatim)**; fno ≥3 → `sub_1760` (TocLookup+read by name) - **fno 0x102 (258) → `sub_2410` (SUBMIT read)**; fno 0x101 → `sub_250C`; fno 0x103 → `sub_2608`; fno 0x81 → `sub_183C` - **SUBMIT `sub_2410`** (live-confirmed): `sysclib memcpy 0x24` bytes (recv buf 0x54e50 → job struct `dword_BF50`=base+0xBF50), sets `dword_B310=1` (job type), `dword_B300=0x10000000`, then **`SignalSema(dword_B318)`**. - **THE WORKER = thread `sub_21B0`** — a DEDICATED thread (NOT one of the 4 SIF servers). Loops: `WaitSema(dword_B318)` → dispatch on `dword_B310` (1→`sub_1DA4`, 2→`sub_1A58`, 3→`sub_1EB0`) → the read (`sub_1A58`→`sub_7A4`→**cdvdman read import `sub_AC98(lbn,sectors,dest,mode)`** with 6× retry via `CdReadRetryLoop`/`sub_CCC`) → sets `dword_B300` = `0x30000000` done or `0x40000000|err`. Filename comes from job struct at `byte_BF54` (job+4) via `TocLookup`. - **THE INIT = `sub_28AC` (InitLoadBuffers)**, reached via `sub_30`: `CreateSema`→`dword_B314`(mutex)+`dword_B318`(worker wakeup, count 0), `CreateThread(entry=sub_21B0)`+`StartThread` (creates the worker), allocs load buffers, reads INFO.DAT/GAME.DAT TOC (`sub_1870`), sets `dword_B300=0x30000000` ready. - **Why disco fired no cdvdman:** the harness runs only `_start` + the 4 SIF-server thread *setups* (each halts at the RpcLoop stub). It NEVER runs the init RPC (`sub_30`/InitLoadBuffers), so `dword_B318`=0 and no worker exists → submit's `SignalSema` went to sema **0** (noop), read never ran. Poll returned the static `dword_B300` (0x30000000). **S2.2c/d WIRING IMPLICATION (for next steer — NOT yet wired):** our run-to-halt model has no persistent scheduler. To service a real 0x503 read we must (1) run `InitLoadBuffers` once at load (so semas/buffers/TOC/worker exist), and (2) after a submit sets `dword_B310`+signals, DIRECTLY run one worker iteration (`sub_1A58`/`sub_1DA4`/`sub_1EB0` per `B310`) so the cdvdman read (`sub_AC98`) executes inline — then hook `sub_AC98` to `readCdSectors` into IOP RAM. The exact numeric cdvdman fid resolves the instant `sub_AC98` first executes (import hook logs it). **PAUSE for user steer before behavioral wiring** (per plan). --- ## Prior Status (2026-07-18a) — ✅ S2.2a + S2.2b CONFIRMED. Real ARKD_DVD.IRX `_start` now runs to a CLEAN HALT in the embedded R3000, and all 4 SIF-RPC services self-registered. We now have the REAL per-sid dispatch function pointers — the exact Stage 2 targets. **S2.2a/S2.2b RESULT (run log 07-18a, `PS2_ARKD_IRX_RUN=1`):** - `$sp` bug fixed (`setGpr(29, 0x1FFF00)` before `_start`) → the `unhandled write32 @0xffffff**` stack-fault spam is GONE. - `_start run: retired=183 finalPC=0x0ffffff0 halted=1 threads=4` — module init ran real R3000 code to a clean sentinel halt; captured 4 CreateThread entries. - Real HLE imports serviced: AllocSysMemory (bump), CreateSema, CreateThread (records entry), StartThread (records arg), printf. printf strings prove the real module ran: **"ARIKA IOP Control Driver Version %d.%d.%d (C)ARIKA Co.,Ltd."** + **"TARGET CD/DVD Version."** - S2.2b ran each of the 4 server-thread bodies once → each self-registered its RPC. **CONFIRMED dispatch table (sid → service func in IOP RAM, load base 0x040000):** | sid | client | service func | recv buf | thread entry | |--------|----------|--------------|----------|--------------| | 0x500 | 0x5a9330 | **0x0401d4** | 0x054a50 | 0x04a4dc | | 0x501 | 0x5a9380 | **0x046708** | 0x055250 | 0x04a554 | | 0x502 | 0x5a93a8 | **0x049d38** | 0x055650 | 0x04a5cc | | 0x503 | 0x5a9358 | **0x042700** | 0x054e50 | 0x04a644 | - **The DVD-read blocker = sid 0x503 → func 0x042700.** Stuck CALL packet (steady state): mode=2, send buf, recv buf 0x5aa7d0, size 0x10, rpc_number climbing (timeout retries). handleRPC still → false (S2.2d not wired yet), so the game still hangs at pc=0x422454 / lastCall=0x172998 — EXPECTED, no regression. - Parked (non-fatal): `No exact recompiled function for guest PC 0x1bfdb0` (via 0x171c30→0x1bfdb0) spams during the init CALL burst then recovers — missing dispatch-table entry, unrelated to DVD blocker. **NEXT: S2.2c + S2.2d** — cdvdman sceCdRead → real ISO sectors into IOP RAM (`readCdSectors`); then per-CALL bridge in SIF.cpp (gated `PS2_ARKD_SERVICE=1`) that, for sid 0x503, sets up the R3000 (a0=send buf mapped into IOP RAM, $sp, halt sentinel), calls func 0x042700, and copies the service's recv-buffer output back to the guest recv buf (0x5aa7d0) before the dispatcher runs. --- ## Prior Status (2026-07-17h) — ✅ SIF PACKET-POOL SENTINEL FIX CONFIRMED (0x20561900 loop GONE). Game now runs a stable main loop (furthest ever). New blocker: **ARKD DVD-read RPCs get NO result data** — the SIF.cpp echo path never invokes handleRPC, and ARKD SID isn't handled in ps2_iop.cpp. **SENTINEL FIX CONFIRMED (run log 07-17h):** - `SIF.cpp` BIND branch `pkt[10] = 0xFFFFFFFF` fix WORKED. The `No exact recompiled function for guest PC 0x20561900` loop is GONE. Outbound packets no longer carry string garbage in w[3]. Boot advanced well past the corruption point. - All 5 IRX still load OK. Then the game issued its post-ARKD service binds (clients 0x5a9330/0x5a9358/0x5a9380/0x5a93a8, SIDs 0x500 etc.) and entered a **stable main loop** — watchdog PC varies (0x11e3b0/0x11f240/0x175210 vblank/0x104c74) and stuckSecs resets, i.e. alive, not hung. Reaches 0x175210 (INTC VBLANK) now. **NEW BLOCKER (07-17h): ARKD service RPCs return no data** **STAGE 1 TRACE CONFIRMED (07-17i, `PS2_ARKD_TRACE=1`) — the Stage 2 contract:** - 4 ARKD clients, 4 distinct SIDs (static guess of a single sid 0x500 on 0x5a9358 was WRONG — 0x5a9358 is sid **0x503**): | client | sid (w8) | recv buf | |------------|----------|-----------| | 0x5a9330 | 0x500 | 0x5aa3d0 | | 0x5a9380 | 0x501 | 0x5aabd0 | | 0x5a93a8 | 0x502 | 0x5aafd0 | | 0x5a9358 | 0x503 | 0x5aa7d0 | - BIND: w4=0x5 (mode), w8=real sid. CALL: cid=0x8000000a, shared send buf **0x20561900**, recv sizes report **rsz=0** (game reads recv buf by fixed addr, not copy-back). - Func numbers seen on sid 0x500: 0x0 (init), then **0x12, 0x11, 0x1** — matches prior R3000-era `rpcno 1/2/17/18/258` servicing (state line ~216), so the real ARKD_DVD.IRX service fn is the Stage 2 target. - `handleRPC → false` on every ARKD CALL (expected: no handler) — bridge is observe-only, no regression, main loop preserved. - **The hang, now visible:** after the CALLs return nothing, the guest dispatcher (`sub_178068` loop) jumps to PC **0x20561900** = the send-buffer address → "No exact recompiled function for guest PC 0x20561900". The game is executing the RPC packet as code because the reply never delivered a valid function pointer. Stage 2 must service these 4 SIDs so a real reply lands in the recv buf before the dispatcher runs. - The game repeatedly issues CALL packets to ARKD client **0x5a9358** (seq incrementing 0x1a→0x36+, ~1-2/sec = timeout-retry), waiting for DVD data that never arrives. - Root cause (confirmed by code read): ARKD CALLs travel the **SIF.cpp `deliverSifRpcReply` echo path** (captured via sceSifSetDma, cid=0x8000000a), which synthesizes an empty completion and **never calls `ps2_iop().handleRPC`**. Separately, ps2_iop.cpp `handleRPC` has **no handler for the ARKD SID (0x500)** — the C++ HLE chain (SoundDriver/DbcMan/LibSd/Sound/ClFile/Sdrdrv/McServ/LOADFILE/CDVD) doesn't cover it. - Per no-IOP-faking rule ([[feedback_no_iop_faking]]): route ARKD CALLs through the embedded **R3000 interpreter running the real ARKD_DVD.IRX** (already loaded), not hand-written C++ outputs. Needs a plan pass — decide the wiring: SIF.cpp CALL branch → handleRPC/R3000, fill recv buf, echo back before dispatcher runs. - Secondary/non-blocking: `-104` (0x68) @0x174f50 ra=0x105000 and `107` (0x6B) @0x174f70 ra=0x17e238 fire periodically but do NOT hang the loop (thunk array verified correct vs ELF; dispatcher keys GsPutIMR/SetSyscall at 0x71/0x74 — these are genuinely different syscalls in the 0x65-0x6F RFU range, not GsIMR). Leave as no-op unless proven to gate the ARKD loop. --- ## Prior Status (2026-07-17g) — ✅ SYSCALL THUNK TABLE REGISTERED & CONFIRMED (0x1748a0 class GONE). ✅ ALL 5 IRX loadfile RPCs complete. Sentinel fix for 0x20561900 coded [CONFIRMED WORKING 07-17h]. **THUNK FIX CONFIRMED (run log 07-17g):** - `game_overrides.cpp` `applySdbzKernelThunkFixes` now registers all **120** EE kernel syscall thunks (`0x174880..0x174FF0`, 16B stride, `li $v1,N; syscall; jr $ra`) via `replaceFunction` + template-per-index handler (sets $v1 sign-extended, calls 2-arg `handleSyscall`, returns via $ra). `No exact recompiled function for guest PC 0x1748a0` is gone; thunks appear live in traces. - Boot then ran the FULL loadfile chain: bind ping-pong, func-255 "3000" handshake, then `Load Module cdrom0:\{MCMAN,MCSERV,LIBSD,CRI_ADXI,ARKD_DVD}.IRX OK!` — furthest boot ever. - Non-fatal unimplemented syscalls seen (follow-ups, game continued past both): `0x6b` (107) @pc=0x174f70 ra=0x17e238; `-104` (0xffffff98) @pc=0x174f50 ra=0x105000 (new async-stack thread). **NEW BLOCKER (07-17g): jump to guest PC 0x20561900 (SIF packet-pool corruption) — FIX CODED, UNVERIFIED** - Root cause: `SIF.cpp` BIND-completion echoed the request's WORD[5] (EE packet-pool buffer `0x20561900`) into `pkt[10]` → guest `_request_end` (0x178560) stores it as `client[5]` = "IOP server receive buffer". `rpc_call` (0x178BE8) uses client[5] as the DEST of every send-payload DMA → our `sceSifSetDma` saw a copyable EE dest and copied payloads (0x200-byte module-path strings etc.) over the game's own SIF packet pool at 0x561900. Evidence: later outbound packets carried string garbage (`w[3]=0x5c3a306d` "m0:\"); pool link word at buffer+20 holds `0x20561900`, which the dispatcher (0x178068, post-ARKD binds rpcNum 0xf–0x12, clients 0x5a9330/0x5a9380) eventually jumped to. Infinite `No exact recompiled function for guest PC 0x20561900` loop. - Fix: `SIF.cpp` BIND branch now sets `pkt[10] = 0xFFFFFFFF` (established IOP-bound sentinel; DMA copy path skips non-copyable dests; our RPC HLE reads payloads from src). One-line + comment. - Next expected after fix: the post-ARKD service calls (mode=3 async, clients 0x5a9330/0x5a9380, likely ARKD_DVD/CRI driver RPC) currently get NO result data from HLE loopback — may become the next blocker; route through embedded R3000 IOP interpreter per no-IOP-faking rule. --- ## Prior Status (2026-07-17f) — ✅ -65540 SIGNATURE GATE CLEARED & CONFIRMED. Boot advanced to a NEW, different blocker: missing recompiled function entry at EE 0x1748a0. [RESOLVED 07-17g via 120-thunk registration] **FIX CONFIRMED WORKING (run log):** - **Seed — `game_overrides.cpp:381` `applySdbzLoadfileSeed`**: writes EE `0x461C30 = -1` at ELF-load. Log: `[loadfile-seed] 0x461C30 <- -1 ... readback=0xffffffff`. REQUIRED (not redundant) — it arms d4a0's real path. - **LOADFILE HLE — `SIF.cpp` `deliverSifRpcReply` (kSifCmdRpcCall branch)**: tracks the client object BIND'd to sid `0x80000006` (LOADFILE, a PS2 ROM/kernel service — NOT a game IRX), then on its func-255 CALL writes the fixed protocol version `0x30303033` ("3000") into the recv buffer (`w[10]`, =`0x564b40`) BEFORE dispatcher 0x178068 runs. Log: `[SifRpcReply:LOADFILE] func255 recv=0x564b40 <- 0x30303033 ("3000")`. - **Result:** gate d5a0 passes → the `Can't load module cdrom0:\SIO2MAN.IRX;1, ret = -65540` retry loop is GONE. Game then issued the real func-0 loadfile RPC for `"cdrom0:\SIO2MAN.IRX;1"` (path decoded from packet bytes) and proceeded. **NEW BLOCKER (2026-07-17f):** after SIO2MAN loadfile, execution hits an indirect-dispatch jump with no recompiled function: ``` Error: No exact recompiled function for guest PC 0x1748a0 tableBase=0x100008 tableEnd=0x4e6c84 codeRegion=yes [guest-branch:missing-target] kind=IndirectJump op=dispatch source=0x0 target=0x1748a0 pc=0x1748a0 ra=0x1057fc sp=0x1ffbd40 a0=0x1 a1=0x2 policy=1 trace=... -> 0x171fd8 -> 0x172168 -> 0x174fe0 -> 0x1748a0 ``` - **DECODED from raw ELF (offset 0x74920):** `0x1748a0` = `addiu $v1,$zero,2; syscall; jr $ra` — a tiny **syscall trampoline**, one of a contiguous series the recompiler folded into `hw_timer_update_x @0x174808` and never emitted as exact entries: `0x174890`(li $v1,1) `0x1748a0`(#2) `0x1748b0`(#3) `0x1748c0`(#4) … up to the `syscall_stub @0x1748F0` block IDA *did* recognize. NOT garbage — a real missed entry point. - The game takes each trampoline's address and calls it **indirectly**, so each needs its own exact recompiled function entry; the dispatcher cannot jump into the middle of `0x174808`'s single C++ body. Class = **recompiler missing-entry** (cf. [[project_dispatch_table_unpopulated]], PR #150 entry discovery), NOT SIF HLE. - Caller returns to `0x1057fc`; `a0=1 a1=2`. Preceding: unimplemented EE `syscall 0x6b` @pc=0x174f74 (separate concern; these trampolines carry small $v1=1..N, likely game-installed SetSyscall handlers per [[reference_ee_syscalls]]). **NEXT (needs a fresh look, user to decide direction — pivot to recompiler-entry domain):** 1. Two fix routes: (A) add the trampoline addresses (0x174890, 0x1748a0, 0x1748b0, …) as function entries via the manifest/entry-discovery mechanism used by [[project_dispatch_table_unpopulated]] (which added entries WITHOUT a full recompiler rerun) — preferred; (B) runtime override that emulates the trampoline (set $v1=N, invoke the syscall dispatcher) in game_overrides.cpp — allowed layer, no rebuild, but needs one override per address. 2. First confirm how many trampolines the game actually calls (0x1748a0 is the one that faulted; siblings may fault next) and what syscall $v1=2 maps to in our Dispatcher. 3. Check whether a runtime dispatch fallback exists for `policy=1` "No exact recompiled function" — if it can be taught to run a mid-function guest address, that generalizes the fix. --- ## Prior Status (2026-07-17d) — ROOT CAUSE PINNED: -65540 is a PRE-RPC signature gate that short-circuits BEFORE any loadfile RPC. Not missing IRX, not Deci2 drain. `[loadgate]` dump decoded the exact gate words. **`[loadgate]` dump (run_log.txt):** `initFlag@461C30=0x0 sigA@461B5C=0x30303033("3000") ptr@461C34=0x4c07b0 sigB@*461C34=0x2e2e2e2e("....") fetched@564D68=0x0` **Chain (source-confirmed):** - SIO2MAN loads first via the infinite-retry wrapper (decompile ~line 2718-2727) → dies before `AudioSysInit`@0x422170 (mcman/mcserv/libsd/criadxi/arkddvd). - `noop_sub_dc78`@0x17DC78 → `noop_sub_d4a0`@0x17D4A0 → gate `wrap_mem_compare_n_c_0`@0x17D5A0. - **d4a0: `if (dword_461C30 >= 0) return 0;`** — 461C30=0 → short-circuits, NEVER runs the bind+func-255 RPC → 564D68 stays 0 (this is why the `[iop:LOADFILE]` sid-0x80000006 hook never fired). - Gate returns -65540 unless `564D68 == "3000"(461B5C)` OR `== "...."(*461C34)`. 564D68=0 → -65540 forever. - 461C30 must be -1 at first call. Only setter `wrap_mem_set_u`@0x17D630 has NO caller (SDK loadfile-init glue our runtime skips); ELF genuinely has 0 there (nearby .data 461B5C="3000" read correctly). - RPC plumbing READY: `mem_fill_z_18`@0x178A08=sceSifBindRpc, `mem_fill_z_369`@0x178BE8=sceSifCallRpc, both issue SIF DMA cmd 0x80000009 our runtime already intercepts. **FIX (allowed layer):** (1) seed EE 0x461C30=-1 once before first loadfile (enables game's own d4a0 — not faking); (2) HLE loadfile RPC sid 0x80000006 func 0xFF → return "3000" (0x30303033) → gate passes. Part 2 = legitimized entry for the parked IRX-loader work. **NEXT (cheap validating experiment):** seed 461C30=-1 only, one-time guarded, rebuild/run → expect `[iop:LOADFILE]` to fire + reveal func-255 request. Awaiting user go-ahead (pivot; /compact after). --- ### [SUPERSEDED 2026-07-17c] blocker framed as SIO2MAN.IRX load returning -65540 (correct value, wrong mechanism — it's a signature-gate short-circuit, not a load-RPC failure) **Diagnostics run result (recomp, run_log.txt):** - First TTY line printed ONCE, boot continued: `CMemory::Init memsize : 0173fc00 block:00000800` - Then ~20 SIF-RPC exchanges (game's own SoundDriver services, cid 0x80000009/0x8000000a), NO CD module loads. - Then boot spins FOREVER re-printing ONE fatal line: `Can't load module cdrom0:\SIO2MAN.IRX;1, ret = -65540` - No SIF/DMA traffic between the repeats → it is a fatal-error spin, NOT a printf-flush drain bug. printf worked fine for CMemory::Init (printed once, moved on). **Root cause (confirmed by source read):** - The game's `sceSifLoadModule("cdrom0:\SIO2MAN.IRX;1")` returned **-65540** (=0xFFFEFFFC) and the game treats it as fatal. - Our EE-syscall stub `SifLoadModule` (RPC.cpp:1510) + `trackSifModuleLoad` (Loader.h:78) ALWAYS return a positive fake id — they can NEVER produce -65540. So the game is NOT using our EE syscall path. - The game issues the **IOP LOADFILE RPC itself (sid 0x80000006)**. Our runtime has **NO LOADFILE handler** (grep: zero hits for 0x80000006/LOADFILE) → SifBindRpc hands it a dummy server → the call returns garbage/-65540. - Aggravating fact: we boot from the **ELF directly, no ISO mounted**, so even a real LOADFILE couldn't read `cdrom0:\SIO2MAN.IRX;1`. **Deci2 drain theory (07-17b, below) is SUPERSEDED.** The Deci2 Poll/latch is the messenger printing the error, not the disease. IPC-frozen "live read at 0x176508" gate is moot. Deci2.cpp still holds Step-1 diagnostics (case 3/-7 TTY-text log, case 4 handle dump) — no behavioral change; leave or trim later. **FORK (decision needed before code) — how to satisfy the SIO2MAN.IRX load:** 1. **HLE the LOADFILE RPC** (sid 0x80000006) to return a success module id for the CD IRX modules — fastest, but conflicts with [[feedback_no_iop_faking]] (no hand-faked IOP values). 2. **Load SIO2MAN.IRX into the embedded IOP interpreter** on demand (the philosophically-correct path per [[reference_iop_interpreter]] + [[feedback_no_iop_faking]]); needs the IRX bytes reachable (ISO mount or bundled file) and import-timing care [[reference_irx_import_resolution_timing]]. 3. Verify FIRST which EE function issues the load and how it forms -65540 (disasm the sceSifLoadModule caller) before choosing 1 vs 2. --- ### Prior Status (2026-07-17b, SUPERSEDED by 07-17c above) — CLEAN PCSX2 A/B; Deci2 Poll descriptor-drain theory. **Clean PCSX2 A/B obtained** (SDBZ booted on real PCSX2, cycles=630M, paused at fn_1763F8). Full disasm + live-memory captured. Ground truth in memory [[reference_ps2_sif_boot]] §2026-07-17b. **The blocker (real model):** boot hangs in the CALLER's descriptor-drain loop at `0x176508`: ``` loop: lw v0,0xC(s0) ; s0=socket handle (0x460000 on real HW) beqz -> exit jal sceDeci2Poll(a0=4) ; fn_176020 -> fn_1750D0 -> syscall 0x7C lw v0,0xC(s0); bnez -> loop ``` Game polls `sceDeci2Poll` until socket descriptor count `0xC(handle)` drains to 0. On real HW each Poll advances DMA + decrements it. **Our Poll never does → spins forever.** **Two words were being conflated (now separated):** - `0x5612DC` (0xC of state-block s3=0x5612D0) = send-busy latch. fn_1763F8 (sceDeci2Send builder) RAISES it (`sw v1=1,0xC(s3)`), never clears it; cleared by poll/receive path. My Deci2.cpp case-4 clear of this word is **semantically CORRECT** — the 07-17a "clear-fix is WRONG / re-entrancy guard" claim is **RETRACTED** (it rode on a mis-sampled s3=0x4B72A7, caught before the `addiu s3,s5,0x12D0`). - `0x46000C` (0xC of socket handle s0) = descriptor count = the word the hang loop ACTUALLY reads. Case-4 never touches it → incomplete. **Helper identities:** fn_175FF0=sceDeci2Send(a0=3), fn_176020=sceDeci2Poll(a0=4), both via fn_1750D0=syscall 0x7C. D_STAT/0x175170 is NOT involved — dropped as a suspect. **Fix target (Deci2.cpp case 4 / sceDeci2Poll):** keep clearing latch 0x5612DC AND decrement/zero socket descriptor count at 0xC(handle) so the 0x176508 loop exits. Behavioral only — no hand-faked constant. **Next: START HERE (NO CODE until step 1 passes):** 1. **Recomp-side live read** — launch recomp, let it reach the spin, read recomp's live `s0` at the 0x176508 loop, confirm which handle word it reads (real HW=0x46000C; recomp may differ). Gate. 2. Only then edit Deci2.cpp case 4 as above. Give user the build command (`.\build.ps1`), don't build. 3. Run exe → parse run_log.txt for next blocker. - Optional trace shortcut (user offered IDA/PCSX2): break at 0x176508, dump socket-handle struct (0x00–0x20) BEFORE and AFTER one Poll on real HW — the diff at 0xC = exact spec for the fix. --- ## Prior Status (2026-07-16k, STALE — superseded by 07-17b A/B above) — 0x79 FIX VERIFIED. Boot advanced (2nd full RPC round now runs). New/real blocker = idle scheduler churn at `0x100008` (`ra=0x0`); threads loop through **string-lib / path-parse** code (strchr @0x18e0d8, char-fetch @0x18e0a0) — game appears to retry a file/module load that never succeeds. 0x6b is COSMETIC (result discarded), not the blocker. **Verify run (2026-07-16k, build w/ 0x79 fix — note: linked with `/FORCE`, LNK4088 "image may not run" warning, but ran fine):** - ✅ **`v1=0x79` TODO warnings GONE.** 0x79/0x7E alias fix confirmed working. - ✅ **Boot ADVANCED past the fix:** a SECOND full SIF-RPC bind/call round now runs (sid 13–19) that did NOT happen pre-fix. 0x79 was a real gate. - **`v1=0x6b` @ pc=0x174f74 = COSMETIC.** Disasm proof: 0x174f74 is `syscall_stub_z_16` (bare `syscall` thunk); caller = `sub_17E200(char*, int)` @ ra=0x17e238. That fn builds a 104-byte SIF cmd block (`byte_564D80`, size=104=0x68, id=`0x80000003`), cache-writes-back, sends via SIF. The 0x6b return value is **discarded** — the fn's `if(!v7) return 0` gates on `syscall_stub_z_21`, a DIFFERENT syscall, not 0x6b. Log confirms: `pkt[0x68 0x0 0x80000003 ...]` fires right after, boot continues. **Do NOT route 0x6b — leave as harmless TODO.** - **REAL blocker:** after both RPC rounds, EE goes idle: `watchdog pc=0x100008 ra=0x0`, stuckSecs climbs indefinitely. Trace rotates through STRING-LIBRARY fns (verified via decompile): `sub_18e0a0`=1-char fetch, `0x18e0d8`=strchr/memchr (SIMD `pcpyh`/`pxor` byte-scan), plus `sub_195f38`/`sub_18cf68`. Game is busy-looping in **path/string parsing** — consistent with retrying a file or `rom0:`/mc load that returns empty/error every pass. - `[Deci2Call:poll]` fires a handful of times early then STOPS (not the terminal spinner). Its `+C` clear is ineffective (`+C` stays 0x1) but that loop is no longer where the game is stuck; `+4` (0x3d→0x43) is a self-incremented counter, not a completion gate. **Next: START HERE (2026-07-16k):** 1. Identify WHAT file/module the string-parse retry loop is trying to load. Candidates: the `sub_17E200` "rom0:"/SIF-cmd string (`0x306d6f72 0x4e44553a` = "rom0"/":SUD"→ likely "rom0:SDUN..." or a boot module path), or a memory-card path. Grep the ELF decompile for callers of `sub_17E200` and the string const at its `a1`. 2. Check whether an SIF-RPC *reply/callback* the parse loop waits on is never delivered — the 2nd RPC round (sid 13–19) ends with `[Sema:Wait] sid=19` and no matching signal; a missing reply could leave the loader spinning. 3. Trace the idle-loop entry: find who calls `sub_195f38`/`sub_18cf68` and what condition breaks the loop. 4. Log is UTF-16 — grep/sed see spaced chars. --- ## Prior status (2026-07-16j) — 0x79 fix APPLIED (now verified above) RE-BASELINED on FRESH log. `sceSifCheckInit -1` loop is GONE. Blocker was = boot thread churns at `0x100008` because EE syscall **0x79 (GetOsdConfigParam alias)** fell through to TODO→0. FIX APPLIED (dispatcher aliases 0x79→GetOsdConfigParam, 0x7E→SetOsdConfigParam @ Dispatcher.cpp:186-195). **Fresh run (2026-07-16j, current build, `run_log.txt` now UTF-16, Jul-16 23:38):** - **No `[sifman]`/`[thevent]`/`[IRX-stub]`/`sceSifCheckInit` anywhere.** The entire 07-16h blocker is confirmed dead — it was the stale July-8 build. Stale-premise finding (below, 07-16i) validated. - **SIF-RPC now fully works via HLE:** bind (`cid=0x80000009`) + call (`cid=0x8000000a`) ping-pong completes, `[SifRpcReply] deliver → run dispatcher 0x178068`, semaphores signal/wake. Two full RPC init cycles run (sid 4–6, then 13–19). - **New blocker:** after RPC init, EE settles into a scheduler churn — `watchdog pc=0x100008 ra=0x0`, `stuckSecs` climbs 0→7+, trace cycles the `0x17xxxx`–`0x19xxxx` kernel/thread range without forward progress. `[Deci2Call:poll]` spins with `+4=0x43`. - **Root cause found:** log shows `[Syscall TODO] ... v1=0x79` (repeated) and `v1=0x6b` (once). `handleSyscall` switches on `$v1`. Dispatcher.cpp had cases for **0x4A/0x4B** (Set/GetOsdConfigParam) and 0x6E/0x6F (…Param2) but **NOT the 0x79/0x7E aliases** the SDBZ crt0 actually calls. 0x79 (GetOsdConfigParam) fell to `TODO`→returned 0 and left the OSD-config buffer unfilled; boot loop that branches on language/video-mode config never satisfied → stalls at 0x100008. - **FIX (applied, NOT yet build-verified):** `Dispatcher.cpp:186-195` — added `case 0x79` → `GetOsdConfigParam` and `case 0x7E` → `SetOsdConfigParam` (route aliases to existing handlers; no new logic, no faked values). `src/lib` file, legal. - **Held back:** `0x6b` (called ONCE @ pc=0x174f74) — not in db-syscalls.md, identity ambiguous, blind-routing risky. Only add if 0x79 fix proves insufficient. **Next: START HERE:** 1. User runs `.\build.ps1` then `.\launch_recomp.ps1`. 2. Confirm `[Syscall TODO] ... v1=0x79` warnings are GONE and whether PC moves off `0x100008` / `stuckSecs` stops climbing. 3. If still stalled → investigate `0x6b` @ pc=0x174f74 (disasm that fn to ID what it expects) and the `[Deci2Call:poll] +4=0x43` spin. 4. Note log is now UTF-16 — read with tools that handle it (grep/sed see spaced chars). --- ## Prior finding (2026-07-16i) — stale-premise alert (VALIDATED by 07-16j fresh run above) **Verified this session (2026-07-16i) — do not skip:** - The `[sifman]`, `[thevent]`, `[IRX-stub]`, `[IopRuntime]` log tags that ALL of 07-16h is based on appear in **ZERO `.cpp`/`.h` in the repo or the pr137/pr-fix worktrees** — only inside log files. Grep-confirmed. - `run_log.txt` (root) is dated **Jul 8** — its producing binary was built from a source generation containing a full **IRX-loader + import-stub interpreted-IOP** (`[IRX-stub] lib='sifman' funcIdx=7/8 ... patched→tramp`, `[sifman] sceSifCheckInit -> -1`, `[thevent] SetEventFlag`). That source is **GONE** from the current tree. - The ONLY interpreted-IOP code present now is `ps2xRuntime/src/lib/ps2_iop_cpu.cpp` (`IopCpu`), which is **untracked (`??`)** and instantiated in exactly ONE place — `ps2_runtime.cpp:2277` — inside a `PS2_IOP_CPU_SELFTEST` env-gated self-test (sum(1..10), discarded). **No IRX loaded, no sifman/thevent stubs registered, `setImportHook` never called.** - Current IOP RPC handling = C++ HLE dispatcher in `ps2_iop.cpp::handleRPC` (dbcman/libsd/cl/sdrdrv/mcman/cdvd by SID), NOT interpreted IRX. - **Net:** diagnosing "why interpreted sifman CheckInit returns -1" = chasing code that isn't compiled anymore. The whole 07-16h front line is against a build that no longer exists in this tree. **Decision (user, 2026-07-16i):** re-run the CURRENT build first, capture a fresh run_log, re-baseline the blocker to what the current HLE-IOP tree actually does. User is compacting before continuing ([[feedback_compact_after_plan]]). **Next session — START HERE:** 1. User runs `.\build.ps1` then `.\launch_recomp.ps1` on the CURRENT tree. 2. Read the FRESH `run_log.txt` (NOT the Jul-8 one). Confirm whether `[sifman]`/`[IRX-stub]` tags even appear anymore — if they don't, the interpreted-sifman blocker is fully moot. 3. Identify the REAL current boot blocker from the fresh log + watchdog trace, then re-baseline this doc. 4. Open question to resolve if it matters: where did the IRX-loader/sifman-stub interpreted-IOP source go (reverted? stashed? uncommitted-then-lost between Jul 8 and now)? `git reflog`/`git stash list`/backup search. Only pursue if the HLE path turns out insufficient and the interpreted path needs restoring. --- ## Current Status (2026-07-16h) — [SUPERSEDED by 07-16i above — premise is stale, see the alert] BIG JUMP: CdInit CLEARED, GS RENDERING, RPC ring converged. New blocker = `sceSifCheckInit` returns -1 forever (IOP-side sifman.irx) Fresh live run via `.\launch_recomp.ps1` (now tees stdout+stderr to `run_log.txt` automatically — Tee-Object). The boot advanced **far past** every prior-session blocker. The whole 07-16d..g SIF-RPC-bind + CdInit-recv analysis below is now HISTORICAL. **What completed this run (run_log.txt, in order):** - **CdInit CALL sid=0x80000592 rpcno=0 COMPLETED** (L779–780, `done result=0x0`); 0x80000593 rpcno=34 done (L786). The 07-16g "empty recv starve" blocker is GONE. **Option B (recv-fill) is MOOT — do NOT implement it.** - ARKD RPC services 0x500–0x503 bound + serviced (rpcno 1/2/17/18/258 via `[IopRuntime] ARKD handled RPC`); memory-card RPC bound (sid 0x80000100, `[McBindPatch]`); threads 20/21 spawned; semas created. - **GS IS RENDERING** — `[gif:submit]`/`[gif:drain]` path3, `[gs:FRAME]`, `[gs:AD]` draw regs flowing. First real draw activity ever observed. **NEW terminal blocker — a 5-line loop repeating forever (run_log tail, from L1233):** ``` [sifman] sceSifCheckInit -> -1 (clear poll flag) [sifman] iSifSetDma list=0x000A8050 count=1 [sifman] DMA iop=0x000A8160 -> ee=0x00502E80 / 0x00502F00 (alternating) size=128 [thevent] SetEventFlag id=3 bits=0x00000001 -> pattern=0x00000101 [thevent] SetEventFlag id=3 bits=0x00000100 -> pattern=0x00000101 ``` - **All 86 `sceSifCheckInit` returns are `-1`, never success.** SIF reports "not initialized" forever; the EE guest's CheckInit poll never clears → hang. - The `[sifman]`+`iSifSetDma`+`sceSifCheckInit` strings are **NOT in our C++ stubs** (ours are `[sceSifSetDma:...]`/`[SifCallRpc]`). They are printed by the **interpreted IOP sifman.irx running in the R3000 interpreter** — genuine IOP code, consistent with the no-faking rule. So the -1 originates IOP-side; the IOP<->EE SIF init handshake never converges on the IOP side. **Next session — START HERE:** find, in the interpreted sifman path, where `sceSifCheckInit`'s return / the SIF-init flag it reads is computed, and WHY it never flips to "initialized." Likely a missing EE→IOP SIF register/DMA-completion signal the interpreted sifman waits on. Fix must stay behavioral (`game_overrides.cpp` / `src/lib/*.cpp`) or be an interpreter-side correction — NOT a hand-faked return value ([[feedback_no_iop_faking]]). Ground truth in memory [[reference_ps2_sif_boot]] 2026-07-16h. Rebuild incremental: `.\build.ps1` then `.\launch_recomp.ps1`. ## Current Status (2026-07-16d) — RAN the built exe: VBLANK/magenta re-baseline was WRONG; game never reaches 0x175210. Real live blocker (re-confirmed) = SIF-RPC bind ping-pong that never converges; v3 fix RAN and STILL storms First live run of the linked exe (`.\launch_recomp.ps1 -NoDebugger`). Upstream check done: only new item is #170 (IOP refactor to ps2xIOP/) — HOLD; all other recent upstream PRs already in tree. All 5 INTC_STAT VBLANK edits confirmed present in the linked sources before running. **Result — the 07-16b/c magenta=VBLANK-poll re-baseline is REFUTED by our port's own trace:** - PC pinned `0x100008`, `lastCall=0x174cb0` (DeleteSema), stuck permanently. Game **never reaches `0x175210`** — the INTC_STAT VBLANK poll is downstream code it never gets to. The 5-edit VBLANK fix is valid code but unvalidatable from here; **PARKED**, not the front line. ([[reference_intc_stat_vblank_gap]] updated.) - **Actual blocker = the SIF-RPC bind ping-pong from 07-15**, and the v3 `deliverSifRpcReply` fix (WORD[8]-forcing) RAN LIVE and did **NOT** converge. Guest sends BIND `[0x40,0,0x80000009,0,0x5,0x20561900,SEQ,0x564980]` + END `[...0x80000008...]` every iteration; SEQ (word[6]) climbs `0x2..0x21+` forever, fresh sema each time (sid 4→34+). `_request_bind`(0x178938) runs each iteration but the bind never sticks — guest re-binds with next seq. - **Log-cap trap noted:** `[SifRpcReply] deliver` lines going silent is just the `s_replyLogs < 24` cap (SIF.cpp:251), NOT the depth cap (SIF.cpp:193) and NOT delivery stopping. Don't misread it. **Next session — START HERE (diagnosis first, NO blind v4):** v1/v2/v3 have all failed blind. Before any code: dump the FULL 16-word (64-byte) BIND packet + the reply packet + the client-side "server registered" flag the bind loop polls (the 8-word log dump hides words 8..15, where WORD[8] router + IOP-return fields live). Determine WHY the client re-binds after `_request_bind` runs — missing registered-flag field in the reply (word[9..15]), an undelivered `_request_rdata`(0x8000000C)/`_request_call`(0x8000000A) path, or a legit per-frame heartbeat masking a different block. Fix in `src/lib/*.cpp` only, no faked IOP data ([[feedback_no_iop_faking]]). Full ground truth in memory [[reference_ps2_sif_boot]] 2026-07-16d. Rebuild = incremental (SIF.cpp in ps2_runtime lib): `.\build.ps1` then `.\launch_recomp.ps1 -NoDebugger`. ## Current Status (2026-07-16c) — INTC_STAT VBLANK boot-blocker FIX IMPLEMENTED + compiles clean (ps2_runtime.lib built); full ps2EntryRunner link + run verification PENDING (user was mid-build at session close) The magenta-screen stall (2026-07-16b, below) is now root-caused and fixed in `src/lib` only. Game spins forever at `0x175210` polling INTC_STAT `0x1000F000` bit2 (VBLANK-start); runtime never raised that MMIO bit on the vsync tick → infinite spin = magenta screen. Dual-confirmed vs real PCSX2. Full root-cause detail in memory [[reference_intc_stat_vblank_gap]]. **Fix (5 edits, all `src/lib`/one header — NO recompiler run, NO fn_*/runner edits):** - `ps2_memory.cpp` writeIORegister: add W1C case for `0x1000F000` (game's `sw 4` ack clears bit2, matching D_STAT 0x1000E010 W1C precedent). - `ps2_memory.cpp` initialize(): pre-seed `m_ioRegisters[0x1000F000u]=0` after clear() so the vsync worker's OR only ever assigns to an existing node (avoids unordered_map rehash-vs-find race; residual torn-word race is benign). - `ps2_memory.cpp`: new `orIORegister(addr,bits)` definition (`m_ioRegisters[address] |= bits;`). - `ps2_memory.h`: declare `void orIORegister(uint32_t,uint32_t);`. - `Interrupt.cpp` interruptWorkerMain per-tick: `orIORegister(0x1000F000u, 1u<<2)` before kIntcVblankStart dispatch, `1u<<3` before kIntcVblankEnd. **Build scope (learned):** header edit fans `ps2_memory.h` into 17 TUs + the whole unity `fn_*` corpus (`unity_NNNN_cxx.cxx`) → wide recompile, but still NOT a recompiler run (output/ sync = 0 files, fwd decls unchanged). Long compile, bounded. **Status at session close:** `ps2_runtime.lib` built with NO errors (ps2_memory.cpp + Interrupt.cpp compiled clean, 9/12 targets); final `ps2EntryRunner` unity link was still in progress when the session ended. NOT yet run. **Next session — START HERE:** confirm the build linked `ps2EntryRunner.exe` (watch for any `error [A-Z]` / build_errors.txt). If clean, run `.\launch_recomp.ps1 -NoDebugger`, capture the PC watchdog trace, and confirm the `0x175210` VBLANK-poll loop now EXITS each frame (magenta replaced by a rendered frame). Then read the next boot blocker from the new trace. If verified, mark [[reference_intc_stat_vblank_gap]] as fixed. Do NOT resume the RecompDebugger register-parse plan (`snazzy-crunching-fairy.md`) — parked, not the front line. ## Current Status (2026-07-16b) — CORRECTION: 0x175220 is the interactive mem-card prompt wait on PCSX2, NOT a deadlock; our port dies far EARLIER (magenta screen, never reaches PCSX2's black screen) Free-run diagnostic on **real PCSX2** (real ISO + scph39001, DebugServer) settled: EE PC pinned at `0x00175220` across ~876M+ cycles of *real* execution, `0x1000F000`=0 and all SIF mailboxes 0. Earlier sessions read this as a lost-wakeup / SIF-DMA deadlock. **That framing is WRONG.** - **User ground truth:** `0x175220` is the **memory-card check** — an *interactive, input-gated* prompt. Real PCSX2 shows **black screen → mem-card check (press X) → game**. The SIF spin is the prompt's normal poll loop waiting on card status + the user's button press. Not a hang. (Consistent with the 2026-07-07 note at line ~342.) - **Our port's actual behavior:** **only a magenta screen — no black screen, no prompt.** The magenta screen is a boot stage *earlier* than PCSX2's black screen. **Our port never reaches 0x175220 at all** — it dies before the black-screen stage that precedes the mem-card check. - **Implication:** the entire 07-15 SIF-RPC bind-reply investigation (0x174ce0 WaitSema, `deliverSifRpcReply` v1/v2/v3) was chasing a blocker *downstream* of where our port is actually stuck. The real first failure is whatever keeps our port on the magenta screen and prevents the black screen from ever appearing. **Re-baseline the boot investigation to the magenta→black-screen transition, not the SIF-RPC bind.** - User skipped the mem-card check + paused the continue-race on PCSX2 to establish the above. **Next session — START HERE:** determine what our port renders/executes at the magenta-screen stall (what is the EE actually doing in *our runtime* — is it looping, faulting, or stalled on an IOP/GS init that PCSX2 completes before its black screen). Do NOT resume SIF-RPC bind-reply work until the magenta-stage blocker is characterized. The SIF-RPC status entries below (07-15 / 07-15e) are preserved for reference but are NOT the current front line. ## Current Status (2026-07-16) — RecompDebugger: PCSX2 breakpoints armed, unified BP panel, real-time disasm under DebugServer; build+verify pending RecompDebugger-only work (plan `snazzy-crunching-fairy.md`, all 4 parts implemented). No runtime/boot-logic changes. Purpose: make RecompDebugger a reliable capture tool for the real IOP SIF-RPC bind-ack packet (the 07-15 SIF blocker below). - **Part 1 — PCSX2 gutter breakpoints now arm the server.** `tab_codetrace.cpp` disasm-gutter "Set/Remove Breakpoint" mutated only local `g_breakpoints`; now also calls `PCSX2SetBreakpoint`/`PCSX2RemoveBreakpoint` when `g_cpu_source==CPU_PCSX2 && PCSX2DebugServerConnected()`. Gutter BPs actually HALT PCSX2 server-side now (matched the Markers-panel path). - **Part 2 — one unified "Breakpoints" panel.** `tab_breakpoints.cpp`: the two CPU-gated blocks merged into a single `CollapsingHeader("Breakpoints")` branching on `g_cpu_source` — Recomp shared-mem slots (with GPR conditions + Resume/HIT banner) in Recomp mode, PCSX2 server BPs in PCSX2 mode. Presentation merge only; both backends' add/remove/clear + server mirroring preserved. - **Part 3 (main fix) — real-time disasm under DebugServer.** `main_gui.cpp` `SyncFromPCSX2` DebugServer branch early-returned BEFORE `ee_ram_base` discovery, so `ee_ram_window` (RPM-filled disasm bytes) never loaded → disasm only populated on a coincidental socket drop (what user saw as "pause to populate"). Now discovers `ee_ram_base` (`FindEERAM`) + calls `ReadEEWindow` (follow-PC/pinned) before the return. JSON reg path + RPM memory-window path are orthogonal, so disasm streams live while regs stream from JSON — no pause needed. - **Part 4 — flicker.** `tab_codetrace.cpp` `ShowCodeTrace`/`ShowEERegisters` use `static bool s_had_first_snapshot`; keep last-good frame instead of blanking to "Waiting for PCSX2 CPU data..." on transient `regs_valid` dips. Placeholder shows only before the first snapshot. **Files:** `ps2xRuntime/src/tab_codetrace.cpp` (P1,P4), `tab_breakpoints.cpp` (P2), `main_gui.cpp` (P3,P4). Brace balance verified. NO build run yet (user runs all builds). **Build (hand to user):** `.\build.ps1 -Debugger`. Verify vs real PCSX2 + SDBZ ISO + scph39001.bin + DebugServer: (1) disasm streams live while game runs unpaused; (2) gutter Set Breakpoint HALTS PCSX2; (3) single Breakpoints panel correct per CPU mode; (4) no Waiting/Scanning flicker; (5) Recomp-mode disasm+BP no regression. ## Current Status (2026-07-15e) — SIF-RPC bind-reply loopback FIX v3 written (WORD[8] router correction); build+run pending Continues the 0x174ce0 WaitSema blocker below. The game self-hosts its own EE-side libsifrpc: a receive dispatcher `sub_178068` (@0x178068) that polls the pending byte at `0x561600`, and issues raw `sceSifSetDma` BIND/END packets to the IOP, then parks in WaitSema@0x174ce0 awaiting the reply. Our HLE `SifBindRpc` is never called (off this path). **Fix approach (SIF.cpp `deliverSifRpcReply`, anon ns):** on an outbound RPC system-command `sceSifSetDma`, loop the game's OWN 64-byte packet back into RX queue `0x561600` and run its dispatcher inline via `runtime->lookupFunction(0x178068)`. No invented IOP data — client ptr/server fields echoed verbatim. **v1/v2 failed, v3 is the correction:** - v2 echoed verbatim → SignalSema DID fire (trace `0x178560 -> 0x174cd0`) but binds ping-ponged forever (seq `0x2 -> 0x21+`). - ROOT CAUSE (source-level): the dispatcher routes on packet **WORD[8]** (offset 0x20) `& 0x7FFFFFFF` → system handler table `dword_5616E4` slot. Correct slot map (was SWAPPED in old notes): slot **8** `0x80000008` = `sub_178560` **_request_end** (SignalSema+teardown = the WAKE); slot **9** `0x80000009` = `sub_178938` **_request_bind** (registers server, re-sends END). The game's outbound END packet carries WORD[8]=0x80000009 (set by _request_bind:95282), so a verbatim echo re-routes it to _request_bind → re-send → infinite loop. - **v3 fix:** force reply WORD[8] to the completion discriminator the IOP return would carry — BIND reply → `0x80000009` (registration), END reply → `0x80000008` (wake). Fixed libsifrpc protocol constants, not faked IOP data. New consts `kSifDiscWordIdx=8`, `kSifRpcEndDiscriminator`; re-entrancy capped depth 8. See [[reference_ps2_sif_boot]] 2026-07-15e. **Build (hand to user):** `cmake --build "F:\SDBZ Recomp\build" --config Debug --target ps2EntryRunner`. Run: `.\launch_recomp.ps1 -NoDebugger`. SUCCESS = deliver lines STOP after a couple iterations (no seq storm) + pc/lastCall leaves `0x174ce0`. If still storming → client re-binds for another reason; capture log. After boot advances, strip temp `[sceSifSetDma:DTX/OK/FAIL]` + `[SifRpcReply]` cerr diagnostics. ## Current Status (2026-07-15) — sceSifSetDma EE→IOP reject FIXED; boot advanced from DeleteSema spin to WaitSema (0x174ce0) — new blocker: IOP never produces SIF-RPC bind reply Boot is downstream of the 0x100008 dispatch fix (14f). Live spin surfaced via watchdog `lastCall`, not PC. **Fix landed & confirmed working (runtime lib only — `ps2xRuntime/src/lib/Kernel/Stubs/SIF.cpp`):** - **Root cause:** `sceSifSetDma` was range-checking the descriptor's `dest` against EE RAM. For EE→IOP SIF DMA, `dest` is an IOP-side address / sentinel (`0xffffffff`), NOT EE RAM → all transfers rejected → returned 0 → guest tore down semaphores (DeleteSema 0x174cb0) and retried the SIF-RPC bind forever (11-PC spin). - **Fix (2 edits, ~SIF.cpp:708 + :729):** validate/copy only the EE-side `src`. If `dest` is copyable EE RAM → EE→EE loopback copy; else treat as IOP-bound → skip EE-side copy but still accept + return nonzero id. - **Confirmed:** output flipped `[sceSifSetDma:FAIL] why=rangeUncopyable` → `[sceSifSetDma:OK] -> nonzero id` (×2); DeleteSema spin (0x174cb0) gone. - Diagnostic descriptor that cracked it: `src=0x20561900` (valid EE uncached mirror), `dest=0xffffffff`, `size=0x40`, `attr=0x44`, `ra=0x177fc4`. **NEW blocker (identified, NOT fixed):** - Guest now blocks in **WaitSema (syscall 0x44) at `0x174ce0`** (bare trampoline; Dispatcher.cpp:172; WaitSema is a proper blocking Mesa-monitor wait, Sync.cpp:283 — not a busy-spin). - Guest sends the SIF-RPC **bind request** via its OWN recompiled raw SIF-DMA path (`0x177fe8` → `func_177EB0`), NOT our `SifBindRpc` HLE (RPC.cpp:1578 — off this path). Then WaitSemas for the IOP's **bind acknowledgment**, which never arrives → re-loops the whole bind. - Anchor fns mapped: `0x174ce0`=WaitSema trampoline; `0x174cb0`=DeleteSema trampoline; `0x177de8`=RPC request-queue ENQUEUE helper (table base `0x5616d8`, NOT a wait loop, 4× repeat is normal); `0x177fe8`=sceSifBindRpc-family wrapper→jal func_177EB0. **Next session — START HERE (diagnosis, no code yet):** trace whether the accepted EE→IOP SIF-DMA packet actually reaches the embedded R3000 IOP interpreter for servicing (which would generate the reply that durably signals WaitSema@0x174ce0), or lands in a void. Start at `noteDtxSifDmaTransfer` (SIF.cpp) → `ps2_iop.cpp` delivery path. Candidate files from last grep: SIF.cpp, RPC.cpp, ps2_memory.cpp, ps2_iop.cpp, ps2_iop_mcman.cpp, RPC.h, SIF.h, Support.h. Fix must land in `src/lib/*.cpp` only and must NOT hand-write/fake IOP output (run real IRX in interpreter — [[feedback_no_iop_faking]]). After boot advances, remove temp SIF.cpp `[GUARD]`/`[FAIL]`/`[OK]` diagnostic logs. - Rebuild command (hand to user): `cmake --build "f:\SDBZ Recomp\build" --target ps2EntryRunner` (SIF.cpp is in the `ps2_runtime` static lib → incremental, minutes — NOT the 30h runner rebuild). Re-run: `.\run_watchdog.ps1`. Boot success = EE `0x5e6b3c` GameMode → `0x00` = MainMenu. ## Current Status (2026-07-14g) — RecompDebugger.exe file-browse dialog focus bug fixed (2nd attempt); NOT YET rebuilt/verified Continuation of the RecompDebugger revival (plan `sharded-zooming-avalanche.md`, target restored + build-verified in a prior session). User reported two UI bugs against the running exe: - **Config fields empty / Backend=PCSX2:** `symbols.map`, `ps2EntryRunner.exe` etc. already exist on disk (`ps2xRuntime/symbols.map`, `build/ps2xRuntime/Debug/ps2EntryRunner.exe`) — user just needed to fill Settings→Paths fields manually and switch Backend to Recomp. Not a code bug. - **Browse dialog opens behind main window:** `tab_filedialogs.cpp`'s `ShowFileDialogs()` had zero focus-forcing calls; `ImGuiFileDialog::Display()` only calls `Begin()` for the currently-open key. - **1st fix (WRONG, caused regression):** added unconditional `ImGui::SetNextWindowFocus()` before all 6 `Display()` calls. Broke both dialog visibility AND paste-into-path-fields — `SetNextWindowFocus()` sets a *global* pending flag consumed by the *next* `Begin()` of ANY window that frame, not scoped to the dialog; when no dialog was open it stole focus from the next window/widget (e.g. an `InputText`), breaking paste. - **2nd fix (applied, awaiting verify):** added `FocusIfOpen(const char* key)` helper gated on `ImGuiFileDialog::Instance()->IsOpened(key)`, only calling `SetNextWindowFocus()` in the exact frame the matching dialog key is actually open. Replaces the 6 unconditional calls in `ps2xRuntime/src/tab_filedialogs.cpp`. **Next session / user action — START HERE:** run `.\build.ps1 -Debugger`, relaunch `RecompDebugger.exe`, confirm (a) Browse dialog appears on top/visible, (b) paste into path fields still works (no regression). Then continue the original plan: confirm connect to `Local\RecompDebugState` v10 with Backend=Recomp, launch `ps2EntryRunner.exe` on SLUS_214.42, confirm PC advances live with resolved function name — this is the still-unmet verification criterion for the whole debugger-revival effort, separate from the dispatch-table fix below. ## Current Status (2026-07-14f) — dispatch-table fix APPLIED via format conversion; awaiting user build + boot test. Corrects errors in the 2026-07-14e diagnosis below Proofing pass over the 2026-07-14e diagnosis found it partly wrong; a much cheaper fix was applied: - **Correction 1:** `PS2Runtime::registerFunction` is NOT dead code — it exists (`ps2_runtime.cpp:984`) and forwards to `replaceFunction` (`:968`), which writes directly into `g_ps2RecompiledFunctionTable[slot]`. Game overrides installed via it at runtime still win over static init. - **Correction 2:** No recompiler re-run needed. `output/register_functions.cpp` (legacy format, June 14, 375,485 `runtime.registerFunction(addr, name)` entries) matches the current runner generation — every referenced symbol exists verbatim as a `runner\*.cpp` file. - **New finding — THREE coexisting generations in `runner\`** (~2 files per guest address, 33,956 files / 16,990 unique addresses): `sub_*` (May 17), IDA-labeled `entry_*`/`GameMain_*`/`CApp*` (May 28), and `fn_*`/`start_*` (**June 14 — newest**, same recompiler run as the legacy register file, identical timestamps). The June-14 set is the internally consistent one to dispatch. All generations compile into the exe; only the table decides which executes. - **Trap avoided:** pr137-test's populated table (41.5 MB, July 12) is INCOMPATIBLE with main (`sub_00XXXXXX` naming, 26,934-file generation) — never copy it. - **Header gap:** `fn_forward_decls.h`/`ps2_recompiled_functions.h` only declare `fn_*`-pattern names, so the generated table file carries its own forward declarations for all 16,970 referenced symbols. **Fix applied:** `convert_register_functions.ps1` (scratchpad, one-shot) parsed the legacy file and regenerated `output\register_functions.cpp` (31.4 MB) in the dense-table `GeneratedFunctionTableInitializer` format: base `0x100008`, end `0x4e6c84`, 1,022,751 slots, 375,485 entries in 8 chunked init structs, 0 unmapped. Original legacy file preserved at `output\register_functions.cpp.legacy-bak`. Fresh timestamp means `build.ps1`'s newer-wins sync WILL overwrite the runner-side stub on next build (no hand-edit of `runner\`). Spot-checks pass: slot 0 = `start_0x100008`, `0x422630` = `fn_422630_0x422630`, tail alias `0x4e6c80` = `fn_4E68C0_0x4e68c0`. **Next:** user runs `.\build.ps1` then `& "F:\SDBZ Recomp\build\ps2xRuntime\Debug\ps2EntryRunner.exe" "F:\SDBZ Recomp\ELF\SLUS_214.42"`. Expect the 0x100008 loop gone; new "No exact recompiled function" errors at OTHER PCs would be genuine coverage gaps (e.g. the 3 known stub addresses 0x151830/0x170268/0x11aba0, absent from the legacy file too) — separate triage, not a regression. ## Current Status (2026-07-14e, SUPERSEDED by 14f — contains errors, see corrections above) — dense function-dispatch table (`g_ps2RecompiledFunctionTable`) is never populated — boot hangs immediately at PC 0x100008, unrelated to the C3861/regex fix Boot-tested `ps2EntryRunner.exe` after the 2026-07-14d build success. Immediately hangs: `dispatchLoop` fails to resolve guest PC `0x100008` on the very first lookup (`Error: No exact recompiled function for guest PC 0x100008 tableBase=0x0 tableEnd=0x1000000`), then spins forever re-trying the same failing lookup (the `missingFunction` stub doesn't advance `ctx->pc`). **Root cause, traced fully:** - `ps2xRuntime/src/lib/ps2_runtime.cpp`'s `dispatchLoop`/`lookupFunction` dispatch exclusively via a dense array `g_ps2RecompiledFunctionTable[]` (declared in `ps2xRuntime/src/runner/register_functions.cpp`), indexed by `(address - tableBase) >> 2`. - The live `ps2xRuntime/src/runner/register_functions.cpp` is only 6 lines — declares the table (`tableBase=0x0`, all slots null) but never populates a single entry. No self-registration macro or bulk-populate call exists anywhere in the codebase (confirmed via full-repo grep). - The recompiler (`ps2xRecomp/src/lib/function_table_emitter.cpp`, class `FunctionTableEmitter`) is fully capable of emitting the correct file — a `GeneratedFunctionTableInitializer` static-init struct that fills every slot from the function list, with `tableBase` derived from the real address range. This is clearly the intended mechanism. - `output/register_functions.cpp` (the recompiler's actual output directory) DOES exist but is dated **2026-06-14** and is in an **old, incompatible format**: a `registerAllFunctions(PS2Runtime&)` function calling `runtime.registerFunction(addr, fn)` per entry (375K lines) — this populates a different/legacy registration path, not `g_ps2RecompiledFunctionTable`, and `registerAllFunctions` isn't called anywhere in current `ps2_runtime.cpp`. It's dead code from a prior architecture generation. - `build.ps1`'s sync step (`Syncing output\ -> src\runner\`) only copies a file if the `output\` source is *newer* than the destination. Since someone hand-replaced `ps2xRuntime/src/runner/register_functions.cpp` with the 6-line stub on **2026-07-11** (newer than the June 14 `output/` copy, and the file is untracked/`??` in git), the sync step correctly-but-unhelpfully skips it every time, leaving the broken stub in place. - **Net effect:** the dense function table architecture (current `lookupFunction`) and the actual generated output on disk (`output/register_functions.cpp`, legacy `registerAllFunctions` format) are from two different, incompatible generations of the recompiler pipeline. Neither the old output nor the current stub can populate the table `lookupFunction` needs. **What's needed to fix (not yet done — scope/risk requires user awareness before proceeding):** 1. `ps2_recomp.exe` is not currently built (`build/ps2xRecomp/Debug/` only has the lib, `.dir`, `.vcxproj` — no exe). Needs a real build of the `ps2_recomp` target. 2. `config.toml` does not exist at `F:\SDBZ Recomp\config.toml` (the path this file itself documents) — needs locating/regenerating the real recompiler config for SLUS_214.42. 3. Re-running `ps2_recomp.exe ` would regenerate ALL `output/*.cpp` (30,000+ files) in the CURRENT `FunctionTableEmitter` format, including a correct `register_functions.cpp`. 4. `build.ps1`'s sync step should then correctly pick up the newer `output/register_functions.cpp` and copy it over the stale stub (or the stub should just be deleted first so sync always wins). 5. Full recompile after that will be a large rebuild (many/most `.cpp` under `runner/` may be regenerated) — per ps2-recomp skill guardrails, this is NOT a quick incremental rebuild; get explicit user go-ahead on timing before starting. **Next session / user action — START HERE:** decide whether to (a) locate/regenerate `config.toml` and build `ps2_recomp.exe` to properly regenerate `output/`, accepting a large rebuild, or (b) hand-write a stopgap `register_functions.cpp` (small, targeted — e.g. only registering the handful of functions needed to get past the current boot blocker) as a bridge, matching the `GeneratedFunctionTableInitializer` format `lookupFunction` expects. Do NOT resume "fix stub function C3861 errors" work — that thread (regex bug in build.ps1) is fully resolved and unrelated to this new blocker. ## Current Status (2026-07-14d) — Build succeeded: build.ps1 regex fix confirmed working end-to-end, ps2EntryRunner.exe linked Re-ran `& "F:\SDBZ Recomp\build.ps1"` after the 2026-07-14c regex fix. Build completed successfully (+14:42, 10/12 targets, exe linked): the three C3861 errors (`fn_170268_0x170268`, `fn_151830_0x151830`, `fn_11ABA0_0x11aba0`) are gone. Confirms the build.ps1 line 135 regex fix (`uint8_t\s*\*\s*rdram`) is durable, not just correct in isolation. Link-stage warnings present but non-blocking (exe still produced): `LNK4075` (`/INCREMENTAL` ignored due to `/FORCE`), `LNK4006` x2 (raylib's `rcore.obj` `CloseWindow`/`ShowCursor` colliding with `user32.lib`, second definition ignored), `LNK4088` (image built via `/FORCE`, may not run — needs actual boot-test to confirm). The raylib/user32 symbol collision is the same NOUSER issue tracked in [F1 Debug Panel Build Fix](project_f1_debug_panel_build_fix.md) — that fix may not be applied to this target, or `/FORCE` is masking it. **Next session — START HERE:** boot-test `ps2EntryRunner.exe` via the Active Runner Command above to confirm the `/FORCE`-linked exe actually runs (LNK4088 explicitly warns it may not). If it runs, resume the Phase 5 boot-progress chain (verify PC advances past prior blockers, check `0x5e6b3c == 0x00`). If it crashes/fails to start, investigate the raylib/user32 symbol collision as a likely cause — do NOT touch `runner/*.cpp`, fix belongs in the raylib link config or a `NOUSER`-style define per the referenced memory. ## Current Status (2026-07-14b) — Recompiler boundary-detection gap: 3 missing `fn_*` symbols stubbed in game_overrides.cpp; fn_forward_decls.h confirmed auto-regenerated every build (manual header edits are non-durable) A build attempt (+26:45, failed exit 1) hit three C3861 "identifier not found" errors: `fn_170268_0x170268`, `fn_151830_0x151830` (both previously stubbed in a prior session by editing `fn_forward_decls.h` directly), and a new one, `fn_11ABA0_0x11aba0`. **Root cause:** `ps2xRuntime/include/fn_forward_decls.h` is marked "Auto-generated by build.ps1 - do not edit" and is in fact rewritten on every `build.ps1` run (see `build.ps1` lines 110-209). It scans `runner/fn_*.cpp` files (lines 117-126) AND separately regex-scans `ps2xRuntime/src/lib/game_overrides.cpp` (lines 128-145, pattern `void\s+(fn_[0-9A-Fa-f]+_0x[0-9a-fA-F]+)\s*\(uint8_t\*\s*rdram`) for fold-extracted stub definitions with no matching runner file, auto-declaring those too. The prior session's direct edit to the header was silently wiped by the next build run — that's why the fix "didn't hold." **Correct durable fix (now applied):** define missing `fn_*` symbols directly in `game_overrides.cpp` matching the exact signature `void fn_X(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime)` — build.ps1's regex auto-declares them every run, no header edits ever needed. All 3 stubs (`fn_151830_0x151830`, `fn_170268_0x170268`, `fn_11ABA0_0x11aba0`) are now present as diagnostic log-and-return stubs in `game_overrides.cpp` (lines ~27-46), all matching the pattern. **RESOLVED — actual root cause found (2026-07-14c):** the "open/unexplained" gap above was real and now explained. The build.ps1 regex-scan of `game_overrides.cpp` (line 135) was `'void\s+(fn_[0-9A-Fa-f]+_0x[0-9a-fA-F]+)\s*\(uint8_t\*\s*rdram'` — it required `uint8_t*rdram` with NO space between `uint8_t` and `*`. The actual code style in this repo is `uint8_t *rdram` (space before the `*`, not after). So the regex NEVER matched, for any of the three stubs, on any prior run — the auto-scan mechanism was silently broken from the start, not "working but overwritten." Verified via direct PowerShell regex test against the live file: 0 matches with the old pattern, 3/3 matches after the fix. **Fixed:** `build.ps1` line 135 changed to `'void\s+(fn_[0-9A-Fa-f]+_0x[0-9a-fA-F]+)\s*\(uint8_t\s*\*\s*rdram'` (added `\s*` between `uint8_t` and `\*`). **Next session / user action — START HERE:** run `& "F:\SDBZ Recomp\build.ps1"` again. The header should now report N declarations written (not "unchanged") and include all 3 stub symbols — verify with a quick grep on `fn_forward_decls.h` for `151830|170268|11ABA0` if the build still fails on these. If it fails on a *new* undefined `fn_*` symbol, same durable fix pattern applies — add a matching stub to `game_overrides.cpp`; the regex is now confirmed working. ## Current Status (2026-07-14) — CORRECTION to 2026-07-13c below: PR #168's fix was ALREADY in main (commit `b7d9e17b`, 2026-07-11 21:43, "fix(recomp): advance ctx->pc on fallthrough functions with no terminating branch") — the 2026-07-13c claim "the fix has NOT been applied to this main tree" was never verified against git and was wrong. This is more concerning than it sounds: the 2026-07-13c boot-test ran with the fix already present and STILL hit the `0x100008` infinite loop — meaning the fallthrough-pc fix alone did not resolve this hang, or something since regressed it. Also found: `function_emitter.cpp` currently has a separate uncommitted change on top (mid-asm hook injection — `m_midAsmHooksBeforeByAddress`/`AfterByAddress`, lets generated runner code call user C++ hooks before/after a specific instruction). Unclear if this is related. **Next session — START HERE:** a build is in progress (user-run, 2026-07-14). Once done: (1) boot-test via the Active Runner Command, check whether PC still hangs at `0x100008`; (2) if it still hangs, do NOT re-diagnose PR #168 again — that's confirmed present and confirmed insufficient. Instead check whether `74359c8f` (the actual PR #168 commit content) matches what's in `b7d9e17b`, since PR branch and main-tree fix may differ; (3) evaluate whether the uncommitted mid-asm-hook diff affects this at all (temporarily stash it and retest if needed). **Standing rule: before writing "fix not yet applied"/"not yet done" into this file, verify with `git log`/`git diff` on the actual file — this entry was wrong for a full day because that wasn't done.** ## Current Status (2026-07-13c) — SUPERSEDED, KEY CLAIM WAS WRONG (see 2026-07-14 above) — Boot-test path fixed; main-tree build now confirmed to hit the SAME dispatch-fallthrough dead loop as pr137-test (PR #168 fix not yet applied to main) Resolved the 4a.4 boot-test blocker from the entry below: `F:\SDBZ Recomp\ELF\SLUS_214.42` exists; the real current build output tree is `F:\SDBZ Recomp\build\ps2xRuntime\Debug\ps2EntryRunner.exe` (built 2026-07-13 18:32) — `PS2Recomp\out\build\...` (the old documented path) no longer exists on disk. "Active Runner Command" section above corrected accordingly. Ran the corrected boot command (30s timeout). Result: raylib/GL/audio init all succeed cleanly, then the process hard-loops forever at `pc=0x100008` — log shows `[guest-branch:missing-target] kind=IndirectJump ... source=0x0 target=0x100008 pc=0x100008` followed by ~16,000 repeats of `Error: No exact recompiled function for guest PC 0x100008` in 30s. **This is the identical root cause already diagnosed and fixed in the pr137-test sandbox (see 2026-07-11b below) and submitted as [PR #168](https://github.com/ran-j/PS2Recomp/pull/168)** (`fix/dispatch-fallthrough-pc`, commit `74359c8f`): `FunctionEmitter::emit` doesn't advance `ctx->pc` past a function's last instruction when that instruction isn't a branch/jump, so the ELF-entry-point pseudo-functions (1-2 instruction no-op splits with no terminating branch) spin on their own address forever. **The fix has NOT been applied to this main tree** — it currently only exists in the PR #168 branch. **Next session — START HERE:** apply the same `function_emitter.cpp` fix from PR #168 (or wait for it to merge upstream and re-sync) to `ps2xRecomp/src/lib/function_emitter.cpp` in this main tree, rebuild via `build.ps1`, then re-run the corrected boot command above and confirm PC progresses past `0x100008`. Once boot progresses, resume the original 4a.4/4a.5 verification chain from the entry below (confirm `0x5e6b3c == 0x00`, then IRX loader chain `5d2856fb → c25428d1 → 52205a65 → 1a49fa58 → 68326c3b`). ## Current Status (2026-07-13b) — Stage 4a (pr135 port) through sub-stage 4a.4 verified; boot-test blocked on missing guest ELF at runtime Sub-stages 4a.1–4a.4 of the `pr135` IOP-subsystem port (plan: `C:\Users\mwlab\.claude\plans\the-test-directory-has-mighty-mango.md`) are landed and "trust but verify" reviewed clean, including `ps2_memory.cpp` (IOP RAM/SIF-DMA layer, bounds-checked, self-test covered; SPR-DMA-bypasses-DMAE behavior correctly scoped to channels 8/9 for Freekstyle; EE Timer/COP0 Count generalization is a clean parameterization with no behavior change for T0). Attempted the plan's verification item 6 (boot-test `ps2EntryRunner.exe` after 4a.4). Command run: `.\build\ps2xRuntime\Debug\ps2EntryRunner.exe` (no args) → fatal exception `Unable to determine executable path. Pass the guest ELF as argv[1] or define PS2X_DEFAULT_BOOT_ELF.` Root cause confirmed by reading `ps2xRuntime/src/main.cpp` `getExecutablePath()`: the exe requires the guest ELF path as `argv[1]`; no `PS2X_DEFAULT_BOOT_ELF` macro is defined in this build config. Correct invocation per this file's own "Active Runner Command" section above is `& "F:\SDBZ Recomp\PS2Recomp\out\build\ps2xRuntime\Debug\ps2EntryRunner.exe" "F:\SDBZ Recomp\ELF\SLUS_214.42"` — note the build output path in that command (`PS2Recomp\out\build\...`) differs from the path actually used this session (`.\build\ps2xRuntime\Debug\...`); this discrepancy was not resolved before session end. **Next session — START HERE:** (1) verify which build output path is currently correct (`F:\SDBZ Recomp\ELF\SLUS_214.42` per this doc, vs. the `.\build\...` relative path used this session) and confirm `F:\SDBZ Recomp\ELF\SLUS_214.42` actually exists; (2) hand the user the corrected boot-test command with the ELF path included; (3) once boot confirmed (`0x5e6b3c == 0x00`), close out 4a.4 and proceed to sub-stage 4a.5 (IRX loader/module-execution chain: `5d2856fb` → `c25428d1` → `52205a65` → `1a49fa58` → `68326c3b`, reconcile step 3a/3c against main's existing `ps2_iop_audio.cpp`). Also this session: redefined memory `feedback_update_memory_vs_end_session.md`'s "end session protocol" — the old definition referenced `sync.ps1`, which no longer exists in the repo (build_scripts/helper-script staleness was independently confirmed via `command_log.md` and repo-tree globs this session too). New definition: update memory files + update this project-state doc (`BUG_LOG.md` does not exist in this repo, despite being referenced in some memory files — that reference is itself stale and should be corrected/removed next time it's touched) + write a pass-on note. No zip, no git commit, unless separately requested. ## Current Status (2026-07-11b) — pr137-test "silent crash" root-caused as a non-crashing infinite dispatch loop; fix implemented and PR #168 opened upstream; NOT YET build-verified Live cppvsdbg debugging (breakpoints at `ps2_runtime.cpp:1911` inside `dispatchLoop`, plus `2204`/`2255`/`2285` sanity checks) conclusively re-diagnosed the pr137-test "silent crash near boot-transition" — it is **not a crash at all**. The process stays alive indefinitely (window opens, renders a static magenta frame, no exception/abort/exit); `ctx->pc` was observed stuck at `0x100008` across consecutive `dispatchLoop` iterations. Confirmed create_fiber/guest_executor_main/ps2fiber_resume (`SwitchToFiber`) all run and return normally — not a scheduler bug. Confirmed dispatch table is populated/synced correctly — not the known empty-dispatch-table bug. **Root cause:** `FunctionEmitter::emit` (`ps2xRecomp/src/lib/function_emitter.cpp`) never advances `ctx->pc` past a function's own last instruction unless that instruction is a branch/jump (handled via `handleBranchDelaySlots`). The recompiler's function-boundary pass splits the SLUS_214.42 ELF entry point into multiple 1-2 instruction pseudo-functions (`sub_00100008_0x100008` etc., all `padduw $reg,$zero,$zero` no-ops) with no terminating branch, so `ctx->pc` is left pointing at its own address forever and `dispatchLoop` calls the same function in an infinite loop. **Fix implemented** in main tree's `ps2xRecomp/src/lib/function_emitter.cpp`: track whether the last processed instruction had a delay slot (`inst.hasDelaySlot`, the branch/jump signal); if a function ends without one, emit `ctx->pc = 0xu;` before closing the function so `dispatchLoop` always resumes at the next function instead of spinning. This is a defensive emitter-level fallback, not a fix to the upstream function-boundary/discovery pass itself (which is the more correct place to merge these no-op runs into real functions — flagged as an alternative fix direction, not implemented). **PR opened:** https://github.com/ran-j/PS2Recomp/pull/168 (branch `fix/dispatch-fallthrough-pc`, commit `74359c8f`, via fork `TH3BACKLOG/PS2Recomp` since `TH3BACKLOG/SDBZ-Recomp` isn't a registered GitHub fork of `ran-j/PS2Recomp`). **NOT build-verified locally** — no `build.ps1`/`build_scripts` were present in this session's main-tree checkout to compile+run the fix end-to-end; PR body flags this and requests a maintainer/CI pass. **Next session — START HERE:** (1) once PR #168 gets CI/maintainer feedback, address review comments or re-verify; (2) when a build environment is available, actually rebuild `pr137-test` with the emitter fix (or wait for it to land upstream and re-sync) to confirm the pr137-test boot actually progresses past the entry point now; (3) decide whether to commit the restored `PS2_PROJECT_STATE.md` (was untracked this session after being restored from git history); (4) clean up temporary worktree `F:\SDBZ-Recomp-pr-fix` if no longer needed. ## Current Status (2026-07-10c) — Codeium extension Bad Image fix; pr137-test silent-crash debug infra set up; found undocumented uncommitted changes in this tree - **Unrelated Windows/tooling fix:** VS Code showed a "Bad Image" dialog (`0xc000012f`) for a corrupted Codeium extension native module (`codeium.codeium-1.48.2\dist\...node`). Fixed by `code --uninstall-extension codeium.codeium` then `code --install-extension codeium.codeium` (clean v1.48.2 reinstall). Not yet confirmed resolved by user on next VS Code launch. - **pr137-test sandbox — new silent-crash thread found this session, separate from BUG-024:** `ps2EntryRunner.exe` in `F:\SDBZ-Recomp-pr137-test` exits with zero output/no dialog shortly after ELF load + 2 texture loads. `main.cpp` has no SEH handler, so a native access violation/stack overflow would be swallowed silently by Windows — matches the symptom. Added `F:\SDBZ-Recomp-pr137-test\.vscode\launch.json` ("TEST UB - pr137-test ps2EntryRunner (cppvsdbg)") to catch it live with a real native debugger. Full writeup in `F:\SDBZ-Recomp-pr137-test\CRASH_INVESTIGATION.md`. **Not yet run** — needs the user to open that folder as its own workspace and press F5. - **Found uncommitted, undocumented working-tree changes in this tree (not from this session, no matching handoff/memory):** `code_generator.cpp` + `ps2_runtime_macros.h` add an `FPU_SET_ACC`/`ctx->fpuAcc` fix for COP1 accumulator ops (previously aliased onto `f[31]`, which looks wrong but is unverified/unbuilt). `game_overrides.cpp` has ~20 undocumented `sdbzBisectGap*`/`sdbzBisect18D680*`-family diagnostic stubs bisecting the MemSysInit/fn_421B70 gap, plus the already-documented `[HACK][SifCallRpc-unbound]` shim. See CLAUDE.md Project State for full detail — **needs triage next session**: verify `fpuAcc` field exists, build-test, then commit-with-writeup or revert; check if the bisect stubs already answered their question or are still open. ## Current Status (2026-07-08b) — CONFIRMED LIVE: game is genuinely blocked inside the SIF RPC call, not waiting on memory-card input Live-attached via `recomp` MCP (single confirmed `ps2EntryRunner.exe` instance). Decompiled `mem_fill_z_369` (`0x178be8`) — it IS the real `sceSifCallRpc` implementation: builds the RPC packet struct, then `syscall_stub_z_6()` (SIF send) → `syscall_stub_z_7()`/`syscall_stub_z_10()` (SIF wait/receive). With no breakpoint armed and the game running freely, thread tid 1's PC was observed parked at `0x178be8` across repeated reads several seconds apart, with `r4=0x5a9380` (client handle) and `r6=0x3` (cmd 3) — matches the RPC call args from the 2026-07-07b static trace exactly. Also tested and ruled out the alternate hypothesis that this is actually the memory-card prompt waiting on player input: neither keyboard nor controller input has any observable effect, consistent with the game never reaching input-polling code because it's stuck in this RPC wait first. **Next session — START HERE:** identify the 4 RPC handle globals (`dword_5A9330`/`5A9358`/`5A9380`/`5A93A8`) against the known SIF client-handle pattern (SIF CMD IDs `0x80000008/9/A/C`); trace which IOP-side IRX module (likely `ARKD_DVD.IRX`, now fully labeled) should service client `0x5a9380`/cmd 3 and why its service loop isn't responding. Per "No IOP Faking" — fix must get the real IOP service responding, not stub the reply. ## Current Status (2026-07-07d) — `irx_label.py --auto-pattern` heuristic added, bug found + fixed, validated; no application to real CRI_ADXI data yet; black-screen blocker unchanged No runtime code changes. Added `--auto-pattern` flag to `build_scripts/irx_label.py`'s `dump` subcommand (`classify_patterns()`): pre-fills `proposed_name`/`confidence` for getter/setter pairs and duplicate-body pairs, to cut token cost of hand-labeling mechanical CRI_ADXI functions. Found and fixed a real bug during verification: with no minimum-content guard, trivial/nullsub-style stub bodies collided into one bogus 43-function duplicate cluster; fixed by requiring bodies have ≥3 semicolons and ≥80 chars before duplicate-pair matching. Verified against a disposable test module (`CRI_ADXI_autotest`) — false-positive count went 50→4 rows, remaining 4 rows are two genuine duplicate pairs. Also hit and resolved an idalib "failed to open database" error — root cause was the IDA GUI still holding the `.i64` file open (OS file lock), not corruption. Feature is validated but has **not yet been run against the real CRI_ADXI module** — that's the immediate next action. Details in memory `project_irx_labeling_workflow.md`. **Next session — START HERE:** run `dump --auto-pattern` against the real `CRI_ADXI.IRX.i64` (not `_autotest`) to continue batch labeling with the new heuristic, or resume manual reading of `irx_label_CRI_ADXI.IRX.txt` from ~line 2397 if preferred. Refcount inc/dec pattern (addendum pattern #2) is still unimplemented — deprioritized, not blocking. ## Current Status (2026-07-07c) — ARKD_DVD.IRX labeling complete, workflow documented; 2 syscall stubs labeled; black-screen blocker unchanged No runtime code changes. Completed ARKD_DVD.IRX semantic renames (6 functions: TocLookup, TocDecodeAndIndex, InitLoadBuffers, CdReadRetryLoop, CdReadRetryLoopAlt, RpcReplyRetryLoop) via the new `build_scripts/irx_xref_dump.py` headless idalib batch-trace script; workflow documented in memory (`project_irx_labeling_workflow.md`) for reuse on the 6 remaining IRX modules (SIO2MAN, PADMAN, MCMAN, MCSERV, LIBSD, CRI_ADXI). Also labeled 2 SLUS_214.42 syscall stubs from user-pasted disasm: `syscall_stub_z_7` (0x174CB0) = DeleteSema (syscall 0x41), `syscall_stub_z_8` (0x174CC0) = SignalSema (syscall 0x42) — note `syscall_stub_z_7` is a caller inside `mem_fill_z_369` (+0x1A4/+0x1C4), which is the next black-screen investigation target. **Black-screen blocker is still open and unchanged — see 2026-07-07b entry below for full chain. Next session START HERE:** decompile `mem_fill_z_369` to confirm it issues a genuine SIF RPC, identify the 4 RPC handle globals (`dword_5A9330`/`5A9358`/`5A9380`/`5A93A8`) against the SIF RPC client-handle pattern, then trace which IOP-side IRX module should service that RPC and why it doesn't. ## Current Status (2026-07-07b) — Black-screen investigation: `singleton_get_mgr_1()`-null hypothesis refuted; found concrete resource-load fail chain ending in a SIF/IOP RPC round-trip that never gets a valid reply Continuing plan `C:\Users\mwlab\.claude\plans\stateless-fluttering-tiger.md` (root-causing why `GameInit`'s scene object parks at tick-state `9900` inside `fn_327810`/`0x327810`, blocking the splash/render chain and causing the black screen). Pure static IDA analysis this session, no runtime tracing, no source edits. - **Ruled out:** `singleton_get_mgr_1` (`0x1c0da0`) is a trivial lazy singleton — it always returns a valid `$gp`-relative address and can never return null. This definitively refutes the prior "CD-manager-failure via null singleton" hypothesis; the `pool_entry_pop_i` `aCcdreadOpenOut` error branch that theory was chasing is dead code. - **Confirmed the real fail chain, by reading the resource-handle vtable (`dword_4E7930`) implementations:** - `fn_327810` case-3 → `reg_save_stub_z_238("ply_exthit_hit")` → vtable `+16` (`0x1bfe90` = `CCDRead::Open`) → `reg_save_stub_z_203` (`0x1c02c0`, the file-lookup) fails → returns sentinel `-2147418062` ("File not found") → propagates up through `reg_save_stub_z_238` → object parks at tick-state `9900` forever, never finishing init. - Vtable `+24` (`0x1bfe60`) and `+28` (`0x1bfe50`) are both trivial one-line delegation wrappers to `reg_save_stub_z_205`/`reg_save_stub_z_204` — not relevant to the failure. - **Key new finding — `reg_save_stub_z_203`'s file lookup is a live SIF/IOP RPC round-trip:** on first call (gated by an internal one-time-init flag), it busy-waits on 4 RPC handle globals (`dword_5A9330`, `dword_5A9358`, `dword_5A9380`, `dword_5A93A8`) via `wrap_rpc_handle_valid`, dispatches a request via `mem_fill_z_369(dword_5A9358, 3, 3, dword_5A97D0, 48, dword_5AA7D0, 16, 0, 0)` (48-byte cmd / 16-byte reply buffers — classic SIF RPC shape), waits again, then reads the reply from fixed IOP-shared memory (`MEMORY[0x205AA7D0]`/`MEMORY[0x205AA7D4]`) — a negative value there means "not found." Subsequent calls skip the RPC and use a local cache (`reg_save_stub_z_202`). - **This directly ties to the plan's already-documented "zero SIF/RPC/ADX/sceCd traffic in the entire boot log" finding.** The real IOP-side CD-file-table service for this RPC never responds, so the reply slot never gets populated with a valid value, the read comes back negative/garbage, and `CCDRead::Open` reports "File not found." This plausibly explains not just `ply_exthit_hit` but every asset-dependent object stalling the same way — i.e. this may be the actual reason the splash/render chain (plan signals #3-#6) never advances, not a separate downstream issue. - **Not yet confirmed / next session — START HERE:** 1. Decompile `mem_fill_z_369` to confirm it genuinely issues a SIF RPC dispatch (not a raw memory fill despite the generic name). 2. Identify the 4 RPC handle globals (`dword_5A9330`/`5A9358`/`5A9380`/`5A93A8`) — cross-check against the known SIF RPC client-handle pattern in `reference_ps2_sif_boot` memory (SIF CMD IDs `0x80000008/9/A/C`). 3. If confirmed as a real SIF RPC, trace which IOP-side IRX module (likely `ARKD_DVD.IRX`) is supposed to service this specific RPC command, and why it doesn't — per the standing "No IOP Faking" rule, the fix must get the actual IOP service responding, not hand-write the shared-memory reply value. 4. Once resolved, re-check whether this alone is sufficient for the plan's signal #3 (`0x3E2E80` breakpoint starts firing) and signal #4 (SIF/ADX traffic appears) to both go green, or whether they're still separate gates. ## Current Status (2026-07-07) — GitHub PR #1 CLAUDE.md conflict resolved (merge commit `adf8f09`, not yet pushed); `ps2EntryRunner` rebuild shows unverified `/FORCE` linker warning; Phase 5 goal narrowed to memory card prompt (main menu deferred) - User reported GitHub flagged PR #1 (`work/laptop-session-0519` → `main`) as having conflicts. Root cause: `origin/main` was stale (last commit `c68adaf`, 2026-05-27) vs. current branch — one real conflict, in `CLAUDE.md`'s Project State section. Resolved via `git merge origin/main -X ours`, producing merge commit `adf8f09`; verified clean (no conflict markers, all current Project State entries intact). **Not yet pushed to `origin/work/laptop-session-0519`** — awaiting explicit go-ahead before pushing. - Also investigated and confirmed a VSCode CMake Tools auto-configure run (FetchContent populate of toml11/raylib/sdl2/imgui/imgui_file_dialog/nlohmann_json into a stray `F:/SDBZ Recomp/build/` dir) was **not** agent-triggered — consistent with the standing CLAUDE.md rule that the agent never calls cmake directly, plus the build output path itself is a different, redundant tree from the real `PS2Recomp/out/build`. Hardened `.vscode/settings.json` against recurrence: added `cmake.configureOnEdit: false` plus `cmake.buildBeforeRun`/`cmake.options.statusBarVisibility`/`cmake.showOptionsMenu` suppression, alongside the pre-existing `configureOnOpen`/`automaticReconfigure` false settings. Verified via Read that the edit persisted. - **Stray `F:\SDBZ Recomp\build\` directory (from that auto-configure) is still on disk** — flagged to user as safe-to-delete, not deleted (destructive, not authorized). - **Separately, ran a real `ps2EntryRunner` rebuild via `Laptop_Build.ps1` for the still-open BUG-024/PR #137 verification thread below.** Output showed only benign `LNK4006` duplicate-symbol warnings (raylib's bundled stb_image vs `ps2x_imgui_lib`'s `ImGuiFileDialog.obj`), but also `warning LNK4075: ignoring '/INCREMENTAL' due to '/FORCE'` and `warning LNK4088: image being generated due to /FORCE option; image may not run`. This means something forced the linker to resolve unresolved externals rather than hard-error — the actual `LNK2019`/`LNK2001` lines that would show what's unresolved were earlier in the log and were never captured (user closed the terminal first). **Treat the current `ps2EntryRunner.exe` as unverified — do not use it for PR #137 Step 4 boot verification until re-built with full log capture.** - **Next session — START HERE:** (1) decide whether to push `adf8f09`; (2) rerun `Laptop_Build.ps1`, redirecting full output to a file (e.g. `*> build_log.txt`) so the `LNK2019`/`LNK2001` unresolved-external lines (if any) are visible, before trusting the binary for a boot test; (3) resume BUG-024 root-causing (below) once the binary is confirmed sound. ## Current Status (2026-07-06d) — PR #137 verification: `ps2xTest` build fixed, but suite reveals 4 failures + a stack-overflow crash (BUG-024, open) — do NOT delete test dir yet - Continuing the integration-verification plan (`jiggly-pondering-kurzweil.md`). Fixed `ps2xTest`'s build: 6 stale `setPadOverrideState`/`clearPadOverrideState` call sites in `pad_input_tests.cpp` (pre-existing API drift, unrelated to PR #137), then `ps2xTest/CMakeLists.txt` was missing links to `ps2_runner_stubs_a..h` (the 8 static libs holding the generated `fn_*` bodies that `game_overrides.cpp` calls into — `ps2EntryRunner` already linked these, `ps2x_tests` never did). Both fixed; `ps2x_tests.exe` now builds and links clean. - **Running the suite did NOT reach the 367/367 baseline** (from the isolated worktree test). Found: 2 VIF1/DMAC-dispatch-on-store failures, 2 `TerminateThread`/WaitSema teardown-assertion failures, and a hard crash — `STATUS_STACK_OVERFLOW` (exit code `-1073741571`/`0xC00000FD`), confirmed via `cmd`-redirect (`>`/`2>&1`) capturing the real exit code across 2 independent runs, both stopping at the identical point (right after `"semaphore legacy layout decode remains supported"` passes, before the next test even prints `[Run]`). Full detail logged as **BUG-024** in `BUG_LOG.md`. - These failures land directly in the two subsystems the plan itself flagged as highest-risk for the fiber-scheduler rewrite (`Interrupt.cpp`/`Sync.cpp`, VIF1/DMAC dispatch + WaitSema/TerminateThread under the new cooperative model) — plausibly real PR #137 regressions, not stale-test drift (unlike the pad-port case). - **Plan Step 6 (delete `PS2Recomp\ps2xTest\`) is explicitly blocked** until BUG-024 is root-caused and the suite passes clean, per the plan's own gate. Plan Step 4 (boot verification against `SLUS_214.42`, checking for absence of `0x172998`-class freeze) has NOT been attempted yet this session either. - **Next session — START HERE:** identify which test in `ps2xTest/src/ps2_runtime_kernel_tests.cpp` runs immediately after `"semaphore legacy layout decode remains supported"` — that's the crash trigger; determine if it's a genuinely deep call path or a fiber stack-size misconfiguration. Then root-cause the 3 logic failures (VIF1 dispatch x2, TerminateThread x1). Only after all of this passes clean should Step 6 (test-dir deletion) proceed. Boot verification (Step 4) is separate and still pending regardless. ## Current Status (2026-07-06c) — `dword_441A00` CLOSED as dead end; smstt/smrdy proven to be untouched static ELF defaults, not failed writes - **`dword_441A00` write-site search resolved via Opus subagent, cross-validated against raw-ELF disassembler:** `mips_r5900_disassembler.py --find-writes-to 0x441A00` found **zero writers anywhere in the binary**; the slot's static ELF-image init value is `0x0`. The `if(dword_441A00) call(...)` tail in `sub_11FA60` is a by-design optional hook, not a skipped init — the 2026-06-21g/2026-07-06b working theory ("gated but reachable") is refuted. - **`sub_120220`/`sub_1202A0` confirmed genuinely dead code** — full lui/addiu/ori constant-reconstruction scan plus a 4-byte-aligned pointer-table scan found no reference to either function anywhere in the file. - **New decisive finding:** smstt's (`0x4418D0`) static ELF init value is `0xFFFFFFFF` and smrdy's (`0x4418E4`) is `0x0` — **byte-identical** to their observed stuck boot-time values across every session since 2026-06-16. This proves neither value is ever written during boot at all — not a failed `CreateSema`/init call, just an untouched compiled default. It follows that the entire init chain that should populate them (`ADX_Init`/`struct_field_reader_e` ← `module_obj_init_c` ← `module_obj_init_b` ← case-13 of the three splash state machines `sub_3E2E80`/`sub_420E70`/`sub_4216E0`, per 2026-07-02b) never executes at all — and `smstt`'s own writer `wrap_noop_wrapper_k_2` (`0x11e318`) never executes either. - **No `game_overrides.cpp` fix applies to `dword_441A00` itself** — forcing it non-null would only invoke dead code with no effect on the real blocker. - **Next session — START HERE, two candidate targets (unresolved which is earliest-blocking):** 1. Statically trace why the three splash state machines never advance their internal state byte (`*(int*)(a1+48)`) to case 13 — likely gated on an SIF-RPC-ready/file-load/DMA-complete flag. 2. Trace `wrap_noop_wrapper_k_2`'s own caller chain independently of the splash chain — it may be gated by an earlier, separate frontier. Whichever proves earliest-unreached during recomp boot is the true next fix target. ## Current Status (2026-07-06b) — PR #137 fiber scheduler INTEGRATED + COMMITTED (`6a23062`); boot-tested — hang class fixed, smstt/smrdy blocker still open, unrelated - **Cherry-pick completed and committed.** `git cherry-pick -n fbe062e` (39 files, resolving the location/content conflicts described in the 2026-07-06 entry below), plus two files (`Sync.cpp`, `Thread.cpp`) needed additional manual compile fixes (removed a `cv` member from `SemaInfo`/`ThreadInfo` that no longer existed post-rewrite). Build succeeded clean via `build.ps1`. Committed as `6a23062` ("Integrate PR #137 N=1 cooperative fiber scheduler into main tree"), 39 files, +12460/-1695. `git diff HEAD --stat` confirmed zero drift between the committed tree and what was actually built/tested. - **Boot-tested via `boot_test.ps1 -Seconds 30`** (`run_logs\run_20260706_114555.txt`): ran the full 30s without a spin-detection kill (previously the old thread-per-guest-thread model would hang/spin here). Continuous `[SignalSemaById] sid=3` (702x) and `[SifCallRpc]`/`processPendingRpc`/`ARKD handled RPC` cycling (~553x each) confirm genuine non-hung IOP/RPC activity throughout — **the fiber scheduler fixed the old hang/spin class of bug it targeted.** - **The separate, long-standing smstt/smrdy semaphore blocker is untouched by this fix.** Grepped the same log for `smstt|smrdy|gamemode|VblankTick`: `smstt=0xffffffff`, `smrdy=0`, `gamemode=0` unchanged across the entire run (v=60 through v=660). Consistent with — not contradicted by — the 2026-07-02b finding that this blocker's root cause is a still-unresolved *content* gap (an unreached/never-called init that should write valid values into `smstt`/`smrdy`), not a scheduling/threading bug, so the fiber scheduler was never expected to fix it on its own. - **Verdict:** PR #137 integration is DONE, not just validated-standalone. Necessary but not sufficient — it clears one whole class of boot-blocking bug but the game still does not boot further, because the smstt/smrdy content gap is a separate, unresolved issue. - **Next session — START HERE:** resume the smstt/smrdy investigation. Per 2026-07-02b, the trail dead-ends at two unresolved points: (1) whether the three movie/logo splash state machines (`sub_3E2E80`/`sub_420E70`/`sub_4216E0`) are ever ticked during recomp boot at all, and if so whether they stall before reaching case 13 of their internal switch (where they'd call `module_obj_init_b()` → eventually `ADX_Init` → increment `smrdy`); (2) `smstt`'s only writer, `wrap_noop_wrapper_k_2` (`0x11E310`), is called only from `sub_120220`/`sub_1202A0` — a module init/exit callback pair with **zero resolvable callers anywhere in the binary** per IDA `get_callers`/`get_xrefs_to` and the raw-ELF `mips_r5900_disassembler.py` scan. Start by re-checking (2) with fresh eyes/tools — a callback pair with truly zero direct callers is usually invoked indirectly (function-pointer table, module registration array), not literally dead code. ## Current Status (2026-07-06) — PR #137 fiber scheduler isolate-tested (367/367 pass standalone); integration attempted and aborted — deferred, not a quick merge - **Isolate-test setup:** separate git worktree `F:\SDBZ-Recomp-pr137-test` (branch `pr-137-fiber-scheduler`, upstream commit `fbe062e`) built clean via VS 2026 generator (`"Visual Studio 18 2026"`, not 17/2022 — installed VS is v18) after working through a stale-cache generator mismatch and a wrong target name (`ps2x_tests`, not `ps2xTest`). `ps2x_tests.exe` run directly (CTest itself finds no registered tests — this suite isn't CTest-integrated) — **367/367 tests passed.** - **Real-game boot attempt in the isolated worktree failed as expected:** its `ps2EntryRunner.exe` has none of SDBZ's generated `runner/*.cpp` (30,000+ files) or `game_overrides.cpp` — those exist only in the main tree. Confirmed by spin on guest PC `0x100008` (entry point) with "No exact recompiled function" errors. This worktree is only useful for the upstream unit-test suite, not a real boot test. - **Integration into main tree attempted, aborted.** Committed pre-existing uncommitted debugger/pad-port work first (commit `e297223`) to get a clean base, then `git cherry-pick -n fbe062e` against main. Result: PR #137 is a 41-file, +12,382/-1,649-line rewrite of the threading/syscall core (new `ps2_fiber.cpp`/`ps2_scheduler.cpp`/`ps2_scheduler_internal.h`, rewritten `Interrupt.cpp`/`Sync.cpp`/`Thread.cpp`/`System.cpp`/`ps2_runtime.cpp`). Cherry-pick produced **6 real content conflicts** in those exact files plus **~10 "file location" conflicts** (repo restructured under `PS2Recomp/` after PR #137's upstream base, so git didn't know where new files belonged) plus 2 modify/delete conflicts (`CMakeLists.txt`, `ps2_debug_panel.cpp`). Aborted via `git reset --hard e297223` — working tree is clean, nothing merged. - **Verdict: PR #137 is validated standalone but NOT integrated.** Bringing it in requires a dedicated manual-merge session (resolve 6 content conflicts + manually relocate ~10 new files to `PS2Recomp/...` paths + resolve 2 modify/delete conflicts), not a quick cherry-pick. This is the fix path for the long-standing smstt/smrdy `shouldPreemptGuestExecution()` blocker (root-caused 2026-07-04), so it remains the eventual unblock — just not attempted further this session. - **Next session — START HERE (if resuming PR #137):** re-run `git cherry-pick -n fbe062e` from a clean `work/laptop-session-0519` tree, then manually `git mv` the misplaced new files into `PS2Recomp/ps2xRuntime/...`/`PS2Recomp/ps2xTest/...` paths before resolving the 6 content conflicts one file at a time (`ps2_runtime.h` first — smallest, unblocks the rest). Budget this as its own session, not a quick add-on. BUG-023 (digital buttons not lighting up in Input tab, logged 2026-07-05b) remains open and uninvestigated — unrelated to PR #137. ## Current Status (2026-07-05b) — Recomp Debugger scanline-corruption bug fixed + build-verified; Part B binding UI found already fully implemented; real-PCSX2 boot-trace vs recomp diff done (no missing memory-card functionality found) - **Visual bug fixed:** Recomp Debugger window had severe horizontal black/pink scanline corruption across the whole UI (all text unreadable). Existing DPI-awareness fix in `main_gui.cpp` (`SetProcessDpiAwarenessContext` + SDL hints) was confirmed correct and not stale (binary newer than source); no conflicting manifest found either. Root cause: this is a laptop session — hybrid-GPU (NVIDIA Optimus/AMD switchable graphics) rendering mismatch is the classic cause of exactly this stripe pattern when an OpenGL app runs on the wrong GPU relative to the compositor. **Fix:** added `extern "C" { __declspec(dllexport) DWORD NvOptimusEnablement = 0x1; __declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; }` to `main_gui.cpp` (near the top includes) — these exported symbols are read by name by the NVIDIA/AMD drivers to force discrete-GPU selection. Built via `build.ps1 -Debugger`, user confirmed corruption gone. - **Part B of `recursive-beaming-fairy.md` (PCSX2-style per-PS2-button binding UI) found ALREADY FULLY IMPLEMENTED in source** — not something that needed building this session. Verified all pieces present and wired correctly: data model (`PadBinding`/`PadInput`/`cfg_pad_bindings[2][Count]` in `debugger_state.h/.cpp`), persistence (`pad_bind__=` lines in `SaveConfig`/`LoadConfig`), capture-next-input flow (`PollPadBindCapture()`, keyboard/XInput/SDL scan, Escape-cancels), resolver (`ReadPadFromBindings()`), implicit-switch wiring in `SyncFromRecomp()` (`PortHasAnyBinding()` gates whole-device vs per-button resolution), and the UI table (`DescribeBinding()` + Bind/Clear buttons in `tab_inputlogger.cpp`). Plan `recursive-beaming-fairy.md` is now fully complete (Part A done 2026-07-05 earlier session, Part B confirmed done this session) — no further action needed unless a regression is found. - **New bug found and logged (BUG-023, open):** user confirms via direct observation (not the ambiguous frame-log capture from earlier) that the Input tab's analog sticks light up correctly but digital button presses do not — a real, distinct bug from the earlier "buttons stayed 0" frame-log finding (which never proved anything since that capture window never got past the boot blocker). Not yet investigated — likely in `ReadPAD()`'s button-mapping block or the `g_btn_tex`/bitmask draw logic in `main_gui.cpp`. - **Part 1 (real-PCSX2 boot trace vs. recomp diff) done, deliverable below.** User had PCSX2 (DebugServer, port 21512) already paused at the memory-card-load-screen trace point (`PC=0x175220`) when this session picked it up. ### Part 1 deliverable — boot-trace checkpoint at memory-card screen Connected via `mcp__pcsx2__pcsx2_connect` (DebugServer only; Pine/28011 timed out — Pine-only tools like `pcsx2_game_info` unavailable this session). `pcsx2_status`: EE PC=`0x00175220`, paused=true, cycles=3,414,356,645. **Backtrace at this checkpoint (9 frames):** | # | entry | pc | note | |---|-------|----|------| | 0 | 0x1751c0 | 0x175220 | documented VSync-wait routine (same as 2026-07-02 x64dbg trace) | | 1 | 0x1721e0 | 0x172248 | | | 2 | 0x1712d0 | 0x171308 | | | 3 | 0x104c00 | 0x104c74 | | | 4 | 0x199840 | 0x199858 | | | 5 | 0x421ea0 | 0x421f18 | jal → 0x1bfb00 | | 6 | 0x422630 | 0x422660 | matches documented `fn_422630` SIF-handshake loop | | 7 | 0x8fefc | 0x100210 | j → 0x18c518 | This is the **same call chain already documented** as the smstt/smrdy / VSync-wait boot blocker (2026-06-16 → 2026-07-02 entries) — real PCSX2 at this checkpoint is inside the identical function family the recomp is stuck in, confirming the recomp's blocker is a real, shared code path, not something the recomp mis-translated. **31 IOP modules loaded** at this checkpoint (via `pcsx2_get_modules`), notably: `sio2man`, `mcman`, `mcserv`, `padman`, `CRI_ADX_Driver`, `cdvd_driver`/`cdvd_ee_driver` — i.e. by the memory-card screen, real hardware has already loaded the full memory-card + pad + ADX audio IOP stack. **Diff against recomp:** the `ps2recomp_list_overrides`/`ps2recomp_lookup_function` MCP tools errored (`Override dir not found` — tool path misconfiguration, not a project issue) so the comparison was done by direct source inspection instead. Checked `PS2Recomp/ps2xRuntime/src/lib/Kernel/Stubs/MemoryCard.cpp`: **all `sceMc*` memory-card syscalls are fully implemented** (host-filesystem-backed, per-port state, real directory listing/read/write/format, ~50 functions covering the full libmc + in-game memory-card-UI helper surface) — this is not a gap. Combined with the already-working R3000/IOP interpreter (runs real `ARKD_DVD.IRX` etc. natively, not faked), there is **no missing memory-card-related functionality found on the recomp side**. The actual remaining gap is unchanged from prior sessions: the shared smstt/smrdy semaphore boot blocker (root-caused 2026-07-04 to `shouldPreemptGuestExecution()` never returning true, deferred to the PR #137 fiber-scheduler rewrite) is what prevents the recomp from ever reaching this checkpoint on its own, not an unimplemented subsystem. **Next session:** if continuing Part 1, single-step forward from this real-PCSX2 checkpoint (`pcsx2_step`/`pcsx2_continue` + repeated `pcsx2_get_backtrace`) past the memory-card screen into the actual `sceMc*` call sequence order, to get a concrete ground-truth call order (useful once the smstt/smrdy blocker is eventually fixed and the recomp can reach this point itself, for a targeted forward comparison instead of a static-code diff like this one). ## Current Status (2026-07-05) — pad-port display regression fixed + build-verified; controller-input report investigated, root cause still open - **Part A fixed + verified:** `DbgPadSnapshot pad[2]` widened in shared memory (`RECOMP_DEBUG_STATE_VERSION` 9→10); `main_gui.cpp` `SyncFromRecomp()` now indexes `ext->pad[cfg_pad_port]` instead of hardcoding port 0. - **Two independent staleness bugs found/fixed during verification, unrelated to the code fix itself:** 1. Running `ps2EntryRunner.exe`/`RecompDebugger.exe` were stale (33h older than source) — user reran `build.ps1`, clean build. 2. `build_scripts/recomp_mcp_server.py` (separate Python tool, not the C++ runtime) had hardcoded `version != 8` checks and a stale `_PAD_SIZE` assuming the old single-`pad` (6B) layout, silently misaligning every field after `pad` (log entries, IOP state, mem-req/write, reg-write). Fixed to `version 10` / `_PAD_SIZE=(2+4)*2`. Note: it's a long-running process — editing the file doesn't take effect until it's restarted. - **Part B (PCSX2-style per-button binding UI) NOT started** — plan `C:\Users\mwlab\.claude\plans\recursive-beaming-fairy.md` still open at Part A complete / Part B pending. - **User reported pad still not showing input after the above fixes.** Investigated via `Logs\frame_log.csv` (611-frame capture): `buttons` was `0` for every frame, but `lx/ly/rx/ry` showed genuine analog jitter (127-128/125-126/130/124) — confirms the SDL PS4-controller read path is live and correctly wired (`[PAD] SDL GameController opened: PS4 Controller` in logs; `cfg_pad_source=0` Auto already correct — no config change needed). However `pc` only had 2 distinct values across all 611 frames (`0x4bca90`/`0x6ccc80`) — the capture window never advanced past the pre-existing boot blocker, so it's unclear whether a button press was ever actually exercised in that log. **Not a confirmed gamepad-detection bug.** - **Next session — START HERE:** capture a fresh Frame Logger run while actually holding/pressing X on the controller. If `buttons` stays 0 the whole time, that's a real bug in the SDL button-mapping block (`main_gui.cpp` ~998-1032, `SDL_CONTROLLER_BUTTON_A`→Cross bit 14). If it flips nonzero, the pad pipeline (Part A + SDL read) is fully confirmed working and the remaining problem is purely that the game never reaches the memory-card screen (ties back to the long-open smstt/smrdy boot blocker). - **New idea, not yet started:** user proposed a real-PCSX2 reference run (via `mcp__pcsx2__*` tools) up to the memory-card "press X" screen, diffed against `ps2recomp_list_overrides`/`ps2recomp_lookup_function`, to build a list of functions/functionality still missing from the recomp. Not scoped or started. ## Current Status (2026-07-04c) — BUG-018 fixed + verified; Markers register dedup + Labels tab merge fixed + verified — BUILD CONFIRMED, ALL COMMITTED Plan `C:\Users\mwlab\.claude\plans\glittery-orbiting-popcorn.md`, fully implemented, built (`build.ps1 -Debugger`, clean), and live-verified by the user against real PCSX2 + DebugServer. - **BUG-018 CLOSED.** True root cause found (after two earlier failed fix passes, see `BUG_LOG.md`): `step()`/`stepOver()` left a temp breakpoint armed server-side at the post-step PC, so the very next `continue` immediately re-halted — Play looked broken even though resume genuinely worked. Fixed with a `RearmPCSX2Breakpoints()` helper (clears temp breakpoint, re-arms real breakpoints/watchpoints) called after every step/step-over and on reconnect. Also hardened connection handling: Pause/Resume/Frame-Advance/Step-Over now force a synchronous reconnect + re-arm and retry once if `attached==true` but `g_dbgserver.isConnected()==false`, instead of silently no-op'ing for up to 2000ms. - **Markers panel register dedup verified** — only "Watch Registers" shown, no duplicate full GPR dump; standalone "EE Registers" window unaffected. - **Labels tab merge verified** — new top-level "Labels" tab shows "Symbols" and "Ghidra" collapsing-header panels with original functionality intact; Settings no longer has a separate Labels sub-tab. - **Next session:** resume the still-open 2026-07-04 backlog — register write-back live test (halt at a Recomp-backend breakpoint, edit a GPR, Step/Resume, confirm the edited value persists in subsequent execution, not just the shared-memory snapshot) and general Stage 5 regression pass (whitespace/`ImGui::Spacing()` trimming not yet done). ## Current Status (2026-07-04b) — Recomp Debugger Markers-panel QoL pass (3 fixes) — NOT YET BUILT Pure UI work in `RecompDebugger.exe`-side files only, no runtime/boot-logic changes. Continues Stage 5 from the entry below. - **Markers spacing fix:** `ShowCallStack()` (`tab_callstack.cpp`) and `ShowThreads()` (`tab_breakpoints.cpp`) both used `BeginChild(id, ImVec2(0,0), ...)`, which greedily fills all remaining vertical space in the shared `##markers_scroll` parent — this caused the large empty gaps between Call Stack/Threads and the sections below them. Fixed by sizing each child to a height computed from actual row count (capped at 12 for call stack, 10 for threads) instead of (0,0). - **Register-tab consolidation:** folded `ShowRegWatch()` (`tab_regwatch.cpp` — the small "v0"/dropdown/"Clear All" pinned-register watchlist) into the Markers tab as a new "Watch Registers" CollapsingHeader (`tab_breakpoints.cpp`'s `ShowBreakpoints()`), resolving user confusion between "EE Registers"/"Registers"/"IOP Registers". Removed the now-dead standalone "Registers" window: dock-builder line, Windows-menu item, conditional Begin/End block, and SaveConfig/LoadConfig lines in `main_gui.cpp`; `cfg_open_registers` extern in `debugger_state.h` and definition in `debugger_state.cpp`. - **Right-panel "gap" explained (not a bug):** the empty space at the bottom of the 2-column layout's right side is the standard ImGui docking splitter between the Disassembly and Memory Viewer dock nodes — user can drag it down interactively; position persists automatically via `imgui.ini`. No code change made or needed. - **Next session — START HERE:** `& ":\SDBZ Recomp\build.ps1" -Debugger`, fix any compile errors, then live-verify all three items above. After that, resume the still-open 2026-07-04 backlog below (BUG-018 live test, register write-back live test, general Stage 5 regression pass). ## Current Status (2026-07-04) — Recomp Debugger plan: BUG-018 fixed + BUG-022 closed (misdiagnosis) + register write-back completed + Stage 5 UI density started — ALL UNCOMMITTED, NOT YET BUILT Working under `C:\Users\mwlab\.claude\plans\recursive-finding-starfish.md` ("Recomp Debugger — Get It Fully Working"). All changes below are coded in the working tree, uncommitted, and **not yet build-verified** (build.ps1 -Debugger not run this session). - **BUG-018 fixed (source only):** `main_gui.cpp` Pause/Resume/Step handlers now use `g_dbgserver` (DebugServer plugin, port 21512) as the sole transport — all PINE calls removed from these three handlers. If DebugServer isn't connected, the buttons log a clear "requires DebugServer plugin" message instead of silently falling back to PINE (which was racing DebugServer and causing the original desync). - **BUG-022 closed, reclassified as misdiagnosis** — see `BUG_LOG.md` for full writeup. Root cause: `PS2Runtime::shouldPreemptGuestExecution()` deliberately never returns true, so a back-edge loop buried in a deep call chain can spin forever in one native C++ frame the debug-writer never revisits — indistinguishable from a debugger re-arming a halt. This is the same pre-existing `smstt`/`smrdy` semaphore boot blocker (2026-06-16). No debugger-side fix exists; real fix is the deferred fiber-scheduler rewrite (PR #137, held). - **Register-edit write-back completed.** Previously `RecompilerBackend::WriteRegister()` only updated the shared-memory mirror — the edited value never reached the actual running `R5900Context`, so an "Edit" in the GUI looked like it worked but had no effect on execution. Fixed: `RECOMP_DEBUG_STATE_VERSION` 7→8, new `reg_write_idx/value/seq/done_seq` fields, `ServiceRegWrite()` added to `recomp_debug_writer.cpp` (applied inside `CheckBreakpoint()`'s halt spin), and both `ps2_runtime.cpp` call sites (`lookupFunction()` and `dispatchLoop()`) now write the edited GPR back into the live context via `_mm_insert_epi32` (low 32 bits only, preserving the upper 96 bits of each 128-bit MMI register). - **Stage 5 (UI density pass) started.** Breakpoints/Watchpoints/Call Stack/EE Registers/Threads/Recomp-armer consolidated from separate tabs into one scrollable "Markers" panel using collapsing headers (`tab_breakpoints.cpp`, `main_gui.cpp` dockspace/menu renamed "Breakpoints"→"Markers"). Memory Viewer (`tab_memoryviewer.cpp`) got a write toolbar (addr/size/value fields, Recomp-backend only — PCSX2 has no write-memory transport yet). Symbol Sync tool's duplicate path/browse/history UI removed (`tab_symbolsynctool.cpp`) since the same fields already live in Settings. - Minor: interrupt worker thread's `g_currentThreadId` sentinel changed `-1`→`-2` (`Interrupt.cpp`) so it can't collide with `recomp_debug_writer.cpp`'s own "-1 = unclaimed slot" sentinel now that it gets its own tracked debug thread-state slot. - **Next session — START HERE:** run `& ":\SDBZ Recomp\build.ps1" -Debugger`, fix any compile errors. Then live-verify: (1) BUG-018 — attach to real PCSX2 with DebugServer active, confirm Pause/Play/Frame-Advance/Step-Over/Restart all work without fighting each other; (2) register edit — halt at a Recomp-backend breakpoint, edit a GPR via the new popup, Step/Resume, confirm the edited value is actually visible in subsequent execution (not just the shared-memory snapshot); (3) general regression pass on the "Markers" panel consolidation and Memory Viewer write toolbar. Only after all three are confirmed working should Stage 4 (parity checklist, already passed once pre-refactor) be re-walked and Stage 5 continued (whitespace/`ImGui::Spacing()` trimming not yet done). ## Current Status (2026-07-03c) — BUG-021 fix confirmed live via MCP tools; new BUG-022 anomaly found: thread only advances one call-boundary per manual recomp_resume() BUG-021's thread-aware IPC upgrade (seqlock per-tid `thread_states[]`, version 7) was confirmed working end-to-end this session via actual `mcp__recomp__recomp_status()`/`recomp_list_threads()` calls against a live `ps2EntryRunner.exe` (not just raw shared-memory reads): `r0=0x0`, `active_thread_count=1`, `recomp_running=true` all read correctly. Investigated the user's "process seems paused" report. Found the tracked thread (tid=1) is NOT frozen — each `recomp_resume()` call advances it exactly one call-boundary through the documented `smstt`/`smrdy` chain (`0x104b30`→`0x171320`→`0x11fa60`→`0x11e3b0`→`0x11e560`→`0x11f240`→ wraps to `0x104b30`), with genuinely evolving GPR state across passes (`r2: 0x0→0x1`, `r4: 0xfc→0xfe`, `r18: 0x3→0x4`) — real per-vblank forward progress, not a static freeze. But something is re-arming a halt (consistent with `step_requested_tid` or an equivalent single-step condition) between every `recomp_resume()` call, with no traceable external source. Ruled out: (1) stale `bp_slots[1..7]` — raw dump showed all 8 slots `addr=0x0, enabled=0`; (2) a stray `RecompDebugger.exe` (PID 12112) suspected of driving BUG-018's broken step loop — killed it, halting behavior continued unchanged, and a broader `tasklist` confirmed no `RecompDebugger.exe`/`ps2xStudio.exe` was running at all; (3) `--debug` launch-flag single-step mode — confirmed via `Get-CimInstance Win32_Process` that PID 4964 was launched with no `--debug` flag; (4) `recomp_resume()`/`recomp_pause()` self-inflicted re-arming — read both function bodies, neither touches `step_requested_tid`. Logged as **BUG-022** in `BUG_LOG.md` — open, not yet root-caused. Also noted `bp_hit_tid` always reads `-1` even when `bp_hit=1`, suspected to be a race between my `recomp_resume()`'s `_clear_bp_hit()` and the runtime's own `bp_hit_addr`→`bp_hit_tid`→`bp_hit` write sequence (3 non-atomic stores) rather than proof of "no external re-armer" — needs a fresh clean relaunch (no debugger attached at all) to get a true full-speed baseline before digging further. **Next:** kill PID 4964, relaunch cleanly via `launch_debugger.ps1`, and check whether it runs at full speed with nothing attached — if it does, BUG-022's re-arming source is something that only appears once an MCP client (this session's Python tool calls) has attached, which would narrow the search to the debug-writer/IPC layer itself rather than game logic. ## Current Status (2026-07-03b) — smstt/smrdy purpose resolved (audio/ADX, not input); memory-card-screen reachability reframed as priority; found scePadRead gap - Pure research/read-only session, no code changes. Prompted by the user reframing priority: the real first unreached boot milestone is the **memory card screen**, which per the 2026-06-20 real-PCSX2 trace occurs chronologically BEFORE the Atari-logo/ADX/`smstt`/`smrdy` chain — so the long-tracked `smstt`/`smrdy` blocker may not even be the right thing to be chasing first. - Confirmed `smstt`/`smrdy` (`0x4418D0`/`0x4418E4`) are CRI ADX audio-driver (`CRI_ADXI.IRX`) init-readiness flags — **not related to controllers/input**. Write chain: `ADX_Init` (`0x11F268`) ← `module_obj_init_c`/`b` ← three movie/logo state machines (opening movie, Atari logo, Okatron 5000), each at case 13 of their state switch. Confirmed unrelated to `GAME.DAT`/`INFO.DAT` (pure content blobs mapped via `addLsnMapping`, no init role). - **User supplied a critical real-hardware fact:** the memory card screen is an interactive, input-gated prompt — the real game stalls there until the player presses **X**. This is expected/correct behavior, not a hang, on real hardware. - Searched `game_overrides.cpp`'s `SifCallRpc` override for a live button-state handler. Found only a `scePadOpen`-style init-status stub (`sid==0x80000100u && rpcno==1u`, fakes `padValue` written to `recvPtr+12`) — this signals "pad subsystem ready", not actual per-frame button bits. **No `scePadRead`-equivalent live-input handler was found anywhere in the reviewed code.** Filed as BUG-020 in `BUG_LOG.md`. - **Open/unresolved:** (a) whether EE code that would render the memory-card screen is ever reached at all during recomp boot — not confirmed either way this session; (b) if reached, there is currently no mechanism to simulate an X-button press, so recomp would appear to hang there even if everything upstream is working correctly, indistinguishable from a real stall. - **Next session:** determine whether the memory-card-screen code path is reached (trace independently of the `smstt`/`smrdy` gate, since it's chronologically earlier); if reached, a `scePadRead` SIF RPC handler with a fakeable button-bitmask (X-button bit set) will likely be needed to progress past it for any automated/unattended boot test — this is a plausible small addition to the existing `SifCallRpc` override in `game_overrides.cpp`, not yet implemented or approved. ## Current Status (2026-07-03) — BUG-015 breakpoint attempt stalled by IPC-staleness trap; new suspected stall point at 0x172998 not yet confirmed - No code changes. Continuing the BUG-015 "START HERE" from 2026-07-01b: launched `ps2EntryRunner.exe`, attached via `mcp__recomp__*`, armed a breakpoint at `0x17666c` (the Deci2Open call site) to capture `a0`. - **New finding — IPC staleness trap:** multiple orphaned `ps2EntryRunner.exe` instances can run at once, and `recomp_status()` will silently return a frozen snapshot from a stale/orphaned instance instead of erroring. Diagnosed via `Get-Process ps2EntryRunner | Select Id,StartTime,Path` showing two live instances, confirmed by cross-checking against a live x64dbg attach showing a different process state at the same moment. **Rule going forward: verify `Get-Process ps2EntryRunner` returns exactly one instance before trusting `recomp_status()`.** - One orphaned instance resisted `Stop-Process -Force`/`taskkill` until x64dbg (which had it attached) was closed by the user; then it terminated cleanly. - Relaunched cleanly via `build_scripts\launch_debugger.ps1` — corrected its actual usage: `[Config="Debug"|"RelWithDebInfo"] [-DeReOnly] [-DebuggerOnly]`, NOT `--debug` (prior memory was wrong on this). - Re-armed breakpoint at `0x17666c` on the clean instance. Two waits (60s, 120s) both timed out. `recomp_status()` in between showed `pc=0x172998` (GIF busy-wait spot) with `r2` slowly advancing 0x771400 → 0x7d5400 — this is the *same* value progression seen earlier under suspected-stale conditions, so it's now ambiguous whether this is real slow progress or a genuine stall at `0x172998` recurring every run. **Not resolved — ran out of session time before the breakpoint fired.** - Unconfirmed side finding: an earlier orphaned/conflicting instance (PID 33996) showed what looked like a real C++ exception thrown from `getExecutablePath()` (`main.cpp`) via x64dbg's call stack. Not yet reproduced on a clean single-instance run — could be a real bug or an artifact of the multi-instance conflict. - **Next session — START HERE:** (1) `Get-Process ps2EntryRunner,RecompDebugger,x64dbg` and kill any strays before starting. (2) Launch via `launch_debugger.ps1`. (3) `recomp_set_breakpoint("0x17666c")`, then `recomp_wait_for_break(timeout_seconds=180)` or longer. (4) If PC still sits at `0x172998` with `r2` crawling through the same 0x77xxxx-0x7dxxxx range after several minutes, treat that as a new suspected stall and investigate what condition the GIF busy-wait at `0x172998` is spinning on (see `sdbzGifChannelBusyFakeZero` in `game_overrides.cpp`, already a known mitigation point from 2026-07-02 notes). (5) Separately try to reproduce/rule out the `getExecutablePath()` exception on a clean instance. ## Current Status (2026-07-02b) — smstt/smrdy thread: traced ADX_Init chain to 3 movie/logo state machines; separate open sub-thread unresolved - Pure IDA/disasm investigation, no code changes. Continued the older smstt/smrdy root-cause thread (separate from the BUG-015/Deci2Open thread below, which is the more advanced/active blocker — **not reconciled with it this session**). - Traced the full smrdy write chain: `sub_11FA60` (gated on `dword_4418E4`/smrdy `>0`) ← `ADX_Init` (`0x11F268`, increments smrdy 0→1 on first call) ← `module_obj_init_c`/`sub_113F40` (`0x113F40`) ← `module_obj_init_b`/`sub_113D30` (`0x113D30`, thin wrapper) ← three near-identical movie/logo splash state machines: `sub_3E2E80` (`movie/OP_USA.SFD`), `sub_420E70` (`movie/atari.sfd`), `sub_4216E0` (`OKR.SFD`) — each calls `module_obj_init_b()` at **case 13** of a state-byte switch on `*(int*)(a1+48)`. Matches the documented 2026-06-20 real-PCSX2 boot/movie sequence exactly. - `smstt`'s only writer (`wrap_noop_wrapper_k_2` @ `0x11E310`) is called only from `sub_120220`/`sub_1202A0` — a module init/exit callback pair with **zero resolvable callers anywhere in the binary** (checked via IDA `get_callers`, `get_xrefs_to`, and `mips_r5900_disassembler.py --find-calls-to`/`--find-writes-to`, all empty). Fully unresolved. - **Methodology finding:** IDA's `get_callers` returned empty for several functions that structurally must be called (`sub_120220`, `sub_1202A0`, `ADX_Init`, `module_obj_init_c`); `get_xrefs_to` and/or `mips_r5900_disassembler.py --find-calls-to ` found real callers `get_callers` missed (e.g. `0x113D38: jal -> 0x113F40`). When IDA's caller/xref tools disagree or come up empty, cross-check with the raw-ELF disasm script before concluding a function is dead. - **Not started:** whether the 3 movie/logo state machines are actually ticked during recomp boot, or get stuck before reaching case 13 — would tie this thread directly to the 2026-06-20 "recomp never reaches movie/logo code" finding. - **Important:** this thread's `smstt`/`smrdy`/`0x1759a0` blocker framing predates the BUG-015 (`complist=0x0`, Deci2Open) thread below, which is documented as the currently active/more-advanced frontier (see "Carried-Over Open Thread" section, 2026-06-28/06-29 updates). Next session should determine whether these two threads are the same blocker seen at different depths, or genuinely different/superseded — not yet reconciled. ## Current Status (2026-07-01b) — BUG-015 investigation: confirmed Deci2Open fails only in recomp, not real PCSX2 - Traced the Deci2Open wrapper (`0x176640`, call site `jal 0x175F80` at `0x17666c`, return check `bgez v0` at `0x17667c`) on **real, unmodified PCSX2** via `mcp__pcsx2__*` DebugServer tools. - `pc=0x1759a0` is NOT a stuck spin on real hardware — confirmed via breakpoint-list inspection (3 self-set breakpoints, not an organic stall) and a 20-instruction step trace showing clean linear execution into `0x174CE0`→`0x176640`. - At the return-check site, real PCSX2 shows **v0=0x00000005 (success)**. The recomp build (documented in an earlier session) shows **v0=0xFFFFFFFD (-3, failure)** at the same wrapper. This confirms BUG-015's Deci2Open failure is genuinely recomp-specific, not a path real hardware also fails. - Open question, not yet resolved: real PCSX2's return-site snapshot showed `a0=0x1`, vs. the recomp trace's call-site `a0=0x210` — these weren't captured at the same instruction offset, so it's unconfirmed whether they represent the same argument. Need to re-trace real PCSX2's `a0` at the `jal` call site itself (`0x17666c`), not the later return-check site. - Attempted to pivot to the recomp target directly (`mcp__recomp__recomp_status()`) — result was `recomp_running: false`, all-zero state. `ps2EntryRunner.exe` was not running this session; recomp-side trace could not proceed. - **Next session — START HERE:** launch `ps2EntryRunner.exe` per Active Runner Command above, re-attach via `mcp__recomp__*`, set a breakpoint at `0x17666c` to capture the actual `a0` value the recomp build passes to Deci2Open, and separately re-trace real PCSX2's `a0` at that same call-site offset for a true apples-to-apples comparison. Also still open: decode PS2 SDK meaning of return code `-3` for `sceDeci2Open` (`k0=0x7C`). - Deferred (do not resume unless asked): Recomp Debugger breakpoint/watchpoint persistence-across-restart plan, saved at `C:\Users\mwlab\.claude\plans\temporal-popping-hare.md`. User explicitly chose to prioritize BUG-015 over this fix this session. ## Current Status (2026-07-01) — Recomp Debugger Phase B fixes BUILT + live-verified (partial); found `build.ps1` doesn't build the Debugger target at all - Built the 2026-06-30 Phase B fixes via plain `& "F:\SDBZ Recomp\build.ps1"` — succeeded (12/12 targets, `ps2EntryRunner.exe` produced). **This build does NOT touch `RecompDebugger.exe` at all** — `build.ps1` (no switches) only builds the `ps2EntryRunner` target chain. - Verified Finding #1 (telemetry freeze fix, `RecompDbg::Update()` now also called from `lookupFunction()`) live against the running `ps2EntryRunner.exe` via `mcp__recomp__recomp_status`/`recomp_pause`/`recomp_wait_for_break`/`recomp_resume`: GPR values now advance between polls even while PC sits at a hot repeating call site; Pause arms and hits almost instantly on hot loops; `bp_hit` correctly reflects true/false across pause/resume. **Confirmed fixed.** - Noted but out of scope: `cycle_count` telemetry always reads `0` because `R5900Context::insn_count` (`ps2_runtime.h:55`) is declared and read (feeds `cycle_count` at `ps2_runtime.cpp:1110`/`1993`) but never incremented anywhere in the codebase. Pre-existing, unrelated to this session's changes. - User reported Findings #2 (ISO Path field) and #4 (Default-backend combo) "not here" in the actual running Settings tab, despite both being present and correctly wired into `DEBUGGER_SRC_FILES`/`add_executable(RecompDebugger ...)` in `CMakeLists.txt`. Root cause found via `RecompDebugger.exe` vs `tab_settings.cpp` mtime comparison, then confirmed directly: **`build.ps1` with no switches builds `ps2EntryRunner.vcxproj`, not `RecompDebugger.vcxproj`** (`build.ps1:27-33` — the `-Debugger` switch is required: `& "F:\SDBZ Recomp\build.ps1" -Debugger`). The exe the user tested had never been rebuilt since Phase B's `tab_settings.cpp` edits landed. - **Not yet done:** run `build.ps1 -Debugger`, relaunch `RecompDebugger.exe`, re-verify Findings #2 and #4 in the GUI (Settings → Paths: ISO Path field + "Default backend on launch" combo should now be visible). - Filed as BUG-017 in `BUG_LOG.md`. ## Current Status (2026-06-30) — Recomp Debugger Phase B fixes applied; NOT YET BUILT - **Phase B UI overhaul** (Recomp Debugger `main_gui.cpp` + `tab_*.cpp`) — all code changes applied this session, build pending: - `debugger_state.h/.cpp`: added `recomp_pid` global (DWORD) to track launched Recomp process for proper termination. - `main_gui.cpp`: `RestartRecomp()` now kills prior PID via `TerminateProcess`+`WaitForSingleObject(5000)` before relaunch (fixes "double instance" bug); `RestartPCSX2()` now stores `pi.dwProcessId` into `pcsx2_pid` (was never stored → subsequent restarts failed to kill). Scan progress overlay replaced with `ImGui::GetForegroundDrawList()` drawing (fixes invisible loadbar behind docked panels). Dockspace: removed Controllers + Call Stack windows; added EE Registers dockable window (col3). Added `#include ` for `fmodf`. - `tab_codetrace.cpp`: removed right-pane GPR register split; `DisasmPane` now fills full panel width. Extracted `ShowEERegisters()` — full 32-GPR table, HI/LO, Snapshot button. PC History dropped entirely. - `tab_breakpoints.cpp`: added "Call Stack" sub-tab (`ShowCallStack()`) and conditional "Recomp" sub-tab (shm breakpoint armer, previously in Settings→General). Tab formerly called "Execution" remains; renamed in label only. - `tab_settings.cpp`: deleted `ShowSettingsGeneral()` and its BeginTabItem entry. Settings sub-tabs now: Paths / Graphics / Inputs / Labels / Session Log. - **Boot blocker status unchanged** — BUG-015 (`bad=0x1 ra=0x1`, `dword_63FDF8` not reaching `0x422648`) still active frontier. No boot-path code touched this session. - **NOT YET BUILT.** Run `& "F:\SDBZ Recomp\build.ps1"` then retest all UI items (see HANDOFF_2026-06-30.md). ## Current Status (2026-06-29d) — Recomp Debugger overlay CMake wiring complete; NOT YET BUILT - F1 was not showing the in-game rlImGui overlay (`ps2_debug_panel.cpp`). Root cause: `PS2X_ENABLE_DEBUG_UI` never defined in CMakeLists.txt → all overlay code compiled away; `ps2_debug_panel.cpp` also missing from `RUNNER_SRC_FILES`; `rlImGui` dependency entirely absent. - Applied three fixes to `PS2Recomp/ps2xRuntime/CMakeLists.txt`: (1) FetchContent for `rlImGui` from `https://github.com/raylib-extras/rlImGui.git`; (2) add `ps2_debug_panel.cpp` to `RUNNER_SRC_FILES` with `SKIP_UNITY_BUILD_INCLUSION`; (3) build `ps2x_rlimgui_lib` static target + `target_compile_definitions(ps2EntryRunner PRIVATE PS2X_ENABLE_DEBUG_UI)`. - **NOT YET BUILT.** CMake will need to reconfigure (fetches rlImGui). Launch without `--debug` and press F1. - `--debug` flag routes to `run_with_gui()` (ps2xStudio) — completely unrelated to the in-game overlay. - Boot blocker (BUG-015 `bad=0x1 ra=0x1` after `dword_63FDF8` not reaching `0x422648`) still open from prior session. ## Current Status (2026-06-26/27) — BUG-014 ASan build in progress via new build_asan.ps1; not yet linked/tested - User's raw `cmake --build ps2xRuntime-asan --config Debug --target ps2EntryRunner -- /m:1` (bypassing build.ps1, no progress indicator) has compiled/linked all 8 runner-stub sub-projects (`ps2_runner_stubs_a`-`_h`) as of this entry, only benign `C4530` (`/EHsc`) warnings, no errors. Main `ps2EntryRunner` link stage not yet finished. - Added `build_asan.ps1` (repo root) — same timer/progress-bar/log pattern as `build.ps1`, targets the `ps2xRuntime-asan` tree. Use it for future ASan rebuilds: `& "F:\SDBZ Recomp\build_asan.ps1" Debug 1`. - **Next:** once linked, attach debugger/MCP and try to reproduce BUG-014's `_CrtIsValidHeapPointer` heap corruption under ASan — confirms whether the 2026-06-25 recursive-mutex fix actually eliminated it. Separately, BUG-015 (new crash frontier past the old smstt/smrdy blocker) is still open and unrelated to this ASan effort. ## Current Status (2026-06-25) — BUG-014 mutex fix BUILT + BOOT-TESTED: huge progress, new crash frontier (BUG-015) - Built and ran the `PS2Memory` data-race fix from 2026-06-24e (swap-and-clear + `std::recursive_mutex m_memoryMutex` covering `writeIORegister`/`readIORegister`/`read64`/`write8`/`write16`/`processPendingTransfers`). Build completed (~16m29s), boot_test.ps1 run produced the deepest log ever captured in this project. - **Boot now sails through the entire IOP module chain that previously never got this far in one clean pass**: ARKD_DVD.IRX → CRI_ADXI.IRX (CRI ADX Driver Ver.9.61 init) → SIO2MAN.IRX → PADMAN.IRX → MCMAN.IRX → MCSERV.IRX, all loading/initing successfully with real SIF RPC servers registered (sids 0x500-0x503, 0x80000100/0101, 0x90000200, etc.), multiple `SifCallRpc`/`SifSendCmd`/`McBindPatch` round-trips completing with `result=0x00000000`. ARKD's RPC-server mode entry confirmed (`[IopRuntime] ARKD init complete; entering RPC-server mode`). GS register writes begin (`BITBLTBUF`, `FRAME`, `prim`, `rgbaq`) — this is the first time GS/render activity has been logged at all in any boot_test run referenced in this file. - **New crash, filed as BUG-015 in `BUG_LOG.md`:** `[BOOT421B70] EXIT ... dword_63FDF8_after=0x00000000 (expect 0x00422648)` (a SIF-handshake-shaped cell never reaching its expected value), then `[BOOT422630] EXIT pc=0x00000001`, then `Warning: Function at address 0x1 not found` / `[dispatch:first-bad-pc] bad=0x1 ra=0x1` with a long call trace through the same `fn_172e20` interrupt-dispatch path documented in BUG-014's original race. Possibly the same mechanism as BUG-012's `bad=0x1 ra=0x1` finding from 2026-06-24 (previously possibly masked/made non-deterministic by the now-fixed data race), or a new and distinct issue — not yet determined. - A `[Syscall TODO] ... v1=0xffffff98 ... syscallId=0xffffff98` unimplemented-syscall warning fires twice right before the crash; unclear if related. - **This represents the furthest the recomp has ever booted** — supersedes the long-standing smstt/smrdy semaphore-blocker framing (BUG-006/007) as the active frontier; that blocker either got fixed as a side effect of the mutex fix, or was never the real gate (consistent with BUG-010's "smstt/smrdy idle, not deadlocked" finding). Need a fresh, deliberate check of `smstt`/`smrdy` values in this run's `[VblankTick]` lines to confirm either way — not done yet, log was truncated before that confirmation. - **Next:** (1) Re-run `boot_test.ps1 -Seconds 30`, confirm `smstt`/`smrdy` status explicitly this time. (2) Find what writes (or should write) `dword_63FDF8` and reads it back expecting `0x422648` — likely a different SIF handshake step/value than the `0x500854` sentinel documented in `reference_ps2_sif_boot.md`. (3) Determine whether the `bad=0x1`/`ra=0x1` dispatch-recovery crash is the same mechanism as BUG-012, and whether it's fatal to the run or just logged-and-recovered (need to see what happens after the recovery heuristic kicks in — log truncated here). (4) Re-test whether BUG-014's original ASan-only heap-corruption symptom (debugger-attached) is actually resolved now, separately from this new boot-test (non-debugger) progress. ## Current Status (2026-06-24e) — BUG-014 ASan diagnosis build, IN PROGRESS - New thread, unrelated to the LIBSD/boot-blocker work below: `ps2EntryRunner.exe` hits a VC++ debug-heap assertion (`_CrtIsValidHeapPointer`) only when the recomp MCP/debugger is attached — runs clean without it. Filed as BUG-014 in `BUG_LOG.md`. Building a sibling ASan-instrumented CMake dir (`PS2Recomp/out/build/ps2xRuntime-asan`, MSVC `/fsanitize=address`) to get a precise corruption stack trace before touching any source — normal Debug build dir is untouched. - Build-tooling obstacles hit (not the actual bug): (1) `LNK1394: cannot infer default ASAN libraries` on `SDL2.vcxproj` — SDL2's FetchContent sub-build defaulted to a shared DLL under ASan; fixed by forcing `-DSDL_SHARED=OFF -DSDL_STATIC=ON` + deleting stale `_deps/sdl2-build`/`sdl2-subbuild` caches (they don't pick up new flags without a full delete+reconfigure). (2) `C1060`/`C1002: compiler is out of heap space` on unity-build TUs and a few giant generated runner files — `-DCMAKE_UNITY_BUILD=OFF` did not disable unity batching for the runner-stub targets (their CMakeLists likely sets `UNITY_BUILD` as a target property, ignoring the cache var); 32-bit `cl.exe` + ASan instrumentation on already-huge MIPS-recompiled TUs exhausts heap. - **Current mitigation, build in progress as of this entry:** full wipe + reconfigure of `ps2xRuntime-asan` with `-T host=x64`, building with `cmake --build ... -- /m:1` (serial, to avoid multiple large ASan compiles competing for limited heap at once). Latest log showed stub targets a-d done/in-progress of 8 total (`ps2_runner_stubs_a`..`h`), no errors. - **Not yet built successfully.** Once it links: attach the debugger/MCP, reproduce the heap corruption, capture the ASan stack trace. Per the standing rule, any eventual fix lands in `ps2_memory.cpp`/`game_overrides.cpp` only — never `fn_*.cpp`/`src/runner/`. - Repro commands: ``` Remove-Item -Recurse -Force "F:\SDBZ Recomp\PS2Recomp\out\build\ps2xRuntime-asan" cmake -S "F:\SDBZ Recomp\PS2Recomp\ps2xRuntime" -B "F:\SDBZ Recomp\PS2Recomp\out\build\ps2xRuntime-asan" -G "Visual Studio 18 2026" -A x64 -T host=x64 -DCMAKE_CXX_FLAGS="/fsanitize=address" -DCMAKE_C_FLAGS="/fsanitize=address" -DCMAKE_UNITY_BUILD=OFF -DCMAKE_DISABLE_PRECOMPILE_HEADERS=ON -DSDL_SHARED=OFF -DSDL_STATIC=ON cmake --build "F:\SDBZ Recomp\PS2Recomp\out\build\ps2xRuntime-asan" --config Debug --target ps2EntryRunner -- /m:1 > "F:\SDBZ Recomp\asan_build_log.txt" 2>&1 ``` ## Current Status (2026-06-24d) — LIBSD.IRX pre-load REAL FIX applied (supersedes 2026-06-24b's wrong diagnosis); NOT YET BUILT - 2026-06-24b's "R_MIPS_26 relocation/symtab" theory was **wrong** — disproven by directly parsing LIBSD.IRX's ELF (`.rel.text` has 762 entries, every single one has `r_sym=0`, so they all hit the existing `loadAddr` branch correctly; there is no real `.symtab` involved at all, `.symtab` only has the null entry). IDA Pro MCP cross-check confirmed: function `start` at `0xB0` (the real entry, matches the loader's own computed `entryPoint`), `nullsub_1` at `0xA0`, export-table magic `0x41C00000` at offset `0x0`, null terminator at `0x9C`. - **Actual root cause of "DID NOT RETURN":** `IopRuntime::start()` wrote the `SYSCALL 0xFFFE` return sentinel to IOP address `0x4` *after* the LIBSD pre-run block executed, not before. LIBSD's `module_start` legitimately returns via `jr $ra` with `$ra=4` (~10 real instructions + a few clean subroutine calls in) — but address 4 was still zeroed memory (decodes as NOP) at that point, so the interpreter walked off into uninitialized IOP RAM until it hit garbage that misdecoded as an unhandled syscall. **Fix:** moved the sentinel write up, before the LIBSD block, in `iop_runtime.cpp` (`IopRuntime::start()`). - **Actual root cause of `registerModuleExports()` no-op for "libsd":** LIBSD uses a second, equally real export-table header format the step-5 scanner didn't recognize — magic `0x41C0xxxx` (not `0x41E0`), name at header+12 (not +4), and entries are raw already-relocated 4-byte absolute addresses starting at header+20 (not 2-word `j addr;nop` pairs), terminated by a null word. **Fix:** added a second branch ("Format B") to the step-5 scanner in `irx_loader.cpp` alongside the existing ARKD-style format ("Format A"). - Step budget for LIBSD's pre-run raised back from 50,000 → 5,000,000 now that it's expected to actually return quickly (module_start is ~0x5C bytes / a few dozen instructions). - **Not yet built/tested.** Needs to reach the laptop too if this session is on a different machine — diff spans `PS2Recomp/ps2xRuntime/src/lib/iop/iop_runtime.cpp` and `PS2Recomp/ps2xRuntime/src/lib/iop/irx_loader.cpp`. - **Next:** build, run `boot_test.ps1 -Seconds 30`, check for `[IopRuntime] LIBSD.IRX loaded and initialized (steps=N).` with no `DID NOT RETURN`, a `[IRX] export table (fmtB) lib='Sound_Device_Library' entries=N` line, and check whether ARKD's `libsd` funcIdx 4/5/7/8/9/10/11/17/23/26/27 now resolve to real LIBSD code instead of `[libsd]` HLE fallback prints at runtime. Does not touch the smstt/smrdy boot blocker (separate subsystem) — proceed to that next once this is verified. ## Current Status (2026-06-24) — uncommitted shouldPreemptGuestExecution fix found unverified; new recomp MCP server built (not yet wired in) - Investigation-only session, no code written by Claude. User asked about wiring a live MCP server to `RecompDebugState` shared memory (same protocol `ps2xStudio`'s `RecompBackend` already uses) so Claude can query PC/registers/breakpoints directly instead of via the GUI or polling scripts. - Built `build_scripts/recomp_mcp_server.py` — Python `FastMCP` server wrapping the existing IPC struct (`HEADER_FMT = "pc`) — this is the existing BUG-007 mechanism, not a missing audio feature. Confirmed `fn_18E300` (a memmove-style copy loop in the `fn_421B70`/GameInit chain) has 4 loop back-edges that call this method. - **This fix is uncommitted, and its build/test status is unverified** — asked the user directly whether this working-tree version had been built and boot-tested; they replied "unsure." So the next concrete step is simply: build, then boot_test, then check whether `smstt`/`smrdy` finally advance. - See `BUG_LOG.md` BUG-007 "Update (2026-06-24)" for full detail. - **Next:** `& "F:\SDBZ Recomp\build.ps1"`, then `& "F:\SDBZ Recomp\build_scripts\boot_test.ps1" -Seconds 30`. Check `[VblankTick]` lines for `smstt`/`smrdy` changing from `0xffffffff`/`0`, and check the existing `[BOOT1AF600]`/`[BOOT1A4400]`/`[BOOT18E250]`/`[BOOT2D6800]`/`fn_18E300` diagnostic ENTER/EXIT pairing to see which sub-call (if any) still never returns. ## Current Status (2026-06-23c) — BUG-011 FIXED, blocker reverts to pre-existing smstt/smrdy gate - BUG-011 (`WakeupThread(tid=16666)` spinning 72,213 times) is **fixed and verified**. Manually disassembled ARKD_DVD.IRX at `caller_ra=0x1940C` (module offset `0x940C`): `jal 0x1AEA0` (import stub) with delay slot `addiu $a0, $zero, 16666` — confirmed `16666` is a literal hardcoded in ARKD's own binary, a real-IOP-OS-reserved tid our kernel never allocates. - Fix: `WakeupThread` (`iop_kernel.cpp`, thbase[33]) now checks `m_allocatedTids`; if the target tid was never created via our `CreateThread`, it parks the caller (`m_wakeupParkStop`) instead of looping forever. `StartThread` (thbase[6]) gained a matching park branch saving continuation to `sleepingThreads`. Added `m_wakeupParkStop` bool to `iop_kernel.h` (approved header edit). - Verified via `boot_test.ps1 -Seconds 30` (run_20260623_063700.txt): "No spin detected", zero `WakeupThread tid=16666` log lines. - **Boot now proceeds past BUG-011 and reaches the same pre-existing blocker**: `smstt=0xffffffff`, `smrdy=0`, tid=2 perpetual WAIT at `pc=0x1759a0`, `gamemode=0` — unchanged from the 2026-06-16/06-20 semaphore investigation. BUG-011 was upstream noise, not the root blocker. - Full detail in `BUG_LOG.md` BUG-011 (now marked FIXED). - **Next:** Resume the smstt/smrdy semaphore blocker investigation (see prior "2026-06-20"/"2026-06-16" status entries below and `dword_441A00` gate hypothesis) — disasm fn_11FA60/fn_11E3B0/fn_11E560/fn_11F240 + calltree to find what's supposed to write a valid value into smstt/smrdy. ## Current Status (2026-06-23) — RecompBackend / ps2xStudio debugger tool, NOT Phase 5 boot work - Side-track from Phase 5: wiring `RecompBackend` (attaches to `ps2EntryRunner.exe --debug` via `RecompDebugState` shared memory, `recomp_debug_ipc.h`) into `ps2xStudio`'s existing `IEmulatorBackend` abstraction (alongside `PCSX2Backend`). - `build.ps1 -Studio` switch (added prior session) confirmed working — successfully targets `ps2xStudio.vcxproj` and reaches its own sources. - Fixed a C++ namespace-scoping bug causing C2027/C2737/C2660 build errors in `RecompBackend.cpp`: `RecompBackend.hpp` had `struct RecompDebugState* m_shm;` written *inside* `namespace ps2studio { ... }`, which implicitly forward-declared a separate, perpetually-incomplete `ps2studio::RecompDebugState` shadowing the real global `::RecompDebugState` from `recomp_debug_ipc.h`. Fixed: added `struct RecompDebugState;` forward decl *before* the namespace block in the header, changed the member to `::RecompDebugState* m_shm`, and qualified all 7 `static_cast` casts in `RecompBackend.cpp` as `static_cast<::RecompDebugState*>`. - **Not yet rebuilt/verified** — user has not re-run `& "F:\SDBZ Recomp\build.ps1" -Studio"` since the fix. - **Next:** user runs `& "F:\SDBZ Recomp\build.ps1" -Studio"`, paste output. If clean, test "Attach to Recomp" button against a running `ps2EntryRunner.exe --debug` instance and confirm registers/backend name populate. - This does not change Phase 5 boot-blocker status (still smstt/dword_441A00 unresolved, see 2026-06-21g/2026-06-22d below). ## Current Status (2026-06-21) - Fixed build-blocking `SET_GPR_U32` typo in `sdbzLogFn171F40Stub` (game_overrides.cpp ~1174) — replaced with `ctx->r[31] = _mm_set1_epi32((int32_t)raSaved);`. Build succeeded. - BREAKTHROUGH (2026-06-20): EE now gets past `fn_422630`'s SIF-ack spin loop (`[BOOT422630] #1 EXIT`) — sentinel `0x500854` confirmed written and matched (`[BOOT1BF820] subObj=0x00500854`). - **CLOSED OUT (2026-06-21): `BOOT327F90` "NULL vtable" ctor-failure hypothesis is a false positive — disproven.** The diagnostic (`sdbzLogFn327F90Stub`) flagged 11 deferred-ctor-list objects whose offset+0 word reads 0, assuming offset+0 = vtable pointer. Traced real static-init function `sub_4E68C0` (IDA) and its per-object vtable-setter helper `obj_set_fields_z_261` (@0x1be870): it **explicitly zeroes offset+0** (`*(_DWORD*)a1 = 0;`) — the real type/class tag for these objects lives at offset+20 (`dword_4E7858`/`dword_4FAFC8` etc.), not offset 0. These are plain structs, not C++ objects with a vtable at offset 0. All 11 flagged nodes are correct, expected state. Confirmed independently via real PCSX2: breakpoint at ctorFn `0x421FF0` never fires during a fresh System→Reset boot of the unmodified game (manual jump-to-address in disassembly view also doesn't make it execute) — that code path simply isn't exercised the way the diagnostic assumed. BUG-008 (analyzer function-fold) is unrelated to this chain. **The `[BOOT1BF820] vtbl=0x00000000` / `[dispatch:pc-zero]` symptom is therefore NOT explained by a broken ctor** — root cause for that specific NULL-PC jump is still open. - R3000 interpreter layer (from 2026-06-14) still running: ARKD_DVD.IRX loads at IOP addr 0x10000, entry 0x1A6BC. GIF DMA active. ## BREAKTHROUGH (2026-06-21b): smrdy identified as ADX_Init refcount - `smrdy` (`mem[0x4418E4]`) is **not** an arbitrary flag — it's the refcount written by `ADX_Init` @ `0x11f268` (CRI ADX/Sofdec audio middleware init, IDA-named). Pattern: `if (!dword_4418E4) { ...one-time CRI ADX setup... } return ++dword_4418E4;`. Confirmed via `mcp__ida__get_xrefs_to` + decompile. - `ADX_Init`'s only caller is `module_obj_init_c`/`struct_field_reader_e` @ `0x113fc8`, called from `fn_113F40` (`module_init_register(0)` → struct setup → `struct_field_reader_e(0)` → `module_obj_lazy_init_d()`). - **This is the exact same diagnostic chain already logged in `game_overrides.cpp` (~line 1227) as never firing in the recomp build:** `0x422630 → 0x421ea0 → 0x327f90 → 0x1bf820 → 0x3e0e60 → 0x3e10d0 → 0x3e2e80 → fn_113D30 → fn_113F40 → fn_11F268(ADX_Init)`. - **Revises the 2026-06-20 conclusion** that the movie/logo chain and the smstt/smrdy blocker were unrelated — they are directly connected: smrdy can only become nonzero once `fn_113D30`'s chain runs ADX_Init, which is part of CRI ADX/movie-middleware bring-up. The semaphore-wait thread at `pc=0x1759a0` and the ADX init chain converge on the same `dword_4418E4` cell. - Ruled out BUG-008 (analyzer function-fold) as a cause in this chain: checked all 4 confirmed stall-chain addresses (`fn_11FA60`, `fn_11E3B0`, `fn_11E560`, `fn_11F240`) against IDA function boundaries and `fn_forward_decls.h` — all 4 are correctly emitted as standalone `fn_*` entries, no fold-into-sibling pattern like the `0x1C2580` case. BUG-008 does not recur here; see `BUG_LOG.md`. - **Next:** trace why `fn_113D30`'s chain never executes — likely gated by the same upstream blocker as everything else (probably the SIF/IOP-ack wait), not a separate bug. Check what calls `fn_113D30` and whether that caller itself ever runs in the recomp build. ## BREAKTHROUGH (2026-06-21c): found the actual master gate — single byte at $gp-3514 - Traced `fn_113D30`'s caller chain upward via `mcp__ida__get_xrefs_to`: `fn_113D30` (IDA: `module_obj_init_b`) ← `sub_3E2E80` (state-machine: `a1+48` field, case 13 = call `module_obj_init_b`, case 14 = ADX/movie setup calls, case 15 = teardown) ← `sub_3E10D0` (dispatches via `move_dispatch_by_type(*(a1+18))` vtable lookup, calls vtable+20/+16/+24 — unguarded `jalr`, BUG-008 territory but not yet confirmed live here) ← `loadscreen_tick` @ `0x3e0e60` (IDA name). - **`loadscreen_tick` is gated by a single byte at the top:** disassembly confirms `lbu $v0, -0xDBA($gp); beqz $v0, loc_3E0FB8` — if this byte is 0, the ENTIRE state machine (all the way down through `sub_3E2E80`/`fn_113D30`/`ADX_Init`/smrdy) is skipped every call; function just returns `*(a1+12)` immediately. - Confirmed in the recompiled C++ (`runner/loadscreen_tick_0x3e0e60.cpp` line 50-52, read-only — NOT edited, per "no runner file patches" rule): `READ8(ADD32(GPR_U32(ctx, 28), 4294963782))` — i.e. `mem[gp - 3514]`. `gp` is resolved at runtime, not link time (this binary doesn't bake gp into the offset). - **Actionable:** `gp` is already logged by the existing VblankTick diagnostic (`ps2_runtime.cpp:2129`, `dbgGp`). Next session: read `dbgGp` from a `boot_test.ps1` run, compute `dbgGp - 3514`, and read that byte from RDRAM (via PCSX2 MCP `pcsx2_read_memory` once connected, or add a one-line log in `game_overrides.cpp`) to confirm it's 0 for the whole 30s window. If confirmed 0, this single byte is the master gate blocking the entire ADX_Init/smrdy/movie-init chain — find what's supposed to set it (likely an unreached SIF/IOP-ack-dependent init routine, consistent with every other Phase 5 blocker found so far). ## CORRECTION (2026-06-21d): gate-byte hypothesis disproven; ADX_Init chain dead-ended at a sentinel, not a bug - Got a **live DebugServer connection to the running recomp build** (`pcsx2_connect`, port 21512) and traced this empirically instead of via static analysis — much faster, no build/run cycle needed. - **Gate byte at `gp-3514` (resolved live address `0x5022B6`) reads `0x01`, not `0`.** The 2026-06-21c "master gate" hypothesis is **disproven** — the gate is open. `loadscreen_tick` (`0x3e0e60`) executes every frame without being short-circuited. - Confirmed `loadscreen_tick` → `sub_3E10D0` (`0x3e10d0`) IS reached every call (breakpoint hit immediately, repeatedly). - Inside `sub_3E10D0`, traced the actual branch logic via live disasm: `lbu v0,8(a0); andi v0,4; beqz v0,->0x3E10F8` (a0+8 bit2 clear → proceeds) → calls `move_dispatch_by_type(a0+18)` at `0x3F2F90` with arg `0x33` → checks return `v0` at `0x3E1100`; if null, skips the entire vtable dispatch (and therefore `sub_3E2E80`/`fn_113D30`/`ADX_Init`) every time. - **Confirmed `v0` is permanently `0x00000000` across thousands of frames (~5M cycles apart, sampled twice, both null)** — not a one-frame race. - **Root cause of the null, found via decompiling `move_dispatch_by_type` (`0x3f2f90`) in IDA:** `if (a1 >= 0x33) result = 0; else result = dword_625660[a1]();`. The dispatch table `dword_625660` only has valid entries for indices `0x00`-`0x32` (51 entries) — confirmed by reading the live table out of RDRAM (55 `u32` entries dumped, index `0x33` itself holds `0x00000000` too). **Index `0x33` is structurally out-of-range/a sentinel, not a "not yet initialized" slot.** This bounds check is correctly rejecting an invalid index, not gating on upstream init state. - **Conclusion: this specific path (`loadscreen_tick`→`sub_3E10D0`→`move_dispatch_by_type(0x33)`) is very likely a dead end / red herring for the smstt/smrdy blocker.** The object at `0x6330D0` requesting dispatch type `0x33` is either (a) intentionally requesting "no dispatch" as a sentinel — normal behavior, OR (b) has a corrupted/wrong type field — but either way this is NOT gated by an unreached upstream init routine the way every other Phase 5 blocker has been; the table and the bounds check are both self-consistent and match what IDA shows for the original game logic. - **The full caller chain documented in 2026-06-21b/c (`loadscreen_tick→sub_3E10D0→sub_3E2E80→fn_113D30→ADX_Init`) needs to be treated as unconfirmed/likely wrong** — `sub_3E2E80` and everything downstream of it was never actually observed executing in this live session; it was inferred from static xrefs without confirming the dispatch ever resolves non-null. ADX_Init may be reached via a completely different caller/path, or smrdy may not even depend on this loadscreen object at all. - **Next session:** Do NOT continue down this `sub_3E10D0`/type-0x33 path — it's resolved as a sentinel, not a blocker. Instead: (1) re-verify smrdy's *actual* call path live (set a breakpoint directly on `fn_11F268`/`ADX_Init` itself and `continue` from boot, see what really calls it, if anything, in a live run — faster and more reliable than static xref chasing); (2) if `ADX_Init` breakpoint never fires across a full boot_test-equivalent live run, that's the real confirmation smrdy never advances, and the next step is tracing backward from there with live breakpoints instead of static IDA xrefs-only chains. - **New technique validated this session:** live PCSX2 DebugServer MCP connection to the *recomp build itself* (not just real PCSX2) is available and working (`pcsx2_connect` → port 21512). This lets us set breakpoints, read registers/memory, and step through the actual recomp boot in real time — much faster than the static-IDA-trace-then-build-then-log-grep cycle used throughout Phase 5 so far. Use this going forward for chain-tracing instead of pure static analysis. ## SUPERSEDED (2026-06-21e, see 2026-06-21f): initial "ADX_Init never fires" claim was a single-run false negative - First continuous run (>2.9B cycles, no pause) never hit a breakpoint at `0x11f268`. Concluded ADX_Init was unreachable. **This was wrong** — see below, a later run on the same session hit it directly. The boot path is apparently non-deterministic run-to-run (timing-sensitive dispatch through `move_dispatch_by_type`, consistent with the 2026-06-21d sentinel finding being state-dependent, not permanent). Do not trust a single non-hit as proof of unreachability for this codepath again — confirm with multiple runs or an explicit caller-side breakpoint instead. ## CONFIRMED (2026-06-21f): ADX_Init DOES run live; smrdy advances to 1; smstt is a SEPARATE cell, still invalid — that's the real blocker - Live breakpoint at `0x11f268` fired on a fresh run. Backtrace confirmed the exact chain hypothesized in 2026-06-21b: `loadscreen_tick` (`0x3e0e60`) → `fn_113F40` (`0x113f40`) → `ADX_Init` (`0x11f268`). - Before the call: `smrdy` (`mem[0x4418E4]`) = `0`. Set a temporary breakpoint at the return address (`0x113fd0`) and continued — **`smrdy` becomes `1` after ADX_Init returns**, exactly matching the decompile (`return ++dword_4418E4;`). - **`smstt` (`mem[0x4418D0]`) is unaffected — stays `0xffffffff` the entire time.** Confirmed via direct read immediately after the smrdy change. - Continued execution further: PC eventually settles back at `0x175220` (the long-documented spin-loop address) even with `smrdy=1`. **This proves `smrdy` and `smstt` are independent cells that do NOT interact** — the 2026-06-21b note claiming they "converge on the same cell" is wrong. `smstt` lives 0x14 bytes before `smrdy` in the same struct/region but is written by something else entirely (ADX_Init's decompile never touches `dword_4418D0`). - Thread snapshot mid-investigation showed `TID 2` at `PC=0x174ce8, status=4, waitType=2` (not the previously-documented `0x1759a0`) — the boot does walk through more states than previously captured before re-converging on the same ultimate spin point. Not yet mapped to a specific function/wait call. - **Revised conclusion:** smrdy was never the real blocker — it's a CRI ADX refcount that increments fine once its caller chain (which does run, intermittently/timing-dependently) executes. The actual stall is entirely about `smstt` (`0x4418D0`) never receiving a valid semaphore ID. ADX_Init and its whole call chain are confirmed irrelevant to the real fix. - **Next:** Find what's *supposed* to write a valid value into `smstt`/`mem[0x4418D0]` — this is a distinct, never-yet-traced data flow from the ADX_Init/smrdy chain. Start by getting `mcp__ida__get_xrefs_to_field` or static xrefs on `dword_4418D0` specifically (not `dword_4418E4`), since all prior tracing conflated the two cells. Also revisit `fn_11FA60`→`fn_11E3B0`→`fn_11E560`→`fn_11F240` (2026-06-21b chain) with live breakpoints to see which of those actually execute and whether any of them touch `0x4418D0` specifically. ## CONFIRMED (2026-06-21g): smstt's only writer is gated behind a never-written function-pointer slot, dword_441A00 - Static `mcp__ida__get_xrefs_to` on `dword_4418D0` (smstt) found only 3 hits, all inside the BUG-006/007 chain region: `wrap_noop_wrapper_k_2`/`noop_wrapper_k_2` (`0x11e310`) is the **only writer** anywhere in the binary — `dword_4418D0 = a1;`. `noop_wrapper_unk_x` (`0x11e328`) and `noop_wrapper_unk_c` (`0x11e3b8`) only *read* it via `noop_wrapper_unk_y(dword_4418D0)`. - Traced callers of `noop_wrapper_k_2` (`0x11e310`): exactly two, `sub_120220` and `sub_1202A0` (both unreferenced by anything else — see below). - `sub_120220`: calls `dat_callback_dispatch_f_clone_01()` to get a value, then `wrap_noop_wrapper_k_2(v4)` — writes a **real/valid** id into smstt. This is the "real init" path. - `sub_1202A0`: calls `wrap_noop_wrapper_k_2(-1)` unconditionally — writes `0xffffffff`, i.e. **this is literally the function that produces the broken/invalid smstt value** we've been chasing since BUG-006. - **`get_xrefs_to` on both `sub_120220` and `sub_1202A0` returns empty — zero static references anywhere in the binary.** Neither is called via any visible `jal`/`jalr` or data reference IDA can see. They must be reached through a computed/indirect mechanism IDA's static pass doesn't resolve. - Traced one level further via the live BUG-006/007 dispatch chain (`sub_11FA60` decompiled live): at the very end, `sub_11FA60` does `if (dword_441A00) return ((fnptr)dword_441A00)(a1); else return 0;` — a function-pointer slot read directly from memory. - **Live memory read confirms `dword_441A00` (`mem[0x441A00]`) = `0x00000000` during the active stall.** Since it's null, this final dispatch call never happens — `sub_11FA60` just returns 0 every time, every frame. - **`get_xrefs_to` on `0x441A00` (the slot) finds exactly one hit — and it's a *read*, inside `sub_11FA60` itself.** There is no static write site anywhere in the binary for this slot. Whatever is supposed to populate it does so through a path completely invisible to IDA's static analysis — almost certainly a SIF RPC/IOP-side callback registration writing directly into EE memory (consistent with the pre-existing SIF boot-handshake hypothesis in `reference_ps2_sif_boot.md`), not a plain EE-side function call. - Also confirmed live: `dword_441980`, `dword_441984`, `dword_44197c` (the three values fed to `array_state_dispatch`/`sub_11ECD8` earlier in `sub_11FA60`) are all `0` — `array_state_dispatch` no-ops on a null/zero handle (`if (a1) {...} return 0;`), so those three calls are also dead weight right now, separate from the `dword_441A00` issue. - **Conclusion:** The real boot blocker is now narrowed to a single concrete question: **what is supposed to write a non-null function pointer into `dword_441A00` (`mem[0x441A00]`)?** That write, once it happens, would call through to (most likely) `sub_120220` — which is the only path that writes a *valid* value into smstt instead of the `-1` sentinel from `sub_1202A0`. - **CONFIRMED LIVE (2026-06-21g, same session):** Armed a write-watchpoint on `0x441A00` and let the boot free-run (stale breakpoints at `0x11f268`/`0x113fd0` from the earlier ADX_Init trace were cleared first so they wouldn't intercept). Across ~3B cycles of free execution — ending with the EE settled back into the known spin loop at `0x175220` — **the watchpoint recorded 0 hits.** `dword_441A00` is never written during boot. This empirically confirms the static finding: whatever is supposed to populate this function-pointer slot (and thereby unlock `sub_120220`'s valid-smstt write path) sits on a dead/unreached code path on this boot. - **Next:** The watchpoint approach is validated — re-run it across a fresh boot (not just one session) to rule out the same non-determinism seen with ADX_Init (2026-06-21e/f), in case `0x441A00`'s writer is also timing-sensitive rather than fully dead. If it stays at 0 hits across multiple fresh runs, pivot to finding *what's supposed to call whatever writes `0x441A00`* — most likely a SIF RPC/IOP-side callback registration (consistent with `reference_ps2_sif_boot.md`), so check the SIF RPC bind-table setup path next, not more EE-side static xref chasing (IDA can't see this write at all, confirmed by `get_xrefs_to` returning only the read site). ## CONFIRMED (2026-06-22): new diagnostic thread — sub_327810 (Tick) state machine stuck at state 1, gated by RPC-handle validity, not by ccdReply content - Added `nextObjState` diagnostic (`mem[0x5E5920+4]`) to `[VblankTick]` in `Interrupt.cpp` to check whether the boot state machine `sub_327810` (entry traced via `BOOT1BF820`: `nextObj=0x5e5920`, `Tick=0x327810`) is progressing. **Result: frozen at `1` across the entire 30s run** (v=120 through v=1740) — proves `Tick()` runs every vblank but cannot pass its state-1 exit condition. - Decompiled `sub_327810` (IDA): state 1's exit condition is `if (!wrap_noop_wrapper_unk_z_z_196()) goto LABEL_63;` (stay at state 1) else advance. - Traced the gate: `wrap_noop_wrapper_unk_z_z_196` (`0x1bff40`) → `noop_wrapper_unk_z_z_196` (`0x1c0a30`) — polls 4 SIF RPC client handles via `wrap_rpc_handle_valid()`, sends an RPC (`mem_fill_z_369`), then checks `mem[0x5AA7D0]` against `0x4xxxxxxx` (not-found, exits true) or exactly `0x30000000` (ready, exits true). - String xref confirms this is a **CD Table-of-Contents read completion poll**: `0x4c2d10` = `"CCDReadObj::IsReady TOC not found"`. - Traced the RPC issuer: `reg_save_stub_z_174`/`sub_1BCEE0` (real start `0x1bcee0`) binds 4 RPC clients (`clientDataNum` 1280-1283) then calls `mem_fill_z_369(dword_5A9358, 0, 3, dword_5A97D0, 0, dword_5AA7D0, ...)` — funcnum 0, reply buffer = the exact address being polled. - Added `ccdReply` diagnostic (raw `mem[0x5AA7D0]`) to `[VblankTick]`. **Result: `ccdReply=0x30000000` for the entire run** — this is one of the two values `noop_wrapper_unk_z_z_196` treats as a PASS. Yet `nextObjState` never advances past 1. - **Conclusion: the CD-TOC reply content is fine — the gate must be failing earlier, on `wrap_rpc_handle_valid()` for one or more of the 4 client handles (`dword_5A9330`, `dword_5A9358`, `dword_5A9380`, `dword_5A93A8`), before the function ever reaches the `ccdReply` check.** - Decompiled `rpc_handle_valid` (`0x178de8`): `v1 = *a1; return v1 && a1[1] == *(u32*)(v1+24) && (*(u32*)(v1+16) & 1);` — three conditions per handle (non-null backing struct pointer, an id match, and a ready-bit). - Added a 4-bit `rpcValid=` diagnostic to `[VblankTick]` in `Interrupt.cpp`, evaluating that same condition for all 4 client-handle addresses. **Built but not yet run** — this is the literal next checkpoint. **Next:** Build + run via `dev_cycle.ps1 -ChainDiag`, read `rpcValid=` (4 digits, one per handle in order 0x5A9330/0x5A9358/0x5A9380/0x5A93A8) from the raw `[VblankTick]` log lines (NOT the dev_cycle.ps1 trend table — it doesn't parse this field). Any `0` digit identifies which client handle is failing and which sub-condition (null backing ptr / id mismatch / ready-bit clear) to chase next — likely ties back into the same SIF RPC bind-table setup path already flagged as the leading hypothesis for the `dword_441A00` blocker above. ## Review Session (2026-06-22d) — no new findings Pure review/verification pass: re-read `HANDOFF_2026-06-22.md`/`b`/`c`, confirmed the `dword_441A00` thread (2026-06-21g) and the `BUG-009 rpcValid` thread (2026-06-22) are still the two open leads and are hypothesized — not yet proven — to share one root cause (incomplete SIF RPC client bind/callback registration). No build, no run, no PCSX2 session this pass. Wrote `Handoffs/DISCORD_UPDATE_2026-06-22.md` consolidating every open blocker for an upstream Discord ask. `rpcValid=` diagnostic (built, not yet run per above) and the `0x441A00` watchpoint re-verification (per 2026-06-21g) remain the actual next actionable steps. ## Carried-Over Open Thread (2026-06-17, corrected/advanced 2026-06-28 — now the active boot-blocker frontier) **complist=0x0 → black screen.** `complist` at `gameObj+472` is null for the full 30s test → component iterator `0x18d348` exits immediately → no render callbacks. A fresh 2026-06-28 `boot_test.ps1` run confirmed this is the current real blocker (BUG-015's `pc=1` crash and the old smstt/smrdy gate are NOT reached/NOT the active issue in this boot path — see `BUG_LOG.md` BUG-015 2026-06-28 update). **Corrected via live `pcsx2_connect` trace (2026-06-28):** lazy-init wrapper is at **`0x175268`** (off-by-4 from the original `0x175264` guess), gates on flag `mem[0x461B60]`. It calls `0x176640` only while the flag is 0, and only commits `flag=1` if `0x176640` returns nonzero. `0x176640` is NOT a SIF caller — it calls `FlushCache` (harmless) then a **Deci2Open-shaped call** (`Deci2Call`, syscall 0x7C, code=1, protocol arg `0x210`) that returns a raw negative result (`-3` observed live); `0x176640` converts any negative result to a final `0` ("failed"), so the wrapper's flag never commits and it retries forever. **Revises the 2026-06-17 "SIF bind-table" hypothesis — the actual mechanism is a Deci2/debug-channel open failing repeatedly, not a SIF RPC/bind issue** (the `0x1763f8` SIF-caller lead from the original note was not reached/relevant in this trace). **Next:** breakpoint `Deci2Call`'s entry directly (read `$a0`/`$a1` to confirm code/args truly decode as `code=1, protocol=0x210`) to determine why `allocateDeci2Socket()` (which has no validation and should always succeed) is producing a negative result — or whether `_DEBUG`/`RUNTIME_DECI2CALL` excludes this code path in the actual binary and a stale register is being misread. Also still unconfirmed: whether `0x461B60` is actually upstream of `complist`/`gameObj+472` at all (inferred 2026-06-17, never independently re-verified) — worth checking via xrefs/live trace before sinking more time into the Deci2 angle specifically. **Update (2026-06-29) — VS Code native debugging confirms `_DEBUG`/`RUNTIME_DECI2CALL` code path IS live and Deci2Call's default handler works correctly, but for a different call site than this thread's `0x176640`.** Used VS Code's plain `cppvsdbg` "Launch ps2EntryRunner" config (not `pcsx2_connect`) with breakpoints set directly in `Deci2.cpp` (line 139, inside `case 1:`) and `System.cpp` (line 419, `g_syscall_overrides.find()`). Confirmed: (1) no `SetSyscall` override registered for syscall `0x7C`, so every Deci2 call hits the built-in default handler; (2) for the call actually caught — made by `fn_1759A0` (the tid=2 thread, running on its own real `std::thread` via `StartThread`'s lambda) calling `fn_174BD0` → `Deci2Call` code=1 — `allocateDeci2Socket()` returned `socket=1`, a clean success. This rules out "SetSyscall override breaks Deci2Open" but does **not** settle the `0x176640`/protocol=`0x210` call site's `-3` result, since this was a different caller. **Next:** repeat with a breakpoint conditioned on `protocol == 0x210` (or caller `$ra` near `0x176680`) to catch the specific `0x176640` call and read its raw args/result directly — see `BUG_LOG.md` BUG-015 2026-06-29 update for full detail. Also worth noting as a reusable technique: VS Code's native debugger gives full C++ symbol resolution (real function/variable names) for `ps2EntryRunner.exe` directly, no MIPS-register decoding needed — useful for narrow code-level questions even though breakpoints must be placed manually in the editor UI (no scripting). The `mcp__recomp__*` IPC tools stay stale/frozen when launched this way (they need `launch_debugger.ps1 --debug` instead). ## BUG_LOG.md Cross-Reference `F:\SDBZ Recomp\BUG_LOG.md` tracks numbered bugs for upstream (BUG-001 through BUG-008). BUG-006/007 = this smstt/smrdy boot stall. BUG-008 = unguarded `jalr`/vtable dispatch + analyzer function-boundary-fold bug (confirmed live at `0x1C2580`, ruled OUT of the `fn_11FA60`→`fn_11E3B0`→`fn_11E560`→`fn_11F240` chain specifically — see `Handoffs\HANDOFF_2026-06-21.md`). ## Pending Build (game_overrides.cpp) Five EE functions were generated as 1-instruction stubs with no `jr $ra` due to sub-block truncation. Fixed with native C++ overrides: - fn_104bf0 (0x104bf0) - fn_1a4500 (0x1a4500) - fn_1bf2e0 (0x1bf2e0) - fn_22c8f0 (0x22c8f0) - fn_22cbe0 (0x22cbe0) ## Pending Build (iop_kernel.cpp) registerLibsd() added — implements ARKD_DVD.IRX's libsd imports ## Prior Fixes (built, committed) - thsemap index ordering (6=SignalSema, 7=iSignalSema, 8=WaitSema) - WakeupThread wokenSet removal - count-based thsemap semaphores - stub[37] pre-signals all semas - sceCdSeek LBN tracking - sceCdPosToInt mode fix - processPendingRpc 50M budget - fn_180D30, fn_17F5D0, fn_18D470 fixed ## Boot Test Command ``` & "F:\SDBZ Recomp\build_scripts\boot_test.ps1" -Seconds 30 ``` ## What to Verify After Next Build - Multiple VblankTick lines - mainupd non-zero - fnptr non-zero - WaitSema block/wake once - rpc=0x001 WARNING gone ## Learned Patterns ### 2026-08-24 - **★★★ A conditionally-gated write DECOUPLES the call count from the stored value by design — comparing them is not evidence of anything.** I traced 14,192 `svm_lock`/`svm_unlock` dispatches, found the running balance never went negative, saw the counter at `0x45EFC0` sitting at -2, and called it "an airtight contradiction". It is not a contradiction at all: `svm_lock` does `beq $v1,$zero,` on the null hook and **skips the increment entirely**, so every call made while the hook is null contributes zero. Net +18 calls with a -2 counter is exactly what correct code produces. **When the write is behind a gate, the only balance the counter tracks is the balance inside the gate-open windows** — so measure the gate transitions first, or you are comparing two quantities the hardware never claimed were equal. - **★★★ The word "airtight" is a tell. Grep your own draft for it.** Every load-bearing noun in that report — "exactly two writers", "reachable only via", "can only come from", "nothing ever re-zeros" — was a static-analysis *coverage result* re-typed as a *fact about the binary*. [[feedback_no_guessing]] catches these only if you actually re-read the draft looking for absolute quantifiers. **Absolute quantifiers ("only", "exactly", "never", "no others") are the audit targets; each one must name the tool whose coverage it rests on.** - **★★ Applying a caveat in one paragraph and ignoring it in the next is the commonest form of this failure.** The same 24h report correctly wrote "types 3/4/5/1000 are not confirmed dispatch-visible, so this is not proof of absence ([[feedback_registerfunction_bypass]])" — and then, six lines earlier, treated the type-1/2 hit counts as a **complete** call census. Same trace, same blind spot, opposite treatment. **A caveat stated about one row of a table applies to every row of that table.** - **★★ `eeref`'s `ptr=0 gp=0 imm=N` means "eeref found N", never "there are N".** `refs 0x45efc0` returns `IMM` hits, i.e. `lui`/`addiu` address-formations. A write through a register-held pointer — a `memset` over a range, a struct field store — produces no `IMM` and is **invisible**. In a hook/function-pointer system, indirect access is the expected case, so this is the blind spot most likely to be load-bearing ([[project_eeref_static_xref]]). - **★ An address that "obviously" belongs to one subsystem may be shared infrastructure.** I modelled `0x13bc10`/`0x13bc28` as an SVM-private lock/unlock pair. `eeref refs` says **17 and 19 callers** — `VU0_RunMicro`, `sub_12EB28`, `sub_12F4A8`, and more — and the sets are **asymmetric** (two callers issue unlock twice against one lock, the early-return pattern). **Run `eeref refs` on the wrapper before assuming the wrapper is yours**, or you inherit a balance guarantee that was never there ([[feedback_never_convict_by_count_with_two_callers]]). ### 2026-08-19 - **★★★ A high call count refutes "it is asleep" outright — measure the count before building the theory.** Stage 5.16 spent a full cycle on "the CRI server thread never wakes". One coverage census killed it: `0x130c48` = **727 calls**, 354 `sceCdRead` out, 354 RPC completions back. The thread was running the whole time; the *status byte* simply never reached DONE. **Before hypothesising about scheduling, ask whether the code ran at all** — `PS2_COVERAGE=1` answers it for zero build cost and would have saved the cycle. - **★★ Enumerate the outcomes from the code's tail, not from the two you can imagine.** The read path was framed as a 2-way question (status 9 = error vs 3 = done) through several runs. `sub_12EE20`'s actual tail is `sync = sceCdSync(1); chk = sub_12ECC0(obj); if (!sync) obj[2] = chk ? 9 : 3;` — so there is a **third** arm: `sync != 0` leaves the byte at **2 forever**, and that arm has a different fix in a different file. **A decision table built from imagination silently assigns the unlisted outcome to whichever listed one it resembles.** - **★★ A short-circuit gate makes "checked and clean" indistinguishable from "never checked".** `sub_12ECC0` returns `0` early whenever `dword_44D27C` is 0 — *without ever calling `sceCdGetError`*. A probe reading only its return value would have reported "no drive error" with full confidence in the exact case where nothing was asked. **Log the gate alongside the verdict, always ([[feedback_probe_gate_on_shape_not_address]])** — the `[srd]` probe emits `gate44d27c=` on every line for this reason. - **★ A wrapper on an outer function cannot report its callees' return values unless the inner wrappers relay them — and the relay must be cleared *before* the call.** `sceCdSync` and `sub_12ECC0` both execute **inside** `sub_12EE20`, so their results reach the adjudicating log line only through `g_srdLastSync`/`g_srdLastChk`. Clearing those after the call, or not at all, would let a previous tick's value be reported as this tick's — a stale-but-plausible number, the worst kind ([[feedback_probe_the_final_value]]). ### 2026-08-16 - **★★★ A backup filename that inserts its suffix *before* the extension stays inside the glob.** `register_functions.cpp` → `register_functions.stub-bak.cpp` still matches `src/runner/*.cpp`, so the file was still declared **and still compiled** — the generator emitted `void register_functions.stub-bak(...)` (a `.` and a `-` in an identifier) into the auto-generated `fn_forward_decls.h`. The safe form appends after the extension: `register_functions.cpp.stub-bak`, which is exactly what `output/register_functions.cpp.legacy-bak` had been doing correctly since Jun 14. **Rename a file out of the pattern, not around it.** - **★★ Renaming/restoring one copy of a glob-synced file is half a fix.** `build.ps1:108` syncs `output/` → `src/runner/` on **newer-wins**, so the stale backup in `output/` (Aug 15) would have re-seeded itself over the good file (Jul 23) on the next build. **When a build step mirrors two directories, every file mutation is a two-directory mutation.** - **★★ `/FORCE:MULTIPLE` converts a duplicate-symbol *error* into a silent wrong-answer.** Had the bad backup compiled, it would have defined `g_ps2RecompiledFunctionTable*` a second time — all `nullptr` — and the linker was free to pick it. The build log would have been clean and the game would have hung at `0x100008` exactly as before. **On a `/FORCE`-linked target, treat "it linked" as no evidence at all about symbol identity; verify by artifact size or by a runtime read of the symbol.** - **★★ A liveness counter that reads zero on a healthy run is not a liveness counter.** I named `cov=` as the pass/fail line before the run; the run came back `cov=0/0` with the game plainly executing. The signals that actually separated the broken runs from the good one were `busy%` / `gif/s` / `dma/s` / `progress`. **Pick the indicator from a known-good run, not from its name** — same failure mode as [[feedback_probe_gate_on_shape_not_address]]. - **★ Swapping out a subsystem silently retires the probes that lived inside it.** Dropping `ps2_gs_rasterizer.cpp` for upstream's GS left `[drawpath]` **write-only**: VIF1 still stamps `g_curSite`/`g_curSrc` from 8 sites, but nothing reads them. No error, no warning — the tag simply never appears. **After any subsystem swap, enumerate which probes had their reader on the removed side.** ### 2026-08-10 - **★★ A host-side backend that is fully wired can still be dead code.** `ps2_pad.cpp` had a complete keyboard + USB gamepad map, and reading it said "X and Space should work." They never could: the map terminates at `Kernel/Stubs/Pad.cpp`, and **SDBZ statically links its own libpad**, so the SDK stub layer is never entered for this game. **Before answering "is input mapped?", establish which of the two paths — SDK stub or guest-linked library — the game actually takes.** Same disease as reading an emitted expression to verify a translation ([[feedback_verify_translations_by_decoding]]): the code was correct and irrelevant. - **★★ When a game links its own SDK library, the integration point is the library's *memory layout*, not its call sites.** There is no function to hook: `jal` into guest libpad compiles to a direct C++ `fn_` call that bypasses `registerFunction`, and the hardware's own producer (padman) pushes asynchronously on vsync, so there is no per-frame guest call to intercept either. The only durable seam is the buffer both sides agree on — so the offsets have to be **decoded from the guest's decompiled library**, field by field, and every one becomes a load-bearing constant. - **★ Two-field state machines fail closed on the field you didn't write.** `scePadGetState` returns STABLE only when `state==6` **and** `reqState==0`; portopen leaves `reqState=2`, and `6 + 2` is special-cased to "busy". Writing the field that obviously means "ready" would have produced a run that looked exactly like no fix at all. **Read the consumer's full predicate, not the field whose name matches your intent** — the generalised form of the 2026-08-01 "read the consumer, not just the callee" entry. - **★ A strictly-less comparison in a double-buffer selector makes ties non-neutral.** `0x188320` picks half 1 only when `c0 < c1`, so equal counters always select half 0. A publisher that sets its counter *equal* to the other half would silently never be seen. Fill the stale half, then publish `max(c0,c1)+1` **last**, behind a release fence — the guest EE is a different host thread. - **Third misleading-label occurrence, now a standing expectation.** Every one of the nine libpad functions carries an auto-derived name (`mem_compare_unk_e` = `scePadRead`, `mem_fill_z_24` = the half-picker) that is an SDK-signature false positive. After `rpc_handle_valid`, the Stage 5.11 render path, and now all of libpad: **treat a `mem_*`/`fn_*` name in the decompile dump as noise by default**, and identify by body. ### 2026-08-07 - **★★ A near-zero bucket is the strongest evidence in a three-way split.** `[blackwho]` gave `untex=13.76M texzero=15.76M texcol=19,860`. The two big numbers were ambiguous; the *small* one was decisive — `texcol≈0` exonerated blending, TEXFUNC and FBMSK outright, collapsing the search space in one read. **When instrumenting a "who did it" question, always include the bucket you expect to be empty.** Its emptiness is what makes the others interpretable. - **★★ A "last event" snapshot is owned by whatever is most frequent, not by what you care about.** `[blackwho]`'s `snap*` fields are overwritten by *every* black store, and the full-screen clear always lands last in an interval — so the snapshot permanently reports `prim=0x6` (TME=0, ABE=0) and describes the **clear**, never the offender. A one-slot snapshot next to a counter is a trap unless the store is **gated to the same branch as the counter you are reading**. The `[texzero]` probe fixes this by capturing only on the texzero branch. Same family as [[feedback_probe_the_final_value]] and [[feedback_capped_probes_false_negatives]]: the field is populated, plausible, and about the wrong thing. - **★ A non-reproducing observation is unrecoverable if the log path is fixed.** Run 10's `nonblack=64664` never recurred, and `launch_recomp.ps1` writes one fixed `-Log` path with **no archiving**, so that log is gone. Under `-Determinism 0`, a one-off worth building a hypothesis on must be **archived at the moment it is seen**. (Not fixed this session — noted so the next agent copies `run_log.txt` aside before re-running.) - **★ Judge a build by target topology, not by the presence of `LNK1120`.** `iop_harness.exe` and `ps2x_tests.exe` fail to link `fn_*` runner symbols **by construction** — they link `ps2_runtime.lib` but not the 30,000 runner objects. The only question that matters is whether any failure names `ps2EntryRunner.vcxproj`. **Confirm a probe actually shipped with a monotonic source→obj→lib→exe mtime chain**, not by obj age against wall-clock — 6:14 AM objs look "stale" at 11 AM and are not. ### 2026-08-01 (later — the ARKD completion-path session) - **★★ Any fallthrough that fabricates a return value must name itself.** The IOP import lambda returned `$v0 = 0` for every unhandled `(lib, fid)`. For a boolean-success API that reads as *failed*, and the IRX idiom is `while (!x) x = api(...)` — so **one unlisted ordinal is an infinite spin that consumes the entire interpreter budget and is indistinguishable in the log from "the handler hung."** That is exactly what `sifcmd fid=12` (`sceSifSendCmd`) did to the state-11 audio call, and it cost a session. This is the same disease as a silent probe cap ([[feedback_capped_probes_false_negatives]]) one layer down: a *silent default* manufacturing a zero that reads as data. Fixed generally, not specifically — `[iop:unhandled]` censuses every defaulted import, announces the first hit, and dumps the full census whenever a service run fails to halt (i.e. precisely when something is spinning). - **★ Identify an IOP import by its CALL SITE, not by an SDK ordinal table.** `sifcmd fid=12` was pinned by matching the live `[iop:import]` args (`a0=0x80000001 a1=0x53a30 a2=0x18`) against the verbatim decompiled loop in `irx_arkddvd_sub_A3C0`. Guessing semantics from an ordinal list is how this project produced five misleading-name retractions. **Corollary — a deliberate non-action:** `thsemap fid=6/8` and `sifman fid=7/8` fall through the same way and were **left alone**, because their real semantics may already be "return 0 = success". Building the census that will *name* them from live evidence is worth more than four plausible guesses. - **`retired == the budget literal` + `halted=0` means the zeros in that record are uninitialised, not measured.** `sid=0x501 fno=0x101 -> retired=4000000 halted=0 $v0=0 rsz=0 delivered=0` was read as "the reply is empty" for a full session. It was "the handler never returned" — `$v0`/`rsz`/`delivered` are zero *by construction* when the halt sentinel is never reached. **Before interpreting any output field, check the loop-exit flag that says whether the producer finished.** A round number equal to a literal in the source is the tell. - **A "status table" at a fixed address may be a standard SDK array the guest installed itself.** `0x5618B0` was described as "entry 12 of a bespoke async status table" for several sessions. It is `_sif_sreg[12]` — EE libsifcmd's own array, base `dword_561880`, installed by the guest's `sceSifInitCmd`. Once named correctly, the reader (`0x177AB8`), the writer (`0x177AD0`), and the SET_SREG handler (`0x177A88`) all fell out immediately, and with them the reason our host-side `g_sifSregs` write landed nowhere. **When an address resists explanation, try to match it to a documented SDK structure before inventing a bespoke one for it.** - **"No scheduler" is a load-bearing fact, not a caveat.** `thbase` CreateThread only *records* and StartThread does nothing, so **every thread body the IRX creates is dead code unless something explicitly ticks it.** Three of the four breaks in this blocker were just that: a worker, a publisher, and an init routine that were all correctly created and never once executed. When an IRX subsystem "does nothing," check whether anything runs its threads before looking for a bug in them. ### 2026-08-01 - **★ A function's name in `funcmap` / `studio_callgraph.csv` is a GUESS, not a symbol.** These names were auto-derived, and a wrong one is worse than no name because it silently seeds a hypothesis. `rpc_handle_valid` (`0x178de8`) is actually `sceSifCheckStatRpc` — it means **`is_busy`**, the *inverse* of what the name implies. Four sessions and two retracted fixes were spent trying to make a busy-check return "valid". **Before building any hypothesis on a named function, confirm its semantics from the decompiled body (`ida_scripts/decompiles_SLUS_214_42.txt`) or from ps2sdk.** One read of the caller (`sub_1C0A30`: `while (wrap_rpc_handle_valid(c)) ;`) would have settled it in minutes — a *busy*-check in a `while` with an empty body is a spin-until-idle, so returning 0 is the passing answer. - **Read the consumer, not just the callee.** The same return value can be a gate or a pass depending on how the caller uses it. Both consumers here inverted the naive reading. Enumerate call sites and read the branch before deciding what a return value gates. - **When six retractions have accumulated, the problem is the method, not the hypothesis.** Every one of them came from inferring semantics from our own log in a vacuum while a **real PCSX2 with the real ISO and a full MCP toolchain** sat unused. Differencing against the reference machine is not a last resort — it is the cheapest measurement available (PCSX2 reaches the memory-card prompt in 7–8 s). - **An instrument that exists but is not armed is not evidence.** `PS2_COVERAGE` answers "which game-band addresses does the EE actually dispatch" directly and has been built and documented since July; it has not been armed since 2026-07-29 (`cov=0/0`). Sessions were instead spent hand-reading 2800-line generated `switch` bodies to answer the same question worse. - **`pc == ra` does NOT imply a loop** (co-piolet, 2026-07-29). In this runtime the dispatch/fiber loop parks `ctx->pc` at the return address of the `jal` it is currently inside, so at any `jal` boundary `pc` and `ra` read the same value. `0x421f10` looked like a "tight self-loop" for weeks on exactly this evidence and is in fact `jal sub_00199840`, call 10 of 14 in a per-frame subsystem fan-out. Before claiming a spin, get the actual instruction, and get the function's extent from `Logs\studio_callgraph.csv` — **`CSV Map\map.csv` boundaries are stale** (it gives `sub_00421EA0` as 0xA4 bytes; the real body runs to `0x422168`). - **`stuckSecs` is a false positive whenever `progress` is climbing.** It keys off `pc` stability, and `pc` is stable by construction in a steady frame loop. Trust `progress` / `bsschg` for liveness; ~~`gstate`~~ **do not trust `gstate` alone — see next entry.** - **`gstate@ADDR` is a raw 4-word memory dump, not a struct read** (co-piolet, 2026-07-29). The watchdog's `gstate=` field reads whatever 4 consecutive 32-bit words sit at the watch address with zero knowledge of layout. At the address used this session, `0x50227c`, those 4 words are **static singleton object pointers** (`piRam0050227c`, `iRam00502280`, `iRam00502284`, `iRam00502288`) baked into `.data` at link time and never reassigned anywhere in the 454k-line decompile — confirmed by the literal `iRam00502288 = 0x63fe40;` matching the live telemetry's 4th word exactly. Watching pointer *values* to singletons is like watching `this`: it will always look "frozen" whether or not the game is progressing. To probe real liveness, dereference the pointer and watch a field **inside** the object that's expected to tick every frame — never take "`gstate` never changes" as evidence of a stall by itself. ### 2026-07-24 - **A single green run does not close a nondeterministic bug.** The SIF-RPC ra-slot stomp (scratch-stack fix) was marked CLEARED after one clean run, then reopened when the next session's run happened to nest deeper. The bug only bites when re-entrancy actually overlaps, which isn't every run. Fix: verify the failure MODE was exercised (log + grep for depth≥2) before trusting a clean result, and require 3 consecutive passing runs, not 1, before marking closed. - **Isolating from the caller is not the same as isolating between re-entrancy levels.** Fix (1) gave the nested SIF reply dispatcher its own stack top, separate from the caller — but every nesting level shared that SAME address, so two overlapping levels of the same re-entrant function still collided with each other. When a fix targets "shared state with X," check whether the same resource is also shared among multiple concurrent instances of X itself. - **Multi-config CMake/VS build trees have per-subdirectory target names, not a single top-level target per module.** `--target ps2xRuntime` (the directory name) fails with MSB1009; the real targets are `ps2_runtime` (the lib) and `ps2EntryRunner` (the exe), found by listing `.vcxproj` files under the subdirectory. ### 2026-07-21 - **A silent parser skip is worse than a crash.** `loadGhidraFunctionMap` drops any malformed line with no warning and exits 0, so pointing `ghidra_output` at a space-separated file produces a run with ZERO boundary data that looks completely healthy. When a tool "succeeds" but the numbers are absurd, verify the INPUT was parsed, not just that the tool ran. - **A `jr $ra` scan cannot find the end of a tail-call thunk.** Tail calls (`j target` + delay slot + padding) never execute their own return, so the scan runs into the next function and adjacent thunks appear to overlap. Match the terminator to the function SHAPE; when a scan reports overlapping functions, the scan is wrong before the binary is. - **Absurd derived numbers point at a missing input, not a broken algorithm.** "Promoted 125,228 fallback entries" and a `+0x7adc4` owner offset were both the same missing function map. Chase the input before theorising about the code. - **When an artifact is lost, check whether it was ever really gone.** The Ghidra export was recoverable by JOIN: boundaries survived in the `.i64`, names survived in `symbols.map` (derived from runner filenames that the lost export produced). Validate the join before trusting it — here, 12,072/12,072 names landed exactly on an IDA function start. - **`.gitignore` on a bare directory kills every `!` negation under it.** Git does not descend into an excluded directory, so `build_scripts/` + `!build_scripts/x.py` silently tracks nothing. Use `build_scripts/*`. Symptom: a file you believe is committed has been untracked for months. - **An interior address needs an entry guard, not a new function.** Code emitted inside a neighbour's body with no dispatch slot is fixed by `case 0xADDR:` / `== 0xADDR`, never by carving a new function out of the map. `ctx->pc = 0xADDR;` assignments and `// 0xaddr:` comments appear for EVERY address in range — they do not prove dispatchability, and a substring match on them gives a false all-clear. ### 2026-07-20f - **A generated function entered with an unmatched `ctx->pc` silently falls into its prologue.** The entry `switch (ctx->pc)` ends `default: break;` and the prologue label follows immediately (`function_emitter.cpp:110-123`). Symptom: `sp` drops by the prologue amount and never comes back. Detect with a frame-imbalance wrapper (`exitSp != entrySp`); fix by guarding `default:`. - **A stack-pointer shift masquerades as pointer corruption.** When several callee-saved registers hold plausible-but-wrong values at once (`s0`/`s2`/`s3`/`ra` all wrong), suspect a shifted `sp`, not a bad store. Store traps will find nothing, because nothing was ever written wrong. - **`0x2xxxxxxx` is a valid pointer, not garbage.** It is the PS2 uncached mirror. Data reads mask via `PS2_RAM_MASK` (`ps2_runtime_macros.h:162,187`); only code/dispatch lookup fails to mask. Never treat a `0x2…` value as corruption without checking which path consumes it. - **Check what a value *means* before guarding how it is read.** A defensive `< 0x02000000` range guard suppressed a perfectly safe read and printed `0xffffffff`. The read was never the risk. - **`RecompiledFunction` is nested — `PS2Runtime::RecompiledFunction` (`ps2_runtime.h:476`).** Unqualified use = 12 errors from one line. Verify identifier scope before shipping a build command; flagging a guess is not resolving it. - **`template ` wrappers get one `static` counter per instantiation.** Bounded-log counters print `#1` from every slot, not `#1/#2/#3`. Make the counter a shared non-template global if a global cap is intended. - **The tee'd `run_log.txt` carries console line-wrapping (~120 chars) on top of UTF-16.** A logical record spans 2–3 physical lines; `Select-String` needs `-Context 0,3`. - **2026-07-23 encoding correction:** current `run_log.txt` is UTF-8/ASCII, not UTF-16. `Select-String -Encoding unicode` decodes pairs of normal bytes as CJK-looking glyphs and returns false-negative searches. Use `Select-String -Encoding utf8 ... -Context 0,3`. A fresh run still repeatedly reports `No exact recompiled function for guest PC 0x1`, but has no `1baf90`, `RAFORK`, or `SLOTWATCH` marker. This is not evidence against the new trace path: first verify that `build\\ps2xRuntime\\Debug\\ps2EntryRunner.exe` was relinked after the `game_overrides.cpp` instrumentation edit. - `registerFunction` only intercepts dispatch-loop calls; direct C++ fn_ calls bypass it; fix: add hasFunction check at top of generated fn_ADDR file or use game_overrides.cpp - ALL behavioral fixes go in game_overrides.cpp ONLY (no fn_*.cpp patches, no runner/ edits) - NEVER hand-write IOP output values; run actual ARKD_DVD.IRX via R3000 interpreter - When jr $ra has register-op delay slot: emit delay slot SET_GPR first, THEN `ctx->pc = GPR(31)`, drop delay addr line - Raw guest MMIO poll loops that never exit = a hardware bit the runtime never raises; fix = add the W1C/set path in ps2_memory.cpp writeIORegister + raise the bit on the driving tick (e.g. INTC_STAT 0x1000F000 bit2 VBLANK-start raised in interruptWorkerMain). Pattern: `lw MMIO; andi bit; bnez exit; ...; beqz loop` waiting on a bit == the missing set path. - Vsync worker OR-ing an unordered_map slot races the map's rehash-vs-find; pre-seed the key (=0) in initialize() so the worker only ever assigns to an existing node (residual torn-word race is benign). - A `ps2_memory.h` edit is NOT cheap: fans into 17 TUs + the whole unity fn_* corpus → wide recompile (but still no recompiler run, no output/ sync). Header changes = long build, budget for it. - Log tag `[sifman]`/`[cdvdman]`/`[libsd]` (with lowercase sce* fn names) = the REAL IRX running in the R3000 interpreter, NOT our C++ stubs (ours use `[SifCallRpc]`/`[sceSifSetDma:...]` CamelCase-tag style). When such a tag returns a stuck value, the bug is IOP-side or an EE→IOP signal the interpreted IRX waits on — fix behaviorally, never fake the return. - Multi-session summaries can go STALE: always re-run + read a fresh `run_log.txt` before acting on a documented blocker — this session's boot had already jumped past 3 documented blockers (SIF-RPC bind, CdInit recv-starve) with GS rendering, making the planned "Option B" fix moot before a line was written. - `sceSifCheckInit -> -1` forever = SIF init handshake never completes; guest polls CheckInit and never proceeds. IOP-side sifman never flags "initialized" (likely a missing EE→IOP DMA-completion/register signal). [open blocker 2026-07-16h] - **A value-trap on STORES cannot catch a bad value that arrives by LOAD.** When `pc==ra==target` and a store-trap on that value produces zero hits at the suspect stack slot, stop hunting writers — the slot was never written this frame; the epilogue read stale caller data. Reach for read-side/frame-symmetry instrumentation instead. - **Recompiler re-entry switches break prologue/epilogue pairing.** Generated functions carry an entry `switch (ctx->pc)` that `goto`s mid-body labels, skipping `addiu $sp,-N` and `sd $ra,off($sp)` — but the epilogue still runs in full (`ld $ra,off($sp)` + `addiu $sp,+N`). Prologue is conditional, epilogue is not ⇒ `$ra` loaded from the CALLER's frame. Newer codegen adds a second route: a post-`jr` `switch (jumpTarget)` that re-enters the body after `sp` was already popped. Suspect this whenever a jump target equals live payload data rather than a code address. - **A plausible value at a plausible address is not evidence of corruption.** `0x20561900` (SIF packet-pool base) appearing all over the stack was genuine packet payload, not damage — three hypotheses died before that was accepted. Corruption is only proven when the value reaches `$ra`/`pc`, not when it merely sits in memory. - **Duplicate `runner/*.cpp` files cover identical address ranges from different recompiler generations** (confirmed at `0x178260`, `0x17ed60`, `0x178428`) with materially different codegen. Before reasoning about a generated function's behavior, establish WHICH variant the dispatch table registers — reading the wrong file yields confident, wrong conclusions. - **Bound a runtime writer by its address range before theorizing about it.** `guestRealloc` died instantly to one constant (`kGuestHeapHardLimit = 0x01F00000` vs a fault at `0x01FFBC60`). Grep the ceiling constants first; it is cheaper than reading the copy logic. - **Exclusion by log ABSENCE is valid and cheap.** ARKD `sub_ADD4` was retired by zero `[ARKD:sifdma]` lines. If a code path has a bounded log line and the log is silent, the path never ran — no need to read it. - **Honor your own discriminator before generalizing.** Twice this session a hypothesis was declared dead after checking one of two required functions (`0x17ed60` has no frame; `0x178428` does, and it flipped the verdict). If a test names N functions, check all N before concluding. - **A zero-record probe is only evidence once liveness is proven three ways:** (a) exe mtime > source mtime AND log mtime > exe mtime; (b) an independent control marker from the SAME run is present; (c) the probe's literal tag string is found inside the built exe (`findstr /M /C:"TAG" `, check `$LASTEXITCODE -eq 0`). Skip any one and "silence" is indistinguishable from a stale build. - **`findstr /M /C:` is the only workable way to test for a string in the 730 MB exe.** PowerShell byte-array approaches (`-join ([char]$_)`, hand-rolled chunked scans) OOM or time out. - **`sdbzFrameTraceWrapper` can only see ENTRY and EXIT state.** If an entry-side probe is provably silent, the corruption is mid-body by construction — mirror the probe to the exit side (sample after `original(...)`) rather than adding more entry gates. - **Some registered "recompiled" functions are stub forwarders, not MIPS bodies** — shape `const uint32_t __entryPc = ctx->pc; ps2_stubs::X(...); if (ctx->pc == __entryPc) ctx->pc = getRegU32(ctx,31);`. They read `$ra` but never write it, so they can never be the source of a corrupt `$ra`. Check `register_functions.cpp` FIRST to find the live generation; the `fn__0x.cpp` twin is often dead. - **A diagnostic env var being unset looks identical to "the code path was never reached."** `cov=0/0` and zero `[rpcValid]`/hwwatch lines in a run mean `PS2_COVERAGE`/`PS2X_HWWATCH` simply weren't set for that run — not that coverage was zero or the RPC handler never fired. Before drawing a conclusion from a silent env-gated probe, confirm the gating var was actually exported for that specific run. ## Session 2026-07-30 — BUG-009: confirmed build is current (not stale); explained a silent-probe false lead; HWWATCH run still not executed No code change. Verified `game_overrides.cpp` (mtime Jul 30 06:41) predates all built `ps2EntryRunner.exe` copies, ruling out stale-build as the reason the user's pasted watchdog log showed zero `[rpcValid]` lines and `cov=0/0`. Root cause of the silence: that run simply didn't have `PS2X_HWWATCH`/`PS2_COVERAGE` env vars set — both diagnostics are env-gated and were never armed, not evidence about the RPC path itself. Handed the user the run command with `PS2X_HWWATCH=1`/`PS2X_HWWATCH_VAL=0xFFFFFFFF` set. **Still pending:** that HWWATCH run has not yet been executed/pasted back. **Also still open, flagged but not yet resolved:** two `hwWatchArm` call sites exist in `game_overrides.cpp` (line ~815 pkt_addr hunt vs line ~1555 older `$ra`/entrySp hunt) contending for the single global DR0 register — only one is meaningfully armed per run; worth confirming which fires before trusting a HWWATCH result. ## Session 2026-07-30b — BUG-009: send-path fully traced, HWWATCH run completed, blocker re-localized to boot state machine HWWATCH run executed (90s, `PS2X_HWWATCH_CLIENT`-gated arbitration fix verified no contention with the old `$ra` hunt): 7 hits at `guest=0x00464dc0` in `run_probe.jsonl.hwwatch.txt`, all early in the run. Confirmed via `rpc_call_0x178be8.cpp:153` (sets `pkt_addr`) then `sub_00178560_0x178560.cpp:419` (genuine SDK reply-delivery clear, not a recompiler bug) — one clean bind/reply cycle, then silence for the remaining ~85s. Statically traced the full send path this session to find what's *supposed* to re-arm `pkt_addr`: - Six sceCd-style wrapper functions (`sub_1877C8`/`187898`/`187930`/`1879E8`/`187AA0`/`187B98` in `decompiles_SLUS_214_42.txt`) each gate through `sub_00186CC0_0x186cc0.cpp` ("SendSCmd"-style gate) before the real SIF send via `mem_fill_z_369` → `rpc_call_0x178be8`. - `sub_186CC0` has 3 short-circuit bail conditions: (1) owner-thread check on `dword_46326C`, (2) busy-check — calls `sub_186C50(1)` = `wrap_rpc_handle_valid(dword_464DC0)` directly, (3) in-flight flag `dword_463294 >= 0`. - **Check #2 (the BUG-009 gate) is NOT the blocker.** Since `pkt_addr==0` permanently, this busy-check always reports "not busy" and lets execution through to the real send *every time `sub_186CC0` is reached*. - **Re-localized the stall one level up: nothing calls `sub_186CC0` or its 6 wrapper callers again after the first cycle.** This matches the HWWATCH zero-re-arm evidence. Started reading `sub_00327810_0x327810.cpp` (boot state machine, 2819-line generated `switch(ctx->pc)`/state dispatcher) looking for what triggers a fresh wrapper call — read lines 1-1045 (state teardown for states 14→1, generic device-init/retry sequencer), no direct hit on rpc-related addresses in that range. Lines 1046-2819 unread. **Recommendation for next session:** static reading of the remaining ~1770 lines is expensive for a generated switch; a live breakpoint/HWWATCH on entry to `sub_00186CC0_0x186cc0` (address `0x186cc0`) will show directly and faster whether/when it's re-entered and with what register state — prefer that over continuing the manual disassembly read. ## Session 2026-08-03b — Round-3 VU1 probe BUILDING (unrun); memory system pruned; FPU accumulator item verified closed **★ Live blocker unchanged and still the whole job: no VU1 microprogram is ever uploaded.** See memory `project_stage58_vu1_microcode_missing.md`. Round-2 `[vu:frame]` measured `mpg=0 mpgbytes=0 vu1instrs=1966080 endebit=0 endlimit=30 endrange=0` with `mscal=30 vu1runs=30 xgkicks=0`; `1966080 = 30 × 65536` **exactly**, i.e. every MSCAL burns the full cycle budget decoding a zero-filled code image as NOP pairs. `mpg=0` holds across all 22 intervals **including boot**, so it is not a per-interval drain artifact. `primmask=0x41` (POINT+SPRITE) = zero triangles ever reach the GS. Stage 5.8 exit test is still literally: **one frame with `tme=1` AND `tex0.tbp != 0`.** **Round-3 instrumentation is written and was compiling when this session ended — it has NOT been run.** 7 files, all uncommitted on purpose (no point committing an unrun probe): `ps2xRuntime/include/runtime/ps2_pipeline_stats.h` (new/untracked; header-only inline atomics, so no new TU and no CMakeLists edit), plus `ps2_runtime.cpp`, `include/ps2_runtime.h`, `ps2_gs_gpu.cpp`, `ps2_vif1_interpreter.cpp`, `vu/ps2_vu1_core.cpp`, `vu/ps2_vu1_lower.cpp`. New `[vu:frame]` fields: `codenz`, `datanz`, `vifops`, `mscalpc`. **How to read the round-3 output — decide this before looking, it is a clean two-way split:** - `codenz=0` → VU1 code memory is genuinely empty. Nothing anywhere uploads it; the MSCALs are executing zeros. The bug is **upstream, in the upload path** (does the EE ever store to `0x11008000`? are DMA1 chain-mode tags carrying MPG being skipped?). - `codenz>0` → a microprogram **is** resident, meaning the EE wrote it straight through the mapped `0x11008000` window and bypassed VIF1 MPG entirely. Then the upload is fine and the bug is **ours, downstream** — prime suspect the VU1 decode cache in `ps2_vu1_core.cpp`, which only rebuilds when `getVU1CodeGeneration()` bumps, and only `markVU1CodeModified()` bumps it. A raw EE store through the mapped window never does. - Also read `vifops` (cumulative hex bitmask of VIF1 opcodes actually seen; MPG `0x4A` = bit 10 of `opHi`) to learn what the ~8 MB/interval really contains, `datanz` for whether UNPACK lands geometry in VU1 data memory, and `mscalpc` to rule out the game merely calling address 0. Build status at session end: mid-unity-compile (~1779 of ~4520 TUs), 9 `cl`/`MSBuild`/`link` processes live, no errors in `build_log.txt`. On failure, **read `build_log.txt` directly** — build.ps1's progress bar swallows the real `C2039`-style errors. **Memory system pruned** (the session's other work, done while the build occupied the machine). `MEMORY.md` went from a flat 89-line list (18,628 B) to a 9-section index (13,240 B, −29%) with the VU1 blocker and `command_log` first. Three fully-dead memories were deleted **after** folding their content into `project_state_archive.md`: `project_raout_probe_pending`, `project_stale_frame_ra_read` (the whole `$ra`-corruption thread — closed; real cause was the persistent `SetVSyncFlag` registration, and the archive entry preserves the exonerations of the pool allocator `0x178428`, `strlen 0x191898`, `strcpy 0x191780`, and the falsified "14 bisectable slots" framing), and `project_uncommitted_fpu_bisect_changes`. All 13 dangling `[[wikilinks]]` repointed; verified zero index/disk drift both directions. **FPU accumulator item verified CLOSED by reading the code, not by assuming.** The memory claimed the fix lived in `code_generator.cpp` under a field named `fpuAcc`; both names were wrong, and grepping only those two returned zeros — which reads exactly like "the fix was reverted." Widening to `MADDA|COP1_ADDA|adda\.s` found it landed and coherent at `ps2xRecomp/src/lib/fpu_translator.cpp:80-93` → `FPU_SET_ACC` (`ps2_runtime_macros.h:607`) → `ctx->f_acc` (`ps2_runtime.h:136`). The `sdbzBisectGap*` MemSysInit stubs are gone. One harmless residue: an unused duplicate `float fpuAcc;` at `ps2_runtime.h:63` that nothing reads. **This is the `capped-probes-are-false-negatives` shape again — a too-narrow grep manufactured a zero.** **Still open, not worked, user's call:** - The `sid=0x500 fno=0x1` IOP spin (`cdvdman fid=12` @ `ra=0x0414d0`, `thbase fid=33` @ `ra=0x04abbc`, `sysmem fid=14`, ~31,746 calls each, all defaulting `$v0=0`). Offered as option 2 this session; user chose option 1 (chase the textured draw). Never was the state-11 gate and is not the current one. - `git prune` for the unreachable loose objects left by July's filter-repo purge. - `memory/reference_ps2_sif_boot.md` is 90 KB, ~7× the next largest, and much of it is retired 0xA0-IMBAL/stale-pc narrative. Worth a splitting pass; it is a real read-and-judge job. ## Key Files - `PS2Recomp/ps2xRuntime/src/lib/iop/iop_kernel.cpp` — IOP module registry - `PS2Recomp/ps2xRuntime/include/runtime/iop/iop_kernel.h` — IOP kernel header - `PS2Recomp/ps2xRuntime/src/lib/iop/iop_runtime.cpp` — R3000 interpreter runtime - `PS2Recomp/ps2xRuntime/src/lib/iop/irx_loader.cpp` — IRX loader - `PS2Recomp/ps2xRuntime/src/lib/iop/r3000_cpu.cpp` — R3000 CPU core - Game overrides: search in PS2Recomp/ps2xRuntime/src/ for game_overrides.cpp ## PROHIBITIONS (critical, permanent) 1. NEVER clean build (30+ hour rebuild) 2. NEVER edit runner/*.cpp (machine generated) 3. NEVER edit .h headers without explicit user approval 4. NEVER run cmake directly — always build.ps1 5. NEVER list/scan runner/ directories (30k+ files) 6. NEVER fake IOP output values — use real ARKD_DVD.IRX 7. NEVER patch fn_*.cpp — game_overrides.cpp ONLY