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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions openspec/changes/2026-05-15-issue-224-vsin-mesh/proposal.md
Original file line number Diff line number Diff line change
@@ -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 <name>; 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).
Original file line number Diff line number Diff line change
@@ -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 <name>; 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 <eid> --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
31 changes: 31 additions & 0 deletions openspec/changes/2026-05-15-issue-224-vsin-mesh/tasks.md
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions openspec/changes/2026-05-15-issue-224-vsin-mesh/test-plan.md
Original file line number Diff line number Diff line change
@@ -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 <eid> --stage vs-in` → assert daemon request contains `"stage": "vs-in"`.
- Invoke `rdc mesh <eid> --stage bad-stage` → assert Click validation error, exit code 2.
- Invoke `rdc mesh <eid>` (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`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix code-span whitespace at Line 58.

v inside backticks includes a trailing space and can trigger markdownlint MD038. Rephrase to avoid space inside the code span (e.g., “lines starting with v”).

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 58-58: Spaces inside code span elements

(MD038, no-space-in-code)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/2026-05-15-issue-224-vsin-mesh/test-plan.md` at line 58, The
code span "`v `" in the test-plan assertion includes a trailing space and
triggers markdownlint MD038; update the sentence to remove the space inside the
code span and rephrase to something like “Assert OBJ contains lines starting
with `v`; count matches reported vertex count from `MeshFormat`” so the code
span is just `v` and the meaning remains clear. Ensure the change touches the
test-plan.md assertion that mentions `MeshFormat` and replaces the backticked `v
` with a non-space code span or descriptive wording such as "lines starting with
`v`".


## 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`.
15 changes: 14 additions & 1 deletion src/rdc/commands/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
)
Expand All @@ -38,13 +38,15 @@ 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)
return

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,
Expand Down Expand Up @@ -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]],
Expand Down
19 changes: 12 additions & 7 deletions src/rdc/handlers/buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -318,16 +321,18 @@ 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)
i_size = getattr(mesh, "indexByteSize", 0)
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,
Expand Down
11 changes: 11 additions & 0 deletions tests/integration/test_daemon_handlers_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 15 additions & 1 deletion tests/mocks/mock_renderdoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading
Loading