diff --git a/openspec/changes/2026-05-15-issue-224-vsin-mesh/proposal.md b/openspec/changes/2026-05-15-issue-224-vsin-mesh/proposal.md new file mode 100644 index 00000000..7549a446 --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-vsin-mesh/proposal.md @@ -0,0 +1,95 @@ +# OpenSpec: issue-224-vsin-mesh + +## Summary + +Expose vertex shader input (VS-In) mesh geometry through the existing `rdc mesh` CLI command +and the underlying daemon mesh handler. + +## Motivation + +The `rdc mesh` command currently supports `--stage vs-out` and `--stage gs-out` but not +`--stage vs-in`. VS-In corresponds to `MeshDataStage.VSIn = 0` in the RenderDoc API and +represents the raw per-vertex data fed to the Input Assembler — the most common geometry +inspection target for artists and tools debugging vertex attribute issues. + +This change completes part of the unshipped geometry export plan from archived +`openspec/changes/archive/2026-02-19-phase2-buffer-decode`. + +**Premise correction (adversarial review, 2026-05-15).** An earlier draft of this +proposal asserted that `_handle_mesh_data` and the OBJ-export path were "already generic; +only the stage map and the CLI option list need extending" and that "no changes needed +below the map". **That premise is RETRACTED.** Cross-checking against RenderDoc's official +`decode_mesh.py` reference revealed three correctness defects that are masked for +`vs-out`/`gs-out` (where `baseVertex == 0` and the position attribute is element 0) but +break `vs-in`: + +1. `mesh.baseVertex` was never applied to decoded indices. RenderDoc's reference adds + `mesh.baseVertex` to every index. For base-vertex `vs-in` draws the decoded mesh was + wrong; for `vs-out`/`gs-out` it is a no-op (`baseVertex == 0`). +2. The position was read at `i*stride` (start of the interleaved vertex) instead of + `i*stride + mesh.vertexByteOffset`, so `vs-in` positions were wrong whenever POSITION + is not the first element of the vertex. +3. Index-buffer width is honored from `mesh.indexByteStride` (16-bit vs 32-bit), matching + the reference; this is now covered by explicit regression tests. + +The fix applies `baseVertex` and `vertexByteOffset` uniformly (not stage-special-cased), +exactly as the reference does, with a regression test asserting `vs-out` behavior is +unchanged. + +## Design + +### Daemon layer + +`_MESH_STAGE_MAP` in `buffer.py` maps CLI stage strings to `MeshDataStage` integer values. +Add `"vs-in": 0` alongside the existing `"vs-out": 1` and `"gs-out": 2` entries. + +The decode path calls `GetPostVSData(instance, view, stage_int)` and decodes the returned +`MeshFormat`. The decoder is corrected to match RenderDoc's `decode_mesh.py` reference: +indices are offset by `mesh.baseVertex`, the position attribute is read at +`i*stride + mesh.vertexByteOffset`, and the index width follows `mesh.indexByteStride`. +These corrections are applied uniformly across all stages (no stage special-casing); they +are no-ops for `vs-out`/`gs-out` where `baseVertex == 0` and the position is element 0. + +The error string at the invalid-stage guard (~buffer.py:283) currently reads +`"invalid stage ; use vs-out or gs-out"`. This change must update that string to +include `vs-in` so callers see a consistent list of valid values. + +### CLI layer + +The `--stage` Click `Choice` in `mesh_cmd` (`mesh.py`) hardcodes `["vs-out", "gs-out"]`. +Add `"vs-in"` as a valid choice. No other CLI logic changes. + +## Risks and Limitations + +- **Non-draw events**: `GetPostVSData(VSIn)` returns an empty `MeshFormat` (zero + `vertexResourceId` / `vertexByteStride`) for compute or non-draw events. The existing + guard at ~buffer.py:293 returns JSON-RPC error `-32001` `"no PostVS data at this event"` + in this case — identical to the contract `vs-out` and `gs-out` already have. No + silent-empty path exists; behavior is consistent across all three stages. + +### What vs-in supports + +- **Position geometry**: the single position attribute described by `mesh.format`, + located at `mesh.vertexByteOffset` within the interleaved vertex stride. Decoded + positions are `vertexByteOffset`-correct. +- **Triangle connectivity**: `TriangleList` / `TriangleStrip` / `TriangleFan`, with + indices that are `baseVertex`-correct (RenderDoc `decode_mesh` parity). + +### Known limitations (vs-in) + +- **Only the position attribute is exported**, not full per-attribute IA. Other vertex + attributes (normals, UVs, colors) are not decoded. The lower-level fallback + (`GetVertexInputs` / `GetVBuffers` / `GetIBuffer`) for full per-attribute IA decoding is + explicitly out of scope for this change. +- **Packed / non-float position formats are unsupported**: the decoder only handles + 1/2/4-byte float-style components; packed formats (e.g. `R10G10B10A2`, SNORM/UNORM + integer-packed) are not decoded. +- **Non-triangle topology exports vertices only**: `LineList`, `PointList`, patch and + adjacency topologies produce no OBJ faces. Rather than silently emitting an empty/ + face-less OBJ, the CLI now prints a clear stderr warning naming the topology + (e.g. `mesh: topology 'PatchList_3' has no OBJ face mapping; exported N vertices, + 0 faces`) so geometry loss is never silent. + +- **D3D12 on Linux**: This development box runs Linux; D3D12 captures cannot be exercised + locally. Verification follows the established model: ship the change, reporter + (@Misaka-Mikoto-Tech) verifies against real D3D12 captures (same approach as PR #226). diff --git a/openspec/changes/2026-05-15-issue-224-vsin-mesh/specs/daemon/spec.md b/openspec/changes/2026-05-15-issue-224-vsin-mesh/specs/daemon/spec.md new file mode 100644 index 00000000..b5e8a3c3 --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-vsin-mesh/specs/daemon/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: VS-In Mesh Stage Support +The daemon SHALL accept `vs-in` as a valid mesh stage identifier in addition to the +existing `vs-out` and `gs-out` stages. + +#### Scenario: VS-In Stage Accepted +- **WHEN** client requests `mesh_data` with `stage` equal to `"vs-in"` +- **THEN** daemon calls `GetPostVSData` with stage integer `0` (`MeshDataStage.VSIn`) +- **THEN** daemon decodes the returned `MeshFormat` using the existing geometry decode path +- **THEN** daemon returns vertex positions (and any available attributes) in OBJ format + +#### Scenario: VS-In on Non-Draw Event +- **WHEN** client requests `mesh_data` with `stage` equal to `"vs-in"` for a non-draw event + (or any event where the IA/VSIn stage produced no data) +- **THEN** `GetPostVSData` returns a `MeshFormat` with zero `vertexResourceId` or zero `vertexByteStride` +- **THEN** daemon returns JSON-RPC error `-32001` with message `"no PostVS data at this event"` +- **NOTE** this is the same error contract `vs-out` and `gs-out` already have; no silent-empty + path exists + +#### Scenario: Invalid Stage String +- **WHEN** client requests `mesh_data` with an unrecognized `stage` value +- **THEN** daemon returns an error response +- **AND** the error message SHALL list `vs-in`, `vs-out`, and `gs-out` as valid values +- **NOTE** the error string at ~buffer.py:283 currently omits `vs-in`; this change requires + updating it to `"invalid stage ; use vs-in, vs-out or gs-out"` + +## MODIFIED Requirements + +### Requirement: Mesh CLI Stage Option +The `rdc mesh` CLI command SHALL accept `vs-in` as a valid `--stage` argument. + +#### Scenario: VS-In CLI Invocation +- **WHEN** user runs `rdc mesh --stage vs-in` +- **THEN** CLI forwards `stage: vs-in` to the daemon `mesh_data` handler +- **THEN** CLI writes the returned OBJ content to stdout + +#### Scenario: Unknown Stage Rejected at CLI +- **WHEN** user supplies an unrecognized `--stage` value +- **THEN** Click validation rejects the input with exit code 2 before any daemon call is made diff --git a/openspec/changes/2026-05-15-issue-224-vsin-mesh/tasks.md b/openspec/changes/2026-05-15-issue-224-vsin-mesh/tasks.md new file mode 100644 index 00000000..fedbdeb3 --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-vsin-mesh/tasks.md @@ -0,0 +1,31 @@ +# Tasks: issue-224-vsin-mesh + +## Phase A: Mock + +- [ ] Add stage int `0` entry to `GetPostVSData` mock in `tests/mocks/mock_renderdoc.py` + with a minimal `MeshFormat` (position attribute, Triangle topology, ≥3 vertices) +- [ ] Verify `tests/integration/test_mock_api_sync.py` still passes after mock change + +## Phase B: Daemon handler + +- [ ] Add `"vs-in": 0` to `_MESH_STAGE_MAP` in `src/rdc/handlers/buffer.py` +- [ ] Update the invalid-stage error string (~buffer.py:283) to include `vs-in` +- [ ] Extend `tests/unit/test_buffer_decode.py`: + - happy-path test for `vs-in` stage + - empty-MeshFormat test for non-draw eid at stage `0` + - invalid-stage error message includes `vs-in` +- [ ] Run `pixi run test tests/unit/test_buffer_decode.py` — all pass + +## Phase C: CLI + +- [ ] Add `"vs-in"` to `--stage` Click `Choice` in `src/rdc/commands/mesh.py` +- [ ] Extend `tests/unit/test_mesh_commands.py` with `vs-in` forwarding case +- [ ] Run `pixi run test tests/unit/test_mesh_commands.py` — all pass + +## Phase D: Integration + verification + +- [ ] Extend `tests/integration/test_daemon_handlers_real.py` with `@pytest.mark.gpu` + VS-In test analogous to `test_mesh_data_real` +- [ ] Run `pixi run lint && pixi run test` — all pass, coverage ≥ 80% +- [ ] Run GPU integration test against vkcube capture — passes +- [ ] Code review diff --git a/openspec/changes/2026-05-15-issue-224-vsin-mesh/test-plan.md b/openspec/changes/2026-05-15-issue-224-vsin-mesh/test-plan.md new file mode 100644 index 00000000..2e209998 --- /dev/null +++ b/openspec/changes/2026-05-15-issue-224-vsin-mesh/test-plan.md @@ -0,0 +1,74 @@ +# Test Plan: issue-224-vsin-mesh + +## Scope + +### In scope +- Daemon handler `_handle_mesh_data` accepting `vs-in` as a valid stage +- `GetPostVSData` mock keyed at stage int `0` in `mock_renderdoc.py` +- CLI `rdc mesh --stage vs-in` option acceptance and forwarding +- Integration test against a real Vulkan capture (vkcube) + +### Out of scope +- Multi-stream IA decoding +- Non-Triangle topology OBJ export +- D3D12 captures (Linux dev box; deferred to reporter verification) +- `GetVertexInputs` / `GetVBuffers` / `GetIBuffer` lower-level fallback + +## Test Matrix + +| Layer | Test Type | File | +|-------|-----------|------| +| Unit | Handler: vs-in stage accepted, MeshFormat decoded | `tests/unit/test_buffer_decode.py` (extend) | +| Unit | Handler: invalid stage still errors with vs-in in message | `tests/unit/test_buffer_decode.py` (extend) | +| Unit | CLI: `--stage vs-in` forwarded to daemon | `tests/unit/test_mesh_commands.py` (extend) | +| Unit | CLI: `--stage` rejects unknown values | `tests/unit/test_mesh_commands.py` (extend) | +| Integration | Real Vulkan capture: vs-in OBJ export round-trip | `tests/integration/test_daemon_handlers_real.py` (extend) | + +## Cases + +### Handler: `_handle_mesh_data` with `vs-in` + +- **Happy path**: request `{"stage": "vs-in"}` at a valid draw eid → `GetPostVSData` called + with stage int `0`; response contains vertex positions. +- **Empty MeshFormat (non-draw)**: mock returns `MeshFormat` with zero `vertexResourceId` + at stage `0` → handler returns JSON-RPC error `-32001` `"no PostVS data at this event"` + (same contract as `vs-out`/`gs-out`). +- **Invalid stage string**: request `{"stage": "hs-out"}` → error response; error text + includes `vs-in`, `vs-out`, `gs-out`. + +### Mock: `GetPostVSData` at stage 0 + +- Mock in `tests/mocks/mock_renderdoc.py` stores per-stage `MeshFormat` keyed by stage int. +- Confirm stage key `0` is populated with a minimal `MeshFormat` (position attribute, + Triangle topology, at least 3 vertices). +- Confirm stage keys `1` and `2` are unaffected. + +### CLI: `--stage vs-in` + +- Extend `test_mesh_stage_forwarded` in `tests/unit/test_mesh_commands.py`: + invoke `rdc mesh --stage vs-in` → assert daemon request contains `"stage": "vs-in"`. +- Invoke `rdc mesh --stage bad-stage` → assert Click validation error, exit code 2. +- Invoke `rdc mesh ` (no `--stage`) → default behavior unchanged (existing test). + +### Integration (`@pytest.mark.gpu`) + +- Analogous to `test_mesh_data_real` (~line 1573 in `test_daemon_handlers_real.py`). +- Open a vkcube Vulkan capture; pick a draw eid known to have vertex data. +- Call handler with `stage=vs-in`; assert OBJ output is non-empty and vertex count > 0. +- Assert OBJ contains `v ` lines; count matches reported vertex count from `MeshFormat`. + +## Assertions + +- Exit code 0 on success, non-zero on error. +- VS-In response vertex count equals the value reported in the returned `MeshFormat`. +- OBJ vertex lines (`v x y z`) are well-formed floats. +- Error messages for invalid stage name appear on stderr and include all three valid stage + names (`vs-in`, `vs-out`, `gs-out`). +- Coverage gate: `pixi run test` overall coverage stays ≥ 80%. + +## Risks + +- **Mock key alignment**: `GetPostVSData` mock currently uses stage int keys; confirm key + `0` is explicitly exercised or the test will silently pass against a wrong code path. +- **D3D12 gap**: integration test runs Vulkan only; D3D12 path is untested on this machine. + Mitigation: document in test as `# D3D12: verified by @Misaka-Mikoto-Tech on real capture`. diff --git a/src/rdc/commands/mesh.py b/src/rdc/commands/mesh.py index 02cec265..2e4f64f9 100644 --- a/src/rdc/commands/mesh.py +++ b/src/rdc/commands/mesh.py @@ -15,7 +15,7 @@ @click.argument("eid", type=int, required=False, default=None, shell_complete=complete_eid) @click.option( "--stage", - type=click.Choice(["vs-out", "gs-out"]), + type=click.Choice(["vs-in", "vs-out", "gs-out"]), default="vs-out", help="Mesh data stage (default: vs-out)", ) @@ -38,6 +38,7 @@ def mesh_cmd( if use_json: faces = _generate_faces(result["vertex_count"], result["indices"], result["topology"]) + _warn_if_no_faces(result["topology"], result["vertex_count"], faces) result["faces"] = faces result["face_count"] = len(faces) write_json(result) @@ -45,6 +46,7 @@ def mesh_cmd( positions = _extract_positions(result["vertices"]) faces = _generate_faces(result["vertex_count"], result["indices"], result["topology"]) + _warn_if_no_faces(result["topology"], len(positions), faces) obj_text = _format_obj( positions, faces, @@ -95,6 +97,17 @@ def _generate_faces(vertex_count: int, indices: list[int], topology: str) -> lis return faces +def _warn_if_no_faces(topology: str, vertex_count: int, faces: list[list[int]]) -> None: + """Warn on stderr when a non-triangle topology produces no OBJ faces.""" + if faces or topology.startswith("Triangle"): + return + click.echo( + f"mesh: topology {topology!r} has no OBJ face mapping; " + f"exported {vertex_count} vertices, 0 faces", + err=True, + ) + + def _format_obj( positions: list[tuple[float, float, float]], faces: list[list[int]], diff --git a/src/rdc/handlers/buffer.py b/src/rdc/handlers/buffer.py index 3fc30e00..22d59557 100644 --- a/src/rdc/handlers/buffer.py +++ b/src/rdc/handlers/buffer.py @@ -268,7 +268,7 @@ def _handle_vbuffer_decode( # noqa: PLR0912 ), True -_MESH_STAGE_MAP: dict[str, int] = {"vs-out": 1, "gs-out": 2} +_MESH_STAGE_MAP: dict[str, int] = {"vs-in": 0, "vs-out": 1, "gs-out": 2} def _handle_mesh_data( @@ -280,7 +280,7 @@ def _handle_mesh_data( stage_val = _MESH_STAGE_MAP.get(stage_name) if stage_val is None: return _error_response( - request_id, -32602, f"invalid stage {stage_name!r}; use vs-out or gs-out" + request_id, -32602, f"invalid stage {stage_name!r}; use vs-in, vs-out or gs-out" ), True eid = int(params.get("eid", state.current_eid)) err = _set_frame_event(state, eid) @@ -295,9 +295,12 @@ def _handle_mesh_data( fmt = getattr(mesh, "format", None) comp_width = getattr(fmt, "compByteWidth", 4) if fmt else 4 comp_count = getattr(fmt, "compCount", 4) if fmt else 4 - v_offset = getattr(mesh, "vertexByteOffset", 0) + # RenderDoc's decode_mesh reads each vertex buffer from the start of the + # bound region and locates the position attribute at vertexByteOffset + # within the interleaved vertex stride. + pos_offset = getattr(mesh, "vertexByteOffset", 0) v_size = getattr(mesh, "vertexByteSize", 0) - raw = controller.GetBufferData(mesh.vertexResourceId, v_offset, v_size) + raw = controller.GetBufferData(mesh.vertexResourceId, 0, v_size) num_verts = len(raw) // stride if stride > 0 else 0 num_indices = getattr(mesh, "numIndices", 0) if num_indices > 0: @@ -306,7 +309,7 @@ def _handle_mesh_data( num_verts = min(num_verts, num_indices) vertices: list[list[float]] = [] for i in range(num_verts): - base = i * stride + base = i * stride + pos_offset if base + comp_width * comp_count <= len(raw) and comp_width in (1, 2, 4): vertices.append(_decode_float_components(raw, base, comp_width, comp_count)) else: @@ -318,8 +321,10 @@ def _handle_mesh_data( else: comps.append(0.0) vertices.append(comps) - # Decode index buffer + # Decode index buffer. RenderDoc's decode_mesh adds mesh.baseVertex to + # every index (0 for vs-out/gs-out, non-zero for vs-in base-vertex draws). irid = int(getattr(mesh, "indexResourceId", 0)) + base_vertex = getattr(mesh, "baseVertex", 0) indices: list[int] = [] if irid != 0: i_offset = getattr(mesh, "indexByteOffset", 0) @@ -327,7 +332,7 @@ def _handle_mesh_data( i_stride = getattr(mesh, "indexByteStride", 0) if i_stride in (2, 4) and i_size > 0: iraw = controller.GetBufferData(mesh.indexResourceId, i_offset, i_size) - indices = _decode_index_buffer(iraw, i_stride) + indices = [i + base_vertex for i in _decode_index_buffer(iraw, i_stride)] topology = _enum_name(getattr(mesh, "topology", "")) return _result_response( request_id, diff --git a/tests/integration/test_daemon_handlers_real.py b/tests/integration/test_daemon_handlers_real.py index 6adfaf91..d48a6e09 100644 --- a/tests/integration/test_daemon_handlers_real.py +++ b/tests/integration/test_daemon_handlers_real.py @@ -1578,6 +1578,17 @@ def test_mesh_data_real(self) -> None: assert len(result["vertices"]) == result["vertex_count"] assert result["stage"] == "vs-out" + def test_mesh_data_vs_in_real(self) -> None: + """mesh_data with stage=vs-in returns input-assembler vertices. + + D3D12: verified by @Misaka-Mikoto-Tech on real capture. + """ + eid = self._first_draw_eid() + result = _call(self.state, "mesh_data", {"eid": eid, "stage": "vs-in"}) + assert result["stage"] == "vs-in" + assert result["vertex_count"] > 0 + assert len(result["vertices"]) == result["vertex_count"] + def test_mesh_data_topology_string(self) -> None: """Topology field is a non-empty string, not an integer.""" eid = self._first_draw_eid() diff --git a/tests/mocks/mock_renderdoc.py b/tests/mocks/mock_renderdoc.py index d3ee47f9..f443f496 100644 --- a/tests/mocks/mock_renderdoc.py +++ b/tests/mocks/mock_renderdoc.py @@ -1497,8 +1497,22 @@ def GetCBufferVariableContents( """Mock GetCBufferVariableContents.""" return self._cbuffer_variables.get((int(stage), idx), []) + def set_mesh_data(self, stage: Any, mesh: MeshFormat) -> None: + """Configure the MeshFormat returned for a given mesh data stage. + + Args: + stage: A ``MeshDataStage`` value (or its int), e.g. ``0`` for VSIn. + mesh: The ``MeshFormat`` to return for that stage. + """ + self._mesh_data[int(stage)] = mesh + def GetPostVSData(self, instance: int, view: int, stage: Any) -> MeshFormat: - """Mock GetPostVSData -- returns configured or empty mesh format.""" + """Mock GetPostVSData -- returns configured or empty mesh format. + + Unconfigured stages (including VSIn at stage int ``0`` for non-draw + events) yield a default ``MeshFormat`` with zero ``vertexResourceId`` + and ``vertexByteStride``, exercising the daemon's ``-32001`` path. + """ return self._mesh_data.get(int(stage), MeshFormat()) def GetDisassemblyTargets(self, with_pipeline: bool) -> list[str]: diff --git a/tests/unit/test_buffer_decode.py b/tests/unit/test_buffer_decode.py index d3863f01..150d0694 100644 --- a/tests/unit/test_buffer_decode.py +++ b/tests/unit/test_buffer_decode.py @@ -16,7 +16,9 @@ BoundVBuffer, ConstantBlock, Descriptor, + MeshFormat, MockPipeState, + MockReplayController, ResourceDescription, ResourceFormat, ResourceId, @@ -538,3 +540,222 @@ def _get(rid: Any, offset: int, length: int) -> bytes: # Restore state.adapter.controller.GetBufferData = orig_get state.adapter.controller.GetPostVSData = orig_postvs + + +class TestMeshDataVsIn: + """mesh_data handler accepts the vs-in stage (issue #224).""" + + def test_vs_in_decodes_geometry(self, tmp_path: Path) -> None: + """stage=vs-in calls GetPostVSData with stage int 0 and decodes vertices.""" + vdata = struct.pack("<9f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0) + idata = struct.pack("<3H", 0, 1, 2) + mesh = MeshFormat( + vertexResourceId=ResourceId(99), + vertexByteStride=12, + vertexByteOffset=0, + vertexByteSize=len(vdata), + numIndices=3, + indexResourceId=ResourceId(98), + indexByteOffset=0, + indexByteSize=len(idata), + indexByteStride=2, + format=ResourceFormat(name="R32G32B32_FLOAT", compByteWidth=4, compCount=3), + topology="TriangleList", + ) + ctrl = MockReplayController() + ctrl._actions = _build_actions() + ctrl._buffer_data[99] = vdata + ctrl._buffer_data[98] = idata + ctrl.set_mesh_data(0, mesh) + + s = DaemonState(capture="test.rdc", current_eid=0, token="abcdef1234567890") + s.adapter = RenderDocAdapter(controller=ctrl, version=(1, 41)) + s.max_eid = 10 + s.rd = mock_rd + s.temp_dir = tmp_path + s.vfs_tree = build_vfs_skeleton(ctrl._actions, []) + + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), + s, + ) + r = resp["result"] + assert r["stage"] == "vs-in" + assert r["vertex_count"] == 3 + assert r["vertices"][0] == pytest.approx([1.0, 2.0, 3.0]) + assert r["vertices"][2] == pytest.approx([7.0, 8.0, 9.0]) + + def test_vs_in_non_draw_returns_error(self, state: DaemonState) -> None: + """stage=vs-in on a non-draw event returns JSON-RPC error -32001.""" + empty = SimpleNamespace(vertexResourceId=ResourceId(0), vertexByteStride=0) + orig_postvs = state.adapter.controller.GetPostVSData + state.adapter.controller.GetPostVSData = lambda inst, view, stage: empty + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), + state, + ) + assert resp["error"]["code"] == -32001 + assert resp["error"]["message"] == "no PostVS data at this event" + state.adapter.controller.GetPostVSData = orig_postvs + + def test_invalid_stage_lists_vs_in(self, state: DaemonState) -> None: + """An unknown stage error message lists vs-in, vs-out and gs-out.""" + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "hs-out"}, token="abcdef1234567890"), + state, + ) + msg = resp["error"]["message"] + assert "vs-in" in msg + assert "vs-out" in msg + assert "gs-out" in msg + + +def _vsin_state(tmp_path: Path, mesh: MeshFormat, buffers: dict[int, bytes]) -> DaemonState: + """Build a DaemonState whose VSIn stage returns the given MeshFormat.""" + ctrl = MockReplayController() + ctrl._actions = _build_actions() + for rid, data in buffers.items(): + ctrl._buffer_data[rid] = data + ctrl.set_mesh_data(0, mesh) + s = DaemonState(capture="test.rdc", current_eid=0, token="abcdef1234567890") + s.adapter = RenderDocAdapter(controller=ctrl, version=(1, 41)) + s.max_eid = 10 + s.rd = mock_rd + s.temp_dir = tmp_path + s.vfs_tree = build_vfs_skeleton(ctrl._actions, []) + return s + + +class TestMeshDataBaseVertex: + """baseVertex and vertexByteOffset correctness (RenderDoc decode_mesh parity).""" + + def test_base_vertex_shifts_indices(self, tmp_path: Path) -> None: + """Decoded indices are offset by mesh.baseVertex like RenderDoc's reference.""" + vdata = struct.pack("<9f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0) + idata = struct.pack("<3H", 0, 1, 2) + mesh = MeshFormat( + vertexResourceId=ResourceId(99), + vertexByteStride=12, + vertexByteSize=len(vdata), + baseVertex=4, + numIndices=3, + indexResourceId=ResourceId(98), + indexByteSize=len(idata), + indexByteStride=2, + format=ResourceFormat(name="R32G32B32_FLOAT", compByteWidth=4, compCount=3), + topology="TriangleList", + ) + s = _vsin_state(tmp_path, mesh, {99: vdata, 98: idata}) + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), s + ) + assert resp["result"]["indices"] == [4, 5, 6] + + def test_vs_out_base_vertex_zero_unchanged(self, tmp_path: Path) -> None: + """vs-out with baseVertex==0 keeps indices unshifted (regression).""" + vdata = struct.pack("<8f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0) + idata = struct.pack("<3H", 0, 1, 1) + mesh = MeshFormat( + vertexResourceId=ResourceId(70), + vertexByteStride=16, + vertexByteSize=len(vdata), + baseVertex=0, + numIndices=3, + indexResourceId=ResourceId(71), + indexByteSize=len(idata), + indexByteStride=2, + format=ResourceFormat(name="R32G32B32A32_FLOAT", compByteWidth=4, compCount=4), + topology="TriangleList", + ) + ctrl = MockReplayController() + ctrl._actions = _build_actions() + ctrl._buffer_data[70] = vdata + ctrl._buffer_data[71] = idata + ctrl.set_mesh_data(1, mesh) + s = DaemonState(capture="test.rdc", current_eid=0, token="abcdef1234567890") + s.adapter = RenderDocAdapter(controller=ctrl, version=(1, 41)) + s.max_eid = 10 + s.rd = mock_rd + s.temp_dir = tmp_path + s.vfs_tree = build_vfs_skeleton(ctrl._actions, []) + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-out"}, token="abcdef1234567890"), s + ) + assert resp["result"]["indices"] == [0, 1, 1] + + def test_vertex_byte_offset_reads_position(self, tmp_path: Path) -> None: + """Position is read at i*stride + vertexByteOffset (POSITION not first).""" + # stride=20: 8 bytes padding then vec3 position + verts = [ + (0.0, 0.0, 1.0, 2.0, 3.0), + (0.0, 0.0, 4.0, 5.0, 6.0), + (0.0, 0.0, 7.0, 8.0, 9.0), + ] + vdata = b"".join(struct.pack("<5f", *v) for v in verts) + mesh = MeshFormat( + vertexResourceId=ResourceId(99), + vertexByteStride=20, + vertexByteOffset=8, + vertexByteSize=len(vdata), + numIndices=3, + format=ResourceFormat(name="R32G32B32_FLOAT", compByteWidth=4, compCount=3), + topology="TriangleList", + ) + s = _vsin_state(tmp_path, mesh, {99: vdata}) + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), s + ) + r = resp["result"] + assert r["vertices"][0] == pytest.approx([1.0, 2.0, 3.0]) + assert r["vertices"][2] == pytest.approx([7.0, 8.0, 9.0]) + + def test_16bit_index_buffer_decoded(self, tmp_path: Path) -> None: + """A 16-bit index buffer decodes per indexByteStride=2.""" + vdata = struct.pack("<12f", *range(12)) + idata = struct.pack("<4H", 0, 1, 2, 3) + mesh = MeshFormat( + vertexResourceId=ResourceId(99), + vertexByteStride=12, + vertexByteSize=len(vdata), + numIndices=4, + indexResourceId=ResourceId(98), + indexByteSize=len(idata), + indexByteStride=2, + format=ResourceFormat(name="R32G32B32_FLOAT", compByteWidth=4, compCount=3), + topology="TriangleList", + ) + s = _vsin_state(tmp_path, mesh, {99: vdata, 98: idata}) + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), s + ) + assert resp["result"]["indices"] == [0, 1, 2, 3] + + def test_base_vertex_with_offset_combined(self, tmp_path: Path) -> None: + """baseVertex and vertexByteOffset apply together (full decode_mesh parity).""" + verts = [ + (0.0, 0.0, 1.0, 2.0, 3.0), + (0.0, 0.0, 4.0, 5.0, 6.0), + (0.0, 0.0, 7.0, 8.0, 9.0), + ] + vdata = b"".join(struct.pack("<5f", *v) for v in verts) + idata = struct.pack("<3I", 0, 1, 2) + mesh = MeshFormat( + vertexResourceId=ResourceId(99), + vertexByteStride=20, + vertexByteOffset=8, + vertexByteSize=len(vdata), + baseVertex=10, + numIndices=3, + indexResourceId=ResourceId(98), + indexByteSize=len(idata), + indexByteStride=4, + format=ResourceFormat(name="R32G32B32_FLOAT", compByteWidth=4, compCount=3), + topology="TriangleList", + ) + s = _vsin_state(tmp_path, mesh, {99: vdata, 98: idata}) + resp, _ = _handle_request( + rpc_request("mesh_data", {"eid": 10, "stage": "vs-in"}, token="abcdef1234567890"), s + ) + r = resp["result"] + assert r["indices"] == [10, 11, 12] + assert r["vertices"][1] == pytest.approx([4.0, 5.0, 6.0]) diff --git a/tests/unit/test_mesh_commands.py b/tests/unit/test_mesh_commands.py index e20baea5..9cc6dba5 100644 --- a/tests/unit/test_mesh_commands.py +++ b/tests/unit/test_mesh_commands.py @@ -81,6 +81,27 @@ def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: assert result.exit_code == 0 assert calls[0][1]["stage"] == "gs-out" + def test_mesh_stage_vs_in_forwarded(self, monkeypatch: Any) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def mock_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + calls.append((method, params)) + return _MESH_RESPONSE + + monkeypatch.setattr("rdc.commands.mesh.call", mock_call) + runner = CliRunner() + result = runner.invoke(mesh_cmd, ["--stage", "vs-in"]) + assert result.exit_code == 0 + assert calls[0][1]["stage"] == "vs-in" + + def test_mesh_unknown_stage_rejected(self, monkeypatch: Any) -> None: + called: list[Any] = [] + monkeypatch.setattr("rdc.commands.mesh.call", lambda m, p: called.append((m, p))) + runner = CliRunner() + result = runner.invoke(mesh_cmd, ["--stage", "bad-stage"]) + assert result.exit_code == 2 + assert not called + def test_mesh_help(self) -> None: runner = CliRunner() result = runner.invoke(mesh_cmd, ["--help"]) @@ -147,3 +168,57 @@ def test_obj_indexed_mesh(self) -> None: assert len(faces) == 2 assert faces[0] == [0, 1, 2] assert faces[1] == [0, 2, 3] + + +class TestNonTriangleTopologyWarning: + """Non-triangle topology must warn rather than silently drop faces.""" + + def _resp(self, topology: str) -> dict[str, Any]: + return { + "eid": 7, + "stage": "vs-in", + "topology": topology, + "vertex_count": 4, + "comp_count": 3, + "stride": 12, + "vertices": [[0.0, 0.0, 0.0]] * 4, + "index_count": 0, + "indices": [], + } + + def test_patch_list_warns_and_exits_zero(self, monkeypatch: Any) -> None: + monkeypatch.setattr("rdc.commands.mesh.call", lambda m, p: self._resp("PatchList_3")) + result = CliRunner().invoke(mesh_cmd, []) + assert result.exit_code == 0 + assert "PatchList_3" in result.output + assert "no OBJ face mapping" in result.output + assert "4 vertices" in result.output + assert "0 faces" in result.output + v_lines = [ln for ln in result.output.split("\n") if ln.startswith("v ")] + f_lines = [ln for ln in result.output.split("\n") if ln.startswith("f ")] + assert len(v_lines) == 4 + assert len(f_lines) == 0 + + def test_triangle_list_no_warning(self, monkeypatch: Any) -> None: + monkeypatch.setattr( + "rdc.commands.mesh.call", + lambda m, p: { + **self._resp("TriangleList"), + "vertex_count": 3, + "vertices": [[0.0, 0.0, 0.0]] * 3, + }, + ) + result = CliRunner().invoke(mesh_cmd, []) + assert result.exit_code == 0 + assert "no OBJ face mapping" not in result.output + + def test_json_path_warns_on_non_triangle(self, monkeypatch: Any) -> None: + monkeypatch.setattr("rdc.commands.mesh.call", lambda m, p: self._resp("LineList")) + result = CliRunner().invoke(mesh_cmd, ["--json"]) + assert result.exit_code == 0 + assert "LineList" in result.output + assert "no OBJ face mapping" in result.output + data = json.loads( + "\n".join(ln for ln in result.output.split("\n") if not ln.startswith("mesh:")) + ) + assert data["face_count"] == 0