From 755956ae1864a5524a15b115b49437ad59121988 Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:17:49 -0600 Subject: [PATCH 1/9] test(mock): add DescriptorLogicalLocation/DescriptorRange for descriptor-location API --- tests/mocks/mock_renderdoc.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/mocks/mock_renderdoc.py b/tests/mocks/mock_renderdoc.py index a0d64ab..62fba8c 100644 --- a/tests/mocks/mock_renderdoc.py +++ b/tests/mocks/mock_renderdoc.py @@ -963,6 +963,26 @@ class UsedDescriptor: sampler: SamplerDescriptor = field(default_factory=SamplerDescriptor) +@dataclass +class DescriptorLogicalLocation: + """Mock for DescriptorLogicalLocation from GetDescriptorLocations.""" + + fixedBindNumber: int = 0 + logicalBindName: str = "" + category: int = 0 + stageMask: int = 0 + + +class DescriptorRange: + """Mock for DescriptorRange, constructible from a DescriptorAccess.""" + + def __init__(self, access: DescriptorAccess | None = None) -> None: + self.offset = access.index if access is not None else 0 + self.count = 1 + self.descriptorSize = 0 + self.type = access.type if access is not None else DescriptorType.ConstantBuffer + + @dataclass class MeshFormat: allowRestart: bool = False From 8601156777aef2f737103530db304ef966b3b87d Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:23:35 -0600 Subject: [PATCH 2/9] feat(descriptors): correlate used descriptors to binding name, resource name and dims - add reflection binding maps + GetDescriptorLocations join (batched per store) with graceful fallback when the API or reflection is unavailable - attach resolved resource_name and texture width/height per used descriptor --- src/rdc/handlers/descriptor.py | 71 ++++++++++++++++++++++++++- tests/unit/test_descriptors_daemon.py | 71 +++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/rdc/handlers/descriptor.py b/src/rdc/handlers/descriptor.py index 5d866f3..8ec67fa 100644 --- a/src/rdc/handlers/descriptor.py +++ b/src/rdc/handlers/descriptor.py @@ -17,6 +17,58 @@ from rdc.daemon_server import DaemonState +def _bind_bucket(items: Any) -> dict[int, tuple[str, int]]: + return { + getattr(r, "fixedBindNumber", 0): (r.name, getattr(r, "fixedBindSetOrSpace", 0)) + for r in items + } + + +def _reflection_binding_maps(pipe_state: Any) -> dict[int, dict[str, dict[int, tuple[str, int]]]]: + """Per-stage {bucket: {bind_number: (name, set)}} from reflection, keyed by stage value.""" + from rdc.services.query_service import STAGE_MAP + + maps: dict[int, dict[str, dict[int, tuple[str, int]]]] = {} + for stage_val in STAGE_MAP.values(): + refl = pipe_state.GetShaderReflection(stage_val) + if refl is None: + continue + maps[stage_val] = { + "ro": _bind_bucket(getattr(refl, "readOnlyResources", [])), + "rw": _bind_bucket(getattr(refl, "readWriteResources", [])), + "cb": _bind_bucket(getattr(refl, "constantBlocks", [])), + } + return maps + + +def _descriptor_locations(state: DaemonState, used: Any) -> dict[int, Any]: + """Map id(access) -> logical location via GetDescriptorLocations, batched per store.""" + assert state.adapter is not None + get_locs = getattr(state.adapter.controller, "GetDescriptorLocations", None) + if get_locs is None or not hasattr(state.rd, "DescriptorRange"): + return {} + by_store: dict[Any, list[Any]] = {} + for ud in used: + by_store.setdefault(ud.access.descriptorStore, []).append(ud.access) + out: dict[int, Any] = {} + for store, accesses in by_store.items(): + try: + locs = get_locs(store, [state.rd.DescriptorRange(a) for a in accesses]) + except Exception: # noqa: BLE001 + continue + for access, loc in zip(accesses, locs, strict=False): + out[id(access)] = loc + return out + + +def _refl_bucket(type_name: str) -> str: + if type_name == "ConstantBuffer": + return "cb" + if type_name.startswith("ReadWrite"): + return "rw" + return "ro" + + def _handle_descriptors( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -27,6 +79,8 @@ def _handle_descriptors( if not hasattr(pipe_state, "GetAllUsedDescriptors"): return _error_response(request_id, -32002, "GetAllUsedDescriptors not available"), True used = pipe_state.GetAllUsedDescriptors(True) + bind_maps = _reflection_binding_maps(pipe_state) + loc_map = _descriptor_locations(state, used) desc_rows: list[dict[str, Any]] = [] for ud in used: acc = ud.access @@ -35,15 +89,30 @@ def _handle_descriptors( type_name = _enum_name(acc.type) fmt = getattr(desc, "format", None) fmt_name = fmt.Name() if fmt and hasattr(fmt, "Name") else str(fmt) if fmt else "" + res_id = int(desc.resource) d_row: dict[str, Any] = { "stage": stage_name, "type": type_name, "index": acc.index, "array_element": acc.arrayElement, - "resource_id": int(desc.resource), + "resource_id": res_id, "format": fmt_name, "byte_size": getattr(desc, "byteSize", 0), } + loc = loc_map.get(id(acc)) + logical = getattr(loc, "logicalBindName", "") if loc is not None else "" + bind_num = getattr(loc, "fixedBindNumber", None) if loc is not None else None + name, bset = "", None + stage_buckets = bind_maps.get(int(acc.stage)) + if stage_buckets is not None and bind_num is not None: + name, bset = stage_buckets[_refl_bucket(type_name)].get(bind_num, ("", None)) + d_row["binding"] = name or logical + d_row["set"] = bset if bset is not None else acc.index + d_row["resource_name"] = state.res_names.get(res_id, "") + tex = state.tex_map.get(res_id) + if tex is not None: + d_row["width"] = tex.width + d_row["height"] = tex.height if type_name in ("Sampler", "ImageSampler"): s = getattr(ud, "sampler", None) if s is not None: diff --git a/tests/unit/test_descriptors_daemon.py b/tests/unit/test_descriptors_daemon.py index 806bef4..7122f91 100644 --- a/tests/unit/test_descriptors_daemon.py +++ b/tests/unit/test_descriptors_daemon.py @@ -15,6 +15,8 @@ MockPipeState, ResourceId, SamplerDescriptor, + ShaderReflection, + ShaderResource, ShaderStage, UsedDescriptor, ) @@ -191,3 +193,72 @@ def test_descriptors_eid_out_of_range() -> None: state = _make_state_with_pipe(pipe, max_eid=10) resp = _call(state, "descriptors", eid=999) assert resp["error"]["code"] == -32002 + + +def _ps_with_textures_binding() -> MockPipeState: + pipe = MockPipeState() + pipe._reflections[ShaderStage.Pixel] = ShaderReflection( + readOnlyResources=[ + ShaderResource(name="g_textures", fixedBindNumber=0, fixedBindSetOrSpace=0) + ], + ) + pipe._used_descriptors = [ + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, + type=DescriptorType.Image, + index=0, + arrayElement=46, + ), + descriptor=Descriptor(resource=ResourceId(371), byteSize=0), + ), + ] + return pipe + + +def test_descriptors_binding_name_correlation() -> None: + """A used image element is correlated to its reflection binding name and resource.""" + pipe = _ps_with_textures_binding() + ctrl = SimpleNamespace( + GetRootActions=lambda: [], + GetResources=lambda: [], + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: SimpleNamespace(chunks=[]), + GetPipelineState=lambda: pipe, + GetTextures=lambda: [], + GetBuffers=lambda: [], + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + GetDescriptorLocations=lambda store, ranges: [ + rd.DescriptorLogicalLocation(fixedBindNumber=0, logicalBindName="0[46]") for _ in ranges + ], + ) + state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] + state.res_names = {371: "2D Image 371"} + state.tex_map = { + 371: SimpleNamespace( + width=512, height=512, format=SimpleNamespace(Name=lambda: "BC1_SRGB"), byteSize=174776 + ) + } + resp = _call(state, "descriptors", eid=16) + + d = resp["result"]["descriptors"][0] + assert d["binding"] == "g_textures" + assert d["set"] == 0 + assert d["array_element"] == 46 + assert d["resource_id"] == 371 + assert d["resource_name"] == "2D Image 371" + assert d["width"] == 512 + assert d["height"] == 512 + + +def test_descriptors_binding_fallback_without_locations() -> None: + """Without GetDescriptorLocations the binding is empty but the row is intact.""" + pipe = _ps_with_textures_binding() + state = _make_state_with_pipe(pipe) + resp = _call(state, "descriptors", eid=16) + + d = resp["result"]["descriptors"][0] + assert d["binding"] == "" + assert d["resource_id"] == 371 From 52b472737abd654363c15bba0beddcb59a62b034 Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:28:48 -0600 Subject: [PATCH 3/9] feat(descriptors): add 'rdc descriptors' command for per-draw used descriptors Promotes the used-descriptor view (previously only via cat /draws/N/descriptors) to a discoverable command with --stage/--type/--binding filters, surfacing the resolved binding name, resource and texture dimensions. Regenerate commands.json and commands-quick-ref.md to keep the check-commands and check-skill-ref gates green. --- docs-astro/src/data/commands.json | 6 ++ scripts/gen-commands.py | 2 +- .../_skills/references/commands-quick-ref.md | 22 +++++ src/rdc/cli.py | 2 + src/rdc/commands/descriptors.py | 99 +++++++++++++++++++ src/rdc/handlers/descriptor.py | 24 +++++ tests/unit/test_descriptors_commands.py | 71 +++++++++++++ tests/unit/test_descriptors_daemon.py | 54 ++++++++++ 8 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/rdc/commands/descriptors.py create mode 100644 tests/unit/test_descriptors_commands.py diff --git a/docs-astro/src/data/commands.json b/docs-astro/src/data/commands.json index 6bad518..c615bb5 100644 --- a/docs-astro/src/data/commands.json +++ b/docs-astro/src/data/commands.json @@ -114,6 +114,12 @@ "id": "bindings", "help": "Show bound resources per shader stage.", "usage": "rdc bindings [EID] [--binding INTEGER] [--set INTEGER] [--no-header] [--json] [--jsonl] [-q]" + }, + { + "name": "descriptors", + "id": "descriptors", + "help": "Show the descriptors a draw actually used, resolved to resources.", + "usage": "rdc descriptors [EID] [--stage CHOICE] [--type TEXT] [--binding TEXT] [--no-header] [--json] [--jsonl] [-q]" } ] }, diff --git a/scripts/gen-commands.py b/scripts/gen-commands.py index 6eb3135..76112dd 100644 --- a/scripts/gen-commands.py +++ b/scripts/gen-commands.py @@ -28,7 +28,7 @@ "info", "stats", "events", "draws", "event", "draw", "log", "counters", ]), ("Draw Analysis", "draw-analysis", None, [ - "pipeline", "bindings", + "pipeline", "bindings", "descriptors", ]), ("Resources", "resources-section", None, [ "resources", "resource", "usage", "shader", "shaders", "shader-map", diff --git a/src/rdc/_skills/references/commands-quick-ref.md b/src/rdc/_skills/references/commands-quick-ref.md index 68df33f..9298e85 100644 --- a/src/rdc/_skills/references/commands-quick-ref.md +++ b/src/rdc/_skills/references/commands-quick-ref.md @@ -424,6 +424,28 @@ Debug vertex shader for vertex VTX_ID at event EID. | `--json` | JSON output | flag | | | `--no-header` | Suppress TSV header row | flag | | +## `rdc descriptors` + +Show the descriptors a draw actually used, resolved to resources. + +**Arguments:** + +| Name | Type | Required | +|------|------|----------| +| `eid` | integer | no | + +**Options:** + +| Flag | Help | Type | Default | +|------|------|------|---------| +| `--stage` | Filter by shader stage. | choice | | +| `--type` | Filter by descriptor type (case-insensitive). | text | | +| `--binding` | Filter by binding name or number. | text | | +| `--no-header` | Omit TSV header | flag | | +| `--json` | JSON output | flag | | +| `--jsonl` | JSONL output | flag | | +| `-q, --quiet` | Print primary key column only | flag | | + ## `rdc diff` Compare two RenderDoc captures side-by-side. diff --git a/src/rdc/cli.py b/src/rdc/cli.py index fcab73d..3a47b9a 100644 --- a/src/rdc/cli.py +++ b/src/rdc/cli.py @@ -32,6 +32,7 @@ from rdc.commands.completion import completion_cmd from rdc.commands.counters import counters_cmd from rdc.commands.debug import debug_group +from rdc.commands.descriptors import descriptors_cmd from rdc.commands.diff import diff_cmd from rdc.commands.doctor import doctor_cmd from rdc.commands.events import draw_cmd, draws_cmd, event_cmd, events_cmd @@ -123,6 +124,7 @@ def entry() -> None: main.add_command(shader_map_cmd, name="shader-map") main.add_command(pipeline_cmd, name="pipeline") main.add_command(bindings_cmd, name="bindings") +main.add_command(descriptors_cmd, name="descriptors") main.add_command(shader_cmd, name="shader") main.add_command(shaders_cmd, name="shaders") main.add_command(resources_cmd, name="resources") diff --git a/src/rdc/commands/descriptors.py b/src/rdc/commands/descriptors.py new file mode 100644 index 0000000..769af9f --- /dev/null +++ b/src/rdc/commands/descriptors.py @@ -0,0 +1,99 @@ +"""Used-descriptor inspection command.""" + +from __future__ import annotations + +from typing import Any + +import click + +from rdc.commands._helpers import call, complete_eid +from rdc.formatters.options import list_output_options, render_list +from rdc.formatters.tsv import write_tsv + +_STAGE_CHOICES = ["vs", "hs", "ds", "gs", "ps", "cs"] + + +@click.command("descriptors") +@click.argument("eid", required=False, type=int, shell_complete=complete_eid) +@click.option( + "--stage", + "stage_filter", + type=click.Choice(_STAGE_CHOICES, case_sensitive=False), + help="Filter by shader stage.", +) +@click.option("--type", "type_filter", help="Filter by descriptor type (case-insensitive).") +@click.option("--binding", "binding_filter", help="Filter by binding name or number.") +@list_output_options +def descriptors_cmd( # noqa: PLR0913 + eid: int | None, + stage_filter: str | None, + type_filter: str | None, + binding_filter: str | None, + no_header: bool, + use_json: bool, + use_jsonl: bool, + quiet: bool, +) -> None: + """Show the descriptors a draw actually used, resolved to resources. + + Each array element a shader read is listed with its resolved binding name + (e.g. g_textures), resource, and texture dimensions -- the per-draw view + that maps a bindless index to its concrete texture. + """ + params: dict[str, Any] = {} + if eid is not None: + params["eid"] = eid + if stage_filter is not None: + params["stage"] = stage_filter + if type_filter is not None: + params["type"] = type_filter + if binding_filter is not None: + params["binding"] = binding_filter + + result = call("descriptors", params) + rows: list[dict[str, Any]] = result.get("descriptors", []) + row_eid = result.get("eid", "-") + + def _table() -> None: + tsv_rows = [ + [ + row_eid, + r.get("stage", "-"), + r.get("binding", "-"), + r.get("type", "-"), + r.get("set", "-"), + r.get("array_element", "-"), + r.get("resource_id", "-"), + r.get("resource_name", "-"), + r.get("format", "-"), + r.get("width", "-"), + r.get("height", "-"), + ] + for r in rows + ] + write_tsv( + tsv_rows, + header=[ + "EID", + "STAGE", + "BINDING", + "TYPE", + "SET", + "ARRAY_EL", + "RESOURCE", + "RES_NAME", + "FORMAT", + "WIDTH", + "HEIGHT", + ], + no_header=no_header, + ) + + render_list( + rows, + use_json=use_json, + use_jsonl=use_jsonl, + quiet=quiet, + quiet_key="resource_id", + table=_table, + ) diff --git a/src/rdc/handlers/descriptor.py b/src/rdc/handlers/descriptor.py index 8ec67fa..bb6ea49 100644 --- a/src/rdc/handlers/descriptor.py +++ b/src/rdc/handlers/descriptor.py @@ -69,6 +69,19 @@ def _refl_bucket(type_name: str) -> str: return "ro" +def _descriptor_filters(params: dict[str, Any]) -> tuple[int | None, str | None, str | None]: + """Parse stage/type/binding filter params (stage as a STAGE_MAP value).""" + from rdc.services.query_service import STAGE_MAP + + stage = params.get("stage") + want_stage = STAGE_MAP.get(str(stage).lower()) if stage is not None else None + type_filter = params.get("type") + type_lower = str(type_filter).lower() if type_filter is not None else None + binding = params.get("binding") + bind_lower = str(binding).lower() if binding is not None else None + return want_stage, type_lower, bind_lower + + def _handle_descriptors( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -81,12 +94,17 @@ def _handle_descriptors( used = pipe_state.GetAllUsedDescriptors(True) bind_maps = _reflection_binding_maps(pipe_state) loc_map = _descriptor_locations(state, used) + want_stage, type_lower, bind_lower = _descriptor_filters(params) desc_rows: list[dict[str, Any]] = [] for ud in used: acc = ud.access + if want_stage is not None and int(acc.stage) != want_stage: + continue desc = ud.descriptor stage_name = _enum_name(acc.stage) type_name = _enum_name(acc.type) + if type_lower is not None and type_name.lower() != type_lower: + continue fmt = getattr(desc, "format", None) fmt_name = fmt.Name() if fmt and hasattr(fmt, "Name") else str(fmt) if fmt else "" res_id = int(desc.resource) @@ -108,6 +126,12 @@ def _handle_descriptors( name, bset = stage_buckets[_refl_bucket(type_name)].get(bind_num, ("", None)) d_row["binding"] = name or logical d_row["set"] = bset if bset is not None else acc.index + if bind_lower is not None: + candidates = {str(d_row["binding"]).lower()} + if bind_num is not None: + candidates.add(str(bind_num).lower()) + if bind_lower not in candidates: + continue d_row["resource_name"] = state.res_names.get(res_id, "") tex = state.tex_map.get(res_id) if tex is not None: diff --git a/tests/unit/test_descriptors_commands.py b/tests/unit/test_descriptors_commands.py new file mode 100644 index 0000000..1fbcc50 --- /dev/null +++ b/tests/unit/test_descriptors_commands.py @@ -0,0 +1,71 @@ +"""Tests for the descriptors CLI command.""" + +from __future__ import annotations + +from typing import Any + +from click.testing import CliRunner + +from rdc.cli import main + + +def _result() -> dict[str, Any]: + return { + "eid": 16, + "descriptors": [ + { + "eid": 16, + "stage": "Pixel", + "binding": "g_textures", + "type": "Image", + "set": 0, + "array_element": 46, + "resource_id": 371, + "resource_name": "2D Image 371", + "format": "BC1_SRGB", + "width": 512, + "height": 512, + } + ], + } + + +def test_descriptors_table(monkeypatch) -> None: + monkeypatch.setattr("rdc.commands.descriptors.call", lambda m, p: _result()) + res = CliRunner().invoke(main, ["descriptors", "16"]) + assert res.exit_code == 0 + assert "BINDING" in res.output + assert "g_textures" in res.output + assert "BC1_SRGB" in res.output + assert "512" in res.output + + +def test_descriptors_binding_filter_forwarded(monkeypatch) -> None: + captured: dict[str, Any] = {} + + def fake_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + captured.update(params) + return _result() + + monkeypatch.setattr("rdc.commands.descriptors.call", fake_call) + res = CliRunner().invoke( + main, ["descriptors", "16", "--binding", "g_textures", "--stage", "ps"] + ) + assert res.exit_code == 0 + assert captured["binding"] == "g_textures" + assert captured["stage"] == "ps" + assert captured["eid"] == 16 + + +def test_descriptors_quiet_emits_resource_ids(monkeypatch) -> None: + monkeypatch.setattr("rdc.commands.descriptors.call", lambda m, p: _result()) + res = CliRunner().invoke(main, ["descriptors", "16", "-q"]) + assert res.exit_code == 0 + assert res.output.strip() == "371" + + +def test_descriptors_json(monkeypatch) -> None: + monkeypatch.setattr("rdc.commands.descriptors.call", lambda m, p: _result()) + res = CliRunner().invoke(main, ["descriptors", "16", "--json"]) + assert res.exit_code == 0 + assert '"binding": "g_textures"' in res.output diff --git a/tests/unit/test_descriptors_daemon.py b/tests/unit/test_descriptors_daemon.py index 7122f91..2529b4e 100644 --- a/tests/unit/test_descriptors_daemon.py +++ b/tests/unit/test_descriptors_daemon.py @@ -262,3 +262,57 @@ def test_descriptors_binding_fallback_without_locations() -> None: d = resp["result"]["descriptors"][0] assert d["binding"] == "" assert d["resource_id"] == 371 + + +def test_descriptors_filters() -> None: + """stage/type/binding filters narrow the returned descriptors.""" + pipe = MockPipeState() + pipe._reflections[ShaderStage.Pixel] = ShaderReflection( + readOnlyResources=[ShaderResource(name="g_textures", fixedBindNumber=0)], + ) + pipe._used_descriptors = [ + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Vertex, type=DescriptorType.ConstantBuffer, index=0 + ), + descriptor=Descriptor(resource=ResourceId(1)), + ), + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, type=DescriptorType.Image, index=0, arrayElement=46 + ), + descriptor=Descriptor(resource=ResourceId(371)), + ), + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, type=DescriptorType.Image, index=0, arrayElement=47 + ), + descriptor=Descriptor(resource=ResourceId(379)), + ), + ] + ctrl = SimpleNamespace( + GetRootActions=lambda: [], + GetResources=lambda: [], + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: SimpleNamespace(chunks=[]), + GetPipelineState=lambda: pipe, + GetTextures=lambda: [], + GetBuffers=lambda: [], + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + GetDescriptorLocations=lambda store, ranges: [ + rd.DescriptorLogicalLocation(fixedBindNumber=0, logicalBindName="0") for _ in ranges + ], + ) + state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] + + by_stage = _call(state, "descriptors", eid=0, stage="ps")["result"]["descriptors"] + assert len(by_stage) == 2 + assert all(d["stage"] == "Pixel" for d in by_stage) + + by_type = _call(state, "descriptors", eid=0, type="image")["result"]["descriptors"] + assert all(d["type"] == "Image" for d in by_type) + + by_binding = _call(state, "descriptors", eid=0, binding="g_textures")["result"]["descriptors"] + assert len(by_binding) == 2 From f2ffdef5fb18eb0b667fcf7e3a385ea16024b47e Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:31:59 -0600 Subject: [PATCH 4/9] feat(vfs): surface binding name and texture dims in descriptors leaf Align the cat /draws/N/descriptors columns with the new 'rdc descriptors' command (binding, set, resource name, width, height). --- src/rdc/commands/vfs.py | 6 ++++-- tests/unit/test_vfs_commands.py | 29 +++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/rdc/commands/vfs.py b/src/rdc/commands/vfs.py index 9e2f74e..c5bc18d 100644 --- a/src/rdc/commands/vfs.py +++ b/src/rdc/commands/vfs.py @@ -99,9 +99,11 @@ def _fmt_pixel_mod(m: dict[str, Any]) -> str: ) ), "descriptors": lambda r: ( - "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE\n" + "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT\n" + "\n".join( - f"{d['stage']}\t{d['type']}\t{d['index']}\t{d['array_element']}\t{d['resource_id']}\t{d['format']}\t{d['byte_size']}" + f"{d['stage']}\t{d.get('binding', '')}\t{d['type']}\t{d.get('set', '')}\t" + f"{d['array_element']}\t{d['resource_id']}\t{d.get('resource_name', '')}\t" + f"{d['format']}\t{d.get('width', '')}\t{d.get('height', '')}" for d in r.get("descriptors", []) ) ), diff --git a/tests/unit/test_vfs_commands.py b/tests/unit/test_vfs_commands.py index 6a09a6a..d23a421 100644 --- a/tests/unit/test_vfs_commands.py +++ b/tests/unit/test_vfs_commands.py @@ -360,21 +360,28 @@ def test_cat_descriptors(monkeypatch) -> None: "descriptors": [ { "stage": "Vertex", + "binding": "g_push", "type": "ConstantBuffer", + "set": 0, "index": 0, "array_element": 0, "resource_id": 42, + "resource_name": "", "format": "", "byte_size": 256, }, { "stage": "Pixel", - "type": "ConstantBuffer", + "binding": "g_textures", + "type": "Image", + "set": 0, "index": 0, - "array_element": 0, - "resource_id": 43, - "format": "", - "byte_size": 128, + "array_element": 46, + "resource_id": 371, + "resource_name": "2D Image 371", + "format": "BC1_SRGB", + "width": 512, + "height": 512, }, ], }, @@ -388,9 +395,13 @@ def test_cat_descriptors(monkeypatch) -> None: result = CliRunner().invoke(cat_cmd, ["/draws/5/descriptors"]) assert result.exit_code == 0 lines = result.output.strip().split("\n") - assert lines[0] == "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE" + assert lines[0] == ( + "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT" + ) assert len(lines) == 3 - assert "Vertex\tConstantBuffer\t0\t0\t42\t\t256" in lines[1] + assert lines[1].startswith("Vertex\tg_push\tConstantBuffer\t0\t0\t42") + assert "g_textures" in lines[2] + assert "512" in lines[2] def test_cat_descriptors_empty(monkeypatch) -> None: @@ -409,7 +420,9 @@ def test_cat_descriptors_empty(monkeypatch) -> None: result = CliRunner().invoke(cat_cmd, ["/draws/0/descriptors"]) assert result.exit_code == 0 lines = result.output.strip().split("\n") - assert lines[0] == "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE" + assert lines[0] == ( + "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT" + ) assert len(lines) == 1 From f9160fd31c3275d1465f9af16488d56d0a03c06f Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:34:20 -0600 Subject: [PATCH 5/9] feat(resources): enrich resource/resources with dimensions, format and size Merge texture (width/height/format/byteSize) and buffer (length) data from the daemon's cached tex_map/buf_map into resource rows, so a resolved resource id can be inspected without a second lookup. --- src/rdc/handlers/query.py | 20 ++++++++++++++- tests/unit/test_resources_filter.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/rdc/handlers/query.py b/src/rdc/handlers/query.py index f19cebc..fa7e676 100644 --- a/src/rdc/handlers/query.py +++ b/src/rdc/handlers/query.py @@ -158,6 +158,23 @@ def _handle_shaders( return _result_response(request_id, {"rows": rows}), True +def enrich_resource_row(row: dict[str, Any], state: DaemonState) -> dict[str, Any]: + """Merge texture/buffer dimensions and size into a base resource row.""" + rid = int(row.get("id", 0)) + tex = state.tex_map.get(rid) + if tex is not None: + fmt = getattr(tex, "format", None) + row["width"] = tex.width + row["height"] = tex.height + row["format"] = fmt.Name() if fmt is not None and hasattr(fmt, "Name") else "" + row["size"] = getattr(tex, "byteSize", 0) + return row + buf = state.buf_map.get(rid) + if buf is not None: + row["size"] = getattr(buf, "length", getattr(buf, "byteSize", 0)) + return row + + def _handle_resources( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -175,6 +192,7 @@ def _handle_resources( rows = [r for r in rows if name_lower in r["name"].lower()] if sort_by in ("name", "type"): rows.sort(key=lambda r: r[sort_by].lower()) + rows = [enrich_resource_row(r, state) for r in rows] return _result_response(request_id, {"rows": rows}), True @@ -188,7 +206,7 @@ def _handle_resource( detail = get_resource_detail(state.adapter, resid) if detail is None: return _error_response(request_id, -32001, "resource not found"), True - return _result_response(request_id, {"resource": detail}), True + return _result_response(request_id, {"resource": enrich_resource_row(detail, state)}), True def _handle_passes( diff --git a/tests/unit/test_resources_filter.py b/tests/unit/test_resources_filter.py index 3df8e61..c4ce98c 100644 --- a/tests/unit/test_resources_filter.py +++ b/tests/unit/test_resources_filter.py @@ -347,3 +347,41 @@ def test_no_session_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch) -> None monkeypatch.setattr(mod, "load_session", lambda: None) result = CliRunner().invoke(resources_cmd, []) assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# TestResourceEnrichment — dimensions/format/size enrichment +# --------------------------------------------------------------------------- + + +def _tex(width: int, height: int, fmt: str, size: int) -> Any: + return SimpleNamespace( + width=width, height=height, format=SimpleNamespace(Name=lambda: fmt), byteSize=size + ) + + +class TestResourceEnrichment: + def test_resource_detail_enriched_with_dims(self) -> None: + state = _state_with_resources([_make_res(371, "2D Image 371", "Texture")]) + state.tex_map = {371: _tex(512, 512, "BC1_SRGB", 174776)} + resp, _ = _handle_request(rpc_request("resource", {"id": 371}), state) + r = resp["result"]["resource"] + assert r["width"] == 512 + assert r["height"] == 512 + assert r["format"] == "BC1_SRGB" + assert r["size"] == 174776 + + def test_resources_list_enriched_with_dims(self) -> None: + state = _state_with_resources([_make_res(371, "2D Image 371", "Texture")]) + state.tex_map = {371: _tex(512, 512, "BC1_SRGB", 174776)} + resp, _ = _handle_request(rpc_request("resources"), state) + row = resp["result"]["rows"][0] + assert row["width"] == 512 + assert row["format"] == "BC1_SRGB" + assert row["size"] == 174776 + + def test_buffer_resource_gets_size(self) -> None: + state = _state_with_resources([_make_res(99, "vtx buffer", "Buffer")]) + state.buf_map = {99: SimpleNamespace(length=4096)} + resp, _ = _handle_request(rpc_request("resource", {"id": 99}), state) + assert resp["result"]["resource"]["size"] == 4096 From a8bb7aaa9d7d538d1fc651ef925e0835f35a58b9 Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:37:01 -0600 Subject: [PATCH 6/9] feat(resources): append width/height/format/size columns to 'rdc resources' Surfaces the enriched dimension/format/size fields in the table view; existing ID/TYPE/NAME columns and quiet-key behaviour are unchanged. Update the golden parity and header tests for the widened table. --- src/rdc/commands/resources.py | 16 ++++++++++++++-- tests/unit/test_formatters.py | 6 +++++- tests/unit/test_resources_commands.py | 24 ++++++++++++++++++++++++ tests/unit/test_resources_filter.py | 9 +++------ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/rdc/commands/resources.py b/src/rdc/commands/resources.py index 94abebb..9e4ea04 100644 --- a/src/rdc/commands/resources.py +++ b/src/rdc/commands/resources.py @@ -132,8 +132,20 @@ def resources_cmd( # noqa: PLR0913 rows: list[dict[str, Any]] = result.get("rows", []) def _table() -> None: - tsv_rows = [[r.get("id", "-"), r.get("type", "-"), r.get("name", "-")] for r in rows] - write_tsv(tsv_rows, header=["ID", "TYPE", "NAME"], no_header=no_header) + tsv_rows = [ + [ + r.get("id", "-"), + r.get("type", "-"), + r.get("name", "-"), + r.get("width", "-"), + r.get("height", "-"), + r.get("format", "-"), + r.get("size", "-"), + ] + for r in rows + ] + header = ["ID", "TYPE", "NAME", "WIDTH", "HEIGHT", "FORMAT", "SIZE"] + write_tsv(tsv_rows, header=header, no_header=no_header) render_list( rows, diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py index 64fa570..4bb8b1f 100644 --- a/tests/unit/test_formatters.py +++ b/tests/unit/test_formatters.py @@ -302,7 +302,11 @@ def test_resources_table(self, monkeypatch) -> None: _patch(monkeypatch, "resources", _RESOURCES) result = CliRunner().invoke(main, ["resources"]) assert result.exit_code == 0 - assert result.output == "ID\tTYPE\tNAME\n10\tTexture\talbedo\n20\tBuffer\tverts\n" + assert result.output == ( + "ID\tTYPE\tNAME\tWIDTH\tHEIGHT\tFORMAT\tSIZE\n" + "10\tTexture\talbedo\t-\t-\t-\t-\n" + "20\tBuffer\tverts\t-\t-\t-\t-\n" + ) def test_counters_quiet(self, monkeypatch) -> None: _patch(monkeypatch, "counters", _COUNTERS) diff --git a/tests/unit/test_resources_commands.py b/tests/unit/test_resources_commands.py index 4c24c1b..cb77cf9 100644 --- a/tests/unit/test_resources_commands.py +++ b/tests/unit/test_resources_commands.py @@ -24,6 +24,30 @@ def test_resources_tsv(monkeypatch) -> None: assert "Texture" in result.output +def test_resources_enriched_columns(monkeypatch) -> None: + patch_cli_session( + monkeypatch, + { + "rows": [ + { + "id": 371, + "type": "Texture", + "name": "2D Image 371", + "width": 512, + "height": 512, + "format": "BC1_SRGB", + "size": 174776, + } + ] + }, + ) + result = CliRunner().invoke(resources_cmd, []) + assert result.exit_code == 0 + assert "ID\tTYPE\tNAME\tWIDTH\tHEIGHT\tFORMAT\tSIZE" in result.output + assert "512" in result.output + assert "BC1_SRGB" in result.output + + def test_resources_json(monkeypatch) -> None: patch_cli_session( monkeypatch, diff --git a/tests/unit/test_resources_filter.py b/tests/unit/test_resources_filter.py index c4ce98c..ded848e 100644 --- a/tests/unit/test_resources_filter.py +++ b/tests/unit/test_resources_filter.py @@ -246,15 +246,12 @@ class TestResourcesCLI: def _patch(self, monkeypatch: pytest.MonkeyPatch, rows: list[dict[str, Any]]) -> None: _patch_resources(monkeypatch, {"rows": rows}) - def test_no_options_three_column_header(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_no_options_header(self, monkeypatch: pytest.MonkeyPatch) -> None: self._patch(monkeypatch, self._BASE_ROWS) result = CliRunner().invoke(resources_cmd, []) assert result.exit_code == 0 - assert "ID" in result.output - assert "TYPE" in result.output - assert "NAME" in result.output - for ghost in ("WIDTH", "HEIGHT", "DEPTH", "FORMAT"): - assert ghost not in result.output + for col in ("ID", "TYPE", "NAME", "WIDTH", "HEIGHT", "FORMAT", "SIZE"): + assert col in result.output def test_type_option_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None: import rdc.commands._helpers as mod From 6d6e680086fd87e9b98e253e899d80a32100b03c Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 05:42:21 -0600 Subject: [PATCH 7/9] fix(descriptors): map descriptor locations positionally, not by id() The renderdoc bindings return a fresh proxy on every ud.access, so keying the GetDescriptorLocations result by id(access) collided and mis-assigned binding names (verified on a real bindless Vulkan capture). Align locations to the used list by position and add a regression test that distinguishes locations by type. --- src/rdc/handlers/descriptor.py | 38 +++++++++++-------- tests/unit/test_descriptors_daemon.py | 53 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/rdc/handlers/descriptor.py b/src/rdc/handlers/descriptor.py index bb6ea49..2395f0c 100644 --- a/src/rdc/handlers/descriptor.py +++ b/src/rdc/handlers/descriptor.py @@ -41,24 +41,30 @@ def _reflection_binding_maps(pipe_state: Any) -> dict[int, dict[str, dict[int, t return maps -def _descriptor_locations(state: DaemonState, used: Any) -> dict[int, Any]: - """Map id(access) -> logical location via GetDescriptorLocations, batched per store.""" +def _descriptor_locations(state: DaemonState, used: list[Any]) -> list[Any]: + """Logical locations aligned positionally with ``used`` (one per descriptor, or None). + + Positional rather than id()-keyed: the renderdoc bindings hand back a fresh proxy on + every ``ud.access``, so object identity is not stable across iterations. + """ assert state.adapter is not None get_locs = getattr(state.adapter.controller, "GetDescriptorLocations", None) + locs: list[Any] = [None] * len(used) if get_locs is None or not hasattr(state.rd, "DescriptorRange"): - return {} - by_store: dict[Any, list[Any]] = {} - for ud in used: - by_store.setdefault(ud.access.descriptorStore, []).append(ud.access) - out: dict[int, Any] = {} - for store, accesses in by_store.items(): + return locs + by_store: dict[int, list[int]] = {} + for i, ud in enumerate(used): + by_store.setdefault(int(ud.access.descriptorStore), []).append(i) + for idxs in by_store.values(): + store = used[idxs[0]].access.descriptorStore try: - locs = get_locs(store, [state.rd.DescriptorRange(a) for a in accesses]) + result = get_locs(store, [state.rd.DescriptorRange(used[i].access) for i in idxs]) except Exception: # noqa: BLE001 continue - for access, loc in zip(accesses, locs, strict=False): - out[id(access)] = loc - return out + for j, i in enumerate(idxs): + if j < len(result): + locs[i] = result[j] + return locs def _refl_bucket(type_name: str) -> str: @@ -91,12 +97,12 @@ def _handle_descriptors( return exc.response, True if not hasattr(pipe_state, "GetAllUsedDescriptors"): return _error_response(request_id, -32002, "GetAllUsedDescriptors not available"), True - used = pipe_state.GetAllUsedDescriptors(True) + used = list(pipe_state.GetAllUsedDescriptors(True)) bind_maps = _reflection_binding_maps(pipe_state) - loc_map = _descriptor_locations(state, used) + loc_list = _descriptor_locations(state, used) want_stage, type_lower, bind_lower = _descriptor_filters(params) desc_rows: list[dict[str, Any]] = [] - for ud in used: + for i, ud in enumerate(used): acc = ud.access if want_stage is not None and int(acc.stage) != want_stage: continue @@ -117,7 +123,7 @@ def _handle_descriptors( "format": fmt_name, "byte_size": getattr(desc, "byteSize", 0), } - loc = loc_map.get(id(acc)) + loc = loc_list[i] logical = getattr(loc, "logicalBindName", "") if loc is not None else "" bind_num = getattr(loc, "fixedBindNumber", None) if loc is not None else None name, bset = "", None diff --git a/tests/unit/test_descriptors_daemon.py b/tests/unit/test_descriptors_daemon.py index 2529b4e..7c86293 100644 --- a/tests/unit/test_descriptors_daemon.py +++ b/tests/unit/test_descriptors_daemon.py @@ -264,6 +264,59 @@ def test_descriptors_binding_fallback_without_locations() -> None: assert d["resource_id"] == 371 +def test_descriptors_locations_aligned_per_descriptor() -> None: + """Each descriptor keeps its own location (positional alignment, not id-keyed).""" + pipe = MockPipeState() + pipe._reflections[ShaderStage.Pixel] = ShaderReflection( + readOnlyResources=[ShaderResource(name="g_textures", fixedBindNumber=0)], + readWriteResources=[ShaderResource(name="g_ssbos", fixedBindNumber=1)], + ) + pipe._used_descriptors = [ + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, type=DescriptorType.Image, index=0, arrayElement=46 + ), + descriptor=Descriptor(resource=ResourceId(371)), + ), + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, + type=DescriptorType.ReadWriteBuffer, + index=0, + arrayElement=90, + ), + descriptor=Descriptor(resource=ResourceId(669)), + ), + ] + + def locations(store: object, ranges: list) -> list: + out = [] + for r in ranges: + fbn = 0 if r.type == DescriptorType.Image else 1 + out.append(rd.DescriptorLogicalLocation(fixedBindNumber=fbn, logicalBindName=str(fbn))) + return out + + ctrl = SimpleNamespace( + GetRootActions=lambda: [], + GetResources=lambda: [], + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: SimpleNamespace(chunks=[]), + GetPipelineState=lambda: pipe, + GetTextures=lambda: [], + GetBuffers=lambda: [], + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + GetDescriptorLocations=locations, + ) + state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] + + rows = _call(state, "descriptors", eid=0)["result"]["descriptors"] + by_res = {d["resource_id"]: d["binding"] for d in rows} + assert by_res[371] == "g_textures" + assert by_res[669] == "g_ssbos" + + def test_descriptors_filters() -> None: """stage/type/binding filters narrow the returned descriptors.""" pipe = MockPipeState() From a143e8d36d567d709e49868e92b3e95cd05d4ef4 Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 08:05:55 -0600 Subject: [PATCH 8/9] fix(descriptors): correlate via reflection index, not bind number GetAllUsedDescriptors' access.index is documented as the index into the shader's reflection list for that descriptor type, so resolving names through it is per (set, binding) and cannot collide across descriptor sets/spaces. The previous fixedBindNumber-keyed join collapsed e.g. set=1/binding=0 and set=2/binding=0 onto a single entry, mislabelling one of them. - replace the fixedBindNumber bucket + GetDescriptorLocations join with a direct reflection-index lookup over the read-only/read-write/constant-block/sampler arrays (also yields real names for push constants and samplers) - drop the descriptor-location machinery and its now-unused mock types; this removes the per-store except/continue that rendered a total correlation failure identically to a draw with no named bindings - add a multi-set regression test the old keying could not pass --- src/rdc/handlers/descriptor.py | 98 +++++++++------------- tests/mocks/mock_renderdoc.py | 20 ----- tests/unit/test_descriptors_daemon.py | 116 +++++++++----------------- 3 files changed, 79 insertions(+), 155 deletions(-) diff --git a/src/rdc/handlers/descriptor.py b/src/rdc/handlers/descriptor.py index 2395f0c..63df598 100644 --- a/src/rdc/handlers/descriptor.py +++ b/src/rdc/handlers/descriptor.py @@ -17,64 +17,55 @@ from rdc.daemon_server import DaemonState -def _bind_bucket(items: Any) -> dict[int, tuple[str, int]]: - return { - getattr(r, "fixedBindNumber", 0): (r.name, getattr(r, "fixedBindSetOrSpace", 0)) - for r in items - } +def _reflection_resources(pipe_state: Any) -> dict[int, dict[str, list[Any]]]: + """Per-stage reflection resources by category, keyed by stage value. - -def _reflection_binding_maps(pipe_state: Any) -> dict[int, dict[str, dict[int, tuple[str, int]]]]: - """Per-stage {bucket: {bind_number: (name, set)}} from reflection, keyed by stage value.""" + Correlation uses ``DescriptorAccess.index``, which renderdoc documents as the index into + the shader's reflection list for that descriptor type. It is therefore per (set, binding) + and never collides across descriptor sets/spaces, unlike a ``fixedBindNumber`` join. + """ from rdc.services.query_service import STAGE_MAP - maps: dict[int, dict[str, dict[int, tuple[str, int]]]] = {} + out: dict[int, dict[str, list[Any]]] = {} for stage_val in STAGE_MAP.values(): refl = pipe_state.GetShaderReflection(stage_val) if refl is None: continue - maps[stage_val] = { - "ro": _bind_bucket(getattr(refl, "readOnlyResources", [])), - "rw": _bind_bucket(getattr(refl, "readWriteResources", [])), - "cb": _bind_bucket(getattr(refl, "constantBlocks", [])), + out[stage_val] = { + "ro": list(getattr(refl, "readOnlyResources", [])), + "rw": list(getattr(refl, "readWriteResources", [])), + "cb": list(getattr(refl, "constantBlocks", [])), + "sampler": list(getattr(refl, "samplers", [])), } - return maps + return out -def _descriptor_locations(state: DaemonState, used: list[Any]) -> list[Any]: - """Logical locations aligned positionally with ``used`` (one per descriptor, or None). - - Positional rather than id()-keyed: the renderdoc bindings hand back a fresh proxy on - every ``ud.access``, so object identity is not stable across iterations. - """ - assert state.adapter is not None - get_locs = getattr(state.adapter.controller, "GetDescriptorLocations", None) - locs: list[Any] = [None] * len(used) - if get_locs is None or not hasattr(state.rd, "DescriptorRange"): - return locs - by_store: dict[int, list[int]] = {} - for i, ud in enumerate(used): - by_store.setdefault(int(ud.access.descriptorStore), []).append(i) - for idxs in by_store.values(): - store = used[idxs[0]].access.descriptorStore - try: - result = get_locs(store, [state.rd.DescriptorRange(used[i].access) for i in idxs]) - except Exception: # noqa: BLE001 - continue - for j, i in enumerate(idxs): - if j < len(result): - locs[i] = result[j] - return locs - - -def _refl_bucket(type_name: str) -> str: +def _refl_category(type_name: str) -> str: if type_name == "ConstantBuffer": return "cb" if type_name.startswith("ReadWrite"): return "rw" + if type_name == "Sampler": + return "sampler" return "ro" +def _resolve_binding( + refl_map: dict[int, dict[str, list[Any]]], acc: Any, type_name: str +) -> tuple[str, int | None, int | None]: + """Resolve (name, set, bind_number) for a used descriptor via its reflection index.""" + arr = refl_map.get(int(acc.stage), {}).get(_refl_category(type_name), []) + idx = acc.index + if 0 <= idx < len(arr): + r = arr[idx] + return ( + getattr(r, "name", ""), + getattr(r, "fixedBindSetOrSpace", None), + getattr(r, "fixedBindNumber", None), + ) + return "", None, None + + def _descriptor_filters(params: dict[str, Any]) -> tuple[int | None, str | None, str | None]: """Parse stage/type/binding filter params (stage as a STAGE_MAP value).""" from rdc.services.query_service import STAGE_MAP @@ -97,12 +88,11 @@ def _handle_descriptors( return exc.response, True if not hasattr(pipe_state, "GetAllUsedDescriptors"): return _error_response(request_id, -32002, "GetAllUsedDescriptors not available"), True - used = list(pipe_state.GetAllUsedDescriptors(True)) - bind_maps = _reflection_binding_maps(pipe_state) - loc_list = _descriptor_locations(state, used) + used = pipe_state.GetAllUsedDescriptors(True) + refl_map = _reflection_resources(pipe_state) want_stage, type_lower, bind_lower = _descriptor_filters(params) desc_rows: list[dict[str, Any]] = [] - for i, ud in enumerate(used): + for ud in used: acc = ud.access if want_stage is not None and int(acc.stage) != want_stage: continue @@ -123,19 +113,13 @@ def _handle_descriptors( "format": fmt_name, "byte_size": getattr(desc, "byteSize", 0), } - loc = loc_list[i] - logical = getattr(loc, "logicalBindName", "") if loc is not None else "" - bind_num = getattr(loc, "fixedBindNumber", None) if loc is not None else None - name, bset = "", None - stage_buckets = bind_maps.get(int(acc.stage)) - if stage_buckets is not None and bind_num is not None: - name, bset = stage_buckets[_refl_bucket(type_name)].get(bind_num, ("", None)) - d_row["binding"] = name or logical - d_row["set"] = bset if bset is not None else acc.index + name, bset, bnum = _resolve_binding(refl_map, acc, type_name) + d_row["binding"] = name + d_row["set"] = bset if bset is not None else "-" if bind_lower is not None: - candidates = {str(d_row["binding"]).lower()} - if bind_num is not None: - candidates.add(str(bind_num).lower()) + candidates = {name.lower()} + if bnum is not None: + candidates.add(str(bnum).lower()) if bind_lower not in candidates: continue d_row["resource_name"] = state.res_names.get(res_id, "") diff --git a/tests/mocks/mock_renderdoc.py b/tests/mocks/mock_renderdoc.py index 62fba8c..a0d64ab 100644 --- a/tests/mocks/mock_renderdoc.py +++ b/tests/mocks/mock_renderdoc.py @@ -963,26 +963,6 @@ class UsedDescriptor: sampler: SamplerDescriptor = field(default_factory=SamplerDescriptor) -@dataclass -class DescriptorLogicalLocation: - """Mock for DescriptorLogicalLocation from GetDescriptorLocations.""" - - fixedBindNumber: int = 0 - logicalBindName: str = "" - category: int = 0 - stageMask: int = 0 - - -class DescriptorRange: - """Mock for DescriptorRange, constructible from a DescriptorAccess.""" - - def __init__(self, access: DescriptorAccess | None = None) -> None: - self.offset = access.index if access is not None else 0 - self.count = 1 - self.descriptorSize = 0 - self.type = access.type if access is not None else DescriptorType.ConstantBuffer - - @dataclass class MeshFormat: allowRestart: bool = False diff --git a/tests/unit/test_descriptors_daemon.py b/tests/unit/test_descriptors_daemon.py index 7c86293..e90a411 100644 --- a/tests/unit/test_descriptors_daemon.py +++ b/tests/unit/test_descriptors_daemon.py @@ -219,31 +219,14 @@ def _ps_with_textures_binding() -> MockPipeState: def test_descriptors_binding_name_correlation() -> None: """A used image element is correlated to its reflection binding name and resource.""" pipe = _ps_with_textures_binding() - ctrl = SimpleNamespace( - GetRootActions=lambda: [], - GetResources=lambda: [], - GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), - SetFrameEvent=lambda eid, force: None, - GetStructuredFile=lambda: SimpleNamespace(chunks=[]), - GetPipelineState=lambda: pipe, - GetTextures=lambda: [], - GetBuffers=lambda: [], - GetDebugMessages=lambda: [], - Shutdown=lambda: None, - GetDescriptorLocations=lambda store, ranges: [ - rd.DescriptorLogicalLocation(fixedBindNumber=0, logicalBindName="0[46]") for _ in ranges - ], - ) - state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] + state = _make_state_with_pipe(pipe) state.res_names = {371: "2D Image 371"} state.tex_map = { 371: SimpleNamespace( width=512, height=512, format=SimpleNamespace(Name=lambda: "BC1_SRGB"), byteSize=174776 ) } - resp = _call(state, "descriptors", eid=16) - - d = resp["result"]["descriptors"][0] + d = _call(state, "descriptors", eid=16)["result"]["descriptors"][0] assert d["binding"] == "g_textures" assert d["set"] == 0 assert d["array_element"] == 46 @@ -253,23 +236,36 @@ def test_descriptors_binding_name_correlation() -> None: assert d["height"] == 512 -def test_descriptors_binding_fallback_without_locations() -> None: - """Without GetDescriptorLocations the binding is empty but the row is intact.""" - pipe = _ps_with_textures_binding() +def test_descriptors_binding_empty_without_reflection() -> None: + """With no shader reflection the binding is empty but the row is intact.""" + pipe = MockPipeState() + pipe._used_descriptors = [ + UsedDescriptor( + access=DescriptorAccess( + stage=ShaderStage.Pixel, type=DescriptorType.Image, index=0, arrayElement=46 + ), + descriptor=Descriptor(resource=ResourceId(371)), + ), + ] state = _make_state_with_pipe(pipe) - resp = _call(state, "descriptors", eid=16) - - d = resp["result"]["descriptors"][0] + d = _call(state, "descriptors", eid=16)["result"]["descriptors"][0] assert d["binding"] == "" + assert d["set"] == "-" assert d["resource_id"] == 371 -def test_descriptors_locations_aligned_per_descriptor() -> None: - """Each descriptor keeps its own location (positional alignment, not id-keyed).""" +def test_descriptors_no_cross_set_collision() -> None: + """Bindings sharing a bind number across sets resolve to distinct names. + + Correlation is by reflection index (DescriptorAccess.index), so set=1/binding=0 and + set=2/binding=0 never collapse onto one another. + """ pipe = MockPipeState() pipe._reflections[ShaderStage.Pixel] = ShaderReflection( - readOnlyResources=[ShaderResource(name="g_textures", fixedBindNumber=0)], - readWriteResources=[ShaderResource(name="g_ssbos", fixedBindNumber=1)], + readOnlyResources=[ + ShaderResource(name="g_textures", fixedBindNumber=0, fixedBindSetOrSpace=1), + ShaderResource(name="materialTex", fixedBindNumber=0, fixedBindSetOrSpace=2), + ], ) pipe._used_descriptors = [ UsedDescriptor( @@ -280,41 +276,16 @@ def test_descriptors_locations_aligned_per_descriptor() -> None: ), UsedDescriptor( access=DescriptorAccess( - stage=ShaderStage.Pixel, - type=DescriptorType.ReadWriteBuffer, - index=0, - arrayElement=90, + stage=ShaderStage.Pixel, type=DescriptorType.Image, index=1, arrayElement=0 ), - descriptor=Descriptor(resource=ResourceId(669)), + descriptor=Descriptor(resource=ResourceId(500)), ), ] - - def locations(store: object, ranges: list) -> list: - out = [] - for r in ranges: - fbn = 0 if r.type == DescriptorType.Image else 1 - out.append(rd.DescriptorLogicalLocation(fixedBindNumber=fbn, logicalBindName=str(fbn))) - return out - - ctrl = SimpleNamespace( - GetRootActions=lambda: [], - GetResources=lambda: [], - GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), - SetFrameEvent=lambda eid, force: None, - GetStructuredFile=lambda: SimpleNamespace(chunks=[]), - GetPipelineState=lambda: pipe, - GetTextures=lambda: [], - GetBuffers=lambda: [], - GetDebugMessages=lambda: [], - Shutdown=lambda: None, - GetDescriptorLocations=locations, - ) - state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] - + state = _make_state_with_pipe(pipe) rows = _call(state, "descriptors", eid=0)["result"]["descriptors"] - by_res = {d["resource_id"]: d["binding"] for d in rows} - assert by_res[371] == "g_textures" - assert by_res[669] == "g_ssbos" + by_res = {d["resource_id"]: (d["binding"], d["set"]) for d in rows} + assert by_res[371] == ("g_textures", 1) + assert by_res[500] == ("materialTex", 2) def test_descriptors_filters() -> None: @@ -343,29 +314,18 @@ def test_descriptors_filters() -> None: descriptor=Descriptor(resource=ResourceId(379)), ), ] - ctrl = SimpleNamespace( - GetRootActions=lambda: [], - GetResources=lambda: [], - GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), - SetFrameEvent=lambda eid, force: None, - GetStructuredFile=lambda: SimpleNamespace(chunks=[]), - GetPipelineState=lambda: pipe, - GetTextures=lambda: [], - GetBuffers=lambda: [], - GetDebugMessages=lambda: [], - Shutdown=lambda: None, - GetDescriptorLocations=lambda store, ranges: [ - rd.DescriptorLogicalLocation(fixedBindNumber=0, logicalBindName="0") for _ in ranges - ], - ) - state = make_daemon_state(ctrl=ctrl, token="test-token", rd=rd) # type: ignore[arg-type] + state = _make_state_with_pipe(pipe) by_stage = _call(state, "descriptors", eid=0, stage="ps")["result"]["descriptors"] assert len(by_stage) == 2 assert all(d["stage"] == "Pixel" for d in by_stage) by_type = _call(state, "descriptors", eid=0, type="image")["result"]["descriptors"] + assert len(by_type) == 2 assert all(d["type"] == "Image" for d in by_type) - by_binding = _call(state, "descriptors", eid=0, binding="g_textures")["result"]["descriptors"] - assert len(by_binding) == 2 + by_name = _call(state, "descriptors", eid=0, binding="g_textures")["result"]["descriptors"] + assert len(by_name) == 2 + + by_number = _call(state, "descriptors", eid=0, binding="0")["result"]["descriptors"] + assert len(by_number) == 2 From 61f6c5cbc8d631599176b189ee563d415365fc67 Mon Sep 17 00:00:00 2001 From: "kingstom.chen" Date: Tue, 30 Jun 2026 08:06:29 -0600 Subject: [PATCH 9/9] fix(vfs): keep descriptors leaf columns append-only The /draws//descriptors leaf shipped with STAGE TYPE INDEX ARRAY_EL RESOURCE FORMAT BYTE_SIZE. Restore that column order and append BINDING/SET/RES_NAME/WIDTH/HEIGHT on the right so existing cut -f consumers of the prior layout keep working; the JSON output already kept the original fields. --- src/rdc/commands/vfs.py | 8 ++++---- tests/unit/test_vfs_commands.py | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/rdc/commands/vfs.py b/src/rdc/commands/vfs.py index c5bc18d..95527ec 100644 --- a/src/rdc/commands/vfs.py +++ b/src/rdc/commands/vfs.py @@ -99,11 +99,11 @@ def _fmt_pixel_mod(m: dict[str, Any]) -> str: ) ), "descriptors": lambda r: ( - "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT\n" + "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE\tBINDING\tSET\tRES_NAME\tWIDTH\tHEIGHT\n" + "\n".join( - f"{d['stage']}\t{d.get('binding', '')}\t{d['type']}\t{d.get('set', '')}\t" - f"{d['array_element']}\t{d['resource_id']}\t{d.get('resource_name', '')}\t" - f"{d['format']}\t{d.get('width', '')}\t{d.get('height', '')}" + f"{d['stage']}\t{d['type']}\t{d['index']}\t{d['array_element']}\t{d['resource_id']}\t" + f"{d['format']}\t{d['byte_size']}\t{d.get('binding', '')}\t{d.get('set', '')}\t" + f"{d.get('resource_name', '')}\t{d.get('width', '')}\t{d.get('height', '')}" for d in r.get("descriptors", []) ) ), diff --git a/tests/unit/test_vfs_commands.py b/tests/unit/test_vfs_commands.py index d23a421..a40415e 100644 --- a/tests/unit/test_vfs_commands.py +++ b/tests/unit/test_vfs_commands.py @@ -380,6 +380,7 @@ def test_cat_descriptors(monkeypatch) -> None: "resource_id": 371, "resource_name": "2D Image 371", "format": "BC1_SRGB", + "byte_size": 0, "width": 512, "height": 512, }, @@ -395,11 +396,12 @@ def test_cat_descriptors(monkeypatch) -> None: result = CliRunner().invoke(cat_cmd, ["/draws/5/descriptors"]) assert result.exit_code == 0 lines = result.output.strip().split("\n") + # Original columns kept in place (cut -f compatible); new columns appended on the right. assert lines[0] == ( - "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT" + "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE\tBINDING\tSET\tRES_NAME\tWIDTH\tHEIGHT" ) assert len(lines) == 3 - assert lines[1].startswith("Vertex\tg_push\tConstantBuffer\t0\t0\t42") + assert lines[1].startswith("Vertex\tConstantBuffer\t0\t0\t42\t\t256\tg_push") assert "g_textures" in lines[2] assert "512" in lines[2] @@ -421,7 +423,7 @@ def test_cat_descriptors_empty(monkeypatch) -> None: assert result.exit_code == 0 lines = result.output.strip().split("\n") assert lines[0] == ( - "STAGE\tBINDING\tTYPE\tSET\tARRAY_EL\tRESOURCE\tRES_NAME\tFORMAT\tWIDTH\tHEIGHT" + "STAGE\tTYPE\tINDEX\tARRAY_EL\tRESOURCE\tFORMAT\tBYTE_SIZE\tBINDING\tSET\tRES_NAME\tWIDTH\tHEIGHT" ) assert len(lines) == 1