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
36 changes: 26 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,44 @@ jobs:

steps:
- name: Check out source
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: 9.0.x
cache: true
cache-dependency-path: src/Captail/Captail.csproj

- name: Cache OBS runtime
uses: actions/cache@v6
with:
path: runtime/obs
key: obs-32.1.2-windows-x64-${{ hashFiles('tools/AcquireObsRuntime.ps1') }}
cache-dependency-path: src/Captail/packages.lock.json

- name: Acquire minimal OBS runtime
shell: pwsh
run: ./tools/AcquireObsRuntime.ps1

- name: Build Release
shell: pwsh
run: dotnet build ./Captail.sln -c Release -p:ContinuousIntegrationBuild=true
run: |
dotnet restore ./src/Captail/Captail.csproj --locked-mode
dotnet build ./Captail.sln -c Release --no-restore `
-p:ContinuousIntegrationBuild=true

- name: Run full .NET analyzers
shell: pwsh
run: |
dotnet build ./src/Captail/Captail.csproj -c Debug --no-restore `
-p:EnableNETAnalyzers=true `
-p:AnalysisLevel=latest-all `
-p:AnalysisMode=All `
-p:WarningsAsErrors=CA1806%3BCA2000%3BCA2216%3BCA5392%3BCA5393

- name: Validate GPU capability model
shell: pwsh
run: |
$exe = Resolve-Path './src/Captail/bin/Debug/net9.0-windows10.0.22621.0/win-x64/Captail.exe'
$process = Start-Process -FilePath $exe -ArgumentList '--qa-capability-model' `
-Wait -PassThru
if ($process.ExitCode -ne 0) {
throw "Capability model QA failed: $($process.ExitCode)"
}

- name: Validate localization dictionaries
shell: pwsh
Expand Down
23 changes: 12 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,16 @@ jobs:
GH_TOKEN: ${{ github.token }}

- name: Check out source
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

- name: Set up .NET
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: 9.0.x
cache: true
cache-dependency-path: src/Captail/Captail.csproj

- name: Cache OBS runtime
uses: actions/cache@v6
with:
path: runtime/obs
key: obs-32.1.2-windows-x64-${{ hashFiles('tools/AcquireObsRuntime.ps1') }}
cache-dependency-path: src/Captail/packages.lock.json

- name: Acquire minimal OBS runtime
shell: pwsh
Expand All @@ -77,6 +71,12 @@ jobs:
$installDirectory = Join-Path $env:RUNNER_TEMP "innosetup7"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $installer

$expectedHash = "5ad54ca3def786f8f4212552e54cc6d8d61329e2d24a1cfee0571d42c2684ff1"
$actualHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash
if (-not $actualHash.Equals($expectedHash, [StringComparison]::OrdinalIgnoreCase)) {
throw "Inno Setup SHA-256 verification failed: $actualHash"
}

