mirror of
https://github.com/fstat13/PSCapture.git
synced 2026-10-18 22:00:46 +02:00
- custom-chrome dark window: painted title bar (minimize/close), rounded region, owner-drawn buttons, status pill with state dot, dark console pane - installer: verified writes (write -> check -> report), in-process LoadLibrary + DllRegisterServer with real HRESULT reporting; no more cryptic rundll32 'module not found' - driver check verifies the registered dll still exists on disk - feeder reports 'IN ATTESA DELLA PS5' when the console is unreachable - log file next to the exe with %TEMP% fallback - docs: installer + engineering log (B6, B7, lesson 8)
234 lines
13 KiB
Markdown
234 lines
13 KiB
Markdown
# PSCapture — Engineering log: problems, diagnoses, work-arounds
|
||
|
||
This is the long story: every wall we hit while building PSCapture, how it
|
||
was diagnosed, and what finally fixed (or bypassed) it. Dead ends are kept on
|
||
purpose — they cost real time and shouldn't cost yours.
|
||
|
||
*(Testbed: retail PS5, firmware 13.60, jailbroken; PC on the same LAN.
|
||
All work done on personally owned hardware.)*
|
||
|
||
---
|
||
|
||
## Part A — Getting pixels out of the console
|
||
|
||
### A1. The official capture stack is a dead end
|
||
**Symptoms / approach.** First attempts used the console services that look
|
||
like capture APIs: `libSceAvcap2` / `libSceVideoRecording` etc.
|
||
**What we found.** `libSceVideoRecording` opens a session backed by a **128 MB
|
||
file** and talks to its client over internal IPC; calls from an unprivileged
|
||
payload fail with the service unavailable. The IPMI (inter-process messaging)
|
||
client table is limited, and every live instance of our payload holds a
|
||
session — with ~26 stale instances, a new payload waits **forever**
|
||
(`connect -> 0x8002000d`).
|
||
**Resolution.** Abandoned the service path entirely. The work-around that
|
||
later became permanent: (1) never rely on the recording service; (2) payloads
|
||
kill their own stale instances at boot ("v17 hygiene"), fingerprinting them by
|
||
*their* fixed cacheable mapping in the page tables (PML4 entry #128 — no
|
||
system process uses it).
|
||
|
||
### A2. The direct path: the display engine's scan-out ring
|
||
**Diagnosis.** The AV-control daemon (`SceSysAvControl.elf`) exposes the
|
||
scan-out geometry: ring at `0x200200000`, data at `+0x4200000`, four surfaces
|
||
`0x600000` apart, rewritten at 60 Hz, stored as P010 (10-bit).
|
||
**First bug — P010 decoding.** Extracting the *even* bytes gave noise: the
|
||
usable byte of a 10-bit sample is the **odd** one. Lesson recorded in
|
||
`REVERSE_ENGINEERING.md` so nobody re-makes it.
|
||
|
||
### A3. The rotating image (the pan)
|
||
**Symptom.** The picture "rotated" and slid horizontally forever; reading the
|
||
four surfaces in alternation made it jump every frame.
|
||
**Diagnosis.** The scan-out base drifts within the ring: the frame does not
|
||
start at a fixed offset. The surface content is also quasi-periodic, which
|
||
made the first metric find a **twin minimum** 1920 bytes away and oscillate
|
||
between the two.
|
||
**Dead ends (documented so you skip them):**
|
||
- a "junction" continuity metric that needed a `+960` fudge — produced
|
||
oscillation between twins;
|
||
- chasing the drift with a per-frame tracker — the pursuit itself caused
|
||
visible horizontal wiggling.
|
||
**Final metric** ("exact cut"): strip `[row r, 0..maxj]` must match
|
||
`[row r-1, c..]`, minimizing `Σ |Y[r][j*2+1] − Y[r-1][(c+j)*2+1]|` over
|
||
sampled rows; conversion `pan = ((1920 − c) % 1920) * 2`.
|
||
**Resolution.** Field-verified fixed `pan = 2230` (the drift is ~2 B/s,
|
||
below visibility), and later a **user-tunable fine shift** on the PC side
|
||
(even pixels only, see D2).
|
||
|
||
### A4. The color saga (or: "v10 colors were right")
|
||
**Symptom.** Backgrounds looked red/amber instead of blue; users kept saying
|
||
"too red", "now too gray", "the colors of v10 were right".
|
||
**Diagnosis path.** The chroma plane does **not** start after the luma:
|
||
it begins at byte `0x3EA300` (= 4 104 960), 11 rows *before* the presumed
|
||
boundary, with dead zones before and after (the "green bar" of legend was the
|
||
tail read into the gap → replicate the last good row).
|
||
But position alone wasn't the whole story. Comparing a live frame **byte for
|
||
byte** against an old reference frame of the same screen showed the output
|
||
matched only with **U/V pairs swapped**: in this plane the 16-bit words are
|
||
stored **V-first**. Mean absolute error dropped 15.5 → 1.7 → 0.06 once the
|
||
pair order was corrected in the P010→NV12 conversion (a second shuffle mask
|
||
for the chroma region only).
|
||
**Honest note.** Before that discovery, the wrong knob was shipped: a chroma
|
||
**gain** to "lower the red" — it made the cast less visible but never right.
|
||
The gain knob survives as an optional tuning, default 1.0 (= raw).
|
||
**How the truth was found.** Three independent anchors: neutral white stays
|
||
(128,128) either way, the bluish background flips the right way only with the
|
||
swap, and the byte-level diff against the reference settles it numerically.
|
||
Rendering the *same bytes* with two different YUV formulas long ago had also
|
||
caused a wild goose chase — the rule became: **compare numerically, never
|
||
eye-render across periods** (see Lessons).
|
||
|
||
### A5. Kernel reads without crashing the console (three crashes, one rule)
|
||
**Crash #1/#2.** Full console lock-ups (all TCP ports mute, ICMP half-dead)
|
||
after storms of live payload instances + IPMI maintenance traffic. Recovered
|
||
only by physical power cycle. Root cause never *proven*; the permanent
|
||
hygiene (single instance, kill-istanze at boot) removed the whole class.
|
||
**Crash #3 (diagnosed exactly).** A kernel-dump experiment read kernel VA
|
||
through `kernel_copyout` — a fault there is a **kernel-mode fault**: instant
|
||
panic. **Rule, now absolute:** kernel memory is only ever read via a page
|
||
table walk with present-bit validation + 8-byte reads through the direct map;
|
||
`kernel_copyout` on kernel addresses is banned.
|
||
**Related guard bug (fun one).** The cacheable-mapping builder refused
|
||
physical addresses above 16 GB as "suspicious" — but the display surfaces
|
||
live at `0x459200000` (≈18.7 GB). Result: silent fallback to the slow path
|
||
(45 MB/s, 6 fps). Fix: raise the guard to 32 GB; the real safety net is the
|
||
page-table validation, not the numeric limit.
|
||
|
||
### A6. Speed: from 45 MB/s to 4 GB/s
|
||
**Diagnosis.** Reading the ring through syscall copies serialized inside the
|
||
process (multi-threading didn't help — the copies bottleneck in the kernel
|
||
path).
|
||
**Fix.** Build a **cacheable mapping** of the DCE physical range inside the
|
||
payload process: allocate our own PDPT/PD pages, replicate the daemon's PDE
|
||
flags (write-back, no PCD/PWT), write **one** PML4 entry, done. Bulk frame
|
||
reads went to ~4 GB/s.
|
||
**Tearing.** The DCE rewrites a surface at 60 Hz; a read during a rewrite
|
||
yields a mixed frame (ghosts, colored tails). Fix: read, wait 2 ms, compare;
|
||
if the surface changed it was alive — move to the next one.
|
||
|
||
---
|
||
|
||
## Part B — The PC side: from frames to a "webcam"
|
||
|
||
### B1. Pipeline shape
|
||
Feeder (TCP receiver) → shared memory ring → **DirectShow source filter**
|
||
(`ps5cam.c` → `pscapture.dll`) that every consumer sees as a camera called
|
||
**PS5 Capture**.
|
||
|
||
### B2. Discord error 2014 (spinner → no camera), root cause found in their binary
|
||
**Symptoms.** Camera selectable, infinite spinner, then Discord error 2014 —
|
||
with *any* virtual camera (ours and OBS Virtual Camera alike).
|
||
**Diagnosis.** Discord's own media logs contained the smoking gun:
|
||
`Failed to create capturer (video_capture_module_source.cpp:105)`. Reading
|
||
their capture engine (WebRTC's DShow path inside the Discord binary):
|
||
the engine's device **picker requests 1280×720@30** and only accepts a device
|
||
that lists a *matching* capability. Ours offered only 1920×1080 → no match →
|
||
capturer never created. OBS Virtual Camera exposed zero caps → same fate.
|
||
**Fix — "real webcam" capability profile.** The driver now exposes 11 UVC-like
|
||
capabilities (YUY2/NV12/I420/RGB24/RGB32 at 1080p/720p/480p, negative-height
|
||
variants first), plus a `DevicePath` string in the registry (the modern
|
||
identifier consumers use instead of the friendly name). Verified in their
|
||
logs: `Capturer running at 1280x720@30`, first frame in 10–13 ms.
|
||
**Tooling discoveries that made this tractable:**
|
||
- Discord can be driven with `--remote-debugging-port`; from it the **media
|
||
engine is scriptable** (device selection, camera on/off) without touching
|
||
the UI — see `docs/` history and our notes.
|
||
- The binary is searchable for log strings (`(file:line)` hints) and
|
||
RIP-relative xrefs; that's how the picker logic was pinned down.
|
||
|
||
### B3. Flips and orientations
|
||
**Symptom.** 720p output upside-down for one consumer but not another.
|
||
**Diagnosis.** DIB convention sign vs YUV conventions differ between
|
||
consumers: ffmpeg/WebRTC treat YUV as top-down regardless of `biHeight` sign,
|
||
but XRGB-style DIBs as bottom-up.
|
||
**Fix.** Flip **only** for RGB subtypes when `biHeight > 0`; all YUV paths
|
||
stay top-down. Verified with ffmpeg grabs.
|
||
|
||
### B4. Alignment ("more space on the left than on the right")
|
||
**Symptom.** The image sat a few dozen pixels off; users described margins,
|
||
not offsets.
|
||
**Diagnosis.** Measured **inside** the frame: the centered title text of the
|
||
screen had its glyph center at x≈993 (960 = screen center) → the capture was
|
||
~33 px to the right; button margins agreed (~29 px, arrow ~18 px — the title
|
||
is the cleanest anchor).
|
||
**Fix.** A **cyclic horizontal shift** in the feeder (whole luma plane and UV
|
||
plane, same byte count; **even** pixels only — an odd shift re-labels U/V
|
||
pairs and hue-inverts), exposed as the live `shift px` knob.
|
||
**Long-term.** Bake the value into the payload's pan and drop the PC-side
|
||
shift (planned).
|
||
|
||
### B5. The 1080p question (and what Discord really does)
|
||
Users compared with capture cards: "Elgatos stream at 1080p!".
|
||
**Findings.** (1) The capture path in Discord calls is capped by *Discord's
|
||
engine* (it asks every camera for 720p — capture cards included); (2) as a
|
||
generic DirectShow source, PSCapture **already delivers 1920×1080** to OBS and
|
||
ffmpeg (verified with an ffmpeg 1080p grab while Discord was live) and that's
|
||
the road for 1080p streaming; (3) 1080p *inside* a Discord call via screen
|
||
share is possible up to 1080p60 with Nitro.
|
||
|
||
---
|
||
|
||
### B6. The installer crash-course: a cryptic module error and a lying log
|
||
**Symptom.** Pressing *Install driver* showed *"the specified module could not
|
||
be found"* - the classic rundll32 message, with no other clue anywhere.
|
||
**Diagnosis.** Two faults stacked: when the app was not elevated, writing
|
||
`pscapture.dll` into `%ProgramFiles%` failed silently - the old code logged
|
||
the extraction as successful anyway - and then `rundll32` was asked to load a
|
||
file that did not exist (hence the generic, useless message). On top of that,
|
||
`rundll32` never reports what went wrong back to the caller.
|
||
**Fix.** Every filesystem step is now verified (write -> check size ->
|
||
report), the install aborts honestly when the dll cannot be placed, and
|
||
registration happens in-process (`LoadLibrary` + `DllRegisterServer`) so
|
||
failures come back as real HRESULTs. The driver check also verifies that the
|
||
registered dll still *exists* on disk before declaring victory.
|
||
|
||
### B7. The modern control center UI
|
||
The first GUI iteration was a plain Win32 dialog. It was rebuilt as a dark
|
||
custom-chrome window: painted title bar with integrated minimize/close,
|
||
rounded window region, owner-drawn buttons, a live status pill with a state
|
||
dot, and a dark monospace console pane. Everything is still pure C with zero
|
||
frameworks - the UI is drawn by hand.
|
||
|
||
## Part C — The road to 60 fps
|
||
|
||
**The math.** Raw NV12 1080p is 3.11 MB/frame → 30 fps ≈ 93 MB/s (a gigabit
|
||
LAN is already at the wall at ~110 MB/s); 60 fps ≈ 186 MB/s → needs 2.5 GbE
|
||
*or* compression.
|
||
|
||
**Raw path (2.5 GbE adapter on the console).** The user's adapter is a TP-Link
|
||
UE302C (Realtek RTL8156B). Findings: the console kernel is **FreeBSD 9.0**
|
||
based; the matching driver (`ure`) appears in FreeBSD 13+, and Sony ships only
|
||
the drivers it uses — so nothing will recognize the adapter out of the box.
|
||
Two enablers were secured anyway: the **PLD payload format** and the kernel
|
||
payload loader on port **9019** (how the scene's kernel payloads ship), plus
|
||
the kernel offsets table for 13.60 — enough to *deliver* a custom kernel
|
||
module when the port is ready. (Full port = a separate workstream; the
|
||
homework is in `REVERSE_ENGINEERING.md` §4.)
|
||
|
||
**Compressed path (stays on gigabit).** The console has a hardware JPEG
|
||
encoder (`libSceJpegEnc`). At ~250-350 KB/frame, **60 fps ≈ 15-21 MB/s** —
|
||
five times inside a gigabit. The API surface was recovered from the library
|
||
itself (exports, NIDs, argument structs, validation rules — see the notes in
|
||
`REVERSE_ENGINEERING.md`), and a measuring payload is ready to run on the
|
||
console (each call in a forked child with timeout — Sony APIs abort on wrong
|
||
arguments, so a bad call must die alone).
|
||
|
||
---
|
||
|
||
## Lessons (the rules we now follow)
|
||
|
||
1. **Never** hand a kernel address to `kernel_copyout` — page-walk + present
|
||
check + direct-map reads, always.
|
||
2. One live payload instance. Kill stale ones at boot; never accumulate.
|
||
3. Measure with bytes, not eyes: same formula, same reference frame, signed
|
||
deltas. Eye-comparison across renderers lies.
|
||
4. Even-pixel shifts only (U/V pairing), for the same reason.
|
||
5. Never call `sceAvControlChangeScanInMode` from a payload (it can wedge the
|
||
display engine).
|
||
6. When an API can abort the host on bad arguments, isolate the call in a
|
||
child process with a timeout.
|
||
7. When a suspicious "fix" targets a symptom (the chroma gain), keep looking
|
||
for the *identity* of the problem (the U/V swap).
|
||
|
||
8. **Never log success without checking the I/O result.** A lying log (the
|
||
installer claiming "extracted ok" on a failed write) costs far more time
|
||
than a missing one - it sends you hunting in the wrong place.
|