Skip to content

Commit c88e778

Browse files
Spruill-1Copilot
andcommitted
docs: split README.md into a documentation tree
The repo-root README.md had grown to 1205 lines / ~100 KB covering everything from "how to install" to "D3D11 compute COM class hierarchy" to a 60-entry decision log. Carved it into 25 topic-focused files under docs/ organized by audience: docs/architecture/ System overview, pipeline format, effect graph, topological eval, display monitoring/mocking, compute analysis pipeline, D2D/D3D11 hybrid compute, engine/host split. docs/effects/ Built-in catalog, designer, versioning, numeric expression, parameter nodes, property bindings, working space, new-effect defaults (moved from NewEffectDefaults.md). docs/ui-ux/ Graph editor, multi-output windows, animation, conditional parameter visibility. docs/hosts/ Headless console host, MCP server. docs/development/ Build instructions, project structure. docs/history/ Decision log (60+ entries, single chronological file). docs/README.md Index linking out to every file. The new repo-root README.md is ~5 KB: install + local-dev setup + table of contents pointing at docs/. Updated .github/copilot-instructions.md and .context/resume.md to reflect that docs/ is now the living architecture documentation tree (not README.md). Lossless carve verified -- 1138 body lines in original README mapped 1:1 to 1138 lines across 25 docs files (each + 4 lines for a back-link footer). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 44780a8 commit c88e778

30 files changed

Lines changed: 1343 additions & 1165 deletions

.context/resume.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- **Language**: C++/WinRT — direct COM access to `ID2D1EffectImpl`, `ID2D1DrawTransform`, `ID2D1ComputeTransform`.
1212
- **Branch / repo state**: `main`, tagged `v1.5.0`. Working tree clean; the 4 stacked commits are Phase 8 prep + cleanup, not yet pushed.
1313

14-
> Authoritative sources of truth: `README.md` (architecture + decision log, **63 entries**), `CHANGELOG.md` (per-version diffs, including `[Unreleased]` for the post-1.5 work), `Version.h` (numeric version), `.github/copilot-instructions.md` (AI agent rules). This file is a fast-orientation summary; it can drift — re-check the README before relying on details.
14+
> Authoritative sources of truth: [`docs/`](../docs/README.md) (architecture tree + per-file references) and especially [`docs/history/decision-log.md`](../docs/history/decision-log.md) (**63 entries**), `CHANGELOG.md` (per-version diffs, including `[Unreleased]` for the post-1.5 work), `Version.h` (numeric version), `.github/copilot-instructions.md` (AI agent rules). This file is a fast-orientation summary; it can drift — re-check the docs tree before relying on details.
1515
1616
---
1717

@@ -199,9 +199,10 @@ ShaderLab\
199199
├── app.manifest # DPI awareness, heap type
200200
├── EngineExport.h / .cpp # SHADERLAB_API + ABI version + ShaderLab_GetAbiVersion C export
201201
├── Version.h # App 1.5.0, graph format 2
202-
├── README.md # Living architecture doc + decision log (63 entries)
202+
├── README.md # Slim repo intro + pointer to docs/
203+
├── docs/ # Architecture tree (architecture / effects / ui-ux / hosts / development / history)
204+
├── docs/effects/new-effect-defaults.md # D2D effect default-property reference
203205
├── CHANGELOG.md # Version history
204-
├── NewEffectDefaults.md # D2D effect default-property reference
205206
├── Bootstrap.ps1 # One-command fresh-clone setup
206207
207208
├── pch.h / pch.cpp # App PCH

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ShaderLab is a WinUI 3 desktop application (C++/WinRT) for developing, testing,
77
## Hard Rules
88

