diff --git a/openspec/changes/2026-06-09-remote-texture-export/proposal.md b/openspec/changes/2026-06-09-remote-texture-export/proposal.md new file mode 100644 index 0000000..bffa75e --- /dev/null +++ b/openspec/changes/2026-06-09-remote-texture-export/proposal.md @@ -0,0 +1,115 @@ +# Remote texture export via GetTextureData + local decode + +## Problem + +Issue #236: `tex_export`, `rt_export`, and `rt_depth` all fail in remote mode. + +The three export handlers unconditionally call `controller.SaveTexture(ts, path)`, which +writes a file on the **remote** daemon host's filesystem. The client receives a path that +does not exist locally and cannot fetch it. In the prior codebase all three handlers were +pre-emptively blocked by the `is_remote` guard (introduced alongside remote replay), so +callers received a JSON-RPC error before any file I/O is attempted. The result is that +remote sessions have no supported path to export rendered targets — a major capability gap +for headless/cloud workflows where the daemon runs on a GPU server. + +## Root Cause + +`SaveTexture` is a RenderDoc API that writes to the path it is given, which in a remote +session is a path on the remote host. There is no `SaveTexture`-over-network mode in the +RenderDoc Python API. The `rt_overlay` handler is blocked for a related but distinct +reason: rendering overlays (Overdraw, Wireframe, etc.) requires GPU draw submission, which +is not safe over the remote JSON-RPC channel, so that guard is correct and must stay. + +## Solution + +Replace the `SaveTexture` call with `controller.GetTextureData(resource_id, sub)`, which +returns the raw texel bytes over JSON-RPC to the **client** process. The client decodes the +bytes locally using numpy and Pillow (both already runtime dependencies) and writes the +result as a PNG at the caller-supplied path. + +Two private helpers live in `handlers/_helpers.py`: + +- `_decode_dtype(rd, comp_type, comp_byte_width) -> str | None` — a pure lookup table that + maps a `(CompType, compByteWidth)` pair to a numpy dtype name, or `None` to reject pairs + with no unambiguous 8-bit display mapping. +- `_decode_texture_png(rd, tex, raw, mip, *, is_depth) -> bytes | None` — decodes the tightly + packed bytes into PNG bytes, or returns `None` when the format cannot be displayed. It + does not touch the filesystem; the caller writes the returned bytes. + +A small handler-side helper `_export_remote(...)` in `handlers/texture.py` wraps the wire +fetch: it calls `GetTextureData`, guards against empty/`None` data, calls +`_decode_texture_png`, and writes the PNG. Each export handler routes through it under +`if state.is_remote:`; the existing `SaveTexture` branch is left untouched for local mode. + +### Decode behavior + +- Only `ResourceFormatType.Regular` formats are decoded; every non-Regular format returns a + clear `-32002` error `"format not supported for remote decode"`. +- Mip-level dimensions: `w = max(1, tex.width >> mip)`, `h = max(1, tex.height >> mip)`. +- Byte layout from `GetTextureData` is tightly packed, top-down (no row padding, no Y-flip). +- Dtype and 8-bit conversion are selected per `(compType, compByteWidth)` via `_decode_dtype`: + - Float16 / Float32 (HDR linear) → tonemap: `clip(x, 0, 1)`, sRGB OETF, `*255`, uint8. + Documented as contrast-clamped, not a raw dump. + - UNorm8 / UNormSRGB8 → uint8 pass-through (sRGB bytes are display-ready, no extra gamma). + - UNorm16 → uint16, scaled to 8-bit via divide-by-257. + - SNorm8 / SNorm16 → read as the signed integer width, remapped `[-1, 1] -> [0, 255]` + using the signed-int max as the divisor (normal-map friendly). + - UInt8 / UInt16 → treated as display values (UInt16 also `/257`). + - Depth8 / Depth16 / Depth32 → handled by the depth path (below). +- `BGRAOrder() == True` with `cc >= 3` → swap to RGBA channel order before save. +- Channel expansion for the COLOR path: cc=1 → repeat the single channel to RGB and add + A=255 (full RGBA, **not** mode `L`); cc=2 → RG0 + A=255; cc=3 → add A=255; cc=4 → RGBA + as-is. Output is always RGBA. +- Depth path (`is_depth=True`, used only by `rt_depth`): take channel 0, auto-contrast via + `(d - d_min) / (d_max - d_min)`, then `*255` uint8, written as mode `L`. This matches + RenderDoc's contrast-stretched depth display; it is not a raw depth dump — use `tex_raw` + for exact bytes. + +### Cleanly rejected (→ `-32002`, never a garbled image) + +- `_decode_dtype` returns `None` for: Typeless, SInt, UScaled, SScaled, and any exotic + component width not in the table. +- Non-`Regular` `ResourceFormatType`: block-compressed (BC1-7, ASTC), packed + (R11G11B10, R10G10B10A2, R9G9B9E5, R5G6B5), and combined depth-stencil (D24S8, D32S8). +- MSAA (`msSamp > 1`). +- Length mismatch: `len(raw) != h * w * compCount * compByteWidth`. +- Empty / `None` data: `_decode_texture_png` returns `None` on empty input, and + `_export_remote` additionally guards the `GetTextureData` return so a `None`/empty result + yields `"no texture data returned"` rather than a `len(None)` crash. + +### What stays blocked + +`rt_overlay` remote guard is unchanged: overlay rendering requires GPU submission and cannot +be made safe over remote JSON-RPC. + +### Known limitation / follow-up + +Local mode (`SaveTexture`) renders depth with a RED colormap, while the remote path renders +depth as GRAY (mode `L`). This local(RED)/remote(GRAY) depth-visualization inconsistency is +an accepted limitation for this change; aligning the two colormaps is deferred to a +follow-up. + +### Scope + +- In scope: `tex_export`, `rt_export`, `rt_depth` in remote mode; 3D textures (`depth > 1`) + are exported by tiling depth slices vertically into one image. +- Out of scope: block-compressed formats; packed formats (R11G11B10, R10G10B10A2, R9G9B9E5, + R5G6B5, D24S8, D32S8); MSAA (`msSamp > 1`); local mode (unchanged byte-for-byte). + +## Risks + +| Risk | Mitigation | +|------|------------| +| Double sRGB correction | UNormSRGB bytes pass through uint8 unchanged; only linear Float formats get the sRGB OETF. | +| BGRA channel swap missed | Gate on `BGRAOrder()` (with `cc >= 3`) and reorder to RGBA before `Image.fromarray`. | +| Signed data read as unsigned | SNorm is read at the signed integer width and remapped `[-1,1] -> [0,255]`; SInt is rejected outright. | +| Row-padding assumption | Defensive `len(raw) != expected` check; bail to clear `-32002` rather than corrupt reshape. | +| Y-flip added by mistake | `GetTextureData` is top-down; Pillow `fromarray` is top-down — do NOT flip. | +| MSAA ambiguity | Reject `msSamp > 1` (mirror `tex_stats` guard) in remote decode path. | +| Mip dimension off-by-one | Use `max(1, dim >> mip)` per level; `tex.width`/`tex.height` are mip-0 only. | +| Packed/block format garbage | Guard `tex.format.type == ResourceFormatType.Regular`; emit `-32002` for the rest. | +| Empty/None texture data crash | `_export_remote` guards the `GetTextureData` return before decode; empty → `-32002`. | +| rt_export/rt_depth lack TextureDescription | Resolve `Descriptor.resource` → `state.tex_map[int(resource)]` to obtain width/height/format. | +| Local-mode regression | All new decode logic is strictly under `if state.is_remote:`; existing `SaveTexture` branch is byte-for-byte unchanged. | +| Depth normalization is relative | Document that remote depth PNG is contrast-stretched for visibility; raw bytes available via `tex_raw`. | +| Local/remote depth colormap diverge | Accepted limitation (local RED vs remote GRAY); colormap alignment deferred to follow-up. | diff --git a/openspec/changes/2026-06-09-remote-texture-export/tasks.md b/openspec/changes/2026-06-09-remote-texture-export/tasks.md new file mode 100644 index 0000000..c0684bb --- /dev/null +++ b/openspec/changes/2026-06-09-remote-texture-export/tasks.md @@ -0,0 +1,68 @@ +# Tasks: remote texture export via GetTextureData + +## Phase A: Mock infrastructure + +- [x] `tests/mocks/mock_renderdoc.py`: `ResourceFormat` supports a non-Regular `type` and a + `Name()` so packed/block/typeless tests can be expressed +- [x] `tests/mocks/mock_renderdoc.py`: `ResourceFormat.BGRAOrder()` plus + `MockReplayController._texture_data` / `GetTextureData`, `MockPipeState`, and `Descriptor` + for the remote export paths; existing handler tests still pass + +## Phase B: Decode helpers (TDD) + +- [x] Write the remote-decode unit tests in `tests/unit/test_tex_stats_handler.py` before + implementing the helpers +- [x] Implement `_decode_dtype(rd, comp_type, comp_byte_width) -> str | None` in + `src/rdc/handlers/_helpers.py`: a `(CompType, compByteWidth)` -> numpy-dtype table + covering Float16/32, UNorm8/16, UNormSRGB8, SNorm8/16, UInt8/16, Depth8/16/32; returns + `None` (reject) for Typeless / SInt / UScaled / SScaled / exotic widths +- [x] Implement `_decode_texture_png(rd, tex, raw, mip, *, is_depth) -> bytes | None` in + `src/rdc/handlers/_helpers.py`: + - Empty `raw` -> `None` + - Guard: `tex.format.type != rd.ResourceFormatType.Regular` -> `None` + - Guard: `tex.msSamp > 1` -> `None` + - Mip dimension: `w = max(1, tex.width >> mip)`, `h = max(1, tex.height >> mip)` + - Defensive length check: `len(raw) != h * w * compCount * compByteWidth` -> `None` + - Dtype/scale by `(compType, compByteWidth)`: Float `clip+sRGB-OETF`, UNorm8/UNormSRGB8/ + UInt8 pass-through, UNorm16/UInt16 `/257`, SNorm signed-remap `[-1,1]->[0,255]` + - `BGRAOrder()` (with `cc >= 3`) channel swap to RGBA + - Color channel expand: cc=1 repeat->RGB + A=255, cc=2 RG0+A, cc=3 +A; always RGBA output + - Depth path (`is_depth=True`): channel-0 auto-contrast -> mode `L` + - Return PNG bytes +- [x] Verify color/reject decode cases pass + +## Phase C: tex_export remote branch + +- [x] `src/rdc/handlers/texture.py`: add `_export_remote(...)` helper that calls + `GetTextureData`, guards empty/`None` data (`"no texture data returned"`), calls + `_decode_texture_png`, emits `-32002` `"format not supported for remote decode"` + on `None`, and writes the PNG returning `{"path", "size"}` +- [x] `_handle_tex_export`: route through `_export_remote` under `if state.is_remote:` +- [x] Keep the existing `SaveTexture` branch unchanged for local mode +- [x] Remove the old remote-rejected guard for `tex_export` +- [x] Verify `tex_export` remote + local-regression cases pass + +## Phase D: rt_export and rt_depth remote branches + +- [x] `_handle_rt_export` remote branch: resolve `Descriptor.resource` -> + `state.tex_map[int(resource)]`; route through `_export_remote`; error if resource not in + `tex_map` +- [x] `_handle_rt_depth` remote branch: same pattern, passing `is_depth=True` so depth -> + mode `L` PNG +- [x] Keep both `SaveTexture` local branches unchanged +- [x] Remove the old remote-rejected guards for `rt_export` and `rt_depth` +- [x] Verify `rt_export` / `rt_depth` remote cases pass + +## Phase E: Regression + rt_overlay guard + +- [x] Confirm `rt_overlay` remote guard is intact (`test_rt_overlay_remote_still_rejected`) +- [x] Replace the old `test_*_remote_rejected` tests with cases asserting a PNG is produced +- [x] Run full unit suite — all green +- [x] Run `pixi run check` (lint + typecheck + tests) + +## Phase F: Code review + merge + +- [ ] Code review +- [ ] Open PR targeting `master` +- [ ] Manual GPU proxy check (see test-plan manual section) after PR is up +- [ ] Archive this OpenSpec folder after merge diff --git a/openspec/changes/2026-06-09-remote-texture-export/test-plan.md b/openspec/changes/2026-06-09-remote-texture-export/test-plan.md new file mode 100644 index 0000000..12e0d92 --- /dev/null +++ b/openspec/changes/2026-06-09-remote-texture-export/test-plan.md @@ -0,0 +1,115 @@ +# Test Plan: remote texture export via GetTextureData + +## Scope + +### In scope +- `_decode_dtype` table + `_decode_texture_png` helper: format coverage, channel fixups, + signed remap, length guard, empty-data guard +- `tex_export` in remote mode: PNG written locally +- `rt_export` in remote mode: PNG written locally +- `rt_depth` in remote mode: grayscale PNG written locally +- Regression: local (non-remote) mode still routes through `SaveTexture` unchanged +- Regression: `rt_overlay` still returns an error in remote mode +- `mock_renderdoc.py` extensions required for the above + +### Out of scope +- `rt_overlay` correctness (guard is unchanged) +- Block-compressed / packed formats (returns -32002; negative tests cover the class) +- MSAA textures (guarded; one negative test) +- Live GPU proxy (manual only; see below) + +## Test Matrix + +| Layer | Type | File | +|-------|------|------| +| Unit | `_decode_texture_png` + export handlers (mock) | `tests/unit/test_tex_stats_handler.py` | +| Unit | mock infrastructure | `tests/mocks/mock_renderdoc.py` | + +## Mock infrastructure (prerequisite) + +`tests/mocks/mock_renderdoc.py` provides, for the remote-decode tests: + +- `ResourceFormat(name, compByteWidth, compCount, compType, type=...)` with `BGRAOrder()` + and `Name()`; `type` defaults to Regular and is set non-Regular for packed/block tests. +- `MockReplayController._texture_data[rid]` pre-populated by each test with the raw bytes; + `GetTextureData(rid, sub)` returns it (and can return `b""` to exercise the empty guard). +- `MockPipeState(output_targets=..., depth_target=...)` and `Descriptor(resource=...)` for + the `rt_export` / `rt_depth` paths. + +## Unit test cases + +All remote cases run with `is_remote=True`; no real GPU, no `SaveTexture` call. +Test names are the actual functions present in `tests/unit/test_tex_stats_handler.py`. + +### Color decode — accepted formats + +1. `test_tex_export_remote_rgba8` — R8G8B8A8_UNORM 4x2: valid PNG, size (4,2), mode RGBA. +2. `test_tex_export_remote_bgra_swaps_channels` — B8G8R8A8_UNORM 1x1: input B,G,R,A bytes + decode to RGBA pixel with R/B swapped. +3. `test_tex_export_remote_r8_grayscale` — R8_UNORM 2x2 (cc=1): single channel repeated to + RGB (pixel `(10,10,10)`), output is RGBA. +4. `test_tex_export_remote_float16_hdr` — R16G16B16A16_FLOAT 1x1: value 2.0 clipped + sRGB + encoded to 255; alpha 255. +5. `test_tex_export_remote_r16_unorm_scales_by_257` — R16_UNORM: 65535/257=255, 32896/257=128. +6. `test_tex_export_remote_rgba32f_hdr_clip` — R32G32B32A32_FLOAT 1x1: 5.0 clipped to 1.0 -> + sRGB -> 255; 0.0 -> 0; alpha 255. +7. `test_tex_export_remote_snorm_remaps_signed` — R8G8B8A8_SNORM read as int8: -1->0, + 0->~128, +1->255. +8. `test_tex_export_remote_uint8_passthrough` — R8G8B8A8_UINT 1x1: bytes pass through to RGBA. + +### Color decode — rejected formats (→ -32002) + +9. `test_tex_export_remote_length_mismatch_errors` — 4x4 RGBA8 with 4 bytes only -> -32002. +10. `test_tex_export_remote_special_format_rejected` — BC1_UNORM (non-Regular) -> + "not supported". +11. `test_tex_export_remote_sint_rejected` — R8G8B8A8_SINT (no display mapping) -> + "not supported". +12. `test_tex_export_remote_typeless_rejected` — R8G8B8A8_TYPELESS -> -32002. +13. `test_tex_export_remote_uscaled_rejected` — R8G8B8A8_USCALED -> -32002. +14. `test_tex_export_remote_packed_format_rejected` — R11G11B10_FLOAT (packed, non-Regular) + -> "not supported". +15. `test_tex_export_remote_msaa_rejected` — RGBA8_UNORM with `msSamp=4` -> -32002. +16. `test_tex_export_remote_no_data_rejected` — `GetTextureData` returns `b""` -> + "no texture data", no `len(None)` crash. + +### `rt_export` remote mode + +17. `test_rt_export_remote_decodes_png` — `MockPipeState` output target -> B8G8R8A8_SRGB 2x2 + `TextureDescription` in `tex_map`; result path is a valid PNG, mode RGBA. + +### `rt_depth` remote mode + +18. `test_rt_depth_remote_decodes_grayscale` — D32_FLOAT 2x2 depth target: mode `L`, min + depth -> 0, max depth -> 255, mid depth ~64. +19. `test_rt_depth_remote_d16_decodes_grayscale` — D16 (uint16 depth, compType=Depth) 2x2: + mode `L`, 0 -> 0, 65535 -> 255, 16384 -> ~64. + +### Regression: local mode + rt_overlay + +20. `test_rt_overlay_remote_still_rejected` — `rt_overlay` remote -> -32002, "remote mode". +21. `test_tex_export_local_uses_savetexture` — `is_remote=False`: `SaveTexture` is called + exactly once (local path unchanged, not routed through the decode helper). + +## Manual / GPU proxy check + +The following cannot run in CI (requires a live remote daemon + real GPU): + +- Connect client to a remote daemon replaying a vkcube capture; call `tex_export` on a + known color target; verify the local PNG opens correctly and matches the texture as + displayed in the RenderDoc GUI. +- Same for `rt_export` and `rt_depth`. +- Verify a BC7-compressed texture returns `-32002` (not a garbled PNG). + +## Run command + +```bash +pytest tests/unit/test_tex_stats_handler.py -q +``` + +Optional (explicit interpreter / renderdoc module path used during development): + +```bash +RENDERDOC_PYTHON_PATH=/usr/lib/python3.14/site-packages ./.venv-236/bin/python -m pytest tests/unit/test_tex_stats_handler.py -q +``` + +Full gate: `pixi run check` (lint + typecheck + unit tests). diff --git a/src/rdc/commands/_helpers.py b/src/rdc/commands/_helpers.py index dcafd71..7d2f0ab 100644 --- a/src/rdc/commands/_helpers.py +++ b/src/rdc/commands/_helpers.py @@ -22,6 +22,7 @@ "require_renderdoc", "call", "call_binary", + "call_with_code", "try_call", "completion_call", "fetch_remote_file", @@ -142,6 +143,29 @@ def try_call(method: str, params: dict[str, Any]) -> dict[str, Any] | None: return cast(dict[str, Any], response.get("result", {})) +def call_with_code(method: str, params: dict[str, Any]) -> tuple[dict[str, Any] | None, int | None]: + """Send a JSON-RPC request, returning (result, error_code). + + Like try_call() but surfaces the JSON-RPC error code so callers can + distinguish a missing resource (-32001) from an unsupported operation + (-32002). On success returns (result, None); on any failure returns + (None, code) where code is the daemon error code or None when the call + never reached the daemon. + """ + try: + host, port, token = require_session() + except SystemExit: + return None, None + payload = _request(method, 1, {"_token": token, **params}).to_dict() + try: + response = send_request(host, port, payload) + except (OSError, ValueError): + return None, None + if "error" in response: + return None, response["error"].get("code") + return cast(dict[str, Any], response.get("result", {})), None + + def completion_call(method: str, params: dict[str, Any]) -> dict[str, Any] | None: """Send a request for shell completion, silently returning None on failure.""" with contextlib.redirect_stderr(io.StringIO()): diff --git a/src/rdc/commands/snapshot.py b/src/rdc/commands/snapshot.py index 95a177b..08aa95d 100644 --- a/src/rdc/commands/snapshot.py +++ b/src/rdc/commands/snapshot.py @@ -8,7 +8,13 @@ import click -from rdc.commands._helpers import call, complete_eid, fetch_remote_file, try_call +from rdc.commands._helpers import ( + call, + call_with_code, + complete_eid, + fetch_remote_file, + try_call, +) from rdc.formatters.json_fmt import write_json @@ -39,21 +45,30 @@ def snapshot_cmd(eid: int, output: str, use_json: bool) -> None: (out_dir / f"shader_{stage}.txt").write_text(disasm_resp["disasm"]) files.append(f"shader_{stage}.txt") - # Color targets (stop on first failure) + # Color targets: stop when a target is absent (-32001), but skip-and-warn + # on a decode failure (-32002, e.g. an unsupported remote format) so one + # bad target does not silently truncate the rest of the bundle. for i in range(8): - result = try_call("rt_export", {"eid": eid, "target": i}) - if result is None: - break - data = fetch_remote_file(result["path"]) - (out_dir / f"color{i}.png").write_bytes(data) - files.append(f"color{i}.png") + result, code = call_with_code("rt_export", {"eid": eid, "target": i}) + if result is not None: + data = fetch_remote_file(result["path"]) + (out_dir / f"color{i}.png").write_bytes(data) + files.append(f"color{i}.png") + continue + if code == -32002: + click.echo(f"snapshot: skipped color{i} (decode unsupported)", err=True) + continue + break - # Depth target - depth_result = try_call("rt_depth", {"eid": eid}) - if depth_result: + # Depth target: surface a warning when remote depth decode is unsupported + # (e.g. D24S8 or MSAA) instead of silently omitting depth.png. + depth_result, depth_code = call_with_code("rt_depth", {"eid": eid}) + if depth_result is not None: data = fetch_remote_file(depth_result["path"]) (out_dir / "depth.png").write_bytes(data) files.append("depth.png") + elif depth_code == -32002: + click.echo("snapshot: skipped depth (decode unsupported)", err=True) # Manifest manifest = { diff --git a/src/rdc/handlers/_helpers.py b/src/rdc/handlers/_helpers.py index fdac504..9a128e5 100644 --- a/src/rdc/handlers/_helpers.py +++ b/src/rdc/handlers/_helpers.py @@ -254,6 +254,145 @@ def _make_subresource(rd: Any, mip: int = 0) -> Any: return sub +def _srgb_encode(linear: Any) -> Any: + """Apply the sRGB OETF to a clipped [0, 1] float array.""" + import numpy as np + + lo = linear <= 0.0031308 + return np.where(lo, linear * 12.92, 1.055 * np.power(linear, 1.0 / 2.4) - 0.055) + + +def _decode_dtype(rd: Any, comp_type: int, comp_byte_width: int) -> str | None: + """numpy dtype name for a (CompType, compByteWidth) pair, or None to reject. + + Pairs absent from this map have no unambiguous 8-bit display mapping + (Typeless, SInt, UScaled, SScaled, exotic widths) and are rejected rather + than guessed. Float covers half/single; packed floats (R11G11B10, R9G9B9E5) + are non-Regular and never reach here. + """ + ct = rd.CompType + table: dict[tuple[int, int], str] = { + (int(ct.Float), 2): "float16", + (int(ct.Float), 4): "float32", + (int(ct.UNorm), 1): "uint8", + (int(ct.UNorm), 2): "uint16", + (int(ct.UNormSRGB), 1): "uint8", + (int(ct.SNorm), 1): "int8", + (int(ct.SNorm), 2): "int16", + (int(ct.UInt), 1): "uint8", + (int(ct.UInt), 2): "uint16", + (int(ct.Depth), 1): "uint8", + (int(ct.Depth), 2): "uint16", + (int(ct.Depth), 4): "float32", + } + return table.get((comp_type, comp_byte_width)) + + +def _decode_texture_png(rd: Any, tex: Any, raw: bytes, mip: int, *, is_depth: bool) -> bytes | None: + """Decode tightly packed GetTextureData bytes into PNG bytes. + + Handles the full ``ResourceFormatType.Regular`` space deliberately: every + (CompType, compByteWidth) pair we can display is mapped to a numpy dtype and + an explicit 8-bit conversion. Any pair not in the table (Typeless, SInt, + UScaled, SScaled, exotic widths), every non-Regular format (block-compressed, + packed, combined depth-stencil), MSAA, length mismatches, and empty data all + return ``None`` so the caller emits a clean error rather than a wrong image. + + Args: + rd: The renderdoc module. + tex: TextureDescription for the resource. + raw: Tightly packed pixel bytes for one subresource (top-down). + mip: Mip level the bytes correspond to. + is_depth: Whether to render the data as a single grayscale depth channel. + + For 3D textures (``depth > 1``) ``GetTextureData`` returns the whole + width*height*depth mip. Every depth slice is tiled vertically into a single + ``(depth*height, width)`` image so no slice is silently dropped; all slices + share the same channel/sRGB/BGRA/expand processing. ``depth == 1`` is + byte-for-byte identical to the 2D path. + + Returns: + PNG-encoded bytes, or ``None`` if the format cannot be decoded. + """ + import io + + import numpy as np + from PIL import Image + + if not raw: + return None + + fmt = tex.format + if fmt.type != rd.ResourceFormatType.Regular: + return None + if getattr(tex, "msSamp", 1) > 1: + return None + + width = max(1, tex.width >> mip) + height = max(1, tex.height >> mip) + depth_lvl = max(1, getattr(tex, "depth", 1) >> mip) + cc = fmt.compCount + cbw = fmt.compByteWidth + if cc <= 0 or len(raw) != width * height * depth_lvl * cc * cbw: + return None + + ct = int(fmt.compType) + dtype_name = _decode_dtype(rd, ct, cbw) + if dtype_name is None: + return None + # Tile depth slices vertically: (depth*height, width, cc). + arr = np.frombuffer(raw, dtype=np.dtype(dtype_name)).reshape((depth_lvl * height, width, cc)) + height = depth_lvl * height + + if is_depth: + d = arr[:, :, 0].astype(np.float32) + d_min, d_max = float(d.min()), float(d.max()) + norm = (d - d_min) / (d_max - d_min) if d_max > d_min else np.zeros_like(d) + gray = (norm * 255.0).round().astype(np.uint8) + buf = io.BytesIO() + Image.fromarray(gray, mode="L").save(buf, format="PNG") + return buf.getvalue() + + if ct == int(rd.CompType.Float): + sanitized = np.nan_to_num(arr.astype(np.float32), nan=0.0, posinf=1.0, neginf=0.0) + f = np.clip(sanitized, 0.0, 1.0) + if cc == 4: + rgb8 = (_srgb_encode(f[:, :, :3]) * 255.0).round().astype(np.uint8) + a8 = (f[:, :, 3:4] * 255.0).round().astype(np.uint8) + rgba8 = np.concatenate([rgb8, a8], axis=2) + else: + rgba8 = (_srgb_encode(f) * 255.0).round().astype(np.uint8) + elif ct == int(rd.CompType.SNorm): + # [-1, 1] -> [0, 1]; divisor is the signed-int max for the width. + denom = float(np.iinfo(np.dtype(dtype_name)).max) + f = np.clip(arr.astype(np.float32) / denom, -1.0, 1.0) * 0.5 + 0.5 + rgba8 = (f * 255.0).round().astype(np.uint8) + elif dtype_name == "uint16": + rgba8 = (arr / 257.0).round().astype(np.uint8) + else: + rgba8 = arr.astype(np.uint8) + + if fmt.BGRAOrder() and cc >= 3: + rgba8 = rgba8[:, :, [2, 1, 0] + list(range(3, cc))] + + if cc == 1: + rgb = np.repeat(rgba8, 3, axis=2) + out = np.dstack([rgb, np.full((height, width, 1), 255, np.uint8)]) + elif cc == 2: + zero = np.zeros((height, width, 1), np.uint8) + alpha = np.full((height, width, 1), 255, np.uint8) + out = np.concatenate([rgba8, zero, alpha], axis=2) + elif cc == 3: + alpha = np.full((height, width, 1), 255, np.uint8) + out = np.concatenate([rgba8, alpha], axis=2) + else: + out = rgba8 + + buf = io.BytesIO() + Image.fromarray(out, mode="RGBA").save(buf, format="PNG") + return buf.getvalue() + + def require_pipe(params: dict[str, Any], state: DaemonState, request_id: int) -> tuple[int, Any]: """Validate adapter, set eid, return pipe_state. diff --git a/src/rdc/handlers/texture.py b/src/rdc/handlers/texture.py index effd74b..f2b3273 100644 --- a/src/rdc/handlers/texture.py +++ b/src/rdc/handlers/texture.py @@ -7,6 +7,7 @@ from rdc.handlers._helpers import ( PipeError, + _decode_texture_png, _error_response, _make_subresource, _make_texsave, @@ -62,11 +63,42 @@ def _handle_tex_info( ), True +def _export_remote( + request_id: int, + state: DaemonState, + tex: Any, + resource_id: Any, + temp_path: Path, + mip: int, + *, + is_depth: bool = False, +) -> tuple[dict[str, Any], bool]: + """Fetch raw pixels over the wire and decode them locally to a PNG.""" + controller = state.adapter.controller # type: ignore[union-attr] + sub = _make_subresource(state.rd, mip) + try: + raw = controller.GetTextureData(resource_id, sub) + except Exception as exc: # noqa: BLE001 + return _error_response(request_id, -32002, f"GetTextureData failed: {exc}"), True + if not raw: + return _error_response(request_id, -32002, "no texture data returned"), True + png = _decode_texture_png(state.rd, tex, raw, mip, is_depth=is_depth) + if png is None: + fmt_name = tex.format.Name() if hasattr(tex.format, "Name") else "" + return _error_response( + request_id, -32002, f"format {fmt_name} not supported for remote decode" + ), True + try: + temp_path.write_bytes(png) + size = temp_path.stat().st_size + except OSError as exc: + return _error_response(request_id, -32002, f"failed to write export: {exc}"), True + return _result_response(request_id, {"path": str(temp_path), "size": size}), True + + def _handle_tex_export( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: - if state.is_remote: - return _error_response(request_id, -32002, "not supported in remote mode"), True if state.adapter is None: return _error_response(request_id, -32002, "no replay loaded"), True if state.rd is None: @@ -87,6 +119,8 @@ def _handle_tex_export( if err: return _error_response(request_id, -32002, err), True temp_path = state.temp_dir / f"tex_{res_id}_mip{mip}.png" + if state.is_remote: + return _export_remote(request_id, state, tex, tex.resourceId, temp_path, mip) controller = state.adapter.controller texsave = _make_texsave(state.rd, tex.resourceId, mip) success = controller.SaveTexture(texsave, str(temp_path)) @@ -117,7 +151,12 @@ def _handle_tex_raw( return _error_response(request_id, -32002, err), True controller = state.adapter.controller sub = _make_subresource(state.rd) - raw_data = controller.GetTextureData(tex.resourceId, sub) + try: + raw_data = controller.GetTextureData(tex.resourceId, sub) + except Exception as exc: # noqa: BLE001 + return _error_response(request_id, -32002, f"GetTextureData failed: {exc}"), True + if not raw_data: + return _error_response(request_id, -32002, "no texture data returned"), True temp_path = state.temp_dir / f"tex_{res_id}.raw" temp_path.write_bytes(raw_data) return _result_response( @@ -129,8 +168,6 @@ def _handle_tex_raw( def _handle_rt_export( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: - if state.is_remote: - return _error_response(request_id, -32002, "not supported in remote mode"), True if state.rd is None: return _error_response(request_id, -32002, "renderdoc module not available"), True if state.temp_dir is None: @@ -147,8 +184,14 @@ def _handle_rt_export( match = [t for i, t in non_null if i == target_idx] if not match: return _error_response(request_id, -32001, f"target index {target_idx} out of range"), True + resource = match[0].resource temp_path = state.temp_dir / f"rt_{eid}_color{target_idx}.png" - texsave = _make_texsave(state.rd, match[0].resource) + if state.is_remote: + tex = state.tex_map.get(int(resource)) + if tex is None: + return _error_response(request_id, -32001, f"target {int(resource)} not found"), True + return _export_remote(request_id, state, tex, resource, temp_path, 0) + texsave = _make_texsave(state.rd, resource) success = state.adapter.controller.SaveTexture(texsave, str(temp_path)) # type: ignore[union-attr] if not success or not temp_path.exists(): return _error_response(request_id, -32002, "SaveTexture failed"), True @@ -161,8 +204,6 @@ def _handle_rt_export( def _handle_rt_depth( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: - if state.is_remote: - return _error_response(request_id, -32002, "not supported in remote mode"), True if state.rd is None: return _error_response(request_id, -32002, "renderdoc module not available"), True if state.temp_dir is None: @@ -175,6 +216,13 @@ def _handle_rt_depth( if int(depth.resource) == 0: return _error_response(request_id, -32001, f"no depth target at eid {eid}"), True temp_path = state.temp_dir / f"rt_{eid}_depth.png" + if state.is_remote: + tex = state.tex_map.get(int(depth.resource)) + if tex is None: + return _error_response( + request_id, -32001, f"depth target {int(depth.resource)} not found" + ), True + return _export_remote(request_id, state, tex, depth.resource, temp_path, 0, is_depth=True) texsave = _make_texsave(state.rd, depth.resource) success = state.adapter.controller.SaveTexture(texsave, str(temp_path)) # type: ignore[union-attr] if not success or not temp_path.exists(): diff --git a/tests/mocks/mock_renderdoc.py b/tests/mocks/mock_renderdoc.py index f443f49..f9fa2d5 100644 --- a/tests/mocks/mock_renderdoc.py +++ b/tests/mocks/mock_renderdoc.py @@ -320,6 +320,20 @@ class CompType(IntEnum): UNormSRGB = 9 +class ResourceFormatType(IntEnum): + Regular = 0 + BC1 = 2 + R10G10B10A2 = 12 + R11G11B10 = 13 + R5G6B5 = 14 + R9G9B9E5 = 16 + D16S8 = 19 + D24S8 = 20 + D32S8 = 21 + S8 = 22 + A8 = 28 + + class MeshDataStage(IntEnum): VSIn = 0 VSOut = 1 diff --git a/tests/unit/test_snapshot_command.py b/tests/unit/test_snapshot_command.py index cf99780..73d5b89 100644 --- a/tests/unit/test_snapshot_command.py +++ b/tests/unit/test_snapshot_command.py @@ -220,6 +220,83 @@ def test_multiple_color_targets(self, tmp_path: Path) -> None: assert len(color_files) == 3 +class TestSnapshotRemoteSkips: + """#8/#9: a -32002 decode failure must skip-and-warn, not silently truncate.""" + + def _send_request_with_codes( + self, tmp_path: Path, *, color_error_code: int, depth_error_code: int + ) -> Any: + color0 = _make_temp_png(tmp_path, "tmp_color0.png") + + def fake_send_request( + host: str, port: int, payload: dict[str, Any], **_kw: Any + ) -> dict[str, Any]: + method = payload["method"] + params = payload.get("params", {}) + if method == "shader_all": + return {"jsonrpc": "2.0", "id": 1, "result": {"eid": 142, "stages": []}} + if method == "rt_export": + idx = params.get("target", 0) + if idx == 0: + return {"jsonrpc": "2.0", "id": 1, "result": {"path": color0, "size": 1}} + if idx == 1: + # Unsupported format on target 1 -> -32002. + return { + "jsonrpc": "2.0", + "id": 1, + "error": {"code": color_error_code, "message": "not supported"}, + } + # Target 2+ absent -> -32001 stop. + return { + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32001, "message": "out of range"}, + } + if method == "rt_depth": + return { + "jsonrpc": "2.0", + "id": 1, + "error": {"code": depth_error_code, "message": "unsupported depth"}, + } + return {"jsonrpc": "2.0", "id": 1, "result": {}} + + return fake_send_request + + def test_color_decode_failure_skips_and_continues(self, tmp_path: Path) -> None: + out_dir = tmp_path / "snap" + mock_sr = self._send_request_with_codes( + tmp_path, color_error_code=-32002, depth_error_code=-32001 + ) + with ( + patch.object(snap_mod, "call", return_value=_PIPELINE_RESPONSE), + patch.object(helpers_mod, "require_session", return_value=_SESSION), + patch.object(helpers_mod, "send_request", side_effect=mock_sr), + ): + result = CliRunner().invoke(main, ["snapshot", "142", "-o", str(out_dir)]) + + assert result.exit_code == 0 + # color0 written, color1 skipped (decode), loop reached color2 (absent) and stopped. + assert (out_dir / "color0.png").exists() + assert not (out_dir / "color1.png").exists() + assert "skipped color1" in result.output + + def test_depth_decode_failure_warns(self, tmp_path: Path) -> None: + out_dir = tmp_path / "snap" + mock_sr = self._send_request_with_codes( + tmp_path, color_error_code=-32001, depth_error_code=-32002 + ) + with ( + patch.object(snap_mod, "call", return_value=_PIPELINE_RESPONSE), + patch.object(helpers_mod, "require_session", return_value=_SESSION), + patch.object(helpers_mod, "send_request", side_effect=mock_sr), + ): + result = CliRunner().invoke(main, ["snapshot", "142", "-o", str(out_dir)]) + + assert result.exit_code == 0 + assert not (out_dir / "depth.png").exists() + assert "skipped depth" in result.output + + class TestSnapshotFatalFailures: def test_pipeline_fails(self, tmp_path: Path) -> None: out_dir = tmp_path / "snap" diff --git a/tests/unit/test_tex_stats_handler.py b/tests/unit/test_tex_stats_handler.py index 5148c6a..1400f07 100644 --- a/tests/unit/test_tex_stats_handler.py +++ b/tests/unit/test_tex_stats_handler.py @@ -2,8 +2,13 @@ from __future__ import annotations +import io +import struct + import mock_renderdoc as rd +import numpy as np from conftest import make_daemon_state, rpc_request +from PIL import Image from rdc.daemon_server import DaemonState, _handle_request @@ -291,26 +296,390 @@ def test_tex_stats_histogram_channel_length_mismatch() -> None: # --------------------------------------------------------------------------- -# Remote mode rejection +# Remote-mode export via GetTextureData decode (#236) # --------------------------------------------------------------------------- -def test_tex_export_remote_rejected() -> None: - state = make_daemon_state(is_remote=True, rd=rd) - resp, _ = _handle_request(rpc_request("tex_export", {"id": 1}), state) +def _remote_state( + tex: rd.TextureDescription, + raw: bytes, + tmp_path: object, + *, + output_targets: list[rd.Descriptor] | None = None, + depth_target: rd.Descriptor | None = None, +) -> DaemonState: + ctrl = rd.MockReplayController() + ctrl._textures = [tex] + ctrl._texture_data[int(tex.resourceId)] = raw + if output_targets is not None or depth_target is not None: + ctrl._pipe_state = rd.MockPipeState( + output_targets=output_targets, depth_target=depth_target + ) + return make_daemon_state( + ctrl=ctrl, + current_eid=100, + rd=rd, + tmp_path=tmp_path, + tex_map={int(tex.resourceId): tex}, + is_remote=True, + ) + + +def _read_png(path: str) -> Image.Image: + with open(path, "rb") as fh: + data = fh.read() + assert data[:4] == b"\x89PNG" + return Image.open(io.BytesIO(data)) + + +def test_tex_export_remote_rgba8(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(96), width=4, height=2, format=fmt) + raw = bytes(range(4 * 2 * 4)) + state = _remote_state(tex, raw, tmp_path) + resp, running = _handle_request(rpc_request("tex_export", {"id": 96}), state) + assert running + img = _read_png(resp["result"]["path"]) + assert img.size == (4, 2) + assert img.mode == "RGBA" + assert resp["result"]["size"] > 0 + + +def test_tex_export_remote_bgra_swaps_channels(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="B8G8R8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(96), width=1, height=1, format=fmt) + raw = bytes([10, 20, 30, 40]) # B,G,R,A + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 96}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0)) == (30, 20, 10, 40) + + +def test_tex_export_remote_r8_grayscale(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8_UNORM", compByteWidth=1, compCount=1, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(7), width=2, height=2, format=fmt) + raw = bytes([10, 20, 30, 40]) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 7}), state) + img = _read_png(resp["result"]["path"]) + assert img.size == (2, 2) + assert img.getpixel((0, 0))[:3] == (10, 10, 10) + + +def test_tex_export_remote_float16_hdr(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R16G16B16A16_FLOAT", compByteWidth=2, compCount=4, compType=1) + tex = rd.TextureDescription(resourceId=rd.ResourceId(8), width=1, height=1, format=fmt) + raw = np.array([2.0, 0.5, 0.0, 1.0], dtype=np.float16).tobytes() + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 8}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[0] == 255 # clipped + sRGB encode of 1.0 + assert px[3] == 255 + + +def test_tex_export_remote_length_mismatch_errors(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(96), width=4, height=4, format=fmt) + state = _remote_state(tex, b"\x00\x01\x02\x03", tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 96}), state) assert resp["error"]["code"] == -32002 - assert "not supported in remote mode" in resp["error"]["message"] -def test_rt_export_remote_rejected() -> None: - state = make_daemon_state(is_remote=True, rd=rd) - resp, _ = _handle_request(rpc_request("rt_export", {}), state) +def test_tex_export_remote_special_format_rejected(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="BC1_UNORM", compByteWidth=0, compCount=4, compType=2, type=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(96), width=4, height=4, format=fmt) + state = _remote_state(tex, b"\x00" * 8, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 96}), state) + assert resp["error"]["code"] == -32002 + assert "not supported" in resp["error"]["message"] + + +def test_rt_export_remote_decodes_png(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="B8G8R8A8_SRGB", compByteWidth=1, compCount=4, compType=9) + tex = rd.TextureDescription(resourceId=rd.ResourceId(96), width=2, height=2, format=fmt) + raw = bytes(range(2 * 2 * 4)) + targets = [rd.Descriptor(resource=rd.ResourceId(96))] + state = _remote_state(tex, raw, tmp_path, output_targets=targets) + resp, _ = _handle_request(rpc_request("rt_export", {"eid": 100}), state) + img = _read_png(resp["result"]["path"]) + assert img.size == (2, 2) + assert img.mode == "RGBA" + + +def test_rt_depth_remote_decodes_grayscale(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="D32_FLOAT", compByteWidth=4, compCount=1, compType=8) + tex = rd.TextureDescription(resourceId=rd.ResourceId(305), width=2, height=2, format=fmt) + raw = struct.pack("<4f", 0.0, 0.25, 0.75, 1.0) + depth = rd.Descriptor(resource=rd.ResourceId(305)) + state = _remote_state(tex, raw, tmp_path, depth_target=depth) + resp, _ = _handle_request(rpc_request("rt_depth", {"eid": 100}), state) + img = _read_png(resp["result"]["path"]) + assert img.size == (2, 2) + assert img.mode == "L" + assert img.getpixel((0, 0)) == 0 + assert img.getpixel((1, 1)) == 255 + # depth=0.25 over [0,1] range -> 0.25*255 = 63.75 -> 64; uint32 reinterpret gives ~251 + assert abs(img.getpixel((1, 0)) - 64) <= 2 + + +def test_rt_depth_remote_d16_decodes_grayscale(tmp_path: object) -> None: + # compType 8 = Depth; D16 is uint16, not float16 + fmt = rd.ResourceFormat(name="D16", compByteWidth=2, compCount=1, compType=8) + tex = rd.TextureDescription(resourceId=rd.ResourceId(306), width=2, height=2, format=fmt) + raw = struct.pack("<4H", 0, 16384, 49152, 65535) # uint16 depths + depth = rd.Descriptor(resource=rd.ResourceId(306)) + state = _remote_state(tex, raw, tmp_path, depth_target=depth) + resp, _ = _handle_request(rpc_request("rt_depth", {"eid": 100}), state) + img = _read_png(resp["result"]["path"]) + assert img.size == (2, 2) + assert img.mode == "L" + assert img.getpixel((0, 0)) == 0 + assert img.getpixel((1, 1)) == 255 + # depth=16384 over [0,65535] -> 16384/65535*255 = 63.75 -> 64 + assert abs(img.getpixel((1, 0)) - 64) <= 2 + + +def test_tex_export_remote_r16_unorm_scales_by_257(tmp_path: object) -> None: + # R16 UNorm: 16-bit -> 8-bit via /257. 65535/257 = 255, 32896/257 = 128. + fmt = rd.ResourceFormat(name="R16_UNORM", compByteWidth=2, compCount=1, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(50), width=2, height=1, format=fmt) + raw = struct.pack("<2H", 65535, 32896) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 50}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0))[:3] == (255, 255, 255) + assert img.getpixel((1, 0))[:3] == (128, 128, 128) + + +def test_tex_export_remote_rgba32f_hdr_clip(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R32G32B32A32_FLOAT", compByteWidth=4, compCount=4, compType=1) + tex = rd.TextureDescription(resourceId=rd.ResourceId(51), width=1, height=1, format=fmt) + raw = np.array([5.0, 0.0, 0.0, 1.0], dtype=np.float32).tobytes() + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 51}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[0] == 255 # clipped to 1.0 -> sRGB -> 255 + assert px[1] == 0 # 0.0 -> 0 + assert px[3] == 255 + + +def test_tex_export_remote_float_rgba_alpha_linear(tmp_path: object) -> None: + # Float RGBA: RGB gets sRGB OETF, alpha stays LINEAR. alpha 0.5 -> ~128, not ~188. + fmt = rd.ResourceFormat(name="R32G32B32A32_FLOAT", compByteWidth=4, compCount=4, compType=1) + tex = rd.TextureDescription(resourceId=rd.ResourceId(52), width=1, height=1, format=fmt) + raw = np.array([1.0, 1.0, 1.0, 0.5], dtype=np.float32).tobytes() + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 52}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[:3] == (255, 255, 255) # 1.0 -> sRGB -> 255 + assert abs(px[3] - 128) <= 2 # alpha linear 0.5 -> ~128 + assert px[3] < 180 # gamma-encoding would give ~188 + + +def test_tex_export_remote_snorm_remaps_signed(tmp_path: object) -> None: + # SNorm normal map: -1 -> 0, 0 -> ~128, +1 -> 255. Read as int8, not uint8. + fmt = rd.ResourceFormat(name="R8G8B8A8_SNORM", compByteWidth=1, compCount=4, compType=3) + tex = rd.TextureDescription(resourceId=rd.ResourceId(52), width=1, height=1, format=fmt) + raw = struct.pack("<4b", -127, 0, 127, 127) # R=-1, G=0, B=+1, A=+1 + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 52}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[0] == 0 # -1 -> 0 + assert abs(px[1] - 128) <= 1 # 0 -> ~128 + assert px[2] == 255 # +1 -> 255 + + +def test_tex_export_remote_uint8_passthrough(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UINT", compByteWidth=1, compCount=4, compType=4) + tex = rd.TextureDescription(resourceId=rd.ResourceId(53), width=1, height=1, format=fmt) + raw = bytes([10, 20, 30, 40]) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 53}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0)) == (10, 20, 30, 40) + + +def test_tex_export_remote_sint_rejected(tmp_path: object) -> None: + # SInt has no unambiguous display mapping -> clean reject. + fmt = rd.ResourceFormat(name="R8G8B8A8_SINT", compByteWidth=1, compCount=4, compType=5) + tex = rd.TextureDescription(resourceId=rd.ResourceId(54), width=1, height=1, format=fmt) + state = _remote_state(tex, bytes([1, 2, 3, 4]), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 54}), state) + assert resp["error"]["code"] == -32002 + assert "not supported" in resp["error"]["message"] + + +def test_tex_export_remote_typeless_rejected(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_TYPELESS", compByteWidth=1, compCount=4, compType=0) + tex = rd.TextureDescription(resourceId=rd.ResourceId(55), width=1, height=1, format=fmt) + state = _remote_state(tex, bytes([1, 2, 3, 4]), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 55}), state) + assert resp["error"]["code"] == -32002 + + +def test_tex_export_remote_uscaled_rejected(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_USCALED", compByteWidth=1, compCount=4, compType=6) + tex = rd.TextureDescription(resourceId=rd.ResourceId(56), width=1, height=1, format=fmt) + state = _remote_state(tex, bytes([1, 2, 3, 4]), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 56}), state) + assert resp["error"]["code"] == -32002 + + +def test_tex_export_remote_packed_format_rejected(tmp_path: object) -> None: + # R11G11B10 is a packed (non-Regular) format -> reject. + fmt = rd.ResourceFormat( + name="R11G11B10_FLOAT", compByteWidth=4, compCount=3, compType=1, type=13 + ) + tex = rd.TextureDescription(resourceId=rd.ResourceId(57), width=1, height=1, format=fmt) + state = _remote_state(tex, bytes(4), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 57}), state) + assert resp["error"]["code"] == -32002 + assert "not supported" in resp["error"]["message"] + + +def test_tex_export_remote_msaa_rejected(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription( + resourceId=rd.ResourceId(58), width=1, height=1, format=fmt, msSamp=4 + ) + state = _remote_state(tex, bytes(4), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 58}), state) assert resp["error"]["code"] == -32002 - assert "not supported in remote mode" in resp["error"]["message"] -def test_rt_depth_remote_rejected() -> None: +def test_tex_export_remote_no_data_rejected(tmp_path: object) -> None: + # GetTextureData returns empty -> clean -32002, no len(None) crash. + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(59), width=2, height=2, format=fmt) + state = _remote_state(tex, b"", tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 59}), state) + assert resp["error"]["code"] == -32002 + assert "no texture data" in resp["error"]["message"] + + +def test_tex_export_remote_3d_tiles_depth_slices(tmp_path: object) -> None: + # 3D RGBA8 depth=2: GetTextureData returns the whole w*h*depth mip. + # Slices are tiled vertically into one (depth*height, width) image. + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription( + resourceId=rd.ResourceId(70), width=2, height=2, depth=2, format=fmt + ) + raw = bytes(range(2 * 2 * 2 * 4)) # depth*height*width*cc + state = _remote_state(tex, raw, tmp_path) + resp, running = _handle_request(rpc_request("tex_export", {"id": 70}), state) + assert running + img = _read_png(resp["result"]["path"]) + assert img.size == (2, 4) # width=2, height=depth*height=4 + assert img.mode == "RGBA" + # First pixel of slice 0 and first pixel of slice 1 differ. + assert img.getpixel((0, 0)) == (0, 1, 2, 3) + assert img.getpixel((0, 2)) == (16, 17, 18, 19) + + +def test_tex_export_remote_float_nan_renders_black(tmp_path: object, recwarn: object) -> None: + # NaN in a float HDR channel must render as 0 with no numpy RuntimeWarning. + fmt = rd.ResourceFormat(name="R16G16B16A16_FLOAT", compByteWidth=2, compCount=4, compType=1) + tex = rd.TextureDescription(resourceId=rd.ResourceId(71), width=1, height=1, format=fmt) + raw = np.array([np.nan, 0.5, 0.0, 1.0], dtype=np.float16).tobytes() + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 71}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[0] == 0 # NaN -> 0 + assert px[3] == 255 + assert not any(issubclass(w.category, RuntimeWarning) for w in recwarn) # type: ignore[attr-defined] + + +def test_tex_export_remote_rg8_two_channel(tmp_path: object) -> None: + # cc=2 R8G8_UNORM: B forced to 0, alpha forced to 255. + fmt = rd.ResourceFormat(name="R8G8_UNORM", compByteWidth=1, compCount=2, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(72), width=1, height=1, format=fmt) + raw = bytes([90, 160]) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 72}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0)) == (90, 160, 0, 255) + + +def test_tex_export_remote_rgb8_three_channel_appends_alpha(tmp_path: object) -> None: + # cc=3 R8G8B8_UNORM: opaque alpha appended. + fmt = rd.ResourceFormat(name="R8G8B8_UNORM", compByteWidth=1, compCount=3, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(73), width=1, height=1, format=fmt) + raw = bytes([11, 22, 33]) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 73}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0)) == (11, 22, 33, 255) + + +def test_tex_export_remote_snorm16_remaps_signed(tmp_path: object) -> None: + # cc=2 R16G16_SNORM: -32767 -> 0, 0 -> ~128, +32767 -> 255. + fmt = rd.ResourceFormat(name="R16G16_SNORM", compByteWidth=2, compCount=2, compType=3) + tex = rd.TextureDescription(resourceId=rd.ResourceId(74), width=1, height=1, format=fmt) + raw = struct.pack("<2h", -32767, 0) + state = _remote_state(tex, raw, tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 74}), state) + img = _read_png(resp["result"]["path"]) + px = img.getpixel((0, 0)) + assert px[0] == 0 # -1 -> 0 + assert abs(px[1] - 128) <= 1 # 0 -> ~128 + assert px[2] == 0 # B forced to 0 + assert px[3] == 255 + + +def test_rt_export_remote_srgb_bgra_pixel_values(tmp_path: object) -> None: + # B8G8R8A8_SRGB (compType 9 = UNormSRGB): input bytes [B,G,R,A] -> RGBA. + # UNormSRGB is a passthrough (already display-encoded); BGRA order swaps. + fmt = rd.ResourceFormat(name="B8G8R8A8_SRGB", compByteWidth=1, compCount=4, compType=9) + tex = rd.TextureDescription(resourceId=rd.ResourceId(75), width=1, height=1, format=fmt) + raw = bytes([10, 20, 30, 40]) # B=10, G=20, R=30, A=40 + targets = [rd.Descriptor(resource=rd.ResourceId(75))] + state = _remote_state(tex, raw, tmp_path, output_targets=targets) + resp, _ = _handle_request(rpc_request("rt_export", {"eid": 100}), state) + img = _read_png(resp["result"]["path"]) + assert img.getpixel((0, 0)) == (30, 20, 10, 40) + + +def test_rt_overlay_remote_still_rejected() -> None: state = make_daemon_state(is_remote=True, rd=rd) - resp, _ = _handle_request(rpc_request("rt_depth", {}), state) + resp, _ = _handle_request(rpc_request("rt_overlay", {"overlay": "wireframe"}), state) assert resp["error"]["code"] == -32002 - assert "not supported in remote mode" in resp["error"]["message"] + assert "remote mode" in resp["error"]["message"] + + +# --------------------------------------------------------------------------- +# Local mode still routes through SaveTexture (regression) +# --------------------------------------------------------------------------- + + +def test_tex_export_local_uses_savetexture(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription(resourceId=rd.ResourceId(42), width=4, height=4, format=fmt) + ctrl = rd.MockReplayController() + ctrl._textures = [tex] + ctrl._actions = [ + rd.ActionDescription(eventId=100, flags=rd.ActionFlags.Drawcall, _name="vkCmdDraw"), + ] + save_calls: list[str] = [] + orig_save = ctrl.SaveTexture + + def _spy(texsave: object, path: str) -> bool: + save_calls.append(path) + return orig_save(texsave, path) + + ctrl.SaveTexture = _spy # type: ignore[method-assign] + state = make_daemon_state( + ctrl=ctrl, + current_eid=100, + rd=rd, + tmp_path=tmp_path, + tex_map={42: tex}, + is_remote=False, + ) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 42}), state) + assert "result" in resp + assert len(save_calls) == 1