$signature = Get-AuthenticodeSignature -FilePath $installer
if ($signature.Status -ne "Valid" -or
$signature.SignerCertificate.Subject -notmatch "Pyrsys B\.V\.") {
Expand All @@ -103,6 +103,7 @@ jobs:
- name: Build release packages
shell: pwsh
run: |
dotnet restore ./src/Captail/Captail.csproj --locked-mode
./tools/BuildRelease.ps1 `
-Version $env:VERSION `
-OutputDirectory $env:RELEASE_DIR `
Expand Down Expand Up @@ -178,7 +179,7 @@ jobs:
}

- name: Upload workflow artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Captail-${{ inputs.version }}-win-x64
path: |
Expand All @@ -189,7 +190,7 @@ jobs:
retention-days: 30

- name: Attest release assets
uses: actions/attest@v4
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: |
${{ env.RELEASE_DIR }}/Captail-${{ inputs.version }}-Portable-win-x64.zip
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable user-facing changes are documented here.

## [Unreleased]

## [0.1.2] - 2026-07-22

### Fixed

- Settings, audio-source changes, replay toggles, and replay saves no longer block the UI while libobs starts, stops, or writes a replay.
- Failed settings changes now roll back configuration, hotkeys, autostart state, and the recording pipeline instead of leaving Captail partially configured.
- Rapid pipeline operations are serialized to prevent save, restart, watchdog recovery, and shutdown races.
- Configuration writes are atomic and automatically recover from the last valid backup after a damaged file.
- Game Capture now uses the selected game's client size for source resolution and avoids an unnecessary scene-composition pass.
- Installer upgrades request a graceful Captail shutdown and uninstall removes its startup entry.

### Changed

- Moved libobs lifetime operations to a dedicated thread and reduced synchronous disk, device, process, and log work on the UI thread.
- Hardened native library loading, OBS runtime acquisition, dependency locking, installer downloads, and GitHub Actions against dependency substitution.
- Added automated capability, codec, recovery, and high-frame-rate Game Capture diagnostics.
- Validated AV1 Game Capture at 2560x1440 and 240 unique frames per second on an NVIDIA GeForce RTX 5070, including concurrent synthetic GPU load.

## [0.1.1] - 2026-07-21

### Fixed
Expand Down
6 changes: 0 additions & 6 deletions Captail.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,18 @@ Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x64.ActiveCfg = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x64.Build.0 = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x86.ActiveCfg = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x86.Build.0 = Debug|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|Any CPU.Build.0 = Release|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x64.ActiveCfg = Release|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x64.Build.0 = Release|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x86.ActiveCfg = Release|Any CPU
{FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
25 changes: 25 additions & 0 deletions installer/Captail.iss
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,28 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType:

[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch Captail"; Flags: nowait postinstall skipifsilent

[Code]
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
InstalledExe: String;
begin
Result := '';
InstalledExe := ExpandConstant('{app}\{#MyAppExeName}');
if FileExists(InstalledExe) then
begin
if not Exec(InstalledExe, '--shutdown-existing', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode) then
Result := 'Could not ask Captail to stop before updating.'
else if ResultCode <> 0 then
Result := 'Captail is still busy. Wait for replay saving to finish, then retry.';
end;
end;

procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
RegDeleteValue(HKCU,
'Software\Microsoft\Windows\CurrentVersion\Run', 'Captail');
end;
18 changes: 9 additions & 9 deletions native/ObsBridge/CaptailObsBridge.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <Windows.h>
#include <atomic>
#include <cstdarg>
#include <cstdio>

Expand All @@ -7,23 +8,22 @@ using obs_log_handler = void(__cdecl *)(int level, const char *format, va_list a
using base_set_log_handler_proc = void(__cdecl *)(obs_log_handler handler, void *context);

namespace {
managed_log_callback callback = nullptr;
std::atomic<managed_log_callback> callback{nullptr};

void __cdecl obs_log(int level, const char *format, va_list args, void *)
{
if (!callback || !format)
managed_log_callback current = callback.load(std::memory_order_acquire);
if (!current || !format)
return;

char message[8192]{};
vsnprintf_s(message, sizeof(message), _TRUNCATE, format, args);
callback(level, message);
current(level, message);
}

base_set_log_handler_proc get_setter()
{
HMODULE obs = GetModuleHandleW(L"obs.dll");
if (!obs)
obs = LoadLibraryW(L"obs.dll");
return obs
? reinterpret_cast<base_set_log_handler_proc>(
GetProcAddress(obs, "base_set_log_handler"))
Expand All @@ -32,20 +32,20 @@ base_set_log_handler_proc get_setter()
} // namespace

extern "C" __declspec(dllexport) bool __cdecl
everloop_install_obs_log_handler(managed_log_callback managed_callback)
captail_install_obs_log_handler(managed_log_callback managed_callback)
{
auto setter = get_setter();
if (!setter)
return false;
callback = managed_callback;
callback.store(managed_callback, std::memory_order_release);
setter(obs_log, nullptr);
return true;
}

extern "C" __declspec(dllexport) void __cdecl everloop_remove_obs_log_handler()
extern "C" __declspec(dllexport) void __cdecl captail_remove_obs_log_handler()
{
auto setter = get_setter();
if (setter)
setter(nullptr, nullptr);
callback = nullptr;
callback.store(nullptr, std::memory_order_release);
}
63 changes: 54 additions & 9 deletions native/ObsCaptureFixture/ObsCaptureFixture.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#include <Windows.h>
#include <chrono>
#include <d3d11.h>
#include <dxgi.h>
#include <wrl/client.h>
#include <thread>

using Microsoft::WRL::ComPtr;

Expand All @@ -17,8 +17,16 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lp
}
}

int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
int WINAPI wWinMain(
_In_ HINSTANCE instance,
_In_opt_ HINSTANCE,
_In_ PWSTR,
_In_ int show)
{
constexpr int client_width = 2560;
constexpr int client_height = 1440;
constexpr DWORD window_style = WS_POPUP;

WNDCLASSW window_class{};
window_class.hInstance = instance;
window_class.lpfnWndProc = window_proc;
Expand All @@ -27,15 +35,19 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
if (!RegisterClassW(&window_class))
return 1;

RECT window_rect{0, 0, client_width, client_height};
if (!AdjustWindowRect(&window_rect, window_style, FALSE))
return 2;

HWND window = CreateWindowExW(
0,
WS_EX_TOPMOST,
window_class.lpszClassName,
L"Captail OBS Capture Fixture",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
1280,
720,
window_style,
0,
0,
window_rect.right - window_rect.left,
window_rect.bottom - window_rect.top,
nullptr,
nullptr,
instance,
Expand Down Expand Up @@ -80,8 +92,20 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
back_buffer.Get(), nullptr, &render_target)))
return 4;

D3D11_TEXTURE2D_DESC stress_desc{};
back_buffer->GetDesc(&stress_desc);
stress_desc.BindFlags = 0;
stress_desc.MiscFlags = 0;
ComPtr<ID3D11Texture2D> stress_texture;
if (FAILED(device->CreateTexture2D(&stress_desc, nullptr, &stress_texture)))
return 5;

ShowWindow(window, show);
uint32_t frame = 0;
constexpr auto frame_interval = std::chrono::nanoseconds(1'000'000'000 / 240);
auto next_frame = std::chrono::steady_clock::now();
auto measurement_start = next_frame;
uint32_t measurement_frames = 0;
MSG message{};
while (message.message != WM_QUIT) {
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
Expand All @@ -98,8 +122,29 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
1.0f,
};
context->ClearRenderTargetView(render_target.Get(), color);
for (int pass = 0; pass < 32; pass++) {
context->CopyResource(stress_texture.Get(), back_buffer.Get());
context->CopyResource(back_buffer.Get(), stress_texture.Get());
}
swap_chain->Present(0, DXGI_PRESENT_ALLOW_TEARING);
std::this_thread::yield();
measurement_frames++;
const auto measurement_now = std::chrono::steady_clock::now();
const auto measurement_elapsed = measurement_now - measurement_start;
if (measurement_elapsed >= std::chrono::seconds(1)) {
const double measured_fps = measurement_frames /
std::chrono::duration<double>(measurement_elapsed).count();
wchar_t title[96]{};
swprintf_s(title, L"Captail Capture Fixture - %.1f FPS", measured_fps);
SetWindowTextW(window, title);
measurement_start = measurement_now;
measurement_frames = 0;
}
next_frame += frame_interval;
while (std::chrono::steady_clock::now() < next_frame)
YieldProcessor();
auto now = std::chrono::steady_clock::now();
if (now - next_frame > frame_interval)
next_frame = now;
}
return 0;
}
Loading
Loading