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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ TODO.local
# Claude Code
.claude/
CLAUDE.md
AGENTS.md
**/AGENTS.md

# Sanitizer outputs
*.asan
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- [x] 1.1 Add `headless` (bool), `frames` (uint32_t), and `output_path` (std::filesystem::path) fields to `CliOptions` in `src/app/cli.hpp`
- [x] 1.2 Register `--headless`, `--frames`, and `--output` with CLI11 in `src/app/cli.cpp`; validate that `--frames` and `--output` are both present when `--headless` is set, returning an error otherwise
- [ ] 1.3 Verify: `goggles --headless --frames 5 --output /tmp/x.png --help` prints new flags; `goggles --headless --frames 5 -- app` exits with non-zero and descriptive message about missing `--output`
- [x] 1.3 Verify: `goggles --headless --frames 5 --output /tmp/x.png --help` prints new flags; `goggles --headless --frames 5 -- app` exits with non-zero and descriptive message about missing `--output`

## 2. VulkanBackend Surfaceless Factory

Expand All @@ -20,7 +20,7 @@

- [x] 4.1 Add `auto readback_to_png(std::filesystem::path output) -> tl::expected<void, Error>` declaration to `vulkan_backend.hpp`
- [x] 4.2 Implement `readback_to_png()`: allocate host-visible staging buffer, record commands to transition offscreen image `eColorAttachmentOptimal → eTransferSrcOptimal`, `vkCmdCopyImageToBuffer`, transition back; submit + fence wait; if memory is not host-coherent call `vkInvalidateMappedMemoryRanges`; map buffer, call `stbi_write_png`; check return value and propagate failure as `tl::expected` error; unmap + destroy staging buffer via RAII
- [ ] 4.3 Verify: running the full pipeline with a known test client produces a non-empty PNG at the specified output path
- [x] 4.3 Verify: running the full pipeline with a known test client produces a non-empty PNG at the specified output path

## 5. Application Headless Path

Expand All @@ -32,12 +32,12 @@

- [x] 6.1 In the headless path in `main.cpp`, open a `signalfd` for `SIGTERM` and `SIGINT` (block both signals first via `sigprocmask`); wrap the fd in `goggles::util::UniqueFd`
- [x] 6.2 In `run_headless()`, poll the signalfd with zero timeout each tick; on signal receipt, log the signal, terminate the child, and return an `Error` causing main to exit with non-zero code
- [ ] 6.3 Verify: `kill -TERM <pid>` while goggles runs in headless mode causes clean shutdown with child process terminated
- [x] 6.3 Verify: `kill -TERM <pid>` while goggles runs in headless mode causes clean shutdown with child process terminated

## 7. Tests

- [x] 7.1 Add CLI parsing tests in `tests/app/test_cli.cpp`: `--headless --frames 10 --output /tmp/x.png` parses correctly; `--headless --frames 10` (missing `--output`) returns error
- [ ] 7.2 Add a CTest integration test (disabled in CI via `DEFINED ENV{CI}` guard) that runs `goggles --headless --frames 3 --output /tmp/goggles_headless_test.png -- <solid_color_client>` and verifies exit code 0 and file exists
- [x] 7.2 Add a CTest integration test (disabled in CI via `DEFINED ENV{CI}` guard) that runs `goggles --headless --frames 3 --output /tmp/goggles_headless_test.png -- <solid_color_client>` and verifies exit code 0 and file exists

## 8. Verification

Expand Down
80 changes: 77 additions & 3 deletions openspec/specs/app-window/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@
TBD - created by archiving change add-sdl3-window-test. Update Purpose after archive.
## Requirements
### Requirement: SDL3 Window Creation
The application SHALL create an SDL3 window with Vulkan support enabled on startup.
The application SHALL create an SDL3 window with Vulkan support enabled on startup **unless `--headless` is active**, in which case SDL SHALL NOT be initialized and no window SHALL be created.

#### Scenario: Window creation success
- **GIVEN** SDL3 is properly initialized
- **WHEN** the application starts
- **WHEN** the application starts without `--headless`
- **THEN** a window titled "Goggles" SHALL be created
- **AND** the window SHALL have the Vulkan flag set

#### Scenario: SDL3 initialization failure
- **GIVEN** SDL3 cannot be initialized
- **WHEN** the application starts
- **WHEN** the application starts without `--headless`
- **THEN** an error SHALL be logged
- **AND** the application SHALL exit with a non-zero code

#### Scenario: Headless mode skips SDL entirely
- **GIVEN** the application is launched with `--headless`
- **WHEN** initialization runs
- **THEN** `SDL_Init` SHALL NOT be called
- **AND** no SDL window SHALL be created

### Requirement: Window Event Loop
The application SHALL run an event loop that processes window events until the user closes the window.

Expand Down Expand Up @@ -141,3 +147,71 @@ selection for multi-GPU systems.
- **WHEN** the application selects a GPU
- **THEN** it SHALL prefer a GPU that supports presenting to the current surface
- **AND** it SHALL log all available GPUs with their indices

### Requirement: Headless Mode CLI Flags

The application SHALL accept `--headless`, `--frames <N>`, and `--output <path>` as top-level CLI flags. `--frames` and `--output` are required when `--headless` is present; providing either without the other SHALL produce an error.

#### Scenario: Valid headless invocation
- **WHEN** run with `--headless --frames 10 --output /tmp/frame.png -- ./app`
- **THEN** `CliOptions.headless` SHALL be `true`, `frames` SHALL be `10`, `output_path` SHALL be `/tmp/frame.png`

#### Scenario: --frames without --output
- **WHEN** run with `--headless --frames 10 -- ./app` and `--output` is absent
- **THEN** the application SHALL print a descriptive error and exit with a non-zero code

### Requirement: Filter Chain Control Scope and Precedence
The application UI SHALL expose three filter-related controls with distinct scope and precedence:
- `Application -> Window Management -> Filter Chain (All Surfaces)` controls global prechain/effect enablement.
- `Application -> Window Management -> Surface List` controls per-surface prechain/effect enablement.
- `Shader Controls -> Effect Stage (RetroArch) -> Enable Shader` controls effect stage only.

The application SHALL resolve an effective runtime policy that applies precedence as:
1) global toggle,
2) per-surface toggle,
3) effect-stage toggle.

The application SHALL dispatch the resolved policy through a single runtime update path so prechain
and effect stage updates occur together.

For first-time surface discovery, the application SHALL use deterministic defaulting rules:
- In direct Vulkan capture sessions, newly discovered active Vulkan-target surfaces default to
filter-chain enabled.
- Once the user toggles a surface, the user choice SHALL be preserved and SHALL NOT be overwritten
by subsequent auto-default evaluation.

When a direct Vulkan capture session initializes prechain defaults and no explicit prechain target
is configured, the application SHALL initialize prechain target from viewer swapchain extent.

#### Scenario: Global toggle disables all surfaces
- **GIVEN** the Window Management panel is visible
- **WHEN** the user disables `Filter Chain (All Surfaces)`
- **THEN** subsequent frames SHALL bypass prechain and effect stages for all surfaces

#### Scenario: Per-surface toggle applies when global is enabled
- **GIVEN** `Filter Chain (All Surfaces)` is enabled
- **WHEN** the user disables a surface entry in Surface List
- **THEN** subsequent frames for that surface SHALL bypass prechain and effect stages
- **AND** other enabled surfaces SHALL continue using prechain/effect

#### Scenario: Effect toggle does not disable prechain
- **GIVEN** global and per-surface toggles are enabled
- **WHEN** the user disables `Enable Shader`
- **THEN** subsequent frames SHALL bypass effect stage only
- **AND** prechain behavior SHALL remain controlled by global/per-surface toggles

#### Scenario: Runtime updates are applied atomically
- **GIVEN** any toggle transition changes effective stage policy
- **WHEN** the application dispatches runtime state to the backend
- **THEN** prechain and effect stage updates SHALL be applied together in one policy update

#### Scenario: First discovery defaults ON for direct Vulkan sessions
- **GIVEN** a direct Vulkan capture session and a newly discovered active surface
- **WHEN** the surface appears in Surface List without user override
- **THEN** its per-surface filter toggle SHALL default to enabled

Comment on lines +208 to +212
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 | 🟠 Major

Surface defaulting rule conflicts with current runtime behavior.

This scenario states newly discovered direct-Vulkan surfaces default to enabled, but runtime logic currently initializes new surface filter state to disabled (src/app/application.cpp, Line 515). Please align spec or implementation to a single rule.

#### Scenario: User override remains authoritative
- **GIVEN** a surface has been manually toggled by the user
- **WHEN** the surface list is refreshed or source timing changes
- **THEN** the surface toggle SHALL keep the user-selected value

104 changes: 104 additions & 0 deletions openspec/specs/headless-mode/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# headless-mode Specification

## Purpose
TBD - created by archiving change add-headless-mode. Update Purpose after archive.
## Requirements
### Requirement: Headless CLI Flags
The application SHALL accept `--headless`, `--frames <N>`, and `--output <path>` as CLI flags. When `--headless` is present, `--frames` and `--output` MUST both be provided; missing either SHALL produce a descriptive error and exit with a non-zero code.

#### Scenario: All three flags provided
- **WHEN** the application is run with `--headless --frames 10 --output /tmp/frame.png -- ./app`
- **THEN** `CliOptions.headless` SHALL be `true`, `frames` SHALL be `10`, and `output_path` SHALL be `/tmp/frame.png`

#### Scenario: Missing --output with --headless
- **WHEN** the application is run with `--headless --frames 10 -- ./app` without `--output`
- **THEN** the application SHALL print a descriptive error message
- **AND** SHALL exit with a non-zero code

