Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs-astro/src/data/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@
"name": "texture",
"id": "texture",
"help": "Export texture as PNG.",
"usage": "rdc texture <ID> [-o PATH] [--mip INTEGER] [--raw]"
"usage": "rdc texture <ID> [-o PATH] [--mip INTEGER] [--slice INTEGER RANGE] [--raw]"
},
{
"name": "buffer",
Expand Down
1 change: 1 addition & 0 deletions src/rdc/_skills/references/commands-quick-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
14 changes: 12 additions & 2 deletions src/rdc/commands/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
21 changes: 15 additions & 6 deletions src/rdc/handlers/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("<u4")).reshape((depth_lvl * height, width))
Expand All @@ -410,7 +419,7 @@ def _decode_texture_png(rd: Any, tex: Any, raw: bytes, mip: int, *, is_depth: bo

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)
cc = fmt.compCount
cbw = fmt.compByteWidth
if cc <= 0 or len(raw) != width * height * depth_lvl * cc * cbw:
Expand Down
70 changes: 65 additions & 5 deletions src/rdc/handlers/texture.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@
}


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)
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]:
Expand Down Expand Up @@ -70,20 +97,36 @@ def _export_remote(
resource_id: Any,
temp_path: Path,
mip: int,
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]
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:
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"
Expand All @@ -107,22 +150,39 @@ 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
if mip < 0 or mip >= tex.mips:
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,
allow_save_fallback=True,
)
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
Expand Down
6 changes: 6 additions & 0 deletions src/rdc/vfs/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ def _r(
"tex_export",
[("id", int), ("mip", int)],
)
_r(
r"/textures/(?P<id>\d+)/mips/(?P<mip>\d+)/slices/(?P<slice>\d+)\.png",
"leaf_bin",
"tex_export",
[("id", int), ("mip", int), ("slice", int)],
)
_r(r"/textures/(?P<id>\d+)/data", "leaf_bin", "tex_raw", [("id", int)])

_r("/buffers", "dir")
Expand Down
13 changes: 13 additions & 0 deletions src/rdc/vfs/tree_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,19 @@ 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:
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)
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]
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_export_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading