From fb0190c9265883e3fe5484b39eae527b79154b55 Mon Sep 17 00:00:00 2001 From: Misaka Mikoto Date: Tue, 21 Jul 2026 23:45:39 +0800 Subject: [PATCH 1/2] fix(texture): export requested 3d texture slice --- .../_skills/references/commands-quick-ref.md | 1 + src/rdc/commands/export.py | 14 ++- src/rdc/handlers/_helpers.py | 21 ++-- src/rdc/handlers/texture.py | 44 ++++++++- src/rdc/vfs/router.py | 6 ++ src/rdc/vfs/tree_cache.py | 10 ++ tests/unit/test_export_commands.py | 24 +++++ tests/unit/test_tex_stats_handler.py | 98 +++++++++++++++++-- tests/unit/test_vfs_router.py | 7 ++ tests/unit/test_vfs_tree_cache.py | 9 ++ 10 files changed, 214 insertions(+), 20 deletions(-) diff --git a/src/rdc/_skills/references/commands-quick-ref.md b/src/rdc/_skills/references/commands-quick-ref.md index 89d46ede..2c2f3ea3 100644 --- a/src/rdc/_skills/references/commands-quick-ref.md +++ b/src/rdc/_skills/references/commands-quick-ref.md @@ -1206,6 +1206,7 @@ Export texture as PNG. |------|------|------|---------| | `-o, --output` | Write to file | path | | | `--mip` | Mip level (default 0) | integer | 0 | +| `--slice` | Texture array or 3D depth slice (default 0) | integer range | 0 | | `--raw` | Force raw output even on TTY | flag | | ## `rdc thumbnail` diff --git a/src/rdc/commands/export.py b/src/rdc/commands/export.py index 6852437e..0a87098f 100644 --- a/src/rdc/commands/export.py +++ b/src/rdc/commands/export.py @@ -119,10 +119,20 @@ def _complete_rt_target( @click.argument("id", type=int, shell_complete=_complete_texture_id) @click.option("-o", "--output", type=click.Path(), default=None, help="Write to file") @click.option("--mip", default=0, type=int, help="Mip level (default 0)") +@click.option( + "--slice", + "array_slice", + default=0, + type=click.IntRange(min=0), + help="Texture array or 3D depth slice (default 0)", +) @click.option("--raw", is_flag=True, help="Force raw output even on TTY") -def texture_cmd(id: int, output: str | None, mip: int, raw: bool) -> None: +def texture_cmd(id: int, output: str | None, mip: int, array_slice: int, raw: bool) -> None: """Export texture as PNG.""" - vfs_path = f"/textures/{id}/mips/{mip}.png" if mip > 0 else f"/textures/{id}/image.png" + if array_slice > 0: + vfs_path = f"/textures/{id}/mips/{mip}/slices/{array_slice}.png" + else: + vfs_path = f"/textures/{id}/mips/{mip}.png" if mip > 0 else f"/textures/{id}/image.png" _export_vfs_path(vfs_path, output, raw) diff --git a/src/rdc/handlers/_helpers.py b/src/rdc/handlers/_helpers.py index 80bfa32e..3dee1dd9 100644 --- a/src/rdc/handlers/_helpers.py +++ b/src/rdc/handlers/_helpers.py @@ -235,21 +235,22 @@ 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) -> Any: +def _make_texsave(rd: Any, resource_id: Any, mip: int = 0, array_slice: int = 0) -> Any: """Create a TextureSave object using the renderdoc module.""" ts = rd.TextureSave() ts.resourceId = resource_id ts.mip = mip ts.slice = rd.TextureSliceMapping() + ts.slice.sliceIndex = array_slice ts.destType = rd.FileType.PNG return ts -def _make_subresource(rd: Any, mip: int = 0) -> Any: +def _make_subresource(rd: Any, mip: int = 0, array_slice: int = 0) -> Any: """Create a Subresource object using the renderdoc module.""" sub = rd.Subresource() sub.mip = mip - sub.slice = 0 + sub.slice = array_slice sub.sample = 0 return sub @@ -340,7 +341,15 @@ def _unpack_r9g9b9e5(words: Any) -> Any: return np.stack([rm * scale, gm * scale, bm * scale], axis=-1).astype(np.float32) -def _decode_texture_png(rd: Any, tex: Any, raw: bytes, mip: int, *, is_depth: bool) -> bytes | None: +def _decode_texture_png( + rd: Any, + tex: Any, + raw: bytes, + mip: int, + *, + is_depth: bool, + depth_override: int | None = None, +) -> bytes | None: """Decode tightly packed GetTextureData bytes into PNG bytes. Handles the full ``ResourceFormatType.Regular`` space deliberately: every @@ -384,7 +393,7 @@ def _decode_texture_png(rd: Any, tex: Any, raw: bytes, mip: int, *, is_depth: bo return None width = max(1, tex.width >> mip) height = max(1, tex.height >> mip) - depth_lvl = max(1, getattr(tex, "depth", 1) >> mip) + depth_lvl = depth_override or max(1, getattr(tex, "depth", 1) >> mip) if len(raw) != width * height * depth_lvl * 4: return None words = np.frombuffer(raw, dtype=np.dtype("> mip) height = max(1, tex.height >> mip) - depth_lvl = max(1, getattr(tex, "depth", 1) >> mip) + depth_lvl = depth_override or 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: diff --git a/src/rdc/handlers/texture.py b/src/rdc/handlers/texture.py index 809e359b..c5b6ef10 100644 --- a/src/rdc/handlers/texture.py +++ b/src/rdc/handlers/texture.py @@ -33,6 +33,26 @@ } +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) + if depth == 1: + return raw + width = max(1, tex.width >> mip) + height = max(1, tex.height >> mip) + fmt = tex.format + if fmt.type == rd.ResourceFormatType.Regular: + slice_size = width * height * fmt.compCount * fmt.compByteWidth + elif fmt.type in (rd.ResourceFormatType.R11G11B10, rd.ResourceFormatType.R9G9B9E5): + slice_size = width * height * 4 + else: + return raw + if len(raw) != slice_size * depth: + return raw + start = array_slice * slice_size + return raw[start : start + slice_size] + + def _handle_tex_info( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -70,19 +90,25 @@ def _export_remote( resource_id: Any, temp_path: Path, mip: int, + array_slice: int = 0, *, 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) + sub = _make_subresource(state.rd, mip, array_slice) 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 tex.type == state.rd.TextureType.Texture3D: + raw = _select_3d_slice(tex, raw, mip, array_slice, state.rd) + depth_override = 1 if tex.type == state.rd.TextureType.Texture3D else None + png = _decode_texture_png( + state.rd, tex, raw, mip, is_depth=is_depth, depth_override=depth_override + ) if png is None: fmt_name = tex.format.Name() if hasattr(tex.format, "Name") else "" return _error_response( @@ -107,6 +133,7 @@ def _handle_tex_export( return _error_response(request_id, -32002, "temp directory not available"), True res_id = int(params.get("id", 0)) mip = int(params.get("mip", 0)) + array_slice = int(params.get("slice", 0)) tex = state.tex_map.get(res_id) if tex is None: return _error_response(request_id, -32001, f"texture {res_id} not found"), True @@ -114,15 +141,22 @@ def _handle_tex_export( return _error_response( request_id, -32001, f"mip {mip} out of range (max: {tex.mips - 1})" ), True + slice_count = tex.arraysize + if tex.type == state.rd.TextureType.Texture3D: + slice_count = max(1, tex.depth >> mip) + if array_slice < 0 or array_slice >= slice_count: + return _error_response( + request_id, -32001, f"slice {array_slice} out of range (max: {slice_count - 1})" + ), True eid = int(params.get("eid", state.current_eid)) err = _set_frame_event(state, eid) if err: return _error_response(request_id, -32002, err), True - temp_path = state.temp_dir / f"tex_{res_id}_mip{mip}.png" + temp_path = state.temp_dir / f"tex_{res_id}_mip{mip}_slice{array_slice}.png" if state.is_remote: - return _export_remote(request_id, state, tex, tex.resourceId, temp_path, mip) + return _export_remote(request_id, state, tex, tex.resourceId, temp_path, mip, array_slice) controller = state.adapter.controller - texsave = _make_texsave(state.rd, tex.resourceId, mip) + texsave = _make_texsave(state.rd, tex.resourceId, mip, array_slice) success = controller.SaveTexture(texsave, str(temp_path)) if not success or not temp_path.exists(): return _error_response(request_id, -32002, "SaveTexture failed"), True diff --git a/src/rdc/vfs/router.py b/src/rdc/vfs/router.py index 3422f717..348aed89 100644 --- a/src/rdc/vfs/router.py +++ b/src/rdc/vfs/router.py @@ -206,6 +206,12 @@ def _r( "tex_export", [("id", int), ("mip", int)], ) +_r( + r"/textures/(?P\d+)/mips/(?P\d+)/slices/(?P\d+)\.png", + "leaf_bin", + "tex_export", + [("id", int), ("mip", int), ("slice", int)], +) _r(r"/textures/(?P\d+)/data", "leaf_bin", "tex_raw", [("id", int)]) _r("/buffers", "dir") diff --git a/src/rdc/vfs/tree_cache.py b/src/rdc/vfs/tree_cache.py index a4db87ba..2d78a06b 100644 --- a/src/rdc/vfs/tree_cache.py +++ b/src/rdc/vfs/tree_cache.py @@ -221,6 +221,16 @@ def build_vfs_skeleton( tree.static[f"{prefix}/mips"] = VfsNode("mips", "dir", list(mip_children)) for i in range(mip_count): tree.static[f"{prefix}/mips/{i}.png"] = VfsNode(f"{i}.png", "leaf_bin") + if getattr(getattr(t, "type", None), "name", "") == "Texture3D": + slice_count = max(1, getattr(t, "depth", 1) >> i) + else: + slice_count = getattr(t, "arraysize", 1) + if slice_count > 1: + slices_path = f"{prefix}/mips/{i}/slices" + slice_children = [f"{j}.png" for j in range(1, slice_count)] + tree.static[slices_path] = VfsNode("slices", "dir", slice_children) + for j in range(1, slice_count): + tree.static[f"{slices_path}/{j}.png"] = VfsNode(f"{j}.png", "leaf_bin") # /buffers buf_ids = [str(int(getattr(b, "resourceId", 0))) for b in _buffers] diff --git a/tests/unit/test_export_commands.py b/tests/unit/test_export_commands.py index be54b4e6..82db21dc 100644 --- a/tests/unit/test_export_commands.py +++ b/tests/unit/test_export_commands.py @@ -57,6 +57,30 @@ def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, An vfs_calls = [c for c in calls if c[0] == "vfs_ls"] assert any("/textures/42/mips/2.png" in str(c) for c in vfs_calls) + def test_texture_slice_path(self, monkeypatch: Any, tmp_path: Path) -> None: + """--slice should encode the requested subresource in the VFS path.""" + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + calls.append((method, dict(params) if params else {})) + if method == "vfs_ls": + return {"kind": "leaf_bin", "path": params.get("path", "/") if params else "/"} + temp = tmp_path / "export.bin" + temp.write_bytes(b"\x89PNG" + b"\x00" * 50) + return {"path": str(temp), "size": 54} + + monkeypatch.setattr("rdc.commands.export.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs.call", mock_call) + monkeypatch.setattr("rdc.commands.vfs._stdout_is_tty", lambda: False) + out_file = tmp_path / "slice.png" + runner = click.testing.CliRunner() + result = runner.invoke( + texture_cmd, ["42", "--mip", "2", "--slice", "3", "-o", str(out_file)] + ) + assert result.exit_code == 0 + vfs_calls = [c for c in calls if c[0] == "vfs_ls"] + assert any("/textures/42/mips/2/slices/3.png" in str(c) for c in vfs_calls) + def test_texture_tty_protection(self, monkeypatch: Any, tmp_path: Path) -> None: mock = _make_mockcall(tmp_path) monkeypatch.setattr("rdc.commands.export.call", mock) diff --git a/tests/unit/test_tex_stats_handler.py b/tests/unit/test_tex_stats_handler.py index a77b90f1..db42dc5d 100644 --- a/tests/unit/test_tex_stats_handler.py +++ b/tests/unit/test_tex_stats_handler.py @@ -736,23 +736,67 @@ def test_tex_export_remote_no_data_rejected(tmp_path: object) -> None: 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. +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), width=2, height=2, depth=2, format=fmt + 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, 4) # width=2, height=depth*height=4 + assert img.size == (2, 2) 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_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_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( + resourceId=rd.ResourceId(171), + type=rd.TextureType.Texture3D, + width=2, + height=2, + depth=4, + mips=2, + format=fmt, + ) + state = _remote_state(tex, bytes(16), tmp_path) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 171, "mip": 1, "slice": 2}), state) + assert resp["error"]["code"] == -32001 + 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: @@ -1001,3 +1045,43 @@ def _spy(texsave: object, path: str) -> bool: resp, _ = _handle_request(rpc_request("tex_export", {"id": 42}), state) assert "result" in resp assert len(save_calls) == 1 + + +def test_tex_export_local_texture3d_sets_requested_slice(tmp_path: object) -> None: + fmt = rd.ResourceFormat(name="R8G8B8A8_UNORM", compByteWidth=1, compCount=4, compType=2) + tex = rd.TextureDescription( + resourceId=rd.ResourceId(172), + type=rd.TextureType.Texture3D, + width=2, + height=2, + depth=2, + format=fmt, + ) + ctrl = rd.MockReplayController() + ctrl._textures = [tex] + ctrl._actions = [ + rd.ActionDescription(eventId=100, flags=rd.ActionFlags.Drawcall, _name="vkCmdDraw"), + ] + slice_indices: list[int] = [] + orig_save = ctrl.SaveTexture + + def _spy(texsave: object, path: str) -> bool: + slice_indices.append(texsave.slice.sliceIndex) + 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={172: tex}, + is_remote=False, + ) + resp, _ = _handle_request(rpc_request("tex_export", {"id": 172, "slice": 1}), state) + assert "result" in resp + + resp0, _ = _handle_request(rpc_request("tex_export", {"id": 172, "slice": 0}), state) + assert "result" in resp0 + + assert slice_indices == [1, 0] diff --git a/tests/unit/test_vfs_router.py b/tests/unit/test_vfs_router.py index 57c8eb63..10437f7b 100644 --- a/tests/unit/test_vfs_router.py +++ b/tests/unit/test_vfs_router.py @@ -364,6 +364,13 @@ def test_textures_id_mips_png(mip: int) -> None: assert m == PathMatch(kind="leaf_bin", handler="tex_export", args={"id": 42, "mip": mip}) +def test_textures_id_mips_slice_png() -> None: + m = resolve_path("/textures/42/mips/2/slices/3.png") + assert m == PathMatch( + kind="leaf_bin", handler="tex_export", args={"id": 42, "mip": 2, "slice": 3} + ) + + def test_textures_id_data() -> None: m = resolve_path("/textures/42/data") assert m == PathMatch(kind="leaf_bin", handler="tex_raw", args={"id": 42}) diff --git a/tests/unit/test_vfs_tree_cache.py b/tests/unit/test_vfs_tree_cache.py index 3f10a4c9..0d48173d 100644 --- a/tests/unit/test_vfs_tree_cache.py +++ b/tests/unit/test_vfs_tree_cache.py @@ -14,6 +14,7 @@ ShaderReflection, ShaderResource, TextureDescription, + TextureType, ) from rdc.vfs.formatter import render_ls, render_tree_root @@ -326,6 +327,14 @@ def test_texture_mip_leaf_bin(self, typed_skeleton: VfsTree) -> None: def test_texture_mips_1(self, typed_skeleton: VfsTree) -> None: assert typed_skeleton.static["/textures/10/mips"].children == ["0.png"] + def test_texture3d_slice_leaf_is_available_to_vfs(self) -> None: + texture = TextureDescription( + resourceId=ResourceId(30), type=TextureType.Texture3D, depth=4, mips=2 + ) + tree = build_vfs_skeleton(_make_actions(), _make_typed_resources(), textures=[texture]) + assert tree.static["/textures/30/mips/0/slices/1.png"].kind == "leaf_bin" + assert tree.static["/textures/30/mips/1/slices/1.png"].kind == "leaf_bin" + def test_buffers_children(self, typed_skeleton: VfsTree) -> None: assert typed_skeleton.static["/buffers"].children == ["20"] From 753ec6909251e148f2ed33a303fcbc394cd4c6a8 Mon Sep 17 00:00:00 2001 From: Misaka Mikoto Date: Wed, 22 Jul 2026 11:45:32 +0800 Subject: [PATCH 2/2] fix(texture): export remote compressed 3d slices --- docs-astro/src/data/commands.json | 2 +- src/rdc/handlers/texture.py | 28 +++++++++++++- src/rdc/vfs/tree_cache.py | 3 ++ tests/unit/test_tex_stats_handler.py | 57 +++++++++++++++++++++++++++- tests/unit/test_vfs_tree_cache.py | 6 +++ 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs-astro/src/data/commands.json b/docs-astro/src/data/commands.json index 6eac9b00..3f9ff70d 100644 --- a/docs-astro/src/data/commands.json +++ b/docs-astro/src/data/commands.json @@ -199,7 +199,7 @@ "name": "texture", "id": "texture", "help": "Export texture as PNG.", - "usage": "rdc texture [-o PATH] [--mip INTEGER] [--raw]" + "usage": "rdc texture [-o PATH] [--mip INTEGER] [--slice INTEGER RANGE] [--raw]" }, { "name": "buffer", diff --git a/src/rdc/handlers/texture.py b/src/rdc/handlers/texture.py index c5b6ef10..8c9edad4 100644 --- a/src/rdc/handlers/texture.py +++ b/src/rdc/handlers/texture.py @@ -33,6 +33,13 @@ } +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) @@ -93,6 +100,7 @@ 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.""" controller = state.adapter.controller # type: ignore[union-attr] @@ -110,6 +118,15 @@ 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" @@ -154,7 +171,16 @@ def _handle_tex_export( return _error_response(request_id, -32002, err), True temp_path = state.temp_dir / f"tex_{res_id}_mip{mip}_slice{array_slice}.png" if state.is_remote: - return _export_remote(request_id, state, tex, tex.resourceId, temp_path, mip, array_slice) + 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) success = controller.SaveTexture(texsave, str(temp_path)) diff --git a/src/rdc/vfs/tree_cache.py b/src/rdc/vfs/tree_cache.py index 2d78a06b..ad9b06ac 100644 --- a/src/rdc/vfs/tree_cache.py +++ b/src/rdc/vfs/tree_cache.py @@ -226,6 +226,9 @@ def build_vfs_skeleton( else: slice_count = getattr(t, "arraysize", 1) if slice_count > 1: + mip_path = f"{prefix}/mips/{i}" + tree.static[f"{prefix}/mips"].children.append(str(i)) + tree.static[mip_path] = VfsNode(str(i), "dir", ["slices"]) slices_path = f"{prefix}/mips/{i}/slices" slice_children = [f"{j}.png" for j in range(1, slice_count)] tree.static[slices_path] = VfsNode("slices", "dir", slice_children) diff --git a/tests/unit/test_tex_stats_handler.py b/tests/unit/test_tex_stats_handler.py index db42dc5d..bf1d146b 100644 --- a/tests/unit/test_tex_stats_handler.py +++ b/tests/unit/test_tex_stats_handler.py @@ -4,6 +4,7 @@ import io import struct +from pathlib import Path import mock_renderdoc as rd import numpy as np @@ -432,8 +433,10 @@ def test_tex_export_remote_length_mismatch_errors(tmp_path: object) -> None: assert resp["error"]["code"] == -32002 -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) +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) @@ -782,6 +785,56 @@ def _get_texture_data(resource_id: object, sub: object) -> bytes: 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( diff --git a/tests/unit/test_vfs_tree_cache.py b/tests/unit/test_vfs_tree_cache.py index 0d48173d..77642fff 100644 --- a/tests/unit/test_vfs_tree_cache.py +++ b/tests/unit/test_vfs_tree_cache.py @@ -332,7 +332,13 @@ def test_texture3d_slice_leaf_is_available_to_vfs(self) -> None: resourceId=ResourceId(30), type=TextureType.Texture3D, depth=4, mips=2 ) tree = build_vfs_skeleton(_make_actions(), _make_typed_resources(), textures=[texture]) + + assert "0" in tree.static["/textures/30/mips"].children + assert tree.static["/textures/30/mips/0"].children == ["slices"] + assert tree.static["/textures/30/mips/0/slices"].children == ["1.png", "2.png", "3.png"] assert tree.static["/textures/30/mips/0/slices/1.png"].kind == "leaf_bin" + assert tree.static["/textures/30/mips/1"].children == ["slices"] + assert tree.static["/textures/30/mips/1/slices"].children == ["1.png"] assert tree.static["/textures/30/mips/1/slices/1.png"].kind == "leaf_bin" def test_buffers_children(self, typed_skeleton: VfsTree) -> None: