From d438450f2bfe2ef5498310347e60bfce0fcc4858 Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 28 Feb 2026 23:21:40 -0800 Subject: [PATCH 1/4] feat(vfs): add pixel dir, pass attachments, shader used-by routes Fill three VFS route gaps: 1. /draws//pixel/ - add to _DRAW_CHILDREN and register dir routes so `rdc ls` and `rdc tree` show the pixel directory 2. /passes//attachments/ - lazy-populate color/depth children via populate_pass_attachments(), add pass_attachment handler returning resource IDs 3. /shaders//used-by - persist EID list in shader_meta, add leaf route and handler returning which draws reference a shader --- src/rdc/commands/vfs.py | 2 + src/rdc/handlers/_helpers.py | 32 ++++++ src/rdc/handlers/query.py | 48 ++++++++ src/rdc/handlers/shader.py | 13 +++ src/rdc/handlers/vfs.py | 8 ++ src/rdc/vfs/router.py | 4 + src/rdc/vfs/tree_cache.py | 31 ++++- tests/unit/test_draws_events_daemon.py | 151 +++++++++++++++++++++++++ tests/unit/test_shader_preload.py | 14 +++ tests/unit/test_vfs_daemon.py | 121 ++++++++++++++++++++ tests/unit/test_vfs_router.py | 75 ++++++++++++ tests/unit/test_vfs_tree_cache.py | 86 +++++++++++++- 12 files changed, 583 insertions(+), 2 deletions(-) diff --git a/src/rdc/commands/vfs.py b/src/rdc/commands/vfs.py index 39d6a47..e7306f7 100644 --- a/src/rdc/commands/vfs.py +++ b/src/rdc/commands/vfs.py @@ -82,6 +82,8 @@ def _fmt_pixel_mod(m: dict[str, Any]) -> str: "EID\tFRAG\tDEPTH\tPASSED\tFLAGS\n" + "\n".join(_fmt_pixel_mod(m) for m in r.get("modifications", [])) ), + "pass_attachment": lambda r: format_kv(r), + "shader_used_by": lambda r: "\n".join(str(e) for e in r.get("eids", [])), } diff --git a/src/rdc/handlers/_helpers.py b/src/rdc/handlers/_helpers.py index 2ace90e..7ed2833 100644 --- a/src/rdc/handlers/_helpers.py +++ b/src/rdc/handlers/_helpers.py @@ -47,6 +47,7 @@ def __init__(self, response: dict[str, Any]) -> None: _VALID_LOG_LEVELS: set[str] = {*_LOG_SEVERITY_MAP.values(), "UNKNOWN"} _SHADER_PATH_RE = re.compile(r"^/draws/(\d+)/(?:shader|targets|bindings|cbuffer)(?:/|$)") +_PASS_ATTACH_RE = re.compile(r"^/passes/([^/]+)/attachments(?:/|$)") def _result_response(request_id: int, result: dict[str, Any]) -> dict[str, Any]: @@ -212,6 +213,7 @@ def _walk(actions: list[Any]) -> None: "stages": shader_stages[sid], "uses": len(shader_eids[sid]), "first_eid": shader_eids[sid][0], + "eids": shader_eids[sid], "entry": getattr(refl, "entryPoint", "main") if refl else "main", "inputs": len(getattr(refl, "readOnlyResources", [])) if refl else 0, "outputs": len(getattr(refl, "readWriteResources", [])) if refl else 0, @@ -347,3 +349,33 @@ def _ensure_shader_populated( assert state.vfs_tree is not None populate_draw_subtree(state.vfs_tree, eid, pipe) return None + + +def _ensure_pass_attachments_populated( + request_id: int, path: str, state: DaemonState +) -> dict[str, Any] | None: + """Trigger dynamic pass attachment subtree population if needed.""" + m = _PASS_ATTACH_RE.match(path) + if m is None or state.vfs_tree is None: + return None + pass_name = m.group(1) + attach_path = f"/passes/{pass_name}/attachments" + node = state.vfs_tree.static.get(attach_path) + if node is None or node.children: + return None + + safe_map = state.vfs_tree.pass_name_map + orig_name = safe_map.get(pass_name, pass_name) + pass_info = next((p for p in state.vfs_tree.pass_list if p["name"] == orig_name), None) + if pass_info is None: + return None + + begin_eid = pass_info.get("begin_eid", 0) + err = _seek_replay(state, begin_eid) + if err: + return _error_response(request_id, -32002, err) + pipe = state.adapter.get_pipeline_state() # type: ignore[union-attr] + from rdc.vfs.tree_cache import populate_pass_attachments + + populate_pass_attachments(state.vfs_tree, pass_name, pipe) + return None diff --git a/src/rdc/handlers/query.py b/src/rdc/handlers/query.py index 5b149c1..fd5b30b 100644 --- a/src/rdc/handlers/query.py +++ b/src/rdc/handlers/query.py @@ -585,6 +585,53 @@ def _handle_pass_deps( return _result_response(request_id, result), True +def _handle_pass_attachment( + request_id: int, params: dict[str, Any], state: DaemonState +) -> tuple[dict[str, Any], bool]: + """Return attachment info for a specific pass attachment.""" + if state.adapter is None: + return _error_response(request_id, -32002, "no replay loaded"), True + from rdc.services.query_service import get_pass_detail + + name = str(params.get("name", "")) + attachment = str(params.get("attachment", "")) + if state.vfs_tree and name in state.vfs_tree.pass_name_map: + name = state.vfs_tree.pass_name_map[name] + + actions = state.adapter.get_root_actions() + detail = get_pass_detail(actions, state.structured_file, name) + if detail is None: + return _error_response(request_id, -32001, f"pass not found: {name}"), True + + err = _seek_replay(state, detail["begin_eid"]) + if err: + return _error_response(request_id, -32002, err), True + pipe = state.adapter.get_pipeline_state() + + if attachment == "depth": + depth_id = int(pipe.GetDepthTarget().resource) + if depth_id == 0: + return _error_response(request_id, -32001, "no depth target"), True + return _result_response( + request_id, {"pass": name, "attachment": "depth", "resource_id": depth_id} + ), True + + if attachment.startswith("color"): + try: + idx = int(attachment[5:]) + except ValueError: + return _error_response(request_id, -32602, f"invalid attachment: {attachment}"), True + targets = [t for t in pipe.GetOutputTargets() if int(t.resource) != 0] + if idx < 0 or idx >= len(targets): + return _error_response(request_id, -32001, f"color target {idx} not found"), True + return _result_response( + request_id, + {"pass": name, "attachment": attachment, "resource_id": int(targets[idx].resource)}, + ), True + + return _error_response(request_id, -32001, f"unknown attachment: {attachment}"), True + + def _handle_preload( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -607,6 +654,7 @@ def _handle_preload( "passes": _handle_passes, "pass": _handle_pass, "pass_deps": _handle_pass_deps, + "pass_attachment": _handle_pass_attachment, "log": _handle_log, "info": _handle_info, "stats": _handle_stats, diff --git a/src/rdc/handlers/shader.py b/src/rdc/handlers/shader.py index 025982e..b3ba367 100644 --- a/src/rdc/handlers/shader.py +++ b/src/rdc/handlers/shader.py @@ -260,6 +260,18 @@ def _handle_shader_list_info( return _result_response(request_id, {"id": sid, **info_meta}), True +def _handle_shader_used_by( + request_id: int, params: dict[str, Any], state: DaemonState +) -> tuple[dict[str, Any], bool]: + """Return list of EIDs that use this shader.""" + _build_shader_cache(state) + sid = int(params.get("id", 0)) + meta = state.shader_meta.get(sid) + if meta is None: + return _error_response(request_id, -32001, f"shader {sid} not found"), True + return _result_response(request_id, {"id": sid, "eids": meta.get("eids", [])}), True + + def _handle_shader_list_disasm( request_id: int, params: dict[str, Any], state: DaemonState ) -> tuple[dict[str, Any], bool]: @@ -279,4 +291,5 @@ def _handle_shader_list_disasm( "shader_all": _handle_shader_all, "shader_list_info": _handle_shader_list_info, "shader_list_disasm": _handle_shader_list_disasm, + "shader_used_by": _handle_shader_used_by, } diff --git a/src/rdc/handlers/vfs.py b/src/rdc/handlers/vfs.py index 3a6fab5..63866d8 100644 --- a/src/rdc/handlers/vfs.py +++ b/src/rdc/handlers/vfs.py @@ -7,6 +7,7 @@ from rdc.handlers._helpers import ( _action_type_str, _build_shader_cache, + _ensure_pass_attachments_populated, _ensure_shader_populated, _error_response, _get_flat_actions, @@ -280,6 +281,10 @@ def _handle_vfs_ls( if pop_err: return pop_err, True + pop_err2 = _ensure_pass_attachments_populated(request_id, path, state) + if pop_err2: + return pop_err2, True + node = state.vfs_tree.static.get(path) if node is None: return _error_response(request_id, -32001, f"not found: {path}"), True @@ -341,6 +346,9 @@ def _subtree(p: str, d: int) -> dict[str, Any]: err = _ensure_shader_populated(request_id, p, state) if err is not None: raise _VfsPopulateError(err) + err2 = _ensure_pass_attachments_populated(request_id, p, state) + if err2 is not None: + raise _VfsPopulateError(err2) n = tree.static.get(p) if n is None: return {"name": p.rsplit("/", 1)[-1] or "/", "kind": "dir", "children": []} diff --git a/src/rdc/vfs/router.py b/src/rdc/vfs/router.py index 7a6a75e..77f36dd 100644 --- a/src/rdc/vfs/router.py +++ b/src/rdc/vfs/router.py @@ -112,6 +112,8 @@ def _r( _r(r"/draws/(?P\d+)/vbuffer", "leaf", "vbuffer_decode", [("eid", int)]) _r(r"/draws/(?P\d+)/ibuffer", "leaf", "ibuffer_decode", [("eid", int)]) _r(r"/draws/(?P\d+)/descriptors", "leaf", "descriptors", [("eid", int)]) +_r(r"/draws/(?P\d+)/pixel", "dir", None, [("eid", int)]) +_r(r"/draws/(?P\d+)/pixel/(?P\d+)", "dir", None, [("eid", int), ("x", int)]) _r( r"/draws/(?P\d+)/pixel/(?P\d+)/(?P\d+)", "leaf", @@ -149,6 +151,7 @@ def _r( _r(r"/passes/(?P[^/]+)/info", "leaf", "pass") _r(r"/passes/(?P[^/]+)/draws", "dir") _r(r"/passes/(?P[^/]+)/attachments", "dir") +_r(r"/passes/(?P[^/]+)/attachments/(?P[^/]+)", "leaf", "pass_attachment") # resources _r("/resources", "dir") @@ -161,6 +164,7 @@ def _r( _r(r"/shaders/(?P\d+)", "dir", None, [("id", int)]) _r(r"/shaders/(?P\d+)/info", "leaf", "shader_list_info", [("id", int)]) _r(r"/shaders/(?P\d+)/disasm", "leaf", "shader_list_disasm", [("id", int)]) +_r(r"/shaders/(?P\d+)/used-by", "leaf", "shader_used_by", [("id", int)]) _r("/by-marker", "dir") _r("/textures", "dir") _r(r"/textures/(?P\d+)", "dir", None, [("id", int)]) diff --git a/src/rdc/vfs/tree_cache.py b/src/rdc/vfs/tree_cache.py index 25e26b1..f31eec3 100644 --- a/src/rdc/vfs/tree_cache.py +++ b/src/rdc/vfs/tree_cache.py @@ -45,6 +45,7 @@ "vbuffer", "ibuffer", "descriptors", + "pixel", ] _PIPELINE_CHILDREN = [ "summary", @@ -64,7 +65,7 @@ ] _PASS_CHILDREN = ["info", "draws", "attachments"] _SHADER_STAGE_CHILDREN = ["disasm", "source", "reflect", "constants"] -_SHADER_CHILDREN = ["info", "disasm"] +_SHADER_CHILDREN = ["info", "disasm", "used-by"] @dataclass @@ -171,6 +172,7 @@ def build_vfs_skeleton( tree.static[f"{prefix}/vbuffer"] = VfsNode("vbuffer", "leaf") tree.static[f"{prefix}/ibuffer"] = VfsNode("ibuffer", "leaf") tree.static[f"{prefix}/descriptors"] = VfsNode("descriptors", "leaf") + tree.static[f"{prefix}/pixel"] = VfsNode("pixel", "dir") # /passes — sanitize names containing "/" to avoid path corruption safe_pass_names = [n.replace("/", "_") for n in pass_names] @@ -362,6 +364,32 @@ def populate_draw_subtree( return subtree +def populate_pass_attachments( + tree: VfsTree, + pass_name: str, + pipe_state: Any, +) -> None: + """Populate /passes//attachments/ with color and depth leaves.""" + prefix = f"/passes/{pass_name}/attachments" + node = tree.static.get(prefix) + if node is None or node.children: + return + + children: list[str] = [] + for i, t in enumerate(pipe_state.GetOutputTargets()): + if int(t.resource) != 0: + name = f"color{i}" + children.append(name) + tree.static[f"{prefix}/{name}"] = VfsNode(name, "leaf") + + depth = pipe_state.GetDepthTarget() + if int(depth.resource) != 0: + children.append("depth") + tree.static[f"{prefix}/depth"] = VfsNode("depth", "leaf") + + node.children = list(children) + + def populate_shaders_subtree(tree: VfsTree, shader_meta: dict[int, dict[str, Any]]) -> None: """Populate /shaders// dir nodes from shader metadata. @@ -376,3 +404,4 @@ def populate_shaders_subtree(tree: VfsTree, shader_meta: dict[int, dict[str, Any tree.static[prefix] = VfsNode(str(sid), "dir", list(_SHADER_CHILDREN)) tree.static[f"{prefix}/info"] = VfsNode("info", "leaf") tree.static[f"{prefix}/disasm"] = VfsNode("disasm", "leaf") + tree.static[f"{prefix}/used-by"] = VfsNode("used-by", "leaf") diff --git a/tests/unit/test_draws_events_daemon.py b/tests/unit/test_draws_events_daemon.py index 29e22c0..48fbf96 100644 --- a/tests/unit/test_draws_events_daemon.py +++ b/tests/unit/test_draws_events_daemon.py @@ -9,10 +9,16 @@ ActionDescription, ActionFlags, APIEvent, + Descriptor, + MockPipeState, + ResourceDescription, + ResourceId, SDBasic, SDChunk, SDData, SDObject, + ShaderReflection, + ShaderStage, StructuredFile, ) @@ -505,3 +511,148 @@ def test_mesh_dispatch_in_info_draw_calls(self): def test_mesh_dispatch_not_classified_as_dispatch(self): resp, _ = _handle_request(rpc_request("count", {"what": "dispatches"}), _make_mesh_state()) assert resp["result"]["value"] == 0 + + +# --------------------------------------------------------------------------- +# Gap 2: pass_attachment handler +# --------------------------------------------------------------------------- + + +def _make_pass_attachment_state(): + """State with a pass and pipeline targets for attachment tests.""" + actions = _build_actions() + sf = _build_sf() + resources = [ResourceDescription(resourceId=ResourceId(100), name="tex0")] + pipe = MockPipeState( + output_targets=[ + Descriptor(resource=ResourceId(300)), + Descriptor(resource=ResourceId(400)), + ], + depth_target=Descriptor(resource=ResourceId(500)), + ) + ctrl = SimpleNamespace( + GetRootActions=lambda: actions, + GetResources=lambda: resources, + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + GetPipelineState=lambda: pipe, + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: sf, + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + ) + state = make_daemon_state(ctrl=ctrl, version=(1, 33), max_eid=300, structured_file=sf) + from rdc.vfs.tree_cache import build_vfs_skeleton + + state.vfs_tree = build_vfs_skeleton(actions, resources, sf=sf) + return state + + +class TestPassAttachmentHandler: + def test_returns_color_resource_id(self): + state = _make_pass_attachment_state() + resp, _ = _handle_request( + rpc_request("pass_attachment", {"name": "Shadow", "attachment": "color0"}), state + ) + assert "error" not in resp + assert resp["result"]["resource_id"] == 300 + + def test_returns_depth_resource_id(self): + state = _make_pass_attachment_state() + resp, _ = _handle_request( + rpc_request("pass_attachment", {"name": "Shadow", "attachment": "depth"}), state + ) + assert "error" not in resp + assert resp["result"]["resource_id"] == 500 + + def test_color_not_found(self): + state = _make_pass_attachment_state() + resp, _ = _handle_request( + rpc_request("pass_attachment", {"name": "Shadow", "attachment": "color99"}), state + ) + assert resp["error"]["code"] == -32001 + + def test_pass_not_found(self): + state = _make_pass_attachment_state() + resp, _ = _handle_request( + rpc_request("pass_attachment", {"name": "NonExistent", "attachment": "color0"}), state + ) + assert resp["error"]["code"] == -32001 + + def test_no_adapter(self): + state = DaemonState(capture="test.rdc", current_eid=0, token="tok") + resp, _ = _handle_request( + rpc_request("pass_attachment", {"name": "Shadow", "attachment": "color0"}), state + ) + assert resp["error"]["code"] == -32002 + + +# --------------------------------------------------------------------------- +# Gap 3: shader_used_by handler +# --------------------------------------------------------------------------- + + +def _make_shader_used_by_state(): + """State with shader cache for used-by tests.""" + pipe = MockPipeState() + pipe._shaders[ShaderStage.Vertex] = ResourceId(100) + pipe._shaders[ShaderStage.Pixel] = ResourceId(200) + refl_vs = ShaderReflection(resourceId=ResourceId(100), entryPoint="vs_main") + refl_ps = ShaderReflection(resourceId=ResourceId(200), entryPoint="ps_main") + pipe._reflections[ShaderStage.Vertex] = refl_vs + pipe._reflections[ShaderStage.Pixel] = refl_ps + + actions = [ + ActionDescription(eventId=10, flags=ActionFlags.Drawcall, numIndices=3, _name="Draw1"), + ActionDescription(eventId=20, flags=ActionFlags.Drawcall, numIndices=3, _name="Draw2"), + ] + ctrl = SimpleNamespace( + GetRootActions=lambda: actions, + GetResources=lambda: [ResourceDescription(resourceId=ResourceId(1), name="res0")], + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + GetPipelineState=lambda: pipe, + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: SimpleNamespace(chunks=[]), + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + DisassembleShader=lambda p, r, t: "; disasm", + GetDisassemblyTargets=lambda _with_pipeline=False: ["SPIR-V"], + ) + state = make_daemon_state(ctrl=ctrl, version=(1, 33), max_eid=20) + from rdc.vfs.tree_cache import build_vfs_skeleton + + state.vfs_tree = build_vfs_skeleton(actions, []) + return state + + +class TestShaderUsedByHandler: + def test_returns_eids(self): + state = _make_shader_used_by_state() + resp, _ = _handle_request(rpc_request("shader_used_by", {"id": 100}), state) + assert "error" not in resp + assert isinstance(resp["result"]["eids"], list) + assert set(resp["result"]["eids"]) == {10, 20} + + def test_all_eids_correct(self): + state = _make_shader_used_by_state() + resp, _ = _handle_request(rpc_request("shader_used_by", {"id": 200}), state) + assert "error" not in resp + assert set(resp["result"]["eids"]) == {10, 20} + + def test_not_found(self): + state = _make_shader_used_by_state() + # Build cache first + _handle_request(rpc_request("shader_used_by", {"id": 100}), state) + resp, _ = _handle_request(rpc_request("shader_used_by", {"id": 9999}), state) + assert resp["error"]["code"] == -32001 + + def test_no_adapter(self): + state = DaemonState(capture="test.rdc", current_eid=0, token="tok") + resp, _ = _handle_request(rpc_request("shader_used_by", {"id": 100}), state) + assert resp["error"]["code"] == -32002 + + def test_cache_auto_build(self): + state = _make_shader_used_by_state() + assert not state._shader_cache_built + resp, _ = _handle_request(rpc_request("shader_used_by", {"id": 100}), state) + assert "error" not in resp + assert state._shader_cache_built diff --git a/tests/unit/test_shader_preload.py b/tests/unit/test_shader_preload.py index 1ca90b4..6f1de87 100644 --- a/tests/unit/test_shader_preload.py +++ b/tests/unit/test_shader_preload.py @@ -195,6 +195,20 @@ def test_second_call_is_noop(self, tracked_state: tuple[DaemonState, list[int]]) assert s.disasm_cache[100] == "sentinel" +class TestShaderMetaContainsEids: + def test_eids_present(self, state: DaemonState) -> None: + _build_shader_cache(state) + assert "eids" in state.shader_meta[100] + assert "eids" in state.shader_meta[200] + + def test_tracked_eids_correct(self, tracked_state: tuple[DaemonState, list[int]]) -> None: + s, _ = tracked_state + _build_shader_cache(s) + assert set(s.shader_meta[100]["eids"]) == {10, 20} + assert set(s.shader_meta[200]["eids"]) == {10, 20, 30} + assert set(s.shader_meta[300]["eids"]) == {30} + + class TestBuildShaderCachePopulatesCaches: def test_disasm_and_meta(self, state: DaemonState) -> None: _build_shader_cache(state) diff --git a/tests/unit/test_vfs_daemon.py b/tests/unit/test_vfs_daemon.py index bee3f80..4435b2b 100644 --- a/tests/unit/test_vfs_daemon.py +++ b/tests/unit/test_vfs_daemon.py @@ -10,6 +10,7 @@ ActionFlags, APIEvent, BufferDescription, + Descriptor, MockPipeState, ResourceDescription, ResourceFormat, @@ -515,3 +516,123 @@ def test_long_draws_type_str(self): assert draw42["type"] == "DrawIndexed" dispatch300 = next(c for c in resp["result"]["children"] if c["name"] == "300") assert dispatch300["type"] == "Dispatch" + + +# --------------------------------------------------------------------------- +# Gap 1: /draws//pixel/ discoverability +# --------------------------------------------------------------------------- + + +class TestVfsPixelDir: + def test_draw_eid_includes_pixel(self): + resp, _ = _handle_request(rpc_request("vfs_ls", {"path": "/draws/42"}), _make_state()) + names = [c["name"] for c in resp["result"]["children"]] + assert "pixel" in names + + def test_vfs_ls_pixel_dir(self): + resp, _ = _handle_request(rpc_request("vfs_ls", {"path": "/draws/42/pixel"}), _make_state()) + result = resp["result"] + assert result["kind"] == "dir" + assert result["children"] == [] + + +# --------------------------------------------------------------------------- +# Gap 2: /passes//attachments/ children +# --------------------------------------------------------------------------- + + +def _make_state_with_targets(): + """Build state with pipe state that has color and depth targets.""" + actions = _build_actions() + sf = _build_sf() + resources = _build_resources() + pipe = MockPipeState( + output_targets=[ + Descriptor(resource=ResourceId(300)), + Descriptor(resource=ResourceId(400)), + ], + depth_target=Descriptor(resource=ResourceId(500)), + ) + ctrl = SimpleNamespace( + GetRootActions=lambda: actions, + GetResources=lambda: resources, + GetAPIProperties=lambda: SimpleNamespace(pipelineType="Vulkan"), + GetPipelineState=lambda: pipe, + SetFrameEvent=lambda eid, force: None, + GetStructuredFile=lambda: sf, + GetDebugMessages=lambda: [], + Shutdown=lambda: None, + ) + state = make_daemon_state( + ctrl=ctrl, + version=(1, 33), + max_eid=300, + structured_file=sf, + ) + state.vfs_tree = build_vfs_skeleton(actions, resources, sf=sf) + return state + + +class TestVfsPassAttachments: + def test_vfs_ls_pass_attachments_triggers_populate(self): + state = _make_state_with_targets() + resp, _ = _handle_request( + rpc_request("vfs_ls", {"path": "/passes/Shadow/attachments"}), state + ) + result = resp["result"] + assert result["kind"] == "dir" + names = [c["name"] for c in result["children"]] + assert "color0" in names + assert "depth" in names + + def test_vfs_tree_pass_attachments_populated(self): + state = _make_state_with_targets() + resp, _ = _handle_request( + rpc_request("vfs_tree", {"path": "/passes/Shadow", "depth": 2}), state + ) + tree = resp["result"]["tree"] + attach_node = next(c for c in tree["children"] if c["name"] == "attachments") + names = [c["name"] for c in attach_node["children"]] + assert "color0" in names + assert "depth" in names + + +# --------------------------------------------------------------------------- +# Gap 3: /shaders//used-by in VFS +# --------------------------------------------------------------------------- + + +class TestVfsShaderUsedBy: + def test_vfs_ls_shaders_id_includes_used_by(self): + pipe = _make_pipe_with_shaders() + refl_vs = ShaderReflection(resourceId=ResourceId(1), entryPoint="vs_main") + refl_ps = ShaderReflection(resourceId=ResourceId(2), entryPoint="ps_main") + pipe._reflections[ShaderStage.Vertex] = refl_vs + pipe._reflections[ShaderStage.Pixel] = refl_ps + state = _make_state(pipe_state=pipe) + ctrl = state.adapter.controller + ctrl.DisassembleShader = lambda p, r, t: "; disasm" + ctrl.GetDisassemblyTargets = lambda _with_pipeline=False: ["SPIR-V"] + # Build shader cache first + resp, _ = _handle_request(rpc_request("shaders_preload"), state) + assert "error" not in resp + # Now check ls on a shader + resp, _ = _handle_request(rpc_request("vfs_ls", {"path": "/shaders/1"}), state) + names = [c["name"] for c in resp["result"]["children"]] + assert "used-by" in names + + def test_vfs_ls_shaders_used_by_is_leaf(self): + pipe = _make_pipe_with_shaders() + refl_vs = ShaderReflection(resourceId=ResourceId(1), entryPoint="vs_main") + refl_ps = ShaderReflection(resourceId=ResourceId(2), entryPoint="ps_main") + pipe._reflections[ShaderStage.Vertex] = refl_vs + pipe._reflections[ShaderStage.Pixel] = refl_ps + state = _make_state(pipe_state=pipe) + ctrl = state.adapter.controller + ctrl.DisassembleShader = lambda p, r, t: "; disasm" + ctrl.GetDisassemblyTargets = lambda _with_pipeline=False: ["SPIR-V"] + resp, _ = _handle_request(rpc_request("shaders_preload"), state) + assert "error" not in resp + resp, _ = _handle_request(rpc_request("vfs_ls", {"path": "/shaders/1"}), state) + used_by = next(c for c in resp["result"]["children"] if c["name"] == "used-by") + assert used_by["kind"] == "leaf" diff --git a/tests/unit/test_vfs_router.py b/tests/unit/test_vfs_router.py index 419ed90..81a031b 100644 --- a/tests/unit/test_vfs_router.py +++ b/tests/unit/test_vfs_router.py @@ -495,3 +495,78 @@ def test_pixel_history_color_target_1() -> None: def test_pixel_history_non_integer_coord() -> None: assert resolve_path("/draws/120/pixel/abc/384") is None + + +# ── Gap 1: Pixel directory routes ──────────────────────────────────── + + +def test_draws_pixel_dir() -> None: + m = resolve_path("/draws/142/pixel") + assert m == PathMatch(kind="dir", handler=None, args={"eid": 142}) + + +def test_draws_pixel_x_dir() -> None: + m = resolve_path("/draws/142/pixel/100") + assert m == PathMatch(kind="dir", handler=None, args={"eid": 142, "x": 100}) + + +def test_draws_pixel_leaf_no_regression() -> None: + m = resolve_path("/draws/120/pixel/512/384") + assert m == PathMatch( + kind="leaf", handler="pixel_history", args={"eid": 120, "x": 512, "y": 384} + ) + + +def test_draws_pixel_color_target_no_regression() -> None: + m = resolve_path("/draws/120/pixel/512/384/color0") + assert m == PathMatch( + kind="leaf", + handler="pixel_history", + args={"eid": 120, "x": 512, "y": 384, "target": 0}, + ) + + +# ── Gap 2: Pass attachment routes ──────────────────────────────────── + + +def test_pass_attachment_color() -> None: + m = resolve_path("/passes/Shadow/attachments/color0") + assert m == PathMatch( + kind="leaf", handler="pass_attachment", args={"name": "Shadow", "attachment": "color0"} + ) + + +def test_pass_attachment_depth() -> None: + m = resolve_path("/passes/Shadow/attachments/depth") + assert m == PathMatch( + kind="leaf", handler="pass_attachment", args={"name": "Shadow", "attachment": "depth"} + ) + + +def test_pass_attachment_color1() -> None: + m = resolve_path("/passes/GBuffer/attachments/color1") + assert m == PathMatch( + kind="leaf", handler="pass_attachment", args={"name": "GBuffer", "attachment": "color1"} + ) + + +def test_pass_attachments_dir_no_regression() -> None: + m = resolve_path("/passes/Shadow/attachments") + assert m == PathMatch(kind="dir", handler=None, args={"name": "Shadow"}) + + +# ── Gap 3: Shader used-by routes ───────────────────────────────────── + + +def test_shaders_used_by() -> None: + m = resolve_path("/shaders/100/used-by") + assert m == PathMatch(kind="leaf", handler="shader_used_by", args={"id": 100}) + + +def test_shaders_id_dir() -> None: + m = resolve_path("/shaders/100") + assert m == PathMatch(kind="dir", handler=None, args={"id": 100}) + + +def test_shaders_id_non_numeric_returns_none() -> None: + assert resolve_path("/shaders/abc/used-by") is None diff --git a/tests/unit/test_vfs_tree_cache.py b/tests/unit/test_vfs_tree_cache.py index 1d3e90a..ab92cae 100644 --- a/tests/unit/test_vfs_tree_cache.py +++ b/tests/unit/test_vfs_tree_cache.py @@ -17,7 +17,13 @@ ) from rdc.vfs.formatter import render_ls, render_tree_root -from rdc.vfs.tree_cache import VfsTree, build_vfs_skeleton, populate_draw_subtree +from rdc.vfs.tree_cache import ( + VfsTree, + build_vfs_skeleton, + populate_draw_subtree, + populate_pass_attachments, + populate_shaders_subtree, +) # --------------------------------------------------------------------------- # Fixtures @@ -152,6 +158,7 @@ def test_draw_node_structure(self, skeleton: VfsTree) -> None: "vbuffer", "ibuffer", "descriptors", + "pixel", ] assert node.children == expected @@ -627,6 +634,83 @@ def test_eviction_cleans_binding_leaf_nodes(self) -> None: assert "/draws/20/shader/ps" in skeleton.static +# --------------------------------------------------------------------------- +# Gap 1: /draws//pixel/ discoverability +# --------------------------------------------------------------------------- + + +class TestPixelDir: + def test_pixel_in_draw_children(self, skeleton: VfsTree) -> None: + assert "pixel" in skeleton.static["/draws/10"].children + + def test_pixel_dir_exists(self, skeleton: VfsTree) -> None: + assert skeleton.static["/draws/10/pixel"].kind == "dir" + assert skeleton.static["/draws/10/pixel"].children == [] + + +# --------------------------------------------------------------------------- +# Gap 2: /passes//attachments/ children +# --------------------------------------------------------------------------- + + +class TestPopulatePassAttachments: + @pytest.fixture + def skel(self) -> VfsTree: + return build_vfs_skeleton(_make_actions(), _make_resources()) + + def test_color_and_depth(self, skel: VfsTree) -> None: + pipe = _make_pipe_with_targets() + populate_pass_attachments(skel, "ShadowPass", pipe) + assert skel.static["/passes/ShadowPass/attachments"].children == [ + "color0", + "color1", + "depth", + ] + + def test_color_only(self, skel: VfsTree) -> None: + pipe = MockPipeState( + output_targets=[Descriptor(resource=ResourceId(300))], + ) + populate_pass_attachments(skel, "ShadowPass", pipe) + assert skel.static["/passes/ShadowPass/attachments"].children == ["color0"] + + def test_no_targets(self, skel: VfsTree) -> None: + pipe = MockPipeState() + populate_pass_attachments(skel, "ShadowPass", pipe) + assert skel.static["/passes/ShadowPass/attachments"].children == [] + + def test_idempotent(self, skel: VfsTree) -> None: + pipe = _make_pipe_with_targets() + populate_pass_attachments(skel, "ShadowPass", pipe) + populate_pass_attachments(skel, "ShadowPass", pipe) + assert len(skel.static["/passes/ShadowPass/attachments"].children) == 3 + + def test_leaf_nodes(self, skel: VfsTree) -> None: + pipe = _make_pipe_with_targets() + populate_pass_attachments(skel, "ShadowPass", pipe) + assert skel.static["/passes/ShadowPass/attachments/color0"].kind == "leaf" + assert skel.static["/passes/ShadowPass/attachments/depth"].kind == "leaf" + + +# --------------------------------------------------------------------------- +# Gap 3: /shaders//used-by +# --------------------------------------------------------------------------- + + +class TestShaderUsedBy: + def test_shader_children_include_used_by(self) -> None: + skel = build_vfs_skeleton(_make_actions(), _make_resources()) + meta = {100: {"stages": ["vs"], "eids": [10]}} + populate_shaders_subtree(skel, meta) + assert "used-by" in skel.static["/shaders/100"].children + + def test_shader_used_by_leaf_exists(self) -> None: + skel = build_vfs_skeleton(_make_actions(), _make_resources()) + meta = {100: {"stages": ["vs"], "eids": [10]}} + populate_shaders_subtree(skel, meta) + assert skel.static["/shaders/100/used-by"].kind == "leaf" + + # --------------------------------------------------------------------------- # Formatter tests # --------------------------------------------------------------------------- From 3b8549133fdd9e1c0c19b9c03d751c808d952dc4 Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 28 Feb 2026 23:37:31 -0800 Subject: [PATCH 2/4] fix(handlers): correct attachment index mismatch in pass_attachment handler The handler compacted targets (filtering resource==0) before indexing, but populate_pass_attachments uses enumerate() to preserve original indices. This caused wrong resource lookups when empty slots exist. Use all_targets with original index and validate non-zero resource. Also add e2e tests 5.28-5.31 for new VFS routes: draw pixel listing, pass attachments, shader used-by, and pass attachment detail. --- src/rdc/handlers/query.py | 6 +++--- tests/e2e/test_vfs.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/rdc/handlers/query.py b/src/rdc/handlers/query.py index fd5b30b..74355ef 100644 --- a/src/rdc/handlers/query.py +++ b/src/rdc/handlers/query.py @@ -621,12 +621,12 @@ def _handle_pass_attachment( idx = int(attachment[5:]) except ValueError: return _error_response(request_id, -32602, f"invalid attachment: {attachment}"), True - targets = [t for t in pipe.GetOutputTargets() if int(t.resource) != 0] - if idx < 0 or idx >= len(targets): + all_targets = pipe.GetOutputTargets() + if idx < 0 or idx >= len(all_targets) or int(all_targets[idx].resource) == 0: return _error_response(request_id, -32001, f"color target {idx} not found"), True return _result_response( request_id, - {"pass": name, "attachment": attachment, "resource_id": int(targets[idx].resource)}, + {"pass": name, "attachment": attachment, "resource_id": int(all_targets[idx].resource)}, ), True return _error_response(request_id, -32001, f"unknown attachment: {attachment}"), True diff --git a/tests/e2e/test_vfs.py b/tests/e2e/test_vfs.py index e1dc607..893a2b7 100644 --- a/tests/e2e/test_vfs.py +++ b/tests/e2e/test_vfs.py @@ -259,3 +259,39 @@ def test_bad_flag_exit_code(self, vkcube_session: str) -> None: exit_code=2, ) assert "depth" in out.lower() + + +class TestLsDrawPixel: + """5.28: rdc ls /draws/11/ includes pixel directory.""" + + def test_pixel_in_draw_listing(self, vkcube_session: str) -> None: + out = rdc_ok("ls", "/draws/11", session=vkcube_session) + assert "pixel" in out + + +class TestLsPassAttachments: + """5.29: rdc ls /passes//attachments/ shows color/depth entries.""" + + def test_attachments_listed(self, vkcube_session: str) -> None: + passes_out = rdc_ok("ls", "/passes", session=vkcube_session) + pass_name = passes_out.strip().splitlines()[0].strip() + out = rdc_ok("ls", f"/passes/{pass_name}/attachments", session=vkcube_session) + assert "color0" in out + + +class TestCatShaderUsedBy: + """5.30: rdc cat /shaders/111/used-by shows EID list.""" + + def test_shader_used_by(self, vkcube_session: str) -> None: + out = rdc_ok("cat", "/shaders/111/used-by", session=vkcube_session) + assert "11" in out + + +class TestCatPassAttachment: + """5.31: rdc cat /passes//attachments/color0 shows attachment info.""" + + def test_attachment_info(self, vkcube_session: str) -> None: + passes_out = rdc_ok("ls", "/passes", session=vkcube_session) + pass_name = passes_out.strip().splitlines()[0].strip() + out = rdc_ok("cat", f"/passes/{pass_name}/attachments/color0", session=vkcube_session) + assert "resource_id" in out From b8d04492a84d746e8a6675babe6f15d68facd729 Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sat, 28 Feb 2026 23:42:56 -0800 Subject: [PATCH 3/4] test(e2e): comprehensive coverage for VFS route gaps Add 6 e2e tests (5.32-5.37) covering pixel dir, pass attachment depth/invalid, shader used-by listing for both shaders 111 and 112. Update blackbox test catalog with all 10 new VFS route gap tests. --- tests/e2e/blackbox_test_catalog.md | 291 +++++++++++++++++++++++++++++ tests/e2e/test_vfs.py | 58 ++++++ 2 files changed, 349 insertions(+) create mode 100644 tests/e2e/blackbox_test_catalog.md diff --git a/tests/e2e/blackbox_test_catalog.md b/tests/e2e/blackbox_test_catalog.md new file mode 100644 index 0000000..67082d5 --- /dev/null +++ b/tests/e2e/blackbox_test_catalog.md @@ -0,0 +1,291 @@ +# rdc-cli Black-Box E2E Test Catalog + +> Generated from manual testing session 2026-02-28. +> Fixture: `tests/fixtures/vkcube.rdc` unless noted. +> All tests verified on Linux x86_64, RenderDoc 1.41, Python 3.14. + +## Notation + +- `[P]` = PASS, `[F]` = FAIL (bug), `[N]` = NOTE (behavior worth documenting) +- `exit:N` = expected exit code + +--- + +## 1. Pre-Session Commands (no daemon) + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 1.1 | `rdc --version` | Print version string | [P] `0.3.7.dev1` | +| 1.2 | `rdc --help` | Print all commands | [P] 66+ commands listed | +| 1.3 | `rdc doctor` | Check renderdoc, python, platform | [P] all green | +| 1.4 | `rdc status` (no session) | `error: no active session` exit:1 | [P] | +| 1.5 | `rdc close` (no session) | `error: no active session` exit:1 | [P] | +| 1.6 | `rdc completion bash` | Valid bash completion script | [P] | +| 1.7 | `rdc completion zsh` | Valid zsh completion script | [P] | +| 1.8 | `rdc open nonexistent.rdc` | `error: file not found` exit:1 | [P] | + +## 2. Session Lifecycle + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 2.1 | `rdc open tests/fixtures/vkcube.rdc` | Open capture, print session path | [P] | +| 2.2 | `rdc status` | session, capture, eid, daemon | [P] | +| 2.3 | `rdc goto 1` | `current_eid set to 1` | [P] | +| 2.4 | `rdc goto 5` | `current_eid set to 5` (max valid) | [P] | +| 2.5 | `rdc goto 999` | `error: eid 999 out of range` exit:1 | [P] | +| 2.6 | `rdc goto -- -1` | `error: eid must be >= 0` exit:1 | [P] | +| 2.7 | `rdc goto -1` | Click option error exit:2 | [P] | +| 2.8 | `rdc --session test2 open hello_triangle.rdc` | Separate session created | [P] | +| 2.9 | `rdc --session test2 status` | Shows test2 session independently | [P] | +| 2.10 | `rdc --session test2 close` | Closes only test2 | [P] | +| 2.11 | `rdc open --listen :0` | Random port, prints connect info + token | [P] | +| 2.12 | `rdc close` | Session closed | [P] | + +## 3. Query Commands + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 3.1 | `rdc info` | Capture metadata (API, events, draws) | [P] | +| 3.2 | `rdc stats` | Per-pass breakdown, top draws | [P] | +| 3.3 | `rdc log` | Validation messages with LEVEL/EID/MESSAGE | [P] | +| 3.4 | `rdc events` | EID/TYPE/NAME TSV listing | [P] | +| 3.5 | `rdc event 11` | Single event detail with parameters | [P] | +| 3.6 | `rdc event 999` | `error: eid out of range` exit:1 | [P] | +| 3.7 | `rdc draws` | Draw calls with triangles/pass/marker | [P] | +| 3.8 | `rdc draw 11` | Draw detail (type, triangles, instances) | [P] | +| 3.9 | `rdc draw 5` | Shows non-draw event as draw (0 triangles) | [N] exit:0 | +| 3.10 | `rdc count events` | `6` | [P] | +| 3.11 | `rdc count draws` | `1` | [P] | +| 3.12 | `rdc count resources` | `46` | [P] | +| 3.13 | `rdc count shaders` | `2` | [P] | +| 3.14 | `rdc count badtarget` | Click choice error exit:2 | [P] | +| 3.15 | `rdc search "main"` | Matches in shader disassembly | [P] | +| 3.16 | `rdc search "gl_Position"` | Matches in VS disassembly | [P] | +| 3.17 | `rdc search "nonexistent_xyz"` | Empty output exit:0 | [P] | +| 3.18 | `rdc shader-map` | EID-to-shader TSV (vs/hs/ds/gs/ps/cs) | [P] | +| 3.19 | `rdc pipeline 11` | Pipeline summary (topology, pipe IDs) | [P] | +| 3.20 | `rdc pipeline 11 topology` | KEY/VALUE pair | [P] | +| 3.21 | `rdc pipeline 11 viewport` | Viewport state | [P] | +| 3.22 | `rdc pipeline 11 blend` | Blend state with JSON-like blends array | [P] | +| 3.23 | `rdc pipeline 11 badslice` | `error: invalid section` exit:1 | [P] | +| 3.24 | `rdc bindings 11` | Descriptor bindings per stage | [P] | +| 3.25 | `rdc shader vs` | Stage-only form (uses current EID) | [P] | +| 3.26 | `rdc shader 11 vs` | EID+stage form | [P] | +| 3.27 | `rdc shader xx` | `error: not valid EID or stage` exit:2 | [P] | +| 3.28 | `rdc shader vs --reflect --json` | JSON output (no reflect data embedded) | [N] see note | +| 3.29 | `rdc shaders` | Shader list with STAGES/USES | [P] | +| 3.30 | `rdc shaders --stage vs` | Filtered by stage | [P] (tested on dynamic_rendering) | +| 3.31 | `rdc resources` | Full resource list | [P] | +| 3.32 | `rdc resource 97` | Resource detail | [P] | +| 3.33 | `rdc resource 99999` | `error: resource not found` exit:1 | [P] | +| 3.34 | `rdc passes` | Pass list with draw counts | [P] | +| 3.35 | `rdc pass 0` | Pass detail (begin/end eid, targets) | [P] | +| 3.36 | `rdc passes --deps` | Dependency DAG TSV | [P] | +| 3.37 | `rdc passes --dot` (no --deps) | `error: --dot requires --deps` exit:2 | [P] | +| 3.38 | `rdc passes --deps --dot` | DOT graph output | [P] | +| 3.39 | `rdc usage 97` | Resource usage across events | [P] | + +## 4. Output Format Flags + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 4.1 | `rdc events --json` | Valid JSON array | [P] | +| 4.2 | `rdc events --jsonl` | One JSON object per line | [P] | +| 4.3 | `rdc events --no-header` | TSV without header row | [P] | +| 4.4 | `rdc events -q` | Primary key column only (EIDs) | [P] | +| 4.5 | `rdc draws --json` | Valid JSON array | [P] | +| 4.6 | `rdc resources --json` | Valid JSON, 46 items | [P] | +| 4.7 | `rdc resources -q` | Resource IDs only | [P] | +| 4.8 | `rdc resource 97 --json` | JSON with lowercase keys | [P] | +| 4.9 | `rdc pixel 300 300 11 --json` | Full pixel history JSON | [P] | +| 4.10 | `rdc cat /info --json` | VFS leaf in JSON | [P] | + +## 5. VFS Navigation + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 5.1 | `rdc ls /` | Root entries (capabilities, info, stats, ...) | [P] | +| 5.2 | `rdc ls -l /` | Long format with TYPE column | [P] | +| 5.3 | `rdc tree / --depth 1` | Tree with dirs/leaves/aliases | [P] | +| 5.4 | `rdc tree /draws --depth 2` | Draw subtree (pipeline, shader, targets) | [P] | +| 5.5 | `rdc cat /info` | Same as `rdc info` | [P] | +| 5.6 | `rdc cat /stats` | Same as `rdc stats` | [P] | +| 5.7 | `rdc cat /log` | Validation messages | [P] | +| 5.8 | `rdc cat /capabilities` | Capture capabilities | [P] | +| 5.9 | `rdc cat /events/11` | Event detail via VFS | [P] | +| 5.10 | `rdc cat /draws/11/pipeline/topology` | Pipeline subsection via VFS | [P] | +| 5.11 | `rdc cat /draws/11/shader/vs/disasm` | Shader disasm via VFS | [P] | +| 5.12 | `rdc cat /draws/11/postvs` | Post-VS data via VFS | [P] | +| 5.13 | `rdc cat /draws/11/descriptors` | Descriptor bindings via VFS | [P] | +| 5.14 | `rdc cat /draws/11/descriptors --json` | JSON with sampler details | [P] but SWIG leak | +| 5.15 | `rdc cat /resources/97/info` | Resource info via VFS | [P] | +| 5.16 | `rdc cat /textures/97/info` | Texture metadata via VFS | [P] | +| 5.17 | `rdc cat /shaders/111/info` | Shader info via VFS | [P] | +| 5.18 | `rdc ls /textures` | Texture IDs | [P] | +| 5.19 | `rdc ls /shaders` | Shader IDs | [P] | +| 5.20 | `rdc ls /passes` | Pass names (may contain spaces/#) | [P] | +| 5.21 | `rdc cat "/passes/Colour Pass #1 .../info"` | Pass info with special chars | [P] | +| 5.22 | `rdc cat /nonexistent` | `error: not found` exit:1 | [P] | +| 5.23 | `rdc ls /nonexistent` | `error: not found` exit:1 | [P] | +| 5.24 | `rdc cat /passes/.../draws` (dir) | `error: Is a directory` exit:1 | [P] | +| 5.25 | `rdc cat /textures/97/image.png -o /tmp/f.png` | Binary PNG via VFS | [P] | +| 5.26 | `rdc cat /draws/11/targets/color0.png -o /tmp/f.png` | RT PNG via VFS | [P] | +| 5.27 | `rdc tree / --max-depth 1` | `error: Did you mean --depth?` exit:2 | [P] | +| 5.28 | `rdc ls /draws/11` | Shows `pixel` directory | [P] | +| 5.29 | `rdc ls /passes//attachments` | Lists color0 entry | [P] | +| 5.30 | `rdc cat /shaders/111/used-by` | Shows EID 11 | [P] | +| 5.31 | `rdc cat /passes//attachments/color0` | Shows resource_id | [P] | +| 5.32 | `rdc tree /draws --depth 2` | Shows pixel entry | [P] | +| 5.33 | `rdc ls /passes//attachments` | Includes depth target | [P] | +| 5.34 | `rdc cat /passes//attachments/depth` | Depth resource info | [P] | +| 5.35 | `rdc cat /passes//attachments/color99` | Error exit:1 | [P] | +| 5.36 | `rdc ls /shaders/111` | Lists used-by entry | [P] | +| 5.37 | `rdc cat /shaders/112/used-by` | Shows EID for shader 112 | [P] | + +## 6. Export Commands + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 6.1 | `rdc texture 97 -o /tmp/tex.png` | 16KB PNG file | [P] | +| 6.2 | `rdc texture 99999 -o /tmp/bad.png` | `error: not found` exit:1 | [P] | +| 6.3 | `rdc rt 11 -o /tmp/rt.png` | 42KB render target PNG | [P] | +| 6.4 | `rdc rt 11 --overlay wireframe -o /tmp/w.png` | Wireframe overlay PNG | [P] | +| 6.5 | `rdc buffer 102 -o /tmp/buf.bin` | 1.2KB binary buffer | [P] | +| 6.6 | `rdc mesh 11 -o /tmp/mesh.obj` | OBJ file (36 verts, 12 faces) | [P] | +| 6.7 | `rdc thumbnail -o /tmp/thumb.png` | Capture thumbnail PNG | [P] | +| 6.8 | `rdc snapshot 11 -o /tmp/snap` | 5 files (pipeline, shaders, targets) | [P] | +| 6.9 | `rdc gpus` | GPU list (vendor, driver) | [P] | +| 6.10 | `rdc sections` | Section list with name/type/size | [P] | +| 6.11 | `rdc section "renderdoc/internal/framecapture"` | Binary section data | [P] | +| 6.12 | `rdc section "0"` | `error: section not found` exit:1 | [P] | +| 6.13 | `rdc tex-stats 97` | Channel min/max (RGBA) | [P] | +| 6.14 | `rdc tex-stats` (no arg) | `error: missing RESOURCE_ID` exit:2 | [P] | + +## 7. Debug Commands + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 7.1 | `rdc debug pixel 11 300 300` | PS debug summary (steps, inputs, outputs) | [P] | +| 7.2 | `rdc debug pixel 11 300 300 --trace` | Step-by-step trace table | [P] | +| 7.3 | `rdc debug vertex 11 0` | VS debug summary | [P] | +| 7.4 | `rdc debug pixel 11 99999 99999` | `error: no fragment at pixel` exit:1 | [P] | +| 7.5 | `rdc debug pixel 11 -- -5 -5` | SWIG uint32_t error (see bug B-NEW-1) | [F] | +| 7.6 | `rdc pixel 300 300 11` | Pixel history (EID/FRAG/DEPTH/PASSED) | [P] | +| 7.7 | `rdc pixel 300 300 11 --json` | Full pixel history JSON with modifications | [P] | + +## 8. Assert/CI Commands + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 8.1 | `rdc assert-pixel 11 300 300 --expect "0.33 0.33 0.33 0.52" --tolerance 0.02` | `pass:` exit:0 | [P] | +| 8.2 | `rdc assert-pixel 11 300 300 --expect "1.0 0.0 0.0 1.0"` | `fail:` exit:1 | [P] | +| 8.3 | `rdc assert-clean` | `fail: 1 message(s)` exit:1 (vkcube has HIGH validation) | [P] | +| 8.4 | `rdc assert-count events --expect 6` | `pass:` exit:0 | [P] | +| 8.5 | `rdc assert-count events --expect 10` | `fail:` exit:1 | [P] | +| 8.6 | `rdc assert-count draws --expect 1` | `pass:` exit:0 | [P] | +| 8.7 | `rdc assert-count resources --expect 10 --op gt` | `pass: 46 > 10` exit:0 | [P] | +| 8.8 | `rdc assert-count triangles --expect 12` | `pass:` exit:0 | [P] | +| 8.9 | `rdc assert-count shaders --expect 2` | `pass:` exit:0 | [P] | +| 8.10 | `rdc assert-state 11 topology --expect TriangleList` | `pass:` exit:0 | [P] | +| 8.11 | `rdc assert-state 11 topology --expect PointList` | `fail:` exit:1 | [P] | +| 8.12 | `rdc assert-image /tmp/rt0.png /tmp/rt0.png` | `match` exit:0 | [P] | +| 8.13 | `rdc assert-image /tmp/rt0.png /tmp/tex97.png` | `error: size mismatch` exit:2 | [P] | + +## 9. Diff Command + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 9.1 | `rdc diff A A --stats` | All passes `=` identical | [P] | +| 9.2 | `rdc diff A A --framebuffer` | `identical` exit:0 | [P] | +| 9.3 | `rdc diff A B --stats` (different size) | Stats comparison | [P] | +| 9.4 | `rdc diff A B --framebuffer` (different size) | `error: size mismatch` exit:2 | [P] | +| 9.5 | `rdc diff A B --draws` | Draw comparison with confidence | [P] | +| 9.6 | `rdc diff A B --resources` | Resource comparison | [P] | +| 9.7 | `rdc diff A B --shortstat` | `identical` or summary | [P] | + +## 10. Script Command + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 10.1 | `rdc script valid.py` | Script output + elapsed time | [P] | +| 10.2 | `rdc script error.py` | `error: script error: ...` exit:1 | [P] | + +## 11. Advanced Features + +| # | Command | Expected | Status | +|---|---------|----------|--------| +| 11.1 | `rdc shader-encodings` | List (GLSL, SPIRV) | [P] | +| 11.2 | `rdc open --listen :0` | Random port + token | [P] | +| 11.3 | Named sessions (`--session test2`) | Independent session isolation | [P] | +| 11.4 | Pipe: `rdc events -q \| wc -l` | Correct line count | [P] | +| 11.5 | Pipe: `rdc resources -q \| xargs rdc resource` | Batch processing works | [P] | + +## 12. Multi-Fixture Validation + +| # | Fixture | Test | Status | +|---|---------|------|--------| +| 12.1 | `vkcube.rdc` | All standard tests | [P] | +| 12.2 | `hello_triangle.rdc` | Open/status/close | [P] | +| 12.3 | `vkcube_validation.rdc` | info, diff vs vkcube | [P] | +| 12.4 | `dynamic_rendering.rdc` | Multi-pass (2 passes, 4 draws) | [P] | +| 12.5 | `oit_depth_peeling.rdc` | Complex DAG (9 passes, 12 draws, 36 deps) | [P] | + +--- + +## Bugs Found + +### B-NEW-1: SWIG error leaked for negative pixel coords +- **Command**: `rdc debug pixel 11 -- -5 -5` +- **Got**: `error: DebugPixel failed: in method 'ReplayController_DebugPixel', argument 2 of type 'uint32_t'` +- **Expected**: Friendly error like `error: pixel coordinates must be >= 0` +- **Severity**: P3 (UX, no functional impact) + +### B-NEW-2: TextureFilter SWIG object leaked in JSON +- **Command**: `rdc cat /draws/11/descriptors --json` +- **Got**: `"filter": ""` +- **Expected**: Human-readable filter description or structured fields +- **Severity**: P2 (data quality, affects machine parsing) + +### B-NEW-3: `--reflect` flag produces no additional data +- **Command**: `rdc shader vs --reflect --json` +- **Got**: Same JSON as without `--reflect` +- **Expected**: Additional reflection data (inputs, outputs, cbuffers) +- **Severity**: P2 (missing feature or silent no-op) + +## Notes + +### N1: TSV vs JSON key casing +- TSV headers: `ID`, `TYPE`, `NAME` (uppercase) +- JSON keys: `id`, `type`, `name` (lowercase) +- Agent code must handle both formats + +### N2: Arg order inconsistency +- `rdc pixel X Y [EID]` — coords first +- `rdc debug pixel EID X Y` — EID first +- Both are valid Click conventions but may confuse agent automation + +### N3: `draw` command on non-draw EID +- `rdc draw 5` returns exit:0 with Triangles=0 for a non-draw event +- Could arguably return exit:1 or a warning + +--- + +## Coverage Summary + +| Category | Tests | Pass | Fail | Note | +|----------|-------|------|------|------| +| Pre-session | 8 | 8 | 0 | 0 | +| Session lifecycle | 12 | 12 | 0 | 0 | +| Query commands | 39 | 39 | 0 | 1 | +| Output formats | 10 | 10 | 0 | 0 | +| VFS navigation | 37 | 37 | 0 | 0 | +| Export commands | 14 | 14 | 0 | 0 | +| Debug commands | 7 | 6 | 1 | 0 | +| Assert/CI | 13 | 13 | 0 | 0 | +| Diff | 7 | 7 | 0 | 0 | +| Script | 2 | 2 | 0 | 0 | +| Advanced | 5 | 5 | 0 | 0 | +| Multi-fixture | 5 | 5 | 0 | 0 | +| **TOTAL** | **159** | **158** | **1** | **1** | + +Pass rate: **99.4%** (158/159) diff --git a/tests/e2e/test_vfs.py b/tests/e2e/test_vfs.py index 893a2b7..d6cdfe1 100644 --- a/tests/e2e/test_vfs.py +++ b/tests/e2e/test_vfs.py @@ -295,3 +295,61 @@ def test_attachment_info(self, vkcube_session: str) -> None: pass_name = passes_out.strip().splitlines()[0].strip() out = rdc_ok("cat", f"/passes/{pass_name}/attachments/color0", session=vkcube_session) assert "resource_id" in out + + +class TestTreeDrawsPixel: + """5.32: rdc tree /draws --depth 2 shows pixel entry.""" + + def test_pixel_in_draw_tree(self, vkcube_session: str) -> None: + out = rdc_ok("tree", "/draws", "--depth", "2", session=vkcube_session) + assert "pixel" in out + + +class TestLsPassAttachmentsDepth: + """5.33: rdc ls /passes//attachments/ includes depth target.""" + + def test_depth_in_attachments(self, vkcube_session: str) -> None: + passes_out = rdc_ok("ls", "/passes", session=vkcube_session) + pass_name = passes_out.strip().splitlines()[0].strip() + out = rdc_ok("ls", f"/passes/{pass_name}/attachments", session=vkcube_session) + assert "depth" in out + + +class TestCatPassAttachmentDepth: + """5.34: rdc cat /passes//attachments/depth shows depth resource.""" + + def test_depth_attachment(self, vkcube_session: str) -> None: + passes_out = rdc_ok("ls", "/passes", session=vkcube_session) + pass_name = passes_out.strip().splitlines()[0].strip() + out = rdc_ok("cat", f"/passes/{pass_name}/attachments/depth", session=vkcube_session) + assert "resource_id" in out + + +class TestCatPassAttachmentInvalid: + """5.35: rdc cat /passes//attachments/color99 returns error.""" + + def test_invalid_attachment(self, vkcube_session: str) -> None: + passes_out = rdc_ok("ls", "/passes", session=vkcube_session) + pass_name = passes_out.strip().splitlines()[0].strip() + rdc_fail( + "cat", + f"/passes/{pass_name}/attachments/color99", + session=vkcube_session, + exit_code=1, + ) + + +class TestLsShaderUsedBy: + """5.36: rdc ls /shaders/111 lists used-by entry.""" + + def test_used_by_listed(self, vkcube_session: str) -> None: + out = rdc_ok("ls", "/shaders/111", session=vkcube_session) + assert "used-by" in out + + +class TestCatShaderUsedByOther: + """5.37: rdc cat /shaders/112/used-by shows EID for other shader.""" + + def test_other_shader_used_by(self, vkcube_session: str) -> None: + out = rdc_ok("cat", "/shaders/112/used-by", session=vkcube_session) + assert "11" in out From 1f159138635e6a2c749cd64b86745ee00f6891cb Mon Sep 17 00:00:00 2001 From: BANANASJIM Date: Sun, 1 Mar 2026 00:13:58 -0800 Subject: [PATCH 4/4] fix(e2e): resolve conftest import collision between unit and e2e tests --- pyproject.toml | 2 +- tests/e2e/conftest.py | 61 ++++------------------------------- tests/e2e/e2e_helpers.py | 58 +++++++++++++++++++++++++++++++++ tests/e2e/test_advanced.py | 2 +- tests/e2e/test_assert.py | 2 +- tests/e2e/test_debug.py | 2 +- tests/e2e/test_diff.py | 2 +- tests/e2e/test_export.py | 2 +- tests/e2e/test_formats.py | 2 +- tests/e2e/test_presession.py | 2 +- tests/e2e/test_query.py | 2 +- tests/e2e/test_session.py | 2 +- tests/e2e/test_shader_edit.py | 2 +- tests/e2e/test_vfs.py | 2 +- 14 files changed, 76 insertions(+), 67 deletions(-) create mode 100644 tests/e2e/e2e_helpers.py diff --git a/pyproject.toml b/pyproject.toml index b4514f7..50e22df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ where = ["src"] [tool.pytest.ini_options] addopts = "-q" testpaths = ["tests"] -pythonpath = ["src", "tests/mocks", "scripts"] +pythonpath = ["src", "tests/mocks", "tests/e2e", "scripts"] markers = [ "gpu: requires real renderdoc module and GPU", "vulkan_samples: requires vulkan-samples binary for live capture testing", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b6e8861..40936a9 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,67 +6,18 @@ from __future__ import annotations -import json import os -import subprocess import uuid from collections.abc import Generator from pathlib import Path -from typing import Any import pytest - -FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" -VKCUBE = FIXTURES_DIR / "vkcube.rdc" -HELLO_TRIANGLE = FIXTURES_DIR / "hello_triangle.rdc" -DYNAMIC_RENDERING = FIXTURES_DIR / "dynamic_rendering.rdc" -OIT_DEPTH_PEELING = FIXTURES_DIR / "oit_depth_peeling.rdc" -VKCUBE_VALIDATION = FIXTURES_DIR / "vkcube_validation.rdc" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def rdc( - *args: str, - session: str = "e2e_default", - timeout: int = 30, -) -> subprocess.CompletedProcess[str]: - """Run ``uv run rdc`` as a subprocess and return the result.""" - cmd = ["uv", "run", "rdc", "--session", session, *args] - return subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - ) - - -def rdc_ok(*args: str, session: str = "e2e_default", timeout: int = 30) -> str: - """Run rdc, assert exit 0, return stdout.""" - r = rdc(*args, session=session, timeout=timeout) - assert r.returncode == 0, f"rdc {' '.join(args)} failed:\n{r.stderr}" - return r.stdout - - -def rdc_json(*args: str, session: str = "e2e_default", timeout: int = 30) -> Any: - """Run rdc with --json, assert exit 0, return parsed JSON.""" - out = rdc_ok(*args, "--json", session=session, timeout=timeout) - return json.loads(out) - - -def rdc_fail( - *args: str, session: str = "e2e_default", exit_code: int = 1, timeout: int = 30 -) -> str: - """Run rdc, assert expected non-zero exit, return combined output.""" - r = rdc(*args, session=session, timeout=timeout) - assert r.returncode == exit_code, ( - f"Expected exit {exit_code}, got {r.returncode}\nstdout: {r.stdout}\nstderr: {r.stderr}" - ) - return r.stdout + r.stderr - +from e2e_helpers import ( + DYNAMIC_RENDERING, + OIT_DEPTH_PEELING, + VKCUBE, + rdc, +) # --------------------------------------------------------------------------- # Session fixtures diff --git a/tests/e2e/e2e_helpers.py b/tests/e2e/e2e_helpers.py new file mode 100644 index 0000000..95eb433 --- /dev/null +++ b/tests/e2e/e2e_helpers.py @@ -0,0 +1,58 @@ +"""Shared helpers and constants for e2e black-box tests. + +Extracted from conftest.py so that test modules can import them +without colliding with tests/conftest.py on sys.path. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +VKCUBE = FIXTURES_DIR / "vkcube.rdc" +HELLO_TRIANGLE = FIXTURES_DIR / "hello_triangle.rdc" +DYNAMIC_RENDERING = FIXTURES_DIR / "dynamic_rendering.rdc" +OIT_DEPTH_PEELING = FIXTURES_DIR / "oit_depth_peeling.rdc" +VKCUBE_VALIDATION = FIXTURES_DIR / "vkcube_validation.rdc" + + +def rdc( + *args: str, + session: str = "e2e_default", + timeout: int = 30, +) -> subprocess.CompletedProcess[str]: + """Run ``uv run rdc`` as a subprocess and return the result.""" + cmd = ["uv", "run", "rdc", "--session", session, *args] + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def rdc_ok(*args: str, session: str = "e2e_default", timeout: int = 30) -> str: + """Run rdc, assert exit 0, return stdout.""" + r = rdc(*args, session=session, timeout=timeout) + assert r.returncode == 0, f"rdc {' '.join(args)} failed:\n{r.stderr}" + return r.stdout + + +def rdc_json(*args: str, session: str = "e2e_default", timeout: int = 30) -> Any: + """Run rdc with --json, assert exit 0, return parsed JSON.""" + out = rdc_ok(*args, "--json", session=session, timeout=timeout) + return json.loads(out) + + +def rdc_fail( + *args: str, session: str = "e2e_default", exit_code: int = 1, timeout: int = 30 +) -> str: + """Run rdc, assert expected non-zero exit, return combined output.""" + r = rdc(*args, session=session, timeout=timeout) + assert r.returncode == exit_code, ( + f"Expected exit {exit_code}, got {r.returncode}\nstdout: {r.stdout}\nstderr: {r.stderr}" + ) + return r.stdout + r.stderr diff --git a/tests/e2e/test_advanced.py b/tests/e2e/test_advanced.py index d14e158..987bea8 100644 --- a/tests/e2e/test_advanced.py +++ b/tests/e2e/test_advanced.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest -from conftest import HELLO_TRIANGLE, rdc, rdc_fail, rdc_ok +from e2e_helpers import HELLO_TRIANGLE, rdc, rdc_fail, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_assert.py b/tests/e2e/test_assert.py index 0b4bf6f..bbf9e2e 100644 --- a/tests/e2e/test_assert.py +++ b/tests/e2e/test_assert.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest -from conftest import rdc, rdc_fail, rdc_ok +from e2e_helpers import rdc, rdc_fail, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_debug.py b/tests/e2e/test_debug.py index 1d2f360..171ac22 100644 --- a/tests/e2e/test_debug.py +++ b/tests/e2e/test_debug.py @@ -10,7 +10,7 @@ from __future__ import annotations import pytest -from conftest import rdc_fail, rdc_json, rdc_ok +from e2e_helpers import rdc_fail, rdc_json, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_diff.py b/tests/e2e/test_diff.py index 5496419..e88d928 100644 --- a/tests/e2e/test_diff.py +++ b/tests/e2e/test_diff.py @@ -9,7 +9,7 @@ from __future__ import annotations import pytest -from conftest import HELLO_TRIANGLE, VKCUBE, VKCUBE_VALIDATION, rdc, rdc_ok +from e2e_helpers import HELLO_TRIANGLE, VKCUBE, VKCUBE_VALIDATION, rdc, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_export.py b/tests/e2e/test_export.py index 59fe5ff..2bd084b 100644 --- a/tests/e2e/test_export.py +++ b/tests/e2e/test_export.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from conftest import rdc_fail, rdc_ok +from e2e_helpers import rdc_fail, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_formats.py b/tests/e2e/test_formats.py index 17066dc..320589b 100644 --- a/tests/e2e/test_formats.py +++ b/tests/e2e/test_formats.py @@ -9,7 +9,7 @@ import json import pytest -from conftest import rdc, rdc_json, rdc_ok +from e2e_helpers import rdc, rdc_json, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_presession.py b/tests/e2e/test_presession.py index df065ef..f672557 100644 --- a/tests/e2e/test_presession.py +++ b/tests/e2e/test_presession.py @@ -6,7 +6,7 @@ from __future__ import annotations -from conftest import rdc_fail, rdc_ok +from e2e_helpers import rdc_fail, rdc_ok class TestVersion: diff --git a/tests/e2e/test_query.py b/tests/e2e/test_query.py index 1f97d62..86431fe 100644 --- a/tests/e2e/test_query.py +++ b/tests/e2e/test_query.py @@ -10,7 +10,7 @@ import re import pytest -from conftest import rdc, rdc_fail, rdc_ok +from e2e_helpers import rdc, rdc_fail, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_session.py b/tests/e2e/test_session.py index 101a336..e74a00c 100644 --- a/tests/e2e/test_session.py +++ b/tests/e2e/test_session.py @@ -10,7 +10,7 @@ import uuid import pytest -from conftest import HELLO_TRIANGLE, VKCUBE, rdc, rdc_fail, rdc_ok +from e2e_helpers import HELLO_TRIANGLE, VKCUBE, rdc, rdc_fail, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_shader_edit.py b/tests/e2e/test_shader_edit.py index 4fa45f7..e351dea 100644 --- a/tests/e2e/test_shader_edit.py +++ b/tests/e2e/test_shader_edit.py @@ -13,7 +13,7 @@ from pathlib import Path import pytest -from conftest import rdc, rdc_ok +from e2e_helpers import rdc, rdc_ok pytestmark = pytest.mark.gpu diff --git a/tests/e2e/test_vfs.py b/tests/e2e/test_vfs.py index d6cdfe1..9471af8 100644 --- a/tests/e2e/test_vfs.py +++ b/tests/e2e/test_vfs.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from conftest import rdc_fail, rdc_ok +from e2e_helpers import rdc_fail, rdc_ok pytestmark = pytest.mark.gpu