diff --git a/src/rdc/handlers/_helpers.py b/src/rdc/handlers/_helpers.py index 3dee1dd..c3617ef 100644 --- a/src/rdc/handlers/_helpers.py +++ b/src/rdc/handlers/_helpers.py @@ -235,14 +235,24 @@ def _walk(actions: list[Any]) -> None: populate_shaders_subtree(state.vfs_tree, state.shader_meta) -def _make_texsave(rd: Any, resource_id: Any, mip: int = 0, array_slice: int = 0) -> Any: - """Create a TextureSave object using the renderdoc module.""" +def _make_texsave( + rd: Any, + resource_id: Any, + mip: int = 0, + array_slice: int = 0, + dest_type: Any | None = None, +) -> Any: + """Create a TextureSave object using the renderdoc module. + + PNG remains the command-level default. ``dest_type`` is retained here so + future CLI format selection reaches RenderDoc's own exporter unchanged. + """ ts = rd.TextureSave() ts.resourceId = resource_id ts.mip = mip ts.slice = rd.TextureSliceMapping() ts.slice.sliceIndex = array_slice - ts.destType = rd.FileType.PNG + ts.destType = rd.FileType.PNG if dest_type is None else dest_type return ts diff --git a/src/rdc/handlers/texture.py b/src/rdc/handlers/texture.py index 8c9edad..5b1dda5 100644 --- a/src/rdc/handlers/texture.py +++ b/src/rdc/handlers/texture.py @@ -33,13 +33,6 @@ } -def _is_block_compressed(fmt: Any) -> bool: - """Return whether RenderDoc identifies a format as block-compressed.""" - block_format = getattr(fmt, "BlockFormat", None) - renderdoc_result = bool(block_format()) if callable(block_format) else False - return renderdoc_result or fmt.Name().upper().startswith(("ASTC", "BC", "EAC", "ETC", "PVRTC")) - - def _select_3d_slice(tex: Any, raw: bytes, mip: int, array_slice: int, rd: Any) -> bytes: """Return one depth slice when remote GetTextureData returns a whole 3D mip.""" depth = max(1, tex.depth >> mip) @@ -90,7 +83,7 @@ def _handle_tex_info( ), True -def _export_remote( +def _export_decoded( request_id: int, state: DaemonState, tex: Any, @@ -100,9 +93,8 @@ def _export_remote( array_slice: int = 0, *, is_depth: bool = False, - allow_save_fallback: bool = False, ) -> tuple[dict[str, Any], bool]: - """Fetch raw pixels over the wire and decode them locally to a PNG.""" + """Fetch raw pixels and decode them locally for the local depth-export path.""" controller = state.adapter.controller # type: ignore[union-attr] sub = _make_subresource(state.rd, mip, array_slice) try: @@ -118,18 +110,9 @@ def _export_remote( state.rd, tex, raw, mip, is_depth=is_depth, depth_override=depth_override ) if png is None: - if allow_save_fallback and _is_block_compressed(tex.format): - texsave = _make_texsave(state.rd, resource_id, mip, array_slice) - success = controller.SaveTexture(texsave, str(temp_path)) - if success and temp_path.exists(): - return _result_response( - request_id, - {"path": str(temp_path), "size": temp_path.stat().st_size}, - ), True - return _error_response(request_id, -32002, "SaveTexture fallback failed"), True 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" + request_id, -32002, f"format {fmt_name} not supported for local depth decode" ), True try: temp_path.write_bytes(png) @@ -139,6 +122,52 @@ def _export_remote( return _result_response(request_id, {"path": str(temp_path), "size": size}), True +def _save_result_ok(result: Any) -> bool: + """Accept current ResultDetails and older bool SaveTexture bindings.""" + ok = getattr(result, "OK", None) + return bool(ok()) if callable(ok) else bool(result) + + +def _save_result_message(result: Any) -> str: + """Return a RenderDoc failure message when the binding provides one.""" + message = getattr(result, "Message", None) + return str(message()) if callable(message) else "SaveTexture failed" + + +def _export_remote( + request_id: int, + state: DaemonState, + resource_id: Any, + temp_path: Path, + mip: int, + array_slice: int = 0, + *, + dest_type: Any | None = None, +) -> tuple[dict[str, Any], bool]: + """Export through RenderDoc's local SaveTexture implementation. + + In remote replay, the controller lives locally and fetches image data via + the replay proxy. This is the same path used by qrenderdoc and preserves + RenderDoc's handling of compressed, HDR, 3D, and special formats. + """ + controller = state.adapter.controller # type: ignore[union-attr] + texsave = _make_texsave(state.rd, resource_id, mip, array_slice, dest_type) + try: + result = controller.SaveTexture(texsave, str(temp_path)) + except Exception as exc: # noqa: BLE001 + return _error_response(request_id, -32002, f"SaveTexture failed: {exc}"), True + if not _save_result_ok(result): + return _error_response(request_id, -32002, _save_result_message(result)), True + if not temp_path.exists(): + return _error_response( + request_id, -32002, "SaveTexture did not create an output file" + ), True + return _result_response( + request_id, + {"path": str(temp_path), "size": temp_path.stat().st_size}, + ), True + + def _handle_tex_export( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -174,12 +203,10 @@ def _handle_tex_export( return _export_remote( request_id, state, - tex, tex.resourceId, temp_path, mip, array_slice, - allow_save_fallback=True, ) controller = state.adapter.controller texsave = _make_texsave(state.rd, tex.resourceId, mip, array_slice) @@ -247,10 +274,7 @@ def _handle_rt_export( resource = match[0].resource temp_path = state.temp_dir / f"rt_{eid}_color{target_idx}.png" 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) + return _export_remote(request_id, state, 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(): @@ -281,7 +305,9 @@ def _handle_rt_depth( return _error_response( request_id, -32001, f"depth target {int(depth.resource)} not found" ), True - resp, running = _export_remote( + if state.is_remote: + return _export_remote(request_id, state, depth.resource, temp_path, 0) + resp, running = _export_decoded( request_id, state, tex, depth.resource, temp_path, 0, is_depth=True ) # Combined depth-stencil and MSAA formats decode to None (-32002). Locally, diff --git a/tests/unit/test_tex_stats_handler.py b/tests/unit/test_tex_stats_handler.py index bf1d146..8f84061 100644 --- a/tests/unit/test_tex_stats_handler.py +++ b/tests/unit/test_tex_stats_handler.py @@ -7,7 +7,6 @@ from pathlib import Path import mock_renderdoc as rd -import numpy as np from conftest import make_daemon_state, rpc_request from PIL import Image @@ -343,7 +342,7 @@ def test_tex_stats_histogram_channel_length_mismatch() -> None: # --------------------------------------------------------------------------- -# Remote-mode export via GetTextureData decode (#236) +# Remote-mode export through RenderDoc SaveTexture (#236) # --------------------------------------------------------------------------- @@ -379,114 +378,118 @@ def _read_png(path: str) -> Image.Image: 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) +def test_tex_export_remote_uses_savetexture_for_requested_3d_subresource(tmp_path: object) -> None: + """Remote PNG export must use RenderDoc's local SaveTexture path, like qrenderdoc.""" + fmt = rd.ResourceFormat(name="ASTC6x6_UNORM", type=99) + tex = rd.TextureDescription( + resourceId=rd.ResourceId(196), + type=rd.TextureType.Texture3D, + width=64, + height=64, + depth=4, + mips=2, + format=fmt, + ) + state = _remote_state(tex, b"", tmp_path) + controller = state.adapter.controller # type: ignore[union-attr] + save_calls: list[tuple[int, int, str]] = [] + + def _no_raw_fetch(resource_id: object, sub: object) -> bytes: + del resource_id, sub + raise AssertionError("remote PNG export must not call GetTextureData") + + def _save(texsave: object, path: str) -> object: + save_calls.append((texsave.mip, texsave.slice.sliceIndex, path)) + assert texsave.destType == rd.FileType.PNG + Path(path).write_bytes(b"renderdoc-png") + + class _Result: + def OK(self) -> bool: # noqa: N802 + return True + + return _Result() + + controller.GetTextureData = _no_raw_fetch # type: ignore[method-assign] + controller.SaveTexture = _save # type: ignore[method-assign] + + resp, running = _handle_request( + rpc_request("tex_export", {"id": 196, "mip": 1, "slice": 1}), state + ) + assert running - img = _read_png(resp["result"]["path"]) - assert img.size == (4, 2) - assert img.mode == "RGBA" - assert resp["result"]["size"] > 0 + assert "result" in resp + assert save_calls == [(1, 1, resp["result"]["path"])] -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_reports_savetexture_failure(tmp_path: object) -> None: + tex = rd.TextureDescription(resourceId=rd.ResourceId(198), width=2, height=2) + state = _remote_state(tex, b"", tmp_path) + controller = state.adapter.controller # type: ignore[union-attr] + def _no_raw_fetch(resource_id: object, sub: object) -> bytes: + del resource_id, sub + raise AssertionError("remote PNG export must not call GetTextureData") -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) + class _Failure: + def OK(self) -> bool: # noqa: N802 + return False + def Message(self) -> str: # noqa: N802 + return "mock encoder rejected texture" -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 + controller.GetTextureData = _no_raw_fetch # type: ignore[method-assign] + controller.SaveTexture = lambda texsave, path: _Failure() # type: ignore[method-assign] + resp, running = _handle_request(rpc_request("tex_export", {"id": 198}), state) -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 running + assert resp["error"] == {"code": -32002, "message": "mock encoder rejected texture"} -def test_tex_export_remote_non_block_special_format_rejected(tmp_path: object) -> None: - fmt = rd.ResourceFormat( - name="R10G10B10A2_UNORM", compByteWidth=4, compCount=4, compType=2, type=12 - ) - 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_tex_export_remote_requires_savetexture_output_file(tmp_path: object) -> None: + tex = rd.TextureDescription(resourceId=rd.ResourceId(199), width=2, height=2) + state = _remote_state(tex, b"", tmp_path) + controller = state.adapter.controller # type: ignore[union-attr] + controller.SaveTexture = lambda texsave, path: True # type: ignore[method-assign] + resp, running = _handle_request(rpc_request("tex_export", {"id": 199}), state) -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" + assert running + assert resp["error"] == { + "code": -32002, + "message": "SaveTexture did not create an output file", + } -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_export_remote_uses_savetexture_without_raw_decode(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R16G16B16A16_FLOAT", compByteWidth=2, compCount=4, compType=1) + tex = rd.TextureDescription(resourceId=rd.ResourceId(197), width=2, height=2, format=fmt) + state = _remote_state( + tex, + b"", + tmp_path, + output_targets=[rd.Descriptor(resource=tex.resourceId)], + ) + controller = state.adapter.controller # type: ignore[union-attr] + save_calls: list[tuple[int, int]] = [] + def _no_raw_fetch(resource_id: object, sub: object) -> bytes: + del resource_id, sub + raise AssertionError("remote PNG export must not call GetTextureData") -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 _save(texsave: object, path: str) -> bool: + save_calls.append((texsave.mip, texsave.slice.sliceIndex)) + Path(path).write_bytes(b"renderdoc-png") + return True + + controller.GetTextureData = _no_raw_fetch # type: ignore[method-assign] + controller.SaveTexture = _save # type: ignore[method-assign] + + resp, running = _handle_request(rpc_request("rt_export", {"eid": 100}), state) + + assert running + assert "result" in resp + assert save_calls == [(0, 0)] # --------------------------------------------------------------------------- @@ -570,271 +573,6 @@ def _spy(texsave: object, path: str) -> bool: assert len(save_calls) == 1 -def test_rt_depth_remote_d24s8_no_fallback(tmp_path: object) -> None: - fmt = rd.ResourceFormat( - name="D24S8", - compByteWidth=4, - compCount=1, - compType=8, - type=int(rd.ResourceFormatType.D24S8), - ) - tex = rd.TextureDescription(resourceId=rd.ResourceId(308), width=2, height=2, format=fmt) - depth = rd.Descriptor(resource=tex.resourceId) - state = _remote_state(tex, b"\x00" * 8, tmp_path, depth_target=depth) - - def _no_save(texsave: object, path: str) -> bool: - raise AssertionError("SaveTexture must not be called in remote mode") - - state.adapter.controller.SaveTexture = _no_save # type: ignore[union-attr,method-assign] - resp, _ = _handle_request(rpc_request("rt_depth", {"eid": 100}), state) - assert resp["error"]["code"] == -32002 - - -def test_rt_depth_local_remote_byte_identical_d16(tmp_path: object) -> None: - from pathlib import Path - - fmt = rd.ResourceFormat(name="D16", compByteWidth=2, compCount=1, compType=8) - raw = struct.pack("<4H", 0, 16384, 49152, 65535) - - local_dir = Path(str(tmp_path)) / "local" - remote_dir = Path(str(tmp_path)) / "remote" - local_dir.mkdir() - remote_dir.mkdir() - - tex_l = rd.TextureDescription(resourceId=rd.ResourceId(309), width=2, height=2, format=fmt) - local_state = _local_depth_state(tex_l, raw, local_dir) - local_resp, _ = _handle_request(rpc_request("rt_depth", {"eid": 100}), local_state) - - tex_r = rd.TextureDescription(resourceId=rd.ResourceId(309), width=2, height=2, format=fmt) - remote_state = _remote_state( - tex_r, raw, remote_dir, depth_target=rd.Descriptor(resource=tex_r.resourceId) - ) - remote_resp, _ = _handle_request(rpc_request("rt_depth", {"eid": 100}), remote_state) - - with open(local_resp["result"]["path"], "rb") as fh: - local_bytes = fh.read() - with open(remote_resp["result"]["path"], "rb") as fh: - remote_bytes = fh.read() - assert local_bytes == remote_bytes - - -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_unsupported_packed_format_rejected(tmp_path: object) -> None: - # R5G6B5 (type=14) is a still-unsupported packed (non-Regular) format -> reject. - # R11G11B10/R9G9B9E5 now decode, so this preserves rejection coverage for a - # packed type the change does not handle. - fmt = rd.ResourceFormat(name="R5G6B5_UNORM", compByteWidth=2, compCount=3, compType=2, type=14) - 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 - - -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_defaults_to_slice_zero(tmp_path: object) -> None: - # Remote GetTextureData returns the whole 3D mip, but the export API returns one slice. - fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) - tex = rd.TextureDescription( - resourceId=rd.ResourceId(70), - type=rd.TextureType.Texture3D, - 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, 2) - assert img.mode == "RGBA" - assert img.getpixel((0, 0)) == (0, 1, 2, 3) - - -def test_tex_export_remote_texture3d_exports_requested_slice(tmp_path: object) -> None: - fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) - tex = rd.TextureDescription( - resourceId=rd.ResourceId(170), - type=rd.TextureType.Texture3D, - width=2, - height=2, - depth=2, - format=fmt, - ) - state = _remote_state(tex, b"", tmp_path) - ctrl = state.adapter.controller # type: ignore[union-attr] - slices = [bytes(range(16)), bytes(range(16, 32))] - - def _get_texture_data(resource_id: object, sub: object) -> bytes: - del resource_id, sub - return b"".join(slices) - - ctrl.GetTextureData = _get_texture_data # type: ignore[method-assign] - resp, _ = _handle_request(rpc_request("tex_export", {"id": 170, "slice": 1}), state) - img = _read_png(resp["result"]["path"]) - assert img.size == (2, 2) - assert img.getpixel((0, 0)) == (16, 17, 18, 19) - - -def test_tex_export_remote_astc3d_falls_back_to_savetexture(tmp_path: object) -> None: - fmt = rd.ResourceFormat(name="ASTC6x6_UNORM", type=99) - tex = rd.TextureDescription( - resourceId=rd.ResourceId(173), - type=rd.TextureType.Texture3D, - width=64, - height=64, - depth=4, - mips=7, - format=fmt, - ) - state = _remote_state(tex, b"\x00" * 256, tmp_path) - save_calls: list[tuple[int, int, str]] = [] - controller = state.adapter.controller # type: ignore[union-attr] - original_save = controller.SaveTexture - - def _spy(texsave: object, path: str) -> bool: - save_calls.append((texsave.mip, texsave.slice.sliceIndex, path)) - return original_save(texsave, path) - - controller.SaveTexture = _spy # type: ignore[method-assign] - resp, running = _handle_request( - rpc_request("tex_export", {"id": 173, "mip": 1, "slice": 1}), state - ) - - assert running - assert "result" in resp - assert save_calls == [(1, 1, resp["result"]["path"])] - assert Path(resp["result"]["path"]).read_bytes().startswith(b"\x89PNG") - - -def test_tex_export_remote_astc3d_reports_savetexture_fallback_failure(tmp_path: object) -> None: - fmt = rd.ResourceFormat(name="ASTC6x6_UNORM", type=99) - tex = rd.TextureDescription( - resourceId=rd.ResourceId(174), - type=rd.TextureType.Texture3D, - width=64, - height=64, - depth=4, - format=fmt, - ) - state = _remote_state(tex, b"\x00" * 256, tmp_path) - state.adapter.controller._save_texture_fails = True # type: ignore[union-attr] - - resp, _ = _handle_request(rpc_request("tex_export", {"id": 174, "slice": 1}), state) - - assert resp["error"]["code"] == -32002 - assert resp["error"]["message"] == "SaveTexture fallback failed" - - def test_tex_export_texture3d_rejects_invalid_slice_for_mip(tmp_path: object) -> None: fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) tex = rd.TextureDescription( @@ -852,70 +590,6 @@ def test_tex_export_texture3d_rejects_invalid_slice_for_mip(tmp_path: object) -> assert resp["error"]["message"] == "slice 2 out of range (max: 1)" -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_overlay", {"overlay": "wireframe"}), state) @@ -923,149 +597,6 @@ def test_rt_overlay_remote_still_rejected() -> None: assert "remote mode" in resp["error"]["message"] -# --------------------------------------------------------------------------- -# Packed HDR formats: R11G11B10_FLOAT (type=13) and R9G9B9E5_SHAREDEXP (type=16) -# --------------------------------------------------------------------------- - - -def _r11g11b10_tex(res_id: int, **kw: object) -> rd.TextureDescription: - fmt = rd.ResourceFormat( - name="R11G11B10_FLOAT", compByteWidth=4, compCount=3, compType=1, type=13 - ) - return rd.TextureDescription(resourceId=rd.ResourceId(res_id), format=fmt, **kw) # type: ignore[arg-type] - - -def _r9g9b9e5_tex(res_id: int, **kw: object) -> rd.TextureDescription: - fmt = rd.ResourceFormat( - name="R9G9B9E5_SHAREDEXP", compByteWidth=4, compCount=3, compType=1, type=16 - ) - return rd.TextureDescription(resourceId=rd.ResourceId(res_id), format=fmt, **kw) # type: ignore[arg-type] - - -def test_tex_export_remote_r11g11b10_happy_path(tmp_path: object) -> None: - # (1.0, 0.5, 0.25): R exp=15 mant=0, G exp=14 mant=0, B exp=13 mant=0. - tex = _r11g11b10_tex(110, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - tex = _r11g11b10_tex(111, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - tex = _r11g11b10_tex(112, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - # exp=0 mant=1 for all channels -> ~9.5e-7, sRGB rounds to 0, no error. - tex = _r11g11b10_tex(113, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - tex = _r11g11b10_tex(114, width=2, height=2) - state = _remote_state(tex, b"\x00" * 4, tmp_path) # should be 16 bytes - resp, _ = _handle_request(rpc_request("tex_export", {"id": 114}), state) - assert resp["error"]["code"] == -32002 - - -def test_tex_export_remote_r11g11b10_msaa_rejected(tmp_path: object) -> None: - tex = _r11g11b10_tex(115, width=1, height=1, msSamp=4) - state = _remote_state(tex, struct.pack(" None: - tex = _r11g11b10_tex(116, width=1, height=1, depth=2) - state = _remote_state(tex, struct.pack("<2I", 0x681C03C0, 0x00000000), tmp_path) - resp, _ = _handle_request(rpc_request("tex_export", {"id": 116}), state) - img = _read_png(resp["result"]["path"]) - assert img.size == (1, 2) - px0 = img.getpixel((0, 0)) - assert px0[0] == 255 - assert abs(px0[1] - 188) <= 2 - assert abs(px0[2] - 137) <= 2 - assert img.getpixel((0, 1)) == (0, 0, 0, 255) - - -def test_tex_export_remote_r9g9b9e5_white(tmp_path: object) -> None: - # E=24, mant=1 each -> 1.0 each channel. - tex = _r9g9b9e5_tex(160, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - # E=22, rm=4 gm=2 bm=1 -> 1.0, 0.5, 0.25. - tex = _r9g9b9e5_tex(161, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - tex = _r9g9b9e5_tex(162, width=1, height=1) - state = _remote_state(tex, struct.pack(" None: - tex = _r9g9b9e5_tex(163, width=2, height=2) - state = _remote_state(tex, b"\x00" * 4, tmp_path) - resp, _ = _handle_request(rpc_request("tex_export", {"id": 163}), state) - assert resp["error"]["code"] == -32002 - - -def test_tex_export_remote_r9g9b9e5_3d_tiled(tmp_path: object) -> None: - tex = _r9g9b9e5_tex(164, width=1, height=1, depth=2) - state = _remote_state(tex, struct.pack("<2I", 0xC0040201, 0x00000000), tmp_path) - resp, _ = _handle_request(rpc_request("tex_export", {"id": 164}), state) - img = _read_png(resp["result"]["path"]) - assert img.size == (1, 2) - assert img.getpixel((0, 0)) == (255, 255, 255, 255) - assert img.getpixel((0, 1)) == (0, 0, 0, 255) - - -def test_tex_export_remote_r9g9b9e5_max_exp_clips_white(tmp_path: object) -> None: - # E=31, all mantissas=511 -> each channel = 65408.0 (finite), clips to white. - tex = _r9g9b9e5_tex(165, width=1, height=1) - state = _remote_state(tex, struct.pack("