#### Scenario: Missing --frames with --headless
- **WHEN** the application is run with `--headless --output /tmp/frame.png -- ./app` without `--frames`
- **THEN** the application SHALL print a descriptive error message
- **AND** SHALL exit with a non-zero code

### Requirement: Headless Initialization Skips SDL and ImGui
When `--headless` is set, the application SHALL NOT initialize SDL, create a window, or initialize the ImGui layer. The `CompositorServer` SHALL be initialized and operational.

#### Scenario: No SDL window in headless mode
- **GIVEN** the application is launched with `--headless`
- **WHEN** initialization completes
- **THEN** no SDL window SHALL exist
- **AND** no ImGui context SHALL be created
- **AND** `CompositorServer` SHALL report a valid Wayland display name

#### Scenario: Child app receives Wayland display
- **GIVEN** the application is launched with `--headless -- ./test_app`
- **WHEN** the child process is spawned
- **THEN** `WAYLAND_DISPLAY` SHALL be set in the child's environment to the compositor's socket

### Requirement: Surfaceless VulkanBackend for Headless Mode
When initialized for headless mode, `VulkanBackend` SHALL NOT create a `vk::SurfaceKHR`, swapchain, or present-related semaphores. It SHALL allocate an offscreen `vk::Image` with format `eR8G8B8A8Unorm` and usage flags `eColorAttachment | eTransferSrc` as the sole render target.

#### Scenario: Headless factory creates no surface
- **GIVEN** `VulkanBackend::create_headless()` is called
- **WHEN** initialization completes
- **THEN** no `vk::SurfaceKHR` SHALL exist
- **AND** no swapchain SHALL exist
- **AND** the offscreen image SHALL be allocated with format `eR8G8B8A8Unorm`

#### Scenario: Physical device selection without present queue
- **GIVEN** headless mode is active
- **WHEN** a physical device is selected
- **THEN** the selection SHALL require DMA-BUF and external memory extensions
- **AND** SHALL NOT require surface present support

### Requirement: Headless Render Loop
In headless mode, the application SHALL run a loop that consumes compositor frames via `get_presented_frame()`, imports each DMA-BUF into `VulkanBackend`, records and submits render commands into the offscreen image, and waits on a fence before the next frame. The loop SHALL exit when the configured number of frames have been rendered.

#### Scenario: N frames rendered then exit
- **GIVEN** the application is launched with `--headless --frames 5 --output /tmp/out.png -- ./app`
- **WHEN** 5 compositor frames have been delivered and rendered
- **THEN** the application SHALL call `readback_to_png` and write the PNG
- **AND** SHALL exit with code 0

#### Scenario: No vkQueuePresentKHR called
- **GIVEN** headless mode is active
- **WHEN** a frame is rendered
- **THEN** `vkQueuePresentKHR` SHALL NOT be called
- **AND** the render fence SHALL be waited on synchronously before the next frame

### Requirement: PNG Readback and Export
After rendering the final frame, `VulkanBackend` SHALL read back the offscreen image to CPU memory using a staging buffer and write a PNG file to the configured output path using `stb_image_write_png`. The operation SHALL return `tl::expected<void, Error>`; write failure SHALL propagate as an error.

#### Scenario: Successful PNG write
- **GIVEN** headless rendering of N frames is complete
- **WHEN** `readback_to_png(output_path)` is called
- **THEN** a valid PNG file SHALL exist at `output_path`
- **AND** the image dimensions SHALL match the configured compositor output resolution

#### Scenario: PNG write failure propagated
- **GIVEN** `output_path` is in a non-writable directory
- **WHEN** `readback_to_png(output_path)` is called
- **THEN** the function SHALL return an `Error` describing the failure
- **AND** the application SHALL exit with a non-zero code

#### Scenario: Image layout transition before readback
- **WHEN** `readback_to_png` is called after rendering
- **THEN** the offscreen image SHALL be transitioned from `eColorAttachmentOptimal` to `eTransferSrcOptimal` before `vkCmdCopyImageToBuffer`
- **AND** the staging buffer memory SHALL be invalidated before CPU read if the memory type is not host-coherent

### Requirement: Signal-Based Shutdown in Headless Mode
In headless mode, the application SHALL handle `SIGTERM` and `SIGINT` via `signalfd`. On signal receipt the run loop SHALL exit cleanly, child processes SHALL be terminated with the same SIGTERM→SIGKILL escalation used in windowed mode, and all Vulkan resources SHALL be released before process exit.

#### Scenario: SIGTERM triggers clean shutdown
- **GIVEN** the application is running in headless mode
- **WHEN** `SIGTERM` is delivered to the goggles process
- **THEN** the run loop SHALL exit at the next tick
- **AND** the child process SHALL receive SIGTERM
- **AND** the application SHALL exit with a non-zero code indicating signal termination

#### Scenario: Child exit triggers headless shutdown
- **GIVEN** the application is running in headless mode with a child app
- **WHEN** the child process exits before N frames are rendered
- **THEN** the run loop SHALL exit
- **AND** the application SHALL exit with a non-zero code

Loading
Loading