99
- **C++/WinRT only** — never generate C# code. Direct COM access to `ID2D1EffectImpl`, `ID2D1DrawTransform`, `ID2D1ComputeTransform` is the reason this project exists.
10-
- **README.md is a living architecture doc** — update it with Mermaid diagrams and a decision log entry whenever a significant architectural decision is made.
10+
- **`docs/` is the living architecture documentation tree.** Update the relevant file under `docs/architecture/`, `docs/effects/`, etc. when a significant architectural change lands. Append a new entry to [`docs/history/decision-log.md`](../docs/history/decision-log.md) with a Mermaid diagram for any choice that future contributors will need to understand the *why* of. The repo-root `README.md` is intentionally slim — install + build + a pointer to `docs/` — and should not be expanded with technical detail.
1111
- **All new `.cpp` files must `#include "pch.h"` as the first include** — precompiled header is mandatory (`pch.h` aggregates WinRT, D2D, D3D, Win2D, DXGI, WIC, and STL headers).
1212

1313
## Build

README.md

Lines changed: 41 additions & 1161 deletions
Large diffs are not rendered by default.

docs/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ShaderLab Documentation
2+
3+
The repo root [README.md](../README.md) covers identity, install, and getting-started. Everything below is the deeper technical reference, organized by audience.
4+
5+
> **Looking for the changelog?** [`CHANGELOG.md`](../CHANGELOG.md) at the repo root tracks every version. The [decision log](history/decision-log.md) below tracks architectural choices with rationale (independent of release boundaries).
6+
7+
---
8+
9+
## Architecture
10+
11+
Reference for "how does ShaderLab work under the hood".
12+
13+
- [Architecture Overview](architecture/overview.md) — high-level diagram of the components (rendering, graph, effects, UI controllers, hosts).
14+
- [Pipeline Format Strategy](architecture/pipeline-format.md) — why the pipeline is always scRGB FP16 and how DWM/ACM handles the final display conversion.
15+
- [Effect Graph Model](architecture/effect-graph-model.md)`EffectGraph` / `EffectNode` / `EffectEdge` / `PropertyValue`, JSON serialization, dirty tracking.
16+
- [Topological Evaluation](architecture/topological-evaluation.md) — Kahn's algorithm, evaluation order, cycle detection.
17+
- [Display Monitoring](architecture/display-monitoring.md) — DXGI adapter-change events, `WM_DISPLAYCHANGE`, ICC profile parsing, SDR white level.
18+
- [Display Profile Mocking](architecture/display-profile-mocking.md) — simulated SDR/HDR/WCG environments and the testing harness.
19+
- [Compute Shader Analysis Pipeline](architecture/compute-analysis-pipeline.md) — D2D compute conventions, CPU readback, analysis output schema.
20+
- [D2D / D3D11 Hybrid Compute System](architecture/d2d-d3d11-hybrid-compute.md)`CustomComputeBridgeEffect`, `D3D11ComputeRunner`, GPU-binding routing, COM class hierarchy.
21+
- [Engine / Host Split](architecture/engine-host-split.md)`ShaderLabEngine.dll` ABI, `IEngineCommandSink`, host event hooks.
22+
23+
## Effects
24+
25+
Reference for the effect catalog and per-effect mechanics.
26+
27+
- [Built-in Effect Catalog](effects/builtin-catalog.md) — the ~35 ShaderLab effects (Analysis, Color, Source, Tone Mapping, Parameter).
28+
- [Effect Versioning System](effects/effect-versioning.md) — how `effectVersion` bumps are detected on graph load.
29+
- [Effect Designer](effects/effect-designer.md) — the modal window for authoring custom pixel/compute shaders.
30+
- [Numeric Expression Node (ExprTk)](effects/numeric-expression.md) — single-input math expression parameter node.
31+
- [Parameter Nodes](effects/parameter-nodes.md) — Float / Integer / Toggle / Gamut Parameter and Clock.
32+
- [Property Bindings (Data Pins)](effects/property-bindings.md) — wiring analysis fields to downstream parameters.
33+
- [Working Space Integration](effects/working-space.md) — host-driven node mirroring the active display profile.
34+
- [New Effect Defaults](effects/new-effect-defaults.md) — default property values for every newly added D2D effect.
35+
36+
## UI / UX
37+
38+
- [Graph Editor UX](ui-ux/graph-editor.md) — keyboard / mouse shortcuts, color coding, inline data display.
39+
- [Multi-Output Windows](ui-ux/multi-output-windows.md) — the secondary preview windows.
40+
- [Animation System](ui-ux/animation-system.md) — Clock-driven dirty propagation.
41+
- [Conditional Parameter Visibility](ui-ux/conditional-parameter-visibility.md)`visibleWhen` expressions on parameters.
42+
43+
## Hosts
44+
45+
- [ShaderLabHeadless (Console Host)](hosts/headless.md) — the no-UI host for CI / batch scripting.
46+
- [MCP Server (AI Agent Integration)](hosts/mcp-server.md) — JSON-RPC 2.0 server, tool catalog, route distribution.
47+
48+
## Development
49+
50+
- [Build Instructions](development/build.md) — prerequisites, configurations, dependency map.
51+
- [Project Structure](development/project-structure.md) — full file tree with per-file descriptions.
52+
53+
## History
54+
55+
- [Decision Log](history/decision-log.md) — chronological architectural decisions with rationale (60+ entries).
56+
57+
---
58+
59+
Back to [Repo root](../README.md).
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Compute Shader Analysis Pipeline
2+
3+
Custom compute shaders can act as analysis effects, producing typed output fields that are read back to the CPU and can drive downstream effect properties via data bindings.
4+
5+
## Analysis Output Types
6+
7+
| Type | Pixels | Packing |
8+
|------|--------|---------|
9+
| `float` | 1 | `.x` used |
10+
| `float2` | 1 | `.xy` used |
11+
| `float3` | 1 | `.xyz` used |
12+
| `float4` | 1 | all 4 components |
13+
| `floatarray` | ceil(N/4) | 4 floats packed per pixel |
14+
| `float2array` | N | `.xy` per pixel |
15+
| `float3array` | N | `.xyz` per pixel |
16+
| `float4array` | N | all 4 per pixel |
17+
18+
## D2D Compute Shader Conventions
19+
20+
D2D evaluates compute effects in **tiles**. Key conventions:
21+
22+
- **`_TileOffset`** (int2): Auto-injected at cbuffer offset 0 in `CalculateThreadgroups`. Gives the tile's origin in the full image.
23+
- **`Source.GetDimensions()`**: Returns the full source image size (not tile size).
24+
- **`SampleLevel()`**: Must use normalized UVs via `SampleLevel()`. `Load()` is not available in D2D compute shaders.
25+
- **`Output.GetDimensions()`**: Returns the tile size, not the full image.
26+
- **Constant buffer upload**: Done in `CalculateThreadgroups` (not `PrepareForRender`) for correct per-tile values.
27+
28+
## Shader Pattern
29+
```hlsl
30+
cbuffer Constants : register(b0) {
31+
int2 _TileOffset; // Auto-injected per tile
32+
// User parameters here...
33+
};
34+
Texture2D Source : register(t0);
35+
RWTexture2D<float4> Output : register(u0);
36+
SamplerState Sampler0 : register(s0);
37+
38+
[numthreads(8, 8, 1)]
39+
void main(uint3 DTid : SV_DispatchThreadID) {
40+
uint srcW, srcH;
41+
Source.GetDimensions(srcW, srcH);
42+
uint2 globalPos = DTid.xy + uint2(_TileOffset);
43+
if (globalPos.x >= srcW || globalPos.y >= srcH) return;
44+
45+
float2 uv = (float2(globalPos) + 0.5) / float2(srcW, srcH);
46+
float4 color = Source.SampleLevel(Sampler0, uv, 0);
47+
Output[DTid.xy] = color;
48+
}
49+
```
50+
51+
52+
---
53+
54+
Back to [docs/](../README.md)[Repo root](../../README.md)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# D2D / D3D11 Hybrid Compute System
2+
3+
## Problem
4+
5+
D2D's custom compute shader API (`ID2D1ComputeTransform`) has fundamental limitations that prevent full-image reduction operations:
6+
7+
| Limitation | Impact |
8+
|-----------|--------|
9+
| **Per-tile UAV clearing** | D2D clears the output `RWTexture2D<float4>` before each tile dispatch. Scatter writes don't accumulate across tiles. |
10+
| **No custom UAV binding** | `ID2D1ComputeInfo::SetResourceTexture` binds read-only `ID2D1ResourceTexture` (register t), not UAVs (register u). |
11+
| **No uint atomics on output** | The output UAV is `RWTexture2D<float4>`. `InterlockedMin`/`InterlockedMax`/`InterlockedAdd` require `RWBuffer<uint>`. |
12+
| **No input as D3D11 texture** | `PrepareForRender` doesn't expose the input image as a D3D11 surface. The effect context is deliberately isolated from the device. |
13+
14+
The built-in `CLSID_D2D1Histogram` effect works around these via private D2D internals not exposed through the public API.
15+
16+
## Solution: Evaluator-Owned D3D11 Dispatch
17+
18+
The graph evaluator owns a **raw D3D11 compute dispatch path** that bypasses D2D's tiling entirely. D2D handles the effect graph wiring (input/output connections), while D3D11 handles the actual computation.
19+
20+
## COM Class Hierarchy
21+
22+
```mermaid
23+
classDiagram
24+
class ID2D1EffectImpl {
25+
<<interface>>
26+
+Initialize(effectContext, transformGraph)
27+
+PrepareForRender(changeType)
28+
+SetGraph(transformGraph)
29+
}
30+
31+
class ID2D1DrawTransform {
32+
<<interface>>
33+
+SetDrawInfo(drawInfo)
34+
+MapInputRectsToOutputRect()
35+
+MapOutputRectToInputRects()
36+
+MapInvalidRect()
37+
+GetInputCount()
38+
}
39+
40+
class D3D11ComputeRunner {
41+
<<RWStructuredBuffer<float4> path>>
42+
-ID3D11ComputeShader* m_shader
43+
-ID3D11Buffer* m_resultBuffer
44+
+CompileShader(hlsl)
45+
+Dispatch(input, cbuffer, resultCount) vector~float~
46+
}
47+
48+
class CustomPixelShaderEffect {
49+
<<PixelShader path>>
50+
+LoadShaderBytecode()
51+
+SetConstantBufferData()
52+
}
53+
54+
class CustomComputeShaderEffect {
55+
<<ComputeShader / D3D11ComputeShader paths>>
56+
+SetThreadGroupSize()
57+
+CalculateThreadgroups()
58+
}
59+
60+
ID2D1EffectImpl <|.. CustomPixelShaderEffect
61+
ID2D1DrawTransform <|.. CustomPixelShaderEffect
62+
ID2D1EffectImpl <|.. CustomComputeShaderEffect
63+
64+
note for CustomComputeShaderEffect "D2D-tiled compute\nUAV cleared per tile\nNo atomics\n+ D3D11 hybrid mode dispatched\n by GraphEvaluator via D3D11ComputeRunner"
65+
```
66+
67+
## Data Flow: D2D → D3D11 Handoff
68+
69+
```mermaid
70+
flowchart TD
71+
subgraph D2D_Graph["D2D Effect Graph (Evaluator)"]
72+
SRC[Source Node<br/>ID2D1Image*] --> FX[Upstream Effect<br/>Gamut Map / Delta E / etc.]
73+
FX --> CACHE["cachedOutput<br/>(deferred ID2D1Image*)"]
74+
end
75+
76+
subgraph Realize["Realize to D3D11 Texture"]
77+
CACHE --> CREATE["dc->CreateBitmap()<br/>DXGI_FORMAT_R32G32B32A32_FLOAT"]
78+
CREATE --> DRAW["dc->SetTarget(bitmap)<br/>dc->DrawImage(cachedOutput)<br/>dc->SetTarget(prev)"]
79+
DRAW --> FLUSH["dc->Flush()<br/>⚠ Required — D2D batches<br/>commands until Flush/EndDraw"]
80+
FLUSH --> SURFACE["bitmap->GetSurface()<br/>→ IDXGISurface"]
81+
SURFACE --> QI["surface->QueryInterface()<br/>→ ID3D11Texture2D"]
82+
end
83+
84+
subgraph D3D11_Compute["D3D11 Compute Dispatch"]
85+
QI --> SRV["CreateShaderResourceView()<br/>register(t0)"]
86+
SRV --> CBUF["Update Constant Buffer<br/>(Width, Height, Channel, NonzeroOnly)"]
87+
CBUF --> CLEAR["ClearUnorderedAccessViewUint()<br/>(reset result buffer)"]
88+
CLEAR --> DISPATCH["ctx->Dispatch(1, 1, 1)<br/>32×32 = 1024 threads"]
89+
end
90+
91+
subgraph GPU_Reduction["GPU Reduction (groupshared)"]
92+
DISPATCH --> STRIDE["Each thread strides<br/>across entire image"]
93+
STRIDE --> LOCAL["Per-thread accumulators<br/>min, max, sum, count"]
94+
LOCAL --> SHARED["groupshared parallel reduction<br/>log2(1024) = 10 steps"]
95+
SHARED --> WRITE["Thread 0 writes<br/>8 uints to RWBuffer"]
96+
end
97+
98+
subgraph Readback["Result Readback (32 bytes)"]
99+
WRITE --> COPY["CopyResource()<br/>→ staging buffer"]
100+
COPY --> MAP["Map() + read 8 uints"]
101+
MAP --> STATS["ImageStats struct<br/>min, max, mean, samples, nonzero"]
102+
STATS --> ANALYSIS["node.analysisOutput.fields<br/>(data pins on graph)"]
103+
end
104+
```
105+
106+
## Three Effect Types Compared
107+
108+
| | D2D Pixel Shader | D2D Compute Shader | D3D11 Hybrid Compute |
109+
|---|---|---|---|
110+
| **COM class** | `CustomPixelShaderEffect` | `CustomComputeShaderEffect` (D2D-tiled mode) | `CustomComputeShaderEffect` (D3D11 mode) |
111+
| **D2D interface** | `ID2D1DrawTransform` | `ID2D1ComputeTransform` | `ID2D1DrawTransform` (pass-through) |
112+
| **Shader target** | `ps_5_0` | `cs_5_0` | `cs_5_0` (dispatched by host) |
113+
| **Execution** | D2D renders directly | D2D dispatches per-tile | Evaluator dispatches via D3D11 |
114+
| **Tiling** | D2D-managed | D2D-managed (UAV cleared) | **None** — single dispatch |
115+
| **Atomics** | N/A | No (float4 UAV only) | **Yes** (RWStructuredBuffer / RWBuffer) |
116+
| **groupshared** | N/A | Yes (per-tile only) | **Yes** (full image) |
117+
| **Shader linking** | Yes (D2D optimizes) | No | No |
118+
| **Image output** | Yes | Yes | Optional (pass-through or none) |
119+
| **Analysis output** | Via pixel readback | Via pixel readback | Via `RWStructuredBuffer<float4> Result` |
120+
| **`CustomShaderType`** | `PixelShader` | `ComputeShader` | `D3D11ComputeShader` |
121+
122+
The `D3D11ComputeShader` mode is what powers Channel / Luminance / Chromaticity Statistics, the gamut analysis effects, and any user-authored "analyze the whole image" shader created via the Effect Designer. Internally it dispatches through `Rendering::D3D11ComputeRunner`.
123+
124+
## Usage: ShaderLab Evaluator (Optimized Path)
125+
126+
```cpp
127+
// In GraphEvaluator::ProcessDeferredCompute(), for D3D11ComputeShader nodes:
128+
129+
// 1. Render upstream D2D output to FP32 bitmap
130+
winrt::com_ptr<ID2D1Bitmap1> gpuTarget;
131+
dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, fp32Props, gpuTarget.put());
132+
winrt::com_ptr<ID2D1Image> prevTarget;
133+
dc->GetTarget(prevTarget.put());
134+
dc->SetTarget(gpuTarget.get());
135+
dc->Clear(D2D1::ColorF(0, 0, 0, 0));
136+
dc->DrawImage(upstreamNode->cachedOutput);
137+
dc->SetTarget(prevTarget.get());
138+
139+
// 2. Flush D2D command batch — CRITICAL for D2D→D3D11 handoff.
140+
// D2D batches DrawImage commands until EndDraw() or Flush().
141+
// Without this, D3D11 reads uninitialized zeros from the texture.
142+
dc->Flush();
143+
144+
// 3. Get D3D11 texture (zero-copy — same DXGI surface)
145+
winrt::com_ptr<IDXGISurface> surface;
146+
gpuTarget->GetSurface(surface.put());
147+
winrt::com_ptr<ID3D11Texture2D> d3dTexture;
148+
surface->QueryInterface(d3dTexture.put());
149+
150+
// 4. Dispatch GPU reduction (single call)
151+
auto stats = m_gpuReduction.Reduce(d3dCtx, d3dTexture.get(), channel, nonzeroOnly);
152+
153+
// 5. Populate analysis output for graph data pins
154+
node->analysisOutput.fields = { {"Min", stats.min}, {"Max", stats.max}, ... };
155+
```
156+
157+
## Known Limitations
158+
159+
- **D2D→D3D11 flush required**: When rendering a D2D effect chain to a bitmap and then reading it with D3D11, `dc->Flush()` **must** be called between `DrawImage` and any D3D11 access to the underlying texture. D2D batches draw commands until `EndDraw()` or `Flush()` — without an explicit flush, D3D11 reads zeros from the texture. Applied in `DispatchUserD3D11Compute` in `GraphEvaluator`.
160+
- **D2D draw session required**: `ProcessDeferredCompute` must run inside an active `BeginDraw`/`EndDraw` session because `DispatchUserD3D11Compute` calls `dc->DrawImage` internally to pre-render the upstream chain into an FP32 bitmap. Outside a draw session that DrawImage silently no-ops and the compute reads a black input texture. The GUI's `RenderFrame`, the headless host's `runEval` / `RunRender`, and the test bench all wrap the call accordingly.
161+
- **No shader linking**: D3D11 compute shaders are opaque to D2D. They don't participate in D2D's shader linking optimization for chained pixel shader effects.
162+
- **Single thread group per dispatch**: `D3D11ComputeRunner` dispatches `(1,1,1)` — one group of 1024 threads. For images larger than ~33 megapixels (1024² pixels per thread), a multi-dispatch pyramid would be needed.
163+
164+
165+
---
166+
167+
Back to [docs/](../README.md) • [Repo root](../../README.md)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Display Monitoring
2+
3+
```mermaid
4+
sequenceDiagram
5+
participant App as ShaderLab
6+
participant DXGI as DXGI Output
7+
participant DM as DisplayMonitor
8+
participant PF as PipelineFormat
9+
participant SC as SwapChain
10+
11+
App->>DXGI: IDXGIOutput6::GetDesc1()
12+
DXGI-->>App: DXGI_OUTPUT_DESC1
13+
App->>DM: Initialize(hWnd)
14+
DM->>DM: Register WM_DISPLAYCHANGE
15+
DM->>DM: Register IDXGIFactory7::RegisterAdaptersChangedEvent
16+
17+
Note over DM: Display change detected
18+
DM->>DXGI: Re-query IDXGIOutput6::GetDesc1()
19+
DXGI-->>DM: Updated capabilities
20+
DM->>PF: NotifyDisplayChanged(newCaps)
21+
PF->>SC: Recreate with new format if needed
22+
DM->>App: Update status bar
23+
```
24+
25+
## SDR white level
26+
27+
`DisplayCapabilities::sdrWhiteLevelNits` is queried from the OS via `DisplayConfigGetDeviceInfo(DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL)`, decoded as `nits = SDRWhiteLevel / 1000 * 80`. This value tracks the user's **Settings → Display → HDR → "SDR content brightness"** slider when HDR is on; when HDR is off it falls back to 80 nits.
28+
29+
The value is exposed to graphs through the **`Working Space` parameter node** (see [Working Space Integration](#working-space-integration)) on its `SdrWhiteNits` analysis output. Effects that need to know the nit value of scRGB 1.0 (the entire ICtCp suite) consume it via property bindings — wire `working_space.SdrWhiteNits` into the effect's nit-target parameter and it tracks both the OS slider and any simulated `DisplayProfile` preset automatically. There is no longer any per-effect "follow the live monitor" or "follow the working space" host-side plumbing; the Working Space node is the single explicit path.
30+
31+
32+
---
33+
34+
Back to [docs/](../README.md)[Repo root](../../README.md)

0 commit comments

Comments
 (0)