diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..6c8fe4bb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{c,h,m,mm,cpp,hpp,json,cmake,md,ps1,js}] +trim_trailing_whitespace = true diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml new file mode 100644 index 00000000..3f51e5d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -0,0 +1,38 @@ +name: ✨ Feature Request ✨ +description: "💡 Got a great idea? Share your creativity with us!" +title: "[✨Feature Request]: " +labels: ['enhancement'] +body: + - type: input + id: problem + attributes: + label: 🌈 Feature You'd Like + description: "Briefly describe the feature you'd like to add or improve. Let's make the software even better together!" + validations: + required: false + - type: textarea + id: way-to-solve + attributes: + label: 🚀 Ideal Solution + description: "What does your perfect solution look like? Feel free to use your imagination - don't worry about technical details!" + validations: + required: true + - type: textarea + id: instead + attributes: + label: 🤔 Alternative Solutions (Optional) + description: "If you have other ideas, please share them here. The more, the merrier!" + validations: + required: false + - type: checkboxes + id: "latest-version" + attributes: + label: "✅ Final Step" + description: "Before submitting your brilliant idea, please confirm the following:" + options: + - label: "🌟 I've checked the latest version and confirmed my requested feature isn't already implemented." + required: true + - label: "📋 I've reviewed existing feature requests to make sure this isn't a duplicate." + required: true + - label: "🌍 I've written this request in English so it can be understood by the wider community." + required: true diff --git a/.github/ISSUE_TEMPLATE/2_bug_report.yml b/.github/ISSUE_TEMPLATE/2_bug_report.yml new file mode 100644 index 00000000..62b72307 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_bug_report.yml @@ -0,0 +1,57 @@ +name: 🐛 Bug Report 🐛 +description: "🛠️ Found a problem? Let us know here!" +title: "[🐛Bug]: " +labels: ["bug"] +body: + - type: input + id: "expectation" + attributes: + label: "✨ What You Expected" + description: "How should the software work? Describe what you expected to happen." + validations: + required: false + - type: textarea + id: "describe-the-bug" + attributes: + label: "❓ What Actually Happened" + description: "Oops! Something went wrong? Please tell us what you saw - the more details, the better!" + validations: + required: true + - type: textarea + id: "reproduction-method" + attributes: + label: "📝 Steps to Reproduce the Issue" + description: "Can you tell us how you encountered this bug? List the steps so we can find the problem faster." + placeholder: "For example:\n1. Used what feature\n2. Then this happened...\n3. Oops! Problem occurred!" + validations: + required: false + - type: dropdown + id: "platform" + attributes: + label: "📌 Platform" + description: "Which platform are you using? For Linux, please select your desktop environment." + options: + - "Windows" + - "macOS" + - "Linux - GNOME" + - "Linux - KDE Plasma" + - "Linux - Xfce" + - "Linux - Cinnamon" + - "Linux - MATE" + - "Linux - LXQt" + - "Linux - Niri" + - "Linux - Other desktop environment" + validations: + required: true + - type: checkboxes + id: "latest-version" + attributes: + label: "✅ Final Checklist" + description: "Before submitting your bug report, please confirm the following:" + options: + - label: "⚡ I've confirmed I'm using the latest version, not an older one." + required: true + - label: "📋 I've checked the FAQ and known issues list to confirm this is a new bug." + required: true + - label: "🌍 This report is written in English so that more members of the community can read and understand it." + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..577d3745 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 Questions & Discussions 💬 + url: https://github.com/vladelaina/BongoCat/discussions + about: "🤝 Have a question or want to chat? Join our friendly community discussions!" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..2fece481 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Ciallo~(∠・ω<)⌒★ + +### 🎉 You are awesome! + +Thank you for taking the time to make this project better! We truly value your contribution.💕 + +--- + +### 📝 What's changed? +*(Briefly describe what you did or what problem you fixed)* + +> + +--- + +### 🤝 Don't worry about perfection + +**We value your ideas and participation more than perfect code!** + +If there are any style, testing, or detail issues, **we will help fix and polish them**. Just submit it, and we'll handle the rest! + +--- + +*Thanks again! Happy to build this with you! 🙌* diff --git a/.github/gcc-problem-matcher.json b/.github/gcc-problem-matcher.json index 7e293929..821ede71 100644 --- a/.github/gcc-problem-matcher.json +++ b/.github/gcc-problem-matcher.json @@ -1,7 +1,7 @@ { "problemMatcher": [ { - "owner": "bongo-cat-gcc", + "owner": "bongocat-gcc", "pattern": [ { "regexp": "^(.+?):(\\d+):(\\d+):\\s+(warning|error|fatal error):\\s+(.*)$", diff --git a/.github/scripts/__pycache__/scan-virustotal.cpython-314.pyc b/.github/scripts/__pycache__/scan-virustotal.cpython-314.pyc new file mode 100644 index 00000000..368c26ac Binary files /dev/null and b/.github/scripts/__pycache__/scan-virustotal.cpython-314.pyc differ diff --git a/.github/scripts/__pycache__/watch-virustotal.cpython-314.pyc b/.github/scripts/__pycache__/watch-virustotal.cpython-314.pyc new file mode 100644 index 00000000..48f5dedb Binary files /dev/null and b/.github/scripts/__pycache__/watch-virustotal.cpython-314.pyc differ diff --git a/.github/scripts/build-unix.sh b/.github/scripts/build-unix.sh index 123aaf46..f6e76570 100644 --- a/.github/scripts/build-unix.sh +++ b/.github/scripts/build-unix.sh @@ -2,7 +2,7 @@ set -uo pipefail -log="${RUNNER_TEMP:-.}/bongo-cat-native-build.log" +log="${RUNNER_TEMP:-.}/bongocat-native-build.log" set +e "$@" 2>&1 | tee "$log" status=${PIPESTATUS[0]} diff --git a/.github/scripts/build-windows.ps1 b/.github/scripts/build-windows.ps1 deleted file mode 100644 index 8165f4df..00000000 --- a/.github/scripts/build-windows.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -param( - [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] - [string[]]$BuildCommand -) - -$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { '.' } -$log = Join-Path $tempRoot 'bongo-cat-native-build.log' -$executable = $BuildCommand[0] -$arguments = if ($BuildCommand.Count -gt 1) { $BuildCommand[1..($BuildCommand.Count - 1)] } else { @() } - -& $executable @arguments 2>&1 | Tee-Object -FilePath $log -$status = $LASTEXITCODE -if ($status -eq 0) { exit 0 } - -$pattern = 'FAILED:|fatal error|(?:warning|error) [A-Z]+\d+:|LNK\d+|MSB\d+: error|unresolved external|cannot open file|ninja: build stopped' -Get-Content -LiteralPath $log | - Where-Object { $_ -match $pattern } | - Select-Object -Last 30 | - ForEach-Object { - $message = $_.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') - Write-Output "::error title=Windows build failure::$message" - } -exit $status diff --git a/.github/scripts/check-appimage.sh b/.github/scripts/check-appimage.sh new file mode 100644 index 00000000..b7bfcc1a --- /dev/null +++ b/.github/scripts/check-appimage.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +appimage=$(realpath "${1:?Usage: check-appimage.sh APPIMAGE}") +test_runner=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/test-unix.sh +work=$(mktemp -d) +trap 'rm -rf -- "$work"' EXIT +cd "$work" +chmod +x "$appimage" +"$appimage" --appimage-extract > /dev/null +root="$work/squashfs-root" +for file in AppRun bongocat.desktop bongocat.png usr/bin/BongoCat \ + usr/bin/assets/bongocat.png usr/bin/assets/locales/en-US.json \ + usr/bin/assets/models/standard/cat.model3.json \ + usr/bin/assets/models/standard/demomodel.moc3 \ + usr/bin/assets/models/standard/demomodel.1024/texture_00.png \ + usr/bin/assets/FrameworkShaders/VertShaderSrc.vert \ + usr/bin/assets/FrameworkShaders/FragShaderSrc.frag \ + usr/bin/assets/FrameworkShaders/VertShaderSrcBlend.vert \ + usr/bin/assets/FrameworkShaders/FragShaderSrcBlend.frag; do + test -s "$root/$file" || { echo "Missing AppImage resource: $file" >&2; exit 1; } +done +# Exercise the actual AppImage entry point without requiring a FUSE mount. +bash "$test_runner" env APPIMAGE_EXTRACT_AND_RUN=1 \ + BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 "$appimage" \ + --ci-smoke --ci-ignore-global-input \ + --ci-live2d-scenario=visual-consistency "--storage-root=$work/smoke-data" +mapfile -d '' audits < <(find "$work/smoke-data" -name live2d-audit.txt -print0) +[[ ${#audits[@]} == 1 ]] +grep -qx 'renderer=cubism-native' "${audits[0]}" +grep -qx 'assertions=passed' "${audits[0]}" +echo 'AppImage layout and native Live2D smoke test passed.' diff --git a/.github/scripts/check-publish-guards.ps1 b/.github/scripts/check-publish-guards.ps1 new file mode 100644 index 00000000..19aebf86 --- /dev/null +++ b/.github/scripts/check-publish-guards.ps1 @@ -0,0 +1,149 @@ +[CmdletBinding()] +param([switch]$SelfTest) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$upstreamGuards = @( + "github.repository == 'vladelaina/BongoCat'" +) +$workflowDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'workflows' +$sensitiveMarkers = @( + 'build-store-package.ps1', + 'CUBISM_SDK_ARCHIVE_URL', + 'VIRUSTOTAL_API_KEY', + 'actions/upload-artifact', + 'softprops/action-gh-release' +) +$runtimeArtifactMarkers = @( + 'build-store-package.ps1', + 'Upload desktop release', + 'Upload Microsoft Store MSIX', + 'Upload Windows desktop release', + 'Upload Windows portable release', + 'name: bongocat-release-', + 'softprops/action-gh-release' +) + +function Get-PublishingGuardFailures { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Label + ) + + $jobsStart = $Text.IndexOf("jobs:", [StringComparison]::Ordinal) + if ($jobsStart -lt 0) { return @() } + $jobsText = $Text.Substring($jobsStart) + $jobPattern = '(?ms)^ ([A-Za-z0-9_-]+):\s*\r?\n' + + '(.*?)(?=^ [A-Za-z0-9_-]+:\s*\r?\n|\z)' + $failures = @() + $jobMatches = [regex]::Matches($jobsText, $jobPattern) + $buildJob = @($jobMatches | Where-Object { + $_.Groups[1].Value -eq 'build' + } | Select-Object -First 1) + $cubismRequirementMarkers = @( + '-DBONGO_CAT_REQUIRE_CUBISM=ON', + '-RequireCubism' + ) + $protectedBuild = $buildJob.Count -eq 1 -and @( + $cubismRequirementMarkers | Where-Object { + $buildJob[0].Value.Contains($_) + } + ).Count -gt 0 + + foreach ($job in $jobMatches) { + $jobName = $job.Groups[1].Value + $jobText = $job.Value + $matched = @($sensitiveMarkers | Where-Object { $jobText.Contains($_) }) + if ($matched.Count -eq 0) { continue } + $guarded = @($upstreamGuards | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + if (-not $guarded) { + $failures += "$Label`: job '$jobName' lacks upstream guard; " + + "matched $($matched -join ', ')" + } + $publishesRuntimeArtifacts = @($runtimeArtifactMarkers | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + $usesProtectedBuild = $protectedBuild -and $jobText -match + '(?m)^\s+needs:\s*(?:build|\[[^\]]*\bbuild\b[^\]]*\])\s*$' + $hasCubismRequirement = @( + $cubismRequirementMarkers | Where-Object { + $jobText.Contains($_) + } + ).Count -gt 0 + if ($publishesRuntimeArtifacts -and + -not $hasCubismRequirement -and + -not $usesProtectedBuild) { + $failures += "$Label`: job '$jobName' publishes artifacts without " + + 'requiring a Cubism SDK build' + } + } + return $failures +} + +$workflowPaths = @( + Get-ChildItem -LiteralPath $workflowDirectory -File | + Where-Object { $_.Extension -in @('.yml', '.yaml') } | + Sort-Object FullName +) +$failures = @() +$checkedJobs = 0 +$selfTestText = $null + +foreach ($path in $workflowPaths) { + $text = Get-Content -LiteralPath $path.FullName -Raw + $pathFailures = @(Get-PublishingGuardFailures -Text $text -Label $path.Name) + $failures += $pathFailures + $jobsStart = $text.IndexOf("jobs:", [StringComparison]::Ordinal) + if ($jobsStart -ge 0) { + $jobsText = $text.Substring($jobsStart) + $checkedJobs += @([regex]::Matches($jobsText, + '(?ms)^ ([A-Za-z0-9_-]+):\s*\r?\n(.*?)(?=^ [A-Za-z0-9_-]+:\s*\r?\n|\z)') | + Where-Object { + $jobText = $_.Value + @($sensitiveMarkers | Where-Object { + $jobText.Contains($_) + }).Count -gt 0 + }).Count + } + if (-not $selfTestText -and + ($sensitiveMarkers | Where-Object { $text.Contains($_) })) { + $selfTestText = $text + } +} + +if ($SelfTest) { + if (-not $selfTestText) { + $failures += 'Self-test could not find a sensitive workflow.' + } + else { + $mutated = $selfTestText + foreach ($guard in $upstreamGuards) { + $mutated = $mutated.Replace($guard, 'true') + } + $caught = @(Get-PublishingGuardFailures -Text $mutated -Label 'self-test') + if ($caught.Count -eq 0) { + $failures += 'Self-test did not reject a removed upstream guard.' + } + $withoutCubism = $selfTestText.Replace( + '-DBONGO_CAT_REQUIRE_CUBISM=ON', + '-DBONGO_CAT_REQUIRE_CUBISM=OFF').Replace( + '-RequireCubism', '') + $cubismFailures = @(Get-PublishingGuardFailures ` + -Text $withoutCubism -Label 'self-test') + if (-not ($cubismFailures -match 'requiring a Cubism SDK build')) { + $failures += 'Self-test did not reject an SDK-free artifact job.' + } + } +} + +if ($failures.Count -gt 0) { + Write-Error ("Publishing guard policy failed:`n- " + + ($failures -join "`n- ")) + exit 1 +} + +Write-Host "Publishing guard policy passed for $checkedJobs sensitive job(s)." +if ($SelfTest) { Write-Host 'Publishing guard negative self-tests passed.' } diff --git a/.github/scripts/check-unix-package.ps1 b/.github/scripts/check-unix-package.ps1 new file mode 100644 index 00000000..973cea93 --- /dev/null +++ b/.github/scripts/check-unix-package.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Archive, + [Parameter(Mandatory = $true)] + [ValidateSet('linux-x64', 'macos-x64', 'macos-arm64')][string]$Platform, + [switch]$SkipSmoke +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$archivePath = (Resolve-Path -LiteralPath $Archive).Path +$testRunner = Join-Path $PSScriptRoot 'test-unix.sh' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("BongoCatPackage_" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +try { + Push-Location $temporaryRoot + try { + cmake -E tar xf $archivePath + if ($LASTEXITCODE -ne 0) { throw 'Package extraction failed' } + $roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory) + if ($roots.Count -ne 1 -or $roots[0].Name -notmatch "^BongoCat-[0-9].*-$Platform$") { + throw 'Expected one production BongoCat package directory' + } + $root = $roots[0].FullName + if ($Platform.StartsWith('macos-')) { + $executable = Join-Path $root 'BongoCat.app/Contents/MacOS/BongoCat' + $assets = Join-Path $root 'BongoCat.app/Contents/Resources/assets' + } else { + $executable = Join-Path $root 'BongoCat' + $assets = Join-Path $root 'assets' + } + $required = @($executable) + @( + 'bongocat.png', 'locales/en-US.json', + 'models/standard/cat.model3.json', + 'models/standard/demomodel.moc3', + 'models/standard/demomodel.1024/texture_00.png', + 'FrameworkShaders/VertShaderSrc.vert', + 'FrameworkShaders/FragShaderSrc.frag', + 'FrameworkShaders/VertShaderSrcBlend.vert', + 'FrameworkShaders/FragShaderSrcBlend.frag' + ) | ForEach-Object { + if ([IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $assets $_ } + } + foreach ($path in $required) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-Item -LiteralPath $path).Length -eq 0) { + throw "Package resource missing or empty: $path" + } + } + $licenses = @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter LICENSE) + if ($licenses.Count) { throw "Unexpected loose LICENSE files: $($licenses.FullName -join ', ')" } + Write-Host "Package layout verified: $Platform" + if (-not $SkipSmoke) { + $storage = Join-Path $temporaryRoot 'smoke-data' + & bash $testRunner env BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 ` + $executable --ci-smoke --ci-ignore-global-input ` + --ci-live2d-scenario=visual-consistency "--storage-root=$storage" + if ($LASTEXITCODE -ne 0) { throw 'Packaged application smoke test failed' } + $audits = @(Get-ChildItem -LiteralPath $storage -Recurse -File -Filter live2d-audit.txt) + if ($audits.Count -ne 1) { throw 'Packaged application produced no unique Live2D audit' } + $audit = Get-Content -LiteralPath $audits[0].FullName -Raw + if ($audit -notmatch '(?m)^renderer=cubism-native\r?$' -or + $audit -notmatch '(?m)^assertions=passed\r?$') { + throw "Packaged Live2D verification failed: $audit" + } + Write-Host "Packaged native Live2D smoke test passed: $Platform" + } + } finally { + Pop-Location + } +} finally { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force +} diff --git a/.github/scripts/install-inno.ps1 b/.github/scripts/install-inno.ps1 new file mode 100644 index 00000000..35bdcb23 --- /dev/null +++ b/.github/scripts/install-inno.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$PackageVersion = (Get-Content -LiteralPath "$PSScriptRoot/../../packaging/windows/inno-version.txt" -Raw).Trim() +choco upgrade innosetup --version $PackageVersion ` + --source https://community.chocolatey.org/api/v2/ ` + --force --allow-downgrade --fail-on-unfound --no-progress --yes +if ($LASTEXITCODE -ne 0) { throw "Pinned Inno Setup installation failed (exit $LASTEXITCODE)" } +$info = & "$PSScriptRoot/../../packaging/windows/find-inno.ps1" -PassThru +$compiler = $info.Path +if ($info.Version.ToString(3) -ne $PackageVersion) { + throw "Expected Inno Setup $PackageVersion, found $($info.Version) at $compiler" +} +$compilerDir = Split-Path $compiler -Parent +$env:Path = "$compilerDir;$env:Path" +$compilerDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 +Write-Host "Inno Setup compiler: $compiler" diff --git a/.github/scripts/restore-cubism-sdk.ps1 b/.github/scripts/restore-cubism-sdk.ps1 new file mode 100644 index 00000000..fb0e58ac --- /dev/null +++ b/.github/scripts/restore-cubism-sdk.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param( + [string]$ArchiveUrl = $(if ($env:CUBISM_SDK_ARCHIVE_URL) { + $env:CUBISM_SDK_ARCHIVE_URL + } else { + 'https://cubism.live2d.com/sdk-native/bin/CubismSdkForNative-5-r.5.zip' + }), + [string]$ExpectedSha256 = $(if ($env:CUBISM_SDK_ARCHIVE_SHA256) { + $env:CUBISM_SDK_ARCHIVE_SHA256 + } else { + '7FF3A4BBC19C0A8728965AA522AB77EB11B252916453E68A8A78D3B71188BB12' + }), + [string]$GlewArchiveUrl = $(if ($env:CUBISM_GLEW_ARCHIVE_URL) { + $env:CUBISM_GLEW_ARCHIVE_URL + } else { + 'https://github.com/nigels-com/glew/releases/download/glew-2.2.0/glew-2.2.0.zip' + }), + [string]$ExpectedGlewSha256 = $(if ($env:CUBISM_GLEW_ARCHIVE_SHA256) { + $env:CUBISM_GLEW_ARCHIVE_SHA256 + } else { + 'A9046A913774395A095EDCC0B0AC2D81C3AACCA61787B39839B941E9BE14E0D4' + }), + [string]$Destination +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +if (-not $Destination) { + $Destination = Join-Path $repositoryRoot 'vendor/CubismSdkForNative' +} + +if ([string]::IsNullOrWhiteSpace($ArchiveUrl) -or + [string]::IsNullOrWhiteSpace($ExpectedSha256) -or + [string]::IsNullOrWhiteSpace($GlewArchiveUrl) -or + [string]::IsNullOrWhiteSpace($ExpectedGlewSha256)) { + throw 'Cubism SDK and GLEW archive URLs and SHA-256 digests are required.' +} + +$ExpectedSha256 = $ExpectedSha256.Trim().ToUpperInvariant() +if ($ExpectedSha256 -notmatch '^[0-9A-F]{64}$') { + throw "CUBISM_SDK_ARCHIVE_SHA256 must be a 64-character SHA-256 digest." +} +$ExpectedGlewSha256 = $ExpectedGlewSha256.Trim().ToUpperInvariant() +if ($ExpectedGlewSha256 -notmatch '^[0-9A-F]{64}$') { + throw "CUBISM_GLEW_ARCHIVE_SHA256 must be a 64-character SHA-256 digest." +} + +$Destination = [System.IO.Path]::GetFullPath($Destination) +$repositoryPrefix = $repositoryRoot.TrimEnd('\', '/') + + [IO.Path]::DirectorySeparatorChar +if (-not $Destination.StartsWith($repositoryPrefix, + [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path $Destination -Leaf) -ne 'CubismSdkForNative') { + throw "Refusing to replace an unexpected Cubism SDK destination: $Destination" +} +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatCubism_" + [guid]::NewGuid()) +$archivePath = Join-Path $temporaryRoot "cubism-sdk.zip" +$glewArchivePath = Join-Path $temporaryRoot "glew.zip" +$extractPath = Join-Path $temporaryRoot "extracted" + +try { + New-Item -ItemType Directory -Force -Path $temporaryRoot, $extractPath | Out-Null + Write-Host "Downloading the pinned Cubism SDK archive..." + Invoke-WebRequest -Uri $ArchiveUrl -OutFile $archivePath -UseBasicParsing + + $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualSha256 -ne $ExpectedSha256) { + throw "Cubism SDK archive hash mismatch: expected $ExpectedSha256, got $actualSha256." + } + Write-Host "Cubism SDK archive SHA-256: $actualSha256" + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + $rootCandidate = Get-Item -LiteralPath $extractPath + if (-not (Test-Path (Join-Path $rootCandidate.FullName "Core/include/Live2DCubismCore.h"))) { + $rootCandidate = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | + Where-Object { Test-Path (Join-Path $_.FullName "Core/include/Live2DCubismCore.h") } | + Select-Object -First 1 + } + if (-not $rootCandidate) { + throw "The archive does not contain a CubismSdkForNative Core/include tree." + } + + $required = @( + (Join-Path $rootCandidate.FullName "Core/include/Live2DCubismCore.h"), + (Join-Path $rootCandidate.FullName "Core/lib/windows/x86/143/Live2DCubismCore_MT.lib"), + (Join-Path $rootCandidate.FullName "Core/lib/windows/x86_64/143/Live2DCubismCore_MT.lib"), + (Join-Path $rootCandidate.FullName "Core/lib/linux/x86_64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Core/lib/macos/x86_64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Core/lib/macos/arm64/libLive2DCubismCore.a"), + (Join-Path $rootCandidate.FullName "Framework/CMakeLists.txt") + ) + foreach ($path in $required) { + if (-not (Test-Path -LiteralPath $path)) { + throw "The Cubism SDK archive is incomplete; missing $path." + } + } + + Write-Host "Downloading the pinned GLEW 2.2.0 archive..." + Invoke-WebRequest -Uri $GlewArchiveUrl -OutFile $glewArchivePath -UseBasicParsing + $actualGlewSha256 = (Get-FileHash -LiteralPath $glewArchivePath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualGlewSha256 -ne $ExpectedGlewSha256) { + throw "GLEW archive hash mismatch: expected $ExpectedGlewSha256, got $actualGlewSha256." + } + Write-Host "GLEW archive SHA-256: $actualGlewSha256" + Expand-Archive -LiteralPath $glewArchivePath -DestinationPath $extractPath -Force + $glewRoot = Get-ChildItem -LiteralPath $extractPath -Directory -Recurse | + Where-Object { + (Test-Path (Join-Path $_.FullName 'build/cmake/CMakeLists.txt')) -and + (Test-Path (Join-Path $_.FullName 'include/GL/glew.h')) -and + (Test-Path (Join-Path $_.FullName 'src/glew.c')) + } | + Select-Object -First 1 + if (-not $glewRoot) { + throw 'The GLEW archive does not contain the expected CMake, header, and source trees.' + } + + Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + Get-ChildItem -LiteralPath $rootCandidate.FullName -Force | Copy-Item -Destination $Destination -Recurse -Force + $glewDestination = Join-Path $Destination 'Samples/OpenGL/thirdParty/glew' + New-Item -ItemType Directory -Force -Path $glewDestination | Out-Null + Get-ChildItem -LiteralPath $glewRoot.FullName -Force | + Copy-Item -Destination $glewDestination -Recurse -Force + $requiredGlew = @( + (Join-Path $glewDestination 'build/cmake/CMakeLists.txt'), + (Join-Path $glewDestination 'include/GL/glew.h'), + (Join-Path $glewDestination 'src/glew.c') + ) + foreach ($path in $requiredGlew) { + if (-not (Test-Path -LiteralPath $path)) { + throw "The restored GLEW tree is incomplete; missing $path." + } + } + Write-Host "Cubism SDK restored to $Destination" +} +finally { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/.github/scripts/scan-virustotal.py b/.github/scripts/scan-virustotal.py new file mode 100644 index 00000000..e624ab58 --- /dev/null +++ b/.github/scripts/scan-virustotal.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""Scan release artifacts in batches of four and publish CI summaries. + +Detections are advisory because unsigned Windows software can trigger a small +number of heuristic engines. API and report-processing failures still fail the +scan job, while the independent release job remains available. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import urllib.error +import urllib.request +import uuid +from collections import deque +from pathlib import Path +from typing import Any + + +API_ROOT = "https://www.virustotal.com/api/v3" +MAX_DIRECT_UPLOAD_BYTES = 32 * 1024 * 1024 +REQUEST_WINDOW_SECONDS = 120 +REQUEST_LIMIT = 4 +BATCH_SIZE = 4 +BATCH_DELAY_SECONDS = 120 +_request_times: deque[float] = deque() + + +class VirusTotalError(RuntimeError): + """An API or result-processing failure.""" + + +def escape_workflow_value(value: object) -> str: + """Keep third-party result text from creating workflow commands.""" + return ( + str(value) + .replace("%", "%25") + .replace("\r", "%0D") + .replace("\n", "%0A") + ) + + +def escape_markdown_cell(value: object) -> str: + return ( + str(value) + .replace("`", "\\`") + .replace("|", "\\|") + .replace("\r", " ") + .replace("\n", " ") + ) + + +def api_request( + method: str, + url: str, + api_key: str, + body: bytes | None = None, + content_type: str | None = None, +) -> dict[str, Any]: + headers = { + "accept": "application/json", + "x-apikey": api_key, + "user-agent": "BongoCat-VirusTotal-CI/1", + } + if body is not None: + headers["content-type"] = content_type or "application/octet-stream" + + request = urllib.request.Request( + url, data=body, headers=headers, method=method + ) + for attempt in range(5): + # Upload URLs, uploads, polling and retries all consume API quota. + while _request_times: + now = time.monotonic() + if now - _request_times[0] >= REQUEST_WINDOW_SECONDS: + _request_times.popleft() + elif len(_request_times) >= REQUEST_LIMIT: + delay = REQUEST_WINDOW_SECONDS - (now - _request_times[0]) + print(f"VirusTotal request quota used; waiting {delay:.1f}s") + time.sleep(delay) + else: + break + _request_times.append(time.monotonic()) + try: + with urllib.request.urlopen(request, timeout=60) as response: + payload = response.read() + try: + return json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VirusTotalError( + "VirusTotal returned invalid JSON" + ) from exc + except urllib.error.HTTPError as exc: + response_body = exc.read().decode("utf-8", errors="replace") + exc.close() + if exc.code in (429, 500, 502, 503, 504) and attempt < 4: + minimum_delay = 120 if exc.code == 429 else 5 + retry_after = exc.headers.get("Retry-After", str(minimum_delay)) + try: + delay = max(int(retry_after), minimum_delay) + except ValueError: + delay = minimum_delay + print( + f"VirusTotal HTTP {exc.code}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + detail = response_body[:500].replace("\n", " ") + raise VirusTotalError( + f"VirusTotal HTTP {exc.code}: {detail}" + ) from exc + except (urllib.error.URLError, TimeoutError) as exc: + if attempt < 4: + delay = 2**attempt + print( + f"VirusTotal request failed; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + raise VirusTotalError( + f"VirusTotal request failed: {exc}" + ) from exc + + raise VirusTotalError("VirusTotal request failed after retries") + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_multipart(path: Path) -> tuple[bytes, str]: + boundary = f"----BongoCatVirusTotal{uuid.uuid4().hex}" + name = path.name.replace('"', "") + prefix = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n' + "Content-Type: application/octet-stream\r\n\r\n" + ).encode("utf-8") + suffix = f"\r\n--{boundary}--\r\n".encode("ascii") + return ( + prefix + path.read_bytes() + suffix, + f"multipart/form-data; boundary={boundary}", + ) + + +def upload(path: Path, api_key: str) -> str: + upload_url = f"{API_ROOT}/files" + if path.stat().st_size > MAX_DIRECT_UPLOAD_BYTES: + response = api_request( + "GET", f"{API_ROOT}/files/upload_url", api_key + ) + upload_data = response.get("data", {}) + if isinstance(upload_data, str): + upload_url = upload_data + elif isinstance(upload_data, dict): + attributes = upload_data.get("attributes", {}) + links = upload_data.get("links", {}) + if not isinstance(attributes, dict): + attributes = {} + if not isinstance(links, dict): + links = {} + upload_url = ( + attributes.get("url", "") + or links.get("self", "") + or upload_data.get("id", "") + or upload_data.get("url", "") + ) + else: + upload_url = "" + if not upload_url: + raise VirusTotalError( + "VirusTotal did not return an upload URL" + ) + + body, content_type = build_multipart(path) + response = api_request( + "POST", upload_url, api_key, body, content_type + ) + response_data = response.get("data", {}) + analysis_id = ( + response_data.get("id", "") + if isinstance(response_data, dict) + else "" + ) + if not analysis_id: + raise VirusTotalError( + "VirusTotal upload response did not contain an analysis ID" + ) + return analysis_id + + +def wait_for_analysis( + analysis_id: str, + api_key: str, + timeout_seconds: int, + poll_seconds: int, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + url = f"{API_ROOT}/analyses/{analysis_id}" + while True: + response = api_request("GET", url, api_key) + response_data = response.get("data", {}) + attributes = ( + response_data.get("attributes", {}) + if isinstance(response_data, dict) + else {} + ) + if not isinstance(attributes, dict): + attributes = {} + status = attributes.get("status", "unknown") + print(f"VirusTotal analysis status: {status}") + if status == "completed": + return attributes + if status in ("failure", "timeout"): + raise VirusTotalError( + f"VirusTotal analysis ended with status: {status}" + ) + if time.monotonic() >= deadline: + raise VirusTotalError( + "VirusTotal analysis polling timed out" + ) + time.sleep(poll_seconds) + + +def write_summary( + path: Path, sha256: str, attributes: dict[str, Any], + report_path: Path | None = None, +) -> None: + stats = attributes.get("stats", {}) + if not isinstance(stats, dict): + stats = {} + keys = ( + "malicious", + "suspicious", + "harmless", + "undetected", + "timeout", + "failure", + ) + counts = {key: int(stats.get(key, 0) or 0) for key in keys} + detections = [] + results = attributes.get("results", {}) + if not isinstance(results, dict): + results = {} + for engine, result in results.items(): + if not isinstance(result, dict): + continue + category = result.get("category") + if category in ("malicious", "suspicious"): + detections.append( + { + "engine": str(engine), + "category": category, + "result": result.get("result") or "unspecified", + } + ) + detections.sort(key=lambda item: (item["category"], item["engine"])) + + report = { + "file_name": path.name, + "sha256": sha256, + "status": attributes.get("status"), + "stats": counts, + "detections": detections, + "file_report_url": ( + f"https://www.virustotal.com/gui/file/{sha256}" + ), + } + report_path = report_path or Path( + os.environ.get("VT_REPORT_PATH", "virustotal-report.json") + ) + report_path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + total_flagged = counts["malicious"] + counts["suspicious"] + if total_flagged: + print( + f"::warning::VirusTotal flagged {total_flagged} engine(s): " + f"malicious={counts['malicious']}, " + f"suspicious={counts['suspicious']}" + ) + for item in detections[:20]: + print( + "::warning::VirusTotal " + f"{escape_workflow_value(item['category'])} / " + f"{escape_workflow_value(item['engine'])}: " + f"{escape_workflow_value(item['result'])}" + ) + else: + print( + "::notice::VirusTotal completed without malicious or " + "suspicious detections " + f"({counts['harmless']} harmless, " + f"{counts['undetected']} undetected)" + ) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + lines = [ + "## VirusTotal scan", + "", + f"- File: `{path.name}`", + f"- SHA-256: `{sha256}`", + "- Report: " + f"[open in VirusTotal](https://www.virustotal.com/gui/file/{sha256})", + "- Policy: advisory; review malicious or suspicious engine results", + "- Counts: " + + ", ".join(f"{key}={counts[key]}" for key in keys), + ] + if detections: + lines.extend( + [ + "", + "### Malicious or suspicious engines", + "", + "| Engine | Category | Result |", + "| --- | --- | --- |", + ] + ) + lines.extend( + f"| `{escape_markdown_cell(item['engine'])}` | " + f"`{escape_markdown_cell(item['category'])}` | " + f"`{escape_markdown_cell(item['result'])}` |" + for item in detections[:50] + ) + else: + lines.extend( + ["", "No malicious or suspicious result was reported."] + ) + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write("\n".join(lines) + "\n") + + +def write_skip_summary(reason: str) -> None: + print(f"::notice::VirusTotal scan skipped: {reason}") + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write( + f"## VirusTotal scan\n\nScan skipped: {reason}\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--file", required=True, type=Path, nargs="+") + parser.add_argument("--report-directory", type=Path) + parser.add_argument("--require-api-key", action="store_true") + parser.add_argument("--timeout-seconds", type=int, default=900) + parser.add_argument("--poll-seconds", type=int, default=15) + args = parser.parse_args() + + for path in args.file: + if not path.is_file(): + print( + f"VirusTotal input file does not exist: {path}", + file=sys.stderr, + ) + return 2 + api_key = os.environ.get("VIRUSTOTAL_API_KEY", "").strip() + if not api_key: + if args.require_api_key: + print( + "VIRUSTOTAL_API_KEY is required for this scan", + file=sys.stderr, + ) + return 2 + write_skip_summary("VIRUSTOTAL_API_KEY is not configured") + return 0 + + try: + if args.report_directory: + args.report_directory.mkdir(parents=True, exist_ok=True) + for start in range(0, len(args.file), BATCH_SIZE): + if start: + print("Previous batch completed; waiting 120s before next batch") + time.sleep(BATCH_DELAY_SECONDS) + batch = args.file[start:start + BATCH_SIZE] + print(f"VirusTotal batch {start // BATCH_SIZE + 1}: {len(batch)} files") + pending = [] + # Submit the whole batch before waiting for any analysis result. + for path in batch: + sha256 = file_sha256(path) + print(f"VirusTotal input: {path} ({path.stat().st_size} bytes)") + print(f"VirusTotal SHA-256: {sha256}") + pending.append((path, sha256, upload(path, api_key))) + for index, (path, sha256, analysis_id) in enumerate(pending, start + 1): + attributes = wait_for_analysis( + analysis_id, api_key, + max(args.timeout_seconds, 30), + max(args.poll_seconds, 5), + ) + report_path = None + if args.report_directory or len(args.file) > 1: + report_path = (args.report_directory or Path(".")) / ( + f"virustotal-report-{index}-{path.name}.json" + ) + write_summary(path, sha256, attributes, report_path) + return 0 + except VirusTotalError as exc: + print(f"::error::VirusTotal scan failed: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test-unix.sh b/.github/scripts/test-unix.sh new file mode 100644 index 00000000..3624d8ae --- /dev/null +++ b/.github/scripts/test-unix.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$(uname -s)" == "Linux" ]]; then + # SDL window lifecycle tests need an X server and an OpenGL context. + export SDL_VIDEODRIVER=x11 + export LIBGL_ALWAYS_SOFTWARE=1 + # Keep GLX enabled: the Cubism/GLEW path requires GLX initialization. + export BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY=1 + exec xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" "$@" +fi + +exec "$@" diff --git a/.github/scripts/test-windows.ps1 b/.github/scripts/test-windows.ps1 index 525037e4..77f8c65d 100644 --- a/.github/scripts/test-windows.ps1 +++ b/.github/scripts/test-windows.ps1 @@ -4,8 +4,8 @@ param( ) $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { '.' } -$log = Join-Path $tempRoot 'bongo-cat-native-tests.log' -$junit = Join-Path $tempRoot 'bongo-cat-native-tests.xml' +$log = Join-Path $tempRoot 'bongocat-native-tests.log' +$junit = Join-Path $tempRoot 'bongocat-native-tests.xml' $executable = $TestCommand[0] $arguments = if ($TestCommand.Count -gt 1) { $TestCommand[1..($TestCommand.Count - 1)] diff --git a/.github/scripts/watch-virustotal.py b/.github/scripts/watch-virustotal.py new file mode 100644 index 00000000..f54a4bb8 --- /dev/null +++ b/.github/scripts/watch-virustotal.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Consume artifacts while builds run; one worker owns the shared VT quota. + +Both workflows must use the same job concurrency group. The initial cooldown +protects the quota when a previous worker finished or was cancelled abruptly. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import time +import zipfile + + +spec = importlib.util.spec_from_file_location( + "scan_virustotal", Path(__file__).with_name("scan-virustotal.py") +) +vt = importlib.util.module_from_spec(spec) +spec.loader.exec_module(vt) + + +def github_bytes(endpoint: str) -> bytes: + result = subprocess.run( + ["gh", "api", endpoint], capture_output=True, check=False + ) + if result.returncode: + raise vt.VirusTotalError( + f"GitHub artifact discovery/download failed: {result.stderr.decode(errors='replace')}" + ) + return result.stdout + + +def github_items(endpoint: str, key: str) -> list[dict]: + items = [] + separator = "&" if "?" in endpoint else "?" + page = 1 + while True: + data = json.loads(github_bytes(f"{endpoint}{separator}per_page=100&page={page}")) + batch = data[key] + items.extend(batch) + if len(batch) < 100: + return items + page += 1 + + +class ArtifactSource: + def __init__(self, prefix: str, producer_prefix: str, directory: Path): + self.repo = f"repos/{os.environ['GITHUB_REPOSITORY']}" + self.run = f"{self.repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}" + self.prefix = prefix + self.producer_prefix = producer_prefix + self.directory = directory + self.seen: set[int] = set() + + def discover(self) -> list[Path]: + files = [] + for artifact in github_items(f"{self.run}/artifacts", "artifacts"): + artifact_id = artifact["id"] + if not artifact["name"].startswith(self.prefix) or artifact_id in self.seen: + continue + if artifact["expired"]: + raise vt.VirusTotalError(f"Scan artifact expired: {artifact_id}") + payload = github_bytes(f"{self.repo}/actions/artifacts/{artifact_id}/zip") + destination = (self.directory / str(artifact_id)).resolve() + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + for member in archive.infolist(): + target = (destination / member.filename).resolve() + if not target.is_relative_to(destination): + raise vt.VirusTotalError("Unsafe path in artifact archive") + if member.is_dir() or target.name.lower().endswith(".sha256"): + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + files.append(target) + self.seen.add(artifact_id) + return files + + def finished(self) -> bool: + # Include earlier successful jobs on a failed-jobs rerun, keeping the + # newest job for each matrix name. Queued jobs are included by the API. + jobs = github_items(f"{self.run}/jobs?filter=all", "jobs") + latest = {} + for job in sorted(jobs, key=lambda item: item["id"]): + latest[job["name"]] = job + producers = [job for name, job in latest.items() + if name.startswith(self.producer_prefix)] + others = [job for name, job in latest.items() + if name != "security / virustotal"] + # The second condition also handles a skipped matrix after an upstream + # failure, for which GitHub may expose a single unexpanded job. + return bool(producers) and ( + (len(producers) >= 5 and all(job["status"] == "completed" for job in producers)) + or (bool(others) and all(job["status"] == "completed" for job in others)) + ) + + +def watch(source: ArtifactSource, api_key: str, reports: Path, + timeout: int = 18000, analysis_timeout: int = 900) -> None: + deadline = time.monotonic() + timeout + pending = [] + uploaded = 0 + failures = [] + reports.mkdir(parents=True, exist_ok=True) + # The concurrency lock excludes other CI/release workers; this covers the + # previous holder's final requests even if it could not persist any state. + print("Waiting 120s for the previous VirusTotal worker's quota to expire", flush=True) + time.sleep(vt.REQUEST_WINDOW_SECONDS) + while True: + if time.monotonic() >= deadline: + raise vt.VirusTotalError("Timed out waiting for build artifacts/scans") + finished = source.finished() + # Discover AFTER checking jobs so final uploads cannot fall between the + # last discovery and the decision to stop. + ready = source.discover() + for path in ready: + sha256 = vt.file_sha256(path) + print(f"Submitting completed artifact: {path.name}", flush=True) + analysis_id = vt.upload(path, api_key) + uploaded += 1 + pending.append((path, sha256, analysis_id, time.monotonic(), uploaded)) + if pending and not ready: + # One poll per discovery pass. New artifacts take priority over + # polling, and a slow analysis never blocks other uploads. + path, sha256, analysis_id, started, index = pending.pop(0) + response = vt.api_request("GET", f"{vt.API_ROOT}/analyses/{analysis_id}", api_key) + attributes = response.get("data", {}).get("attributes", {}) + status = attributes.get("status", "unknown") + if status == "completed": + vt.write_summary(path, sha256, attributes, + reports / f"virustotal-report-{index}-{path.name}.json") + elif status in ("failure", "timeout") or time.monotonic() - started >= analysis_timeout: + failures.append(f"{path.name}: analysis {status} or polling timed out") + else: + pending.append((path, sha256, analysis_id, started, index)) + if finished and not pending: + if not uploaded: + raise vt.VirusTotalError("No packaged artifacts found for VirusTotal") + if failures: + raise vt.VirusTotalError("; ".join(failures)) + return + if not ready: + time.sleep(15) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-prefix", required=True) + parser.add_argument("--producer-prefix", required=True) + args = parser.parse_args() + api_key = os.environ.get("VIRUSTOTAL_API_KEY", "").strip() + if not api_key: + vt.write_skip_summary("VIRUSTOTAL_API_KEY is not configured") + return 0 + try: + watch(ArtifactSource(args.artifact_prefix, args.producer_prefix, Path("vt-input")), + api_key, Path(".")) + except (vt.VirusTotalError, OSError, ValueError, zipfile.BadZipFile) as exc: + print(f"::error::{vt.escape_workflow_value(exc)}", flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cac37d0e..5317d3fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,21 @@ permissions: contents: read concurrency: - group: bongo-cat-ci-${{ github.workflow }}-${{ github.ref }} + group: bongocat-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + publishing-policy: + name: security / publishing-policy + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Validate publishing guards + shell: pwsh + run: ./.github/scripts/check-publish-guards.ps1 -SelfTest + quality: name: quality / syntax-tests runs-on: ubuntu-latest @@ -26,8 +37,9 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build cppcheck \ - libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + cmake ninja-build cppcheck xvfb xauth libgl1-mesa-dri \ + libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev libxfixes-dev \ + libcurl4-openssl-dev - name: Reject legacy product-name references run: | @@ -48,18 +60,27 @@ jobs: cppcheck --enable=warning,performance,portability \ --error-exitcode=1 --inline-suppr \ --suppress=missingIncludeSystem --suppress=unknownMacro \ - --std=c11 -Iinclude -Isrc/ui -Isrc/runtime "${c_files[@]}" + --std=c11 -Iinclude -Isrc/runtime \ + -Isrc/runtime/lifecycle -Isrc/runtime/model \ + -Isrc/runtime/model/import -Isrc/runtime/model/import/mver \ + -Isrc/runtime/model/import/nearby -Isrc/runtime/shell \ + -Isrc/ui/backend -Isrc/ui/preferences -Isrc/ui/rendering \ + -Isrc/ui/theme -Isrc/platform/windows "${c_files[@]}" mapfile -d '' cpp_files < <(find src -type f -name '*.cpp' -print0) cppcheck --enable=warning,performance,portability \ --error-exitcode=1 --inline-suppr \ --suppress=missingIncludeSystem --suppress=unknownMacro \ --suppress=passedByValueCallback --std=c++17 \ - -Iinclude -Isrc/ui -Isrc/runtime "${cpp_files[@]}" + -Iinclude -Isrc/runtime -Isrc/runtime/model/import \ + -Isrc/ui/backend -Isrc/ui/preferences -Isrc/ui/rendering \ + -Isrc/ui/theme "${cpp_files[@]}" - name: Configure warning-clean Linux build run: | cmake -S . -B build-quality -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - name: Register GCC diagnostics @@ -69,7 +90,7 @@ jobs: run: bash .github/scripts/build-unix.sh cmake --build build-quality --parallel 2 - name: Run native tests - run: ctest --test-dir build-quality --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build-quality --output-on-failure build: name: build / ${{ matrix.platform }} @@ -78,12 +99,21 @@ jobs: fail-fast: false matrix: include: - - os: windows-latest + - os: windows-2022 platform: windows-x64 + cmake_arch: x64 + - os: windows-2022 + platform: windows-x86 + cmake_arch: Win32 - os: ubuntu-latest platform: linux-x64 - - os: macos-latest - platform: macos + cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout source @@ -94,21 +124,17 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake ninja-build libgl1-mesa-dev libx11-dev libxi-dev \ - libxfixes-dev - - - name: Configure Windows release - if: runner.os == 'Windows' - shell: pwsh - run: | - cmake -S . -B build -A x64 ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri - name: Configure Unix release if: runner.os != 'Windows' run: | cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DBONGO_CAT_WARNINGS_AS_ERRORS=ON - name: Register GCC diagnostics @@ -118,22 +144,37 @@ jobs: - name: Build Windows release if: runner.os == 'Windows' shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build --config Release --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -Target ALL_BUILD - name: Build Windows validation tools if: runner.os == 'Windows' shell: pwsh - run: >- - & ./.github/scripts/build-windows.ps1 - cmake --build build --config Release - --target bongo_cat_validation_tools --parallel 2 + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -SkipConfigure ` + -Target bongo_cat_validation_tools - name: Build Unix release if: runner.os != 'Windows' run: bash .github/scripts/build-unix.sh cmake --build build --parallel 2 + - name: Install software OpenGL for Windows tests + if: runner.os == 'Windows' + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 + with: + version: '23.3.3' + deployment-choice: '1' + - name: Test Windows release if: runner.os == 'Windows' shell: pwsh @@ -143,46 +184,335 @@ jobs: - name: Test Unix release if: runner.os != 'Windows' - run: ctest --test-dir build --output-on-failure + run: bash .github/scripts/test-unix.sh ctest --test-dir build --output-on-failure - - name: Resolve release package name - id: package + desktop-package: + name: package / ${{ matrix.platform }} + if: >- + github.repository == 'vladelaina/BongoCat' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + needs: [publishing-policy, quality] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 + runs-on: ${{ matrix.os }} + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Restore licensed Cubism SDK shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Install Linux dependencies + if: runner.os == 'Linux' run: | - $name = (Get-Content build/bongo-cat-package-name.txt -Raw).Trim() - if ($name -notmatch '^BongoCat-\d+\.\d+\.\d+-(windows|linux|macos)-(x64|x86|arm64|universal)$') { - throw "Unexpected release package name: $name" - } - "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri + + - name: Configure release + run: | + cmake -S . -B build-package -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ + -DBONGO_CAT_REQUIRE_CUBISM=ON \ + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build release + run: bash .github/scripts/build-unix.sh cmake --build build-package --parallel 2 - - name: Package release + - name: Test release + run: bash .github/scripts/test-unix.sh ctest --test-dir build-package --output-on-failure + + - name: Package desktop release + id: package + timeout-minutes: 5 shell: pwsh run: | - cmake --build build --config Release --target package - $archives = @(Get-ChildItem build/dist -File | Where-Object { + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion + $name = (Get-Content build-package/bongocat-package-name.txt -Raw).Trim() + $platform = "${{ matrix.platform }}" + if ($name -notmatch "^BongoCat-$([regex]::Escape($version))-$platform$") { + throw "Cubism desktop package is not production-ready: $name" + } + + cmake --build build-package --target package + if ($LASTEXITCODE -ne 0) { throw 'Unix archive packaging failed' } + $archives = @(Get-ChildItem build-package/dist -File | Where-Object { $_.Name -match '\.(zip|tar\.gz)$' }) if ($archives.Count -ne 1) { - throw "Expected one release archive, found $($archives.Count)" + throw "Expected one desktop archive, found $($archives.Count)" + } + $packageCheckArgs = @{ + Archive = $archives[0].FullName + Platform = $platform + SkipSmoke = $platform.StartsWith('macos-') + } + & ./.github/scripts/check-unix-package.ps1 @packageCheckArgs + "name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "file=$($archives[0].FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Upload desktop release + uses: actions/upload-artifact@v7 + with: + name: bongocat-vt-${{ matrix.platform }}-archive-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.package.outputs.file }} + if-no-files-found: error + retention-days: 30 + compression-level: 0 + # Preserve package ZIPs inside the artifact transport archive. + archive: true + + store-package: + name: package / ${{ matrix.platform }} + if: >- + github.repository == 'vladelaina/BongoCat' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + needs: [publishing-policy, quality] + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x64 + cmake_arch: x64 + store_arch: x64 + - platform: windows-x86 + cmake_arch: Win32 + store_arch: '' + runs-on: windows-2022 + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Restore licensed Cubism SDK + shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Build Store release + shell: pwsh + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build-store ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -RequireCubism ` + -Target ALL_BUILD + + - name: Install software OpenGL for Windows tests + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 + with: + version: '23.3.3' + deployment-choice: '1' + + - name: Run Store release tests + shell: pwsh + run: >- + & ./.github/scripts/test-windows.ps1 + ctest --test-dir build-store -C Release --output-on-failure + + - name: Read app version + id: version + shell: pwsh + run: | + $projectVersion = & ./packaging/get-project-version.ps1 + "version=$($projectVersion.AppVersion)" | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Install Inno Setup + shell: pwsh + run: >- + & ./.github/scripts/install-inno.ps1 + + - name: Package Windows desktop release + id: desktop-package + shell: pwsh + run: | + $name = (Get-Content build-store/bongocat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" + if ($name -ne $expected) { + throw "Cubism desktop package is not production-ready: $name" + } + + cmake --build build-store --config Release --target package-portable + if ($LASTEXITCODE -ne 0) { + throw 'Windows portable packaging failed' } - $expected = '${{ steps.package.outputs.name }}' - if (-not $archives[0].Name.StartsWith("$expected.")) { - throw "Unexpected release archive: $($archives[0].Name)" + cmake --build build-store --config Release --target package-installer + if ($LASTEXITCODE -ne 0) { + throw 'Windows installer packaging failed' } - if (-not (Test-Path -LiteralPath "$($archives[0].FullName).sha256")) { - throw "Release archive checksum is missing" + $portable = "build-store/dist/$name-portable.exe" + $setup = "build-store/dist/$name-setup.exe" + if (-not (Test-Path -LiteralPath $portable)) { + throw "Production portable package is missing: $portable" } - @' - This CI artifact uses the diagnostic renderer because the licensed - Cubism SDK is not present in the public source checkout. It is for - startup and platform diagnostics, not end-user distribution. - '@ | Set-Content build/dist/DIAGNOSTIC-NO-CUBISM.txt -Encoding ascii + if (-not (Test-Path -LiteralPath $setup)) { + throw "Production desktop installer is missing: $setup" + } + $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() + $setupHash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + $app = "build-store/Release/BongoCat.exe" + $appHash = (Get-FileHash -LiteralPath $app -Algorithm SHA256).Hash.ToLowerInvariant() + $cubismArch = if ('${{ matrix.cmake_arch }}' -eq 'Win32') { 'x86' } else { 'x86_64' } + $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" + $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() + $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() + $innoInfo = & ./packaging/windows/find-inno.ps1 -PassThru + $innoVersion = $innoInfo.Version.ToString(3) + @" + ## Windows build provenance (${{ matrix.platform }}) + + | Input or artifact | Value | + | --- | --- | + | Commit | ``${{ github.sha }}`` | + | Runner image | ``$env:ImageOS $env:ImageVersion`` | + | CMake | ``$cmakeVersion`` | + | Inno Setup | ``$innoVersion`` at ``$($innoInfo.Path)`` | + | Cubism Core SHA-256 | ``$cubismHash`` | + | BongoCat.exe SHA-256 | ``$appHash`` | + | Portable SHA-256 | ``$portableHash`` | + | Installer SHA-256 | ``$setupHash`` | + + "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "portable=$portable" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "file=$setup" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Upload Windows installer failure log + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: installer-log-${{ matrix.platform }}-${{ github.run_attempt }} + path: build-store/installer.log + if-no-files-found: ignore + + - name: Build Microsoft Store MSIX + if: matrix.store_arch == 'x64' + shell: pwsh + run: | + ./packaging/microsoft-store/build-store-package.ps1 ` + -ExecutablePath "./build-store/Release/BongoCat.exe" ` + -Architecture "${{ matrix.store_arch }}" ` + -OutputDirectory "./store-output" + $msix = Get-Item "store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" + Write-Host ("Microsoft Store MSIX size: {0} KB" -f [math]::Round($msix.Length / 1KB, 2)) + + - name: Validate Microsoft Store MSIX + if: matrix.store_arch == 'x64' + shell: pwsh + run: | + ./packaging/microsoft-store/validate-store-package.ps1 ` + -PackagePath "./store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix" ` + -ExpectedArchitecture "${{ matrix.store_arch }}" + + - name: Upload Microsoft Store MSIX + if: matrix.store_arch == 'x64' + uses: actions/upload-artifact@v7 + with: + name: bongocat-vt-windows-x64-msix-${{ github.run_id }}-${{ github.run_attempt }} + path: store-output/bongocat_${{ steps.version.outputs.version }}_${{ matrix.store_arch }}.msix + if-no-files-found: error + retention-days: 90 + compression-level: 0 + # Preserve package ZIPs inside the artifact transport archive. + archive: true + + - name: Upload Windows desktop release + uses: actions/upload-artifact@v7 + with: + name: bongocat-vt-${{ matrix.platform }}-setup-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.desktop-package.outputs.file }} + if-no-files-found: error + retention-days: 30 + compression-level: 0 + # Preserve package ZIPs inside the artifact transport archive. + archive: true - - name: Upload diagnostic build + - name: Upload Windows portable release uses: actions/upload-artifact@v7 with: - name: diagnostic-no-cubism-${{ steps.package.outputs.name }} - path: build/dist/* + name: bongocat-vt-${{ matrix.platform }}-portable-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.desktop-package.outputs.portable }} if-no-files-found: error retention-days: 30 compression-level: 0 + # Preserve package ZIPs inside the artifact transport archive. + archive: true + + virus-total: + name: security / virustotal + if: >- + github.repository == 'vladelaina/BongoCat' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref == 'refs/heads/main' + needs: [publishing-policy, quality] + runs-on: ubuntu-latest + timeout-minutes: 310 + # CI and releases share the same VirusTotal API quota. + concurrency: + group: bongocat-virustotal + cancel-in-progress: false + permissions: + contents: read + actions: read + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + # Discover artifacts while matrix jobs are still building. A single + # worker owns the rolling request window for every platform. + - name: Scan artifacts as they become available + env: + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + GH_TOKEN: ${{ github.token }} + run: >- + python3 -u .github/scripts/watch-virustotal.py + --artifact-prefix 'bongocat-vt-' + --producer-prefix 'package / ' + + - name: Add VirusTotal note when scanning is skipped + if: always() && hashFiles('virustotal-report-*.json') == '' + run: | + { + echo "## VirusTotal scans" + echo + echo "No report was produced. Check the scan logs and VIRUSTOTAL_API_KEY configuration." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload VirusTotal reports + if: always() && hashFiles('virustotal-report-*.json') != '' + uses: actions/upload-artifact@v7 + with: + name: virustotal-ci-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: virustotal-report-*.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..7998af92 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,397 @@ +name: Release BongoCat + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: bongocat-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: build / ${{ matrix.platform }} + if: github.repository == 'vladelaina/BongoCat' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + platform: windows-x64 + cmake_arch: x64 + - os: windows-2022 + platform: windows-x86 + cmake_arch: Win32 + - os: ubuntu-22.04 + platform: linux-x64 + cmake_arch: '' + - os: macos-15-intel + platform: macos-x64 + cmake_arch: x86_64 + - os: macos-15 + platform: macos-arm64 + cmake_arch: arm64 + env: + CUBISM_SDK_ARCHIVE_URL: ${{ secrets.CUBISM_SDK_ARCHIVE_URL }} + CUBISM_SDK_ARCHIVE_SHA256: ${{ secrets.CUBISM_SDK_ARCHIVE_SHA256 }} + CUBISM_GLEW_ARCHIVE_URL: ${{ secrets.CUBISM_GLEW_ARCHIVE_URL }} + CUBISM_GLEW_ARCHIVE_SHA256: ${{ secrets.CUBISM_GLEW_ARCHIVE_SHA256 }} + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Validate tag version + id: version + shell: pwsh + run: | + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion + if ($env:GITHUB_REF_NAME -ne "v$version") { + throw "Tag $env:GITHUB_REF_NAME does not match project version $version" + } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT ` + -Append -Encoding utf8 + + - name: Restore licensed Cubism SDK + shell: pwsh + run: ./.github/scripts/restore-cubism-sdk.ps1 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build libgl1-mesa-dev libegl1-mesa-dev libx11-dev libxi-dev \ + libxfixes-dev libcurl4-openssl-dev xvfb xauth libgl1-mesa-dri \ + curl file desktop-file-utils + + - name: Configure Unix release + if: runner.os != 'Windows' + run: | + cmake -S . -B build-release -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=ON \ + -DBONGO_CAT_OPTIMIZE_RELEASE_IPO=ON \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ + -DBONGO_CAT_REQUIRE_CUBISM=ON \ + -DBONGO_CAT_WARNINGS_AS_ERRORS=ON + + - name: Build Windows release + if: runner.os == 'Windows' + shell: pwsh + run: | + & ./scripts/build-windows.ps1 ` + -BuildDir build-release ` + -Configuration Release ` + -Architecture '${{ matrix.cmake_arch }}' ` + -Jobs 2 ` + -RequireCubism ` + -Target ALL_BUILD + + - name: Build Unix release + if: runner.os != 'Windows' + run: bash .github/scripts/build-unix.sh cmake --build build-release --parallel 2 + + - name: Install software OpenGL for Windows tests + if: runner.os == 'Windows' + uses: ssciwr/setup-mesa-dist-win@2dfeaebfa514f74c2b8cd8d27108c31486d33e72 # v3 + with: + version: '23.3.3' + deployment-choice: '1' + + - name: Test Windows release + if: runner.os == 'Windows' + shell: pwsh + run: >- + & ./.github/scripts/test-windows.ps1 + ctest --test-dir build-release -C Release --output-on-failure + + - name: Test Unix release + if: runner.os != 'Windows' + run: bash .github/scripts/test-unix.sh ctest --test-dir build-release --output-on-failure + + - name: Install Inno Setup + if: runner.os == 'Windows' + shell: pwsh + run: >- + & ./.github/scripts/install-inno.ps1 + + - name: Package Windows release + if: runner.os == 'Windows' + shell: pwsh + run: | + $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" + if ($name -ne $expected) { + throw "Unexpected release package name: $name" + } + + cmake --build build-release --config Release --target package-portable + if ($LASTEXITCODE -ne 0) { + throw 'Windows portable packaging failed' + } + cmake --build build-release --config Release --target package-installer + if ($LASTEXITCODE -ne 0) { + throw 'Windows installer packaging failed' + } + + $portable = "build-release/dist/$name-portable.exe" + $portableChecksum = "$portable.sha256" + $setup = "build-release/dist/$name-setup.exe" + $setupChecksum = "$setup.sha256" + foreach ($file in @($portable, $setup, $setupChecksum)) { + if (-not (Test-Path -LiteralPath $file)) { + throw "Release file is missing: $file" + } + } + $portableHash = (Get-FileHash -LiteralPath $portable -Algorithm SHA256).Hash.ToLowerInvariant() + "$portableHash $([IO.Path]::GetFileName($portable))" | + Set-Content -LiteralPath $portableChecksum -Encoding ascii + $setupHash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() + $app = "build-release/Release/BongoCat.exe" + $appHash = (Get-FileHash -LiteralPath $app -Algorithm SHA256).Hash.ToLowerInvariant() + $cubismArch = if ('${{ matrix.cmake_arch }}' -eq 'Win32') { 'x86' } else { 'x86_64' } + $cubismCore = "vendor/CubismSdkForNative/Core/lib/windows/$cubismArch/143/Live2DCubismCore_MT.lib" + $cubismHash = (Get-FileHash -LiteralPath $cubismCore -Algorithm SHA256).Hash.ToLowerInvariant() + $cmakeVersion = (& cmake --version | Select-Object -First 1).Trim() + $innoInfo = & ./packaging/windows/find-inno.ps1 -PassThru + $innoVersion = $innoInfo.Version.ToString(3) + @" + ## Windows build provenance (${{ matrix.platform }}) + + | Input or artifact | Value | + | --- | --- | + | Commit | ``${{ github.sha }}`` | + | Runner image | ``$env:ImageOS $env:ImageVersion`` | + | CMake | ``$cmakeVersion`` | + | Inno Setup | ``$innoVersion`` at ``$($innoInfo.Path)`` | + | Cubism Core SHA-256 | ``$cubismHash`` | + | BongoCat.exe SHA-256 | ``$appHash`` | + | Portable SHA-256 | ``$portableHash`` | + | Installer SHA-256 | ``$setupHash`` | + + "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + New-Item -ItemType Directory -Path release-output -Force | Out-Null + Copy-Item -LiteralPath $portable, $portableChecksum, $setup, ` + $setupChecksum -Destination release-output + + - name: Upload Windows installer failure log + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: installer-log-${{ matrix.platform }}-${{ github.run_attempt }} + path: build-release/installer.log + if-no-files-found: ignore + + - name: Package Unix release + if: runner.os != 'Windows' + timeout-minutes: 5 + shell: pwsh + run: | + $name = (Get-Content build-release/bongocat-package-name.txt -Raw).Trim() + $expected = "BongoCat-${{ steps.version.outputs.version }}-${{ matrix.platform }}" + if ($name -ne $expected) { + throw "Unexpected release package name: $name" + } + + cmake --build build-release --target package + if ($LASTEXITCODE -ne 0) { + throw 'Unix archive packaging failed' + } + $archives = @(Get-ChildItem build-release/dist -File | Where-Object { + $_.Name -match '\.(zip|tar\.gz)$' + }) + if ($archives.Count -ne 1) { + throw "Expected one Unix release archive, found $($archives.Count)" + } + $archive = $archives[0] + $packageCheckArgs = @{ + Archive = $archive.FullName + Platform = '${{ matrix.platform }}' + SkipSmoke = '${{ matrix.platform }}'.StartsWith('macos-') + } + & ./.github/scripts/check-unix-package.ps1 @packageCheckArgs + $checksum = "$($archive.FullName).sha256" + if (-not (Test-Path -LiteralPath $checksum -PathType Leaf)) { + throw "Release checksum is missing: $checksum" + } + + New-Item -ItemType Directory -Path release-output -Force | Out-Null + Copy-Item -LiteralPath $archive.FullName, $checksum ` + -Destination release-output + + if ('${{ matrix.platform }}' -eq 'linux-x64') { + cmake --build build-release --target package-appimage + if ($LASTEXITCODE -ne 0) { throw 'AppImage packaging failed' } + $appimage = Get-Item build-release/dist/*.AppImage + bash ./.github/scripts/check-appimage.sh $appimage.FullName + $appimageHash = (Get-FileHash $appimage -Algorithm SHA256).Hash.ToLowerInvariant() + "$appimageHash $($appimage.Name)" | Set-Content "$($appimage.FullName).sha256" -Encoding ascii + Copy-Item -LiteralPath $appimage.FullName, "$($appimage.FullName).sha256" -Destination release-output + } + + - name: Upload packaged release + uses: actions/upload-artifact@v7 + with: + name: bongocat-release-${{ matrix.platform }} + path: release-output/* + if-no-files-found: error + retention-days: 7 + + virus-total: + name: security / virustotal + if: github.repository == 'vladelaina/BongoCat' + runs-on: ubuntu-latest + timeout-minutes: 310 + # CI and releases share the same VirusTotal API quota. + concurrency: + group: bongocat-virustotal + cancel-in-progress: false + permissions: + contents: read + actions: read + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + # Discover artifacts while matrix jobs are still building. A single + # worker owns the rolling request window for every platform. + - name: Scan artifacts as they become available + env: + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + GH_TOKEN: ${{ github.token }} + run: >- + python3 -u .github/scripts/watch-virustotal.py + --artifact-prefix 'bongocat-release-' + --producer-prefix 'build / ' + + - name: Add VirusTotal note when scanning is skipped + if: always() && hashFiles('virustotal-report-*.json') == '' + run: | + { + echo "## VirusTotal scans" + echo + echo "No report was produced. Check the scan logs and VIRUSTOTAL_API_KEY configuration." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload VirusTotal reports + if: always() && hashFiles('virustotal-report-*.json') != '' + uses: actions/upload-artifact@v7 + with: + name: virustotal-release-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: virustotal-report-*.json + if-no-files-found: error + retention-days: 90 + + publish: + name: publish / all platforms + needs: build + if: github.repository == 'vladelaina/BongoCat' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout source + uses: actions/checkout@v5 + + - name: Download packaged releases + uses: actions/download-artifact@v8 + with: + pattern: bongocat-release-* + path: release-output + merge-multiple: true + + - name: Validate complete release + id: version + shell: pwsh + run: | + $projectVersion = & ./packaging/get-project-version.ps1 + $version = $projectVersion.AppVersion + if ($env:GITHUB_REF_NAME -ne "v$version") { + throw "Tag $env:GITHUB_REF_NAME does not match project version $version" + } + $files = @() + foreach ($platform in 'windows-x64', 'windows-x86') { + $base = "BongoCat-$version-$platform" + $files += "release-output/$base-portable.exe" + $files += "release-output/$base-portable.exe.sha256" + $files += "release-output/$base-setup.exe" + $files += "release-output/$base-setup.exe.sha256" + } + $unixArchives = @{ + 'linux-x64' = 'tar.gz' + 'macos-x64' = 'zip' + 'macos-arm64' = 'zip' + } + foreach ($platform in $unixArchives.Keys) { + $archive = "release-output/BongoCat-$version-$platform.$($unixArchives[$platform])" + $files += $archive + $files += "$archive.sha256" + } + $files += "release-output/BongoCat-$version-linux-x64.AppImage" + $files += "release-output/BongoCat-$version-linux-x64.AppImage.sha256" + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + throw "Release is incomplete: $file" + } + } + foreach ($checksum in $files | Where-Object { $_ -like '*.sha256' }) { + $line = (Get-Content -LiteralPath $checksum -Raw).Trim() + if ($line -notmatch '^([0-9a-fA-F]{64})\s+(.+)$') { + throw "Invalid checksum file: $checksum" + } + $expectedHash = $Matches[1] + $checksumName = $Matches[2] + $payload = $checksum.Substring(0, $checksum.Length - 7) + $expectedName = Split-Path $payload -Leaf + if ($checksumName -ne $expectedName) { + throw "Checksum names the wrong file: $checksum" + } + $actual = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash + if ($actual -ne $expectedHash) { + throw "Checksum mismatch: $payload" + } + } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT ` + -Append -Encoding utf8 + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: BongoCat v${{ steps.version.outputs.version }} + body: | + ## Download + + + + + + ### GitHub Downloads + + - [Windows x64 installer](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x64-setup.exe) + - [Windows x64 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x64-portable.exe) + - [Windows x86 installer](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-setup.exe) + - [Windows x86 portable](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-windows-x86-portable.exe) + - [macOS Apple Silicon](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-arm64.zip) + - [macOS Intel](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-macos-x64.zip) + - [Linux x64](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-linux-x64.tar.gz) + - [Linux x64 AppImage](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/BongoCat-${{ steps.version.outputs.version }}-linux-x64.AppImage) + + generate_release_notes: true + fail_on_unmatched_files: true + make_latest: true + files: | + release-output/*-portable.exe + release-output/*-setup.exe + release-output/*.zip + release-output/*.tar.gz + release-output/*.AppImage diff --git a/.gitignore b/.gitignore index 60de1200..bf58b4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # CMake build trees and generated metadata /build*/ /out/ +/output/ /CMakeFiles/ /Testing/ /CMakeCache.txt @@ -52,6 +53,27 @@ Desktop.ini # Local reference files /.reference/ +/docs/* +!/docs/ +!/docs/README.zh-CN.md +!/docs/README.zh-Hant.md +!/docs/README.fr-FR.md +!/docs/README.de-DE.md +!/docs/README.ja-JP.md +!/docs/README.ko-KR.md +!/docs/README.pt-BR.md +!/docs/README.ru-RU.md +!/docs/README.es-ES.md +!/docs/README.id-ID.md +!/docs/wechat.png +!/docs/windows-multimonitor-input-validation.md +!/docs/model-import-rendering.md + # Local Windows audit tools /cmake/*.ps1 !/cmake/RunCubismViewerBlindTest.ps1 + +# Local diagnostic test state +/.diagnostic-session/ +/.diagnostic-storage/ +/.diagnostic-storage-morning/ diff --git a/CMakeLists.txt b/CMakeLists.txt index d53037bb..946fe9f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,7 @@ cmake_minimum_required(VERSION 3.24) -project(BongoCat VERSION 0.1.0 DESCRIPTION "BongoCat" HOMEPAGE_URL "https://bongocat.pet" LANGUAGES C CXX) +set(BONGO_CAT_VERSION "1.10.0") +project(BongoCat VERSION ${BONGO_CAT_VERSION} DESCRIPTION "BongoCat" HOMEPAGE_URL "https://github.com/vladelaina/BongoCat" LANGUAGES C CXX) +include(cmake/Version.cmake) include(FetchContent) include(CTest) option(BONGO_CAT_FETCH_DEPS "Download pinned open-source dependencies" ON) @@ -14,7 +16,6 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) include(cmake/ReleaseOptimize.cmake) - add_library(bongo_cat_warnings INTERFACE) if(MSVC) target_compile_options(bongo_cat_warnings INTERFACE /W4 /permissive- /utf-8) @@ -30,6 +31,7 @@ else() endif() endif() include(cmake/Dependencies.cmake) +include(cmake/AudioDecoder.cmake) find_package(OpenGL REQUIRED) set(BONGO_CAT_LIVE2D_SOURCES src/live2d/live2d_stub.c) set(BONGO_CAT_CUBISM_ENABLED OFF) @@ -43,9 +45,20 @@ if(EXISTS "${CUBISM_CORE_HEADER}") else() include(cmake/Cubism.cmake) set(BONGO_CAT_LIVE2D_SOURCES - src/live2d/cubism_bridge.cpp src/live2d/cubism_model_load.cpp - src/live2d/cubism_model_update.cpp src/live2d/cubism_viewer_look.cpp + src/live2d/cubism_bridge.cpp src/live2d/cubism_bridge_load.cpp + src/live2d/cubism_bridge_visual.cpp + src/live2d/cubism_expression.cpp + src/live2d/cubism_model_load.cpp + src/live2d/cubism_model_renderer.cpp + src/live2d/cubism_model_idle.cpp + src/live2d/cubism_model_update.cpp + src/live2d/cubism_parameter_overrides.cpp + src/live2d/cubism_motion_playback.cpp + src/live2d/cubism_motion_preview.cpp + src/live2d/cubism_motion_selection.cpp + src/live2d/cubism_motion_state.cpp src/live2d/cubism_viewer_look.cpp src/live2d/cubism_model_bounds.cpp + src/live2d/cubism_model_viewport.cpp src/live2d/cubism_model_validate.cpp) set(BONGO_CAT_CUBISM_ENABLED ON) endif() @@ -54,230 +67,72 @@ elseif(BONGO_CAT_REQUIRE_CUBISM) else() message(WARNING "Cubism SDK missing; building the diagnostic backend") endif() - add_library(bongo_cat_core STATIC - src/core/config_defaults.c src/core/config_io.c + src/core/config_defaults.c src/core/config_model_state.c src/core/config_session_defaults.c src/core/config_names.c src/core/config_fields.c src/core/config_io.c src/core/config_settings_save.c src/core/config_file.c src/core/config_session_io.c src/core/error.c src/core/file.c src/core/i18n.c src/core/input_state.c src/core/mver_pointer.c src/core/mouse_tracking.c src/core/sha256.c src/core/utf8.c src/core/model_catalog.c - src/core/model_behavior.c - src/core/path.c src/core/path_io.c src/core/json.c) -target_sources(bongo_cat_core PRIVATE src/core/shortcut.c) -target_include_directories(bongo_cat_core PUBLIC include) + src/core/model_behavior.c src/core/model_json.c + src/core/path.c src/core/path_io.c src/core/json.c src/core/shortcut.c + src/core/sound_shortcut.c + src/core/update_release.c src/core/update_version.c) +target_include_directories(bongo_cat_core PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) target_link_libraries(bongo_cat_core PUBLIC yyjson PRIVATE bongo_cat_warnings) include(cmake/CubismAssets.cmake) if(WIN32) - target_sources(bongo_cat_core PRIVATE src/core/windows_utf8.c) + target_sources(bongo_cat_core PRIVATE + src/platform/windows/windows_utf8.c) + target_include_directories(bongo_cat_core PRIVATE + src/platform/windows) endif() +include(cmake/RuntimeSources.cmake) add_library(bongo_cat_runtime STATIC - src/core/app_state.c - src/media/audio.c - src/media/image.c src/media/image_resize.c src/media/image_alpha.c - src/media/stb_image_impl.c - src/platform/memory.c src/render/gl_api.c - src/render/overlay.c src/render/mver_pointer_overlay.c src/render/mver_pointer_overlay_draw.c - src/runtime/app.c src/runtime/config_store.c - src/runtime/assets.c src/runtime/startup.c - src/runtime/frame_audit.c src/runtime/frame_clock.c src/runtime/modal_frame.c - src/runtime/gamepad.c - src/runtime/live2d_audit.c src/runtime/live2d_visual_audit.c - src/runtime/live2d_viewer_audit.c - src/runtime/memory_policy.c src/runtime/mouse.c src/runtime/mouse_mapping.c - src/runtime/model_files.c src/runtime/model_update.c - src/runtime/model_storage.c - src/runtime/model_import.c - src/runtime/model_import_package.c - src/runtime/model_import_discovery.c - src/runtime/model_import_format.c - src/runtime/model_import_portable.c src/runtime/model_import_portable_scan.c - src/runtime/model_import_mver_patch.c - src/runtime/model_import_mver.c src/runtime/model_import_mver_image.c - src/runtime/model_import_mver_shortcut.c src/runtime/model_import_mver_effect.c - src/runtime/model_import_mver_motion.c src/runtime/model_import_mver_labels.c src/runtime/model_import_report.c - src/runtime/model_import_mver_metadata.c src/runtime/model_import_metadata.c - src/runtime/runtime_flow.c - src/runtime/shortcuts.c - src/runtime/tray.c - src/runtime/window.c src/runtime/window_display.c src/runtime/window_drag.c - src/runtime/window_geometry.c - src/runtime/window_menu_behavior.c src/runtime/window_menu_preview.c - src/runtime/window_hit.c src/runtime/window_wheel.c - src/ui/nuklear_impl.c - src/ui/ui_backend.c src/ui/ui_resize_cache.c src/ui/ui_scale.c src/ui/ui_cursor.c - src/ui/ui_animation.c src/ui/ui_paint.c src/ui/ui_paint_cache.c src/ui/ui_paint_border.c src/ui/ui_paint_shell.c - src/ui/ui_font.c src/ui/ui_font_atlas.c src/ui/ui_font_atlas_sources.c src/ui/ui_font_atlas_ranges.c src/ui/ui_font_atlas_upload.c src/ui/ui_font_reload.c - src/ui/ui_input.c - src/ui/ui_catime.c src/ui/ui_catime_tabs.c src/ui/ui_catime_theme.c src/ui/ui_catime_icons.c - src/ui/ui_native_theme.c - src/ui/preferences.c src/ui/preferences_scale.c src/ui/preferences_render.c src/ui/preferences_language.c - src/ui/preferences_assets.c src/ui/preferences_about.c src/ui/preferences_about_community.c - src/ui/preferences_live_resize.c - src/ui/preferences_dialog.c src/ui/preferences_import.c src/ui/preferences_overlay.c - src/ui/preferences_behavior_dialog.c src/ui/preferences_behavior_rename.c src/ui/preferences_behavior_row.c - src/ui/preferences_notice.c - src/ui/preferences_model.c src/ui/preferences_model_cover.c src/ui/preferences_model_glyphs.c - src/ui/preferences_pages.c src/ui/preferences_theme.c - src/ui/preferences_shortcuts.c src/ui/preferences_shortcut_clear.c src/ui/preferences_smoke.c - src/ui/preferences_widgets.c src/ui/preferences_controls.c src/ui/preferences_combo.c src/ui/preferences_toggle.c - ${BONGO_CAT_LIVE2D_SOURCES}) -target_include_directories(bongo_cat_runtime PUBLIC include) + ${BONGO_CAT_RUNTIME_SOURCES} ${BONGO_CAT_LIVE2D_SOURCES}) +target_include_directories(bongo_cat_runtime PUBLIC "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include) +target_include_directories(bongo_cat_runtime PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) target_include_directories(bongo_cat_runtime SYSTEM PRIVATE ${BONGO_CAT_STB_INCLUDE_DIR} ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) -target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings) +target_link_libraries(bongo_cat_runtime PRIVATE bongo_cat_warnings bongo_cat_archive) target_link_libraries(bongo_cat_runtime PUBLIC bongo_cat_core SDL3::SDL3-static OpenGL::GL ${BONGO_CAT_MINIAUDIO_TARGET}) if(MSVC) - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c src/ui/preferences/about/preferences_about_svg.c PROPERTIES COMPILE_OPTIONS "/W0") else() - set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/nuklear_impl.c + set_source_files_properties(src/media/stb_image_impl.c src/media/image_resize.c src/ui/backend/nuklear_impl.c src/ui/preferences/about/preferences_about_svg.c PROPERTIES COMPILE_OPTIONS "-w") endif() if(BONGO_CAT_CUBISM_ENABLED) - target_link_libraries(bongo_cat_runtime PUBLIC Framework Live2DCubismCore glew_s) + target_link_libraries(bongo_cat_runtime PUBLIC Framework) target_compile_definitions(bongo_cat_runtime PUBLIC BONGO_CAT_HAS_CUBISM=1) endif() target_compile_definitions(bongo_cat_runtime PRIVATE BONGO_CAT_DEV_ASSET_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" BONGO_CAT_DEV_LOCALE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/locales") -if(WIN32) - enable_language(RC) - target_sources(bongo_cat_runtime PRIVATE src/platform/windows.c src/platform/windows_menu.c src/platform/windows_directory.c - src/platform/windows_startup.c src/platform/windows_borderless.c src/platform/windows_capture.c - src/platform/windows_pointer.c src/platform/windows_direct_input.c src/platform/windows_input.c src/platform/windows_popup.c - src/platform/windows_layered.c src/platform/windows_tray.c - src/platform/windows_assets.c) - target_link_libraries(bongo_cat_runtime PRIVATE dinput8 dxguid dwmapi ole32 shell32 user32 uuid windowscodecs) -elseif(APPLE) - enable_language(OBJC) - target_sources(bongo_cat_runtime PRIVATE src/platform/macos.m src/platform/macos_input.m - src/platform/macos_keys.m src/platform/macos_menu.m) - target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" - "-framework ApplicationServices") -else() - find_package(X11 REQUIRED) - if(NOT TARGET X11::Xi OR NOT TARGET X11::Xfixes) - message(FATAL_ERROR "Linux native build requires XInput2 and Xfixes development packages") - endif() - target_sources(bongo_cat_runtime PRIVATE src/platform/linux.c src/platform/linux_menu.c - src/platform/linux_x11.c) - target_link_libraries(bongo_cat_runtime PRIVATE X11::X11 X11::Xi X11::Xfixes m) - target_link_libraries(bongo_cat_core PRIVATE m) -endif() +include(cmake/PlatformRuntime.cmake) +include(cmake/AboutWebP.cmake) add_executable(bongo_cat src/main.c) target_link_libraries(bongo_cat PRIVATE bongo_cat_runtime bongo_cat_warnings) set_target_properties(bongo_cat PROPERTIES OUTPUT_NAME "BongoCat") bongo_cat_enable_release_ipo(Framework bongo_cat_core bongo_cat_runtime bongo_cat) -if(WIN32) - add_library(bongo_cat_tool_support STATIC EXCLUDE_FROM_ALL src/tools/windows_tool.c) - target_include_directories(bongo_cat_tool_support PUBLIC src/tools) - target_link_libraries(bongo_cat_tool_support PUBLIC bongo_cat_warnings) - - add_executable(cubism_viewer_desktop_capture EXCLUDE_FROM_ALL - src/tools/cubism_viewer_desktop_capture.c) - target_compile_definitions(cubism_viewer_desktop_capture PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_desktop_capture PRIVATE - bongo_cat_tool_support bongo_cat_warnings d3d11 dxgi dxguid user32) - - add_library(bongo_cat_validation_image STATIC EXCLUDE_FROM_ALL - src/tools/validation_image.c src/tools/validation_gdiplus.c) - target_include_directories(bongo_cat_validation_image PUBLIC src/tools) - target_link_libraries(bongo_cat_validation_image PUBLIC - bongo_cat_warnings windowscodecs gdiplus ole32) - - add_executable(cubism_viewer_drag_capture EXCLUDE_FROM_ALL - src/tools/cubism_viewer_drag_capture.c) - target_compile_definitions(cubism_viewer_drag_capture PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_drag_capture PRIVATE - bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings - user32 gdi32 ole32) - - add_executable(cubism_viewer_blind_test EXCLUDE_FROM_ALL - src/tools/cubism_viewer_blind_test.c) - target_compile_definitions(cubism_viewer_blind_test PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(cubism_viewer_blind_test PRIVATE - bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings ole32) - - add_executable(mver_blind_metrics EXCLUDE_FROM_ALL - src/tools/mver_blind_metrics.c) - target_compile_definitions(mver_blind_metrics PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(mver_blind_metrics PRIVATE - bongo_cat_validation_image bongo_cat_warnings ole32) - - add_executable(mver_phase_metrics EXCLUDE_FROM_ALL - src/tools/mver_phase_metrics.c) - target_compile_definitions(mver_phase_metrics PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(mver_phase_metrics PRIVATE - bongo_cat_validation_image bongo_cat_warnings ole32) - - add_custom_target(bongo_cat_validation_tools - DEPENDS cubism_viewer_desktop_capture cubism_viewer_drag_capture - cubism_viewer_blind_test mver_blind_metrics mver_phase_metrics) -endif() -if(APPLE) - set_source_files_properties(resources/icons/icon.icns PROPERTIES - MACOSX_PACKAGE_LOCATION Resources) - target_sources(bongo_cat PRIVATE resources/icons/icon.icns) - set_target_properties(bongo_cat PROPERTIES - MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" - MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop - MACOSX_BUNDLE_ICON_FILE icon.icns - MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") +include(cmake/ValidationTools.cmake) +include(cmake/ApplicationResources.cmake) +if(MSVC AND BONGO_CAT_SDL_WINDOWS_TRIMMED) + get_filename_component(BONGO_CAT_MSVC_TOOL_DIR "${CMAKE_LINKER}" DIRECTORY) + find_program(BONGO_CAT_DUMPBIN dumpbin PATHS "${BONGO_CAT_MSVC_TOOL_DIR}" REQUIRED) add_custom_command(TARGET bongo_cat POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory - "$/MacOS/assets" - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" - "$/MacOS/assets" - VERBATIM) -endif() - -if(WIN32) - target_sources(bongo_cat_runtime PRIVATE src/platform/windows_keys.c) - add_executable(bongo_cat_asset_packer src/tools/asset_packer.c) - target_compile_definitions(bongo_cat_asset_packer PRIVATE _CRT_SECURE_NO_WARNINGS) - target_link_libraries(bongo_cat_asset_packer PRIVATE bongo_cat_warnings) - set(BONGO_CAT_ASSET_STAGE "${CMAKE_CURRENT_BINARY_DIR}/embedded-assets") - set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongo-cat-assets.pak") - file(GLOB_RECURSE BONGO_CAT_ASSET_INPUTS CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/*") - bongo_cat_configure_embedded_cubism_assets() - add_custom_command(OUTPUT "${BONGO_CAT_ASSET_PACK}" - COMMAND ${CMAKE_COMMAND} -E rm -rf "${BONGO_CAT_ASSET_STAGE}" - COMMAND ${CMAKE_COMMAND} -E make_directory "${BONGO_CAT_ASSET_STAGE}/assets" - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" "${BONGO_CAT_ASSET_STAGE}/assets" - ${BONGO_CAT_ASSET_EXTRA_COMMANDS} - COMMAND "$" - "${BONGO_CAT_ASSET_STAGE}/assets" "${BONGO_CAT_ASSET_PACK}" - DEPENDS ${BONGO_CAT_ASSET_INPUTS} bongo_cat_asset_packer + COMMAND ${CMAKE_COMMAND} "-DEXECUTABLE=$" + "-DDUMPBIN=${BONGO_CAT_DUMPBIN}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckWindowsImports.cmake" + COMMENT "Checking the final Windows executable's input capabilities" VERBATIM) - add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") - file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) - file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" BONGO_CAT_ICON_RC) - set(BONGO_CAT_WINDOWS_MANIFEST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows.manifest") - if(MSVC) - set(BONGO_CAT_MANIFEST_RESOURCE "") - target_link_options(bongo_cat PRIVATE "/MANIFEST:EMBED" - "/MANIFESTINPUT:${BONGO_CAT_WINDOWS_MANIFEST}") - else() - file(TO_CMAKE_PATH "${BONGO_CAT_WINDOWS_MANIFEST}" BONGO_CAT_MANIFEST_RC) - set(BONGO_CAT_MANIFEST_RESOURCE "1 RT_MANIFEST \"${BONGO_CAT_MANIFEST_RC}\"") - endif() - configure_file(cmake/windows_resources.rc.in - "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" @ONLY) - set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" - APPEND PROPERTY OBJECT_DEPENDS "${BONGO_CAT_ASSET_PACK}") - target_sources(bongo_cat PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") - add_dependencies(bongo_cat bongo_cat_asset_pack) - set_target_properties(bongo_cat PROPERTIES WIN32_EXECUTABLE TRUE) endif() if(MSVC) set_property(SOURCE src/main.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") @@ -292,45 +147,32 @@ endif() bongo_cat_stage_cubism_assets(bongo_cat) +if(APPLE) + # The linker signs the executable on its own before the bundle's resources are + # copied in, which leaves strict verification failing with "code has no resources + # but signature indicates they must be present". Re-sign once every resource and + # executable change has landed, so this must stay the last POST_BUILD step. + add_custom_command(TARGET bongo_cat POST_BUILD + COMMAND /usr/bin/codesign --force --sign - "$" + COMMENT "Ad-hoc signing the BongoCat bundle" + VERBATIM) +endif() + add_custom_target(check-lines ALL COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLines.cmake VERBATIM) -if(BUILD_TESTING) - add_executable(bongo_cat_core_tests tests/test_main.c tests/test_config.c tests/test_input.c tests/test_models.c tests/test_mver_pointer.c tests/test_shortcut.c src/platform/windows_keys.c) - target_link_libraries(bongo_cat_core_tests PRIVATE bongo_cat_core bongo_cat_warnings $<$:user32>) - target_compile_definitions(bongo_cat_core_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - add_test(NAME core COMMAND bongo_cat_core_tests) - add_executable(bongo_cat_i18n_tests tests/test_i18n.c) - target_link_libraries(bongo_cat_i18n_tests PRIVATE bongo_cat_core bongo_cat_warnings) - target_compile_definitions(bongo_cat_i18n_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - add_test(NAME i18n COMMAND bongo_cat_i18n_tests) - add_executable(bongo_cat_ui_tests tests/test_nuklear.c src/ui/nuklear_impl.c src/ui/ui_paint_border.c) - target_include_directories(bongo_cat_ui_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) - target_link_libraries(bongo_cat_ui_tests PRIVATE bongo_cat_warnings) - add_test(NAME ui COMMAND bongo_cat_ui_tests) - add_executable(bongo_cat_app_state_tests tests/test_app_state.c src/core/app_state.c) - target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) - target_include_directories(bongo_cat_app_state_tests PRIVATE include) - add_test(NAME app-state COMMAND bongo_cat_app_state_tests) - add_executable(bongo_cat_mver_import_tests tests/test_mver_import.c tests/test_mver_pointer_import.c) - target_include_directories(bongo_cat_mver_import_tests PRIVATE src/runtime src/ui) - target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) - target_link_libraries(bongo_cat_mver_import_tests PRIVATE bongo_cat_runtime) - target_compile_definitions(bongo_cat_mver_import_tests PRIVATE BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") - if(MSVC) - set_property(SOURCE tests/test_mver_import.c tests/test_windows_capture.c APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") - endif() - add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) - if(WIN32) - add_executable(bongo_cat_windows_capture_tests tests/test_windows_capture.c) - target_include_directories(bongo_cat_windows_capture_tests PRIVATE src/platform) - target_link_libraries(bongo_cat_windows_capture_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) - add_test(NAME windows-capture COMMAND bongo_cat_windows_capture_tests) - endif() - if(UNIX AND NOT APPLE) - target_link_libraries(bongo_cat_ui_tests PRIVATE m) - target_link_libraries(bongo_cat_app_state_tests PRIVATE m) - endif() -endif() +add_custom_target(check-localization ALL + COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckLocalization.cmake + VERBATIM) +add_custom_target(check-platform-runtime-safety ALL + COMMAND ${CMAKE_COMMAND} -DROOT=${CMAKE_CURRENT_SOURCE_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckPlatformRuntimeSafety.cmake + VERBATIM) +add_custom_target(check-cubism-user-model-safety ALL + COMMAND ${CMAKE_COMMAND} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckCubismUserModelSafety.cmake + VERBATIM) +include(cmake/Tests.cmake) include(cmake/Packaging.cmake) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..0b098b30 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at vladelaina@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at . + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . diff --git a/LICENSE b/LICENSE index 18cd5ed3..19cc6f03 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,661 @@ -MIT License - -Copyright (c) 2026 vladelaina - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..8b7db9cd --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,30 @@ +MIT License + +SPDX-License-Identifier: MIT + +Copyright (c) 2026 vladelaina + +The default built-in model mode (`standard`) and the bundled model assets in +`resources/assets/models/standard`, `resources/assets/models/keyboard`, and +`resources/assets/models/gamepad` are provided under the MIT License. This +license applies to those model assets and their accompanying textures, +motions, expressions, and input artwork only. It does not relicense the +BongoCat source code or native runtime, which are covered by AGPL-3.0-only. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of these model assets and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..c544fe45 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,118 @@ +# Privacy Policy + +## Introduction + +**BongoCat** is a desktop application that displays an interactive desktop +pet and responds to local keyboard, mouse, and gamepad input. We value your +privacy. This policy explains what BongoCat does and does not collect, and +how information is stored. + +## Data Collection and Usage + +### Information We Do **Not** Collect + +BongoCat does not: + +* Create accounts, unique user identifiers, trackers, or profiling systems +* Collect usage statistics, telemetry, analytics, or advertising data +* Upload keyboard, mouse, gamepad, model, configuration, or system data +* Record the content of your keystrokes or input events for transmission +* Sell or share personal data with third parties + +Keyboard, mouse, and gamepad input is processed locally to animate the pet and +is not sent to a remote service. + +On Linux, experimental Wayland evdev monitoring is disabled by default and +requires explicit opt-in with `BONGOCAT_ENABLE_EVDEV=1`. When enabled and +device permissions allow it, raw keyboard events can include input in other +applications and password fields. Hiding the pet does not stop this listener, +and it does not detect screen locking or session switching. See +[Linux Input in SECURITY.md](SECURITY.md#linux-input) before enabling it. + +### Locally Stored Data + +BongoCat stores application data only on your device. The exact platform +locations are: + +* **Windows:** `%LOCALAPPDATA%\BongoCat\` (or the private package storage for + the Microsoft Store/MSIX version) +* **macOS:** `~/Library/Application Support/BongoCat/`, with caches and logs + in the corresponding macOS cache and log directories +* **Linux:** the XDG configuration, data, cache, and state directories for + `bongocat` (normally under `~/.config`, `~/.local/share`, `~/.cache`, and + `~/.local/state`) + +The following categories may be stored locally: + +1. **Settings** + User preferences, window and input options, selected models, shortcuts, + labels, and other application settings are stored in `config/settings.json`. + +2. **Session and runtime state** + Temporary session information and startup state are stored under the local + state directory. These files support normal operation and crash recovery. + +3. **Logs** + Diagnostic messages, startup and shutdown records, warnings, errors, and + limited platform information may be written to `logs/BongoCat.log`. + Logs are intended for local troubleshooting only and are never uploaded by + BongoCat. + +4. **Models and user resources** + Built-in or imported model packages, behavior definitions, sounds, fonts, + and other resources are stored in the local data and models directories. + BongoCat does not transmit these files. + +## Network Access + +BongoCat does not require a network connection for normal operation. Network +access may occur in these cases: + +* **Update checking on Windows:** When an update check is initiated or + scheduled, BongoCat sends a basic HTTPS `GET` request to the GitHub Releases + API endpoint + `https://api.github.com/repos/vladelaina/BongoCat/releases/latest`. + The request retrieves release information and does not include personal + data. The user agent identifies the application as `BongoCat Update + Checker/1.0`. Automatic update checks are not currently available on macOS + or Linux. +* **Opening external links:** If you choose a website or feedback link in the + application, BongoCat asks your operating system to open that URL in your + default browser. The browser and the destination website then apply their + own privacy policies. + +BongoCat does not use remote images, advertising networks, or analytics +services as part of its normal runtime. + +## Data Protection and Deletion + +Application data remains on your device and is not synchronized by BongoCat. +You can remove it by closing BongoCat and deleting its platform-specific +configuration, data, cache, state, and log directories. Deleting these files +also removes your settings, imported models, and local logs. + +## Permission Usage + +Permissions used by BongoCat are limited to core features: + +* **File system access:** Reading and writing settings, session state, logs, + models, and user-provided resources +* **Keyboard, mouse, and gamepad access:** Detecting local input so the pet can + react and follow the configured behavior +* **Network access on Windows:** Checking GitHub for release information when + update checking is used +* **Opening external URLs:** Only when you select a link in the application +* **Startup integration:** Only when you enable a start-with-system option, + where supported + +## Changes to This Policy + +If this policy changes materially, the updated version will be published on +the BongoCat project homepage or repository. + +## Contact + +For questions or suggestions about this privacy policy, please use the GitHub +project page: . + +> Last Updated: August 28, 2026 diff --git a/README.md b/README.md index 4a2637ac..b1fc7ed0 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,409 @@ -# BongoCat - -`BongoCat` is a small native desktop overlay for Live2D models. It keeps a -borderless, transparent window on the desktop and maps keyboard, mouse, and -gamepad input to the model. - -This README is about the source tree and its build. It is not a user manual. -The Windows build with the Live2D Cubism SDK is the reference build; the public -CI jobs build and test the same program with a diagnostic renderer because the -SDK is licensed and is not checked into this repository. - -## Current State - -The native application, bundled model packages, preferences, import path, and -platform integrations are in this tree. The evidence ledger in -[`docs/feature-audit.md`](docs/feature-audit.md) records what has been exercised -on a real Windows Cubism build and what still needs a manual check on a -particular operating system or device. - -Without Cubism, CMake selects a diagnostic backend. The application still -starts, opens its preferences, processes input, and runs the native tests, but -it cannot draw or animate a real Live2D model. Configure with -`-DBONGO_CAT_REQUIRE_CUBISM=ON` when a missing SDK should be a configuration error -instead of a diagnostic build. - -## Features - -- Standard, keyboard, and gamepad model modes, with three bundled model - packages. -- Cubism `.model3.json` loading, textures, motions, expressions, physics, pose, - eye blink, and breath updates. -- Import and removal of custom model packages. A package is checked for a valid - manifest and safe file references before it is installed under the user data - directory. -- Automatic discovery of Bongo-Cat-Mver packages in the executable directory - or its immediate parent, shown separately as nearby models. Packages may use - `config.json` + `img/` directly or an intact child directory. -- Mver 0.1.6 is the animation and input behavior baseline; standalone - Tauri/Live2D model folders are loaded through a canvas and input adapter. - Model containers with nested full packages and image-only variants (for - example `A-*` plus `Z-*` directories) are also discovered. The source stays - read-only and BongoCat caches only generated adapter files. -- Keyboard and mouse input, pointer tracking, gamepad buttons and axes, - mirroring, automatic key release, and configurable shortcuts. -- Optional keep-on-screen positioning, disabled by default. A fully unreachable - window is recovered after a display is disconnected, without polling or - interacting with fullscreen applications. -- Transparent borderless window, click-through, always-on-top, hover hiding, - monitor clamping, scaling, opacity, tray integration, and Shift + right-drag - resizing. -- Preferences, light/dark themes, and ten shipped locales: Simplified Chinese, - Traditional Chinese, English, French, German, Japanese, Korean, Brazilian - Portuguese, Russian, and Spanish. - -## How the Runtime Is Split - -The program is intentionally split at the points where the implementation has -different ownership: -```text -SDL events and native hooks - | - v - input state (C11 atomics) ---> main-thread application - | - model parameters and UI state - | - Cubism update -> OpenGL draw -``` -The platform hooks do not touch a model. They publish timestamped events to a -bounded queue; pointer coordinates are coalesced separately so mouse movement -does not consume the queue needed for key and button transitions. The main loop -drains the queue, applies releases and parameters, updates the model, and swaps -the SDL OpenGL window. +
+ + Catime + + +

+ BongoCat +

+
-Frames are submitted when something changed. An idle Cubism model is allowed to -accumulate a short time slice before it updates, which keeps an idle transparent -window quiet without making motions depend on a fixed frame rate. -The C runtime calls a small interface in `src/live2d`. The Cubism implementation -is C++17 because that is the SDK's API; the rest of the application remains C11. -This keeps SDK types out of the public headers and makes the diagnostic backend -possible. + +

+ 💘C × SDL3 × OpenGL — Three Mysterious Forces, United as One! Bong~ Bongocat!!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

-## Source Tree -```text -include/bongo_cat/ public C interfaces and data structures -src/core/ config, paths, input state, catalogs, hashes -src/runtime/ application lifecycle, model import, and UI flow -src/render/ OpenGL helpers and overlays -src/live2d/ Cubism bridge and diagnostic backend -src/platform/ Win32, Cocoa, X11, input, and tray adapters -src/ui/ Nuklear backend, preferences, themes, localization -resources/assets/ bundled models, textures, locales, and tray assets -tests/ native tests and fixtures -cmake/ dependency, optimization, audit, and source-policy modules -docs/ audit evidence and parity notes -``` + +
+ +
-## Dependencies -The build uses SDL3 for the window and event loop, desktop OpenGL for drawing, -yyjson for JSON, stb for image loading, miniaudio for motion audio, and -Nuklear for the preferences UI. With `BONGO_CAT_FETCH_DEPS=ON` (the default), CMake -fetches pinned revisions of those open-source dependencies and verifies their -SHA-256 values. Set it to `OFF` to use installed packages and headers instead. +image -The Live2D Cubism SDK is a separate download. Put Cubism SDK for Native 5 r.5 -at `vendor/CubismSdkForNative` after accepting Live2D's license. The expected -tree contains `Core`, `Framework`, and the SDK's OpenGL sample dependencies. -On Windows, run the SDK helper once: -```powershell -cd vendor\CubismSdkForNative\Samples\OpenGL\thirdParty\scripts -.\setup_glew_glfw.bat -``` +> [!TIP] +> The model featured in this demonstration is from [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Looking for **free** models? We work with talented model creators to bring you a wide variety of free models, while continuously exploring more fun desktop experiences! Visit our official website: [bongocat.pet](https://bongocat.pet/models) +

+ + + +

-The SDK is ignored by Git and is never fetched by this project. +

+ +

-## Building +## 📥 Download -### Windows with Cubism + + + -Use Visual Studio 2022 or the matching Build Tools (v143, Windows 10/11 SDK): +- GitHub Releases -```bat -build.bat Release -``` + Download the latest release from [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). -The repository build script displays a live `#F77DAA` progress bar, keeps -configuration and build logs under `build-cubism/`, and uses two parallel jobs -by default. Set `BONGOCAT_BUILD_JOBS` to change the job count or set -`BONGOCAT_CLEAN_BUILD=1` to request a clean build. The Cubism SDK is used -automatically when it is present; set `BONGOCAT_REQUIRE_CUBISM=1` to fail -instead of using the diagnostic backend when it is missing. The equivalent -manual commands are: +## 🛠️ Build From Source -```powershell -cmake -S . -B build-cubism -G "Visual Studio 17 2022" -A x64 ` - -DBONGO_CAT_REQUIRE_CUBISM=ON ` - -DBONGO_CAT_WARNINGS_AS_ERRORS=ON -cmake --build build-cubism --config Release --parallel 2 -ctest --test-dir build-cubism -C Release --output-on-failure -``` +BongoCat uses CMake and requires a C11 compiler, a C++17 compiler, CMake 3.24 +or newer, and desktop OpenGL development files. SDL3, yyjson, stb, miniaudio, +and Nuklear are downloaded at configure time by default, so the first +configuration needs network access. + +Run the commands below from the project root (the directory containing +`CMakeLists.txt`). + +### 📋 Platform Prerequisites + +- **Windows:** Visual Studio 2022 with the Desktop C++ workload and CMake. + Use the MSVC generator; MinGW can build the diagnostic backend but is not + supported for the Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake, and Ninja. Select an architecture + with `CMAKE_OSX_ARCHITECTURES` when it differs from the host default. +- **Linux (Debian/Ubuntu):** GCC or Clang, Ninja, and the OpenGL/X11 headers: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` -The executable is `build-cubism/Release/BongoCat.exe`. +### 🔧 Configure and Build -Build the versioned release archive and SHA-256 file with: +On Linux and macOS, use a single-configuration generator such as Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +On Windows, run from a Visual Studio 2022 developer shell (or another shell +where MSVC is available): ```powershell -cmake --build build-cubism --config Release --target package +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel ``` -Release archives use the conventional product-version-platform-architecture -format, for example `BongoCat-0.1.0-windows-x64.zip`. The executable inside -keeps the stable `BongoCat.exe` name so upgrades do not break shortcuts. +The executable is written to `build/BongoCat` on Linux, to +`build/BongoCat.app/Contents/MacOS/BongoCat` on macOS, and to +`build/Release/BongoCat.exe` for Visual Studio builds. -### Diagnostic or Unix build +### 🧪 Tests -```powershell -cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build +CTest targets are enabled by default. Run them after building: + +```bash ctest --test-dir build --output-on-failure ``` -The native Linux branch requires the usual OpenGL and X11/XInput2/Xfixes -development packages. macOS uses its Cocoa and ApplicationServices frameworks. - -To use system dependencies rather than CMake's pinned downloads: +For a multi-configuration generator such as Visual Studio, select the build +configuration explicitly: ```powershell -cmake -S . -B build -G Ninja -DBONGO_CAT_FETCH_DEPS=OFF +ctest --test-dir build -C Release --output-on-failure ``` -When fetching is disabled, CMake needs SDL3-static and yyjson package -configurations plus `stb_image.h`, `stb_image_write.h`, `miniaudio.h`, and -`nuklear.h`. Their include roots can be set with -`BONGO_CAT_STB_INCLUDE_DIR`, `BONGO_CAT_MINIAUDIO_INCLUDE_DIR`, and -`BONGO_CAT_NUKLEAR_INCLUDE_DIR`. +### 🎭 Live2D / Cubism SDK (Optional) -### Install tree +If the Cubism SDK is not present, CMake emits a warning and builds the +diagnostic backend. This backend is intended for startup and platform +diagnostics; it does not provide Live2D model rendering. To build the full +runtime, install a compatible Cubism SDK for Native and either place it at +`vendor/CubismSdkForNative` or pass its location explicitly: -```powershell -cmake --install build --config Release --component Runtime --prefix out +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON ``` -The install tree is portable. Windows embeds the resource archive; Unix builds -place `assets/` beside the executable or application bundle. Settings and -imported models are kept in the per-user data directory. -Native preferences and session files use schema version 2. Version 1 and any -other unsupported schema are rejected and rebuilt from current defaults; no -configuration migration path is retained. +The SDK must contain its Core library, Framework sources, and the OpenGL GLEW +third-party tree in the layout expected by `cmake/Cubism.cmake`. Windows +Cubism builds require Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` makes +configuration fail instead of silently selecting the diagnostic backend. + +### ⚙️ CMake Options + +| Option | Default | Description | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Download the pinned third-party dependencies with CMake `FetchContent`. Set `OFF` only when SDL3, yyjson, stb, miniaudio, and Nuklear are already available to CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path to the Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fail configuration when a usable Cubism SDK is unavailable. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Treat native compiler warnings as errors. | + +For an offline build with `BONGO_CAT_FETCH_DEPS=OFF`, provide CMake package +configurations for SDL3 (including `SDL3-static`) and yyjson, plus the include +directories for stb, Nuklear, and miniaudio when they are not discoverable: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Project Status + + +## 📜 License + +The BongoCat source code and native runtime are licensed under +[AGPL-3.0-only](LICENSE). + +The default built-in model mode (`standard`) remains MIT-licensed. The +bundled model assets in `resources/assets/models/standard`, `keyboard`, and +`gamepad` are covered by the separate [MIT license notice](LICENSE-MIT). +That MIT license applies to the model assets and their accompanying artwork +only; it does not relicense the BongoCat source code or native runtime. + -## Runtime Data and Test Switches +## 🧭 Technical Architecture -The default data directory comes from SDL's preference path and contains -`preferences.json`, `session.json`, `custom-models/`, and the generated `portable-mver/` adapter -cache. A Bongo-Cat-Mver package can be used without importing by placing its -`config.json` and `img/` beside `BongoCat`, by placing the intact package -directory there, or by keeping both under the same immediate parent directory. -A collection such as `露西亚-誓焰版` may be selected as one folder; its full -package and nested image patches are discovered together. -Tests and portable launches may override both paths: +> The current native version is built on C/C++, SDL3, and OpenGL. The diagram below focuses on the runtime data flow; build and packaging details live in CMake. + +### 🔄 Runtime Ownership and Frame Scheduling + +Each process owns one `BongoCatApp` and one main-thread event and render loop. +Platform listeners stop at the input boundary: ```text -BongoCat --data-root=C:\path\to\data --preferences=C:\path\to\preferences.json --session=C:\path\to\session.json +Platform listeners +(keyboard / pointer) + | + v + C11 input state + (atomic edge queue + coalesced pointer position) + | + v + main-thread application <----- SDL3 events + | + v + model parameters, overlay, and UI state + | + v + model update -> OpenGL composition -> platform presentation ``` -Arguments beginning with `--ci-` are test instrumentation. They select a model, -preference page, language, theme, frame audit, or input audit; they are not a -stable end-user command-line interface. +The Windows Raw Input receiver, macOS Quartz event tap, and Linux XInput2 listener +run outside the main loop. They publish timestamped key and mouse-button edges +to the bounded atomic queue and coalesce pointer motion separately, so frequent +motion cannot displace ordered key and button edges. Native SDL wake events +notify the main thread. Windows registers a message-only receiver for background +keyboard and mouse input with `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, preserving legacy +window messages. Device motion drives the model when another application hides +or locks the cursor; SDL supplies the desktop cursor position. The receiver +tracks held inputs per device and clears them on device removal or desktop +switches. It does not install input hooks, use DirectInput, or send input to games. +SDL3 window, preferences, and gamepad events +are handled on the main thread, where gamepad events are normalized before they +reach model parameters or shortcuts. No platform listener calls Live2D, +overlay, or UI code directly. + +`bongo_cat_app_run` handles update-shutdown and secondary-process arguments, +enforces single-instance ownership for the primary process, allocates the +application state, runs initialization, enters `bongo_cat_app_loop`, and then +flushes state and destroys resources in a defined order. Initialization loads +configuration and storage paths, locates assets, creates the SDL/OpenGL pet +window, initializes the platform backend, creates the Live2D, overlay, and +audio services, scans the built-in/installed/nearby model sources, and loads a +usable model. `BongoCatApp` owns settings, session state, model and behavior +catalogs, platform handles, and runtime service handles. + +Installed model packages use Mver as the canonical format. The import workflow +resolves a selected file or directory, discovers and validates candidates, +fingerprints package identity, converts Tauri sources to Mver, applies image +patches, and commits the normalized package under `models_root`. It then +generates the runtime adapter and refreshes the catalogs. Nearby sources are +discovered without installing their source tree; their adapters and inspection +results are cached outside `models_root` under `cache_root`. + +Each main-loop iteration waits for SDL/native wakeups or the earliest pending +frame, UI, animation, or pointer-hit deadline (with a maximum wait of 250 ms). +It dispatches queued SDL events, drains the atomic input queue and release +recovery, updates window and model-refresh state, and applies input-derived +parameters. With Cubism enabled, the model deadline follows +`settings.model.max_fps` (60 FPS by default); diagnostic builds use a 100 ms +fallback interval. The elapsed model time is capped at 250 ms and split into up +to eight substeps, targeting no more than 1/30 s per substep. + +The normal pet path renders only when the window is visible, not minimized, and +marked dirty. A frame clears the background, draws the model, and composites +pointer, key, and effect overlays before calling the platform presenter. +Preview operations can request immediate renders, while capture renders may +skip presentation. macOS and Linux swap the SDL OpenGL window directly. +Windows swaps directly when layered presentation is inactive and otherwise +reads back the frame for `UpdateLayeredWindow`. The preferences UI owns a +separate SDL/OpenGL window and is rendered and presented independently from +the pet window. + +The C runtime calls the ABI declared in `include/bongo_cat/model.h`. The Live2D +bridge and Cubism implementation live in `src/live2d` and use C++17 only when +the Cubism SDK is enabled; the rest of the native runtime uses C11. Cubism +types remain behind opaque C handles, while `src/live2d/live2d_stub.c` provides +the diagnostic backend when the SDK is unavailable. + + +```mermaid +flowchart TB + Input(["Keyboard / mouse / gamepad"]) + BuiltIn(["Built-in model assets"]) + Sources(["External model sources
Mver, Tauri, .model3.json, image patches"]) + Desktop(["Pet window and preferences window"]) + + subgraph Runtime["BongoCat native runtime"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Startup and initialization
configuration, storage, window, platform"] + Loop["SDL3 main loop
wait, dispatch, update, render"] + Shutdown["Shutdown
flush state, stop services, release resources"] + InputQueue[("Atomic input state
edge queue and coalesced pointer position")] + InputDispatch["Input dispatch
shortcuts, pointer mapping, model parameters"] + State[("BongoCatApp state
settings, session, catalogs, runtime handles")] + Import["Model discovery and import
validate, normalize to Mver, install/cache"] + Catalog[("Model and behavior catalogs")] + Live2D["Live2D C ABI
Cubism SDK or diagnostic stub"] + Overlay["Overlay and audio"] + Preferences["Preferences and desktop shell
Nuklear UI, tray, window actions"] + Compose["OpenGL frame composition"] + Present["Platform presentation"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + + subgraph Platform["Platform backends"] + direction LR + Global["Global keyboard / pointer capture
Windows, macOS, Linux"] + SDL["SDL3 events
window and gamepad events"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` -## Tests +## ❓ FAQ -The CTest suite covers strict configuration-format validation, model discovery, -input ordering and recovery, shortcuts, localization, UI helpers, application -state, and SHA-256 resource validation. Only the current configuration format -is accepted; older schemas are rejected without migration. +### 🔒 Does BongoCat record my keyboard or mouse input? -```powershell -ctest --test-dir build --output-on-failure -cmake --build build --target check-lines +No. BongoCat processes keyboard and mouse input locally to drive animations +and shortcuts. It does not record or upload your keystrokes, mouse actions, or +other interaction data. Configuration is stored locally as well, and the app +contains no ads, analytics tools, or user-tracking code. When an update check +is performed, it only requests public release metadata; it does not send input, +configuration, or usage data. + +### Linux Wayland Input + +X11 uses XInput2 by default. Experimental evdev input for Wayland is off by +default. After reviewing [the input permission risks](SECURITY.md#linux-input), +it can be explicitly selected for one launch: + +```sh +BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat ``` -`check-lines` enforces a default maximum of 300 physical lines for native C, -C++, Objective-C, header, CMake module, and native test files. A small set of -mechanically dense files has reviewed, explicit limits in the policy. The rule -is a review aid, not a claim that shorter files are automatically better. +This does not grant device permissions. Do not run the app as root or add +your account to the `input` group to make it work. Raw input can include +password keystrokes and is not paused on screen lock or session switching. +Close the app to stop monitoring; hiding it does not stop input. Launch +without the variable to return to the default backend. Evdev mouse following +uses unaccelerated device motion; Wayland placement, click-through, and +always-on-top support still depend on the compositor. + +### 🖼️ Why OpenGL instead of Vulkan? + +We chose OpenGL not because Vulkan is bad, but because BongoCat does not need +that level of complexity. The app mainly renders one Live2D model, a few UI +layers, and a transparent desktop window. OpenGL already handles that +comfortably, and it works naturally with SDL3 and Cubism's OpenGL renderer. +Moving to Vulkan would mean maintaining much more rendering and synchronization +code across three desktop platforms, without a noticeable improvement for +users. For BongoCat's current workload, OpenGL keeps the renderer smaller, +easier to debug, and easier to maintain while still delivering the performance +we need. + + + +## Project Status +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## Sponsors + +
+ + + + + +
+ SignPath + + Free code signing on Windows provided by + SignPath.io, certificate by + SignPath Foundation +
+
+ + + +## 🙏 Special Thanks +> [!TIP] +> Every step BongoCat takes is powered by the spirit of open source. We sincerely thank all our community contributors for their selfless contributions It is your support that makes desktop companionship more free and genuine.❤️‍🔥 + + + + + + -## Continuous Integration +[linux.do](https://linux.do/t/topic/2845597) +--- -GitHub Actions runs Cppcheck, the source-size check, a warning-clean Linux build, -CTest, and a Windows/Linux/macOS build matrix on pushes, pull requests, tags, -and manual runs. CI artifacts use the diagnostic backend; a licensed Cubism SDK -is not provisioned on the runners. +
-## Third-Party and Distribution Notes +Copyright © 2026 - **BongoCat**\ +By vladelaina\ +Made with ❤️ & ⌨️ -Third-party components retain their upstream licenses. Cubism SDK files must be -obtained from Live2D and are not redistributed here. Before distributing a -binary, review the licenses for SDL3, yyjson, stb, miniaudio, Nuklear, OpenGL, -and the selected platform SDK together with the project's own distribution -terms. +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f8c0f721 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,76 @@ +# Security Policy + +## Windows Input and Game Compatibility + +The Windows input backend subscribes to keyboard and mouse Raw Input using +`RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` on a message-only window owned by BongoCat. +It preserves legacy input delivery and does not install global input hooks, +inject code into games, read or write game memory, simulate game input, or +install an input driver. The normal Windows runtime requests `asInvoker` +with `uiAccess="false"`; it does not request administrator privileges. + +Foreground window and cursor queries provide read-only state for pointer +tracking and local diagnostics. Normal input diagnostics contain counters, +timing, and window/cursor metadata, rather than typed text. Development tools +that simulate input are separate `EXCLUDE_FROM_ALL` targets and are not part +of the application target or its installation rules. + +Input diagnostics are enabled in the normal session log. At most once every +10 seconds per running loop, they report cumulative receiver/consumer counters +and a current display snapshot, including the foreground PID and monitor +bounds. They do not log key names, scan codes, text, window titles, process +paths, or device identifiers. Counter updates do not write a log per input +event. Receiver startup/shutdown and registration failures are logged separately. + +Registration success alone does not establish that input messages are +arriving. BongoCat does not attempt to bypass a game's input restrictions. +The receiver checks its process-local registrations once per second and +restores missing subscriptions or background flags on its own receiver. +It leaves registrations targeting another receiver untouched. Recovery is +not triggered by a period without input or by the foreground monitor, and +is deferred while the normal input desktop is unavailable. + +Using documented Windows APIs does not guarantee acceptance by every +anti-cheat product. The source checks in +`cmake/CheckPlatformRuntimeSafety.cmake` enforce the project's input API +boundaries; they are not an anti-cheat certification or a substitute for +testing the actual release build. + +## Linux Input + +X11 uses XInput2 by default. Experimental Wayland input through evdev is +disabled unless the process is started with `BONGOCAT_ENABLE_EVDEV=1` in a +Wayland session. Restart without that variable to disable it. This opt-in +selects evdev for the entire session; unreadable devices do not trigger +automatic backend switching. Window placement still depends on the compositor. + +The evdev backend opens existing input event devices read-only and never +changes permissions, installs udev rules, requests root, grabs devices, or +injects input. Do not run BongoCat as root or add your account to the +`input` group just to enable this feature. Broad input-group membership or +generic udev rules can also grant other processes under your account access +to your keyboard. Device access must be managed separately by your system +administrator with the narrowest permissions appropriate for the machine. + +Raw device input is more sensitive than compositor-mediated input: it can +include keys entered in password fields, and this backend does not detect +screen locking or session changes. Hiding the pet does not stop monitoring. +Do not enable evdev where this scope is unacceptable; exit BongoCat before +locking or switching sessions when using this experimental backend. Removing +a device ACL is not a guarantee that already-open descriptors are revoked. +Key events stay in memory for animation and shortcuts; diagnostics contain +device counts, not key names or input contents. They are not uploaded. + +Each device has separate pressed state. Disconnects release its held inputs. +On kernel buffer overflow, known keys are released and incomplete events are +discarded until the next synchronization report. Since the backend deliberately +does not query device state with ioctl, a key held across overflow must be +released and pressed again. Relative mouse motion is raw and unaccelerated, +not the compositor's global cursor position; absolute touchpad motion is not +converted into relative motion. + +## Reporting Security Issues + +We always aim to ship secure software and take security flaws seriously. Thank you for responsibly disclosing your findings to the team to keep the open source community a safe space. + +To report a security issue, reach us at vladelaina@gmail.com. We will be in touch should we need any additional information or guidance to fix the bug. diff --git a/build.bat b/build.bat index c364cd60..3e80970d 100644 --- a/build.bat +++ b/build.bat @@ -8,8 +8,13 @@ set "BUILD_JOBS=%BONGOCAT_BUILD_JOBS%" if "%BUILD_JOBS%"=="" set "BUILD_JOBS=2" set "CLEAN_ARG=" if /I "%BONGOCAT_CLEAN_BUILD%"=="1" set "CLEAN_ARG=-Clean" +if /I "%~2"=="-Clean" set "CLEAN_ARG=-Clean" +if /I "%~3"=="-Clean" set "CLEAN_ARG=-Clean" set "CUBISM_ARG=" if /I "%BONGOCAT_REQUIRE_CUBISM%"=="1" set "CUBISM_ARG=-RequireCubism" +set "PACKAGE_ARG=" +if /I "%~2"=="-Package" set "PACKAGE_ARG=-Package" +if /I "%~3"=="-Package" set "PACKAGE_ARG=-Package" where powershell.exe >nul 2>&1 if errorlevel 1 ( @@ -21,6 +26,6 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ^ -File "%~dp0scripts\build-windows.ps1" ^ -Configuration "%BUILD_TYPE%" ^ -BuildDir "%~dp0build-cubism" ^ - -Jobs %BUILD_JOBS% %CUBISM_ARG% %CLEAN_ARG% + -Jobs %BUILD_JOBS% %CUBISM_ARG% %CLEAN_ARG% %PACKAGE_ARG% exit /b %ERRORLEVEL% diff --git a/cmake/AboutWebP.cmake b/cmake/AboutWebP.cmake new file mode 100644 index 00000000..17985d18 --- /dev/null +++ b/cmake/AboutWebP.cmake @@ -0,0 +1,43 @@ +# The live /co SVG embeds PNG, JPEG and WebP avatars. Decode all in memory +# with C libraries; only the decoder is linked, no command-line tools installed. +if(BONGO_CAT_FETCH_DEPS) + # Keep the decoder static without changing the parent's library policy. + function(bongo_cat_add_about_webp) + if(POLICY CMP0168) + # Download directly on CMake 3.30+, without an MSBuild sub-project. + cmake_policy(SET CMP0168 NEW) + endif() + set(BUILD_SHARED_LIBS OFF) + set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE) + set(WEBP_USE_THREAD OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_FUZZTEST OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_CWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_DWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_GIF2WEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_IMG2WEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_VWEBP OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_WEBPINFO OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_WEBPMUX OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_EXTRAS OFF CACHE BOOL "" FORCE) + set(WEBP_BUILD_LIBWEBPMUX OFF CACHE BOOL "" FORCE) + FetchContent_Declare(about_webp + URL https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz + URL_HASH SHA256=e4ab7009bf0629fd11982d4c2aa83964cf244cffba7347ecd39019a9e38c4564 + DOWNLOAD_EXTRACT_TIMESTAMP FALSE + # Populate only; manually add with EXCLUDE_FROM_ALL for CMake 3.24 too. + SOURCE_SUBDIR bongo-download-only) + FetchContent_MakeAvailable(about_webp) + if(NOT TARGET webpdecoder) + add_subdirectory("${about_webp_SOURCE_DIR}" "${about_webp_BINARY_DIR}" EXCLUDE_FROM_ALL) + endif() + target_include_directories(bongo_cat_runtime SYSTEM PRIVATE "${about_webp_SOURCE_DIR}/src") + target_link_libraries(bongo_cat_runtime PRIVATE webpdecoder) + endfunction() + bongo_cat_add_about_webp() +else() + find_path(BONGO_CAT_WEBP_INCLUDE_DIR webp/decode.h REQUIRED) + find_library(BONGO_CAT_WEBP_LIBRARY NAMES webpdecoder webp REQUIRED) + target_include_directories(bongo_cat_runtime SYSTEM PRIVATE "${BONGO_CAT_WEBP_INCLUDE_DIR}") + target_link_libraries(bongo_cat_runtime PRIVATE "${BONGO_CAT_WEBP_LIBRARY}") +endif() diff --git a/cmake/ApplicationResources.cmake b/cmake/ApplicationResources.cmake new file mode 100644 index 00000000..c56df9a5 --- /dev/null +++ b/cmake/ApplicationResources.cmake @@ -0,0 +1,85 @@ +if(APPLE) + set(BONGO_CAT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.icns") + set_target_properties(bongo_cat PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "BongoCat" + MACOSX_BUNDLE_GUI_IDENTIFIER com.bongocat.desktop + MACOSX_BUNDLE_ICON_FILE "icon.icns" + MACOSX_BUNDLE_INFO_PLIST + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Info.plist.in") + # Stage the icon with other resources before any later POST_BUILD signing. + # Relink when it changes so incremental builds also run the resource copy. + set_property(TARGET bongo_cat APPEND PROPERTY LINK_DEPENDS + "${BONGO_CAT_APP_ICON}") + add_custom_command(TARGET bongo_cat POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -rf + "$/MacOS/assets" + "$/MacOS/FrameworkShaders" + "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${BONGO_CAT_APP_ICON}" + "$/Resources/icon.icns" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "$/Resources/assets" + COMMAND ${CMAKE_COMMAND} -E rm -f + "$/Resources/assets/models/LICENSE" + VERBATIM) +endif() + +if(WIN32) + target_sources(bongo_cat_runtime PRIVATE + src/platform/windows/windows_keys.c) + add_executable(bongo_cat_asset_packer src/tools/asset_packer.c) + target_compile_definitions(bongo_cat_asset_packer PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(bongo_cat_asset_packer PRIVATE bongo_cat_warnings) + set(BONGO_CAT_ASSET_STAGE "${CMAKE_CURRENT_BINARY_DIR}/embedded-assets") + set(BONGO_CAT_ASSET_PACK "${CMAKE_CURRENT_BINARY_DIR}/bongocat-assets.pak") + file(GLOB_RECURSE BONGO_CAT_ASSET_INPUTS CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets/*") + bongo_cat_configure_embedded_cubism_assets() + add_custom_command(OUTPUT "${BONGO_CAT_ASSET_PACK}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${BONGO_CAT_ASSET_STAGE}" + COMMAND ${CMAKE_COMMAND} -E make_directory + "${BONGO_CAT_ASSET_STAGE}/assets" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "${BONGO_CAT_ASSET_STAGE}/assets" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + "${BONGO_CAT_ASSET_STAGE}/assets/LICENSE" + ${BONGO_CAT_ASSET_EXTRA_COMMANDS} + COMMAND "$" + "${BONGO_CAT_ASSET_STAGE}/assets" "${BONGO_CAT_ASSET_PACK}" + DEPENDS ${BONGO_CAT_ASSET_INPUTS} "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + bongo_cat_asset_packer + VERBATIM) + add_custom_target(bongo_cat_asset_pack DEPENDS "${BONGO_CAT_ASSET_PACK}") + file(TO_CMAKE_PATH "${BONGO_CAT_ASSET_PACK}" BONGO_CAT_ASSET_PACK_RC) + file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/icon.ico" + BONGO_CAT_ICON_RC) + set(BONGO_CAT_WINDOWS_MANIFEST + "${CMAKE_CURRENT_BINARY_DIR}/windows.manifest") + configure_file(cmake/windows.manifest.in + "${BONGO_CAT_WINDOWS_MANIFEST}" @ONLY) + if(MSVC) + set(BONGO_CAT_MANIFEST_RESOURCE "") + target_sources(bongo_cat PRIVATE "${BONGO_CAT_WINDOWS_MANIFEST}") + else() + file(TO_CMAKE_PATH "${BONGO_CAT_WINDOWS_MANIFEST}" + BONGO_CAT_MANIFEST_RC) + set(BONGO_CAT_MANIFEST_RESOURCE + "1 RT_MANIFEST \"${BONGO_CAT_MANIFEST_RC}\"") + endif() + configure_file(cmake/windows_resources.rc.in + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" @ONLY) + set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc" + APPEND PROPERTY OBJECT_DEPENDS "${BONGO_CAT_ASSET_PACK}") + target_sources(bongo_cat PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") + add_dependencies(bongo_cat bongo_cat_asset_pack) + set_target_properties(bongo_cat PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/cmake/Archive.cmake b/cmake/Archive.cmake new file mode 100644 index 00000000..eab8e39a --- /dev/null +++ b/cmake/Archive.cmake @@ -0,0 +1,19 @@ +if(BONGO_CAT_FETCH_DEPS) + FetchContent_Declare(miniz + URL https://codeload.github.com/richgel999/miniz/tar.gz/refs/tags/3.1.0 + URL_HASH SHA256=09569fc19d060ac9f5999ba9356728c2494ebe6a24ac0eb0a6b6ae3d396cfea6 + SOURCE_SUBDIR bongocat-unused) + FetchContent_MakeAvailable(miniz) + # Build only the library, without changing the parent's build options. + file(WRITE "${miniz_BINARY_DIR}/miniz_export.h" + "#pragma once\n#define MINIZ_EXPORT\n") + add_library(bongo_cat_archive STATIC + "${miniz_SOURCE_DIR}/miniz.c" "${miniz_SOURCE_DIR}/miniz_zip.c" + "${miniz_SOURCE_DIR}/miniz_tinfl.c" "${miniz_SOURCE_DIR}/miniz_tdef.c") + target_include_directories(bongo_cat_archive SYSTEM PUBLIC + "${miniz_SOURCE_DIR}" "${miniz_BINARY_DIR}") +else() + find_package(miniz CONFIG REQUIRED) + add_library(bongo_cat_archive INTERFACE) + target_link_libraries(bongo_cat_archive INTERFACE miniz::miniz) +endif() diff --git a/cmake/AudioDecoder.cmake b/cmake/AudioDecoder.cmake new file mode 100644 index 00000000..19aae316 --- /dev/null +++ b/cmake/AudioDecoder.cmake @@ -0,0 +1,35 @@ +# Own the decoder translation unit so WAV, FLAC and Ogg/Vorbis work equally +# with downloaded dependencies and system packages. No extra codec DLLs. +if(EXISTS "${BONGO_CAT_STB_INCLUDE_DIR}/stb_vorbis.c") + set(BONGO_CAT_VORBIS_HEADER "stb_vorbis.c") +elseif(EXISTS "${BONGO_CAT_STB_INCLUDE_DIR}/stb_vorbis.h") + set(BONGO_CAT_VORBIS_HEADER "stb_vorbis.h") +else() + message(FATAL_ERROR "Install the complete stb headers, including stb_vorbis.c or stb_vorbis.h") +endif() +if(BONGO_CAT_FETCH_DEPS) + set_property(TARGET miniaudio PROPERTY SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/media/audio/miniaudio_impl.c") + target_include_directories(miniaudio SYSTEM PRIVATE "${BONGO_CAT_STB_INCLUDE_DIR}") +else() + add_library(bongo_cat_audio_decoder STATIC src/media/audio/miniaudio_impl.c) + if(BONGO_CAT_MINIAUDIO_INCLUDE_DIR) + target_include_directories(bongo_cat_audio_decoder SYSTEM PUBLIC + "${BONGO_CAT_MINIAUDIO_INCLUDE_DIR}") + else() + target_include_directories(bongo_cat_audio_decoder SYSTEM PUBLIC + "$") + endif() + target_include_directories(bongo_cat_audio_decoder SYSTEM PRIVATE + "${BONGO_CAT_STB_INCLUDE_DIR}") + find_package(Threads REQUIRED) + target_link_libraries(bongo_cat_audio_decoder PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) + if(UNIX) + target_link_libraries(bongo_cat_audio_decoder PRIVATE m) + endif() + set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_audio_decoder) +endif() +target_compile_definitions(${BONGO_CAT_MINIAUDIO_TARGET} PUBLIC + MA_NO_ENCODING MA_NO_GENERATION MA_NO_CUSTOM) +target_compile_definitions(${BONGO_CAT_MINIAUDIO_TARGET} PRIVATE + BONGO_CAT_VORBIS_HEADER="${BONGO_CAT_VORBIS_HEADER}") diff --git a/cmake/CheckCubismUserModelSafety.cmake b/cmake/CheckCubismUserModelSafety.cmake new file mode 100644 index 00000000..5d270c66 --- /dev/null +++ b/cmake/CheckCubismUserModelSafety.cmake @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.24) + +include("${CMAKE_CURRENT_LIST_DIR}/CubismUserModelSafety.cmake") + +function(assert_patched variable label) + set(source "${${variable}}") + bongo_cat_patch_cubism_renderer_creation(source) + set(expected [=[if (_renderer) + { + _renderer->Initialize(_model, maskBufferCount); + }]=]) + string(FIND "${source}" "${expected}" guard_position) + if(guard_position EQUAL -1) + message(FATAL_ERROR "Cubism user-model safety test failed: ${label}") + endif() +endfunction() + +set(official_unsafe [=[void CubismUserModel::CreateRenderer( + csmUint32 width, csmUint32 height, csmInt32 maskBufferCount) +{ + _renderer = Rendering::CubismRenderer::Create(width, height); + + _renderer->Initialize(_model, maskBufferCount); +} + +void CubismUserModel::DeleteRenderer() +{ +}]=]) +assert_patched(official_unsafe "official r.5 layout") + +set(alternate_unsafe [=[void CubismUserModel::CreateRenderer(csmInt32 maskBufferCount) +{ + _renderer=Rendering::CubismRenderer::Create(); + _renderer -> Initialize ( _model , maskBufferCount ) ; +} +void CubismUserModel::DeleteRenderer() +{ +}]=]) +assert_patched(alternate_unsafe "alternate renderer signature") + +set(already_safe [=[void CubismUserModel::CreateRenderer(csmInt32 maskBufferCount) +{ + _renderer = Rendering::CubismRenderer::Create(); + if (_renderer != nullptr) + { + _renderer->Initialize(_model, maskBufferCount); + } +} +void CubismUserModel::DeleteRenderer() +{ +}]=]) +set(expected_safe "${already_safe}") +bongo_cat_patch_cubism_renderer_creation(already_safe) +if(NOT already_safe STREQUAL expected_safe) + message(FATAL_ERROR + "Cubism user-model safety test failed: safe source was modified") +endif() + +message(STATUS "Cubism user-model safety patch policy passed") diff --git a/cmake/CheckLines.cmake b/cmake/CheckLines.cmake index e3349445..bf6a146b 100644 --- a/cmake/CheckLines.cmake +++ b/cmake/CheckLines.cmake @@ -1,3 +1,5 @@ +cmake_minimum_required(VERSION 3.24) + if(NOT DEFINED ROOT) message(FATAL_ERROR "ROOT is required") endif() @@ -22,30 +24,7 @@ foreach(FILE IN LISTS FILES) file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") - set(LIMIT 300) - if(RELATIVE_FILE STREQUAL "src/live2d/cubism_model_load.cpp") - set(LIMIT 358) - elseif(RELATIVE_FILE STREQUAL "src/media/image.c") - set(LIMIT 332) - elseif(RELATIVE_FILE STREQUAL "src/runtime/model_import_portable.c") - set(LIMIT 341) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_render.c") - set(LIMIT 308) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_scale.c") - set(LIMIT 307) - elseif(RELATIVE_FILE STREQUAL "src/ui/preferences_model.c") - set(LIMIT 304) - elseif(RELATIVE_FILE STREQUAL "tests/test_mver_import.c") - set(LIMIT 327) - elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_blind_test.c") - set(LIMIT 376) - elseif(RELATIVE_FILE STREQUAL "src/tools/cubism_viewer_desktop_capture.c") - set(LIMIT 396) - elseif(RELATIVE_FILE STREQUAL "src/tools/mver_phase_metrics.c") - set(LIMIT 321) - elseif(RELATIVE_FILE STREQUAL "CMakeLists.txt") - set(LIMIT 336) - endif() + set(LIMIT 500) if(COUNT GREATER LIMIT) list(APPEND FAILED "${RELATIVE_FILE}: ${COUNT} (limit ${LIMIT})") diff --git a/cmake/CheckLocalization.cmake b/cmake/CheckLocalization.cmake new file mode 100644 index 00000000..4e75bee2 --- /dev/null +++ b/cmake/CheckLocalization.cmake @@ -0,0 +1,45 @@ +if(NOT DEFINED ROOT) + message(FATAL_ERROR "ROOT is required") +endif() + +file(GLOB_RECURSE SOURCES + "${ROOT}/src/*.c" "${ROOT}/src/*.cc" "${ROOT}/src/*.cpp" + "${ROOT}/src/*.cxx" "${ROOT}/src/*.m" "${ROOT}/src/*.mm" + "${ROOT}/src/*.h" "${ROOT}/src/*.hh" "${ROOT}/src/*.hpp" + "${ROOT}/src/*.hxx" "${ROOT}/include/*.h" "${ROOT}/include/*.hh" + "${ROOT}/include/*.hpp" "${ROOT}/include/*.hxx") +set(KEYS "") +foreach(SOURCE IN LISTS SOURCES) + file(READ "${SOURCE}" CONTENT) + string(REGEX MATCHALL + "\"(native|pages|components|composables)\\.[A-Za-z0-9_.]+\"" + FOUND_KEYS "${CONTENT}") + foreach(FOUND IN LISTS FOUND_KEYS) + string(LENGTH "${FOUND}" LENGTH) + math(EXPR LAST "${LENGTH} - 2") + string(SUBSTRING "${FOUND}" 1 ${LAST} KEY) + list(APPEND KEYS "${KEY}") + endforeach() +endforeach() +list(REMOVE_DUPLICATES KEYS) + +file(GLOB LOCALES "${ROOT}/resources/assets/locales/*.json") +if(NOT LOCALES) + message(FATAL_ERROR "No locale files found") +endif() +foreach(LOCALE IN LISTS LOCALES) + file(READ "${LOCALE}" JSON) + foreach(KEY IN LISTS KEYS) + string(REPLACE "." ";" PARTS "${KEY}") + set(VALUE "${JSON}") + foreach(PART IN LISTS PARTS) + string(JSON VALUE ERROR_VARIABLE ERROR GET "${VALUE}" "${PART}") + if(ERROR) + message(FATAL_ERROR + "Missing localization key '${KEY}' in ${LOCALE}") + endif() + endforeach() + endforeach() +endforeach() +list(LENGTH KEYS KEY_COUNT) +message(STATUS "Localization key policy passed (${KEY_COUNT} keys)") diff --git a/cmake/CheckMacOSBundleIcon.cmake b/cmake/CheckMacOSBundleIcon.cmake new file mode 100644 index 00000000..ed40df9c --- /dev/null +++ b/cmake/CheckMacOSBundleIcon.cmake @@ -0,0 +1,67 @@ +# Verifies that the macOS bundle's app icon is declared, present, and decodable. +# Any one of those failing leaves Finder showing the system default icon. +# +# Run from a build directory: +# cmake -DBUNDLE= -P cmake/CheckMacOSBundleIcon.cmake + +if(NOT DEFINED BUNDLE) + message(FATAL_ERROR "BUNDLE is required (path to the .app to inspect)") +endif() +if(NOT IS_DIRECTORY "${BUNDLE}") + message(FATAL_ERROR "no application bundle at ${BUNDLE}") +endif() + +set(plist "${BUNDLE}/Contents/Info.plist") +set(resources "${BUNDLE}/Contents/Resources") +if(NOT EXISTS "${plist}") + message(FATAL_ERROR "bundle has no Contents/Info.plist: ${plist}") +endif() + +# PlistBuddy reports an absent key on stderr; any other failure means the tool +# could not read the plist, which is a different diagnosis. +execute_process( + COMMAND /usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "${plist}" + RESULT_VARIABLE plist_result + OUTPUT_VARIABLE icon_name + ERROR_VARIABLE plist_error + OUTPUT_STRIP_TRAILING_WHITESPACE) +if(NOT plist_result EQUAL 0) + if(plist_error MATCHES "Does Not Exist") + message(FATAL_ERROR "Info.plist declares no CFBundleIconFile, so Finder falls " + "back to the system default icon") + endif() + message(FATAL_ERROR "PlistBuddy could not read ${plist}: ${plist_error}") +endif() + +set(icon_path "${resources}/${icon_name}") +if(NOT EXISTS "${icon_path}") + set(icon_path "${resources}/${icon_name}.icns") +endif() +if(NOT EXISTS "${icon_path}") + message(FATAL_ERROR "CFBundleIconFile names '${icon_name}', but ${resources} " + "contains no such resource") +endif() + +# Container bytes alone do not prove the image data is usable; a decode does. The +# uniquely named scratch directory is the only thing this run removes. +execute_process( + COMMAND /usr/bin/mktemp -d "macos-bundle-icon.XXXXXXXX" + RESULT_VARIABLE mktemp_result + OUTPUT_VARIABLE scratch_dir + ERROR_VARIABLE mktemp_error + OUTPUT_STRIP_TRAILING_WHITESPACE) +if(NOT mktemp_result EQUAL 0 OR NOT IS_DIRECTORY "${scratch_dir}") + message(FATAL_ERROR "could not create a scratch directory: ${mktemp_error}") +endif() + +execute_process( + COMMAND iconutil -c iconset "${icon_path}" -o "${scratch_dir}/icon.iconset" + RESULT_VARIABLE decode_result + ERROR_VARIABLE decode_error + OUTPUT_QUIET) +file(REMOVE_RECURSE "${scratch_dir}") +if(NOT decode_result EQUAL 0) + message(FATAL_ERROR "iconutil cannot decode ${icon_path}: ${decode_error}") +endif() + +message(STATUS "bundle icon OK: CFBundleIconFile='${icon_name}' -> ${icon_path}") diff --git a/cmake/CheckModelStartupRecovery.cmake b/cmake/CheckModelStartupRecovery.cmake new file mode 100644 index 00000000..9528c711 --- /dev/null +++ b/cmake/CheckModelStartupRecovery.cmake @@ -0,0 +1,23 @@ +if(NOT EXISTS "${EXECUTABLE}" OR NOT EXISTS "${ASSET_ROOT}/models" OR + NOT TEST_ROOT) + message(FATAL_ERROR "Startup recovery test requires executable, assets and test root") +endif() + +string(RANDOM LENGTH 12 ALPHABET 0123456789abcdef run_id) +set(storage "${TEST_ROOT}/${run_id}") +file(MAKE_DIRECTORY "${storage}") +file(COPY "${ASSET_ROOT}/models" DESTINATION "${storage}") +foreach(model standard keyboard gamepad) + file(WRITE "${storage}/models/${model}/cat.model3.json" "{}") +endforeach() + +execute_process( + COMMAND "${CMAKE_COMMAND}" -E env BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1 + "${EXECUTABLE}" --ci-smoke --ci-ignore-global-input "--storage-root=${storage}" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE errors TIMEOUT 50) +if(NOT result STREQUAL "0" OR + NOT "${output}${errors}" MATCHES "Recovered startup using bundled model") + message(FATAL_ERROR + "Startup recovery failed (${result}); data retained at ${storage}\n${output}\n${errors}") +endif() +message(STATUS "Startup recovered from damaged stored models; test data: ${storage}") diff --git a/cmake/CheckPlatformRuntimeSafety.cmake b/cmake/CheckPlatformRuntimeSafety.cmake new file mode 100644 index 00000000..24b857a4 --- /dev/null +++ b/cmake/CheckPlatformRuntimeSafety.cmake @@ -0,0 +1,210 @@ +if(NOT DEFINED ROOT) + message(FATAL_ERROR "ROOT is required") +endif() + +file(GLOB_RECURSE PRODUCTION_FILES + "${ROOT}/src/*.c" "${ROOT}/src/*.cc" "${ROOT}/src/*.cpp" + "${ROOT}/src/*.cxx" "${ROOT}/src/*.m" "${ROOT}/src/*.mm" + "${ROOT}/src/*.h" "${ROOT}/src/*.hh" "${ROOT}/src/*.hpp" + "${ROOT}/src/*.hxx" "${ROOT}/include/*.h" "${ROOT}/include/*.hh" + "${ROOT}/include/*.hpp" "${ROOT}/include/*.hxx") +list(FILTER PRODUCTION_FILES EXCLUDE REGEX "[/\\\\]src[/\\\\]tools[/\\\\]") + +set(FORBIDDEN_APIS + OpenProcess ReadProcessMemory WriteProcessMemory VirtualAllocEx + VirtualProtectEx CreateRemoteThread CreateRemoteThreadEx NtOpenProcess + NtReadVirtualMemory NtWriteVirtualMemory NtCreateThreadEx + RtlCreateUserThread QueueUserAPC SetThreadContext DebugActiveProcess + DebugBreakProcess MiniDumpWriteDump CreateToolhelp32Snapshot Process32First + Process32Next EnumProcesses EnumProcessModules AdjustTokenPrivileges + LookupPrivilegeValue OpenSCManager CreateService StartService ControlService + DeviceIoControl NtLoadDriver AttachThreadInput SendInput keybd_event + mouse_event SetWindowsHookEx SetWindowsHookExA SetWindowsHookExW DirectInput8Create + SetCursorPos SetPhysicalCursorPos ClipCursor BlockInput + SDL_SetWindowRelativeMouseMode + SDL_SetWindowKeyboardGrab + CGEventPost CGEventPostToPid CGEventCreateKeyboardEvent + CGEventCreateMouseEvent CGWarpMouseCursorPosition + CGAssociateMouseAndMouseCursorPosition IOHIDManagerCreate + IOHIDEventSystemClientCreate IOServiceOpen task_for_pid mach_vm_read + mach_vm_read_overwrite mach_vm_write mach_vm_allocate mach_vm_protect + AuthorizationCreate AuthorizationExecuteWithPrivileges SMJobBless + XTestFakeKeyEvent XTestFakeButtonEvent XTestFakeMotionEvent XWarpPointer + XGrabKey XGrabButton process_vm_readv process_vm_writev ptrace ioctl + setuid seteuid setgid setegid capset) +set(FORBIDDEN_TOKENS + SE_DEBUG_NAME SeDebugPrivilege PROCESS_VM_READ PROCESS_VM_WRITE + PROCESS_VM_OPERATION PROCESS_ALL_ACCESS THREAD_SET_CONTEXT + kCGHIDEventTap kCGHeadInsertEventTap IOConnectCall + /dev/uinput CAP_SYS_PTRACE CAP_SYS_ADMIN + WH_KEYBOARD_LL WH_MOUSE_LL RIDEV_NOLEGACY RIDEV_CAPTUREMOUSE + SDL_WINDOW_KEYBOARD_GRABBED SDL_HINT_FORCE_RAISEWINDOW + RIDEV_NOHOTKEYS RIDEV_APPKEYS RIDEV_EXCLUDE RIDEV_EXINPUTSINK) +set(FAILURES "") + +foreach(FILE IN LISTS PRODUCTION_FILES) + file(READ "${FILE}" SOURCE) + file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") + string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") + foreach(API IN LISTS FORBIDDEN_APIS) + string(REGEX MATCH + "(^|[^A-Za-z0-9_])${API}[ \t\r\n]*\\(" MATCHED "${SOURCE}") + if(MATCHED) + list(APPEND FAILURES "${RELATIVE_FILE}: forbidden production API ${API}") + endif() + endforeach() + foreach(TOKEN IN LISTS FORBIDDEN_TOKENS) + string(FIND "${SOURCE}" "${TOKEN}" POSITION) + if(NOT POSITION EQUAL -1) + list(APPEND FAILURES "${RELATIVE_FILE}: forbidden production token ${TOKEN}") + endif() + endforeach() +endforeach() + +# Intentional sensitive capabilities remain confined to reviewed modules. +set(SENSITIVE_RULES + "GetAsyncKeyState|src/platform/windows/windows_keys.c" + "RegisterRawInputDevices|src/platform/windows/windows_input_registration.c" + "GetRegisteredRawInputDevices|src/platform/windows/windows_input_registration.c" + "GetRawInputData|src/platform/windows/windows_input_receiver.c" + "SDL_HINT_WINDOWS_RAW_KEYBOARD|src/runtime/shell/window.c|src/platform/windows/windows_input.c" + "BitBlt|src/platform/windows/windows_capture_probe.c" + "PrintWindow|src/platform/windows/windows_capture_probe.c" + "UpdateLayeredWindow|src/platform/windows/windows_layered.c" + "CGEventTapCreate|src/platform/macos/macos_input.m" + "CGEventTapEnable|src/platform/macos/macos_input.m" + "CGPreflightListenEventAccess|src/platform/macos/macos_input.m" + "CGRequestListenEventAccess|src/platform/macos/macos_input.m" + "/dev/input|src/platform/linux/linux_evdev_devices.c" + "XISelectEvents|src/platform/linux/linux_x11.c" + "XFixesSetWindowShapeRegion|src/platform/linux/linux_x11.c" + "XSendEvent|src/platform/linux/linux.c|src/platform/linux/linux_x11.c" + "XGrabPointer|src/platform/linux/linux_menu.c" + "XGrabKeyboard|src/platform/linux/linux_menu.c" + "SDL_INIT_GAMEPAD|src/runtime/input/gamepad.c" + "SDL_OpenGamepad|src/runtime/input/gamepad.c" + "SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS|src/runtime/input/gamepad.c" + "SDL_CreateProcessWithProperties|src/runtime/model/multi_pet_process.c" + "SDL_KillProcess|src/runtime/model/multi_pet_process.c") +foreach(RULE IN LISTS SENSITIVE_RULES) + string(REPLACE "|" ";" PARTS "${RULE}") + list(POP_FRONT PARTS CAPABILITY) + foreach(FILE IN LISTS PRODUCTION_FILES) + file(READ "${FILE}" SOURCE) + string(FIND "${SOURCE}" "${CAPABILITY}" POSITION) + if(POSITION EQUAL -1) + continue() + endif() + file(RELATIVE_PATH RELATIVE_FILE "${ROOT}" "${FILE}") + string(REPLACE "\\" "/" RELATIVE_FILE "${RELATIVE_FILE}") + list(FIND PARTS "${RELATIVE_FILE}" OWNER_POSITION) + if(OWNER_POSITION EQUAL -1) + list(JOIN PARTS ", " OWNERS) + list(APPEND FAILURES + "${RELATIVE_FILE}: ${CAPABILITY} is owned by ${OWNERS}") + endif() + endforeach() +endforeach() + +# The reviewed evdev exception permits read-only observation, not device writes +# or permission changes. +file(READ "${ROOT}/src/platform/linux/linux_evdev_devices.c" LINUX_EVDEV) +foreach(TOKEN O_WRONLY O_RDWR O_CREAT O_TRUNC) + string(FIND "${LINUX_EVDEV}" "${TOKEN}" POSITION) + if(NOT POSITION EQUAL -1) + list(APPEND FAILURES "linux_evdev_devices.c: forbidden open flag ${TOKEN}") + endif() +endforeach() +foreach(TOKEN O_RDONLY O_NONBLOCK O_CLOEXEC O_NOFOLLOW) + string(FIND "${LINUX_EVDEV}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES "linux_evdev_devices.c: missing open safeguard ${TOKEN}") + endif() +endforeach() +foreach(API chmod fchmod chown fchown system popen) + string(REGEX MATCH "(^|[^A-Za-z0-9_])${API}[ \t\r\n]*\\(" MATCHED "${LINUX_EVDEV}") + if(MATCHED) + list(APPEND FAILURES "linux_evdev_devices.c: forbidden API ${API}") + endif() +endforeach() + +file(READ "${ROOT}/cmake/windows.manifest.in" WINDOWS_MANIFEST) +string(FIND "${WINDOWS_MANIFEST}" + "" + MANIFEST_POSITION) +if(MANIFEST_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/windows.manifest.in: expected asInvoker with uiAccess=false") +endif() + +file(READ "${ROOT}/packaging/windows/BongoCat.iss.in" WINDOWS_PACKAGING) +file(READ "${ROOT}/packaging/windows/install-lifecycle.iss.in" WINDOWS_INSTALL_LIFECYCLE) +string(APPEND WINDOWS_PACKAGING "\n${WINDOWS_INSTALL_LIFECYCLE}") +string(FIND "${WINDOWS_PACKAGING}" "PrivilegesRequired=lowest" + INSTALLER_LEVEL_POSITION) +string(FIND "${WINDOWS_PACKAGING}" + "DefaultDirName={code:GetInstallDir}" + INSTALLER_ROOT_POSITION) +string(FIND "${WINDOWS_PACKAGING}" + "{localappdata}\\Programs\\BongoCat" INSTALLER_DEFAULT_POSITION) +if(INSTALLER_LEVEL_POSITION EQUAL -1 OR INSTALLER_ROOT_POSITION EQUAL -1 + OR INSTALLER_DEFAULT_POSITION EQUAL -1) + list(APPEND FAILURES + "packaging/windows/BongoCat.iss.in: expected current-user Inno installation") +endif() + +file(READ "${ROOT}/cmake/Info.plist.in" MACOS_INFO) +string(FIND "${MACOS_INFO}" "NSInputMonitoringUsageDescription" + INPUT_MONITORING_POSITION) +string(FIND "${MACOS_INFO}" "NSAccessibilityUsageDescription" + ACCESSIBILITY_POSITION) +if(INPUT_MONITORING_POSITION EQUAL -1 OR NOT ACCESSIBILITY_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/Info.plist.in: expected Input Monitoring without Accessibility") +endif() + +file(READ "${ROOT}/src/platform/macos/macos_input.m" MACOS_INPUT) +foreach(TOKEN kCGSessionEventTap kCGTailAppendEventTap + kCGEventTapOptionListenOnly) + string(FIND "${MACOS_INPUT}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES + "src/platform/macos/macos_input.m: missing safe event tap token ${TOKEN}") + endif() +endforeach() + +file(READ "${ROOT}/cmake/PlatformRuntime.cmake" PLATFORM_RUNTIME) +string(FIND "${PLATFORM_RUNTIME}" "X11::Xtst" XTEST_POSITION) +if(NOT XTEST_POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/PlatformRuntime.cmake: XTest must not enter the Linux runtime") +endif() + +file(READ "${ROOT}/cmake/Dependencies.cmake" DEPENDENCIES) +foreach(TOKEN 402fc52af4e731184ad6a704068b5ccd27d8f1b8 + e413151af71c23d316b6076a96a999342142afa792394eaf8a542a03503fc491) + string(FIND "${DEPENDENCIES}" "${TOKEN}" POSITION) + if(POSITION EQUAL -1) + list(APPEND FAILURES + "cmake/Dependencies.cmake: SDL dependency changed without safety review") + endif() +endforeach() + +file(READ "${ROOT}/cmake/ValidationTools.cmake" VALIDATION_TOOLS) +foreach(TOOL cubism_viewer_desktop_capture cubism_viewer_drag_capture) + string(REGEX MATCH + "add_executable\\(${TOOL}[ \t\r\n]+EXCLUDE_FROM_ALL" TOOL_ISOLATED + "${VALIDATION_TOOLS}") + if(NOT TOOL_ISOLATED) + list(APPEND FAILURES + "cmake/ValidationTools.cmake: ${TOOL} must remain EXCLUDE_FROM_ALL") + endif() +endforeach() + +if(FAILURES) + list(REMOVE_DUPLICATES FAILURES) + list(JOIN FAILURES "\n" MESSAGE_TEXT) + message(FATAL_ERROR "Platform runtime safety policy failed:\n${MESSAGE_TEXT}") +endif() + +message(STATUS "Platform runtime safety policy passed") diff --git a/cmake/CheckWindowsImports.cmake b/cmake/CheckWindowsImports.cmake new file mode 100644 index 00000000..df1646ad --- /dev/null +++ b/cmake/CheckWindowsImports.cmake @@ -0,0 +1,20 @@ +if(NOT EXISTS "${EXECUTABLE}" OR NOT EXISTS "${DUMPBIN}") + message(FATAL_ERROR "Windows import audit requires an executable and dumpbin") +endif() +execute_process(COMMAND "${DUMPBIN}" /nologo /imports "${EXECUTABLE}" + RESULT_VARIABLE status OUTPUT_VARIABLE imports ERROR_VARIABLE error) +if(NOT status EQUAL 0) + message(FATAL_ERROR "Cannot inspect Windows imports: ${error}") +endif() +foreach(api SetWindowsHookExA SetWindowsHookExW CallNextHookEx + UnhookWindowsHookEx AttachThreadInput) + if(imports MATCHES "[ \t]${api}[ \t\r]*\n") + message(FATAL_ERROR "Unused Windows capability reintroduced: ${api}") + endif() +endforeach() +foreach(api RegisterRawInputDevices GetRawInputData) + if(NOT imports MATCHES "[ \t]${api}[ \t\r]*\n") + message(FATAL_ERROR "Required background input capability missing: ${api}") + endif() +endforeach() +message(STATUS "Windows import audit passed: ${EXECUTABLE}") diff --git a/cmake/Cubism.cmake b/cmake/Cubism.cmake index 30f21098..68fde2db 100644 --- a/cmake/Cubism.cmake +++ b/cmake/Cubism.cmake @@ -2,10 +2,6 @@ set(CUBISM_CORE_PATH "${BONGO_CAT_CUBISM_SDK}/Core") set(CUBISM_FRAMEWORK_PATH "${BONGO_CAT_CUBISM_SDK}/Framework") set(CUBISM_GLEW_PATH "${BONGO_CAT_CUBISM_SDK}/Samples/OpenGL/thirdParty/glew") -# GLEW 2.2.0 still declares compatibility with pre-3.5 CMake. CMake 4 removes -# that compatibility unless the parent project supplies an explicit floor. -set(CMAKE_POLICY_VERSION_MINIMUM 3.5) - foreach(PATH IN ITEMS CUBISM_FRAMEWORK_PATH CUBISM_GLEW_PATH) if(NOT EXISTS "${${PATH}}") message(FATAL_ERROR "Incomplete Cubism SDK: ${${PATH}} is missing") @@ -17,8 +13,13 @@ if(WIN32) if(MSVC_VERSION LESS 1930 OR MSVC_VERSION GREATER_EQUAL 1950) message(FATAL_ERROR "Cubism Windows build requires Visual Studio 2022") endif() + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(CUBISM_WINDOWS_ARCH "x86_64") + else() + set(CUBISM_WINDOWS_ARCH "x86") + endif() set(CUBISM_CORE_LIBRARY - "${CUBISM_CORE_PATH}/lib/windows/x86_64/143/Live2DCubismCore_MT.lib") + "${CUBISM_CORE_PATH}/lib/windows/${CUBISM_WINDOWS_ARCH}/143/Live2DCubismCore_MT.lib") elseif(APPLE) if(CMAKE_OSX_ARCHITECTURES) list(GET CMAKE_OSX_ARCHITECTURES 0 CUBISM_ARCH) @@ -38,12 +39,25 @@ set_target_properties(Live2DCubismCore PROPERTIES IMPORTED_LOCATION "${CUBISM_CORE_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${CUBISM_CORE_PATH}/include") -set(BUILD_UTILS OFF CACHE BOOL "" FORCE) -add_subdirectory("${CUBISM_GLEW_PATH}/build/cmake" "${CMAKE_BINARY_DIR}/cubism-glew") +add_library(glew_s STATIC "${CUBISM_GLEW_PATH}/src/glew.c") +target_include_directories(glew_s SYSTEM PUBLIC "${CUBISM_GLEW_PATH}/include") +target_compile_definitions(glew_s PUBLIC GLEW_STATIC GLEW_NO_GLU) +target_link_libraries(glew_s PUBLIC OpenGL::GL) +if(UNIX AND NOT APPLE) + find_package(X11 REQUIRED) + target_link_libraries(glew_s PRIVATE X11::X11 ${CMAKE_DL_LIBS}) +endif() set(FRAMEWORK_SOURCE OpenGL) add_subdirectory("${CUBISM_FRAMEWORK_PATH}" "${CMAKE_BINARY_DIR}/cubism-framework") +include(cmake/CubismUserModelSafety.cmake) +bongo_cat_harden_cubism_user_model(Framework) +include(cmake/CubismCoreProfile.cmake) include(cmake/CubismShaderOptimize.cmake) bongo_cat_optimize_cubism_shaders(Framework) +if(APPLE) + bongo_cat_core_profile_prepare_shaders() + bongo_cat_core_profile_patch_renderer(Framework) +endif() if(WIN32) target_compile_definitions(Framework PUBLIC CSM_TARGET_WIN_GL) @@ -52,5 +66,7 @@ elseif(APPLE) else() target_compile_definitions(Framework PUBLIC CSM_TARGET_LINUX_GL) endif() -target_include_directories(Framework PUBLIC "${CUBISM_GLEW_PATH}/include") +target_include_directories(Framework SYSTEM PUBLIC + "${CUBISM_FRAMEWORK_PATH}/src" + "${CUBISM_CORE_PATH}/include") target_link_libraries(Framework PUBLIC Live2DCubismCore glew_s) diff --git a/cmake/CubismAssets.cmake b/cmake/CubismAssets.cmake index 305136c5..cae42e9d 100644 --- a/cmake/CubismAssets.cmake +++ b/cmake/CubismAssets.cmake @@ -4,32 +4,33 @@ function(bongo_cat_configure_embedded_cubism_assets) return() endif() file(GLOB_RECURSE shader_inputs CONFIGURE_DEPENDS - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard/*") + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/*") set(BONGO_CAT_ASSET_INPUTS ${BONGO_CAT_ASSET_INPUTS} ${shader_inputs} PARENT_SCOPE) set(BONGO_CAT_ASSET_EXTRA_COMMANDS COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}" "${BONGO_CAT_ASSET_STAGE}/assets/FrameworkShaders" PARENT_SCOPE) endfunction() function(bongo_cat_stage_cubism_assets target) - if(APPLE) - add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -f - "$/assets/logo-mac.png" - "$/assets/ui-icons.png" - "$/assets/ui-symbols-1x.png" - "$/assets/ui-symbols-4x.png") - endif() if(NOT BONGO_CAT_CUBISM_ENABLED) return() endif() + get_target_property(is_bundle ${target} MACOSX_BUNDLE) + if(APPLE AND is_bundle) + set(shader_destination + "$/Resources/assets/FrameworkShaders") + else() + set(shader_destination "$/FrameworkShaders") + endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - "${BONGO_CAT_CUBISM_SDK}/Framework/src/Rendering/OpenGL/Shaders/Standard" - "$/FrameworkShaders") - if(UNIX AND NOT APPLE) + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}" + "${shader_destination}" + VERBATIM) + if(UNIX AND NOT APPLE AND target STREQUAL "bongo_cat") install(DIRECTORY - "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard/" - DESTINATION assets/FrameworkShaders) + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/" + DESTINATION assets/FrameworkShaders COMPONENT Runtime) endif() endfunction() diff --git a/cmake/CubismCoreProfile.cmake b/cmake/CubismCoreProfile.cmake new file mode 100644 index 00000000..03398f6e --- /dev/null +++ b/cmake/CubismCoreProfile.cmake @@ -0,0 +1,105 @@ +# macOS core-profile shaders and buffer-backed drawing. +set(BONGO_CAT_CUBISM_SHADER_SOURCE_DIR + "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard") +if(APPLE) + set(BONGO_CAT_CUBISM_SHADER_SOURCE_DIR + "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-shaders") +endif() + +function(bongo_cat_core_profile_convert_shader input output) + file(READ "${input}" text) + string(REPLACE "\r\n" "\n" text "${text}") + string(REPLACE "#version 120" "#version 330" text "${text}") + get_filename_component(name "${input}" NAME) + if(name STREQUAL "FragShaderSrcColorBlend.frag" OR + name STREQUAL "FragShaderSrcAlphaBlend.frag") + # Appended blend-mode snippets: pure functions without legacy syntax. + elseif(input MATCHES "\\.vert$") + string(REPLACE "attribute " "in " text "${text}") + string(REPLACE "varying " "out " text "${text}") + else() + string(REPLACE "varying " "in " text "${text}") + string(REPLACE "texture2D(" "texture(" text "${text}") + string(REPLACE "gl_FragColor" "BongoCatFragColor" text "${text}") + string(REPLACE "#version 330\n" + "#version 330\n\nout vec4 BongoCatFragColor;\n" text "${text}") + endif() + foreach(legacy IN ITEMS "attribute " "varying " "texture2D(" "gl_FragColor" + "#version 120") + string(FIND "${text}" "${legacy}" position) + if(NOT position EQUAL -1) + message(FATAL_ERROR "Core-profile shader conversion failed for ${name}: " + "'${legacy}' remains") + endif() + endforeach() + file(WRITE "${output}" "${text}") +endfunction() + +function(bongo_cat_core_profile_prepare_shaders) + set(source_dir "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/Shaders/Standard") + file(MAKE_DIRECTORY "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}") + file(GLOB shader_files CONFIGURE_DEPENDS "${source_dir}/*") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${shader_files}) + foreach(input IN LISTS shader_files) + get_filename_component(name "${input}" NAME) + bongo_cat_core_profile_convert_shader("${input}" + "${BONGO_CAT_CUBISM_SHADER_SOURCE_DIR}/${name}") + endforeach() +endfunction() + +function(bongo_cat_core_profile_patch_shader variable) + set(source "${${variable}}") + string(ASCII 239 187 191 bom) + string(REPLACE "${bom}" "" source "${source}") + foreach(spec IN ITEMS + "AttributePositionLocation|vertexArray|model.GetDrawableVertexCount(index) * sizeof(csmFloat32) * 2|0" + "AttributeTexCoordLocation|uvArray|model.GetDrawableVertexCount(index) * sizeof(csmFloat32) * 2|1" + "AttributePositionLocation|renderTargetVertexArray|sizeof(renderTargetVertexArray)|0" + "AttributeTexCoordLocation|renderTargetUvArray|sizeof(renderTargetUvArray)|1" + "AttributeTexCoordLocation|renderTargetReverseUvArray|sizeof(renderTargetReverseUvArray)|1") + string(REPLACE "|" ";" fields "${spec}") + list(GET fields 0 attribute) + list(GET fields 1 data) + list(GET fields 2 size) + list(GET fields 3 slot) + bongo_cat_replace_cubism_text(source + "glVertexAttribPointer(shaderSet->${attribute}, 2, GL_FLOAT, GL_FALSE, sizeof(csmFloat32) * 2, ${data});" + "bongo_cat::CoreProfileBinding::attribute(${slot}, shaderSet->${attribute}, ${data}, ${size});" + "core-profile ${data}") + endforeach() + if(source MATCHES "glVertexAttribPointer[ \t\r\n]*\\(") + message(FATAL_ERROR "Unpatched Cubism client-side vertex attributes") + endif() + set(${variable} "#include \"cubism_core_profile.hpp\"\n${source}" PARENT_SCOPE) +endfunction() + +function(bongo_cat_core_profile_patch_renderer target) + set(source_path "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL/CubismRenderer_OpenGLES2.cpp") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism") + set(output_source "${output_dir}/CubismRenderer_OpenGLES2.cpp") + file(READ "${source_path}" source) + string(ASCII 239 187 191 bom) + string(REPLACE "${bom}" "" source "${source}") + string(REPLACE "\r\n" "\n" source "${source}") + bongo_cat_replace_cubism_text(source + "glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray);" + "bongo_cat::CoreProfileBinding::draw(indexCount, indexArray);" + "core-profile mesh indices") + bongo_cat_replace_cubism_text(source + "glDrawElements(GL_TRIANGLES, sizeof(ModelRenderTargetIndexArray) / sizeof(csmUint16), GL_UNSIGNED_SHORT, ModelRenderTargetIndexArray);" + "bongo_cat::CoreProfileBinding::draw(sizeof(ModelRenderTargetIndexArray) / sizeof(csmUint16), ModelRenderTargetIndexArray);" + "core-profile composite indices") + if(source MATCHES "glDrawElements[ \t\r\n]*\\(") + message(FATAL_ERROR "Unpatched Cubism client-side index draw") + endif() + file(MAKE_DIRECTORY "${output_dir}") + file(WRITE "${output_source}" "#include \"cubism_core_profile.hpp\"\n${source}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${source_path}") + get_target_property(framework_sources ${target} SOURCES) + list(REMOVE_ITEM framework_sources "${source_path}") + set_property(TARGET ${target} PROPERTY SOURCES "${framework_sources}") + target_sources(${target} PRIVATE "${output_source}") + target_include_directories(${target} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/live2d" + "${CUBISM_FRAMEWORK_PATH}/src/Rendering/OpenGL") +endfunction() diff --git a/cmake/CubismShaderOptimize.cmake b/cmake/CubismShaderOptimize.cmake index 3e6c6311..4eb7264e 100644 --- a/cmake/CubismShaderOptimize.cmake +++ b/cmake/CubismShaderOptimize.cmake @@ -36,10 +36,13 @@ function(bongo_cat_optimize_cubism_shaders target) set(output_source "${output_dir}/CubismShader_OpenGLES2.cpp") file(READ "${source_path}" source) string(REPLACE "\r\n" "\n" source "${source}") - # Match Pixi's Live2D texture defaults and avoid an unnecessary mip chain. + # The uploader selects trilinear filtering after building a complete, + # alpha-safe mip chain and retains linear filtering if that work fails. + # Do not overwrite that per-texture decision on every drawable. bongo_cat_replace_cubism_text(source - "GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR" - "GL_TEXTURE_MIN_FILTER, GL_LINEAR" "linear model texture sampling") + "glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);" + "/* Model upload owns minification filtering and fallback. */" + "model texture minification ownership") set(compile_anchor [=[ _shaderSets[ShaderNames_MultMaskedInvertedPremultipliedAlpha]->ShaderProgram = _shaderSets[ShaderNames_NormalMaskedInvertedPremultipliedAlpha]->ShaderProgram;]=]) @@ -105,6 +108,10 @@ void CubismShader_OpenGLES2::SetupShaderProgramForDrawable]=]) bongo_cat_replace_cubism_text(source "${offscreen_setup}" "${offscreen_lazy}" "offscreen lazy shader call") + if(APPLE) + bongo_cat_core_profile_patch_shader(source) + endif() + file(MAKE_DIRECTORY "${output_dir}") file(REMOVE "${output_dir}/CubismShader_OpenGLES2.hpp") file(WRITE "${output_source}" "${source}") diff --git a/cmake/CubismUserModelSafety.cmake b/cmake/CubismUserModelSafety.cmake new file mode 100644 index 00000000..5269cd64 --- /dev/null +++ b/cmake/CubismUserModelSafety.cmake @@ -0,0 +1,112 @@ +function(bongo_cat_patch_cubism_renderer_creation variable) + set(source "${${variable}}") + set(create_anchor "void CubismUserModel::CreateRenderer") + set(delete_anchor "void CubismUserModel::DeleteRenderer") + string(FIND "${source}" "${create_anchor}" create_position) + string(FIND "${source}" "${delete_anchor}" delete_position) + if(create_position EQUAL -1 OR delete_position LESS create_position) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer functions") + endif() + + math(EXPR function_length "${delete_position} - ${create_position}") + string(SUBSTRING "${source}" ${create_position} ${function_length} + create_function) + set(create_pattern + [=[_renderer[ \t]*=[ \t]*Rendering::CubismRenderer::Create[ \t]*\([^;]*\)[ \t]*;]=]) + string(REGEX MATCH "${create_pattern}" create_statement "${create_function}") + if(NOT create_statement) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer creation") + endif() + string(FIND "${create_function}" "${create_statement}" statement_position) + string(LENGTH "${create_statement}" statement_length) + math(EXPR statement_end "${statement_position} + ${statement_length}") + string(SUBSTRING "${create_function}" ${statement_end} -1 create_tail) + string(REGEX MATCH "${create_pattern}" duplicate_create "${create_tail}") + if(duplicate_create) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: multiple renderer creators") + endif() + + set(initialize_pattern + [=[_renderer[ \t]*->[ \t]*Initialize[ \t]*\([ \t]*_model[ \t]*,[ \t]*maskBufferCount[ \t]*\)[ \t]*;]=]) + string(REGEX MATCH "${initialize_pattern}" initialize_statement + "${create_function}") + if(NOT initialize_statement) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: renderer initialization") + endif() + string(FIND "${create_function}" "${initialize_statement}" + initialize_position) + string(LENGTH "${initialize_statement}" initialize_length) + math(EXPR initialize_end "${initialize_position} + ${initialize_length}") + string(SUBSTRING "${create_function}" ${initialize_end} -1 initialize_tail) + string(REGEX MATCH "${initialize_pattern}" duplicate_initialize + "${initialize_tail}") + if(duplicate_initialize) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: multiple renderer initializers") + endif() + + set(renderer_condition + [=[if[ \t]*\([ \t]*_renderer([ \t]*!=[ \t]*(NULL|nullptr))?[ \t]*\)]=]) + set(guard_pattern + "${renderer_condition}[ \t\n]*(\\{[ \t\n]*)?${initialize_pattern}") + string(REGEX MATCH "${guard_pattern}" guarded_initialize + "${create_function}") + if(NOT guarded_initialize) + set(safe_initialize [=[if (_renderer) + { + _renderer->Initialize(_model, maskBufferCount); + }]=]) + string(REPLACE "${initialize_statement}" "${safe_initialize}" + create_function "${create_function}") + string(SUBSTRING "${source}" 0 ${create_position} prefix) + string(SUBSTRING "${source}" ${delete_position} -1 suffix) + set(source "${prefix}${create_function}${suffix}") + endif() + set(${variable} "${source}" PARENT_SCOPE) +endfunction() + +function(bongo_cat_harden_cubism_user_model target) + set(model_dir "${CUBISM_FRAMEWORK_PATH}/src/Model") + set(source_path "${model_dir}/CubismUserModel.cpp") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/cubism-model") + set(output_source "${output_dir}/CubismUserModel.cpp") + + file(READ "${source_path}" source) + string(REPLACE "\r\n" "\n" source "${source}") + + set(resize_unsafe [=[ _renderer->SetRenderTargetSize(width, height);]=]) + set(resize_safe [=[ if (_renderer) + { + _renderer->SetRenderTargetSize(width, height); + }]=]) + if(source MATCHES "void CubismUserModel::SetRenderTargetSize") + string(FIND "${source}" "${resize_safe}" resize_safe_position) + if(resize_safe_position EQUAL -1) + string(FIND "${source}" "${resize_unsafe}" resize_unsafe_position) + if(resize_unsafe_position EQUAL -1) + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: render target resize") + endif() + string(REPLACE "${resize_unsafe}" "${resize_safe}" source "${source}") + endif() + else() + message(FATAL_ERROR + "Cubism 5 r.5 user-model patch mismatch: resize function") + endif() + + bongo_cat_patch_cubism_renderer_creation(source) + + file(MAKE_DIRECTORY "${output_dir}") + file(WRITE "${output_source}" "${source}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${source_path}") + get_target_property(framework_sources ${target} SOURCES) + list(REMOVE_ITEM framework_sources "${source_path}") + set_property(TARGET ${target} PROPERTY SOURCES "${framework_sources}") + target_sources(${target} PRIVATE "${output_source}") + target_include_directories(${target} PRIVATE "${model_dir}") +endfunction() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index ef2a0539..631f0813 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -27,6 +27,7 @@ if(BONGO_CAT_FETCH_DEPS) # dummy fallback enabled for diagnostics, while omitting unused APIs. set(SDL_OPENGLES OFF CACHE BOOL "" FORCE) set(SDL_VULKAN OFF CACHE BOOL "" FORCE) + set(SDL_METAL OFF CACHE BOOL "" FORCE) set(SDL_OFFSCREEN OFF CACHE BOOL "" FORCE) set(SDL_VIRTUAL_JOYSTICK OFF CACHE BOOL "" FORCE) set(SDL_CAMERA OFF CACHE BOOL "" FORCE) @@ -34,16 +35,29 @@ if(BONGO_CAT_FETCH_DEPS) set(SDL_HAPTIC OFF CACHE BOOL "" FORCE) set(SDL_POWER OFF CACHE BOOL "" FORCE) set(SDL_SENSOR OFF CACHE BOOL "" FORCE) + if(WIN32) + set(SDL_DIALOG OFF CACHE BOOL "" FORCE) + endif() + set(YYJSON_DISABLE_INCR_READER ON CACHE BOOL "" FORCE) + set(YYJSON_DISABLE_UTILS ON CACHE BOOL "" FORCE) + set(YYJSON_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_FUZZER OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_MISC OFF CACHE BOOL "" FORCE) + set(YYJSON_BUILD_DOC OFF CACHE BOOL "" FORCE) + set(YYJSON_INSTALL OFF CACHE BOOL "" FORCE) set(MINIAUDIO_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(MINIAUDIO_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(MINIAUDIO_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(MINIAUDIO_INSTALL OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_EXTRA_NODES ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_LIBVORBIS ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_LIBOPUS ON CACHE BOOL "" FORCE) set(MINIAUDIO_NO_ENCODING ON CACHE BOOL "" FORCE) + set(MINIAUDIO_NO_CUSTOM ON CACHE BOOL "" FORCE) # Motion metadata may reference WAV/MP3 files, so keep both decoders enabled. # FORCE also repairs build trees created by older size-only configurations. set(MINIAUDIO_NO_WAV OFF CACHE BOOL "" FORCE) + set(MINIAUDIO_NO_FLAC OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_MP3 OFF CACHE BOOL "" FORCE) set(MINIAUDIO_NO_GENERATION ON CACHE BOOL "" FORCE) FetchContent_Declare(SDL3 URL https://github.com/libsdl-org/SDL/archive/402fc52af4e731184ad6a704068b5ccd27d8f1b8.tar.gz @@ -57,6 +71,10 @@ if(BONGO_CAT_FETCH_DEPS) FetchContent_Declare(nuklear URL https://github.com/Immediate-Mode-UI/Nuklear/archive/8109cfbabe04f8705408c5d8ab1a6cd48649ccda.tar.gz URL_HASH SHA256=23e5e1b12e897f1d568eb703aa313b7224c9b75e1118764ceba477d13b8e39f4) FetchContent_MakeAvailable(SDL3 yyjson stb miniaudio nuklear) + if(WIN32) + include("${CMAKE_CURRENT_LIST_DIR}/SDLWindowsRuntime.cmake") + bongo_cat_trim_sdl_windows(SDL3-static "${sdl3_SOURCE_DIR}") + endif() set(BONGO_CAT_STB_INCLUDE_DIR "${stb_SOURCE_DIR}") set(BONGO_CAT_MINIAUDIO_INCLUDE_DIR "${miniaudio_SOURCE_DIR}") set(BONGO_CAT_NUKLEAR_INCLUDE_DIR "${nuklear_SOURCE_DIR}") @@ -103,16 +121,12 @@ else() bongo_cat_require_dependency_header(BONGO_CAT_MINIAUDIO_INCLUDE_DIR miniaudio.h "Install miniaudio headers or a miniaudio CMake package") - set(miniaudio_impl "${CMAKE_CURRENT_BINARY_DIR}/generated/miniaudio_impl.c") - file(CONFIGURE OUTPUT "${miniaudio_impl}" CONTENT [=[ -#define MINIAUDIO_IMPLEMENTATION -#include -]=] @ONLY) - add_library(bongo_cat_miniaudio_system STATIC "${miniaudio_impl}") - target_include_directories(bongo_cat_miniaudio_system SYSTEM PUBLIC + # AudioDecoder.cmake supplies the implementation and the Vorbis backend. + add_library(bongo_cat_miniaudio_system INTERFACE) + target_include_directories(bongo_cat_miniaudio_system SYSTEM INTERFACE "${BONGO_CAT_MINIAUDIO_INCLUDE_DIR}") - target_compile_definitions(bongo_cat_miniaudio_system PRIVATE - MA_NO_ENCODING MA_NO_GENERATION) set(BONGO_CAT_MINIAUDIO_TARGET bongo_cat_miniaudio_system) endif() endif() + +include("${CMAKE_CURRENT_LIST_DIR}/Archive.cmake") diff --git a/cmake/DiagnosticBuildNotice.txt b/cmake/DiagnosticBuildNotice.txt new file mode 100644 index 00000000..bbf7d34c --- /dev/null +++ b/cmake/DiagnosticBuildNotice.txt @@ -0,0 +1,7 @@ +BongoCat diagnostic build + +This package was built without the Live2D Cubism SDK. Live2D rendering, +animation, pointer tracking, imported-model rendering, and cover generation +are disabled. This build is only for startup and platform diagnostics. + +Do not distribute this package to end users or submit it to an app store. diff --git a/cmake/HtmlLayoutProbe.js b/cmake/HtmlLayoutProbe.js deleted file mode 100644 index cf3c5d12..00000000 --- a/cmake/HtmlLayoutProbe.js +++ /dev/null @@ -1,100 +0,0 @@ -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; - -const endpoint = process.argv[2] || "http://127.0.0.1:9231"; -const debugPort = new URL(endpoint).port || "9231"; -const edge = process.argv[3] || - "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; -const profile = resolve(process.argv[4] || `build-final/layout-cdp-profile-${process.pid}`); -const reference = process.argv[5] || "http://127.0.0.1:8765/index.html#model"; - -const browser = spawn(edge, ["--headless=new", "--disable-gpu", - "--no-first-run", "--no-default-browser-check", - `--remote-debugging-port=${debugPort}`, "--remote-allow-origins=*", - `--user-data-dir=${profile}`, "--window-size=934,773", reference], - { stdio: "ignore" }); - -let version; -for (let attempt = 0; attempt < 100 && !version; attempt++) { - try { version = await (await fetch(`${endpoint}/json/version`)).json(); } - catch { await new Promise(resolve => setTimeout(resolve, 100)); } -} -if (!version) { - browser.kill(); - throw new Error("Browser debug endpoint did not start"); -} - -await new Promise(resolve => setTimeout(resolve, 800)); -const targets = await (await fetch(`${endpoint}/json`)).json(); -const target = targets.find(item => item.type === "page" && - item.url.includes("127.0.0.1:8765")); -if (!target) throw new Error("No browser page target found"); - -const socket = new WebSocket(target.webSocketDebuggerUrl); -const expression = String.raw`(async () => { - const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); - for (let attempt = 0; attempt < 50 && - !document.querySelector('[data-page="2"]'); attempt++) await delay(100); - const snapshot = selectors => Object.fromEntries(selectors.map(selector => [ - selector, [...document.querySelectorAll(selector)].map(element => { - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - x: rect.x, y: rect.y, width: rect.width, height: rect.height, - font: style.font, color: style.color, background: style.background, - border: style.border, radius: style.borderRadius, shadow: style.boxShadow - }; - }) - ])); - const common = [".bongocat-surface", ".side-nav", ".brand-logo-wrap", ".menu-item", - ".content-header", ".page-scroll", ".section-title"]; - const pages = {}; - document.querySelector('[data-page="2"]').click(); - await delay(300); - pages.model = snapshot([...common, ".model-grid", ".upload-card", ".model-card", - ".model-preview", ".model-info", ".card-actions", ".card-action"]); - document.querySelector('[data-action="open-behavior"]')?.click(); - await delay(100); - pages.modal = snapshot([".modal-mask", ".modal", ".modal-head", ".modal-close", - ".modal-body", ".segmented", ".segment", ".behavior-list", - ".behavior-row", ".shortcut-editor", ".behavior-play-button"]); - document.querySelector('[data-page="4"]').click(); - await delay(300); - pages.support = snapshot([...common, ".support-page", ".support-hero", - ".support-hero-logo-wrap", ".github-star", ".support-hero h2", ".support-byline", - ".support-hero p", ".support-hero-footer", ".support-version", ".btn-primary", - ".feedback-link", ".support-section", ".support-section-title", - ".support-section-subtitle", ".support-icon-list", ".support-icon-link", - ".support-app-icon"]); - return pages; -})()`; - -const result = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Browser probe timed out")), 10000); - socket.addEventListener("open", () => socket.send(JSON.stringify({ - id: 1, - method: "Runtime.evaluate", - params: { expression, awaitPromise: true, returnByValue: true } - }))); - socket.addEventListener("message", event => { - const message = JSON.parse(event.data); - if (message.id !== 1) return; - clearTimeout(timeout); - if (message.error) reject(new Error(JSON.stringify(message.error))); - else if (message.result.exceptionDetails) reject(new Error( - JSON.stringify(message.result.exceptionDetails))); - else resolve(message.result.result.value); - socket.close(); - }); - socket.addEventListener("error", reject); -}); - -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -const browserSocket = new WebSocket(version.webSocketDebuggerUrl); -await new Promise(resolve => { - browserSocket.addEventListener("open", () => browserSocket.send(JSON.stringify({ - id: 2, method: "Browser.close" - }))); - browserSocket.addEventListener("close", resolve); - browserSocket.addEventListener("error", resolve); -}); diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in index 532258ca..7a28c611 100644 --- a/cmake/Info.plist.in +++ b/cmake/Info.plist.in @@ -6,7 +6,7 @@ CFBundleDevelopmentRegionen CFBundleDisplayNameBongoCat CFBundleExecutableBongoCat - CFBundleIconFileicon.icns + CFBundleIconFile${MACOSX_BUNDLE_ICON_FILE} CFBundleIdentifiercom.bongocat.desktop CFBundleInfoDictionaryVersion6.0 CFBundleNameBongoCat @@ -16,10 +16,8 @@ CFBundleVersion@PROJECT_VERSION@ LSMinimumSystemVersion11.0 LSUIElement - NSAccessibilityUsageDescription - BongoCat needs accessibility access to react to global keyboard and mouse input. NSInputMonitoringUsageDescription - BongoCat needs input monitoring access to animate from global keyboard and mouse input. + BongoCat needs input monitoring access to animate from global keyboard input. NSHighResolutionCapable diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index e1c54e55..192f8847 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -1,59 +1,57 @@ install(TARGETS bongo_cat RUNTIME DESTINATION . COMPONENT Runtime BUNDLE DESTINATION . COMPONENT Runtime) +if(WIN32) + install(FILES LICENSE DESTINATION . COMPONENT Runtime) + install(FILES resources/assets/models/LICENSE DESTINATION assets/models COMPONENT Runtime) +endif() +set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat") +if(NOT BONGO_CAT_CUBISM_ENABLED) + set(BONGO_CAT_PACKAGE_PRODUCT "BongoCat-Diagnostic") + install(FILES cmake/DiagnosticBuildNotice.txt DESTINATION . + COMPONENT Runtime) +endif() if(UNIX AND NOT APPLE) install(DIRECTORY resources/assets DESTINATION . COMPONENT Runtime - PATTERN "logo-mac.png" EXCLUDE PATTERN "ui-icons.png" EXCLUDE - PATTERN "ui-symbols-1x.png" EXCLUDE PATTERN "ui-symbols-4x.png" EXCLUDE) + PATTERN "LICENSE" EXCLUDE) endif() -if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(BONGO_CAT_PACKAGE_ARCH "x64") -else() - set(BONGO_CAT_PACKAGE_ARCH "x86") -endif() - -if(APPLE) - if(CMAKE_OSX_ARCHITECTURES) - list(LENGTH CMAKE_OSX_ARCHITECTURES BONGO_CAT_OSX_ARCH_COUNT) - if(BONGO_CAT_OSX_ARCH_COUNT GREATER 1) - set(BONGO_CAT_PACKAGE_ARCH "universal") - else() - list(GET CMAKE_OSX_ARCHITECTURES 0 BONGO_CAT_OSX_ARCH) - if(BONGO_CAT_OSX_ARCH MATCHES "^(arm64|aarch64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - endif() - elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - set(BONGO_CAT_PACKAGE_PLATFORM "macos-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "ZIP") -elseif(WIN32) - set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "ZIP") -else() - if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") - set(BONGO_CAT_PACKAGE_ARCH "arm64") - endif() - set(BONGO_CAT_PACKAGE_PLATFORM "linux-${BONGO_CAT_PACKAGE_ARCH}") - set(CPACK_GENERATOR "TGZ") -endif() +include(cmake/PackagingPlatform.cmake) set(BONGO_CAT_PACKAGE_NAME - "BongoCat-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") -file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongo-cat-package-name.txt" + "${BONGO_CAT_PACKAGE_PRODUCT}-${PROJECT_VERSION}-${BONGO_CAT_PACKAGE_PLATFORM}") +file(GENERATE OUTPUT "${CMAKE_BINARY_DIR}/bongocat-package-name.txt" CONTENT "${BONGO_CAT_PACKAGE_NAME}\n") -set(CPACK_PACKAGE_NAME "BongoCat") -set(CPACK_PACKAGE_VENDOR "BongoCat") +set(CPACK_PACKAGE_NAME "${BONGO_CAT_PACKAGE_PRODUCT}") +set(CPACK_PACKAGE_VENDOR "vladelaina") set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") set(CPACK_PACKAGE_FILE_NAME "${BONGO_CAT_PACKAGE_NAME}") set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/dist") set(CPACK_PACKAGE_CHECKSUM "SHA256") +set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE") set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) set(CPACK_INSTALL_CMAKE_PROJECTS "${CMAKE_BINARY_DIR};${PROJECT_NAME};Runtime;/") + +if(WIN32) + set(BONGO_CAT_PORTABLE_EXECUTABLE + "${CMAKE_BINARY_DIR}/dist/${BONGO_CAT_PACKAGE_NAME}-portable.exe") + add_custom_command(OUTPUT "${BONGO_CAT_PORTABLE_EXECUTABLE}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/dist" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" + "${BONGO_CAT_PORTABLE_EXECUTABLE}" + DEPENDS bongo_cat + COMMENT "Building the BongoCat Windows portable executable" + VERBATIM) + add_custom_target(package-portable + DEPENDS "${BONGO_CAT_PORTABLE_EXECUTABLE}") +endif() + include(CPack) +include(cmake/PackagingAppImage.cmake) +if(WIN32) + include(cmake/PackagingInno.cmake) +endif() diff --git a/cmake/PackagingAppImage.cmake b/cmake/PackagingAppImage.cmake new file mode 100644 index 00000000..abb30670 --- /dev/null +++ b/cmake/PackagingAppImage.cmake @@ -0,0 +1,9 @@ +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND + BONGO_CAT_PACKAGE_PLATFORM STREQUAL "linux-x64") + add_custom_target(package-appimage + COMMAND bash "${CMAKE_SOURCE_DIR}/packaging/linux/build-appimage.sh" + "${CMAKE_BINARY_DIR}" + DEPENDS bongo_cat + COMMENT "Building the BongoCat Linux AppImage" + VERBATIM) +endif() diff --git a/cmake/PackagingInno.cmake b/cmake/PackagingInno.cmake new file mode 100644 index 00000000..375a83b9 --- /dev/null +++ b/cmake/PackagingInno.cmake @@ -0,0 +1,14 @@ +# Locate ISCC at package time: CI installs it after configuring the application. +foreach(template BongoCat install-lifecycle) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/packaging/windows/${template}.iss.in" + "${CMAKE_CURRENT_BINARY_DIR}/${template}.iss" @ONLY) +endforeach() +add_custom_target(package-installer + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_CURRENT_SOURCE_DIR}/packaging/windows/build-installer.ps1" + -BuildDir "${CMAKE_BINARY_DIR}" -Configuration "$" + -PackageName "${BONGO_CAT_PACKAGE_NAME}" + DEPENDS bongo_cat + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Building the BongoCat Inno Setup installer" + VERBATIM) diff --git a/cmake/PackagingPlatform.cmake b/cmake/PackagingPlatform.cmake new file mode 100644 index 00000000..c2640ac5 --- /dev/null +++ b/cmake/PackagingPlatform.cmake @@ -0,0 +1,32 @@ +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(BONGO_CAT_PACKAGE_ARCH "x64") +else() + set(BONGO_CAT_PACKAGE_ARCH "x86") +endif() + +if(APPLE) + if(CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES BONGO_CAT_OSX_ARCH_COUNT) + if(BONGO_CAT_OSX_ARCH_COUNT GREATER 1) + set(BONGO_CAT_PACKAGE_ARCH "universal") + else() + list(GET CMAKE_OSX_ARCHITECTURES 0 BONGO_CAT_OSX_ARCH) + if(BONGO_CAT_OSX_ARCH MATCHES "^(arm64|aarch64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + endif() + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + set(BONGO_CAT_PACKAGE_PLATFORM "macos-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "ZIP") +elseif(WIN32) + set(BONGO_CAT_PACKAGE_PLATFORM "windows-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "ZIP") +else() + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64)$") + set(BONGO_CAT_PACKAGE_ARCH "arm64") + endif() + set(BONGO_CAT_PACKAGE_PLATFORM "linux-${BONGO_CAT_PACKAGE_ARCH}") + set(CPACK_GENERATOR "TGZ") +endif() diff --git a/cmake/PlatformRuntime.cmake b/cmake/PlatformRuntime.cmake new file mode 100644 index 00000000..56cfde97 --- /dev/null +++ b/cmake/PlatformRuntime.cmake @@ -0,0 +1,65 @@ +if(WIN32) + enable_language(RC) + target_sources(bongo_cat_runtime PRIVATE + src/platform/windows/windows.c + src/platform/windows/windows_directory.c + src/platform/windows/windows_package.c + src/platform/windows/windows_startup.c + src/platform/windows/windows_update_handoff.c + src/platform/windows/windows_borderless.c + src/platform/windows/windows_capture.c + src/platform/windows/windows_transparency.c + src/platform/windows/windows_pointer.c + src/platform/windows/windows_snapshot.c + src/platform/windows/windows_snapshot_image.c + src/platform/windows/windows_snapshot_input.c + src/platform/windows/windows_dialog.c + src/platform/windows/windows_input.c + src/platform/windows/windows_input_receiver.c + src/platform/windows/windows_input_registration.c + src/platform/windows/windows_input_devices.c + src/platform/windows/windows_input_packets.c + src/platform/windows/windows_input_relative.c + src/platform/windows/windows_input_detection.c + src/platform/windows/windows_input_probe.c + src/platform/windows/windows_popup.c + src/platform/windows/windows_layered.c + src/platform/windows/windows_layered_state.c + src/platform/windows/windows_opacity.c + src/platform/windows/windows_tray.c + src/platform/windows/windows_assets.c + src/platform/windows/windows_capture_probe.c) + target_include_directories(bongo_cat_runtime PRIVATE + src/platform/windows) + target_link_libraries(bongo_cat_runtime PRIVATE + dwmapi ole32 shell32 user32 uuid windowscodecs advapi32 + winhttp) +elseif(APPLE) + find_package(CURL REQUIRED) + enable_language(OBJC) + target_sources(bongo_cat_runtime PRIVATE + src/platform/macos/macos.m + src/platform/macos/macos_preferences.m + src/platform/macos/macos_input.m + src/platform/macos/macos_keys.m + src/platform/macos/macos_tray.m) + target_link_libraries(bongo_cat_runtime PRIVATE "-framework Cocoa" + "-framework ApplicationServices" CURL::libcurl) +else() + find_package(CURL REQUIRED) + find_package(X11 REQUIRED) + if(NOT TARGET X11::Xi OR NOT TARGET X11::Xfixes) + message(FATAL_ERROR + "Linux native build requires XInput2 and Xfixes development packages") + endif() + target_sources(bongo_cat_runtime PRIVATE + src/platform/linux/linux.c + src/platform/linux/linux_evdev.c + src/platform/linux/linux_evdev_devices.c + src/platform/linux/linux_evdev_events.c + src/platform/linux/linux_evdev_keys.c + src/platform/linux/linux_x11.c) + target_link_libraries(bongo_cat_runtime PRIVATE + X11::X11 X11::Xi X11::Xfixes m CURL::libcurl) + target_link_libraries(bongo_cat_core PRIVATE m) +endif() diff --git a/cmake/ReleaseOptimize.cmake b/cmake/ReleaseOptimize.cmake index 4e7f95ed..57985643 100644 --- a/cmake/ReleaseOptimize.cmake +++ b/cmake/ReleaseOptimize.cmake @@ -1,5 +1,5 @@ option(BONGO_CAT_OPTIMIZE_RELEASE_SIZE - "Enable conservative size and dead-code optimization for Release builds" ON) + "Enable conservative size and dead-code optimization for Release and MinSizeRel builds" ON) option(BONGO_CAT_OPTIMIZE_RELEASE_IPO "Enable compiler link-time optimization for native project targets" ON) @@ -21,6 +21,8 @@ function(bongo_cat_enable_release_ipo) NOT MINGW AND TARGET "${target}") set_property(TARGET "${target}" PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + set_property(TARGET "${target}" PROPERTY + INTERPROCEDURAL_OPTIMIZATION_MINSIZEREL TRUE) endif() endforeach() endfunction() @@ -32,27 +34,32 @@ endif() # Keep the Windows loader's control-flow enforcement enabled in release # binaries as well as the compiler-side indirect-call instrumentation. if(MSVC OR CMAKE_C_SIMULATE_ID STREQUAL "MSVC") - add_compile_options($<$:/guard:cf>) - add_link_options($<$:/guard:cf>) + add_compile_options($<$:/guard:cf>) + add_link_options($<$:/guard:cf>) endif() # Apply before FetchContent so static dependencies use the same settings. LTO -# stays disabled because not every supported third-party archive is LTO-safe. +# is enabled only for the explicitly selected targets above; prebuilt and +# other third-party archives keep their existing LTO settings. if(BONGO_CAT_OPTIMIZE_RELEASE_SIZE) if(MSVC OR CMAKE_C_SIMULATE_ID STREQUAL "MSVC") - add_compile_options($<$:/O1> - $<$:/Gy> $<$:/Gw>) - add_link_options($<$:/OPT:REF> - $<$:/OPT:ICF>) + # Omit unused inline COMDATs and incremental-link padding. Keep the + # existing runtime, exception handling and floating-point semantics. + add_compile_options($<$:/O1> + $<$:/Gy> $<$:/Gw> + $<$:/Zc:inline>) + add_link_options($<$:/OPT:REF> + $<$:/OPT:ICF> + $<$:/INCREMENTAL:NO>) elseif(CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$") - add_compile_options($<$:-Os> - $<$:-ffunction-sections> - $<$:-fdata-sections>) + add_compile_options($<$:-Os> + $<$:-ffunction-sections> + $<$:-fdata-sections>) if(APPLE) - add_link_options($<$:-Wl,-dead_strip>) + add_link_options($<$:-Wl,-dead_strip>) else() - add_link_options($<$:-Wl,--gc-sections> - $<$:-s>) + add_link_options($<$:-Wl,--gc-sections> + $<$:-s>) endif() endif() endif() diff --git a/cmake/RuntimeSources.cmake b/cmake/RuntimeSources.cmake new file mode 100644 index 00000000..bd607530 --- /dev/null +++ b/cmake/RuntimeSources.cmake @@ -0,0 +1,299 @@ +set(BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/input" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/lifecycle" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/mver" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/model/import/nearby" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/shell" + "${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/update" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/backend" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/preferences" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/rendering" + "${CMAKE_CURRENT_SOURCE_DIR}/src/ui/theme") + +set(BONGO_CAT_MEDIA_SOURCES + src/media/audio/audio.c + src/media/audio/audio_playback.c + src/media/image.c + src/media/image_decode.c + src/media/image_info.c + src/media/image_mipmap.c + src/media/image_resize.c + src/media/image_alpha.c + src/media/stb_image_impl.c) + +set(BONGO_CAT_RENDER_SOURCES + src/render/gl_api.c + src/render/overlay.c + src/render/overlay_assets.c + src/render/overlay_input.c + src/render/overlay_draw.c + src/render/mver_pointer_overlay.c + src/render/mver_pointer_overlay_draw.c) + +set(BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES + src/runtime/lifecycle/app.c + src/runtime/lifecycle/app_entry.c + src/runtime/lifecycle/app_input.c + src/runtime/lifecycle/app_loop.c + src/runtime/lifecycle/assets.c + src/runtime/lifecycle/config_store.c + src/runtime/lifecycle/frame_clock.c + src/runtime/lifecycle/memory_policy.c + src/runtime/lifecycle/runtime_flow.c + src/runtime/lifecycle/runtime_state.c + src/runtime/lifecycle/shutdown.c + src/runtime/lifecycle/startup.c + src/runtime/lifecycle/startup_arguments.c + src/runtime/lifecycle/system_language.c + src/runtime/lifecycle/storage_paths.c) + +set(BONGO_CAT_RUNTIME_UPDATE_SOURCES + src/runtime/update/update_channel.c + src/runtime/update/update_http.c + src/runtime/update/update_http_unix.c + src/runtime/update/update_service.c) + +set(BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES + src/runtime/diagnostics/frame_audit.c + src/runtime/diagnostics/frame_presentation_audit.c + src/runtime/diagnostics/live2d_audit.c + src/runtime/diagnostics/live2d_mouse_mapping_audit.c + src/runtime/diagnostics/live2d_pointer_audit.c + src/runtime/diagnostics/live2d_audit_scenario.c + src/runtime/diagnostics/live2d_viewer_audit.c + src/runtime/diagnostics/live2d_viewer_timing.c + src/runtime/diagnostics/live2d_visual_audit.c) + +set(BONGO_CAT_RUNTIME_INPUT_SOURCES + src/runtime/input/gamepad.c + src/runtime/input/mouse.c + src/runtime/input/mouse_bounds.c + src/runtime/input/mouse_mapping.c + src/runtime/input/mouse_pipeline.c + src/runtime/input/shortcuts.c + src/runtime/input/sound_shortcuts.c) + +set(BONGO_CAT_RUNTIME_MODEL_SOURCES + src/runtime/model/model_behavior.c + src/runtime/model/model_behavior_cache.c + src/runtime/model/model_behavior_state.c + src/runtime/model/model_catalog_runtime.c + src/runtime/model/model_catalog_builtins.c + src/runtime/model/model_catalog_commit.c + src/runtime/model/model_catalog_remove.c + src/runtime/model/model_catalog_refresh.c + src/runtime/model/model_catalog_selection.c + src/runtime/model/model_cover.c + src/runtime/model/model_cover_queue.c + src/runtime/model/model_files.c + src/runtime/model/model_geometry.c + src/runtime/model/multi_pet.c + src/runtime/model/multi_pet_selection.c + src/runtime/model/multi_pet_process.c + src/runtime/model/multi_pet_settings.c + src/runtime/model/model_storage.c + src/runtime/model/model_update.c) + +set(BONGO_CAT_RUNTIME_IMPORT_SOURCES + # Shared import workflow. + src/runtime/model/import/model_import_app.c + src/runtime/model/import/model_import_archive.c + src/runtime/model/import/model_import_adapter.c + src/runtime/model/import/model_import_digest.c + src/runtime/model/import/model_import_digest_api.c + src/runtime/model/import/model_import_digest_cache.c + src/runtime/model/import/model_import_digest_path.c + src/runtime/model/import/model_import_discover.c + src/runtime/model/import/model_import_manifest.c + src/runtime/model/import/model_import_identity.c + src/runtime/model/import/model_import_installed.c + src/runtime/model/import/model_import_install.c + src/runtime/model/import/model_import_lock.c + src/runtime/model/import/model_import_metadata.c + src/runtime/model/import/model_import_package.c + src/runtime/model/import/model_import_package_index.c + src/runtime/model/import/model_import_path.c + src/runtime/model/import/model_import_probe.c + src/runtime/model/import/model_import_progressive.c + src/runtime/model/import/model_import_report.c + src/runtime/model/import/model_import_scan.c + src/runtime/model/import/model_import_session.c + src/runtime/model/import/model_import_source.c + + # Mver is the canonical package and runtime-adapter format. + src/runtime/model/import/mver/model_import_mver_assets.c + src/runtime/model/import/mver/model_import_mver_copy.c + src/runtime/model/import/mver/model_import_mver_discovery.c + src/runtime/model/import/mver/model_import_mver_manifest.c + src/runtime/model/import/mver/model_import_mver_effect.c + src/runtime/model/import/mver/model_import_mver_image.c + src/runtime/model/import/mver/model_import_mver_labels.c + src/runtime/model/import/mver/model_import_mver_metadata.c + src/runtime/model/import/mver/model_import_mver_audio.c + src/runtime/model/import/mver/model_import_mver_motion.c + src/runtime/model/import/mver/model_import_mver_patch.c + src/runtime/model/import/mver/model_import_mver_policy.c + src/runtime/model/import/mver/model_import_mver_shortcut.c + + # Tauri packages are converted to canonical Mver packages before loading. + src/runtime/model/import/tauri/model_import_tauri_candidate.c + src/runtime/model/import/tauri/model_import_tauri_config.c + src/runtime/model/import/tauri/model_import_tauri_convert.c + src/runtime/model/import/tauri/model_import_tauri_discovery.c + src/runtime/model/import/tauri/model_import_tauri_images.c + src/runtime/model/import/tauri/model_import_tauri_input.c + src/runtime/model/import/tauri/model_import_tauri_keys.c + src/runtime/model/import/tauri/model_import_tauri_legacy.c + src/runtime/model/import/tauri/model_import_tauri_metadata.c + src/runtime/model/import/tauri/model_import_tauri_resources.c + src/runtime/model/import/tauri/model_import_tauri_tree.c + + # Nearby discovery builds adapters without installing source packages. + src/runtime/model/import/nearby/model_import_nearby.c + src/runtime/model/import/nearby/model_import_nearby_cache.c + src/runtime/model/import/nearby/model_import_nearby_signature.c) + +set(BONGO_CAT_RUNTIME_SHELL_SOURCES + src/runtime/shell/modal_frame.c + src/runtime/shell/tray.c + src/runtime/shell/window.c + src/runtime/shell/window_background.c + src/runtime/shell/window_display.c + src/runtime/shell/window_drag.c + src/runtime/shell/window_geometry.c + src/runtime/shell/window_hit.c + src/runtime/shell/window_menu_actions.c + src/runtime/shell/window_menu_behavior.c + src/runtime/shell/window_menu_behavior_test.c + src/runtime/shell/window_menu_preview.c + src/runtime/shell/window_scale.c + src/runtime/shell/window_snapshot.c + src/runtime/shell/window_wheel.c + src/runtime/shell/window_wheel_test.c) + +set(BONGO_CAT_UI_BACKEND_SOURCES + src/ui/backend/nuklear_impl.c + src/ui/backend/ui_backend.c + src/ui/backend/ui_backend_fonts.c + src/ui/backend/ui_cursor.c + src/ui/backend/ui_input.c + src/ui/backend/ui_resize_cache.c + src/ui/backend/ui_scale.c + src/ui/backend/ui_tooltip.c) + +set(BONGO_CAT_UI_RENDERING_SOURCES + src/ui/dial/dial_window.c + src/ui/dial/dial_input.c + src/ui/dial/dial_items.c + src/ui/dial/dial_geometry.c + src/ui/dial/dial_paint.c + src/ui/dial/dial_draw.c + src/ui/dial/dial_scene.c + src/ui/dial/dial_fonts.c + src/ui/dial/dial_covers.c + src/ui/dial/dial_icons.c + src/ui/rendering/ui_animation.c + src/ui/rendering/ui_font.c + src/ui/rendering/ui_font_atlas.c + src/ui/rendering/ui_font_atlas_ranges.c + src/ui/rendering/ui_font_atlas_sources.c + src/ui/rendering/ui_font_atlas_upload.c + src/ui/rendering/ui_font_reload.c + src/ui/rendering/ui_native_theme.c + src/ui/rendering/ui_paint.c + src/ui/rendering/ui_paint_border.c + src/ui/rendering/ui_paint_cache.c + src/ui/rendering/ui_paint_lifecycle.c + src/ui/rendering/ui_paint_shell.c) + +set(BONGO_CAT_UI_THEME_SOURCES + src/ui/theme/ui_catime.c + src/ui/theme/ui_catime_icons.c + src/ui/theme/ui_catime_tabs.c + src/ui/theme/ui_catime_theme.c) + +set(BONGO_CAT_UI_PREFERENCES_ABOUT_SOURCES + src/ui/preferences/about/preferences_about.c + src/ui/preferences/about/preferences_about_community.c + src/ui/preferences/about/preferences_about_contributors.c + src/ui/preferences/about/preferences_about_feed.c + src/ui/preferences/about/preferences_about_footer.c + src/ui/preferences/about/preferences_about_lifecycle.c + src/ui/preferences/about/preferences_about_online.c + src/ui/preferences/about/preferences_about_page.c + src/ui/preferences/about/preferences_about_svg.c + src/ui/preferences/about/preferences_about_text.c + src/ui/preferences/about/preferences_about_wechat.c) + +set(BONGO_CAT_UI_PREFERENCES_SOURCES + ${BONGO_CAT_UI_PREFERENCES_ABOUT_SOURCES} + src/ui/preferences/preferences.c + src/ui/preferences/preferences_assets.c + src/ui/preferences/preferences_behavior_dialog.c + src/ui/preferences/preferences_behavior_rename.c + src/ui/preferences/preferences_behavior_row.c + src/ui/preferences/preferences_combo.c + src/ui/preferences/preferences_controls.c + src/ui/preferences/preferences_number_edit.c + src/ui/preferences/preferences_dialog.c + src/ui/preferences/preferences_form_label.c + src/ui/preferences/preferences_fonts.c + src/ui/preferences/preferences_gl.c + src/ui/preferences/preferences_import.c + src/ui/preferences/preferences_import_dialog.c + src/ui/preferences/preferences_import_progress.c + src/ui/preferences/preferences_import_worker.c + src/ui/preferences/preferences_icons.c + src/ui/preferences/preferences_language.c + src/ui/preferences/preferences_lifecycle.c + src/ui/preferences/preferences_live_resize.c + src/ui/preferences/preferences_model.c + src/ui/preferences/preferences_model_card.c + src/ui/preferences/preferences_model_card_paint.c + src/ui/preferences/preferences_model_cover.c + src/ui/preferences/preferences_model_section.c + src/ui/preferences/preferences_model_watch.c + src/ui/preferences/preferences_model_glyphs.c + src/ui/preferences/preferences_model_import.c + src/ui/preferences/preferences_model_rename.c + src/ui/preferences/preferences_notice.c + src/ui/preferences/preferences_overlay.c + src/ui/preferences/preferences_page_dispatch.c + src/ui/preferences/preferences_pages.c + src/ui/preferences/preferences_present.c + src/ui/preferences/preferences_render.c + src/ui/preferences/preferences_row_icons.c + src/ui/preferences/preferences_scale.c + src/ui/preferences/preferences_scrollbar.c + src/ui/preferences/preferences_section_icons.c + src/ui/preferences/preferences_shortcut_clear.c + src/ui/preferences/preferences_shortcuts.c + src/ui/preferences/preferences_smoke.c + src/ui/preferences/preferences_text_edit.c + src/ui/preferences/preferences_text_session.c + src/ui/preferences/preferences_theme.c + src/ui/preferences/preferences_toggle.c + src/ui/preferences/preferences_widget_shortcut.c + src/ui/preferences/preferences_widgets.c + src/ui/preferences/preferences_widgets_compound.c) + +set(BONGO_CAT_RUNTIME_SOURCES + src/core/app_state.c + src/platform/common/memory.c + src/platform/common/update_shutdown.c + ${BONGO_CAT_MEDIA_SOURCES} + ${BONGO_CAT_RENDER_SOURCES} + ${BONGO_CAT_RUNTIME_LIFECYCLE_SOURCES} + ${BONGO_CAT_RUNTIME_UPDATE_SOURCES} + ${BONGO_CAT_RUNTIME_DIAGNOSTIC_SOURCES} + ${BONGO_CAT_RUNTIME_INPUT_SOURCES} + ${BONGO_CAT_RUNTIME_MODEL_SOURCES} + ${BONGO_CAT_RUNTIME_IMPORT_SOURCES} + ${BONGO_CAT_RUNTIME_SHELL_SOURCES} + ${BONGO_CAT_UI_BACKEND_SOURCES} + ${BONGO_CAT_UI_RENDERING_SOURCES} + ${BONGO_CAT_UI_THEME_SOURCES} + ${BONGO_CAT_UI_PREFERENCES_SOURCES}) diff --git a/cmake/SDLWindowsRuntime.cmake b/cmake/SDLWindowsRuntime.cmake new file mode 100644 index 00000000..8803d8a1 --- /dev/null +++ b/cmake/SDLWindowsRuntime.cmake @@ -0,0 +1,92 @@ +function(bongo_cat_sdl_replace_section variable begin end replacement) + set(source "${${variable}}") + string(FIND "${source}" "${begin}" first) + if(first LESS 0) + message(FATAL_ERROR "Pinned SDL Windows patch mismatch: ${begin}") + endif() + string(LENGTH "${begin}" begin_size) + math(EXPR after_begin "${first} + ${begin_size}") + string(SUBSTRING "${source}" ${after_begin} -1 tail) + string(FIND "${tail}" "${begin}" duplicate) + string(FIND "${tail}" "${end}" last) + if(NOT duplicate EQUAL -1 OR last LESS 0) + message(FATAL_ERROR "Ambiguous pinned SDL Windows patch: ${begin}") + endif() + string(SUBSTRING "${source}" 0 ${first} prefix) + string(SUBSTRING "${tail}" ${last} -1 suffix) + set(${variable} "${prefix}${replacement}${suffix}" PARENT_SCOPE) +endfunction() + +function(bongo_cat_sdl_generated_source target original output source) + get_target_property(sources ${target} SOURCES) + if(NOT "${original}" IN_LIST sources) + message(FATAL_ERROR "Pinned SDL target no longer contains ${original}") + endif() + set(marked "#define BONGO_CAT_MODIFIED_SDL_WINDOWS_BACKEND 1\n${source}") + if(EXISTS "${output}") + file(READ "${output}" previous) + endif() + if(NOT marked STREQUAL previous) + file(WRITE "${output}" "${marked}") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${original}") + list(REMOVE_ITEM sources "${original}") + set_property(TARGET ${target} PROPERTY SOURCES "${sources}") + target_sources(${target} PRIVATE "${output}") +endfunction() + +function(bongo_cat_trim_sdl_windows target source_root) + set(windows_dir "${source_root}/src/video/windows") + set(output_dir "${CMAKE_CURRENT_BINARY_DIR}/generated/sdl-windows") + file(MAKE_DIRECTORY "${output_dir}") + target_include_directories(${target} PRIVATE "${windows_dir}") + + set(window_path "${windows_dir}/SDL_windowswindow.c") + file(READ "${window_path}" window) + string(REPLACE "\r\n" "\n" window "${window}") + bongo_cat_sdl_replace_section(window + " if (data->keyboard_hook) {" " ReleaseDC(data->hwnd, data->hdc);" "") + bongo_cat_sdl_replace_section(window + "static void WIN_GrabKeyboard(SDL_Window *window)" + "bool WIN_SetWindowMouseRect(" "") + bongo_cat_sdl_replace_section(window + "bool WIN_SetWindowKeyboardGrab(" "\n#endif // !defined(SDL_PLATFORM_XBOXONE)" + [=[bool WIN_SetWindowKeyboardGrab(SDL_VideoDevice *_this, SDL_Window *window, bool grabbed) +{ + (void)_this; + (void)window; + return grabbed ? SDL_Unsupported() : true; +} +]=]) + bongo_cat_sdl_replace_section(window + "void WIN_RaiseWindow(" "void WIN_MaximizeWindow(" + [=[void WIN_RaiseWindow(SDL_VideoDevice *_this, SDL_Window *window) +{ +#if !defined(SDL_PLATFORM_XBOXONE) && !defined(SDL_PLATFORM_XBOXSERIES) + const bool activate = SDL_GetHintBoolean(SDL_HINT_WINDOW_ACTIVATE_WHEN_RAISED, true); + SDL_WindowData *data = window->internal; + HWND hwnd = data->hwnd; + if (activate) { + SetForegroundWindow(hwnd); + if ((window->flags & SDL_WINDOW_POPUP_MENU) && !(window->flags & SDL_WINDOW_NOT_FOCUSABLE)) { + WIN_SetKeyboardFocus(window, window->parent == SDL_GetKeyboardFocus()); + } + } else { + SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, data->copybits_flag | SWP_NOMOVE | SWP_NOSIZE | SWP_NOOWNERZORDER | SWP_NOACTIVATE); + } +#endif +} + +]=]) + bongo_cat_sdl_generated_source(${target} "${window_path}" + "${output_dir}/SDL_windowswindow.c" "${window}") + + set(events_path "${windows_dir}/SDL_windowsevents.c") + file(READ "${events_path}" events) + string(REPLACE "\r\n" "\n" events "${events}") + bongo_cat_sdl_replace_section(events + "LRESULT CALLBACK\nWIN_KeyboardHookProc(" "static bool WIN_SwapButtons(" "") + bongo_cat_sdl_generated_source(${target} "${events_path}" + "${output_dir}/SDL_windowsevents.c" "${events}") + set(BONGO_CAT_SDL_WINDOWS_TRIMMED TRUE PARENT_SCOPE) +endfunction() diff --git a/cmake/Tests.cmake b/cmake/Tests.cmake new file mode 100644 index 00000000..a5ed46f8 --- /dev/null +++ b/cmake/Tests.cmake @@ -0,0 +1,281 @@ +if(BUILD_TESTING) + add_executable(bongo_cat_input_concurrent_tests tests/core/test_input_concurrent.c) + target_include_directories(bongo_cat_input_concurrent_tests PRIVATE tests/support) + target_link_libraries(bongo_cat_input_concurrent_tests PRIVATE + bongo_cat_core SDL3::SDL3-static bongo_cat_warnings) + add_test(NAME input-concurrent COMMAND bongo_cat_input_concurrent_tests) + set_tests_properties(input-concurrent PROPERTIES TIMEOUT 30) + + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(bongo_cat_linux_evdev_tests tests/platform/test_linux_evdev.c) + target_include_directories(bongo_cat_linux_evdev_tests PRIVATE + src/platform/linux tests/support) + target_link_libraries(bongo_cat_linux_evdev_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME linux-evdev COMMAND bongo_cat_linux_evdev_tests) + set_tests_properties(linux-evdev PROPERTIES TIMEOUT 15) + add_executable(bongo_cat_linux_window_tests tests/platform/test_linux_window.c) + target_include_directories(bongo_cat_linux_window_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS} tests/support) + target_link_libraries(bongo_cat_linux_window_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME linux-window COMMAND bongo_cat_linux_window_tests + --ci-smoke --ci-ignore-global-input + "--storage-root=${CMAKE_CURRENT_BINARY_DIR}/linux-window-data") + set_tests_properties(linux-window PROPERTIES TIMEOUT 30 + ENVIRONMENT "BONGOCAT_ENABLE_EVDEV=0;BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1") + endif() + add_executable(bongo_cat_image_filter_tests tests/media/test_image_filter.c) + target_include_directories(bongo_cat_image_filter_tests PRIVATE + src/media tests/support) + target_include_directories(bongo_cat_image_filter_tests SYSTEM PRIVATE + ${BONGO_CAT_STB_INCLUDE_DIR}) + target_link_libraries(bongo_cat_image_filter_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME image-filter COMMAND bongo_cat_image_filter_tests) + if(BONGO_CAT_CUBISM_ENABLED) + add_executable(bongo_cat_overlay_layout_tests tests/media/test_overlay_layout.c) + target_include_directories(bongo_cat_overlay_layout_tests PRIVATE tests/support) + target_include_directories(bongo_cat_overlay_layout_tests SYSTEM PRIVATE + ${BONGO_CAT_STB_INCLUDE_DIR}) + target_link_libraries(bongo_cat_overlay_layout_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME overlay-layout COMMAND bongo_cat_overlay_layout_tests) + endif() + if(APPLE) + add_test(NAME macos-bundle-signature COMMAND /usr/bin/codesign + --verify --strict --deep --verbose=2 "$") + endif() + + add_executable(bongo_cat_hover_fade_tests + tests/core/test_hover_fade.c src/runtime/input/mouse.c + src/runtime/shell/modal_frame.c) + target_include_directories(bongo_cat_hover_fade_tests PRIVATE + "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include tests/support + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_link_libraries(bongo_cat_hover_fade_tests PRIVATE + SDL3::SDL3-static bongo_cat_warnings) + add_test(NAME hover-fade COMMAND bongo_cat_hover_fade_tests) + + add_executable(bongo_cat_audio_tests tests/media/test_audio.c) + target_include_directories(bongo_cat_audio_tests PRIVATE src/media/audio tests/support) + target_link_libraries(bongo_cat_audio_tests PRIVATE bongo_cat_runtime bongo_cat_warnings) + target_compile_definitions(bongo_cat_audio_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME audio COMMAND bongo_cat_audio_tests) + + add_executable(bongo_cat_log_policy_tests tests/core/test_log_policy.c) + target_link_libraries(bongo_cat_log_policy_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME log-policy COMMAND bongo_cat_log_policy_tests) + + add_executable(bongo_cat_core_tests + tests/core/test_main.c + tests/core/test_config.c + tests/core/test_config_validation.c + tests/core/test_language.c + tests/core/test_input.c + tests/core/test_models.c + tests/core/test_mver_pointer.c + tests/core/test_shortcut.c + tests/core/test_sound_shortcut.c + tests/core/test_update.c + src/platform/windows/windows_keys.c) + target_link_libraries(bongo_cat_core_tests PRIVATE + bongo_cat_core bongo_cat_warnings $<$:user32>) + target_include_directories(bongo_cat_core_tests PRIVATE + tests/support + $<$:${CMAKE_CURRENT_SOURCE_DIR}/src/platform/windows>) + target_compile_definitions(bongo_cat_core_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME core COMMAND bongo_cat_core_tests) + + add_executable(bongo_cat_i18n_tests tests/i18n/test_i18n.c) + target_link_libraries(bongo_cat_i18n_tests PRIVATE + bongo_cat_core bongo_cat_warnings) + target_compile_definitions(bongo_cat_i18n_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_test(NAME i18n COMMAND bongo_cat_i18n_tests) + + add_executable(bongo_cat_ui_tests + tests/ui/test_nuklear.c + src/ui/backend/nuklear_impl.c + src/ui/rendering/ui_paint_border.c) + target_include_directories(bongo_cat_ui_tests PRIVATE + src/ui/backend src/ui/rendering) + target_include_directories(bongo_cat_ui_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_ui_tests PRIVATE bongo_cat_warnings) + add_test(NAME ui COMMAND bongo_cat_ui_tests) + + if(APPLE) + # The refresh helper runs the real page code with the platform read replaced + # per target, so the repaint decision is tested without touching TCC. + add_executable(bongo_cat_input_monitoring_refresh_tests + tests/ui/test_input_monitoring_refresh.c + src/ui/preferences/preferences_pages.c) + target_compile_definitions(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_platform_input_monitoring_authorized=bongo_cat_test_input_monitoring_authorized) + target_include_directories(bongo_cat_input_monitoring_refresh_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_input_monitoring_refresh_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_input_monitoring_refresh_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME macos-input-monitoring-refresh + COMMAND bongo_cat_input_monitoring_refresh_tests) + endif() + + add_executable(bongo_cat_app_state_tests + tests/core/test_app_state.c src/core/app_state.c + src/runtime/model/model_behavior_state.c) + target_link_libraries(bongo_cat_app_state_tests PRIVATE bongo_cat_warnings) + target_include_directories(bongo_cat_app_state_tests PRIVATE + "${BONGO_CAT_GENERATED_INCLUDE_DIR}" include tests/support) + add_test(NAME app-state COMMAND bongo_cat_app_state_tests) + + set(BONGO_CAT_MVER_IMPORT_TEST_SOURCES + tests/model_import/test_mver_audio.c + tests/model_import/test_mver_import.c + tests/model_import/test_model_import_source.c + tests/model_import/test_model_import_archive.c + tests/model_import/test_mver_manifest.c + tests/model_import/test_tauri_portable.c + tests/model_import/test_mver_container.c + tests/model_import/test_mver_missing_motion.c + tests/model_import/test_mver_pointer_import.c + tests/ui/test_preferences_text.c + tests/model_import/test_mver_nearby_identity.c + tests/model_import/test_mver_policy.c + tests/model_import/test_model_import_identity.c + tests/model_import/test_slim_package.c + tests/model_import/test_mver_support.c) + add_executable(bongo_cat_mver_import_tests + ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES}) + target_include_directories(bongo_cat_mver_import_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_mver_import_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR} ${BONGO_CAT_STB_INCLUDE_DIR}) + target_link_libraries(bongo_cat_mver_import_tests PRIVATE + bongo_cat_runtime bongo_cat_archive) + target_compile_definitions(bongo_cat_mver_import_tests PRIVATE + BONGO_CAT_NATIVE_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + if(MSVC) + set_property(SOURCE ${BONGO_CAT_MVER_IMPORT_TEST_SOURCES} + APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + set_property(SOURCE tests/platform/test_windows_capture.c APPEND PROPERTY + COMPILE_OPTIONS "/experimental:c11atomics") + endif() + add_test(NAME model-import-unit COMMAND bongo_cat_mver_import_tests) + + add_executable(bongo_cat_preferences_lifecycle_tests + tests/ui/test_preferences_lifecycle.c) + target_include_directories(bongo_cat_preferences_lifecycle_tests PRIVATE + ${BONGO_CAT_RUNTIME_INTERNAL_INCLUDE_DIRS}) + target_include_directories(bongo_cat_preferences_lifecycle_tests SYSTEM PRIVATE + ${BONGO_CAT_NUKLEAR_INCLUDE_DIR}) + target_link_libraries(bongo_cat_preferences_lifecycle_tests PRIVATE bongo_cat_runtime) + if(WIN32) + target_sources(bongo_cat_preferences_lifecycle_tests PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/windows_resources.rc") + add_dependencies(bongo_cat_preferences_lifecycle_tests bongo_cat_asset_pack) + else() + add_custom_command(TARGET bongo_cat_preferences_lifecycle_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "$/assets") + endif() + bongo_cat_stage_cubism_assets(bongo_cat_preferences_lifecycle_tests) + if(MSVC) + target_compile_options(bongo_cat_preferences_lifecycle_tests PRIVATE + /experimental:c11atomics) + endif() + add_test(NAME preferences-lifecycle COMMAND bongo_cat_preferences_lifecycle_tests + --ci-smoke --ci-ignore-global-input + "--storage-root=${CMAKE_CURRENT_BINARY_DIR}/preferences-lifecycle-data") + set_tests_properties(preferences-lifecycle PROPERTIES + ENVIRONMENT "BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN=1" TIMEOUT 60) + if(APPLE) + set_tests_properties(preferences-lifecycle PROPERTIES DISABLED TRUE) + endif() + + if(BONGO_CAT_CUBISM_ENABLED) + add_executable(bongo_cat_core_profile_tests tests/live2d/test_core_profile.cpp) + target_include_directories(bongo_cat_core_profile_tests PRIVATE src/live2d) + target_link_libraries(bongo_cat_core_profile_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME live2d-core-profile COMMAND bongo_cat_core_profile_tests) + set_tests_properties(live2d-core-profile PROPERTIES TIMEOUT 30) + + add_test(NAME model-startup-recovery COMMAND ${CMAKE_COMMAND} + "-DEXECUTABLE=$" + "-DASSET_ROOT=${CMAKE_CURRENT_SOURCE_DIR}/resources/assets" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}/model-startup-recovery" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckModelStartupRecovery.cmake") + set_tests_properties(model-startup-recovery PROPERTIES TIMEOUT 60) + if(APPLE) + set_tests_properties(model-startup-recovery PROPERTIES DISABLED TRUE) + endif() + add_executable(bongo_cat_motion_state_tests + tests/live2d/test_motion_state.cpp) + target_include_directories(bongo_cat_motion_state_tests PRIVATE + src/live2d tests/support) + target_link_libraries(bongo_cat_motion_state_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME live2d-motion-state COMMAND bongo_cat_motion_state_tests) + endif() + + if(APPLE) + add_executable(bongo_cat_macos_click_through_tests + tests/platform/test_macos_click_through.m) + target_link_libraries(bongo_cat_macos_click_through_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME macos-click-through COMMAND bongo_cat_macos_click_through_tests) + set_tests_properties(macos-click-through PROPERTIES + TIMEOUT 60 SKIP_RETURN_CODE 77) + endif() + + if(WIN32) + add_executable(bongo_cat_windows_input_tests + tests/platform/test_windows_input.c + tests/platform/test_windows_relative.c + tests/platform/test_windows_mouse_mapping.c + tests/platform/test_windows_pointer_detection.c + tests/platform/test_windows_raw_receiver.c) + if(MSVC) + # Visual Studio can evaluate target language options as C++ with Cubism. + set_property(SOURCE + tests/platform/test_windows_input.c + tests/platform/test_windows_relative.c + tests/platform/test_windows_mouse_mapping.c + tests/platform/test_windows_pointer_detection.c + tests/platform/test_windows_raw_receiver.c + APPEND PROPERTY COMPILE_OPTIONS "/experimental:c11atomics") + endif() + target_include_directories(bongo_cat_windows_input_tests PRIVATE + tests/support src/platform/windows src/runtime/input) + target_link_libraries(bongo_cat_windows_input_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings user32) + add_test(NAME windows-input COMMAND bongo_cat_windows_input_tests) + set_tests_properties(windows-input PROPERTIES TIMEOUT 30) + + add_executable(bongo_cat_windows_capture_tests + tests/platform/test_windows_capture.c) + target_include_directories(bongo_cat_windows_capture_tests PRIVATE + src/platform/windows) + target_link_libraries(bongo_cat_windows_capture_tests PRIVATE + bongo_cat_runtime bongo_cat_warnings) + add_test(NAME windows-capture COMMAND bongo_cat_windows_capture_tests) + endif() + + if(UNIX AND NOT APPLE) + target_link_libraries(bongo_cat_ui_tests PRIVATE m) + target_link_libraries(bongo_cat_app_state_tests PRIVATE m) + endif() + + if(APPLE) + add_test(NAME macos-bundle-icon COMMAND ${CMAKE_COMMAND} + "-DBUNDLE=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckMacOSBundleIcon.cmake") + endif() +endif() diff --git a/cmake/ValidationTools.cmake b/cmake/ValidationTools.cmake new file mode 100644 index 00000000..9205c5d6 --- /dev/null +++ b/cmake/ValidationTools.cmake @@ -0,0 +1,55 @@ +if(WIN32) + add_library(bongo_cat_tool_support STATIC EXCLUDE_FROM_ALL + src/tools/windows_tool.c) + target_include_directories(bongo_cat_tool_support PUBLIC src/tools) + target_link_libraries(bongo_cat_tool_support PUBLIC bongo_cat_warnings) + + add_executable(cubism_viewer_desktop_capture EXCLUDE_FROM_ALL + src/tools/cubism_viewer_desktop_capture.c + src/tools/cubism_viewer_desktop_capture_dxgi.c) + target_compile_definitions(cubism_viewer_desktop_capture PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_desktop_capture PRIVATE + bongo_cat_tool_support bongo_cat_warnings d3d11 dxgi dxguid user32) + + add_library(bongo_cat_validation_image STATIC EXCLUDE_FROM_ALL + src/tools/validation_image.c src/tools/validation_gdiplus.c) + target_include_directories(bongo_cat_validation_image PUBLIC src/tools) + target_link_libraries(bongo_cat_validation_image PUBLIC + bongo_cat_warnings windowscodecs gdiplus ole32) + + add_executable(cubism_viewer_drag_capture EXCLUDE_FROM_ALL + src/tools/cubism_viewer_drag_capture.c) + target_compile_definitions(cubism_viewer_drag_capture PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_drag_capture PRIVATE + bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings + user32 gdi32 ole32) + + add_executable(cubism_viewer_blind_test EXCLUDE_FROM_ALL + src/tools/cubism_viewer_blind_test.c + src/tools/cubism_viewer_blind_frames.c) + target_compile_definitions(cubism_viewer_blind_test PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(cubism_viewer_blind_test PRIVATE + bongo_cat_validation_image bongo_cat_tool_support bongo_cat_warnings ole32) + + add_executable(mver_blind_metrics EXCLUDE_FROM_ALL + src/tools/mver_blind_metrics.c) + target_compile_definitions(mver_blind_metrics PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(mver_blind_metrics PRIVATE + bongo_cat_validation_image bongo_cat_warnings ole32) + + add_executable(mver_phase_metrics EXCLUDE_FROM_ALL + src/tools/mver_phase_metrics.c + src/tools/mver_phase_frames.c) + target_compile_definitions(mver_phase_metrics PRIVATE + _CRT_SECURE_NO_WARNINGS) + target_link_libraries(mver_phase_metrics PRIVATE + bongo_cat_validation_image bongo_cat_warnings ole32) + + add_custom_target(bongo_cat_validation_tools + DEPENDS cubism_viewer_desktop_capture cubism_viewer_drag_capture + cubism_viewer_blind_test mver_blind_metrics mver_phase_metrics) +endif() diff --git a/cmake/Version.cmake b/cmake/Version.cmake new file mode 100644 index 00000000..f825f30c --- /dev/null +++ b/cmake/Version.cmake @@ -0,0 +1,3 @@ +set(BONGO_CAT_GENERATED_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated") +configure_file(cmake/version.h.in + "${BONGO_CAT_GENERATED_INCLUDE_DIR}/bongo_cat/version.h" @ONLY) diff --git a/cmake/version.h.in b/cmake/version.h.in new file mode 100644 index 00000000..ca9b3c43 --- /dev/null +++ b/cmake/version.h.in @@ -0,0 +1,7 @@ +#ifndef BONGO_CAT_VERSION_H +#define BONGO_CAT_VERSION_H + +#define BONGO_CAT_VERSION "@PROJECT_VERSION@" +#define BONGO_CAT_VERSION_DISPLAY "v" BONGO_CAT_VERSION + +#endif diff --git a/cmake/windows.manifest b/cmake/windows.manifest.in similarity index 69% rename from cmake/windows.manifest rename to cmake/windows.manifest.in index 18b8f4cd..1b578abe 100644 --- a/cmake/windows.manifest +++ b/cmake/windows.manifest.in @@ -1,7 +1,14 @@ - + + + + + + + diff --git a/cmake/windows_resources.rc.in b/cmake/windows_resources.rc.in index 29a3853c..86375334 100644 --- a/cmake/windows_resources.rc.in +++ b/cmake/windows_resources.rc.in @@ -21,7 +21,7 @@ BEGIN BEGIN BLOCK "040904b0" BEGIN - VALUE "CompanyName", "BongoCat\0" + VALUE "CompanyName", "vladelaina\0" VALUE "FileDescription", "@PROJECT_DESCRIPTION@\0" VALUE "FileVersion", "@PROJECT_VERSION@\0" VALUE "InternalName", "BongoCat\0" diff --git a/docs/README.de-DE.md b/docs/README.de-DE.md new file mode 100644 index 00000000..61bb1136 --- /dev/null +++ b/docs/README.de-DE.md @@ -0,0 +1,252 @@ + + +

💘 C/C++ × SDL3 × OpenGL, zusammenrühren, drauflos trommeln! Bong~ Bongo Cat!!!

+

+ Sprache wählen ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!TIP] +> Das in der Demo verwendete Modell stammt von [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Auf der Suche nach **kostenlosen** Modellen? Wir arbeiten mit talentierten Modellerstellern zusammen, um dir eine große Auswahl an kostenlosen Modellen zu bieten – und erkunden daneben ständig weitere unterhaltsame Desktop-Erlebnisse! Besuche unsere offizielle Website: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 Download + + + +- GitHub Releases + + Lade die neueste Version von den [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) herunter. + +## 🛠️ Aus dem Quellcode erstellen + +BongoCat verwendet CMake und benötigt einen C11-Compiler, einen C++17-Compiler, CMake 3.24 oder neuer sowie die OpenGL-Entwicklungsdateien für den Desktop. Standardmäßig werden SDL3, yyjson, stb, miniaudio und Nuklear während der Konfigurationsphase automatisch heruntergeladen, daher ist für die erste Konfiguration eine Internetverbindung erforderlich. + +Führe die folgenden Befehle im Projektstammverzeichnis aus (dem Verzeichnis, das `CMakeLists.txt` enthält). + +### 📋 Plattform-Voraussetzungen + +- **Windows:** Visual Studio 2022 (mit der Workload „Desktopentwicklung mit C++“) und CMake. Verwende den MSVC-Generator; MinGW kann das Diagnose-Backend bauen, unterstützt jedoch nicht das Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake und Ninja. Weicht die Zielarchitektur von der Standardarchitektur des Hosts ab, gib sie über `CMAKE_OSX_ARCHITECTURES` an. +- **Linux (Debian/Ubuntu):** GCC oder Clang, Ninja sowie die OpenGL/X11-Header: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 Konfiguration und Build + +Verwende unter Linux und macOS einen Einzelkonfigurations-Generator wie Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Führe die Befehle unter Windows von der Visual Studio 2022 Developer Command Prompt aus (oder einer anderen Shell, in der MSVC verfügbar ist): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +Die ausführbare Datei befindet sich unter `build/BongoCat` auf Linux, unter `build/BongoCat.app/Contents/MacOS/BongoCat` auf macOS und unter `build/Release/BongoCat.exe` für Visual-Studio-Builds auf Windows. + +### 🧪 Tests + +Die CTest-Ziele sind standardmäßig aktiviert. Führe nach dem Build Folgendes aus: + +```bash +ctest --test-dir build --output-on-failure +``` + +Gib bei einem Mehrkonfigurations-Generator wie Visual Studio die Build-Konfiguration explizit an: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (optional) + +Wird kein Cubism SDK gefunden, gibt CMake eine Warnung aus und baut das Diagnose-Backend. Dieses Backend dient dem Start und der Plattformdiagnose; es bietet kein Rendering von Live2D-Modellen. Um die vollständige Laufzeit zu bauen, installiere eine kompatible Version des Cubism SDK for Native, platziere sie unter `vendor/CubismSdkForNative` oder gib den Pfad explizit an: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +Das SDK muss die Core-Bibliothek, die Framework-Quellen sowie das OpenGL-GLEW-Drittanbieterverzeichnis in der von `cmake/Cubism.cmake` erwarteten Struktur enthalten. Cubism-Builds unter Windows erfordern Visual Studio 2022. Mit `BONGO_CAT_REQUIRE_CUBISM=ON` schlägt die Konfiguration fehl, wenn kein SDK verfügbar ist, anstatt stillschweigend das Diagnose-Backend zu wählen. + +### ⚙️ CMake-Optionen + +| Option | Standard | Beschreibung | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Lädt Drittanbieter-Abhängigkeiten in festgelegten Versionen über CMake `FetchContent` herunter. Setze es nur auf `OFF`, wenn SDL3, yyjson, stb, miniaudio und Nuklear bereits für CMake verfügbar sind. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Pfad zum Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Lässt die Konfiguration fehlschlagen, wenn kein SDK verfügbar ist. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Behandelt Warnungen des nativen Compilers als Fehler. | + +Setze für Offline-Builds `BONGO_CAT_FETCH_DEPS=OFF` und stelle die CMake-Paketkonfigurationen für SDL3 (einschließlich `SDL3-static`) und yyjson bereit; falls stb, Nuklear und miniaudio nicht automatisch gefunden werden können, gib zusätzlich deren Include-Verzeichnisse an: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Projektstatus + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Lizenz + +Der Quellcode und die native Laufzeit von BongoCat stehen unter der [AGPL-3.0-only](../LICENSE)-Lizenz. + +Das standardmäßig eingebaute Modell (`standard`) bleibt unter der MIT-Lizenz. Die in `resources/assets/models/standard`, `keyboard` und `gamepad` gebündelten Modellressourcen fallen unter die separate [MIT-Lizenz-Deklaration](../LICENSE-MIT). Diese MIT-Lizenz gilt ausschließlich für die Modellressourcen und die zugehörigen Grafiken; sie ändert nichts an der Lizenz des BongoCat-Quellcodes oder der nativen Laufzeit. + +## 🧭 Technische Architektur + +Die aktuelle native Version basiert auf C/C++, SDL3 und OpenGL. Das folgende Diagramm zeigt den Datenfluss zur Laufzeit; Details zu Build und Packaging findest du in den CMake-Dateien. + +### 🔄 Runtime-Eigentum und Frame-Planung + +Jeder Prozess besitzt eine `BongoCatApp`-Instanz und eine Ereignis-/Render-Schleife auf dem Hauptthread. Die Plattform-Listener enden an der Eingabegrenze: + +```text +Plattform-Listener (Tastatur/Zeiger) + | + v + C11-Eingabezustand (atomare Flankenwarteschlange + zusammengeführte Zeigerposition) + | + v + Anwendung auf dem Hauptthread <----- SDL3-Ereignisse + | + v + Modellparameter, Overlays und UI-Zustand + | + v + Modell-Update -> OpenGL-Komposition -> Plattform-Präsentation +``` + +Der Windows-Raw-Input-Empfänger, der macOS-Quartz-Ereignis-Tap und die Linux-XInput2-Listener laufen außerhalb der Hauptschleife. Tastatur- und Maustastenereignisse gelangen in eine begrenzte atomare Warteschlange; Bewegungen werden separat zusammengefasst und SDL-Ereignisse wecken den Hauptthread. Windows empfängt Hintergrundeingaben über ein Nachrichtenfenster mit `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, wobei normale Fensternachrichten erhalten bleiben. Wenn eine andere Anwendung den Cursor ausblendet oder sperrt, nutzt das Modell die Gerätebewegungen; SDL liefert die Cursorposition auf dem Desktop. Beim Entfernen eines Geräts oder Wechsel des Eingabedesktops werden gehaltene Zustände bereinigt. Windows verwendet keine Eingabe-Hooks oder DirectInput mehr. SDL3-Fenster-, Präferenz- und Gamepad-Ereignisse werden auf dem Hauptthread verarbeitet. Kein Plattform-Listener ruft direkt Live2D-, Overlay- oder UI-Code auf. + +`bongo_cat_app_run` ist für Update-, Shutdown- und Sekundärprozessparameter verantwortlich, erzwingt den Besitz einer einzelnen Instanz des Hauptprozesses, weist den Anwendungszustand zu, führt die Initialisierung aus, betritt `bongo_cat_app_loop` und aktualisiert anschließend in definierter Reihenfolge den Zustand und gibt Ressourcen frei. Die Initialisierung lädt Konfiguration und Speicherpfade, findet Ressourcen, erstellt das SDL/OpenGL-Pet-Fenster, initialisiert die Plattform-Backends, erstellt Live2D-/Overlay-/Audio-Dienste, scannt eingebaute/installierte/nah gelegene Modellquellen und lädt verfügbare Modelle. `BongoCatApp` hält Einstellungen, Sitzungszustand, Modell- und Verhaltenskataloge, Plattform-Handles sowie Laufzeit-Dienst-Handles. + +Installierte Modellpakete verwenden Mver als kanonisches Format. Der Importprozess analysiert die ausgewählte Datei oder das Verzeichnis, findet und validiert Kandidaten, erzeugt einen Paketidentitäts-Fingerabdruck, konvertiert Tauri-Quellen zu Mver, wendet Bild-Patches an und übermittelt das normalisierte Paket an `models_root`. Anschließend werden Laufzeit-Adapter erzeugt und der Katalog aktualisiert. Nahe Quellen werden nur entdeckt, ohne deren Quellverzeichnis zu installieren; ihre Adapter und Prüfergebnisse werden unter `cache_root` außerhalb von `models_root` zwischengespeichert. + +Jede Iteration der Hauptschleife wartet auf SDL-/native Aufweckereignisse oder auf die früheste Fälligkeit von Frame-, UI-, Animations- und Zeiger-Treffertests (maximal 250 ms). Die Schleife verteilt gepufferte SDL-Ereignisse, leert die atomare Eingabewarteschlange und führt Freigabe-Wiederherstellung durch, aktualisiert Fenster- und Modell-Aktualisierungsstatus und wendet anschließend Eingabeparameter an. Bei aktiviertem Cubism folgt die Modellfrist `settings.model.max_fps` (Standard 60 FPS); Diagnose-Builds verwenden ein Fallback-Intervall von 100 ms. Die verstrichene Modellzeit wird auf maximal 250 ms begrenzt und in bis zu acht Teilschritte aufgeteilt, die jeweils höchstens 1/30 Sekunde dauern. + +Der reguläre Pet-Pfad rendert nur, wenn das Fenster sichtbar, nicht minimiert und als schmutzig markiert ist. Pro Frame wird zunächst der Hintergrund gelöscht, das Modell gezeichnet, dann werden Zeiger-, Tasten- und Effekt-Overlays komponiert und schließlich der Plattform-Präsentator aufgerufen. Vorschauvorgänge können sofortiges Rendern anfordern, Screenshot-Rendering kann die Präsentation überspringen. macOS und Linux tauschen das SDL-OpenGL-Fenster direkt; Windows tauscht direkt, wenn keine geschichtete Darstellung aktiviert ist, andernfalls wird der Framebuffer ausgelesen und `UpdateLayeredWindow` aufgerufen. Die Präferenz-UI besitzt ein eigenes SDL/OpenGL-Fenster und wird separat gerendert und präsentiert. + +Die C-Laufzeit ruft die in `include/bongo_cat/model.h` deklarierte ABI auf. Die Live2D-Brücke und die Cubism-Implementierung befinden sich in `src/live2d` und verwenden C++17 nur, wenn das Cubism SDK aktiviert ist; der restliche native Laufzeitcode verwendet C11. Cubism-Typen bleiben hinter undurchsichtigen C-Handles; wenn das SDK nicht verfügbar ist, stellt `src/live2d/live2d_stub.c` das Diagnose-Backend bereit. + +```mermaid +flowchart TB + Input(["Tastatur / Maus / Gamepad"]) + BuiltIn(["Eingebaute Modellressourcen"]) + Sources(["Externe Modellquellen
Mver, Tauri, .model3.json, Bild-Patches"]) + Desktop(["Pet-Fenster und Präferenzfenster"]) + subgraph Runtime["BongoCat Native Runtime"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Start und Initialisierung
Konfiguration, Speicher, Fenster, Plattform"] + Loop["SDL3-Hauptschleife
Warten, Verteilen, Aktualisieren, Rendern"] + Shutdown["Herunterfahren
Zustand aktualisieren, Dienste stoppen, Ressourcen freigeben"] + InputQueue[("Atomarer Eingabezustand
Flankenwarteschlange und zusammengeführte Zeigerposition")] + InputDispatch["Eingabeverteilung
Tastenkürzel, Zeigerzuordnung, Modellparameter"] + State[("BongoCatApp-Zustand
Einstellungen, Sitzung, Katalog, Laufzeit-Handles")] + Import["Modellerkennung und -import
Validierung, Normalisierung zu Mver, Installation/Cache"] + Catalog[("Modell- und Verhaltenskatalog")] + Live2D["Live2D C ABI
Cubism SDK oder Diagnose-Stub"] + Overlay["Overlays und Audio"] + Preferences["Präferenzen und Desktop-Shell
Nuklear-UI, Tray, Fensteraktionen"] + Compose["OpenGL-Frame-Komposition"] + Present["Plattform-Präsentation"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Plattform-Backends"] + direction LR + Global["Globale Tastatur/Zeiger-Erfassung
Windows, macOS, Linux"] + SDL["SDL3-Ereignisse
Fenster- und Gamepad-Ereignisse"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Häufig gestellte Fragen + +### 🔒 Zeichnet BongoCat meine Tastatur- oder Mauseingaben auf? + +Nein. BongoCat verarbeitet Tastatur- und Mauseingaben lokal, um Animationen und Tastenkürzel anzutreiben. Es zeichnet weder Tastenanschläge, Mausaktionen noch andere Interaktionsdaten auf oder lädt sie hoch. Auch die Konfiguration wird nur lokal gespeichert; die Anwendung enthält keine Werbung, Analyse-Tools oder Code zur Nutzerverfolgung. Bei Update-Checks werden nur öffentliche Versionsmetadaten abgefragt; es werden keine Eingabe-, Konfigurations- oder Nutzungsdaten gesendet. + +### 🖼️ Warum OpenGL und nicht Vulkan? + +Nicht weil Vulkan schlecht ist, sondern weil BongoCat diese Komplexität nicht benötigt. Die Anwendung rendert hauptsächlich ein Live2D-Modell, einige UI-Ebenen und ein transparentes Desktop-Fenster – das bewältigt OpenGL mühelos, und es harmoniert auf natürliche Weise mit SDL3 und dem OpenGL-Renderer von Cubism. Eine Migration zu Vulkan würde mehr Render- und Synchronisationscode auf drei Desktop-Plattformen erfordern, ohne dem Nutzer einen spürbaren Vorteil zu bringen. Für die aktuellen Arbeitslasten von BongoCat macht OpenGL den Renderer schlanker, besser debugbar und wartbarer und liefert weiterhin die benötigte Leistung. + +## 🙏 Besonderer Dank +> [!TIP] +> Jeder Schritt von BongoCat wird vom Open-Source-Gedanken getragen. Wir danken allen Community-Beitragenden aufrichtig für ihren selbstlosen Beitrag (unten nach Beitragsdatum sortiert aufgeführt). Eure Unterstützung macht die Begleitung auf dem Desktop freier und echten.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+Copyright © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md new file mode 100644 index 00000000..1c58b946 --- /dev/null +++ b/docs/README.es-ES.md @@ -0,0 +1,252 @@ + + +

💘 C/C++ × SDL3 × OpenGL, mézclalo todo, ¡a tocar! ¡Bong~ Bongo Cat!!!

+

+ Elige el idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!TIP] +> El modelo usado en la demo proviene de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 ¿Buscas modelos **gratuitos**? Trabajamos con creadores de modelos talentosos para ofrecerte una gran variedad de modelos gratuitos, ¡mientras seguimos explorando experiencias de escritorio aún más divertidas! Visita nuestro sitio web oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 Descarga + + + +- GitHub Releases + + Descarga la última versión desde [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar desde el código fuente + +BongoCat usa CMake y requiere un compilador C11, un compilador C++17, CMake 3.24 o superior y los archivos de desarrollo de OpenGL para escritorio. Por defecto, SDL3, yyjson, stb, miniaudio y Nuklear se descargan automáticamente durante la configuración, por lo que la primera configuración requiere conexión a internet. + +Ejecuta los siguientes comandos en la raíz del proyecto (el directorio que contiene `CMakeLists.txt`). + +### 📋 Requisitos por plataforma + +- **Windows:** Visual Studio 2022 (con la carga de trabajo «Desarrollo para escritorio con C++») y CMake. Usa el generador MSVC; MinGW puede compilar el backend de diagnóstico, pero no es compatible con el SDK de Cubism. +- **macOS:** Xcode Command Line Tools, CMake y Ninja. Si la arquitectura de destino difiere de la predeterminada del host, especifícala mediante `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC o Clang, Ninja y los encabezados de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 Configuración y compilación + +En Linux y macOS, usa un generador de configuración única como Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +En Windows, ejecuta los comandos desde el Símbolo del sistema para desarrolladores de Visual Studio 2022 (o cualquier shell donde MSVC esté disponible): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +El ejecutable se encuentra en `build/BongoCat` en Linux, en `build/BongoCat.app/Contents/MacOS/BongoCat` en macOS y en `build/Release/BongoCat.exe` para las compilaciones de Visual Studio en Windows. + +### 🧪 Pruebas + +Los objetivos de CTest están habilitados por defecto. Ejecuta lo siguiente después de compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para un generador de múltiples configuraciones como Visual Studio, especifica explícitamente la configuración de compilación: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK de Cubism (opcional) + +Si no se encuentra el SDK de Cubism, CMake muestra una advertencia y compila el backend de diagnóstico. Este backend está pensado para el arranque y el diagnóstico de plataforma; no proporciona renderizado de modelos Live2D. Para compilar el runtime completo, instala una versión compatible del Cubism SDK for Native, colócalo en `vendor/CubismSdkForNative` o indica explícitamente su ruta: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +El SDK debe incluir la biblioteca Core, el código fuente de Framework y el directorio de terceros de OpenGL GLEW en la estructura esperada por `cmake/Cubism.cmake`. Las compilaciones de Cubism en Windows requieren Visual Studio 2022. Con `BONGO_CAT_REQUIRE_CUBISM=ON`, la configuración falla si el SDK no está disponible, en lugar de seleccionar silenciosamente el backend de diagnóstico. + +### ⚙️ Opciones de CMake + +| Opción | Valor por defecto | Descripción | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Descarga dependencias de terceros en versiones fijadas mediante `FetchContent` de CMake. Establécelo en `OFF` solo si SDL3, yyjson, stb, miniaudio y Nuklear ya están disponibles para CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Ruta del Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Hace fallar la configuración cuando no hay SDK disponible. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata las advertencias del compilador nativo como errores. | + +Para una compilación sin conexión, establece `BONGO_CAT_FETCH_DEPS=OFF` y proporciona las configuraciones de paquetes de CMake para SDL3 (incluido `SDL3-static`) y yyjson; si stb, Nuklear y miniaudio no se pueden detectar automáticamente, indica también sus directorios de inclusión: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Estado del proyecto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licencia + +El código fuente y el runtime nativo de BongoCat están bajo la licencia [AGPL-3.0-only](../LICENSE). + +El modo de modelo integrado por defecto (`standard`) permanece bajo la licencia MIT. Los recursos de modelo incluidos en `resources/assets/models/standard`, `keyboard` y `gamepad` están cubiertos por la [declaración de licencia MIT](../LICENSE-MIT) independiente. Esta licencia MIT se aplica únicamente a los recursos de los modelos y sus ilustraciones asociadas; no cambia la licencia del código fuente ni del runtime nativo de BongoCat. + +## 🧭 Arquitectura técnica + +La versión nativa actual se basa en C/C++, SDL3 y OpenGL. El siguiente diagrama destaca el flujo de datos en tiempo de ejecución; consulta los archivos de CMake para los detalles de compilación y empaquetado. + +### 🔄 Propiedad del runtime y planificación de fotogramas + +Cada proceso posee una instancia de `BongoCatApp` y un bucle de eventos/renderizado en el hilo principal. Los listeners de plataforma se detienen en el límite de entrada: + +```text +Listeners de plataforma (teclado/ puntero) + | + v + Estado de entrada C11 (cola atómica de flancos + posición de puntero fusionada) + | + v + aplicación en el hilo principal <----- eventos SDL3 + | + v + parámetros del modelo, superposiciones y estado de la interfaz + | + v + actualización del modelo -> composición OpenGL -> presentación de la plataforma +``` + +El receptor Raw Input de Windows, el tap de eventos Quartz de macOS y los listeners XInput2 de Linux se ejecutan fuera del bucle principal. Los eventos de teclas y botones entran en una cola atómica acotada; los movimientos se agrupan por separado y los eventos SDL despiertan el hilo principal. Windows recibe entradas en segundo plano mediante una ventana de mensajes con `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, conservando los mensajes normales. Cuando otra aplicación oculta o bloquea el cursor, el modelo usa los movimientos del dispositivo; SDL proporciona la posición del cursor en el escritorio. Los estados pulsados se limpian al desconectar un dispositivo o cambiar el escritorio de entrada. Windows ya no usa hooks de entrada ni DirectInput. Los eventos de ventana, preferencias y gamepad de SDL3 se procesan en el hilo principal. Ningún listener de plataforma llama directamente al código de Live2D, superposiciones o interfaz. + +`bongo_cat_app_run` gestiona los parámetros de actualización, cierre y procesos secundarios, hace cumplir la propiedad de una única instancia del proceso principal, asigna el estado de la aplicación, realiza la inicialización, entra en `bongo_cat_app_loop` y luego actualiza el estado y libera los recursos en el orden definido. La inicialización carga la configuración y las rutas de almacenamiento, localiza los recursos, crea la ventana de mascota SDL/OpenGL, inicializa los backends de plataforma, crea los servicios de Live2D/superposiciones/audio, escanea las fuentes de modelos integrados/instalados/cercanos y carga los modelos disponibles. `BongoCatApp` conserva la configuración, el estado de la sesión, los catálogos de modelos y comportamientos, los identificadores de plataforma y los identificadores de servicios en tiempo de ejecución. + +Los paquetes de modelos instalados usan Mver como formato canónico. El proceso de importación analiza el archivo o directorio seleccionado, descubre y valida los candidatos, genera una huella de identidad del paquete, convierte las fuentes Tauri a Mver, aplica parches de imagen y envía el paquete normalizado a `models_root`. Después se generan los adaptadores de tiempo de ejecución y se actualiza el catálogo. Las fuentes cercanas solo se descubren sin instalar su árbol de origen; sus adaptadores y resultados de comprobación se almacenan en caché bajo `cache_root`, fuera de `models_root`. + +Cada iteración del bucle principal espera activaciones SDL/nativas o el vencimiento más temprano de los plazos de fotograma, interfaz, animación y acierto de puntero (máximo 250 ms). El bucle distribuye los eventos SDL en cola, vacía la cola de entrada atómica y realiza la recuperación de liberaciones, actualiza el estado de la ventana y de actualización del modelo y luego aplica los parámetros de entrada. Con Cubism habilitado, el plazo del modelo sigue `settings.model.max_fps` (predeterminado 60 FPS); las compilaciones de diagnóstico usan un intervalo de reserva de 100 ms. El tiempo transcurrido del modelo se cuenta como máximo 250 ms y se divide en hasta ocho subpasos, cada uno con un objetivo de no más de 1/30 de segundo. + +La ruta normal de la mascota solo se renderiza cuando la ventana es visible, no está minimizada y está marcada como sucia. Cada fotograma primero limpia el fondo, dibuja el modelo y luego compone las superposiciones de puntero, teclas y efectos, y finalmente llama al presentador de la plataforma. Las operaciones de vista previa pueden solicitar renderizado inmediato; el renderizado de capturas de pantalla puede omitir la presentación. macOS y Linux intercambian directamente la ventana SDL OpenGL; Windows la intercambia directamente cuando la presentación en capas no está habilitada; de lo contrario, lee el búfer de fotogramas y llama a `UpdateLayeredWindow`. La interfaz de preferencias tiene su propia ventana SDL/OpenGL y se renderiza y presenta por separado. + +El runtime C llama a la ABI declarada en `include/bongo_cat/model.h`. El puente Live2D y la implementación de Cubism están en `src/live2d` y solo usan C++17 cuando el SDK de Cubism está habilitado; el resto del runtime nativo usa C11. Los tipos de Cubism se mantienen detrás de identificadores C opacos; cuando el SDK no está disponible, `src/live2d/live2d_stub.c` proporciona el backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Ratón / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fuentes de modelos externos
Mver, Tauri, .model3.json, parches de imagen"]) + Desktop(["Ventana de mascota y ventana de preferencias"]) + subgraph Runtime["Runtime nativo de BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Arranque e inicialización
Configuración, almacenamiento, ventana, plataforma"] + Loop["Bucle principal SDL3
Esperar, distribuir, actualizar, renderizar"] + Shutdown["Apagado
Actualizar estado, detener servicios, liberar recursos"] + InputQueue[("Estado de entrada atómico
Cola de flancos y posición de puntero fusionada")] + InputDispatch["Distribución de entrada
Atajos, mapeo de puntero, parámetros del modelo"] + State[("Estado de BongoCatApp
Configuración, sesión, catálogo, identificadores de runtime")] + Import["Descubrimiento e importación de modelos
Validación, normalización a Mver, instalación/caché"] + Catalog[("Catálogo de modelos y comportamientos")] + Live2D["Live2D C ABI
SDK de Cubism o stub de diagnóstico"] + Overlay["Superposiciones y audio"] + Preferences["Preferencias y shell de escritorio
UI de Nuklear, bandeja, acciones de ventana"] + Compose["Composición de fotogramas OpenGL"] + Present["Presentación de la plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/puntero
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de ventana y gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Preguntas frecuentes + +### 🔒 ¿BongoCat registra mis entradas de teclado o ratón? + +No. BongoCat procesa las entradas de teclado y ratón localmente para impulsar animaciones y atajos. No registra ni sube pulsaciones de teclas, acciones del ratón ni otros datos de interacción. La configuración también se guarda solo localmente; la aplicación no incluye anuncios, herramientas de análisis ni código de seguimiento de usuarios. Al comprobar actualizaciones solo se solicitan los metadatos públicos de versión; no se envían datos de entrada, configuración ni uso. + +### 🖼️ ¿Por qué OpenGL y no Vulkan? + +No porque Vulkan sea malo, sino porque BongoCat no necesita ese nivel de complejidad. La aplicación renderiza principalmente un modelo Live2D, algunas capas de interfaz y una ventana de escritorio transparente; OpenGL lo cubre con facilidad y se integra de forma natural con SDL3 y el renderizador OpenGL de Cubism. Migrar a Vulkan implicaría mantener más código de renderizado y sincronización en tres plataformas de escritorio, sin un beneficio notable para el usuario. Para la carga de trabajo actual de BongoCat, OpenGL hace que el renderizador sea más ligero, más fácil de depurar y mantener, y sigue ofreciendo el rendimiento necesario. + +## 🙏 Agradecimientos especiales +> [!TIP] +> Cada paso de BongoCat está impulsado por el espíritu del código abierto. Agradecemos sinceramente a todos los colaboradores de la comunidad por sus contribuciones desinteresadas (listadas a continuación en orden cronológico). Es vuestro apoyo lo que hace que la compañía en el escritorio sea más libre y genuina.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+Derechos de autor © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.fr-FR.md b/docs/README.fr-FR.md new file mode 100644 index 00000000..24df2fa7 --- /dev/null +++ b/docs/README.fr-FR.md @@ -0,0 +1,394 @@ +
+ + Catime + + +

+ BongoCat +

+
+ + +

+ 💘C/C++ × SDL3 × OpenGL, mélangez le tout, assemblez-le ! Bong~ Bongo Cat !!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ + +
+ +
+ + +> [!TIP] +> Le modèle présenté dans cette démonstration provient de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Vous cherchez des modèles **gratuits** ? Nous collaborons avec des créateurs de modèles talentueux pour vous proposer une grande variété de modèles gratuits, tout en explorant continuellement de nouvelles expériences de bureau amusantes ! Rendez-vous sur notre site officiel : [bongocat.pet](https://bongocat.pet/models) + + +

+ + + +

+ +

+ +

+ +## 📥 Téléchargement + + + + + +- GitHub Releases + + Téléchargez la dernière version depuis [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilation depuis les sources + +BongoCat utilise CMake et nécessite un compilateur C11, un compilateur C++17, +CMake 3.24 ou une version plus récente, ainsi que les fichiers de développement +OpenGL pour environnement de bureau. SDL3, yyjson, stb, miniaudio et Nuklear +sont téléchargés par défaut lors de la configuration ; la première configuration +nécessite donc un accès au réseau. + +Exécutez les commandes ci-dessous depuis la racine du projet (le répertoire +contenant `CMakeLists.txt`). + +### 📋 Prérequis par plateforme + +- **Windows :** Visual Studio 2022 avec la charge de travail Desktop C++ et CMake. + Utilisez le générateur MSVC ; MinGW peut compiler le backend de diagnostic, + mais il n'est pas pris en charge par le SDK Cubism. +- **macOS :** Xcode Command Line Tools, CMake et Ninja. Définissez une architecture + avec `CMAKE_OSX_ARCHITECTURES` lorsqu'elle diffère de l'architecture par défaut + de la machine hôte. +- **Linux (Debian/Ubuntu) :** GCC ou Clang, Ninja ainsi que les en-têtes OpenGL/X11 : + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 Configuration et compilation + +Sous Linux et macOS, utilisez un générateur à configuration unique tel que Ninja : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Sous Windows, exécutez les commandes depuis un shell de développement +Visual Studio 2022 (ou un autre shell dans lequel MSVC est disponible) : + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +L'exécutable est généré dans `build/BongoCat` sous Linux, +dans `build/BongoCat.app/Contents/MacOS/BongoCat` sous macOS, +et dans `build/Release/BongoCat.exe` pour les compilations Visual Studio. + +### 🧪 Tests + +Les cibles CTest sont activées par défaut. Exécutez-les après la compilation : + +```bash +ctest --test-dir build --output-on-failure +``` + +Pour un générateur multi-configuration tel que Visual Studio, +sélectionnez explicitement la configuration de compilation : + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK Cubism (facultatif) + +Si le SDK Cubism n'est pas présent, CMake affiche un avertissement et compile +le backend de diagnostic. Ce backend est destiné au démarrage et aux diagnostics +de plateforme ; il ne permet pas le rendu des modèles Live2D. Pour compiler +le runtime complet, installez une version compatible du Cubism SDK for Native +et placez-la dans `vendor/CubismSdkForNative`, ou indiquez explicitement son +emplacement : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +Le SDK doit contenir sa bibliothèque Core, les sources du Framework ainsi que +l'arborescence tierce OpenGL GLEW dans la structure attendue par +`cmake/Cubism.cmake`. Les compilations Cubism sous Windows nécessitent +Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` fait échouer la configuration +au lieu de sélectionner silencieusement le backend de diagnostic. + +### ⚙️ Options CMake + +| Option | Valeur par défaut | Description | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Télécharge les dépendances tierces aux versions épinglées via `FetchContent` de CMake. Définissez cette option sur `OFF` uniquement si SDL3, yyjson, stb, miniaudio et Nuklear sont déjà accessibles à CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Chemin vers le Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Fait échouer la configuration lorsqu'aucun SDK Cubism utilisable n'est disponible. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Traite les avertissements du compilateur natif comme des erreurs. | + +Pour une compilation hors ligne avec `BONGO_CAT_FETCH_DEPS=OFF`, fournissez +les configurations de paquets CMake pour SDL3 (y compris `SDL3-static`) et yyjson, +ainsi que les répertoires d'inclusion de stb, Nuklear et miniaudio lorsqu'ils ne +peuvent pas être détectés automatiquement : + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 État du projet + + +## 📜 Licence + +Le code source et le runtime natif de BongoCat sont distribués sous licence +[AGPL-3.0-only](LICENSE). + +Le mode de modèle intégré par défaut (`standard`) reste sous licence MIT. +Les ressources de modèles fournies dans `resources/assets/models/standard`, +`keyboard` et `gamepad` sont couvertes par la [licence MIT distincte](LICENSE-MIT). +Cette licence MIT s'applique uniquement aux ressources des modèles et aux +éléments graphiques qui les accompagnent ; elle ne modifie pas la licence du +code source ni du runtime natif de BongoCat. + +## 🧭 Architecture technique + +> La version native actuelle repose sur C/C++, SDL3 et OpenGL. Le diagramme ci-dessous se concentre sur le flux de données à l'exécution ; les détails de compilation et de packaging sont gérés par CMake. + +### 🔄 Gestion du runtime et ordonnancement des frames + +Chaque processus possède une instance de `BongoCatApp` ainsi qu'une boucle +principale d'événements et de rendu exécutée sur le thread principal. +Les écouteurs propres à chaque plateforme s'arrêtent à la frontière du +système d'entrée : + +```text +Écouteurs de plateforme +(clavier / pointeur) + | + v + État des entrées C11 + (file atomique de transitions + position du pointeur coalescée) + | + v + application sur le thread principal <----- événements SDL3 + | + v + paramètres du modèle, overlay et état de l'interface + | + v + mise à jour du modèle -> composition OpenGL -> présentation plateforme +``` + +Le récepteur Raw Input de Windows, l'event tap Quartz de macOS et l'écouteur +XInput2 de Linux s'exécutent en dehors de la boucle principale. Les transitions +des touches et des boutons utilisent une file atomique bornée ; les mouvements +sont regroupés séparément et des événements SDL réveillent le thread principal. +Windows utilise une fenêtre de messages avec `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` +pour recevoir les entrées en arrière-plan, en conservant les messages ordinaires. +Le modèle utilise les mouvements du périphérique lorsqu'une autre application +masque ou verrouille le curseur ; SDL fournit sa position sur le bureau. +Les états sont nettoyés au retrait d'un périphérique ou au changement de bureau. +Windows n'installe plus de hooks d'entrée et n'utilise plus DirectInput. + +Les événements SDL3 liés aux fenêtres, aux préférences et aux manettes sont +traités sur le thread principal, où les événements de manette sont normalisés +avant d'atteindre les paramètres du modèle ou les raccourcis. Aucun écouteur +de plateforme n'appelle directement le code Live2D, celui de l'overlay ou +celui de l'interface utilisateur. + +`bongo_cat_app_run` gère l'arrêt lié aux mises à jour ainsi que les arguments +des processus secondaires, impose une instance unique pour le processus +principal, alloue l'état de l'application, exécute l'initialisation, entre dans +`bongo_cat_app_loop`, puis enregistre l'état et libère les ressources dans un +ordre défini. + +L'initialisation charge la configuration et les chemins de stockage, localise +les ressources, crée la fenêtre SDL/OpenGL de l'animal de compagnie, initialise +le backend de la plateforme, crée les services Live2D, d'overlay et audio, +analyse les sources de modèles intégrées, installées ou locales, puis charge +un modèle utilisable. `BongoCatApp` possède les paramètres, l'état de session, +les catalogues de modèles et de comportements, les handles de plateforme ainsi +que les handles des services d'exécution. + +Les paquets de modèles installés utilisent Mver comme format canonique. +Le processus d'importation résout le fichier ou le répertoire sélectionné, +découvre et valide les candidats, calcule l'empreinte d'identité du paquet, +convertit les sources Tauri vers Mver, applique les correctifs d'image et +enregistre le paquet normalisé sous `models_root`. Il génère ensuite +l'adaptateur d'exécution et actualise les catalogues. + +Les sources locales voisines sont découvertes sans installer leur arborescence +source ; leurs adaptateurs et résultats d'inspection sont mis en cache en dehors +de `models_root`, sous `cache_root`. + +À chaque itération de la boucle principale, l'application attend un réveil +SDL/natif ou la première échéance en attente concernant une frame, l'interface +utilisateur, une animation ou la détection du pointeur, avec un temps d'attente +maximal de 250 ms. Elle distribue ensuite les événements SDL en attente, vide +la file atomique des entrées et traite la récupération des relâchements, met à +jour l'état de la fenêtre et du rafraîchissement des modèles, puis applique les +paramètres dérivés des entrées. + +Lorsque Cubism est activé, l'échéance de mise à jour du modèle suit +`settings.model.max_fps` (60 FPS par défaut) ; les compilations de diagnostic +utilisent un intervalle de secours de 100 ms. Le temps écoulé du modèle est +plafonné à 250 ms et divisé en un maximum de huit sous-étapes, avec pour +objectif de ne pas dépasser 1/30 s par sous-étape. + +Dans le fonctionnement normal de l'animal de compagnie, le rendu n'est effectué +que lorsque la fenêtre est visible, non réduite et marquée comme nécessitant +un rafraîchissement. Une frame efface l'arrière-plan, dessine le modèle et +compose les overlays du pointeur, des touches et des effets avant d'appeler +le système de présentation de la plateforme. + +Les opérations d'aperçu peuvent demander un rendu immédiat, tandis que les +rendus destinés à une capture peuvent ignorer la présentation. macOS et Linux +effectuent directement le swap de la fenêtre OpenGL SDL. Windows effectue +également directement le swap lorsque la présentation en couches est inactive ; +dans le cas contraire, la frame est relue pour `UpdateLayeredWindow`. + +L'interface des préférences possède une fenêtre SDL/OpenGL distincte, rendue et +présentée indépendamment de la fenêtre de l'animal de compagnie. + +Le runtime C appelle l'ABI déclarée dans `include/bongo_cat/model.h`. +Le pont Live2D et l'implémentation Cubism se trouvent dans `src/live2d` et +n'utilisent C++17 que lorsque le SDK Cubism est activé ; le reste du runtime +natif utilise C11. Les types Cubism restent masqués derrière des handles C +opaques, tandis que `src/live2d/live2d_stub.c` fournit le backend de diagnostic +lorsque le SDK n'est pas disponible. + +```mermaid +flowchart TB + Input(["Clavier / souris / manette"]) + BuiltIn(["Ressources des modèles intégrés"]) + Sources(["Sources de modèles externes
Mver, Tauri, .model3.json, correctifs d'image"]) + Desktop(["Fenêtre de l'animal et fenêtre des préférences"]) + subgraph Runtime["Runtime natif BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Démarrage et initialisation
configuration, stockage, fenêtre, plateforme"] + Loop["Boucle principale SDL3
attente, distribution, mise à jour, rendu"] + Shutdown["Arrêt
enregistrement de l'état, arrêt des services, libération des ressources"] + InputQueue[("État atomique des entrées
file de transitions et position du pointeur coalescée")] + InputDispatch["Distribution des entrées
raccourcis, mappage du pointeur, paramètres du modèle"] + State[("État BongoCatApp
paramètres, session, catalogues, handles d'exécution")] + Import["Découverte et importation des modèles
validation, normalisation vers Mver, installation/cache"] + Catalog[("Catalogues des modèles et comportements")] + Live2D["ABI C de Live2D
SDK Cubism ou stub de diagnostic"] + Overlay["Overlay et audio"] + Preferences["Préférences et shell de bureau
interface Nuklear, zone de notification, actions de fenêtre"] + Compose["Composition de la frame OpenGL"] + Present["Présentation plateforme"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plateforme"] + direction LR + Global["Capture globale du clavier / pointeur
Windows, macOS, Linux"] + SDL["Événements SDL3
événements de fenêtre et de manette"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ FAQ + +### 🔒 BongoCat enregistre-t-il les entrées de mon clavier ou de ma souris ? + +Non. BongoCat traite localement les entrées du clavier et de la souris afin de +piloter les animations et les raccourcis. Il n'enregistre ni ne téléverse vos +frappes au clavier, vos actions de souris ou toute autre donnée d'interaction. + +La configuration est également stockée localement, et l'application ne contient +ni publicité, ni outil d'analyse, ni code de suivi des utilisateurs. Lorsqu'une +vérification de mise à jour est effectuée, l'application demande uniquement les +métadonnées publiques des versions ; elle n'envoie aucune donnée d'entrée, +de configuration ou d'utilisation. + +### 🖼️ Pourquoi OpenGL plutôt que Vulkan ? + +Nous avons choisi OpenGL non pas parce que Vulkan serait mauvais, mais parce que +BongoCat n'a tout simplement pas besoin de ce niveau de complexité. +L'application affiche principalement un modèle Live2D, quelques couches +d'interface et une fenêtre de bureau transparente. OpenGL prend déjà très bien +en charge cette charge de travail et s'intègre naturellement à SDL3 ainsi qu'au +renderer OpenGL de Cubism. + +Passer à Vulkan impliquerait de maintenir beaucoup plus de code de rendu et de +synchronisation sur trois plateformes de bureau, sans amélioration perceptible +pour les utilisateurs. Pour la charge de travail actuelle de BongoCat, OpenGL +permet de conserver un renderer plus compact, plus simple à déboguer et plus +facile à maintenir, tout en fournissant les performances dont nous avons besoin. + +## État du projet + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Image d'analyse Repobeats") + +## 🙏 Remerciements +> [!TIP] +> Chaque pas de BongoCat est porté par l'esprit de l'open source. Nous remercions sincèrement tous les contributeurs de la communauté pour leurs contributions désintéressées (listées ci-dessous par ordre chronologique de contribution). C'est votre soutien qui rend l'accompagnement sur le bureau plus libre et plus authentique.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+ +Copyright © 2026 - **BongoCat**\ +Par vladelaina\ +Fait avec ❤️ & ⌨️ + +
diff --git a/docs/README.id-ID.md b/docs/README.id-ID.md new file mode 100644 index 00000000..4eeedc07 --- /dev/null +++ b/docs/README.id-ID.md @@ -0,0 +1,375 @@ + +
+ + Catime + + +

+ BongoCat +

+
+ + + +

+ 💘C/C++ × SDL3 × OpenGL, campur semuanya, satukan! Bong~ Bongo Cat!!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ + + +
+ +
+ + +> [!TIP] +> Model yang ditampilkan dalam demo ini berasal dari [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Mencari model **gratis**? Kami bekerja sama dengan kreator model berbakat untuk menghadirkan beragam model gratis, sambil terus mengeksplorasi pengalaman desktop yang lebih seru! Kunjungi situs resmi kami: [bongocat.pet](https://bongocat.pet/models) + + +

+ + + +

+ +

+ +

+ +## 📥 Unduh + + + + + +- GitHub Releases + + Unduh rilis terbaru dari [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Membangun dari Kode Sumber + +BongoCat menggunakan CMake dan memerlukan compiler C11, compiler C++17, CMake 3.24 +atau yang lebih baru, serta file pengembangan OpenGL desktop. SDL3, yyjson, stb, +miniaudio, dan Nuklear secara default diunduh saat proses konfigurasi, sehingga +konfigurasi pertama memerlukan akses jaringan. + +Jalankan perintah berikut dari root proyek (direktori yang berisi +`CMakeLists.txt`). + +### 📋 Prasyarat Platform + +- **Windows:** Visual Studio 2022 dengan workload Desktop C++ dan CMake. + Gunakan generator MSVC; MinGW dapat membangun backend diagnostik tetapi tidak + didukung untuk Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake, dan Ninja. Pilih arsitektur dengan + `CMAKE_OSX_ARCHITECTURES` jika berbeda dari default host. +- **Linux (Debian/Ubuntu):** GCC atau Clang, Ninja, serta header OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev + ``` + +### 🔧 Konfigurasi dan Build + +Di Linux dan macOS, gunakan generator single-configuration seperti Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Di Windows, jalankan dari shell developer Visual Studio 2022 (atau shell lain +yang menyediakan MSVC): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +File executable dihasilkan di `build/BongoCat` pada Linux, di +`build/BongoCat.app/Contents/MacOS/BongoCat` di macOS, dan ke +`build/Release/BongoCat.exe` untuk build Visual Studio. + +### 🧪 Pengujian + +Target CTest diaktifkan secara default. Jalankan setelah proses build: + +```bash +ctest --test-dir build --output-on-failure +``` + +Untuk generator multi-configuration seperti Visual Studio, pilih konfigurasi +build secara eksplisit: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (Opsional) + +Jika Cubism SDK tidak tersedia, CMake akan menampilkan peringatan dan membangun +backend diagnostik. Backend ini ditujukan untuk diagnostik startup dan platform; +backend ini tidak menyediakan rendering model Live2D. Untuk membangun runtime +lengkap, pasang Cubism SDK for Native yang kompatibel lalu tempatkan di +`vendor/CubismSdkForNative` atau berikan lokasinya secara eksplisit: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK harus berisi Core library, source Framework, dan tree third-party OpenGL GLEW +dengan tata letak yang diharapkan oleh `cmake/Cubism.cmake`. Build Cubism di +Windows memerlukan Visual Studio 2022. `BONGO_CAT_REQUIRE_CUBISM=ON` membuat +konfigurasi gagal alih-alih diam-diam memilih backend diagnostik. + +### ⚙️ Opsi CMake + +| Opsi | Default | Deskripsi | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Unduh dependency pihak ketiga yang versinya dipin menggunakan CMake `FetchContent`. Atur ke `OFF` hanya jika SDL3, yyjson, stb, miniaudio, dan Nuklear sudah tersedia untuk CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Path ke Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Gagalkan konfigurasi jika Cubism SDK yang dapat digunakan tidak tersedia. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Perlakukan warning compiler native sebagai error. | + +Untuk build offline dengan `BONGO_CAT_FETCH_DEPS=OFF`, sediakan konfigurasi +package CMake untuk SDL3 (termasuk `SDL3-static`) dan yyjson, serta direktori +include untuk stb, Nuklear, dan miniaudio jika tidak dapat ditemukan secara +otomatis: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Status Proyek + + +## 📜 Lisensi + +Kode sumber dan runtime native BongoCat dilisensikan di bawah +[AGPL-3.0-only](../LICENSE). + +Mode model bawaan default (`standard`) tetap berlisensi MIT. Aset model yang +disertakan dalam `resources/assets/models/standard`, `keyboard`, dan `gamepad` +dilindungi oleh [pemberitahuan lisensi MIT](../LICENSE-MIT) terpisah. +Lisensi MIT tersebut hanya berlaku untuk aset model dan artwork pendampingnya; +lisensi tersebut tidak mengubah lisensi kode sumber atau runtime native BongoCat. + + +## 🧭 Arsitektur Teknis + +> Versi native saat ini dibangun dengan C/C++, SDL3, dan OpenGL. Diagram di bawah berfokus pada aliran data runtime; detail build dan packaging berada di CMake. + +### 🔄 Kepemilikan Runtime dan Penjadwalan Frame + +Setiap proses memiliki satu `BongoCatApp` serta satu event loop dan render loop +di thread utama. Listener platform berhenti pada batas input: + +```text +Listener platform +(keyboard / pointer) + | + v + state input C11 + (antrean edge atomik + posisi pointer yang digabungkan) + | + v + aplikasi thread utama <----- event SDL3 + | + v + parameter model, overlay, dan state UI + | + v + update model -> komposisi OpenGL -> presentasi platform +``` + +Penerima Raw Input Windows, event tap Quartz macOS, dan listener XInput2 Linux +berjalan di luar main loop. Perubahan tombol masuk ke antrean atomik berbatas, +gerakan digabungkan secara terpisah, dan event SDL membangunkan thread utama. +Windows memakai jendela khusus pesan dengan `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` +untuk menerima input latar belakang sambil mempertahankan pesan jendela biasa. +Model memakai gerakan perangkat ketika aplikasi lain menyembunyikan atau mengunci +kursor; SDL menyediakan posisi kursor desktop. Status tombol dibersihkan ketika +perangkat dilepas atau desktop input berganti. Windows tidak lagi memakai hook +input atau DirectInput. Event jendela SDL3, preferensi, dan gamepad ditangani di thread utama, +tempat event gamepad dinormalisasi sebelum mencapai parameter model atau +shortcut. Tidak ada listener platform yang memanggil kode Live2D, overlay, atau +UI secara langsung. + +`bongo_cat_app_run` menangani update-shutdown dan argumen proses sekunder, +memastikan kepemilikan single-instance untuk proses utama, mengalokasikan state +aplikasi, menjalankan inisialisasi, masuk ke `bongo_cat_app_loop`, lalu melakukan +flush state dan menghancurkan resource dalam urutan yang terdefinisi. +Inisialisasi memuat konfigurasi dan path penyimpanan, menemukan aset, membuat +jendela pet SDL/OpenGL, menginisialisasi backend platform, membuat layanan +Live2D, overlay, dan audio, memindai sumber model bawaan/terpasang/terdekat, lalu +memuat model yang dapat digunakan. `BongoCatApp` memiliki pengaturan, state +sesi, katalog model dan perilaku, handle platform, serta handle layanan runtime. + +Paket model yang terpasang menggunakan Mver sebagai format kanonis. Workflow +impor menentukan file atau direktori yang dipilih, menemukan dan memvalidasi +kandidat, membuat fingerprint identitas paket, mengonversi sumber Tauri ke Mver, +menerapkan patch gambar, lalu menyimpan paket yang sudah dinormalisasi di bawah +`models_root`. Setelah itu, workflow menghasilkan adapter runtime dan +memperbarui katalog. Sumber terdekat ditemukan tanpa memasang source tree-nya; +adapter dan hasil inspeksinya di-cache di luar `models_root` di bawah +`cache_root`. + +Setiap iterasi main loop menunggu sinyal wakeup SDL/native atau deadline frame, UI, +animasi, atau pointer-hit terdekat yang masih tertunda (dengan waktu tunggu +maksimum 250 ms). Iterasi tersebut mendispatch event SDL yang mengantre, +menguras antrean input atomik dan release recovery, memperbarui state jendela +dan model-refresh, serta menerapkan parameter yang berasal dari input. Saat +Cubism aktif, deadline model mengikuti `settings.model.max_fps` (default 60 FPS); +build diagnostik menggunakan interval fallback 100 ms. Waktu model yang berlalu +dibatasi hingga 250 ms dan dibagi menjadi maksimal delapan substep, dengan +target tidak lebih dari 1/30 detik per substep. + +Jalur render pet normal hanya melakukan render saat jendela terlihat, tidak diminimalkan, +dan ditandai dirty. Sebuah frame membersihkan latar belakang, menggambar model, dan +mengomposisikan overlay pointer, tombol, dan efek sebelum memanggil presenter +platform. Operasi preview dapat meminta render langsung, sedangkan render +capture dapat melewati presentasi. macOS dan Linux menukar jendela SDL OpenGL +secara langsung. Windows melakukan swap langsung saat layered presentation tidak +aktif; jika aktif, Windows membaca kembali frame untuk `UpdateLayeredWindow`. +UI preferensi memiliki jendela SDL/OpenGL terpisah yang dirender dan +dipresentasikan secara independen dari jendela pet. + +Runtime C memanggil ABI yang dideklarasikan di `include/bongo_cat/model.h`. +Bridge Live2D dan implementasi Cubism berada di `src/live2d` dan hanya +menggunakan C++17 saat Cubism SDK diaktifkan; runtime native lainnya menggunakan +C11. Tipe Cubism tetap berada di balik handle C yang opaque, sedangkan +`src/live2d/live2d_stub.c` menyediakan backend diagnostik ketika SDK tidak +tersedia. + + +```mermaid +flowchart TB + Input(["Keyboard / mouse / gamepad"]) + BuiltIn(["Aset model bawaan"]) + Sources(["Sumber model eksternal
Mver, Tauri, .model3.json, patch gambar"]) + Desktop(["Jendela pet dan jendela preferensi"]) + + subgraph Runtime["Runtime native BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Startup dan inisialisasi
konfigurasi, penyimpanan, jendela, platform"] + Loop["Main loop SDL3
tunggu, dispatch, update, render"] + Shutdown["Shutdown
flush state, hentikan layanan, lepaskan resource"] + InputQueue[("State input atomik
antrean edge dan posisi pointer yang digabungkan")] + InputDispatch["Dispatch input
shortcut, pemetaan pointer, parameter model"] + State[("State BongoCatApp
pengaturan, sesi, katalog, handle runtime")] + Import["Penemuan dan impor model
validasi, normalisasi ke Mver, instal/cache"] + Catalog[("Katalog model dan perilaku")] + Live2D["ABI C Live2D
Cubism SDK atau stub diagnostik"] + Overlay["Overlay dan audio"] + Preferences["Preferensi dan shell desktop
UI Nuklear, tray, aksi jendela"] + Compose["Komposisi frame OpenGL"] + Present["Presentasi platform"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + + subgraph Platform["Backend platform"] + direction LR + Global["Capture keyboard / pointer global
Windows, macOS, Linux"] + SDL["Event SDL3
event jendela dan gamepad"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ FAQ + +### 🔒 Apakah BongoCat merekam input keyboard atau mouse saya? + +Tidak. BongoCat memproses input keyboard dan mouse secara lokal untuk +menggerakkan animasi dan shortcut. BongoCat tidak merekam atau mengunggah +keystroke, aksi mouse, atau data interaksi lainnya. Konfigurasi juga disimpan +secara lokal, dan aplikasi tidak memiliki iklan, alat analitik, atau kode +pelacakan pengguna. Saat pemeriksaan pembaruan dilakukan, aplikasi hanya meminta +metadata rilis publik; aplikasi tidak mengirim data input, konfigurasi, atau +penggunaan. + +### 🖼️ Mengapa OpenGL, bukan Vulkan? + +Kami memilih OpenGL bukan karena Vulkan buruk, tetapi karena BongoCat tidak +memerlukan tingkat kompleksitas tersebut. Aplikasi terutama merender satu model +Live2D, beberapa lapisan UI, dan sebuah jendela desktop transparan. OpenGL sudah +menangani kebutuhan tersebut dengan nyaman dan bekerja secara alami dengan SDL3 +serta renderer OpenGL milik Cubism. Beralih ke Vulkan berarti harus memelihara +lebih banyak kode rendering dan sinkronisasi di tiga platform desktop, tanpa +peningkatan yang terasa bagi pengguna. Untuk beban kerja BongoCat saat ini, OpenGL +menjaga renderer tetap lebih kecil, lebih mudah di-debug, dan lebih mudah +dipelihara sambil tetap memberikan performa yang dibutuhkan. + + + + +## Status Proyek +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + + +## 🙏 Ucapan Terima Kasih +> [!TIP] +> Setiap langkah BongoCat digerakkan oleh semangat open source. Kami dengan tulus berterima kasih kepada seluruh kontributor komunitas atas kontribusi mereka tanpa pamrih (tercantum di bawah berdasarkan urutan tanggal kontribusi). Dukungan Anda membuat pengalaman pendamping desktop menjadi lebih bebas dan autentik.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+ +Copyright © 2026 - **BongoCat**\ +Oleh vladelaina\ +Dibuat dengan ❤️ & ⌨️ + +
diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md new file mode 100644 index 00000000..d3f5a12f --- /dev/null +++ b/docs/README.ja-JP.md @@ -0,0 +1 @@ + diff --git a/docs/README.ko-KR.md b/docs/README.ko-KR.md new file mode 100644 index 00000000..160e6b9c --- /dev/null +++ b/docs/README.ko-KR.md @@ -0,0 +1,252 @@ + + +

💘 C/C++ × SDL3 × OpenGL,함께 섞어서 마음껏 두드리세요! Bong~ Bongo Cat!!!

+

+ 언어 선택 ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!TIP] +> 데모에 사용된 모델은 [宇痕冫](https://space.bilibili.com/348616056)님의 작품입니다. +> +> 🎁 **무료** 모델을 찾으시나요? 저희는 재능 있는 모델 크리에이터들과 협력하여 다양한 무료 모델을 제공하고, 더 재미있는 데스크톱 경험도 끊임없이 탐색하고 있습니다! 공식 웹사이트를 방문하세요: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 다운로드 + + + +- GitHub Releases + + [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest)에서 최신 버전을 다운로드하세요. + +## 🛠️ 소스 코드로 빌드하기 + +BongoCat은 CMake를 사용하며, C11 컴파일러, C++17 컴파일러, CMake 3.24 이상, 데스크톱 OpenGL 개발 파일이 필요합니다. 기본적으로 SDL3, yyjson, stb, miniaudio 및 Nuklear는 구성 단계에서 자동으로 다운로드되므로, 첫 구성 시 네트워크 연결이 필요합니다. + +프로젝트 루트 디렉토리(`CMakeLists.txt`가 있는 디렉토리)에서 다음 명령어를 실행하세요. + +### 📋 플랫폼 사전 요구 사항 + +- **Windows:** Visual Studio 2022("C++를 사용한 데스크톱 개발" 워크로드 설치) 및 CMake. MSVC 생성기를 사용하세요. MinGW는 진단 백엔드를 빌드할 수 있지만 Cubism SDK를 지원하지 않습니다. +- **macOS:** Xcode Command Line Tools, CMake 및 Ninja. 대상 아키텍처가 호스트 기본 아키텍처와 다른 경우 `CMAKE_OSX_ARCHITECTURES`를 통해 지정하세요. +- **Linux(Debian/Ubuntu):** GCC 또는 Clang, Ninja, OpenGL/X11 헤더 파일: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 구성 및 빌드 + +Linux 및 macOS에서는 Ninja와 같은 단일 구성 생성기를 사용하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +Windows에서는 Visual Studio 2022 개발자 명령 프롬프트(또는 MSVC를 사용할 수 있는 기타 명령줄)에서 다음을 실행하세요: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +실행 파일 위치: Linux `build/BongoCat`, macOS `build/BongoCat.app/Contents/MacOS/BongoCat`, Visual Studio 빌드 Windows 버전 `build/Release/BongoCat.exe`. + +### 🧪 테스트 + +CTest 대상은 기본적으로 활성화됩니다. 빌드 후 실행: + +```bash +ctest --test-dir build --output-on-failure +``` + +Visual Studio와 같은 다중 구성 생성기의 경우 빌드 구성을 명시적으로 지정하세요: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (선택 사항) + +Cubism SDK를 찾을 수 없으면 CMake는 경고를 표시하고 진단 백엔드를 빌드합니다. 이 백엔드는 시작 및 플랫폼 진단에 사용되며 Live2D 모델 렌더링을 제공하지 않습니다. 전체 런타임을 빌드하려면 호환되는 Cubism SDK for Native를 설치하고 `vendor/CubismSdkForNative`에 배치하거나 경로를 명시적으로 전달하세요: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK에는 Core 라이브러리, Framework 소스 코드, 그리고 `cmake/Cubism.cmake`에서 요구하는 레이아웃의 OpenGL GLEW 서드파티 디렉토리가 포함되어야 합니다. Windows Cubism 빌드에는 Visual Studio 2022가 필요합니다. `BONGO_CAT_REQUIRE_CUBISM=ON`은 SDK를 사용할 수 없을 때 진단 백엔드를 자동으로 선택하는 대신 구성을 실패하게 합니다. + +### ⚙️ CMake 옵션 + +| 옵션 | 기본값 | 설명 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | CMake `FetchContent`를 사용하여 고정 버전의 서드파티 종속성을 다운로드합니다. SDL3, yyjson, stb, miniaudio 및 Nuklear를 CMake에서 이미 사용할 수 있는 경우에만 `OFF`로 설정하세요. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native의 경로입니다. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK를 사용할 수 없을 때 구성을 실패하게 합니다. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 로컬 컴파일러 경고를 오류로 처리합니다. | + +오프라인 빌드 시 `BONGO_CAT_FETCH_DEPS=OFF`로 설정하고, SDL3(`SDL3-static` 포함) 및 yyjson의 CMake 패키지 구성을 제공하세요. stb, Nuklear 및 miniaudio를 자동으로 찾을 수 없는 경우 해당 include 디렉토리도 제공해야 합니다: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 프로젝트 상태 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 라이선스 + +BongoCat 소스 코드 및 로컬 런타임은 [AGPL-3.0-only](../LICENSE) 라이선스를 따릅니다. + +기본 내장 모델(`standard`)은 여전히 MIT 라이선스를 따릅니다. `resources/assets/models/standard`, `keyboard` 및 `gamepad`에 번들로 제공되는 모델 리소스는 별도의 [MIT 라이선스 고지](../LICENSE-MIT)가 적용됩니다. 해당 MIT 라이선스는 모델 리소스 및 관련 아트워크에만 적용되며, BongoCat 소스 코드나 로컬 런타임의 라이선스를 변경하지 않습니다. + +## 🧭 기술 아키텍처 + +현재 네이티브 버전은 C/C++, SDL3 및 OpenGL을 기반으로 구축되었습니다. 아래 다이어그램은 런타임 데이터 흐름을 중점적으로 보여줍니다. 빌드 및 패키징 세부 사항은 CMake 파일을 참조하세요. + +### 🔄 런타임 소유권 및 프레임 스케줄링 + +각 프로세스는 하나의 `BongoCatApp`과 하나의 메인 스레드 이벤트/렌더링 루프를 소유합니다. 플랫폼 리스너는 입력 경계에서 중단됩니다: + +```text +플랫폼 리스너 (키보드/포인터) + | + v + C11 입력 상태 (원자적 에지 큐 + 병합된 포인터 위치) + | + v + 메인 스레드 애플리케이션 <----- SDL3 이벤트 + | + v + 모델 파라미터, 오버레이 및 UI 상태 + | + v + 모델 업데이트 -> OpenGL 합성 -> 플랫폼 프레젠테이션 +``` + +Windows Raw Input 수신기, macOS Quartz 이벤트 탭, Linux XInput2 리스너는 메인 루프 외부에서 실행됩니다. 키와 마우스 버튼 이벤트는 유계 원자 큐에 전달하고, 이동량은 별도로 합치며 SDL 이벤트로 메인 스레드를 깨웁니다. Windows는 메시지 전용 창에 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`를 등록하여 일반 창 메시지를 유지하면서 백그라운드 입력을 받습니다. 다른 프로그램이 커서를 숨기거나 잠그면 모델은 장치가 보고한 이동량을 사용하고, 바탕 화면에서는 SDL이 시스템 커서 위치를 제공합니다. 장치를 분리하거나 입력 데스크톱을 전환하면 눌림 상태를 정리합니다. Windows는 더 이상 입력 훅이나 DirectInput을 사용하지 않습니다. SDL3 창, 환경 설정 및 게임패드 이벤트는 메인 스레드에서 처리하며 플랫폼 리스너는 Live2D, 오버레이 또는 UI 코드를 직접 호출하지 않습니다. + +`bongo_cat_app_run`은 업데이트, 종료 및 보조 프로세스 파라미터를 담당하여 메인 프로세스의 단일 인스턴스 소유권을 보장하고, 애플리케이션 상태를 할당하며, 초기화를 수행하고, `bongo_cat_app_loop`로 진입한 후 정의된 순서대로 상태를 플러시하고 리소스를 소멸시킵니다. 초기화는 구성 및 저장 경로를 로드하고, 리소스를 찾고, SDL/OpenGL 펫 창을 생성하고, 플랫폼 백엔드를 초기화하고, Live2D/오버레이/오디오 서비스를 생성하고, 내장/설치/근처 모델 소스를 스캔하고, 사용 가능한 모델을 로드합니다. `BongoCatApp`은 설정, 세션 상태, 모델 및 동작 카탈로그, 플랫폼 핸들 및 런타임 서비스 핸들을 보유합니다. + +설치된 모델 패키지는 Mver을 표준 형식으로 사용합니다. 가져오기 프로세스는 선택한 파일이나 디렉토리를 구문 분석하고, 후보를 발견 및 검증하고, 패키지 ID 지문을 생성하고, Tauri 소스를 Mver로 변환하고, 이미지 패치를 적용하고, 정규화된 패키지를 `models_root`에 제출합니다. 그런 다음 런타임 어댑터를 생성하고 카탈로그를 새로 고칩니다. 근처 소스는 소스 디렉토리를 설치하지 않고 발견되며, 해당 어댑터와 검사 결과는 `models_root` 외부의 `cache_root` 아래에 캐시됩니다. + +각 메인 루프 반복은 SDL/네이티브 웨이크를 기다리거나, 가장 빠른 프레임, UI, 애니메이션 및 포인터 적중 마감 시간(최대 250ms)을 기다립니다. 루프는 대기 중인 SDL 이벤트를 처리하고, 원자 입력 큐를 비우고 해제 복구를 수행하고, 창 및 모델 새로 고침 상태를 업데이트한 다음 입력 파라미터를 적용합니다. Cubism이 활성화되면 모델 마감 시간은 `settings.model.max_fps`(기본값 60 FPS)를 따릅니다. 진단 빌드는 100ms 폴백 간격을 사용합니다. 모델 경과 시간은 최대 250ms까지 계산되며, 각 하위 단계 목표가 1/30초를 초과하지 않도록 최대 8개의 하위 단계로 분할됩니다. + +일반 펫 경로는 창이 보이고 최소화되지 않았으며 더티로 표시된 경우에만 렌더링됩니다. 각 프레임은 먼저 배경을 지우고, 모델을 그린 다음, 포인터, 키 및 효과 오버레이를 합성하고, 마지막으로 플랫폼 프리젠터를 호출합니다. 미리보기 작업은 즉시 렌더링을 요청할 수 있고, 스크린샷 렌더링은 프레젠테이션을 건너뛸 수 있습니다. macOS 및 Linux는 SDL OpenGL 창을 직접 교체합니다. Windows는 계층화된 프레젠테이션이 활성화되지 않은 경우 직접 교체하고, 그렇지 않으면 프레임 버퍼를 읽고 `UpdateLayeredWindow`를 호출합니다. 환경 설정 UI는 자체 SDL/OpenGL 창을 소유하고 별도로 렌더링 및 프레젠테이션을 수행합니다. + +C 런타임은 `include/bongo_cat/model.h`에 선언된 ABI를 호출합니다. Live2D 브리지 및 Cubism 구현은 `src/live2d`에 있으며, Cubism SDK가 활성화된 경우에만 C++17을 사용합니다. 나머지 로컬 런타임은 C11을 사용합니다. Cubism 타입은 불투명 C 핸들 뒤에 유지됩니다. SDK를 사용할 수 없을 때 `src/live2d/live2d_stub.c`는 진단 백엔드를 제공합니다. + +```mermaid +flowchart TB + Input(["키보드 / 마우스 / 게임패드"]) + BuiltIn(["내장 모델 리소스"]) + Sources(["외부 모델 소스
Mver, Tauri, .model3.json, 이미지 패치"]) + Desktop(["펫 창 및 환경 설정 창"]) + subgraph Runtime["BongoCat 네이티브 런타임"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["시작 및 초기화
구성, 저장소, 창, 플랫폼"] + Loop["SDL3 메인 루프
대기, 처리, 업데이트, 렌더링"] + Shutdown["종료
상태 플러시, 서비스 중지, 리소스 해제"] + InputQueue[("원자 입력 상태
에지 큐 및 병합된 포인터 위치")] + InputDispatch["입력 처리
단축키, 포인터 매핑, 모델 파라미터"] + State[("BongoCatApp 상태
설정, 세션, 카탈로그, 런타임 핸들")] + Import["모델 발견 및 가져오기
검증, Mver로 정규화, 설치/캐싱"] + Catalog[("모델 및 동작 카탈로그")] + Live2D["Live2D C ABI
Cubism SDK 또는 진단 스텁"] + Overlay["오버레이 및 오디오"] + Preferences["환경 설정 및 데스크톱 셸
Nuklear UI, 트레이, 창 작업"] + Compose["OpenGL 프레임 합성"] + Present["플랫폼 프레젠테이션"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["플랫폼 백엔드"] + direction LR + Global["전역 키보드/포인터 캡처
Windows, macOS, Linux"] + SDL["SDL3 이벤트
창 및 게임패드 이벤트"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 자주 묻는 질문 + +### 🔒 BongoCat이 제 키보드나 마우스 입력을 기록하나요? + +아니요. BongoCat은 애니메이션 구동과 단축키를 위해 키보드 및 마우스 입력을 로컬에서 처리합니다. 키 입력, 마우스 동작 또는 기타 상호 작용 데이터를 기록하거나 업로드하지 않습니다. 구성도 로컬에만 저장되며, 애플리케이션에는 광고, 분석 도구 또는 사용자 추적 코드가 포함되어 있지 않습니다. 업데이트 확인을 수행할 때는 공개 버전 메타데이터만 요청하며, 입력, 구성 또는 사용 데이터를 전송하지 않습니다. + +### 🖼️ OpenGL을 사용하는 이유는 무엇인가요? Vulkan은 왜 사용하지 않나요? + +Vulkan이 나쁘기 때문이 아니라, BongoCat이 그 정도의 복잡성을 필요로 하지 않기 때문입니다. 애플리케이션은 주로 하나의 Live2D 모델, 소량의 UI 레이어 및 투명 데스크톱 창을 렌더링하며, OpenGL은 이를 쉽게 충족할 수 있고 SDL3 및 Cubism의 OpenGL 렌더러와 자연스럽게 통합됩니다. Vulkan으로 마이그레이션하려면 세 가지 데스크톱 플랫폼에서 더 많은 렌더링 및 동기화 코드를 유지 관리해야 하지만, 사용자에게 눈에 띄는 개선은 없을 것입니다. BongoCat의 현재 워크로드에서 OpenGL은 렌더러를 더 간결하고 디버깅 및 유지 관리하기 쉽게 유지하면서도 필요한 성능을 제공합니다. + +## 🙏 특별 감사 +> [!TIP] +> BongoCat의 모든 걸음은 오픈 소스 정신으로 이루어집니다. 커뮤니티 기여자분들의 사심 없는 기여에 진심으로 감사드립니다(기여 날짜순 정렬). 여러분의 지원이 데스크톱 동반을 더욱 자유롭고 진심 어린 것으로 만들어 줍니다.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+저작권 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md new file mode 100644 index 00000000..6f9d31a4 --- /dev/null +++ b/docs/README.pt-BR.md @@ -0,0 +1,252 @@ + + +

💘 C/C++ × SDL3 × OpenGL, misture tudo e bata à vontade! Bong~ Bongo Cat!!!

+

+ Escolha o idioma ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!TIP] +> O modelo usado na demonstração é de [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Procurando modelos **gratuitos**? Trabalhamos com criadores de modelos talentosos para trazer uma grande variedade de modelos gratuitos, enquanto exploramos continuamente experiências de desktop ainda mais divertidas! Visite nosso site oficial: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 Download + + + +- GitHub Releases + + Baixe a versão mais recente nas [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Compilar a partir do código-fonte + +O BongoCat usa CMake e requer um compilador C11, um compilador C++17, CMake 3.24 ou superior e os arquivos de desenvolvimento de OpenGL para desktop. Por padrão, SDL3, yyjson, stb, miniaudio e Nuklear são baixados automaticamente durante a configuração, portanto a primeira configuração requer conexão com a internet. + +Execute os comandos abaixo na raiz do projeto (o diretório que contém `CMakeLists.txt`). + +### 📋 Pré-requisitos por plataforma + +- **Windows:** Visual Studio 2022 (com a carga de trabalho ‘Desenvolvimento para desktop com C++’) e CMake. Use o gerador MSVC; o MinGW pode compilar o backend de diagnóstico, mas não suporta o SDK do Cubism. +- **macOS:** Xcode Command Line Tools, CMake e Ninja. Se a arquitetura de destino for diferente da padrão do host, especifique-a por meio de `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC ou Clang, Ninja e os cabeçalhos de OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 Configuração e compilação + +No Linux e no macOS, use um gerador de configuração única como o Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +No Windows, execute os comandos no Prompt de Comando do Desenvolvedor do Visual Studio 2022 (ou em qualquer shell em que o MSVC esteja disponível): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +O executável fica em `build/BongoCat` no Linux, em `build/BongoCat.app/Contents/MacOS/BongoCat` no macOS e em `build/Release/BongoCat.exe` para compilações do Visual Studio no Windows. + +### 🧪 Testes + +Os alvos do CTest estão habilitados por padrão. Execute o seguinte após compilar: + +```bash +ctest --test-dir build --output-on-failure +``` + +Para um gerador de múltiplas configurações, como o Visual Studio, especifique explicitamente a configuração de compilação: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / SDK do Cubism (opcional) + +Se o SDK do Cubism não for encontrado, o CMake emitirá um aviso e compilará o backend de diagnóstico. Esse backend é destinado à inicialização e ao diagnóstico de plataforma; ele não fornece renderização de modelos Live2D. Para compilar o runtime completo, instale uma versão compatível do Cubism SDK for Native, coloque-a em `vendor/CubismSdkForNative` ou informe o caminho explicitamente: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +O SDK deve incluir a biblioteca Core, o código-fonte do Framework e o diretório de terceiros do OpenGL GLEW na estrutura esperada por `cmake/Cubism.cmake`. As compilações de Cubism no Windows exigem o Visual Studio 2022. Com `BONGO_CAT_REQUIRE_CUBISM=ON`, a configuração falha quando o SDK não está disponível, em vez de selecionar silenciosamente o backend de diagnóstico. + +### ⚙️ Opções do CMake + +| Opção | Valor padrão | Descrição | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Baixa dependências de terceiros em versões fixadas via `FetchContent` do CMake. Defina como `OFF` apenas se SDL3, yyjson, stb, miniaudio e Nuklear já estiverem disponíveis para o CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Caminho para o Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Faz a configuração falhar quando não há SDK disponível. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Trata avisos do compilador nativo como erros. | + +Para uma compilação offline, defina `BONGO_CAT_FETCH_DEPS=OFF` e forneça as configurações de pacote do CMake para SDL3 (incluindo `SDL3-static`) e yyjson; se stb, Nuklear e miniaudio não puderem ser detectados automaticamente, informe também seus diretórios de inclusão: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Status do projeto + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Licença + +O código-fonte e o runtime nativo do BongoCat estão licenciados sob [AGPL-3.0-only](../LICENSE). + +O modo de modelo integrado padrão (`standard`) permanece sob a licença MIT. Os recursos de modelo incluídos em `resources/assets/models/standard`, `keyboard` e `gamepad` são cobertos pela [declaração de licença MIT](../LICENSE-MIT) separada. Essa licença MIT se aplica apenas aos recursos dos modelos e às artes que os acompanham; ela não altera a licença do código-fonte nem do runtime nativo do BongoCat. + +## 🧭 Arquitetura técnica + +A versão nativa atual é construída com C/C++, SDL3 e OpenGL. O diagrama abaixo destaca o fluxo de dados em tempo de execução; consulte os arquivos do CMake para obter detalhes de compilação e empacotamento. + +### 🔄 Propriedade do runtime e agendamento de quadros + +Cada processo possui uma instância de `BongoCatApp` e um loop de eventos/renderização no thread principal. Os listeners de plataforma param na fronteira de entrada: + +```text +Listeners de plataforma (teclado/ponteiro) + | + v + Estado de entrada C11 (fila atômica de bordas + posição do ponteiro mesclada) + | + v + aplicação no thread principal <----- eventos SDL3 + | + v + parâmetros do modelo, sobreposições e estado da interface + | + v + atualização do modelo -> composição OpenGL -> apresentação da plataforma +``` + +O receptor Raw Input do Windows, o tap de eventos Quartz do macOS e os listeners XInput2 do Linux são executados fora do loop principal. Eventos de teclas e botões entram em uma fila atômica limitada; os movimentos são agrupados separadamente e eventos SDL despertam a thread principal. O Windows recebe entradas em segundo plano por uma janela de mensagens com `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, preservando as mensagens comuns. Quando outro aplicativo oculta ou bloqueia o cursor, o modelo usa os movimentos do dispositivo; o SDL fornece a posição do cursor na área de trabalho. Estados pressionados são limpos ao desconectar um dispositivo ou mudar a área de trabalho de entrada. O Windows não usa mais hooks de entrada nem DirectInput. Eventos de janela, preferências e gamepad do SDL3 são processados na thread principal. Nenhum listener de plataforma chama diretamente o código de Live2D, sobreposições ou interface. + +`bongo_cat_app_run` trata dos parâmetros de atualização, encerramento e processos secundários, impõe a propriedade de instância única do processo principal, aloca o estado do aplicativo, executa a inicialização, entra em `bongo_cat_app_loop` e, em seguida, atualiza o estado e destrói os recursos na ordem definida. A inicialização carrega a configuração e os caminhos de armazenamento, localiza os recursos, cria a janela de mascote SDL/OpenGL, inicializa os backends de plataforma, cria os serviços de Live2D/sobreposições/áudio, verifica fontes de modelos integradas/instaladas/próximas e carrega os modelos disponíveis. `BongoCatApp` mantém as configurações, o estado da sessão, os catálogos de modelos e comportamentos, os identificadores de plataforma e os identificadores de serviços de runtime. + +Os pacotes de modelos instalados usam Mver como formato canônico. O processo de importação analisa o arquivo ou diretório selecionado, descobre e valida candidatos, gera uma impressão digital de identidade do pacote, converte fontes Tauri em Mver, aplica patches de imagem e envia o pacote normalizado para `models_root`. Em seguida, os adaptadores de runtime são gerados e o catálogo é atualizado. Fontes próximas são descobertas apenas sem instalar a árvore de origem; seus adaptadores e resultados de verificação são armazenados em cache sob `cache_root`, fora de `models_root`. + +Cada iteração do loop principal espera ativações SDL/nativas ou o vencimento mais cedo dos prazos de quadro, interface, animação e acerto do ponteiro (máximo de 250 ms). O loop distribui os eventos SDL enfileirados, esvazia a fila de entrada atômica e executa a recuperação de liberação, atualiza o estado da janela e da atualização do modelo e, em seguida, aplica os parâmetros de entrada. Com o Cubism habilitado, o prazo do modelo segue `settings.model.max_fps` (padrão de 60 FPS); builds de diagnóstico usam um intervalo de reserva de 100 ms. O tempo decorrido do modelo é contado no máximo como 250 ms e dividido em até oito subetapas, cada uma com o objetivo de não mais que 1/30 de segundo. + +O caminho normal da mascote é renderizado apenas quando a janela está visível, não minimizada e marcada como suja. Cada quadro primeiro limpa o fundo, desenha o modelo e então compõe as sobreposições de ponteiro, teclas e efeitos, e finalmente chama o apresentador da plataforma. As operações de visualização podem solicitar renderização imediata; a renderização de capturas de tela pode pular a apresentação. macOS e Linux trocam diretamente a janela SDL OpenGL; o Windows a troca diretamente quando a apresentação em camadas não está habilitada; caso contrário, lê o buffer de quadros e chama `UpdateLayeredWindow`. A interface de preferências tem sua própria janela SDL/OpenGL e é renderizada e apresentada separadamente. + +O runtime C chama a ABI declarada em `include/bongo_cat/model.h`. A ponte Live2D e a implementação do Cubism estão em `src/live2d` e usam C++17 apenas quando o SDK do Cubism está habilitado; o restante do runtime nativo usa C11. Os tipos do Cubism são mantidos atrás de identificadores C opacos; quando o SDK não está disponível, `src/live2d/live2d_stub.c` fornece o backend de diagnóstico. + +```mermaid +flowchart TB + Input(["Teclado / Mouse / Gamepad"]) + BuiltIn(["Recursos de modelos integrados"]) + Sources(["Fontes de modelos externos
Mver, Tauri, .model3.json, patches de imagem"]) + Desktop(["Janela de mascote e janela de preferências"]) + subgraph Runtime["Runtime nativo do BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Inicialização e inicialização
Configuração, armazenamento, janela, plataforma"] + Loop["Loop principal SDL3
Aguardar, distribuir, atualizar, renderizar"] + Shutdown["Encerramento
Atualizar estado, parar serviços, liberar recursos"] + InputQueue[("Estado de entrada atômico
Fila de bordas e posição do ponteiro mesclada")] + InputDispatch["Distribuição de entrada
Atalhos, mapeamento do ponteiro, parâmetros do modelo"] + State[("Estado do BongoCatApp
Configurações, sessão, catálogo, identificadores de runtime")] + Import["Descoberta e importação de modelos
Validação, normalização para Mver, instalação/cache"] + Catalog[("Catálogo de modelos e comportamentos")] + Live2D["Live2D C ABI
SDK do Cubism ou stub de diagnóstico"] + Overlay["Sobreposições e áudio"] + Preferences["Preferências e shell de desktop
UI Nuklear, bandeja, ações de janela"] + Compose["Composição de quadros OpenGL"] + Present["Apresentação da plataforma"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Backends de plataforma"] + direction LR + Global["Captura global de teclado/ponteiro
Windows, macOS, Linux"] + SDL["Eventos SDL3
Eventos de janela e gamepad"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Perguntas frequentes + +### 🔒 O BongoCat registra minhas entradas de teclado ou mouse? + +Não. O BongoCat processa entradas de teclado e mouse localmente para impulsionar animações e atalhos. Ele não registra nem envia teclas pressionadas, ações do mouse ou outros dados de interação. A configuração também é salva apenas localmente; o aplicativo não contém anúncios, ferramentas de análise nem código de rastreamento de usuários. Ao verificar atualizações, apenas metadados públicos de versão são solicitados; nenhum dado de entrada, configuração ou uso é enviado. + +### 🖼️ Por que OpenGL em vez de Vulkan? + +Não porque Vulkan seja ruim, mas porque o BongoCat não precisa desse nível de complexidade. O aplicativo renderiza principalmente um modelo Live2D, algumas camadas de interface e uma janela de desktop transparente; o OpenGL dá conta disso facilmente e se integra naturalmente com o SDL3 e o renderizador OpenGL do Cubism. Migrar para Vulkan exigiria manter mais código de renderização e sincronização em três plataformas de desktop, sem benefício perceptível para o usuário. Para a carga de trabalho atual do BongoCat, o OpenGL torna o renderizador mais enxuto, mais fácil de depurar e manter, continuando a oferecer o desempenho necessário. + +## 🙏 Agradecimentos especiais +> [!TIP] +> Cada passo do BongoCat é movido pelo espírito do código aberto. Agradecemos sinceramente a todos os colaboradores da comunidade por suas contribuições altruístas (listadas abaixo em ordem cronológica de contribuição). É o apoio de vocês que torna a companhia no desktop mais livre e genuína.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+Copyright © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.ru-RU.md b/docs/README.ru-RU.md new file mode 100644 index 00000000..8509e64d --- /dev/null +++ b/docs/README.ru-RU.md @@ -0,0 +1,252 @@ + + +

💘 C/C++ × SDL3 × OpenGL — смешай всё и барабань в своё удовольствие! Бонг~ Bongo Cat!!!

+

+ Выберите язык ❯ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!TIP] +> Модель, использованная в демо, принадлежит [宇痕冫](https://space.bilibili.com/348616056). +> +> 🎁 Ищете **бесплатные** модели? Мы сотрудничаем с талантливыми создателями моделей, чтобы предложить вам широкий выбор бесплатных моделей, и постоянно исследуем новые интересные возможности для рабочего стола! Загляните на наш официальный сайт: [bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 Загрузка + + + +- GitHub Releases + + Скачайте последнюю версию из [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest). + +## 🛠️ Сборка из исходного кода + +BongoCat использует CMake и требует компилятор C11, компилятор C++17, CMake 3.24 или новее, а также файлы разработки OpenGL для настольных систем. По умолчанию SDL3, yyjson, stb, miniaudio и Nuklear загружаются автоматически на этапе конфигурации, поэтому для первой настройки требуется подключение к сети. + +Выполните следующие команды в корневом каталоге проекта (каталоге, содержащем `CMakeLists.txt`). + +### 📋 Предварительные требования по платформам + +- **Windows:** Visual Studio 2022 (с рабочей нагрузкой «Разработка классических приложений на C++») и CMake. Используйте генератор MSVC; MinGW может собрать диагностический бэкенд, но не поддерживает Cubism SDK. +- **macOS:** Xcode Command Line Tools, CMake и Ninja. Если целевая архитектура отличается от архитектуры хоста по умолчанию, укажите её через `CMAKE_OSX_ARCHITECTURES`. +- **Linux (Debian/Ubuntu):** GCC или Clang, Ninja, а также заголовочные файлы OpenGL/X11: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 Настройка и сборка + +В Linux и macOS используйте генератор с одной конфигурацией, например Ninja: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +В Windows выполняйте команды из командной строки разработчика Visual Studio 2022 (или любой другой оболочки, где доступен MSVC): + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +Исполняемый файл находится в `build/BongoCat` в Linux, в `build/BongoCat.app/Contents/MacOS/BongoCat` в macOS и в `build/Release/BongoCat.exe` для сборок Visual Studio в Windows. + +### 🧪 Тестирование + +Цели CTest включены по умолчанию. После сборки выполните: + +```bash +ctest --test-dir build --output-on-failure +``` + +Для генератора с несколькими конфигурациями, такого как Visual Studio, явно укажите конфигурацию сборки: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK (необязательно) + +Если Cubism SDK не найден, CMake выдаст предупреждение и соберёт диагностический бэкенд. Этот бэкенд предназначен для запуска и диагностики платформы; он не обеспечивает рендеринг моделей Live2D. Чтобы собрать полный рантайм, установите совместимую версию Cubism SDK for Native, поместите её в `vendor/CubismSdkForNative` или явно укажите путь: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK должен включать библиотеку Core, исходный код Framework и каталог сторонних библиотек OpenGL GLEW в структуре, ожидаемой `cmake/Cubism.cmake`. Сборки Cubism в Windows требуют Visual Studio 2022. При `BONGO_CAT_REQUIRE_CUBISM=ON` конфигурация завершится ошибкой, если SDK недоступен, а не будет молча использовать диагностический бэкенд. + +### ⚙️ Параметры CMake + +| Параметр | По умолчанию | Описание | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | Загружает сторонние зависимости фиксированных версий через `FetchContent` CMake. Установите `OFF`, только если SDL3, yyjson, stb, miniaudio и Nuklear уже доступны для CMake. | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Путь к Cubism SDK for Native. | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | Завершает конфигурацию ошибкой, если SDK недоступен. | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | Обрабатывает предупреждения нативного компилятора как ошибки. | + +Для автономной сборки установите `BONGO_CAT_FETCH_DEPS=OFF` и предоставьте конфигурации пакетов CMake для SDL3 (включая `SDL3-static`) и yyjson; если stb, Nuklear и miniaudio не могут быть найдены автоматически, укажите также их каталоги включения: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 Статус проекта + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 Лицензия + +Исходный код и нативный рантайм BongoCat распространяются под лицензией [AGPL-3.0-only](../LICENSE). + +Встроенный по умолчанию режим модели (`standard`) остаётся под лицензией MIT. Ресурсы моделей, входящие в `resources/assets/models/standard`, `keyboard` и `gamepad`, покрываются отдельным [объявлением о лицензии MIT](../LICENSE-MIT). Эта лицензия MIT распространяется только на ресурсы моделей и сопутствующие художественные материалы; она не меняет лицензию исходного кода или нативного рантайма BongoCat. + +## 🧭 Техническая архитектура + +Текущая нативная версия построена на C/C++, SDL3 и OpenGL. Диаграмма ниже акцентирует поток данных во время выполнения; подробности сборки и упаковки см. в файлах CMake. + +### 🔄 Владение рантаймом и планирование кадров + +Каждый процесс владеет экземпляром `BongoCatApp` и циклом событий/рендеринга в главном потоке. Платформенные слушатели останавливаются на границе ввода: + +```text +Платформенные слушатели (клавиатура/указатель) + | + v + Состояние ввода C11 (атомарная очередь фронтов + объединённая позиция указателя) + | + v + приложение в главном потоке <----- события SDL3 + | + v + параметры модели, оверлеи и состояние интерфейса + | + v + обновление модели -> композиция OpenGL -> представление на платформе +``` + +Приёмник Raw Input в Windows, обработчик событий Quartz в macOS и слушатели XInput2 в Linux работают вне главного цикла. События клавиш и кнопок поступают в ограниченную атомарную очередь; перемещения объединяются отдельно, а события SDL пробуждают главный поток. Windows получает фоновый ввод через окно сообщений с `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY`, сохраняя обычные оконные сообщения. Когда другое приложение скрывает или блокирует курсор, модель использует перемещения устройства; SDL предоставляет положение курсора на рабочем столе. При отключении устройства или смене рабочего стола ввода состояния нажатий очищаются. Windows больше не использует перехватчики ввода или DirectInput. События окна, настроек и геймпада SDL3 обрабатываются в главном потоке. Платформенные слушатели не вызывают напрямую код Live2D, оверлеев или интерфейса. + +`bongo_cat_app_run` обрабатывает параметры обновления, завершения и вторичных процессов, обеспечивает владение единственным экземпляром главного процесса, выделяет состояние приложения, выполняет инициализацию, входит в `bongo_cat_app_loop`, а затем обновляет состояние и уничтожает ресурсы в определённом порядке. Инициализация загружает конфигурацию и пути хранения, находит ресурсы, создаёт окно питомца SDL/OpenGL, инициализирует платформенные бэкенды, создаёт сервисы Live2D/оверлеев/аудио, сканирует встроенные/установленные/близлежащие источники моделей и загружает доступные модели. `BongoCatApp` хранит настройки, состояние сеанса, каталоги моделей и поведения, дескрипторы платформы и дескрипторы сервисов рантайма. + +Установленные пакеты моделей используют Mver в качестве канонического формата. Процесс импорта анализирует выбранный файл или каталог, обнаруживает и проверяет кандидатов, формирует отпечаток идентичности пакета, преобразует источники Tauri в Mver, применяет патчи изображений и отправляет нормализованный пакет в `models_root`. Затем создаются адаптеры рантайма и обновляется каталог. Близлежащие источники обнаруживаются без установки их дерева исходников; их адаптеры и результаты проверок кэшируются в `cache_root` за пределами `models_root`. + +Каждая итерация главного цикла ожидает пробуждения SDL/нативных событий или самого раннего срока кадра, интерфейса, анимации и попадания указателя (максимум 250 мс). Цикл распределяет поставленные в очередь события SDL, очищает атомарную очередь ввода и выполняет восстановление освобождений, обновляет состояние окна и обновления модели, а затем применяет входные параметры. При включённом Cubism срок модели следует `settings.model.max_fps` (по умолчанию 60 FPS); диагностические сборки используют резервный интервал 100 мс. Прошедшее время модели учитывается максимум 250 мс и делится на до восьми подшагов, каждый из которых не превышает 1/30 секунды. + +Обычный путь питомца рендерится только тогда, когда окно видимо, не свёрнуто и помечено как изменённое. Каждый кадр сначала очищает фон, рисует модель, затем компонует оверлеи указателя, клавиш и эффектов и, наконец, вызывает презентер платформы. Операции предпросмотра могут запросить немедленный рендеринг; рендеринг скриншотов может пропустить представление. macOS и Linux напрямую обменивают окно SDL OpenGL; Windows обменивает его напрямую, если многослойное представление не включено, в противном случае считывает кадровый буфер и вызывает `UpdateLayeredWindow`. Интерфейс настроек имеет собственное окно SDL/OpenGL и рендерится и представляется отдельно. + +Среда выполнения C вызывает ABI, объявленный в `include/bongo_cat/model.h`. Мост Live2D и реализация Cubism находятся в `src/live2d` и используют C++17 только при включённом Cubism SDK; остальной нативный рантайм использует C11. Типы Cubism остаются за непрозрачными дескрипторами C; когда SDK недоступен, `src/live2d/live2d_stub.c` предоставляет диагностический бэкенд. + +```mermaid +flowchart TB + Input(["Клавиатура / Мышь / Геймпад"]) + BuiltIn(["Встроенные ресурсы моделей"]) + Sources(["Внешние источники моделей
Mver, Tauri, .model3.json, патчи изображений"]) + Desktop(["Окно питомца и окно настроек"]) + subgraph Runtime["Нативный рантайм BongoCat"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["Запуск и инициализация
Конфигурация, хранилище, окно, платформа"] + Loop["Главный цикл SDL3
Ожидание, распределение, обновление, рендеринг"] + Shutdown["Завершение
Обновить состояние, остановить сервисы, освободить ресурсы"] + InputQueue[("Атомарное состояние ввода
Очередь фронтов и объединённая позиция указателя")] + InputDispatch["Распределение ввода
Сочетания клавиш, сопоставление указателя, параметры модели"] + State[("Состояние BongoCatApp
Настройки, сеанс, каталог, дескрипторы рантайма")] + Import["Обнаружение и импорт моделей
Проверка, нормализация в Mver, установка/кэш"] + Catalog[("Каталог моделей и поведений")] + Live2D["Live2D C ABI
Cubism SDK или диагностический заглушка"] + Overlay["Оверлеи и аудио"] + Preferences["Настройки и оболочка рабочего стола
UI Nuklear, трей, операции с окном"] + Compose["Композиция кадров OpenGL"] + Present["Представление на платформе"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["Платформенные бэкенды"] + direction LR + Global["Глобальный захват клавиатуры/указателя
Windows, macOS, Linux"] + SDL["События SDL3
События окна и геймпада"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ Часто задаваемые вопросы + +### 🔒 Записывает ли BongoCat мой ввод с клавиатуры или мыши? + +Нет. BongoCat обрабатывает ввод с клавиатуры и мыши локально для управления анимациями и сочетаниями клавиш. Он не записывает и не загружает нажатия клавиш, действия мыши или другие данные взаимодействия. Конфигурация также сохраняется только локально; приложение не содержит рекламы, аналитических инструментов или кода отслеживания пользователей. При проверке обновлений запрашиваются только публичные метаданные версии; никакие данные ввода, конфигурации или использования не отправляются. + +### 🖼️ Почему OpenGL, а не Vulkan? + +Не потому что Vulkan плох, а потому что BongoCat не нуждается в такой сложности. Приложение в основном рендерит одну модель Live2D, несколько слоёв интерфейса и прозрачное окно рабочего стола; OpenGL легко справляется с этим и естественно сочетается с SDL3 и рендерером OpenGL от Cubism. Переход на Vulkan потребовал бы поддержки большего объёма кода рендеринга и синхронизации на трёх настольных платформах без заметного преимущества для пользователя. Для текущей нагрузки BongoCat OpenGL делает рендерер более компактным, лёгким в отладке и обслуживании, сохраняя при этом необходимую производительность. + +## 🙏 Особая благодарность +> [!TIP] +> Каждый шаг BongoCat вдохновлён духом открытого исходного кода. Мы искренне благодарим всех участников сообщества за их бескорыстный вклад (перечислен ниже в хронологическом порядке). Именно ваша поддержка делает настольного спутника более свободным и искренним.❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+Авторские права © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md new file mode 100644 index 00000000..25e7e535 --- /dev/null +++ b/docs/README.zh-CN.md @@ -0,0 +1,273 @@ + + +

💘 C/C++ × SDL3 × OpenGL,搅拌在一起,尽情敲击!Bong~ Bongo Cat!!!

+ +

支持的操作系统:Windows、macOS、Linux

+ +

+ English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ +
+ +> [!NOTE] +> 演示中使用的模型来自 [宇痕冫](https://space.bilibili.com/348616056)。 + +>[!TIP] +> 想找**免费**模型?我们与才华横溢的模型创作者们合作,为您带来丰富多样的免费模型,持续探索更多有趣的桌面体验!请访问我们的官方网站:[bongocat.pet](https://bongocat.pet/models) + +

+ + + +

+ +

+ +## 📥 下载 +- ### Microsoft Store + + +- ### GitHub Releases + + 从 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下载最新版本。 + +## 🛠️ 从源码构建 + +BongoCat 使用 CMake,需要 C11 编译器、C++17 编译器、CMake 3.24 或更高版本,以及桌面 OpenGL 开发文件。默认情况下,SDL3、yyjson、stb、miniaudio 和 Nuklear 会在配置阶段自动下载,因此首次配置需要网络连接。 + +请在项目根目录(包含 `CMakeLists.txt` 的目录)运行以下命令。 + +### 平台前置条件 + +- **Windows:** Visual Studio 2022(安装“使用 C++ 的桌面开发”工作负载)和 CMake。请使用 MSVC 生成器;MinGW 可构建诊断后端,但不支持 Cubism SDK。 +- **macOS:** Xcode Command Line Tools、CMake 和 Ninja。如果目标架构与主机默认架构不同,请通过 `CMAKE_OSX_ARCHITECTURES` 指定。 +- **Linux(Debian/Ubuntu):** GCC 或 Clang、Ninja,以及 OpenGL/X11 头文件: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 配置与构建 + +- **在 Linux 和 macOS 上**,请使用类似 Ninja 这样的单配置生成器运行: + + ```bash + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON + cmake --build build --parallel + ``` + +- **在 Windows 上**,请从 Visual Studio 2022 开发者命令行(或 MSVC 可用的其他命令行)运行: + + ```powershell + cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON + cmake --build build --config Release --parallel + ``` +> [!NOTE] +> 可执行文件位置: +> +> - Linux:`build/BongoCat` +> - macOS:`build/BongoCat.app/Contents/MacOS/BongoCat` +> - Windows(Visual Studio 构建):`build/Release/BongoCat.exe` + +### 测试 + +CTest 目标默认启用。构建后运行: + +```bash +ctest --test-dir build --output-on-failure +``` + +对于 Visual Studio 这类多配置生成器,请明确指定构建配置: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### Live2D / Cubism SDK(可选) + +如果未找到 Cubism SDK,CMake 会发出警告并构建诊断后端。该后端用于启动和平台诊断,不提供 Live2D 模型渲染。要构建完整运行时,请安装兼容的 Cubism SDK for Native,将其放置在 `vendor/CubismSdkForNative`,或显式传入路径: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK 必须包含 Core 库、Framework 源码,以及 `cmake/Cubism.cmake` 所要求布局中的 OpenGL GLEW 第三方目录。Windows Cubism 构建需要 Visual Studio 2022。`BONGO_CAT_REQUIRE_CUBISM=ON` 会在 SDK 不可用时使配置失败,而不是静默选择诊断后端。 + +### CMake 选项 + +| 选项 | 默认值 | 说明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | 使用 CMake `FetchContent` 下载固定版本的第三方依赖。仅当 SDL3、yyjson、stb、miniaudio 和 Nuklear 已可供 CMake 使用时才设为 `OFF`。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native 的路径。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | SDK 不可用时使配置失败。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 将本地编译器警告视为错误。 | + +离线构建时将 `BONGO_CAT_FETCH_DEPS=OFF`,并提供 SDL3(包括 `SDL3-static`)和 yyjson 的 CMake 包配置;如果 stb、Nuklear 和 miniaudio 无法自动发现,还需提供其包含目录: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 项目状态 + +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + +## 📜 许可证 + +BongoCat 源代码和本地运行时采用 [AGPL-3.0-only](../LICENSE) 许可证。 + +默认内置模型模式(`standard`)仍采用 MIT 许可证。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中捆绑的模型资源受单独的 [MIT 许可证声明](../LICENSE-MIT) 覆盖。该 MIT 许可证仅适用于模型资源及其配套美术作品,不会改变 BongoCat 源代码或本地运行时的许可证。 + +## 🧭 技术架构 + +当前原生版本基于 C/C++、SDL3 和 OpenGL 构建。下图重点展示运行时数据流;构建与打包细节请参阅 CMake 文件。 + +### 运行时所有权与帧调度 + +每个进程拥有一个 `BongoCatApp` 和一个主线程事件/渲染循环。平台监听器在输入边界处停止: + +```mermaid +flowchart TB + Listener["平台监听器(键盘/指针)"] --> InputState["C11 输入状态(原子边沿队列 + 合并后的指针位置)"] + InputState --> MainThread["主线程应用"] + SDLEvent["SDL3 事件"] --> MainThread + MainThread --> ModelState["模型参数、覆盖层和 UI 状态"] + + subgraph Render["渲染阶段"] + direction LR + ModelUpdate["模型更新"] --> Compose["OpenGL 合成"] --> Present["平台呈现"] + end + + ModelState --> Render +``` + +Windows Raw Input 接收器、macOS Quartz 事件 tap 和 Linux XInput2 监听器运行在主循环之外。按键和鼠标按钮边沿进入有界原子队列,移动量单独合并,并通过 SDL 事件唤醒主线程,避免高频移动挤出按键事件。Windows 使用独立消息窗口,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 订阅后台键鼠输入并保留普通窗口消息;其他程序隐藏或锁定光标时,模型使用设备上报的移动量,桌面跟随则读取 SDL 提供的系统光标位置。接收器分别管理各设备的按下状态,在设备拔出或输入桌面切换时清理。Windows 不再安装输入钩子或使用 DirectInput,也不向游戏发送输入。SDL3 窗口、偏好设置和手柄事件仍在主线程处理,平台监听器不会直接调用 Live2D、覆盖层或 UI 代码。 + +`bongo_cat_app_run` 负责更新、关闭和次级进程参数,确保主进程的单实例所有权,分配应用状态,执行初始化,进入 `bongo_cat_app_loop`,随后按定义顺序刷新状态并销毁资源。初始化会加载配置和存储路径、定位资源、创建 SDL/OpenGL 宠物窗口、初始化平台后端、创建 Live2D/覆盖层/音频服务、扫描内置/已安装/附近的模型来源,并加载可用模型。`BongoCatApp` 持有设置、会话状态、模型和行为目录、平台句柄及运行时服务句柄。 + +已安装的模型包使用 Mver 作为规范格式。导入流程会解析选中的文件或目录,发现并验证候选项,生成包身份指纹,将 Tauri 来源转换为 Mver,应用图像补丁,并将规范化包提交到 `models_root`。随后生成运行时适配器并刷新目录。附近来源只在不安装源目录的情况下被发现;其适配器和检查结果缓存在 `models_root` 之外的 `cache_root` 下。 + +每次主循环迭代都会等待 SDL/原生唤醒,或等待最早的帧、UI、动画和指针命中截止时间(最长 250 毫秒)。循环分发排队的 SDL 事件,清空原子输入队列并执行释放恢复,更新窗口和模型刷新状态,然后应用输入参数。启用 Cubism 时,模型截止时间遵循 `settings.model.max_fps`(默认为 60 FPS);诊断构建使用 100 毫秒的后备间隔。模型经过的时间最多计为 250 毫秒,并拆分为最多八个子步,每个子步目标不超过 1/30 秒。 + +常规宠物路径仅在窗口可见、未最小化且标记为脏时渲染。每帧先清空背景,绘制模型,再合成指针、按键和效果覆盖层,最后调用平台呈现器。预览操作可以请求立即渲染,截图渲染可以跳过呈现。macOS 和 Linux 直接交换 SDL OpenGL 窗口;Windows 在未启用分层呈现时直接交换,否则读取帧缓冲并调用 `UpdateLayeredWindow`。偏好设置 UI 拥有独立的 SDL/OpenGL 窗口,并单独渲染和呈现。 + +C 运行时调用 `include/bongo_cat/model.h` 中声明的 ABI。Live2D 桥接和 Cubism 实现在 `src/live2d` 中,仅在启用 Cubism SDK 时使用 C++17;其余本地运行时使用 C11。Cubism 类型保持在不透明 C 句柄之后;当 SDK 不可用时,`src/live2d/live2d_stub.c` 提供诊断后端。 + +```mermaid +flowchart TB + Input(["键盘 / 鼠标 / 手柄"]) + BuiltIn(["内置模型资源"]) + Sources(["外部模型来源
Mver、Tauri、.model3.json、图像补丁"]) + Desktop(["宠物窗口和偏好设置窗口"]) + subgraph Runtime["BongoCat 原生运行时"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["启动和初始化
配置、存储、窗口、平台"] + Loop["SDL3 主循环
等待、分发、更新、渲染"] + Shutdown["关闭
刷新状态、停止服务、释放资源"] + InputQueue[("原子输入状态
边沿队列和合并指针位置")] + InputDispatch["输入分发
快捷键、指针映射、模型参数"] + State[("BongoCatApp 状态
设置、会话、目录、运行时句柄")] + Import["模型发现和导入
验证、规范化为 Mver、安装/缓存"] + Catalog[("模型和行为目录")] + Live2D["Live2D C ABI
Cubism SDK 或诊断存根"] + Overlay["覆盖层和音频"] + Preferences["偏好设置和桌面外壳
Nuklear UI、托盘、窗口操作"] + Compose["OpenGL 帧合成"] + Present["平台呈现"] + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + subgraph Platform["平台后端"] + direction LR + Global["全局键盘/指针捕获
Windows、macOS、Linux"] + SDL["SDL3 事件
窗口和手柄事件"] + end + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 常见问题 + +### BongoCat 会记录我的键盘或鼠标输入吗? + +不会。BongoCat 仅在本地即时处理键盘和鼠标输入,用于渲染动画和触发快捷键。它不会记录或上传按键、鼠标操作或其他交互数据。配置也只保存在本地。BongoCat 不包含广告、分析工具或用户跟踪代码。执行更新检查时只会请求公开的版本元数据,不会发送输入、配置或使用数据。 + +### Linux Wayland 输入 + +X11 默认使用 XInput2。Wayland 的实验性 evdev 输入默认关闭;阅读 +[输入权限风险](../SECURITY.md#linux-input) 后,可以为单次启动显式启用: + +```sh +BONGOCAT_ENABLE_EVDEV=1 ./build/BongoCat +``` + +这个开关不会授予设备权限。不要为此以 root 身份运行程序,或把账户加入 +`input` 组。原始输入可能包含密码框里的按键,监听不会在锁屏或切换会话时自动暂停。 +退出程序才会停止监听,隐藏宠物不会停止;不带该变量重新启动即可恢复默认后端。 +鼠标跟随使用未经加速的设备位移,窗口定位、点击穿透和置顶仍取决于 Wayland 合成器。 + +### 为什么使用 OpenGL 而不是 Vulkan? + +不是因为 Vulkan 不好,而是 BongoCat 不需要那种程度的复杂性。应用主要渲染一个 Live2D 模型、少量 UI 图层和透明桌面窗口,OpenGL 已能轻松满足,并且能自然地与 SDL3 及 Cubism 的 OpenGL 渲染器配合。迁移到 Vulkan 将需要在三个桌面平台维护更多渲染和同步代码,却不会为用户带来明显提升。对于 BongoCat 当前的工作负载,OpenGL 让渲染器更精简、更易调试和维护,同时仍能提供所需性能。 + +## 🙏 特别感谢 +> [!NOTE] +> BongoCat 的每一步都得益于开源精神。我们衷心感谢所有社区贡献者的无私奉献(按贡献日期先后排序列于下方)。正是你们的支持,让桌面陪伴更加自由与真诚。❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+版权所有 © 2026 - **BongoCat**
+By vladelaina
+Made with ❤️ & ⌨️ +
diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md new file mode 100644 index 00000000..ca00aef1 --- /dev/null +++ b/docs/README.zh-Hant.md @@ -0,0 +1,288 @@ +
+ + Catime + + +

+ BongoCat +

+
+ + + +

+ 💘C/C++ × SDL3 × OpenGL,攪拌均勻,搗碎融合!Bong~ Bongo Cat!!! +

+

+English • 简体中文 • 繁體中文 • Français • Deutsch • 日本語 • 한국어 • Português • Русский • Español • Bahasa Indonesia +

+

+ + + + + +

+ + + +
+ +
+ + +> [!TIP] +> 本示範所使用的模型來自 [宇痕冫](https://space.bilibili.com/348616056)。 +> +> 🎁 尋找**免費**模型?我們與才華洋溢的模型創作者合作,為您帶來豐富多樣的免費模型,同時持續探索更多有趣的桌面體驗!請瀏覽我們的官方網站:[bongocat.pet](https://bongocat.pet/models) + + +

+ + + +

+ +

+ +

+ +## 📥 下載 + + + + + +- GitHub Releases + + 從 [GitHub Releases](https://github.com/vladelaina/BongoCat/releases/latest) 下載最新版本。 + +## 🛠️ 從原始碼建置 + +BongoCat 使用 CMake,需要 C11 編譯器、C++17 編譯器、CMake 3.24 或更新版本,以及桌面 OpenGL 開發檔案。SDL3、yyjson、stb、miniaudio 和 Nuklear 預設在建置配置時下載,因此首次配置需要網路連線。 + +請在專案根目錄(包含 `CMakeLists.txt` 的目錄)執行以下命令。 + +### 📋 平台先決條件 + +- **Windows:** Visual Studio 2022 並包含「桌面 C++」工作負載和 CMake。請使用 MSVC 產生器;MinGW 可建置診斷後端,但不支援 Cubism SDK。 +- **macOS:** Xcode Command Line Tools、CMake 和 Ninja。若目標架構與主機預設不同,請以 `CMAKE_OSX_ARCHITECTURES` 指定。 +- **Linux(Debian/Ubuntu):** GCC 或 Clang、Ninja,以及 OpenGL/X11 標頭檔: + + ```bash + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build \ + libgl1-mesa-dev libx11-dev libxi-dev libxfixes-dev libcurl4-openssl-dev + ``` + +### 🔧 配置與建置 + +在 Linux 和 macOS 上,使用單一配置產生器(如 Ninja): + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --parallel +``` + +在 Windows 上,請從 Visual Studio 2022 開發人員命令提示字元(或任何可使用 MSVC 的 Shell)執行: + +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_FETCH_DEPS=ON +cmake --build build --config Release --parallel +``` + +執行檔產出位置: +- Linux:`build/BongoCat` +- macOS:`build/BongoCat.app/Contents/MacOS/BongoCat` +- Visual Studio 建置:`build/Release/BongoCat.exe` + +### 🧪 測試 + +CTest 目標預設啟用。建置完成後執行: + +```bash +ctest --test-dir build --output-on-failure +``` + +若使用多配置產生器(如 Visual Studio),請明確指定建置配置: + +```powershell +ctest --test-dir build -C Release --output-on-failure +``` + +### 🎭 Live2D / Cubism SDK(選用) + +若未提供 Cubism SDK,CMake 會發出警告並建置診斷後端。此後端僅供啟動和平台診斷之用,不提供 Live2D 模型渲染。若要建置完整執行時期,請安裝相容的 Cubism SDK for Native,並將其放置在 `vendor/CubismSdkForNative`,或明確傳遞路徑: + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_CUBISM_SDK=/path/to/CubismSdkForNative \ + -DBONGO_CAT_REQUIRE_CUBISM=ON +``` + +SDK 必須包含 Core 函式庫、Framework 原始碼,以及 `cmake/Cubism.cmake` 所預期的 OpenGL GLEW 第三方目錄結構。Windows 上的 Cubism 建置需要 Visual Studio 2022。設定 `BONGO_CAT_REQUIRE_CUBISM=ON` 會在無法使用 Cubism SDK 時使配置失敗,而非靜默改用診斷後端。 + +### ⚙️ CMake 選項 + +| 選項 | 預設值 | 說明 | +| --- | --- | --- | +| `BONGO_CAT_FETCH_DEPS` | `ON` | 使用 CMake `FetchContent` 下載固定的第三方依賴項。僅在 SDL3、yyjson、stb、miniaudio 和 Nuklear 已可被 CMake 找到時設為 `OFF`。 | +| `BONGO_CAT_CUBISM_SDK` | `vendor/CubismSdkForNative` | Cubism SDK for Native 的路徑。 | +| `BONGO_CAT_REQUIRE_CUBISM` | `OFF` | 當無法使用 Cubism SDK 時使配置失敗。 | +| `BONGO_CAT_WARNINGS_AS_ERRORS` | `OFF` | 將原生編譯器警告視為錯誤。 | + +若要進行離線建置,設定 `BONGO_CAT_FETCH_DEPS=OFF`,並提供 SDL3(含 `SDL3-static`)和 yyjson 的 CMake 套件配置,以及 stb、Nuklear、miniaudio 的包含路徑(若無法自動找到): + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBONGO_CAT_FETCH_DEPS=OFF \ + -DBONGO_CAT_STB_INCLUDE_DIR=/path/to/stb \ + -DBONGO_CAT_NUKLEAR_INCLUDE_DIR=/path/to/nuklear \ + -DBONGO_CAT_MINIAUDIO_INCLUDE_DIR=/path/to/miniaudio +``` + +## 📌 專案狀態 + + +## 📜 授權條款 + +BongoCat 原始碼和原生執行時期採用 [AGPL-3.0-only](LICENSE) 授權。 + +預設內建模型模式(`standard`)仍以 MIT 授權釋出。`resources/assets/models/standard`、`keyboard` 和 `gamepad` 中的模型資產,則受個別的 [MIT 授權聲明](LICENSE-MIT) 規範。該 MIT 授權僅適用於模型資產及其附屬藝術作品,不適用於 BongoCat 原始碼或原生執行時期。 + + +## 🧭 技術架構 + +> 目前原生版本基於 C/C++、SDL3 和 OpenGL 建構。下圖著重於執行時期資料流;建置與打包細節請參閱 CMake。 + +### 🔄 執行時期所有權與影格排程 + +每個行程擁有一個 `BongoCatApp` 和一個主執行緒事件與渲染迴圈。平台監聽器僅作用於輸入邊界: + +```text +平台監聽器 +(鍵盤/指標) + | + v + C11 輸入狀態 +(原子邊緣佇列 + 合併指標位置) + | + v + 主執行緒應用程式 <----- SDL3 事件 + | + v + 模型參數、覆疊層與 UI 狀態 + | + v + 模型更新 -> OpenGL 合成 -> 平台呈現 +``` + +Windows Raw Input 接收器、macOS Quartz 事件監聽,以及 Linux XInput2 監聽器均執行於主迴圈之外。按鍵和滑鼠按鈕邊緣事件進入有界原子佇列,移動量獨立合併,並透過 SDL 事件喚醒主執行緒。Windows 使用訊息專用視窗,以 `RIDEV_INPUTSINK | RIDEV_DEVNOTIFY` 訂閱背景鍵鼠輸入並保留一般視窗訊息;其他程式隱藏或鎖定游標時,模型使用裝置回報的移動量,桌面跟隨則讀取 SDL 提供的系統游標位置。裝置拔除或輸入桌面切換時會清理按下狀態。Windows 不再安裝輸入鉤子或使用 DirectInput,也不向遊戲傳送輸入。SDL3 視窗、偏好設定和遊戲控制器事件仍在主執行緒處理,平台監聽器不會直接呼叫 Live2D、覆疊層或 UI 程式碼。 + +`bongo_cat_app_run` 負責處理更新、關閉和輔助行程參數,為主要行程強制執行單一實例所有權,分配應用程式狀態,執行初始化,進入 `bongo_cat_app_loop`,然後依序清除狀態並釋放資源。初始化會載入配置和儲存路徑、定位資產、建立 SDL/OpenGL 寵物視窗、初始化平台後端、建立 Live2D、覆疊層和音訊服務、掃描內建/已安裝/鄰近模型來源,並載入可用的模型。`BongoCatApp` 擁有設定、工作階段狀態、模型與行為目錄、平台控制代碼,以及執行時期服務控制代碼。 + +已安裝的模型套件使用 Mver 作為標準格式。匯入流程會解析選取的檔案或目錄、探索並驗證候選項目、指紋辨識套件識別、將 Tauri 來源轉換為 Mver、套用影像修補,並將標準化套件提交至 `models_root`,接著產生執行時期轉接器並重新整理目錄。鄰近來源則在不安裝其原始碼樹的情況下被探索;其轉接器和檢查結果會快取在 `models_root` 之外的 `cache_root` 下。 + +每次主迴圈迭代會等待 SDL/原生喚醒事件,或最早待處理的影格、UI、動畫或指標點擊期限(最長等待 250 毫秒)。它會分派佇列中的 SDL 事件、清空原子輸入佇列與釋放復原、更新視窗與模型重新整理狀態,並套用輸入衍生參數。啟用 Cubism 時,模型期限遵循 `settings.model.max_fps`(預設 60 FPS);診斷版本則使用 100 毫秒的備用間隔。模型經過時間上限為 250 毫秒,並分割為最多八個子步驟,每個子步驟不超過 1/30 秒。 + +一般寵物路徑僅在視窗可見、未最小化且標記為髒污時才進行渲染。每個影格會清除背景、繪製模型,並合成指標、按鍵與特效覆疊層,最後呼叫平台呈現器。預覽操作可要求立即渲染,而擷取渲染則可能略過呈現。macOS 和 Linux 直接交換 SDL OpenGL 視窗。Windows 在圖層呈現未啟用時直接交換,否則會讀回影格供 `UpdateLayeredWindow` 使用。偏好設定 UI 擁有獨立的 SDL/OpenGL 視窗,且獨立於寵物視窗進行渲染和呈現。 + +C 執行時期會呼叫 `include/bongo_cat/model.h` 中宣告的 ABI。Live2D 橋接與 Cubism 實作位於 `src/live2d`,且僅在啟用 Cubism SDK 時使用 C++17;其餘原生執行時期則使用 C11。Cubism 類型保留在不透明的 C 控制代碼之後,而 `src/live2d/live2d_stub.c` 則在 SDK 不可用時提供診斷後端。 + + +```mermaid +flowchart TB + Input(["鍵盤 / 滑鼠 / 遊戲控制器"]) + BuiltIn(["內建模型資產"]) + Sources(["外部模型來源
Mver、Tauri、.model3.json、影像修補"]) + Desktop(["寵物視窗與偏好設定視窗"]) + + subgraph Runtime["BongoCat 原生執行時期"] + direction TB + Entry["src/main.c
bongo_cat_app_run"] + Startup["啟動與初始化
配置、儲存、視窗、平台"] + Loop["SDL3 主迴圈
等待、分派、更新、渲染"] + Shutdown["關閉
清除狀態、停止服務、釋放資源"] + InputQueue[("原子輸入狀態
邊緣佇列與合併指標位置")] + InputDispatch["輸入分派
快捷鍵、指標對應、模型參數"] + State[("BongoCatApp 狀態
設定、工作階段、目錄、執行時期控制代碼")] + Import["模型探索與匯入
驗證、正規化為 Mver、安裝/快取"] + Catalog[("模型與行為目錄")] + Live2D["Live2D C ABI
Cubism SDK 或診斷存根"] + Overlay["覆疊層與音訊"] + Preferences["偏好設定與桌面殼層
Nuklear UI、系統匣、視窗動作"] + Compose["OpenGL 影格合成"] + Present["平台呈現"] + + Entry --> Startup --> Loop + Loop --> Shutdown + Loop --> InputDispatch --> State + Loop <--> State + State --> Live2D + State --> Overlay + State <--> Preferences + Loop --> Preferences + Catalog --> State + State --> Compose + Live2D --> Compose + Overlay --> Compose + Compose --> Present + Loop --> Compose + end + + subgraph Platform["平台後端"] + direction LR + Global["全域鍵盤/指標擷取
Windows、macOS、Linux"] + SDL["SDL3 事件
視窗與遊戲控制器事件"] + end + + Input --> Global --> InputQueue --> InputDispatch + Input --> SDL --> Loop + BuiltIn --> Catalog + Sources --> Import --> Catalog + Present --> Desktop + Preferences --> Desktop +``` + +## ❓ 常見問答 + +### 🔒 BongoCat 會記錄我的鍵盤或滑鼠輸入嗎? + +不會。BongoCat 僅在本地處理鍵盤和滑鼠輸入,以驅動動畫和快捷鍵。它不會記錄或上傳您的按鍵、滑鼠動作或其他互動資料。配置也僅儲存在本地,且應用程式不含廣告、分析工具或使用者追蹤程式碼。執行更新檢查時,僅會請求公開的發行版本元資料;不會傳送輸入、配置或使用資料。 + +### 🖼️ 為什麼選擇 OpenGL 而非 Vulkan? + +我們選擇 OpenGL 並非因為 Vulkan 不好,而是 BongoCat 不需要那樣的複雜度。本應用主要渲染一個 Live2D 模型、少數 UI 層,以及一個透明桌面視窗。OpenGL 已能輕鬆應付,且與 SDL3 和 Cubism 的 OpenGL 渲染器自然整合。轉向 Vulkan 意味著要在三個桌面平台上維護更多渲染和同步程式碼,但對使用者而言並不會有明顯改善。就 BongoCat 目前的工作負載而言,OpenGL 讓渲染器更小巧、更容易除錯和維護,同時仍能提供我們所需的效能。 + + + +## 專案狀態 +![Alt](https://repobeats.axiom.co/api/embed/74334755a589dea40c5d31f8d2bcdc6c2bd39d87.svg "Repobeats analytics image") + + +## 🙏 特別感謝 +> [!TIP] +> BongoCat 的每一步都得益於開源精神。我們衷心感謝所有社群貢獻者的無私奉獻(按貢獻日期先後排序列於下方)。正是你們的支持,讓桌面陪伴更加自由與真誠。❤️‍🔥 + + + + + + + +[linux.do](https://linux.do/t/topic/2845597) +--- + +
+ +Copyright © 2026 - **BongoCat**\ +由 vladelaina 製作\ +以 ❤️ 和 ⌨️ 打造 + +
diff --git a/docs/feature-audit.md b/docs/feature-audit.md deleted file mode 100644 index e99ab028..00000000 --- a/docs/feature-audit.md +++ /dev/null @@ -1,103 +0,0 @@ -# Native feature audit - -This is the completion ledger for the independent native application. `PASS` -requires direct automated or visual evidence. `PENDING` means the implementation -exists but the required hardware or operating system has not been exercised. -A diagnostic drawing never counts as a Live2D visual pass. - -## Current evidence - -- Windows Cubism Release build: `build-delivery-final/Release/BongoCat.exe` - PASS. -- Native tests: `ctest --test-dir build-delivery-final -C Release` - PASS, 5/5. -- Source policy: `check-lines` - PASS; the default is 300 lines and reviewed - file-specific limits are declared in `cmake/CheckLines.cmake`. -- Preferences matrix: `build-cubism/visual-audit-preferences/audit.csv` - PASS, 50/50. -- Real Live2D matrix: `build-cubism/visual-audit-cubism/audit.csv` - PASS, 14/14. -- Pointer/mouse-mirror matrix: `build-cubism/visual-audit-mouse/audit.csv` - PASS, 5/5. -- Visible Cubism memory baseline: PENDING remeasurement on the current Release - build; working set and private committed memory are reported separately. -- Standalone smoke: 22 embedded shaders, `renderer=cubism-native`, GL error 0. -- Release ZIP and SHA-256 sidecar generation - PASS. -- Cubism Native SDK under `vendor/CubismSdkForNative` - PASS, SDK 5 r.5. -- Tauri-parity pointer mapping and frame-rate-independent damping - PASS. - -## Models and rendering - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Three bundled model packages | PASS | Model tests validate MOC3, textures, settings, motions and expressions. | -| Background and key overlays | PASS | Standard, keyboard and gamepad idle/input screenshots match their intended composition. | -| Custom model import/remove | PASS | Valid import/remove smoke passes; malformed and unsafe manifests are rejected. | -| Model selection | PASS | Model catalog tests plus three real-model startup screenshots. | -| Real Cubism model rendering | PASS | All models report `renderer=cubism-native`; screenshots show the expected Tauri-parity composition. | -| Masks, blending and drawable order | PASS | Cubism r.5 renders alpha, masks, face, paws and overlays without OpenGL errors. | -| Motion playback and fades | PASS | Both bundled motions are accepted and differ from idle by 85.63% and 2.11%. | -| Expression playback | PASS | Expression 0 is accepted and differs from the same-run idle baseline. | -| Motion audio | PENDING | Audio loading is implemented; a manual audible check remains. | -| Mirror visual result | PASS | Real-model mirror differs from idle by 41.95%. | - -## Input and behavior - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Keyboard left/right hands | PASS | Individual and simultaneous real frames differ from idle. | -| Mouse left/right buttons | PASS | Runtime parameters pass and real frames differ from idle. | -| Gamepad buttons and four axes | PASS | Runtime parameter assertions and real-frame differences pass. | -| Global Windows keyboard input | PASS | Injected events reach the native hook and alter overlays. | -| Global Windows mouse input | PASS | Injected down/up events reach the native input queue. | -| Global Windows pointer movement | PASS | Low-level hook coordinates, smoothing and parameter mapping are covered. | -| Pointer-driven Live2D tracking | PASS | Frames differ by 8.74%/9.18%; seven parameters and mirrored X/Z directions pass. | -| Windows automatic key release | PASS | Core input tests cover scheduled releases. | -| Gamepad hot plug | PENDING | SDL implementation exists; physical-device testing remains. | -| Behavior shortcuts | PENDING | Parsing and dispatch exist; a configured global shortcut smoke remains. | -| macOS input permission | PENDING | Native implementation exists; macOS manual verification remains. | - -## Window and application - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Transparent borderless main window | PASS | Win32 caption, frame, menu and caption buttons are removed. | -| Preferences window | PASS | 50 screenshots contain no blank frames. | -| Click-through and always-on-top | PASS | Native style flags pass the window smoke. | -| Hide on hover | PASS | Enter, hide, pass-through, leave and restore are asserted. | -| Keep inside monitor | PASS | Off-screen placement is clamped in the geometry smoke. | -| Menu scale and opacity | PASS | Native size and opacity values are asserted. | -| Shift plus right-drag resize | PASS | Synthetic gesture verifies scale, size and release. | -| Capture window presence | PENDING | The pet is hidden from the taskbar/Dock while remaining eligible for OBS window capture; Windows is implemented and macOS/Linux need platform exercise. | -| System tray | PASS | Native SDL tray handle and state callbacks are asserted. | -| Right-click context menu | PASS | Production Win32 popup opens Preferences. | -| Single instance | PASS | A second process exits while the first remains alive. | -| Autostart | PENDING | Platform implementations exist; OS login testing remains. | -| Hidden rendering suspension | PASS | Main loop skips model updates while hidden. | - -## Preferences, localization and data - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Cat, general, model, shortcuts and about pages | PASS | Current 50-view matrix. | -| Model card grid and direct-drawn icons | PASS | Responsive cards are present in both themes. | -| Simplified/Traditional Chinese, English, French, German, Japanese, Korean, Portuguese, Russian and Spanish | PASS | All ten locales pass shape, glyph, persistence and settings-window smoke checks. | -| Light and dark themes | PASS | Both themes are covered for every page and language. | -| System theme | PASS | Auto mode resolves through the native SDL system theme. | -| Click and drag model import | PASS | Both paths use the validated shared importer. | -| Atomic configuration persistence | PASS | Tests include truncated-write recovery. | -| No Electron or Tauri dependency | PASS | Native source/config scan is clean. | - -## Performance and platform gates - -| Requirement | Status | Evidence or remaining proof | -|---|---|---| -| Working set below 100 MiB | PENDING | Repeat the current Release measurement with working set and private committed memory reported separately. | -| Diagnostic idle CPU | PASS | Visible average 0.0324%; hidden average 0.0182%. | -| Cubism visible resource use | PASS | At the parity 60 FPS default: 0.94% total CPU on the current 4-CPU environment. | -| Five-minute bounded-memory runs | PASS | Visible, preferences and hidden modes remain bounded. | -| 24-hour bounded-memory run | PENDING | The harness exists; an uninterrupted result remains. | -| Windows 10 and 11 | PENDING | Current Windows passes; a second supported version remains. | -| macOS x64 and arm64 | PENDING | Requires macOS runners and signed application builds. | -| Linux X11 | PENDING | Requires a Linux runner with XInput2 and Xfixes. | -| Linux Wayland reduced-mode reporting | PENDING | Requires a Wayland session. | - -The Windows implementation has no remaining Cubism or visual blocker. The -remaining `PENDING` rows require a current memory rebaseline, external -hardware, long-duration execution, or operating systems that are not present -on the current machine. diff --git a/docs/modal-frame-architecture.md b/docs/modal-frame-architecture.md deleted file mode 100644 index 2e0c9858..00000000 --- a/docs/modal-frame-architecture.md +++ /dev/null @@ -1,40 +0,0 @@ -# Modal menu frame ownership - -Native context menus run a nested platform event loop. The SDL application loop -does not advance while one is open, so animation must be pumped explicitly. - -The dependency direction is intentionally one way: - -```text -native modal timer -> BongoCatModalTick -> runtime/modal_frame -> input + Live2D + render -``` - -Ownership boundaries: - -- `runtime/modal_frame.*` owns elapsed-time continuity and the work performed by - a modal frame. It is the only modal-related module allowed to coordinate input, - Live2D, and rendering. -- `runtime/window_menu_preview.c` owns temporary context-menu previews. It - delegates animation to the modal frame pump. -- `runtime/tray.c` owns tray state and supplies a modal frame callback. It does - not know how a native menu loop is implemented. -- `platform/windows_borderless.c` owns the desktop menu timer and highlight - messages. Its callback state is attached to the individual window. -- `platform/windows_tray.c` owns SDL tray-window subclassing and the tray menu - timer. Its callback state is attached to the individual tray window. -- Native window dragging uses the same pump while the operating system owns the - nested move loop. -- `platform/macos.m` schedules the same callback in the menu run-loop mode. - SDL's Linux tray is asynchronous; the custom X11 desktop menu calls the - callback from its own loop. - -Invariants for future changes: - -1. Platform menu modules must not include Live2D or application runtime state. -2. Modal ticks must update `app->last_frame_ns`, preventing a large catch-up - step when the SDL loop resumes. -3. The pump must drain model input without dispatching shortcuts before advancing Live2D. -4. Every native timer and subclass must be removed before its callback userdata - is destroyed. -5. Desktop and tray menus must both pass a held-open test with continuing frame - timestamps and different image hashes after pointer movement. diff --git a/docs/model-format-audit-2026-07-24.md b/docs/model-format-audit-2026-07-24.md deleted file mode 100644 index 32ee5e18..00000000 --- a/docs/model-format-audit-2026-07-24.md +++ /dev/null @@ -1,126 +0,0 @@ -# Tauri / Bongo-Cat-Mver 模型格式审计 - -日期:2026-07-24 -原样结构支持更新:2026-07-25 -产品:BongoCat 0.1.0 - -## 结论 - -BongoCat 现在以同一条事务式导入流水线支持三类输入: - -- Tauri/普通 Live2D v3 模型目录。 -- 带 `config.json` 与 `img/` 的完整 Bongo-Cat-Mver 模型包。 -- 只有图片差异的 Mver patch 包,例如“Z-无按键显示”和“Z-有按键显示”。 -- 放在可执行文件同目录(或其一级子目录)的完整 Mver 原生目录。 - -真实 Tauri、完整 Mver 和两个 Z patch 均会安装 `standard`、`keyboard`、 -`gamepad` 三个模型,并逐个通过 Cubism 原生运行时加载。导入后的 `payload` 是用户模型目录的 -逐文件原样副本,BongoCat 生成的预览、输入贴图、媒体和元数据只写入并列的 `adapter` 目录;不会向 -用户源目录或 `payload` 增删、改名、合成或覆盖文件。无法安全跨机器迁移的固定画布坐标、 -工作区坐标和设备精灵几何会写入 -`.bongo-cat-import-report.json`,不再被静默忽略。 - -项目尚未发布,因此用户数据目录中的自定义模型不提供旧统一目录布局兼容或自动迁移;没有当前 -版本旁路描述文件的目录仍会被扫描器忽略。作为明确的生态兼容入口,可执行文件旁的完整 Mver -目录会直接作为只读源加载,BongoCat 只在用户数据的 `portable-mver` 中缓存合成图片、行为元数据和 -兼容报告,不复制、重命名或修改原目录。内置模型仍由只读的预设扫描路径直接加载。 - -## 格式契约 - -| 类型 | 必要结构 | 结果 | -|---|---|---| -| Tauri / Live2D | 唯一合法 `.model3.json` 及其必需引用 | 导入一个或多个模型 | -| 完整 Mver | `config.json`、`img`、合法 mode 配置、编号资源和 Live2D manifest | 每个合法 mode 导入一个模型 | -| Mver patch | 唯一 patch `img` 根及唯一同名完整基础包 | 分别原样保存 base/patch,由 adapter 按文件覆盖语义解析 | -| 运行目录 Mver | 可执行文件旁的完整 Mver 根,或包含完整包/图片补丁的模型容器 | 直接引用 Live2D 源文件,只缓存 BongoCat adapter | - -同目录多 manifest、多个 patch 根、无基础包或多个基础包都被拒绝。图片 patch 不会被伪装成 -可独立运行的模型。 - -## 已映射能力 - -- Live2D v3 MOC、纹理、Physics、Pose、DisplayInfo、Expressions 和 Motions。 -- Tauri 与 Mver 的预览图、背景和三种 mode。 -- Windows VK 到 BongoCat 跨平台快捷键名称的转换,包括组合键和左右修饰键。 -- `input_mode=1` 的 Mver 手柄按钮;`input_mode=0` 保持键盘协议。 -- standard/keyboard/gamepad 的左右手编号图片,支持稀疏编号并报告缺项。 -- `CAT_motion`、`CAT_motion_lock` 和 expression 的逐索引严格映射。 -- Mver `face` 图片表情及 `emoticonKeep` / `emoticonClear`。 -- Mver 独立音频及 `soundKeep` / `soundClear`;关闭 keep 时松键停止。 -- 不同尺寸 straight-alpha 图片合成,不再按最小画布裁切。 -- Z patch 的逐文件覆盖和基础资源继承。 - -## 明确降级 - -以下字段依赖 Mver 固定 1400x1400 SFML 画布、Windows 桌面坐标或其专用手臂曲线,不能直接 -持久化到跨平台模型: - -- `window_size`、`topWindow`、`workarea`。 -- `offsetX/Y`、`scalar`、`hand_offset`。 -- `l2d_offset`、`l2d_correct`。 -- `leftHanded`、`mouse_force_move`。 -- standard 的 `arm/up/mouse/tablet` 动态设备精灵几何。 -- gamepad 的 `stick_offset_L/R`。 - -BongoCat 对应功能继续由用户的窗口、鼠标镜像、模型缩放和手柄轴设置控制。导入报告会逐模型记录 -这些降级项、原因、能力布尔值、声明/可用/缺失资源数以及可选 motion Sound 缺失数。 - -## 审计 TODO - -### P0:协议和行为正确性 - -- [x] M01 固化三类格式契约和稳定的失败规则。 -- [x] M02 将 Z 目录实现为显式 patch 工作流,禁止独立伪模型。 -- [x] M03 校验编号资源、损坏 PNG 和稀疏资源,并输出数量诊断。 -- [x] M04 用 `input_mode` 决定手柄/键盘协议,覆盖低 VK 值和组合键。 -- [x] M05 精确映射 `CAT_motion` / `CAT_motion_lock`,校验配置行数。 -- [x] M06 统一 motion Sound、独立音频、全局开关和 keep/clear 语义。 - -### P1:Mver 可见行为 - -- [x] M07 支持图片 `face`、保持、清除和 momentary 松键恢复。 -- [x] M08 审计鼠标/数位板资源;固定画布设备几何改为显式降级报告。 -- [x] M09 对可移植状态做映射,对机器相关状态做逐字段报告。 -- [x] M10 预检全部 manifest 必需引用;缺失 motion Sound 作为可选诊断。 -- [x] M11 同目录多 manifest 明确拒绝,递归候选去重并稳定排序。 -- [x] M12 限制向上找包边界,patch 基础包必须唯一。 -- [x] M13 修复不同尺寸合成裁切,并测试半透明与透明空白像素。 -- [x] M14 保持 staging/rename/rollback 事务,启动时安全清理本应用临时目录。 -- [x] M15 元数据只接受版本 1,只补缺失默认绑定并保留用户覆盖。 - -### P2:测试、跨平台和组织 - -- [x] M16 增加纯 C 的 Mver chord/device 协议测试,并保留端到端安装测试。 -- [x] M17 增加环境变量驱动的可选真实样本测试入口。 -- [x] M18 对 Tauri、完整 Mver、两个 Z patch 逐模型执行 Cubism smoke。 -- [x] M19 统一 CTest 命令、测试计数和审计文档。 -- [x] M20 曾将当时的本地审计脚本纳入 300 行门禁并拆分超长脚本;这些脚本现已移出仓库。 -- [x] M21 将 discovery、patch、shortcut、motion、effect、metadata、report、storage 分层。 -- [x] M22 将 Mver 的 Windows VK 解析限制在 adapter,运行时只接收 BongoCat 输入名。 -- [x] M23 清理历史命名噪声;旧 schema 测试变量不再使用 `legacy_path`。 -- [x] M24 将原模型与 BongoCat 适配产物分离为 `payload` / `adapter`,并验证源目录未变化。 -- [x] M25 移除未发布旧自定义目录布局的兼容扫描,只接受当前旁路描述文件。 -- [x] M26 按 Mver 原版相对路径习惯自动发现运行目录模型(包括嵌套的完整包与图片补丁),并保持源目录只读。 -- [x] M27 对原版常见的“配置绑定多于 manifest 入口”仅截取可用入口;手动导入仍保留严格校验。 - -## 自动化证据 - -- `model-import-unit`:VK、组合键、`input_mode`、手柄映射,以及运行目录 Mver - 直接/嵌套容器、图片补丁发现和 adapter 缓存测试。 -- `model-import-formats`:32 个合成端到端场景,包括歧义、回滚、patch、稀疏资源、 - momentary、清除、缺失可选音频、非法 chord 和不同尺寸合成。 -- `model-import-real-samples`:由 `BONGO_CAT_TAURI_SOURCE`、 - `BONGO_CAT_MVER_SOURCE`、`BONGO_CAT_MVER_PATCH_SOURCES` 驱动;未配置时明确 Skip。 -- 合成与真实样本审计会比较导入前后源目录签名,并验证安装 payload 的相对路径和 SHA-256 - 与对应 Tauri、Mver、base、patch 源目录完全一致。 -- Windows 多配置生成器的标准命令为: - `ctest --test-dir build-cubism -C Release --output-on-failure`。 - -真实样本结果:Tauri 3/3、A-露西亚 3/3、Z-无按键显示 3/3、Z-有按键显示 3/3, -安装、报告生成和逐模型 Cubism 加载均通过。 - -## 外部门禁 - -本次 Windows 审计不能替代 Linux/macOS 的原生构建,也不能模拟真实手柄热插拔、不同桌面缩放 -和声卡故障。相关代码只使用 SDL、标准 C 和 yyjson 的跨平台接口;这些环境仍应由对应系统的 CI -和真实硬件回归承担。 diff --git a/docs/parity.md b/docs/parity.md deleted file mode 100644 index 63bf31ad..00000000 --- a/docs/parity.md +++ /dev/null @@ -1,150 +0,0 @@ -# Native completion contract - -The standalone native application is complete only when each automated check -and the matching manual scenario pass without depending on another frontend or -desktop runtime. - -## Model and rendering - -- Load all three bundled Cubism `model3` models. -- Import and remove custom model directories without changing their layout. -- Render drawable order, masks, blend modes, opacity, multiply and screen color. -- Run `motion3` motions, `exp3` expressions, fade timing, and motion audio. -- Preserve mouse, keyboard, and gamepad parameter mappings. -- Draw the model background and at most one key overlay per hand. -- Preserve mirror, mouse mirror, model scale, opacity, radius, and FPS controls. - -## Input - -- Observe global keyboard press and release events. -- Observe global mouse buttons and coalesced pointer movement. -- Preserve Windows automatic release timing for system keys. -- Support SDL game controllers, sticks, triggers, and hot plug. -- Register behavior shortcuts and application shortcuts. -- Request the required macOS input-monitoring permission. - -## Window and application - -- Transparent, borderless, draggable main window with per-pixel alpha. -- Click-through, always-on-top, hide-on-hover, and keep-in-monitor behavior. -- Shift plus secondary drag resizing from 10 through 500 percent. -- Tray and context menus, capture-friendly taskbar/Dock suppression, and single instance handling. -- Restore window position and size across monitor and DPI changes. -- Autostart, project information, and application exit. - -## Preferences and data - -- General, cat, model, behavior, and shortcut preference views. -- Simplified Chinese, Traditional Chinese, English, French, German, Japanese, - Korean, Portuguese, Russian, and Spanish. -- Light, dark, and system themes. -- Persist native settings and custom models without external project paths. -- Build, test, run, and package with only files contained in this repository. -- Persist settings atomically and recover from a truncated write. - -## Release gates - -- Golden images for idle, key, mouse, controller, motion, and expression states. -- Recorded input traces produce equivalent parameter values in old and new apps. -- Windows 10/11, macOS x64/arm64, and Linux X11 manual matrix passes. -- Wayland reports reduced global-input support instead of silently failing. -- Idle CPU is below 0.3 percent on the reference machine. -- Working set is below 100 MiB with one bundled model active. -- Hidden rendering is suspended and a 24-hour run has no unbounded growth. -- Every hand-written file passes the configured source-size policy, whose - default is 300 physical lines with reviewed file-specific limits. - -## Cubism Viewer animation equivalence - -Live2D Cubism Viewer 5.3.03 is the animation and interaction baseline for every -model. Motion curves, expression blending, physics, eye blink, pose, idle -motion, and pointer-driven look updates use the official Cubism Framework -order and behavior. Source format never selects a second animation runtime. -Viewer mouse tracking feeds its configured `ParamAngleX`, `ParamAngleY`, -`ParamEyeBallX`, `ParamEyeBallY`, and, when present, `ParamBodyAngleX` channels -through the Viewer's own `TargetPoint` variant before physics consumes them at -the `physics3` authored FPS. That variant uses a `7.2727275 / 30` maximum target -speed, a 0.15 second acceleration time, a pixel-scale desktop dead zone, and -the Viewer's additional 1.2 input gain. Parameter scaling selects the authored -positive or negative extent from the parameter's value immediately before the -additive update; all five default mappings use weight 1.0. Tracking follows -pointer movement continuously and keeps the Viewer's TargetPoint response -curve without requiring a mouse button. Primary and secondary button state -remains independent for model-authored hand parameters. Viewer-default -automatic Idle playback is enabled and avoids immediately repeating the same -Idle motion when alternatives exist. -Desktop tracking is model-centered by default, so look direction changes only -when the pointer crosses the pet's visible center. Users can disable the -setting to retain the legacy screen/work-area-centered coordinate mapping. -In model-centered mode, the authored hand and device range maps absolutely to -the full display containing the pet; legacy mode retains Mver relative input. -The display is selected from the pet's visible center. Negative-coordinate, -stacked, and mixed-DPI layouts retain desktop coordinates, and display mode, -topology, or scale changes invalidate and immediately resample the mapping. -`ParamAngleZ` and model-specific `ParamMouse*` channels are not synthesized by -the Viewer-equivalent path. - -Mver 0.1.6 remains the compatibility baseline for package discovery, imported -files, configuration, shortcuts, window composition, and projection only. A -Tauri or standalone Live2D model is imported through the same adapter and then -runs through the Viewer-equivalent Cubism animation path. - -Mver imports retain the source package's `l2d_correct`, `window_size`, -`l2d_offset`, and horizontal-follow values in `.bongo-cat-mver.json`. The native -renderer uses the Mver 0.1.6 projection order for those models. Expressions keep -the authored canvas projection so the Live2D model remains aligned with the -background and input layers. The compatibility matrix also converts the -modern Cubism Core canvas units to Mver 0.1.6 units while retaining authored -Layout translation and `l2d_offset`. Its horizontal option controls following, -not model mirroring. Window resizing follows the imported reference aspect -ratio. Pointer-driven head, body, eye, and physics parameters use Mver's -configured `workarea`, or its DPI-virtualized primary-screen coordinate domain -when no custom work area is enabled. - -The Mver adapter translates the horizontal target sign and work-area mapping -before the values reach the shared Viewer-equivalent look stage. It does not -alter Cubism motion curves, expression blending, physics, or look timing. - -## Cubism Viewer blind test - -The `viewer-sequence` CI scenario captures model-only Native frames and key -parameters at track and release samples `1, 2, 4, 8, 15, 30`. It does not use -desktop screenshots, so window decoration and desktop overlays cannot affect -the Native side. Reference Viewer screenshots are compared with: - -```powershell -cmake\RunCubismViewerBlindTest.ps1 ` - -ViewerDirectory build-delivery-final\viewer-studio-audit\viewer ` - -NativeDirectory build-delivery-final\viewer-studio-audit\native-run\cubism-viewer-audit\native ` - -OutputDirectory build-delivery-final\viewer-studio-audit\blind-test-final -``` - -The tool removes transparent, white, and black backgrounds, rejects detached -desktop overlays, and normalizes each source with its baseline model bounds. -It writes anonymous randomized A/B ballots, a separate answer key, normalized -frames, and metrics for the full model, face, hair, and hands. Dynamic metrics -also report each side's change from `track-000`, preventing a high static image -score from hiding a different interaction response curve. - -The blind-test entry point is a PowerShell wrapper around the native C target -`cubism_viewer_blind_test`; build it with: - -```powershell -cmake --build build-delivery-final --config Release --target bongo_cat_validation_tools -``` - -The standalone C metric tools are `mver_blind_metrics` and -`mver_phase_metrics`. The former accepts two image paths and optional mask and -threshold arguments; the latter accepts two frame directories and searches -their phase lag. The Viewer capture helpers (`cubism_viewer_desktop_capture` -and `cubism_viewer_drag_capture`) are also C targets and keep their existing -command-line arguments and trace formats. - -## Live2D texture quality - -Live2D atlases retain their authored pixel dimensions for both bundled and -imported models. The loader only downsamples an atlas when it exceeds the -active GPU's `GL_MAX_TEXTURE_SIZE`, or retries at 2048 pixels after an explicit -`GL_OUT_OF_MEMORY` upload failure. Model textures use the same linear, -non-mipmapped sampling as Mver so changing the pet window size does not switch -filtering behavior or silently select a lower-resolution source. diff --git a/docs/performance-audit-2026-07-29.md b/docs/performance-audit-2026-07-29.md deleted file mode 100644 index 52f00124..00000000 --- a/docs/performance-audit-2026-07-29.md +++ /dev/null @@ -1,83 +0,0 @@ -# Performance and package audit — 2026-07-29 - -## Scope - -This pass reduces CPU, memory, GPU texture storage, and package size without -changing pixels, animation timing, frame rate, source resource quality, or -supported Windows/macOS/Linux behavior. - -## Implemented changes - -- Shadow masks use `GL_R8` with RGB=`GL_ONE`, A=`GL_RED` swizzling instead of - storing redundant white RGB channels in `GL_RGBA8`. -- Same-color radial masks use the same single-channel path and retain the - original color through draw tinting. -- Paint texture ownership/upload was separated into `ui_paint_cache` so paint - algorithms and GPU cache policy remain independently maintainable. -- Preferences CPU buffers changed from 512/128 KiB to 256/96 KiB after 232 - captured frames showed historical peaks of 100,300/36,324 bytes. Full - interaction peaked at 47,920/16,458 bytes. -- Four unreferenced legacy assets are excluded from Windows, macOS, and Linux - runtime packages while remaining available in the source tree. -- Visual audit capture now moves the physical pointer outside the window to - remove non-deterministic hover-state noise. - -## Measured results - -| Metric | Before | After | Result | -|---|---:|---:|---:| -| Preferences paint cache | 9,430,040 B | 3,367,808 B | -6,062,232 B (-64.3%) | -| Runtime-flow peak working set | 156.051 MiB | 143.973 MiB | -12.078 MiB | -| Runtime-flow peak private memory | 126.188 MiB | 114.707 MiB | -11.481 MiB | -| Preferences soak working set | 136.207 MiB | 131.574 MiB | -4.633 MiB | -| Preferences soak private memory | 112.555 MiB | 107.773 MiB | -4.782 MiB | -| Hidden soak working set | 107.715 MiB | 102.922 MiB | -4.793 MiB | -| Hidden soak private memory | 94.586 MiB | 92.383 MiB | -2.203 MiB | -| Active settings CPU, 3-run mean | 182.292 ms | 156.250 ms | -14.3% | -| Idle settings CPU, 3-run mean | 0.344% | 0% | no idle work | -| Embedded PAK | 2,245,899 B | 2,228,107 B | -17,792 B | -| Windows executable | 5,456,384 B | 5,438,464 B | -17,920 B | - -## Visual and behavioral parity - -- Fixed-input old/new comparison: 10/10 preferences captures were exactly - identical across 6,120,000 compared pixels; MAE=0 and RMSE=0. -- Visual matrix: 53/53 views passed. -- Overlay matrix: 11/11 views passed; all 8 input scenarios passed. -- Preferences scenarios: 12/12 passed. -- Navigation: all 5 pages passed; P90 frame time 8.67 ms, max 10.29 ms. -- Focus audit passed, including dragging on the first press after focus loss. -- Cursor audit passed for all 11 cursor targets. -- Context menu audit passed with Settings first and all nested labels valid. -- Runtime flow passed all 17 stages with no handle growth or warm recovery - memory growth. - -## Cross-platform and risk decisions - -- Shared rendering remains portable C/OpenGL 3.3; macOS keeps its OpenGL 4.1 - core context and the same R8/swizzle semantics. -- MSVC and MinGW Release builds enforce the 300-line policy. -- Runtime/package exclusions are mirrored in Windows PAK creation, macOS bundle - staging, and Linux installation. -- UPX and whole-pack runtime compression remain intentionally rejected because - they increase antivirus, signing, startup, and peak-memory risk. -- PNG and FLAC data were not recompressed: the current assets are already - compressed, and no verified pixel/PCM-identical optimizer is part of the - toolchain. Exact duplicate payloads remain deduplicated by the asset packer. - -## Evidence - -- `build-final/optimization-paint-r8-final` -- `build-final/visual-parity-paint-r8-after-stable` -- `build-final/optimization-visual-final` -- `build-final/optimization-overlay-final` -- `build-final/optimization-preference-scenarios-final` -- `build-final/optimization-navigation-final` -- `build-final/optimization-focus-final` -- `build-final/optimization-cursor-final` -- `build-final/optimization-context-menu-final` -- `build-final/optimization-soak-visible` -- `build-final/optimization-soak-hidden-final` -- `build-final/optimization-soak-preferences` -- `build-final/optimization-runtime-flow` -- `build-final/optimization-startup-performance` diff --git a/docs/startup-reliability-audit-2026-07-26.md b/docs/startup-reliability-audit-2026-07-26.md deleted file mode 100644 index 2c36132f..00000000 --- a/docs/startup-reliability-audit-2026-07-26.md +++ /dev/null @@ -1,147 +0,0 @@ -# Startup and preferences reliability audit - updated 2026-07-29 - -## Scope and outcome - -This audit covers the standalone native release on the available Windows host, -with emphasis on double-click startup, damaged-data recovery, first-frame -validity, preferences behavior, transparent rendering, resource stability and -portable paths. The audited binary is: - -- `build-final/Release/BongoCat.exe` -- Source baseline before this final audit set: `378fd43` -- SHA-256: `F924800D504356AD30034E975A2B9610DE489A109C2A39739E99756BC0B79037` -- Size: 5,456,384 bytes - -The MSVC and MinGW Release configurations each pass 10/10 CTest cases. No -finite suite can guarantee startup on every possible machine, so signing, -other Windows versions and real macOS/Linux machines remain explicit gates. - -## Startup protections - -| Failure class | Implemented protection | -|---|---| -| A second launch appeared to do nothing | Notify the primary process, restore it on-screen and raise the window. | -| Hidden or off-screen saved state | Recover a visible on-screen main window for interactive launch. | -| Invalid or partially written settings | Strict versioned JSON parsing, defaults on corruption and atomic writes. | -| Damaged extracted assets | Verify embedded assets by size and SHA-256 and repair the cache. | -| Damaged custom Live2D model | Validate packages before install/load and fall back to a bundled model. | -| Unavailable global hooks | Continue startup and persist an actionable diagnostic. | -| Unsupported multisampling | Retry without MSAA and prove the resulting sample count is zero. | -| Fatal graphics initialization | Persist startup stage and error logs instead of failing silently. | -| Deep, spaced or Unicode paths | Use bounded UTF-safe path handling and long-path-aware Windows I/O. | -| Large portable model trees | Apply depth, directory and time budgets without recursive stack growth. | -| Interrupted model import | Isolate temporary imports and clean stale residue on startup. | -| Stale first-frame evidence | Wait for a newly completed audit file and retry transient sharing failures. | - -## Automated startup evidence - -The test set covers core, i18n, UI, app state, model import units and formats, -optional real samples, portable startup soak, the startup reliability matrix -and preferences navigation. - -The startup matrix independently verifies: - -- fresh extraction and a visible nonblank first frame with `gl_error=0`; -- actual normal MSAA values of `sample_buffers=1`, `sample_count=4`; -- forced fallback values of `sample_buffers=0`, `sample_count=0`; -- same-size embedded-asset corruption and cache repair; -- malformed settings and malformed model recovery; -- hidden and far off-screen window recovery; -- unavailable global hooks; -- bounded portable-directory scanning; -- spaces, Unicode and paths longer than the legacy Windows `MAX_PATH` limit; -- long-path cache reuse, cleanup and model import; -- persisted fatal OpenGL diagnostics; and -- second-instance wake-up from hidden and tray-closed states without hanging. - -All 30 PowerShell audit scripts parse successfully, `git diff --check` is clean -and the 300-line handwritten-source policy passes. - -## Preferences verification - -| Area | Result | Evidence | -|---|---|---| -| Visual matrix | PASS 53/53 | 50 preferences combinations plus 3 models | -| HTML reference parity | PASS | 98.4996% average pixel similarity; 97.9526% minimum page | -| Main interactions | PASS x6 | Toggle, scroll, navigation, combo, locale and shortcut editing | -| Sidebar navigation | PASS x10 | 50/50 clicks; all five pages selected every loop | -| Persistence | PASS | Locale, toggle, stepper and shortcut survive save/restart paths | -| Dialog/state scenarios | PASS 12/12 | Behavior, expression, Escape, outside-close, shortcut, locale, controls and toast | -| Pointer feedback | PASS 11/11 | Tabs, close, toggles, combo, shortcut, import/model cards and cancel | -| Focus recovery | PASS | First inactive-window press drags 56 x 32 px | -| Tray ordering | PASS | Preferences is the first tray entry | -| Localization fonts | PASS | Five locales, both themes, 2x oversampling, `1024x8192` atlas | - -Configuration is intentionally split by ownership rather than retaining Tauri -compatibility: user preferences and runtime session state are separately -versioned and atomically persisted. The interaction audit validates both file -formats and debounce behavior. - -## Rendering integrity - -The model overlay now emits premultiplied RGBA and uses independent color/alpha -blend factors. Preferences retain straight-alpha color blending with an -independent alpha channel. The software compositor uses the complete -source-over equation, and model textures use linear no-mipmap filtering aligned -with the original renderer. - -Across 11 model/input captures, each audited frame contains 2,149 to 2,361 -semi-transparent antialiased pixels and zero RGB-greater-than-alpha violations. -Standard, keyboard and gamepad models, left/right/both hands, releases, stress -and gamepad-button states all pass. - -## Performance and stability - -- Cold first valid frame: 1,192 ms. -- Seven warm launches: 511 ms average, 550 ms P95. -- Navigation settles in 235.1 ms, with 9.50 ms average, 15.20 ms P90 and - 16.76 ms maximum audited frame time. -- Active preferences animation uses about 19.5% CPU on this host; idle is 0%. -- Main 30-second soak trends about +0.38 MiB with one additional handle. -- Preferences 30-second soak trends about +0.17 MiB with no handle growth. -- Full runtime close/reopen/recovery flow stays below 170 MiB working set and - 140 MiB private bytes and shows no continuing growth. - -The larger font atlas is a deliberate quality/performance tradeoff. Baking -memory is temporary, runtime memory remains within measured budgets, and idle -rendering does not continue consuming CPU. - -## Windows release properties - -- x64 GUI executable with the static MSVC runtime; imported dependencies are - Windows system components. -- ASLR, high-entropy VA, DEP/NX and Control Flow Guard are enabled. -- Large application state and startup catalogs live on the heap. -- The executable is not Authenticode-signed. Signing remains required before - broad public distribution to establish provenance and reduce SmartScreen - warnings. - -## Cross-platform boundary - -Portable configuration, rendering and bounded/atomic I/O are shared. Native -window, input, tray and startup behavior remain isolated by platform. CI defines -warnings-as-errors build/test/package jobs for Windows x64, Ubuntu Linux x64 and -macOS. This Windows host cannot honestly certify other systems' compositor, -permissions, signing or GUI behavior; their CI and real-machine smokes must pass -for every release candidate. - -## Retained evidence - -- `build-final/visual-audit-final-current/audit.csv` -- `build-final/parity-antialias-current/page0.json` through `page4.json` -- `build-final/overlay-audit-final-current-3/overlay-audit.csv` -- `build-final/preference-scenarios-final-current/audit.csv` -- `build-final/preferences-navigation-stable/result.json` -- `build-final/preferences-interaction-robust-6/result.json` -- `build-final/preferences-performance-current-2/result.json` -- `build-final/focus-current/result.json` -- `build-final/cursor-current/result.json` -- `build-final/context-menu-current/result.json` -- `build-final/runtime-flow-current/runtime-flow-summary.csv` - -## Repeatable commands - -- `cmake --build build-final --config Release --parallel 2` -- `ctest --test-dir build-final -C Release --output-on-failure` -- `ctest --test-dir build-mingw-final -C Release --output-on-failure` -- `cmake -DROOT=. -P cmake/CheckLines.cmake` diff --git a/docs/system-audit-2026-07-17.md b/docs/system-audit-2026-07-17.md deleted file mode 100644 index 1bc93716..00000000 --- a/docs/system-audit-2026-07-17.md +++ /dev/null @@ -1,94 +0,0 @@ -# Systematic release audit - updated 2026-07-29 - -## Scope and result - -This audit covers the native cross-platform application on the available -Windows host: startup and recovery, Cubism rendering, transparent-window -composition, preferences parity and interaction, tray/context menus, focus and -dragging, configuration persistence, resource use, source policy and standalone -delivery. The audited release executable is: - -- `build-final/Release/BongoCat.exe` -- Size: 5,456,384 bytes -- SHA-256: `F924800D504356AD30034E975A2B9610DE489A109C2A39739E99756BC0B79037` - -MSVC Release and MinGW Release both build successfully and pass 10/10 CTest -cases. No failed automated check is being waived in this report. - -## Defects corrected - -| Area | Correction | Verification | -|---|---|---| -| Overlay edges could develop dark/bright halos | Emit premultiplied RGBA and use independent RGB/alpha blending | 11/11 overlay captures, zero premultiply violations | -| Preferences alpha accumulated incorrectly | Keep straight-alpha color blending while composing destination alpha independently | Visual, interaction and scenario matrices | -| Software layer composition ignored destination alpha | Implement the complete source-over equation | Overlay release/input captures | -| Model textures rendered black or with inconsistent filtering | Match Cubism/Pixi linear no-mipmap filtering and retain bounded anisotropy | Three bundled model captures, `gl_error=0` | -| Font edges were visibly coarse | Bake horizontal and vertical 2x oversampling | Multilingual `1024x8192` atlas and 53/53 captures | -| Requested MSAA was assumed rather than measured | Record actual SDL sample buffers/count and assert both normal and fallback paths | Normal `1/4`, fallback `0/0` | -| Sidebar and shortcut audits depended on fragile desktop injection | Drive SDL-compatible window/input paths and add an internal shortcut smoke event | Ten navigation loops and six full interaction loops | -| Heavy-load capture could sample a stale/black frame | Retry completed-frame reads and validate capture color/black ratios | Stable visual and interaction runs | -| Tray preferences entry was below window toggles | Put Preferences first and assert the first SDL tray entry | Tray self-test plus context-menu capture | -| Inactive-window dragging needed an activation click | Accept the first press after focus loss | First press moved the window 56 x 32 px | -| Resource budgets no longer reflected the high-quality font atlas | Re-measure and set runtime-flow ceilings to 170/140 MiB | Runtime reopen/recovery flow | - -## Automated evidence - -| Area | Result | -|---|---| -| MSVC Release tests | PASS - 10/10 | -| MinGW Release tests | PASS - 10/10 | -| Source policy | PASS - checked handwritten files are at most 300 lines | -| PowerShell audit syntax | PASS - 30/30 scripts parse | -| Preferences visual matrix | PASS - 50/50: 5 pages x 5 locales x 2 themes | -| Main model visual matrix | PASS - standard, keyboard and gamepad | -| Current HTML pixel comparison | PASS - 98.4996% average similarity; minimum 97.9526% | -| Preferences scenarios | PASS - 12/12 | -| Preferences interaction | PASS - toggle, scroll, navigation, combo, locale, shortcut, debounce, formats and persistence | -| Sidebar stress | PASS - 10 loops, 50/50 selections | -| Cursor hit regions | PASS - 11/11 | -| Overlay/input states | PASS - 11/11 | -| Premultiplied alpha | PASS - 2,149 to 2,361 antialiased pixels per audited frame, zero violations | -| Tray/context menu | PASS - Preferences is the first item | -| Focus recovery drag | PASS - movement occurs on the first press | -| Startup recovery matrix | PASS - corruption, paths, fallback, diagnostics and second instance | - -The pixel comparison uses the current native screenshots and the retained HTML -reference at 900 x 680. It is a measured visual-parity check, not a claim that -different operating-system text rasterizers will produce byte-identical pixels. - -## Performance and stability - -- Cold first valid frame: 1,192 ms. -- Warm first valid frame: 511 ms average, 550 ms P95. -- Preferences transition: 235.1 ms settled; 9.50 ms average frame, 15.20 ms P90. -- Preferences animation CPU: about 19.5%; settled idle CPU: 0%. -- Main 30-second soak: about +0.38 MiB trend, handles +1. -- Preferences 30-second soak: about +0.17 MiB trend, handles unchanged. -- Runtime flow, including settings close/reopen and model recovery, shows no - continuing growth; measured peak was 156.188 MiB working set and 126.125 MiB - private bytes, below the 170/140 MiB audit budgets. - -## Cross-platform boundary - -Shared behavior stays in portable SDL/OpenGL/C code. Windows-only focus, -startup and shell integration remain behind platform source boundaries; macOS -and Linux have their own platform units. CI defines warnings-as-errors builds, -tests and packages for Windows x64, Ubuntu Linux x64 and macOS. - -The available host proves the MSVC and MinGW Windows paths. Real macOS/Linux GUI -behavior, compositor differences, permissions, signing and packaging remain -release gates and must not be represented as locally executed tests. - -## Repeatable commands - -- `cmake --build build-final --config Release --parallel 2` -- `ctest --test-dir build-final -C Release --output-on-failure` -- `ctest --test-dir build-mingw-final -C Release --output-on-failure` -- `cmake -DROOT=. -P cmake/CheckLines.cmake` - -## External release gates - -- Authenticode/notarization and platform-specific signing. -- Real Windows version coverage beyond this host. -- Real macOS and Linux GUI/compositor smoke tests. -- Physical gamepad hot-plug, audible output and an uninterrupted long soak. diff --git a/docs/wechat.md b/docs/wechat.md new file mode 100644 index 00000000..d082cbad --- /dev/null +++ b/docs/wechat.md @@ -0,0 +1,3 @@ + + + diff --git a/docs/wechat.png b/docs/wechat.png new file mode 100644 index 00000000..2ca68c5e Binary files /dev/null and b/docs/wechat.png differ diff --git a/include/bongo_cat/app.h b/include/bongo_cat/app.h index b529964e..0ff15ea4 100644 --- a/include/bongo_cat/app.h +++ b/include/bongo_cat/app.h @@ -8,19 +8,42 @@ #include "bongo_cat/mouse.h" #include "bongo_cat/platform.h" #include "bongo_cat/shortcut.h" +#include "bongo_cat/sound_shortcut.h" typedef struct BongoCatAudio BongoCatAudio; typedef struct BongoCatTray BongoCatTray; typedef struct BongoCatOverlay BongoCatOverlay; typedef struct BongoCatPreferences BongoCatPreferences; typedef struct BongoCatI18n BongoCatI18n; +typedef struct BongoCatMultiPetRuntime BongoCatMultiPetRuntime; +typedef struct BongoCatModelRefresh BongoCatModelRefresh; +typedef struct BongoCatUpdateService BongoCatUpdateService; + +#define BONGO_CAT_MODEL_COVER_PENDING_CAP 8 typedef struct BongoCatApp { - BongoCatConfig config; + BongoCatSettings settings; + BongoCatSessionState session; BongoCatInputState input; + /* Main-thread-only cumulative input diagnostics. */ + struct { + uint64_t log_ms, drained_keys, ignored_keys, mapped_keys; + uint64_t unmapped_keys, no_model_keys, presented_frames; + } input_diagnostics; BongoCatShortcutState shortcut_state; + BongoCatSoundShortcutState sound_shortcut_state; + bool sound_shortcut_active[BONGO_CAT_BEHAVIOR_BINDING_CAP]; BongoCatModelCatalog models; BongoCatBehaviorCatalog behaviors; + /* One immutable installed package's behavior catalog can be reused when + the user toggles back to it. Nearby source models are excluded because + their files may change outside the application. Allocate it lazily so + single-model child processes do not reserve the catalog. */ + BongoCatBehaviorCatalog *behavior_cache; + char behavior_cache_model_id[BONGO_CAT_ID_CAP]; + char behavior_cache_digest[65]; + bool behavior_cache_valid; + bool behavior_catalog_valid; BongoCatLive2DRenderOptions model_render_options; BongoCatI18n *i18n; BongoCatPlatform platform; @@ -29,25 +52,59 @@ typedef struct BongoCatApp { BongoCatTray *tray; BongoCatOverlay *overlay; BongoCatPreferences *preferences; + BongoCatMultiPetRuntime *multi_pet; + BongoCatModelRefresh *model_refresh; + BongoCatUpdateService *update; SDL_Window *window; void *gl_context; - char preferences_path[BONGO_CAT_PATH_CAP]; + char settings_path[BONGO_CAT_PATH_CAP]; char session_path[BONGO_CAT_PATH_CAP]; + char config_root[BONGO_CAT_PATH_CAP]; char data_root[BONGO_CAT_PATH_CAP]; + char models_root[BONGO_CAT_PATH_CAP]; + char cache_root[BONGO_CAT_PATH_CAP]; + char state_root[BONGO_CAT_PATH_CAP]; + char log_root[BONGO_CAT_PATH_CAP]; + char storage_root[BONGO_CAT_PATH_CAP]; char asset_root[BONGO_CAT_PATH_CAP]; char locale_root[BONGO_CAT_PATH_CAP]; + char nearby_root[BONGO_CAT_PATH_CAP]; + char executable_path[BONGO_CAT_PATH_CAP]; + char primary_state_root[BONGO_CAT_PATH_CAP]; + char secondary_model_id[BONGO_CAT_ID_CAP]; char smoke_import_path[BONGO_CAT_PATH_CAP]; char smoke_model[BONGO_CAT_ID_CAP]; char smoke_runtime_model[BONGO_CAT_ID_CAP]; char smoke_live2d_scenario[BONGO_CAT_ID_CAP]; char smoke_viewer_trace[BONGO_CAT_PATH_CAP]; - char pending_model_cover_path[BONGO_CAT_PATH_CAP]; + char pending_model_cover_ids[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_ID_CAP]; + char pending_model_cover_paths[BONGO_CAT_MODEL_COVER_PENDING_CAP][BONGO_CAT_PATH_CAP]; + uint64_t pending_model_cover_retry_ns[BONGO_CAT_MODEL_COVER_PENDING_CAP]; + unsigned pending_model_cover_attempts[BONGO_CAT_MODEL_COVER_PENDING_CAP]; + size_t pending_model_cover_count; char loaded_model[BONGO_CAT_ID_CAP]; + char loading_model[BONGO_CAT_ID_CAP]; + BongoCatModelMode loaded_mode; bool running; - bool preferences_store_valid; + bool settings_store_valid; bool session_store_valid; + bool settings_store_blocked; + bool session_store_blocked; bool autostart_launch; + bool secondary_pet; + bool secondary_origin_known; + bool secondary_control_known; + bool secondary_control_visible; + bool secondary_control_pass_through; + /* Keep the initial transparent window hidden until its first complete + model frame is ready. This prevents a platform-dependent black flash. */ + bool startup_visibility_pending; + int secondary_origin_x, secondary_origin_y; + uint64_t secondary_control_check_ns; + uint64_t secondary_control_failure_ns; + uint64_t secondary_settings_check_ns; uint64_t startup_raise_due_ns; + uint64_t model_load_last_frame_ns; bool smoke; bool smoke_preferences; bool smoke_preference_shortcut; @@ -55,7 +112,6 @@ typedef struct BongoCatApp { bool smoke_remove_imported; bool smoke_shortcuts; bool smoke_menu; - bool smoke_input_audit; bool smoke_ignore_global_input; bool smoke_pass_through; bool smoke_context_menu; @@ -64,6 +120,7 @@ typedef struct BongoCatApp { bool smoke_runtime_flow; bool smoke_freeze_model; unsigned smoke_runtime_stage; + unsigned model_load_runtime_stage; unsigned model_selection_serial; uint64_t smoke_runtime_flow_ns; int smoke_language; @@ -79,13 +136,19 @@ typedef struct BongoCatApp { uint64_t display_recovery_due_ns; uint64_t mouse_last_ns; uint64_t frame_audit_bmp_ns; - uint64_t preferences_saved_hash, preferences_observed_hash; + uint64_t random_expression_due_ns; + float random_expression_interval_seconds; + uint32_t random_expression_state; + uint64_t settings_saved_hash, settings_observed_hash; uint64_t session_saved_hash, session_observed_hash; - uint64_t preferences_save_due_ns, session_save_due_ns; + uint64_t settings_save_due_ns, session_save_due_ns; BongoCatMouseTracking mouse_tracking; BongoCatMverPointerState mver_pointer; bool hover_inside; bool hover_hidden; + bool hover_fade_active; + float hover_fade_phase, hover_fade_target, hover_fade_opacity; + uint64_t hover_fade_tick_ns, hover_fade_next_ns; bool pointer_known; bool pointer_hit_dirty; bool pointer_transparent; @@ -95,15 +158,24 @@ typedef struct BongoCatApp { bool left_mouse_down; bool right_mouse_down; bool side_mouse_down; - bool model_mouse_parameters; + bool back_mouse_down, forward_mouse_down; + bool mouse_button_event_pending; bool model_pointer_anchor_ready; float model_pointer_anchor_x, model_pointer_anchor_y; + bool pointer_relative_active; + bool pointer_cursor_locked; bool window_minimized; double pointer_x, pointer_y; bool resize_gesture; float resize_scale_start, resize_scale_target; int resize_base_width, resize_base_height; bool resize_pending; + bool resize_render_target_pending; + /* Native preview ownership and gesture lifetime belong to window_snapshot.c. */ + void *window_snapshot; + bool snapshot_blocked; + uint64_t snapshot_deadline_ns; + float snapshot_pointer_x, snapshot_pointer_y; bool wheel_animation_active; bool wheel_gesture_active; int resize_pixel_width, resize_pixel_height; @@ -121,22 +193,46 @@ typedef struct BongoCatApp { float left_stick_x, left_stick_y; float right_stick_x, right_stick_y; bool left_stick_pressed, right_stick_pressed; + BongoCatInputEvent active_inputs[ + BONGO_CAT_INPUT_KEY_STATE_CAP + BONGO_CAT_INPUT_RECOVERY_CAP]; + size_t active_input_count; uint32_t active_gamepad; } BongoCatApp; int bongo_cat_app_run(int argc, char **argv); void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event); +void bongo_cat_app_reapply_input(BongoCatApp *app); void bongo_cat_app_reset_gamepad(BongoCatApp *app); void bongo_cat_gamepad_event(BongoCatApp *app, const void *sdl_event); void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event); bool bongo_cat_app_select_model(BongoCatApp *app, const char *id); bool bongo_cat_app_select_model_with_error(BongoCatApp *app, const char *id, BongoCatError *error); +bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id); +size_t bongo_cat_app_active_model_count(const BongoCatApp *app); +bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, + bool active, BongoCatError *error); +void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled); bool bongo_cat_app_run_behavior(BongoCatApp *app, const BongoCatBehaviorEntry *behavior); +size_t bongo_cat_app_selected_motion_count(const BongoCatApp *app); +void bongo_cat_app_capture_behavior_state(BongoCatApp *app); +size_t bongo_cat_app_saved_behavior_count(const BongoCatApp *app, + const char *model_id); +void bongo_cat_app_forget_behavior_state(BongoCatApp *app, + const char *model_id); +void bongo_cat_app_restore_behavior_state(BongoCatApp *app, + const char *model_id); BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, BongoCatError *error); BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, BongoCatError *error); void bongo_cat_app_rescan_models(BongoCatApp *app); +void bongo_cat_app_refresh_installed_models(BongoCatApp *app); +void bongo_cat_app_refresh_nearby_models(BongoCatApp *app); +void bongo_cat_app_request_model_refresh(BongoCatApp *app); +bool bongo_cat_app_model_refresh_busy(const BongoCatApp *app); +void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, + const char *package_id); +void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app); void bongo_cat_config_store_flush(BongoCatApp *app); #endif diff --git a/include/bongo_cat/audio.h b/include/bongo_cat/audio.h index d442b9fc..6720f3a7 100644 --- a/include/bongo_cat/audio.h +++ b/include/bongo_cat/audio.h @@ -8,7 +8,15 @@ typedef struct BongoCatAudio BongoCatAudio; BongoCatAudio *bongo_cat_audio_create(BongoCatError *error); void bongo_cat_audio_destroy(BongoCatAudio *audio); BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error); +/* With overlap disabled, only the same clip restarts; other clips continue. */ +BongoCatResult bongo_cat_audio_play_sound(BongoCatAudio *audio, const char *path, + bool overlap, BongoCatError *error); +void bongo_cat_audio_update(BongoCatAudio *audio); +void bongo_cat_audio_reset(BongoCatAudio *audio); void bongo_cat_audio_stop(BongoCatAudio *audio); +bool bongo_cat_audio_any_playing(const BongoCatAudio *audio); +bool bongo_cat_audio_is_playing(const BongoCatAudio *audio, const char *path); +void bongo_cat_audio_stop_path(BongoCatAudio *audio, const char *path); void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled); #endif diff --git a/include/bongo_cat/common.h b/include/bongo_cat/common.h index 7d8d5876..b5d3c962 100644 --- a/include/bongo_cat/common.h +++ b/include/bongo_cat/common.h @@ -5,21 +5,32 @@ #include #include +#include "bongo_cat/version.h" + #define BONGO_CAT_NAME "BongoCat" -#define BONGO_CAT_PET_WINDOW_TITLE "BongoCat - Pet" -#define BONGO_CAT_SETTINGS_WINDOW_TITLE "BongoCat - Settings" -#define BONGO_CAT_SLUG "bongo-cat" +#define BONGO_CAT_PET_WINDOW_TITLE BONGO_CAT_NAME +#define BONGO_CAT_TRAY_TITLE BONGO_CAT_NAME " " BONGO_CAT_VERSION_DISPLAY " \xC2\xB7 by vladelaina" +#define BONGO_CAT_SLUG "bongocat" #define BONGO_CAT_EXECUTABLE "BongoCat" #define BONGO_CAT_APP_ID "com.bongocat.desktop" #define BONGO_CAT_NAME_W L"BongoCat" -#define BONGO_CAT_PET_WINDOW_TITLE_W L"BongoCat - Pet" -#define BONGO_CAT_VERSION "0.1.0" +#define BONGO_CAT_PET_WINDOW_TITLE_W BONGO_CAT_NAME_W #define BONGO_CAT_PATH_CAP 1024 #define BONGO_CAT_ID_CAP 128 #define BONGO_CAT_SHORTCUT_CAP 128 #define BONGO_CAT_MODEL_CAP 128 +#define BONGO_CAT_ACTIVE_MODEL_CAP 8 +#define BONGO_CAT_ADDITIONAL_MODEL_CAP (BONGO_CAT_ACTIVE_MODEL_CAP - 1) #define BONGO_CAT_BEHAVIOR_CAP 128 -#define BONGO_CAT_AUTO_RELEASE_CAP 64 +#define BONGO_CAT_BEHAVIOR_ID_CAP 384 +#define BONGO_CAT_BEHAVIOR_BINDING_CAP 256 +#define BONGO_CAT_MENU_LABEL_CAP (BONGO_CAT_ID_CAP + BONGO_CAT_SHORTCUT_CAP + 4) +#define BONGO_CAT_SCHEDULED_RELEASE_CAP 64 +#define BONGO_CAT_UPDATE_VERSION_CAP 32 +#define BONGO_CAT_MODEL_ADAPTER_FILE ".bongo-cat-adapter.json" +#define BONGO_CAT_MODEL_BUILTIN_MARKER ".bongo-cat-builtin" +#define BONGO_CAT_MODEL_ADAPTER_SCHEMA 1 +#define BONGO_CAT_MODEL_ADAPTER_GENERATOR 1 typedef enum BongoCatResult { BONGO_CAT_OK = 0, diff --git a/include/bongo_cat/config.h b/include/bongo_cat/config.h index 57a44a30..4d661516 100644 --- a/include/bongo_cat/config.h +++ b/include/bongo_cat/config.h @@ -3,16 +3,19 @@ #include "bongo_cat/common.h" -#define BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS 3.0f #define BONGO_CAT_DEFAULT_MAX_FPS 60 #define BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT 100.0f #define BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT 100.0f +#define BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS 5.0f +#define BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT 6.0f +#define BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS 0.3f +#define BONGO_CAT_MAX_HIDE_FADE_SECONDS 3.0f typedef enum BongoCatTheme { BONGO_CAT_THEME_AUTO, BONGO_CAT_THEME_LIGHT, BONGO_CAT_THEME_DARK } BongoCatTheme; typedef enum BongoCatLanguage { BONGO_CAT_LANG_EN_US, BONGO_CAT_LANG_ZH_CN, - BONGO_CAT_LANG_ZH_TW, + BONGO_CAT_LANG_ZH_HANT, BONGO_CAT_LANG_FR_FR, BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, @@ -27,82 +30,162 @@ typedef enum BongoCatModelMode { BONGO_CAT_MODE_KEYBOARD, BONGO_CAT_MODE_GAMEPAD } BongoCatModelMode; +typedef enum BongoCatObsBackgroundColor { + BONGO_CAT_OBS_BACKGROUND_GREEN, + BONGO_CAT_OBS_BACKGROUND_BLUE, + BONGO_CAT_OBS_BACKGROUND_RED, + BONGO_CAT_OBS_BACKGROUND_MAGENTA, + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT +} BongoCatObsBackgroundColor; -typedef struct BongoCatModelOptions { +typedef struct BongoCatModelPreferences { + bool multiple_pets; bool mirror; bool mouse_mirror; bool mouse_centered; bool ignore_mouse; - float auto_release_seconds; int max_fps; -} BongoCatModelOptions; +} BongoCatModelPreferences; -typedef struct BongoCatWindowOptions { - bool visible; +typedef struct BongoCatWindowPreferences { bool pass_through; bool always_on_top; bool hide_on_hover; bool keep_in_screen; + bool obs_background; + bool random_expression; + bool rounded_corners; + BongoCatObsBackgroundColor obs_background_color; + float hide_delay_seconds; + float hide_fade_seconds; + float random_expression_interval_seconds; + float corner_radius_percent; +} BongoCatWindowPreferences; + +typedef struct BongoCatWindowState { + bool visible; + bool position_known; float scale_percent; float opacity_percent; - float hide_delay_seconds; int x; int y; int width; int height; -} BongoCatWindowOptions; + /* The authored composition size inside width/height. The outer window + may be larger to hold expression geometry outside the base canvas. */ + int content_width; + int content_height; +} BongoCatWindowState; -typedef struct BongoCatAppOptions { +typedef struct BongoCatApplicationPreferences { bool autostart; bool tray_visible; BongoCatTheme theme; BongoCatLanguage language; -} BongoCatAppOptions; +} BongoCatApplicationPreferences; -typedef struct BongoCatShortcutOptions { - char visible_cat[BONGO_CAT_SHORTCUT_CAP]; +typedef struct BongoCatShortcutPreferences { + char toggle_pet_visibility[BONGO_CAT_SHORTCUT_CAP]; char visible_preferences[BONGO_CAT_SHORTCUT_CAP]; char mirror[BONGO_CAT_SHORTCUT_CAP]; char pass_through[BONGO_CAT_SHORTCUT_CAP]; char always_on_top[BONGO_CAT_SHORTCUT_CAP]; -} BongoCatShortcutOptions; +} BongoCatShortcutPreferences; typedef struct BongoCatBehaviorShortcut { - char id[BONGO_CAT_PATH_CAP]; + char id[BONGO_CAT_BEHAVIOR_ID_CAP]; char shortcut[BONGO_CAT_SHORTCUT_CAP]; char label[BONGO_CAT_ID_CAP]; + bool shortcut_disabled; } BongoCatBehaviorShortcut; -typedef struct BongoCatConfig { - BongoCatModelOptions model; - BongoCatWindowOptions window; - BongoCatAppOptions app; - BongoCatShortcutOptions shortcuts; - BongoCatBehaviorShortcut behavior_shortcuts[BONGO_CAT_BEHAVIOR_CAP]; +typedef struct BongoCatModelLabel { + char id[BONGO_CAT_ID_CAP]; + char label[BONGO_CAT_ID_CAP]; +} BongoCatModelLabel; + +typedef struct BongoCatRemovedModel { + char id[BONGO_CAT_ID_CAP]; +} BongoCatRemovedModel; + +typedef struct BongoCatActiveBehavior { + char model_id[BONGO_CAT_ID_CAP]; + char behavior_id[BONGO_CAT_BEHAVIOR_ID_CAP]; +} BongoCatActiveBehavior; + +#define BONGO_CAT_SETTINGS_EXTENSIONS_CAP 4096 + +typedef struct BongoCatSettings { + BongoCatModelPreferences model; + BongoCatWindowPreferences window; + BongoCatApplicationPreferences app; + BongoCatShortcutPreferences shortcuts; + BongoCatBehaviorShortcut behavior_shortcuts[BONGO_CAT_BEHAVIOR_BINDING_CAP]; size_t behavior_shortcut_count; - char current_model[BONGO_CAT_PATH_CAP]; - BongoCatModelMode current_mode; -} BongoCatConfig; + BongoCatModelLabel model_labels[BONGO_CAT_MODEL_CAP]; + size_t model_label_count; + BongoCatRemovedModel removed_models[BONGO_CAT_MODEL_CAP]; + size_t removed_model_count; + char extensions_json[BONGO_CAT_SETTINGS_EXTENSIONS_CAP]; +} BongoCatSettings; + +typedef struct BongoCatSessionState { + BongoCatWindowState window; + char active_model_id[BONGO_CAT_ID_CAP]; + int last_update_check_day; + char last_update_check_version[BONGO_CAT_UPDATE_VERSION_CAP]; + char available_update_version[BONGO_CAT_UPDATE_VERSION_CAP]; + char additional_model_ids[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t additional_model_count; + BongoCatActiveBehavior active_behaviors[BONGO_CAT_BEHAVIOR_BINDING_CAP]; + size_t active_behavior_count; +} BongoCatSessionState; #ifdef __cplusplus extern "C" { #endif -void bongo_cat_config_defaults(BongoCatConfig *config); -void bongo_cat_config_validate(BongoCatConfig *config); -bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, +void bongo_cat_settings_defaults(BongoCatSettings *settings); +void bongo_cat_settings_validate(BongoCatSettings *settings); +void bongo_cat_session_defaults(BongoCatSessionState *session); +void bongo_cat_session_validate(BongoCatSessionState *session); +bool bongo_cat_session_model_active(const BongoCatSessionState *session, + const char *model_id); +bool bongo_cat_session_add_model(BongoCatSessionState *session, + const char *model_id); +bool bongo_cat_session_remove_model(BongoCatSessionState *session, + const char *model_id); +void bongo_cat_session_clear_additional_models(BongoCatSessionState *session); +bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *settings, const char *shortcut, const char *exclude); -BongoCatResult bongo_cat_preferences_load(const char *path, - BongoCatConfig *config, BongoCatError *error); -BongoCatResult bongo_cat_preferences_save(const char *path, - const BongoCatConfig *config, BongoCatError *error); +const char *bongo_cat_settings_model_label(const BongoCatSettings *settings, + const char *id); +bool bongo_cat_settings_set_model_label(BongoCatSettings *settings, + const char *id, const char *label); +bool bongo_cat_settings_model_removed(const BongoCatSettings *settings, + const char *id); +bool bongo_cat_settings_set_model_removed(BongoCatSettings *settings, + const char *id, bool removed); +bool bongo_cat_settings_restore_model_package(BongoCatSettings *settings, + const char *package_id); +BongoCatResult bongo_cat_settings_load(const char *path, + BongoCatSettings *settings, BongoCatError *error); +BongoCatResult bongo_cat_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error); BongoCatResult bongo_cat_session_load(const char *path, - BongoCatConfig *config, BongoCatError *error); + BongoCatSessionState *session, BongoCatError *error); BongoCatResult bongo_cat_session_save(const char *path, - const BongoCatConfig *config, BongoCatError *error); + const BongoCatSessionState *session, BongoCatError *error); const char *bongo_cat_theme_name(BongoCatTheme value); const char *bongo_cat_language_name(BongoCatLanguage value); +bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value); +bool bongo_cat_language_from_locale(const char *language, + const char *country, BongoCatLanguage *value); const char *bongo_cat_mode_name(BongoCatModelMode value); +const char *bongo_cat_obs_background_color_name( + BongoCatObsBackgroundColor value); +uint32_t bongo_cat_obs_background_color_rgb( + BongoCatObsBackgroundColor value); #ifdef __cplusplus } diff --git a/include/bongo_cat/file.h b/include/bongo_cat/file.h index a0ebae88..328c3ea7 100644 --- a/include/bongo_cat/file.h +++ b/include/bongo_cat/file.h @@ -9,6 +9,7 @@ extern "C" { #endif FILE *bongo_cat_file_open(const char *path, const char *mode); +bool bongo_cat_file_append(const char *path, const void *data, size_t size); bool bongo_cat_file_remove(const char *path); bool bongo_cat_file_replace(const char *source, const char *target, bool durable); diff --git a/include/bongo_cat/gl_api.h b/include/bongo_cat/gl_api.h index a2f6e91a..778b5b7e 100644 --- a/include/bongo_cat/gl_api.h +++ b/include/bongo_cat/gl_api.h @@ -4,6 +4,10 @@ #include "bongo_cat/common.h" #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct BongoCatGL { PFNGLCREATESHADERPROC create_shader; PFNGLSHADERSOURCEPROC shader_source; @@ -40,4 +44,8 @@ bool bongo_cat_gl_clear_errors(void); unsigned int bongo_cat_gl_program(BongoCatGL *gl, const char *vertex, const char *fragment, BongoCatError *error); +#ifdef __cplusplus +} +#endif + #endif diff --git a/include/bongo_cat/image.h b/include/bongo_cat/image.h index 5026bf50..055a1cff 100644 --- a/include/bongo_cat/image.h +++ b/include/bongo_cat/image.h @@ -18,12 +18,14 @@ typedef struct BongoCatImageAlphaMask { unsigned char pixels[BONGO_CAT_ALPHA_MASK_SIZE * BONGO_CAT_ALPHA_MASK_SIZE]; int width, height; } BongoCatImageAlphaMask; +typedef void (*BongoCatImageProgress)(void *userdata, float progress); #ifdef __cplusplus extern "C" { #endif BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, BongoCatError *error); +bool bongo_cat_image_info(const char *path, int *width, int *height); void bongo_cat_image_free(BongoCatImage *image); unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error); @@ -32,8 +34,10 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, unsigned int bongo_cat_image_texture_resampled(const char *path, int max_width, int max_height, float rounding, int *width, int *height, BongoCatError *error); +/* Returns a premultiplied-alpha texture for the Cubism renderer. */ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, - int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatError *error); + int *width, int *height, BongoCatImageAlphaMask *alpha, + BongoCatImageProgress progress, void *userdata, BongoCatError *error); void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, BongoCatImageAlphaMask *mask); unsigned int bongo_cat_image_composite_texture(const char *base, const char *left, diff --git a/include/bongo_cat/input.h b/include/bongo_cat/input.h index c07882e3..29f503b6 100644 --- a/include/bongo_cat/input.h +++ b/include/bongo_cat/input.h @@ -29,12 +29,13 @@ typedef struct BongoCatInputEvent { uint64_t sequence; } BongoCatInputEvent; -typedef struct BongoCatAutoRelease { +typedef struct BongoCatScheduledRelease { uint64_t deadline_ms; char name[BONGO_CAT_ID_CAP]; -} BongoCatAutoRelease; +} BongoCatScheduledRelease; typedef struct BongoCatInputState { + atomic_bool queue_busy; BongoCatInputEvent queue[BONGO_CAT_INPUT_QUEUE_CAP]; atomic_uint_fast16_t head; atomic_uint_fast16_t tail; @@ -48,8 +49,9 @@ typedef struct BongoCatInputState { BongoCatInputEvent recovery[BONGO_CAT_INPUT_RECOVERY_CAP]; atomic_uint_fast8_t recovery_head; atomic_uint_fast8_t recovery_tail; - BongoCatAutoRelease releases[BONGO_CAT_AUTO_RELEASE_CAP]; - size_t release_count; + BongoCatScheduledRelease scheduled_releases[ + BONGO_CAT_SCHEDULED_RELEASE_CAP]; + size_t scheduled_release_count; } BongoCatInputState; void bongo_cat_input_init(BongoCatInputState *state); @@ -61,9 +63,9 @@ bool bongo_cat_input_mouse(BongoCatInputState *state, double x, double y); bool bongo_cat_input_take_mouse(BongoCatInputState *state, double *x, double *y); bool bongo_cat_input_control_down(const BongoCatInputState *state); bool bongo_cat_input_shift_down(const BongoCatInputState *state); -void bongo_cat_input_auto_release(BongoCatInputState *state, +void bongo_cat_input_schedule_release(BongoCatInputState *state, const BongoCatInputEvent *event, uint64_t delay_ms); -bool bongo_cat_input_take_release(BongoCatInputState *state, uint64_t now_ms, - BongoCatInputEvent *event); +bool bongo_cat_input_take_scheduled_release(BongoCatInputState *state, + uint64_t now_ms, BongoCatInputEvent *event); #endif diff --git a/include/bongo_cat/json.h b/include/bongo_cat/json.h index e4b9dbac..7ca8f379 100644 --- a/include/bongo_cat/json.h +++ b/include/bongo_cat/json.h @@ -10,6 +10,10 @@ extern "C" { yyjson_doc *bongo_cat_json_read_file(const char *path, yyjson_read_flag flags, yyjson_read_err *error); +/* Model compatibility is applied in memory; authored files are never rewritten. */ +yyjson_doc *bongo_cat_model_json_parse(const char *data, size_t size, + bool *normalized); +yyjson_doc *bongo_cat_model_json_read(const char *path, bool *normalized); bool bongo_cat_json_write_file(const char *path, const yyjson_mut_doc *document, yyjson_write_flag flags, yyjson_write_err *error); diff --git a/include/bongo_cat/log.h b/include/bongo_cat/log.h new file mode 100644 index 00000000..772346b7 --- /dev/null +++ b/include/bongo_cat/log.h @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_LOG_H +#define BONGO_CAT_LOG_H + +#include + +enum { + BONGO_CAT_LOG_LIFECYCLE = SDL_LOG_CATEGORY_CUSTOM, + BONGO_CAT_LOG_UPDATE, + BONGO_CAT_LOG_INPUT +}; + +static inline bool bongo_cat_log_enabled(int category, SDL_LogPriority priority) { + return priority >= SDL_LOG_PRIORITY_WARN || + (priority == SDL_LOG_PRIORITY_INFO && + (category == BONGO_CAT_LOG_LIFECYCLE || + category == BONGO_CAT_LOG_UPDATE || + category == BONGO_CAT_LOG_INPUT)); +} + +#endif diff --git a/include/bongo_cat/model.h b/include/bongo_cat/model.h index b0dd0cc3..b3c7258a 100644 --- a/include/bongo_cat/model.h +++ b/include/bongo_cat/model.h @@ -3,14 +3,44 @@ #include "bongo_cat/config.h" +typedef enum BongoCatModelSourceFormat { + BONGO_CAT_MODEL_SOURCE_UNKNOWN, + BONGO_CAT_MODEL_SOURCE_BUILTIN, + BONGO_CAT_MODEL_SOURCE_TAURI, + BONGO_CAT_MODEL_SOURCE_MVER, + BONGO_CAT_MODEL_SOURCE_MVER_PATCH +} BongoCatModelSourceFormat; + +typedef enum BongoCatModelCapability { + BONGO_CAT_MODEL_CAPABILITY_LIVE2D = 1u << 0, + BONGO_CAT_MODEL_CAPABILITY_PREVIEW = 1u << 1, + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER = 1u << 2, + BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES = 1u << 3, + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT = 1u << 4, + BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT = 1u << 5, + BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS = 1u << 6, + BONGO_CAT_MODEL_CAPABILITY_AUDIO = 1u << 7, + BONGO_CAT_MODEL_CAPABILITY_EFFECTS = 1u << 8, + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION = 1u << 9, + BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY = 1u << 10, + BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH = 1u << 11 +} BongoCatModelCapability; + typedef struct BongoCatModelEntry { char id[BONGO_CAT_ID_CAP]; + char package_id[BONGO_CAT_ID_CAP]; + char content_digest[65]; + char family_id[BONGO_CAT_ID_CAP]; char display_name[BONGO_CAT_ID_CAP]; char directory[BONGO_CAT_PATH_CAP]; char adapter_directory[BONGO_CAT_PATH_CAP]; char storage_directory[BONGO_CAT_PATH_CAP]; char setting_file[BONGO_CAT_PATH_CAP]; BongoCatModelMode mode; + BongoCatModelSourceFormat source_format; + uint32_t capabilities; + int adapter_schema; + int adapter_generator; bool preset; bool managed; } BongoCatModelEntry; @@ -36,6 +66,8 @@ typedef struct BongoCatBehaviorEntry { int index; BongoCatBehaviorKind kind; bool momentary; + bool sound_overlap; + bool sound_clear; } BongoCatBehaviorEntry; typedef struct BongoCatBehaviorCatalog { @@ -52,11 +84,17 @@ BongoCatResult bongo_cat_models_scan(BongoCatModelCatalog *catalog, const char * bool preset, BongoCatError *error); const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *catalog, const char *id); +bool bongo_cat_model_adapter_metadata_path(const char *directory, + char *path, size_t capacity); +const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry); +const char *bongo_cat_model_name(const BongoCatSettings *settings, + const BongoCatModelEntry *entry); BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, const BongoCatModelEntry *model, BongoCatError *error); typedef struct BongoCatLive2D BongoCatLive2D; typedef struct BongoCatParameterRange { float minimum, maximum, value; } BongoCatParameterRange; +typedef void (*BongoCatLive2DLoadProgress)(void *userdata, float progress); typedef struct BongoCatLive2DVisualState { float fit_scale, fit_translate_x, fit_translate_y; float visible_min_x, visible_min_y, visible_max_x, visible_max_y; @@ -65,8 +103,16 @@ typedef struct BongoCatLive2DVisualState { bool fitted, visible, mver_projection; } BongoCatLive2DVisualState; +/* Extra transparent space around the authored model canvas, expressed as a + fraction of that canvas dimension. For example, top=0.5 reserves half a + canvas height above the normal composition. */ +typedef struct BongoCatLive2DFrame { + float left, top, right, bottom; +} BongoCatLive2DFrame; + typedef struct BongoCatLive2DRenderOptions { bool mver_projection; + bool auto_frame; bool source_mirror; bool custom_pointer_bounds; bool pointer_left_handed; @@ -87,8 +133,17 @@ BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *e void bongo_cat_live2d_destroy(BongoCatLive2D *live2d); BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error); + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error); bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d); +/* Returns the authored pixel canvas size of the loaded model. */ +bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, + int *width, int *height); +bool bongo_cat_live2d_frame(const BongoCatLive2D *live2d, + BongoCatLive2DFrame *frame); +bool bongo_cat_live2d_viewport(const BongoCatLive2D *live2d, + int *x, int *y, int *width, int *height); void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height); void bongo_cat_live2d_reshape(BongoCatLive2D *live2d, int width, int height); bool bongo_cat_live2d_update(BongoCatLive2D *live2d, float delta_seconds); @@ -97,11 +152,31 @@ void bongo_cat_live2d_set_mirror(BongoCatLive2D *live2d, bool mirror); void bongo_cat_live2d_set_render_options(BongoCatLive2D *live2d, const BongoCatLive2DRenderOptions *options); void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y); +void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, + float x, float y); void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d); +/* Synchronize drawables from the current state before a cover-only frame. */ +bool bongo_cat_live2d_prepare_cover_capture(BongoCatLive2D *live2d); bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value); bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, BongoCatParameterRange *range); bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, const char *group, int index); +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_preview_motion(BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *live2d); +bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *live2d, + const char *group, int index); +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, + const char *left_group, int left_index, + const char *right_group, int right_index); bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index); int bongo_cat_live2d_expression(const BongoCatLive2D *live2d); bool bongo_cat_live2d_visual_state(const BongoCatLive2D *live2d, diff --git a/include/bongo_cat/mouse.h b/include/bongo_cat/mouse.h index f8646bcd..b78ce9f3 100644 --- a/include/bongo_cat/mouse.h +++ b/include/bongo_cat/mouse.h @@ -2,6 +2,7 @@ #define BONGO_CAT_MOUSE_H #include +#include typedef struct BongoCatMouseTracking { double target_x; @@ -12,9 +13,14 @@ typedef struct BongoCatMouseTracking { bool settled; } BongoCatMouseTracking; +bool bongo_cat_mouse_sample_fresh(uint64_t now_ns, uint64_t sample_ns); void bongo_cat_mouse_target(BongoCatMouseTracking *tracking, double x, double y); bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, double *x, double *y); +float bongo_cat_mouse_centered_ratio(double position, double center, + double minimum, double maximum); +void bongo_cat_mouse_drag_coordinates(float x_ratio, float y_ratio, + bool horizontal_mirror, float *x, float *y); float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror); diff --git a/include/bongo_cat/overlay.h b/include/bongo_cat/overlay.h index b749ad23..c59e5526 100644 --- a/include/bongo_cat/overlay.h +++ b/include/bongo_cat/overlay.h @@ -1,14 +1,16 @@ #ifndef BONGO_CAT_OVERLAY_H #define BONGO_CAT_OVERLAY_H -#include "bongo_cat/common.h" +#include "bongo_cat/model.h" typedef struct BongoCatOverlay BongoCatOverlay; BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error); void bongo_cat_overlay_destroy(BongoCatOverlay *overlay); -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, const char *model_directory, - BongoCatError *error); +void bongo_cat_overlay_clear(BongoCatOverlay *overlay); +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *overlay, + const char *model_directory, bool model_pointer_preferred, + const BongoCatLive2DRenderOptions *render_options, BongoCatError *error); int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed); bool bongo_cat_overlay_effect(BongoCatOverlay *overlay, const char *path); bool bongo_cat_overlay_hand_active(const BongoCatOverlay *overlay, bool right); diff --git a/include/bongo_cat/platform.h b/include/bongo_cat/platform.h index b01a2958..a0a4b031 100644 --- a/include/bongo_cat/platform.h +++ b/include/bongo_cat/platform.h @@ -13,9 +13,9 @@ typedef struct BongoCatPlatform { BongoCatInputState *input; void *native; void *presenter; - void *relative_pointer; uint32_t wake_event_type; float window_opacity; + bool hover_hide_unavailable; } BongoCatPlatform; typedef enum BongoCatMenuAction { @@ -52,9 +52,11 @@ typedef enum BongoCatMenuAction { BONGO_CAT_MENU_OPACITY_100, BONGO_CAT_MENU_EXIT, BONGO_CAT_MENU_MODEL_ADD, + BONGO_CAT_MENU_REMOVE_PET, BONGO_CAT_MENU_MODEL_FIRST = 1000, BONGO_CAT_MENU_MOTION_FIRST = 2000, - BONGO_CAT_MENU_EXPRESSION_FIRST = 3000 + BONGO_CAT_MENU_EXPRESSION_FIRST = 3000, + BONGO_CAT_MENU_AUDIO_FIRST = 4000 } BongoCatMenuAction; typedef void (*BongoCatMenuPreview)(void *userdata, BongoCatMenuAction action); @@ -62,18 +64,30 @@ typedef struct BongoCatMenuLabels { const char *preferences, *hide, *pass_through, *always_on_top; const char *window_size, *opacity, *model, *add_model, *exit; const char *wheel_size_hint, *wheel_opacity_hint, *motion, *expression; - const char *const *model_names, *const *motion_names, *const *expression_names; - size_t model_count, current_model, motion_count, expression_count, current_expression; + const char *const *model_names; + const char (*motion_names)[BONGO_CAT_MENU_LABEL_CAP]; + const char (*expression_names)[BONGO_CAT_MENU_LABEL_CAP]; + const bool *motion_checked; + size_t model_count, current_model, motion_count; + size_t expression_count, current_expression; float scale_percent, opacity_percent; bool pass_through_checked, always_on_top_checked, dark_theme; BongoCatMenuPreview preview; void (*preview_tick)(void *userdata); BongoCatMenuPreview restore; void *preview_userdata; + const char *remove_pet; + bool remove_pet_visible; + const char *audio; + const char (*audio_names)[BONGO_CAT_MENU_LABEL_CAP]; + const bool *audio_checked; + size_t audio_count; + const char *const *model_cover_directories; } BongoCatMenuLabels; typedef void (*BongoCatTrayClick)(void *userdata); typedef void (*BongoCatModalTick)(void *userdata); +typedef void (*BongoCatTrayRestore)(void *userdata); BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *window, BongoCatInputState *input, BongoCatError *error); @@ -88,11 +102,16 @@ bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible); bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_x, double screen_y, float *local_x, float *local_y); +/* Reports a foreground application's fixed/locked system cursor state. */ +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform); bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y); void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform); +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform); void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled); void bongo_cat_platform_raise_window(SDL_Window *window); +/* Configure platform-native chrome for the preferences window when available. */ +void bongo_cat_platform_configure_preferences_window(SDL_Window *window); bool bongo_cat_platform_open_directory(const char *path); bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, int x, int y, int width, int height); @@ -101,13 +120,23 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bool bongo_cat_platform_dynamic_hit_supported(void); void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata); + BongoCatTrayRestore restore, void *userdata); bool bongo_cat_platform_single_instance_begin(void); bool bongo_cat_platform_single_instance_take_wake(void); +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv); +bool bongo_cat_platform_single_instance_take_update_shutdown(void); void bongo_cat_platform_single_instance_end(void); BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *error); BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, const BongoCatMenuLabels *labels); BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error); +#ifdef __APPLE__ +/* Read the permission macOS grants the app right now, without prompting. + Callers refresh on a user action or a focus change, never per frame. */ +bool bongo_cat_platform_input_monitoring_authorized(void); +/* Ask macOS for Input Monitoring; only an explicit user action may call it. */ +bool bongo_cat_platform_input_monitoring_request(void); +#endif + #endif diff --git a/include/bongo_cat/preferences.h b/include/bongo_cat/preferences.h index 28613b98..1df98f4b 100644 --- a/include/bongo_cat/preferences.h +++ b/include/bongo_cat/preferences.h @@ -13,7 +13,9 @@ void bongo_cat_preferences_destroy(BongoCatPreferences *preferences); void bongo_cat_preferences_show(BongoCatPreferences *preferences); void bongo_cat_preferences_close(BongoCatPreferences *preferences); bool bongo_cat_preferences_visible(const BongoCatPreferences *preferences); -bool bongo_cat_preferences_needs_frame(const BongoCatPreferences *preferences); +bool bongo_cat_preferences_needs_frame(BongoCatPreferences *preferences); +void bongo_cat_preferences_model_watch(BongoCatPreferences *preferences, + uint64_t now); void bongo_cat_preferences_input_begin(BongoCatPreferences *preferences); void bongo_cat_preferences_input_end(BongoCatPreferences *preferences); bool bongo_cat_preferences_event(BongoCatPreferences *preferences, const SDL_Event *event); @@ -21,6 +23,11 @@ bool bongo_cat_preferences_shortcuts_blocked( const BongoCatPreferences *preferences); void bongo_cat_preferences_render(BongoCatPreferences *preferences); void bongo_cat_preferences_invalidate(BongoCatPreferences *preferences); +void bongo_cat_preferences_process_model_selection( + BongoCatPreferences *preferences); +void bongo_cat_preferences_model_load_progress( + BongoCatPreferences *preferences, float progress); +void bongo_cat_preferences_models_changed(BongoCatPreferences *preferences); void bongo_cat_preferences_request_model_import(BongoCatPreferences *preferences); bool bongo_cat_preferences_open_model_import(BongoCatPreferences *preferences, SDL_Window *parent); diff --git a/include/bongo_cat/shortcut.h b/include/bongo_cat/shortcut.h index ca30a493..731fcffc 100644 --- a/include/bongo_cat/shortcut.h +++ b/include/bongo_cat/shortcut.h @@ -12,6 +12,8 @@ typedef struct BongoCatShortcutState { } BongoCatShortcutState; void bongo_cat_shortcut_init(BongoCatShortcutState *state); +/* Format UI labels without changing the stored binding. */ +void bongo_cat_shortcut_format(const char *shortcut, char *output, size_t capacity); bool bongo_cat_shortcut_update(BongoCatShortcutState *state, const BongoCatInputEvent *event); bool bongo_cat_shortcut_matches(const BongoCatShortcutState *state, const BongoCatInputEvent *event, const char *shortcut); diff --git a/include/bongo_cat/sound_shortcut.h b/include/bongo_cat/sound_shortcut.h new file mode 100644 index 00000000..64b93eef --- /dev/null +++ b/include/bongo_cat/sound_shortcut.h @@ -0,0 +1,18 @@ +#ifndef BONGO_CAT_SOUND_SHORTCUT_H +#define BONGO_CAT_SOUND_SHORTCUT_H + +#include "bongo_cat/input.h" + +/* Stores physical keys separately so releasing one Ctrl does not release + the other. Only changed input edges require reevaluating bindings. */ +typedef struct BongoCatSoundShortcutState { + char held[BONGO_CAT_INPUT_KEY_STATE_CAP][BONGO_CAT_ID_CAP]; + size_t count; +} BongoCatSoundShortcutState; + +bool bongo_cat_sound_shortcut_update(BongoCatSoundShortcutState *state, + const BongoCatInputEvent *event); +bool bongo_cat_sound_shortcut_down(const BongoCatSoundShortcutState *state, + const char *shortcut); + +#endif diff --git a/include/bongo_cat/tray.h b/include/bongo_cat/tray.h index b445bb48..14ef758a 100644 --- a/include/bongo_cat/tray.h +++ b/include/bongo_cat/tray.h @@ -9,6 +9,7 @@ typedef struct BongoCatTray BongoCatTray; BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error); void bongo_cat_tray_destroy(BongoCatTray *tray); void bongo_cat_tray_sync(BongoCatTray *tray); +void bongo_cat_tray_prepare_menu(BongoCatTray *tray, void *native_handle); bool bongo_cat_tray_self_test(BongoCatTray *tray); #endif diff --git a/include/bongo_cat/update.h b/include/bongo_cat/update.h new file mode 100644 index 00000000..e58033f9 --- /dev/null +++ b/include/bongo_cat/update.h @@ -0,0 +1,30 @@ +#ifndef BONGO_CAT_UPDATE_H +#define BONGO_CAT_UPDATE_H + +#include "bongo_cat/common.h" + +#define BONGO_CAT_UPDATE_URL_CAP 512 +#define BONGO_CAT_UPDATE_NOTES_CAP 8192 + +typedef struct BongoCatUpdateRelease { + char version[BONGO_CAT_UPDATE_VERSION_CAP]; + char release_url[BONGO_CAT_UPDATE_URL_CAP]; + char installer_url[BONGO_CAT_UPDATE_URL_CAP]; + char portable_url[BONGO_CAT_UPDATE_URL_CAP]; + char notes[BONGO_CAT_UPDATE_NOTES_CAP]; +} BongoCatUpdateRelease; + +#ifdef __cplusplus +extern "C" { +#endif + +int bongo_cat_update_compare_versions(const char *left, const char *right); +bool bongo_cat_update_version_valid(const char *version); +bool bongo_cat_update_parse_release(const char *json, const char *platform, + BongoCatUpdateRelease *release, BongoCatError *error); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/bongo_cat/utf8.h b/include/bongo_cat/utf8.h index beab0859..1ae9e3f6 100644 --- a/include/bongo_cat/utf8.h +++ b/include/bongo_cat/utf8.h @@ -5,7 +5,7 @@ #include bool bongo_cat_utf8_valid(const char *text); -bool bongo_cat_utf8_normalize_legacy(const char *text, +bool bongo_cat_utf8_normalize_mver(const char *text, char *output, size_t capacity); #endif diff --git a/packaging/get-project-version.ps1 b/packaging/get-project-version.ps1 new file mode 100644 index 00000000..f80705bb --- /dev/null +++ b/packaging/get-project-version.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$cmakeLists = Get-Content -LiteralPath ( + Join-Path $repositoryRoot 'CMakeLists.txt') -Raw +if ($cmakeLists -notmatch + '(?m)^\s*set\(BONGO_CAT_VERSION\s+"?([0-9]+)\.([0-9]+)\.([0-9]+)') { + throw 'Unable to read the BongoCat version from CMakeLists.txt.' +} + +$parts = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) +if ($parts | Where-Object { $_ -gt 65535 }) { + throw 'Version components must be between 0 and 65535.' +} +if ($parts[0] -eq 0) { + throw 'The major version must be greater than zero for Microsoft Store packages.' +} + +[PSCustomObject]@{ + AppVersion = $parts -join '.' + StorePackageVersion = "$($parts[0]).$($parts[1]).$($parts[2]).0" +} diff --git a/packaging/linux/bongocat.desktop b/packaging/linux/bongocat.desktop new file mode 100644 index 00000000..ea8969ec --- /dev/null +++ b/packaging/linux/bongocat.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=BongoCat +Comment=An animated desktop cat that reacts to your input +Exec=BongoCat +Icon=bongocat +Terminal=false +Categories=Utility; diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh new file mode 100644 index 00000000..9fa163ec --- /dev/null +++ b/packaging/linux/build-appimage.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $(uname -s) != Linux || $(uname -m) != x86_64 ]]; then + echo 'AppImage packaging requires Linux x86_64.' >&2 + exit 1 +fi +build_dir=$(cd "${1:?Usage: build-appimage.sh BUILD_DIRECTORY}" && pwd) +source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +name=$(tr -d '\r\n' < "$build_dir/bongocat-package-name.txt") +[[ $name =~ ^BongoCat(-Diagnostic)?-[0-9][A-Za-z0-9.+-]*-linux-x64$ ]] || { + echo "Unexpected package name: $name" >&2 + exit 1 +} +work=$(mktemp -d "$build_dir/appimage.XXXXXXXX") +trap 'rm -rf -- "$work"' EXIT +mkdir -p "$build_dir/dist" + +# A private staging tree keeps repeated builds free of stale dependencies. +# Keep assets next to the executable, as expected by the runtime. +appdir="$work/BongoCat.AppDir" +cmake --install "$build_dir" --component Runtime --prefix "$appdir/usr/bin" + +tool="$work/linuxdeploy.AppImage" +curl --fail --location --retry 3 --output "$tool" \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage +chmod +x "$tool" +export ARCH=x86_64 +export VERSION="${name#BongoCat-}" +export OUTPUT="$build_dir/dist/$name.AppImage" +# Build runners do not need FUSE, including subprocess AppImage tools. +export APPIMAGE_EXTRACT_AND_RUN=1 +pushd "$build_dir" >/dev/null +"$tool" --appdir "$appdir" \ + --executable "$appdir/usr/bin/BongoCat" \ + --desktop-file "$source_dir/packaging/linux/bongocat.desktop" \ + --icon-file "$source_dir/resources/assets/bongocat.png" \ + --output appimage +popd >/dev/null +test -s "$OUTPUT" +chmod +x "$OUTPUT" +(cd "$build_dir/dist" && sha256sum "$name.AppImage" > "$name.AppImage.sha256") +echo "AppImage ready: $OUTPUT" diff --git a/packaging/microsoft-store/AppxManifest.xml.in b/packaging/microsoft-store/AppxManifest.xml.in new file mode 100644 index 00000000..5a174cd4 --- /dev/null +++ b/packaging/microsoft-store/AppxManifest.xml.in @@ -0,0 +1,70 @@ + + + + + + + BongoCat + vladelaina + A Live2D desktop pet that reacts to keyboard and mouse input. + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/microsoft-store/README.md b/packaging/microsoft-store/README.md new file mode 100644 index 00000000..9be53df8 --- /dev/null +++ b/packaging/microsoft-store/README.md @@ -0,0 +1,118 @@ +# Microsoft Store packaging + +The protected `store-package` job in `.github/workflows/ci.yml` creates a raw +`bongocat__x64.msix` artifact on pushes to the upstream `main` +branch of `vladelaina/BongoCat`. The artifact is intentionally unsigned: +Partner Center signs accepted MSIX packages with the Microsoft Store +certificate during submission. + +## Store identity + +- Package name: `vladelaina.bongocat` +- Publisher: `CN=5503A135-7FA4-466B-815C-DBE627F4065F` +- Publisher display name: `vladelaina` +- Package family name: `vladelaina.bongocat_hnew8t3b8e0t6` +- Package SID: `S-1-15-2-1569586416-2650217304-2351454042-2692954497-3079119466-4141654193-1648471479` +- Store ID: `9P41MLSX72XW` + +The package name and publisher are stored in `AppxManifest.xml.in`. The PFN +and package SID are derived by Windows and must not be used as signing +secrets. The Store package is x64; the separate Windows x86 workflow target +only publishes its desktop installer and portable executable. + +The manifest declares a system-managed `BongoCat.lnk` on the current user's +desktop through the `desktop7:windows.shortcut` extension. Windows creates the +link during package registration and removes it during package removal. The +shortcut extension is supported starting with Windows build 19645; older +supported Windows releases ignore that extension while continuing to install +and run the app. + +## App data isolation + +Ordinary EXE, installer, and portable launches store data under +`%LOCALAPPDATA%\BongoCat`. When the process has an MSIX package identity, +BongoCat instead resolves the package-private virtualization location as +`%LOCALAPPDATA%\Packages\\LocalCache\Local\BongoCat`. Configuration, +session state, imported models, caches, and logs therefore remain separate +between the desktop and Microsoft Store versions. An explicit +`--storage-root` argument remains isolated at the requested location. +In particular, the desktop settings file is +`%LOCALAPPDATA%\BongoCat\config\settings.json`, while the Store settings file +is `%LOCALAPPDATA%\Packages\\LocalCache\Local\BongoCat\config\settings.json`. + +Package identity is detected at runtime with `GetCurrentPackageFullName`, and +the selected mode and resolved storage path are written to `BongoCat.log`. + +The app version is declared only in the top-level `CMakeLists.txt` and read by +all Store scripts through `packaging/get-project-version.ps1`. MSIX versions +must use four numeric components, have a non-zero major component, and use a +zero revision. The helper therefore converts `major.minor.patch` to the same +version with a `.0` suffix, for example `1.1.0` to `1.1.0.0`. The app version +remains in the filename so GitHub artifacts are easy to identify. + +## Actions SDK setup + +Live2D's licensed Cubism SDK is ignored by Git and cannot be checked into this +public repository. The protected job downloads the pinned Cubism 5-r.5 archive +and GLEW 2.2.0 archive from their official upstream URLs and verifies both +SHA-256 hashes before building. A repository owner may override either URL and +hash with these secrets when an approved mirror is required: + +- `CUBISM_SDK_ARCHIVE_URL` and `CUBISM_SDK_ARCHIVE_SHA256` +- `CUBISM_GLEW_ARCHIVE_URL` and `CUBISM_GLEW_ARCHIVE_SHA256` + +Running the protected workflow is subject to the Live2D Cubism SDK licenses; +the repository owner is responsible for satisfying their release terms. + +The job verifies the digests and required files before configuring CMake with +`BONGO_CAT_REQUIRE_CUBISM=ON`; it never uploads the diagnostic backend. + +## Local build and install check + +On Windows with the Windows 10/11 SDK and the local Cubism SDK installed: + +```powershell +cmake -S . -B build-cubism -G "Visual Studio 17 2022" -A x64 ` + -DBONGO_CAT_REQUIRE_CUBISM=ON -DBONGO_CAT_WARNINGS_AS_ERRORS=ON +cmake --build build-cubism --config Release --target bongo_cat --parallel 2 + +.\packaging\microsoft-store\build-store-package.ps1 ` + -ExecutablePath .\build-cubism\Release\BongoCat.exe +``` + +The script writes the unsigned Store submission package to +`output\microsoft-store\bongocat__x64.msix`. Upload that file +directly to Partner Center. It cannot be installed locally until Microsoft +Store signs it. Validate the exact Identity, PFN, manifest and executable +versions, architecture, payload, and unsigned submission state with: + +```powershell +$projectVersion = .\packaging\get-project-version.ps1 +$msix = ".\output\microsoft-store\bongocat_$($projectVersion.AppVersion)_x64.msix" +.\packaging\microsoft-store\validate-store-package.ps1 ` + -PackagePath $msix +``` + +The protected Actions job runs this validation before uploading its artifact. + +To test local deployment before submission, explicitly build a separate +self-signed package: + +```powershell +$projectVersion = .\packaging\get-project-version.ps1 +$localBase = ".\output\microsoft-store\bongocat_$($projectVersion.AppVersion)_x64-local-test" +.\packaging\microsoft-store\build-store-package.ps1 ` + -ExecutablePath .\build-cubism\Release\BongoCat.exe ` + -SignForLocalTesting + +# Run these certificate/deployment commands from an elevated PowerShell. +Import-Certificate ` + "$localBase.cer" ` + -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null +Add-AppxPackage "$localBase.msix" +Get-AppxPackage -Name vladelaina.bongocat +``` + +The `-local-test` certificate and package are only for sideload testing. Do not +upload either one to Partner Center. The GitHub Actions job never passes +`-SignForLocalTesting` and always emits the unsigned Store submission package. diff --git a/packaging/microsoft-store/build-store-package.ps1 b/packaging/microsoft-store/build-store-package.ps1 new file mode 100644 index 00000000..1aed7337 --- /dev/null +++ b/packaging/microsoft-store/build-store-package.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, + + [string]$OutputDirectory, + [string]$CertificateOutputPath, + [ValidateSet('x86', 'x64')] + [string]$Architecture = 'x64', + [switch]$SignForLocalTesting +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = (Resolve-Path (Join-Path $scriptDirectory "..\..")).Path +$projectVersion = & (Join-Path $repositoryRoot 'packaging\get-project-version.ps1') +$Version = $projectVersion.AppVersion +$PackageVersion = $projectVersion.StorePackageVersion + +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $repositoryRoot "output\microsoft-store" +} + +$ExecutablePath = (Resolve-Path $ExecutablePath).Path +$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) + +$packageBaseName = if ($SignForLocalTesting) { + "bongocat_${Version}_${Architecture}-local-test" +} +else { + "bongocat_${Version}_${Architecture}" +} +$msixPath = Join-Path $OutputDirectory "$packageBaseName.msix" +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("BongoCatStorePackage_" + [guid]::NewGuid()) +$stagingDirectory = Join-Path $temporaryRoot "package" +$assetsDirectory = Join-Path $stagingDirectory "Assets" +$certificatePath = Join-Path $temporaryRoot "BongoCatStoreTemporary.pfx" +$certificate = $null + +if ($CertificateOutputPath -and -not $SignForLocalTesting) { + throw "CertificateOutputPath requires SignForLocalTesting. Store submission packages are unsigned." +} +if ($SignForLocalTesting -and -not $CertificateOutputPath) { + $CertificateOutputPath = Join-Path $OutputDirectory "$packageBaseName.cer" +} + +function Get-WindowsSdkTool { + param([Parameter(Mandatory = $true)][string]$Name) + + $programFilesX86 = ${env:ProgramFiles(x86)} + $sdkRoot = Join-Path $programFilesX86 "Windows Kits\10\bin" + if (Test-Path $sdkRoot) { + $sdkVersions = Get-ChildItem $sdkRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+' } | + Sort-Object { [version]$_.Name } -Descending + + foreach ($sdkVersion in $sdkVersions) { + $candidate = Join-Path $sdkVersion.FullName "x64\$Name" + if (Test-Path $candidate) { + return $candidate + } + } + } + + $appCertKitCandidate = Join-Path $programFilesX86 "Windows Kits\10\App Certification Kit\$Name" + if (Test-Path $appCertKitCandidate) { + return $appCertKitCandidate + } + + throw "$Name was not found. Install the Windows 10/11 SDK." +} + +function New-StoreLogo { + param( + [Parameter(Mandatory = $true)][object]$Source, + [Parameter(Mandatory = $true)][int]$Width, + [Parameter(Mandatory = $true)][int]$Height, + [Parameter(Mandatory = $true)][string]$Destination + ) + + $bitmap = [System.Drawing.Bitmap]::new( + $Width, + $Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + + $scale = [Math]::Min($Width / $Source.Width, $Height / $Source.Height) + $drawWidth = [Math]::Max(1, [int][Math]::Round($Source.Width * $scale)) + $drawHeight = [Math]::Max(1, [int][Math]::Round($Source.Height * $scale)) + $drawX = [int](($Width - $drawWidth) / 2) + $drawY = [int](($Height - $drawHeight) / 2) + $graphics.DrawImage($Source, $drawX, $drawY, $drawWidth, $drawHeight) + } + finally { + $graphics.Dispose() + } + + $bitmap.Save($Destination, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $bitmap.Dispose() + } +} + +try { + New-Item -ItemType Directory -Force -Path $OutputDirectory, $stagingDirectory, $assetsDirectory | Out-Null + Remove-Item $msixPath -Force -ErrorAction SilentlyContinue + + Copy-Item $ExecutablePath (Join-Path $stagingDirectory "BongoCat.exe") + + $manifestTemplate = Get-Content (Join-Path $scriptDirectory "AppxManifest.xml.in") -Raw + $manifest = $manifestTemplate.Replace("@PACKAGE_VERSION@", $PackageVersion) + $manifest = $manifest.Replace("@PROCESSOR_ARCHITECTURE@", $Architecture) + [System.IO.File]::WriteAllText( + (Join-Path $stagingDirectory "AppxManifest.xml"), + $manifest, + [System.Text.UTF8Encoding]::new($false)) + + Add-Type -AssemblyName System.Drawing + $sourceLogo = [System.Drawing.Image]::FromFile((Join-Path $repositoryRoot "resources\assets\bongocat.png")) + try { + New-StoreLogo $sourceLogo 50 50 (Join-Path $assetsDirectory "StoreLogo.png") + New-StoreLogo $sourceLogo 44 44 (Join-Path $assetsDirectory "Square44x44Logo.png") + New-StoreLogo $sourceLogo 150 150 (Join-Path $assetsDirectory "Square150x150Logo.png") + } + finally { + $sourceLogo.Dispose() + } + + $makeAppx = Get-WindowsSdkTool "makeappx.exe" + & $makeAppx pack /o /h SHA256 /d $stagingDirectory /p $msixPath + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx failed with exit code $LASTEXITCODE." + } + + if ($SignForLocalTesting) { + $signTool = Get-WindowsSdkTool "signtool.exe" + $publisher = "CN=5503A135-7FA4-466B-815C-DBE627F4065F" + $certificate = New-SelfSignedCertificate ` + -Type Custom ` + -KeyUsage DigitalSignature ` + -KeyExportPolicy Exportable ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") ` + -Subject $publisher ` + -FriendlyName "BongoCat temporary Microsoft Store package signing" + + $CertificateOutputPath = [System.IO.Path]::GetFullPath($CertificateOutputPath) + New-Item -ItemType Directory -Force -Path (Split-Path $CertificateOutputPath -Parent) | Out-Null + Export-Certificate -Cert $certificate -FilePath $CertificateOutputPath | Out-Null + + $plainPassword = [guid]::NewGuid().ToString("N") + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + Export-PfxCertificate -Cert $certificate -FilePath $certificatePath -Password $securePassword | Out-Null + + & $signTool sign /fd SHA256 /f $certificatePath /p $plainPassword $msixPath + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed with exit code $LASTEXITCODE." + } + } + + if ($SignForLocalTesting) { + Write-Host "Signed local test package created: $msixPath" + } + else { + Write-Host "Unsigned Microsoft Store submission package created: $msixPath" + } + Write-Host "Package identity version: $PackageVersion" + if ($SignForLocalTesting) { + Write-Host "Certificate exported for local installation: $CertificateOutputPath" + } +} +finally { + if ($certificate) { + Remove-Item ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue + } + Remove-Item $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/packaging/microsoft-store/validate-store-package.ps1 b/packaging/microsoft-store/validate-store-package.ps1 new file mode 100644 index 00000000..701bf88e --- /dev/null +++ b/packaging/microsoft-store/validate-store-package.ps1 @@ -0,0 +1,199 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackagePath, + + [ValidateSet('x86', 'x64')] + [string]$ExpectedArchitecture = 'x64' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = (Resolve-Path (Join-Path $scriptDirectory '..\..')).Path +$projectVersion = & (Join-Path $repositoryRoot 'packaging\get-project-version.ps1') +$ExpectedAppVersion = $projectVersion.AppVersion +$ExpectedPackageVersion = $projectVersion.StorePackageVersion +$PackagePath = (Resolve-Path $PackagePath).Path +if ((Split-Path $PackagePath -Leaf) -notmatch + "^bongocat_$([regex]::Escape($ExpectedAppVersion))_") { + throw "Package filename does not match app version $ExpectedAppVersion." +} + +function Get-WindowsSdkTool { + param([Parameter(Mandatory = $true)][string]$Name) + + $sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $sdkVersions = Get-ChildItem $sdkRoot -Directory | + Where-Object { $_.Name -match '^\d+\.\d+' } | + Sort-Object { [version]$_.Name } -Descending + foreach ($sdkVersion in $sdkVersions) { + $candidate = Join-Path $sdkVersion.FullName "x64\$Name" + if (Test-Path $candidate) { return $candidate } + } + throw "$Name was not found. Install the Windows 10/11 SDK." +} + +function Get-PeMachine { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + throw 'The packaged executable does not have an MZ header.' + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + if ($peOffset -lt 0x40 -or $peOffset -gt $stream.Length - 6) { + throw 'The packaged executable has an invalid PE offset.' + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw 'The packaged executable does not have a PE signature.' + } + return $reader.ReadUInt16() + } + finally { + $reader.Dispose() + $stream.Dispose() + } +} + +if (-not ('BongoCatPackageIdentityNative' -as [type])) { + Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; +using System.Text; +public static class BongoCatPackageIdentityNative { + [DllImport("kernelbase.dll", CharSet = CharSet.Unicode)] + public static extern int PackagePublisherIdFromPublisher( + string publisher, ref uint length, StringBuilder publisherId); +} +'@ +} + +$signature = Get-AuthenticodeSignature -FilePath $PackagePath +if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned) { + throw "Store submission package must be unsigned; status is $($signature.Status)." +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'BongoCatStoreValidation_' + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $makeAppx = Get-WindowsSdkTool 'makeappx.exe' + & $makeAppx unpack /o /p $PackagePath /d $temporaryRoot | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx unpack failed with exit code $LASTEXITCODE." + } + + $manifestPath = Join-Path $temporaryRoot 'AppxManifest.xml' + [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw + $identity = $manifest.Package.Identity + $expectedIdentity = @{ + Name = 'vladelaina.bongocat' + Publisher = 'CN=5503A135-7FA4-466B-815C-DBE627F4065F' + Version = $ExpectedPackageVersion + ProcessorArchitecture = $ExpectedArchitecture + } + foreach ($field in $expectedIdentity.Keys) { + if ($identity.$field -ne $expectedIdentity[$field]) { + throw "Manifest Identity $field is '$($identity.$field)'; expected '$($expectedIdentity[$field])'." + } + } + + $application = $manifest.Package.Applications.Application + if ($application.Id -ne 'BongoCat' -or + $application.Executable -ne 'BongoCat.exe' -or + $application.EntryPoint -ne 'Windows.FullTrustApplication') { + throw 'Manifest application entry does not describe the BongoCat full-trust executable.' + } + + $namespaceManager = [Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespaceManager.AddNamespace( + 'desktop7', + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/7') + $shortcutExtension = $manifest.SelectSingleNode( + "//*[local-name()='Application']/*[local-name()='Extensions']/" + + "desktop7:Extension[@Category='windows.shortcut']", + $namespaceManager) + $shortcut = if ($shortcutExtension) { + $shortcutExtension.SelectSingleNode('desktop7:Shortcut', $namespaceManager) + } + else { + $null + } + if (-not $shortcutExtension -or + $shortcutExtension.Executable -ne 'BongoCat.exe' -or + $shortcutExtension.EntryPoint -ne 'Windows.FullTrustApplication' -or + -not $shortcut -or + $shortcut.File -ne '$(Desktop)\BongoCat.lnk' -or + $shortcut.Icon -ne 'BongoCat.exe') { + throw 'Manifest does not declare the expected system-managed desktop shortcut.' + } + + $requiredFiles = @( + 'BongoCat.exe', + 'Assets\StoreLogo.png', + 'Assets\Square44x44Logo.png', + 'Assets\Square150x150Logo.png' + ) + foreach ($relativePath in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $temporaryRoot $relativePath))) { + throw "Required package payload is missing: $relativePath" + } + } + if (Test-Path -LiteralPath (Join-Path $temporaryRoot 'AppxSignature.p7x')) { + throw 'Unsigned Store submission package unexpectedly contains AppxSignature.p7x.' + } + + $executablePath = Join-Path $temporaryRoot 'BongoCat.exe' + $versionInfo = (Get-Item -LiteralPath $executablePath).VersionInfo + $packagedAppVersion = '{0}.{1}.{2}' -f $versionInfo.FileMajorPart, + $versionInfo.FileMinorPart, $versionInfo.FileBuildPart + if ($packagedAppVersion -ne $ExpectedAppVersion) { + throw "Packaged executable version is $packagedAppVersion; expected $ExpectedAppVersion." + } + + $machine = Get-PeMachine $executablePath + $expectedMachine = if ($ExpectedArchitecture -eq 'x64') { 0x8664 } else { 0x014C } + if ($machine -ne $expectedMachine) { + throw ('Packaged executable machine is 0x{0:X4}; expected {1}.' -f + $machine, $ExpectedArchitecture) + } + + $publisherLength = 0 + $publisherResult = [BongoCatPackageIdentityNative]::PackagePublisherIdFromPublisher( + $identity.Publisher, [ref]$publisherLength, $null) + if ($publisherResult -ne 122) { + throw "Publisher ID sizing failed with Windows error $publisherResult." + } + $publisherId = [Text.StringBuilder]::new([int]$publisherLength) + $publisherResult = [BongoCatPackageIdentityNative]::PackagePublisherIdFromPublisher( + $identity.Publisher, [ref]$publisherLength, $publisherId) + if ($publisherResult -ne 0) { + throw "Publisher ID calculation failed with Windows error $publisherResult." + } + $packageFamilyName = "$($identity.Name)_$publisherId" + if ($packageFamilyName -ne 'vladelaina.bongocat_hnew8t3b8e0t6') { + throw "Derived package family name is unexpected: $packageFamilyName" + } + + Write-Host 'Microsoft Store package validation passed.' + Write-Host "Identity: $($identity.Name)" + Write-Host "Version: $($identity.Version)" + Write-Host "Package family name: $packageFamilyName" + Write-Host 'Signature: unsigned (Partner Center submission)' +} +finally { + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + $resolvedTemporaryRoot = [IO.Path]::GetFullPath($temporaryRoot) + if ($resolvedTemporaryRoot.StartsWith($tempPrefix, + [StringComparison]::OrdinalIgnoreCase) -and + (Split-Path $resolvedTemporaryRoot -Leaf) -like + 'BongoCatStoreValidation_*') { + Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force ` + -ErrorAction SilentlyContinue + } +} diff --git a/packaging/windows/BongoCat.iss.in b/packaging/windows/BongoCat.iss.in new file mode 100644 index 00000000..e2fc9398 --- /dev/null +++ b/packaging/windows/BongoCat.iss.in @@ -0,0 +1,48 @@ +#ifndef PayloadDir + #error PayloadDir must point to the staged CMake Runtime component +#endif + +[Setup] +AppId=BongoCat +AppName=@BONGO_CAT_PACKAGE_PRODUCT@ +AppVersion=@PROJECT_VERSION@ +VersionInfoVersion=@PROJECT_VERSION@ +VersionInfoTextVersion=@PROJECT_VERSION@ +AppPublisher=vladelaina +AppPublisherURL=@PROJECT_HOMEPAGE_URL@ +AppSupportURL=@PROJECT_HOMEPAGE_URL@ +DefaultDirName={code:GetInstallDir} +DefaultGroupName=BongoCat +PrivilegesRequired=lowest +DisableProgramGroupPage=yes +DisableWelcomePage=yes +DisableDirPage=auto +UsePreviousAppDir=yes +OutputDir=@CMAKE_BINARY_DIR@/dist +OutputBaseFilename=@BONGO_CAT_PACKAGE_NAME@-setup +SetupIconFile=@CMAKE_CURRENT_SOURCE_DIR@/resources/icons/icon.ico +UninstallDisplayIcon={app}\BongoCat.exe +Compression=zip +WizardStyle=modern +CloseApplications=yes +CloseApplicationsFilter=BongoCat.exe +RestartApplications=no +SetupLogging=yes +#if "@BONGO_CAT_PACKAGE_ARCH@" == "x64" +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +#endif + +[Files] +Source: "{#PayloadDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{userdesktop}\BongoCat"; Filename: "{app}\BongoCat.exe"; Parameters: "--nearby-root=""{userdesktop}"""; WorkingDir: "{userdesktop}" +Name: "{userprograms}\BongoCat\BongoCat"; Filename: "{app}\BongoCat.exe"; Parameters: "--nearby-root=""{userprograms}\BongoCat"""; WorkingDir: "{userprograms}\BongoCat" +Name: "{userprograms}\BongoCat\Uninstall"; Filename: "{uninstallexe}" + +[Run] +Filename: "{app}\BongoCat.exe"; Description: "Launch BongoCat"; Flags: nowait postinstall skipifsilent + +[Code] +#include "install-lifecycle.iss" diff --git a/packaging/windows/build-installer.ps1 b/packaging/windows/build-installer.ps1 new file mode 100644 index 00000000..863b6a0b --- /dev/null +++ b/packaging/windows/build-installer.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BuildDir, + [Parameter(Mandatory = $true)][string]$Configuration, + [Parameter(Mandatory = $true)][string]$PackageName +) +$ErrorActionPreference = 'Stop' +$BuildDir = [IO.Path]::GetFullPath($BuildDir) +$log = Join-Path $BuildDir 'installer.log' +Set-Content -LiteralPath $log -Value 'BongoCat installer packaging' -Encoding utf8 + +function Invoke-PackagingCommand { + param([string]$Executable, [string[]]$Arguments) + # Windows PowerShell wraps native stderr as ErrorRecord objects. Keep + # collecting output until the process exits, then check its actual status. + $ErrorActionPreference = 'Continue' + & $Executable @Arguments 2>&1 | ForEach-Object { + $line = $_.ToString() + Add-Content -LiteralPath $log -Value $line -Encoding utf8 -ErrorAction Stop + Write-Host $line + } + $status = $LASTEXITCODE + if ($status -ne 0) { + throw "$(Split-Path $Executable -Leaf) failed (exit $status). Log: $log" + } +} + +try { + $iscc = & "$PSScriptRoot/find-inno.ps1" + Write-Host "Inno Setup compiler: $iscc" + # A fresh staging directory avoids shipping files left by an older build. + $stage = Join-Path $BuildDir ('inno-stage-' + [Guid]::NewGuid().ToString('N')) + try { + Invoke-PackagingCommand -Executable 'cmake' -Arguments @( + '--install', $BuildDir, '--config', $Configuration, + '--component', 'Runtime', '--prefix', $stage) + Invoke-PackagingCommand -Executable $iscc -Arguments @( + "/DPayloadDir=$stage", (Join-Path $BuildDir 'BongoCat.iss')) + $output = Join-Path $BuildDir "dist/$PackageName-setup.exe" + # MSBuild launches Windows PowerShell with the runner's inherited module + # paths; Get-FileHash may be unavailable there. Use the .NET stream API. + $sha256 = [Security.Cryptography.SHA256]::Create() + $stream = $null + try { + $stream = [IO.File]::OpenRead($output) + $hash = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + if ($null -ne $stream) { $stream.Dispose() } + $sha256.Dispose() + } + "$hash $PackageName-setup.exe" | Set-Content -LiteralPath "$output.sha256" -Encoding ascii + } finally { + # Only remove the unique stage directory directly below the build directory. + $stageFull = [IO.Path]::GetFullPath($stage) + $buildFull = [IO.Path]::GetFullPath($BuildDir).TrimEnd('\', '/') + if ((Split-Path $stageFull -Parent) -eq $buildFull -and + (Split-Path $stageFull -Leaf) -match '^inno-stage-[0-9a-f]{32}$' -and + (Test-Path -LiteralPath $stageFull)) { + Remove-Item -LiteralPath $stageFull -Recurse -Force + } + } +} catch { + $summary = $_.Exception.Message + Add-Content -LiteralPath $log -Value $summary -Encoding utf8 + Write-Host 'Installer failure details:' + Get-Content -LiteralPath $log -Tail 30 | ForEach-Object { Write-Host $_ } + if ($env:GITHUB_ACTIONS -eq 'true') { + $detail = (Get-Content -LiteralPath $log -Tail 12) -join "`n" + $detail = $detail.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Inno Setup packaging failed::$detail" + } + throw +} diff --git a/packaging/windows/find-inno.ps1 b/packaging/windows/find-inno.ps1 new file mode 100644 index 00000000..751b4689 --- /dev/null +++ b/packaging/windows/find-inno.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param([switch]$PassThru) +$ErrorActionPreference = 'Stop' +$candidates = @() +$registeredVersions = @{} +if ($env:BONGO_CAT_ISCC) { $candidates += $env:BONGO_CAT_ISCC.Trim().Trim('"') } +$command = Get-Command ISCC.exe -ErrorAction SilentlyContinue +if ($command) { $candidates += $command.Source } +foreach ($base in @(${env:ProgramFiles(x86)}, $env:ProgramFiles, "$env:LOCALAPPDATA\Programs")) { + if ($base) { $candidates += Join-Path $base 'Inno Setup 6\ISCC.exe' } +} +foreach ($key in @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1')) { + $entry = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if ($entry -and $entry.InstallLocation) { + $path = [IO.Path]::GetFullPath((Join-Path $entry.InstallLocation.Trim().Trim('"') 'ISCC.exe')) + $candidates += $path + $registeredVersions[$path] = [string]$entry.DisplayVersion + } +} +$rejected = @() +foreach ($candidate in ($candidates | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + $path = (Resolve-Path -LiteralPath $candidate).Path + $fileVersion = (Get-Item -LiteralPath $path).VersionInfo + $version = [version]::new($fileVersion.FileMajorPart, $fileVersion.FileMinorPart, + $fileVersion.FileBuildPart, $fileVersion.FilePrivatePart) + $versionSource = 'FileVersion' + # Some official builds report 0.0.0.0 in ISCC.exe (and ISCmplr.dll). + # Use DisplayVersion only from a registration for this exact compiler path. + if ($version.Major -eq 0 -and $registeredVersions.ContainsKey($path)) { + $parsed = $null + if ([version]::TryParse($registeredVersions[$path], [ref]$parsed)) { + $version = $parsed + $versionSource = 'Registry DisplayVersion' + } + } + if ($version.Major -eq 6 -and $version.Minor -ge 3) { + if ($PassThru) { + return [PSCustomObject]@{ + Path = $path + Version = $version + VersionSource = $versionSource + } + } + return $path + } + $rejected += "$path (version $version, $versionSource)" +} +$details = if ($rejected.Count) { " Found but rejected: $($rejected -join '; ')." } else { '' } +throw "Inno Setup 6.3 or newer (6.x) is required for packaging. Install from https://jrsoftware.org/isdl.php or set BONGO_CAT_ISCC to ISCC.exe. Normal application builds do not require Inno Setup.$details" diff --git a/packaging/windows/inno-version.txt b/packaging/windows/inno-version.txt new file mode 100644 index 00000000..1d420242 --- /dev/null +++ b/packaging/windows/inno-version.txt @@ -0,0 +1 @@ +6.7.1 \ No newline at end of file diff --git a/packaging/windows/install-lifecycle.iss.in b/packaging/windows/install-lifecycle.iss.in new file mode 100644 index 00000000..8c149d34 --- /dev/null +++ b/packaging/windows/install-lifecycle.iss.in @@ -0,0 +1,147 @@ +{ Installation discovery, upgrade migration, and uninstall data policy. } +const + UninstallBase = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\'; +var + ExistingDir: String; + RemoveUserData: Boolean; + +function CompareVersions(Left, Right: String): Integer; +var + LeftVersion, RightVersion: Int64; +begin + if not StrToVersion(Left, LeftVersion) then + RaiseException('Invalid installed version: ' + Left); + if not StrToVersion(Right, RightVersion) then + RaiseException('Invalid package version: ' + Right); + Result := ComparePackedVersion(LeftVersion, RightVersion); +end; + +function RegisteredDir(Root: Integer; Key: String): String; +begin + Result := ''; + RegQueryStringValue(Root, UninstallBase + Key, 'InstallLocation', Result); + if (Result = '') and (Key = 'BongoCat') then + RegQueryStringValue(Root, 'Software\BongoCat\BongoCat', '', Result); + Result := RemoveBackslashUnlessRoot(RemoveQuotes(Result)); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; +end; + +function FindExistingDir: String; +begin + { Prefer Inno's registration after the first migration, in either view. } + Result := RegisteredDir(HKCU32, 'BongoCat_is1'); + if (Result = '') and IsWin64 then Result := RegisteredDir(HKCU64, 'BongoCat_is1'); + if Result = '' then Result := RegisteredDir(HKCU32, 'BongoCat'); + if (Result = '') and IsWin64 then Result := RegisteredDir(HKCU64, 'BongoCat'); + if Result = '' then begin + Result := ExpandConstant('{localappdata}\Programs\BongoCat'); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; + end; + if Result = '' then begin + Result := ExpandConstant('{localappdata}\..\LocalPrograms\BongoCat'); + if not FileExists(Result + '\BongoCat.exe') then Result := ''; + end; +end; + +function GetInstallDir(Param: String): String; +begin + if ExistingDir <> '' then Result := ExistingDir + else Result := ExpandConstant('{localappdata}\Programs\BongoCat'); +end; + +function InitializeSetup: Boolean; +var + Version: String; +begin + ExistingDir := FindExistingDir; + Result := True; + { Equal versions must be allowed: a same-version NSIS -> Inno migration + still has to replace the installer registration. Never downgrade. } + if (ExistingDir <> '') and + GetVersionNumbersString(ExistingDir + '\BongoCat.exe', Version) then + if CompareVersions(Version, '@PROJECT_VERSION@') > 0 then begin + SuppressibleMsgBox('A newer version of BongoCat is already installed.', mbError, MB_OK, IDOK); + Result := False; + end; +end; + +procedure InitializeWizard; +begin + if ExistingDir <> '' then begin + WizardForm.DirEdit.Text := ExistingDir; + WizardForm.DirEdit.Enabled := False; + WizardForm.DirBrowseButton.Enabled := False; + end; +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := (PageID = wpSelectDir) and (ExistingDir <> ''); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + App, Version: String; + ExitCode: Integer; +begin + Result := ''; + if (ExistingDir <> '') and + (CompareText(RemoveBackslashUnlessRoot(ExpandConstant('{app}')), + RemoveBackslashUnlessRoot(ExistingDir)) <> 0) then begin + Result := 'An upgrade must use the existing BongoCat installation directory.'; + Exit; + end; + App := ExpandConstant('{app}\BongoCat.exe'); + if GetVersionNumbersString(App, Version) then begin + if CompareVersions(Version, '@PROJECT_VERSION@') > 0 then begin + Result := 'A newer version of BongoCat is already installed in this directory.'; + Exit; + end; + { 0.1.0 treats unknown switches as a normal launch and would wait forever. + Inno Restart Manager handles that version and any shutdown failures. } + if CompareVersions(Version, '0.1.1') >= 0 then + if not Exec(App, '--shutdown-for-update', ExpandConstant('{app}'), + SW_HIDE, ewWaitUntilTerminated, ExitCode) then + Log('Update shutdown unavailable; using Restart Manager.'); + end; +end; + +procedure RemoveLegacyRegistration(Root: Integer); +var + Path: String; +begin + Path := RegisteredDir(Root, 'BongoCat'); + if (Path <> '') and + (CompareText(Path, RemoveBackslashUnlessRoot(ExpandConstant('{app}'))) = 0) then begin + if not RegDeleteKeyIncludingSubkeys(Root, UninstallBase + 'BongoCat') then + Log('Could not remove the legacy NSIS uninstall registration.'); + RegDeleteKeyIncludingSubkeys(Root, 'Software\BongoCat\BongoCat'); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then begin + { Only retire NSIS after successful installation. Do not run its uninstaller: + old releases may delete user files. Inno owns only its installed payload. } + RemoveLegacyRegistration(HKCU32); + if IsWin64 then RemoveLegacyRegistration(HKCU64); + DeleteFile(ExpandConstant('{app}\Uninstall.exe')); + DeleteFile(ExpandConstant('{userprograms}\BongoCat\Uninstall BongoCat.lnk')); + end; +end; + +function InitializeUninstall: Boolean; +begin + RemoveUserData := False; + if not UninstallSilent then + RemoveUserData := MsgBox('Also delete settings, imported models, and other BongoCat user data?' + + #13#10 + 'Choose No to keep your data.', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES; + Result := True; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if (CurUninstallStep = usPostUninstall) and RemoveUserData then + DelTree(ExpandConstant('{localappdata}\BongoCat'), True, True, True); +end; diff --git a/resources/assets/bongocat.png b/resources/assets/bongocat.png new file mode 100644 index 00000000..31702146 Binary files /dev/null and b/resources/assets/bongocat.png differ diff --git a/resources/assets/licenses/libwebp.txt b/resources/assets/licenses/libwebp.txt new file mode 100644 index 00000000..7a6f9954 --- /dev/null +++ b/resources/assets/licenses/libwebp.txt @@ -0,0 +1,30 @@ +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/resources/assets/licenses/nanosvg.txt b/resources/assets/licenses/nanosvg.txt new file mode 100644 index 00000000..f896f2eb --- /dev/null +++ b/resources/assets/licenses/nanosvg.txt @@ -0,0 +1,18 @@ +Copyright (c) 2013-14 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/resources/assets/locales/de-DE.json b/resources/assets/locales/de-DE.json index a00445b2..8bf5d6a2 100644 --- a/resources/assets/locales/de-DE.json +++ b/resources/assets/locales/de-DE.json @@ -9,38 +9,46 @@ "preference": { "title": "Einstellungen", "cat": { - "title": "Anzeige", + "title": "Einstellungen", "labels": { - "modelSettings": "Modelleinstellungen", + "modelSettings": "Modell", "mirrorMode": "Spiegelmodus", "mouseMirror": "Maus spiegeln", "mouseCentered": "Maus auf Desktop-Begleiter zentriert", "ignoreMouse": "Mausereignisse ignorieren", - "windowSettings": "Fenstereinstellungen", + "windowSettings": "Fenster", "passThrough": "Klicks durchlassen", "alwaysOnTop": "Immer im Vordergrund", "keepInScreen": "Auf dem Bildschirm halten", "windowSize": "Fenstergröße", + "windowCorners": "Fensterrundung (%)", "opacity": "Deckkraft", - "autoReleaseDelay": "Verzögerung der automatischen Freigabe", + "randomExpression": "Zufällige Ausdrücke", + "obsBackground": "Einfarbiger Hintergrund", "hideOnHover": "Beim Darüberfahren ausblenden", + "hideFadeSeconds": "Ausblendedauer (s)", "maxFPS": "Maximale Bildrate" }, "hints": { - "passThrough": "Wenn aktiviert, gehen Klicks durch das Fenster hindurch, ohne es zu beeinflussen.", + "hoverUnavailable": "Ausblenden bei Mausberührung ist in dieser Desktop-Umgebung nicht verfügbar.", + "gameInput": "Desktop-Tier reagiert im Spiel nicht?", + "gameInputHelp": "Versuche, BongoCat als Administrator auszuführen und das Spiel in den Fenstermodus zu versetzen.", + "passThrough": "Auch per Rechtsklick auf das Symbol im Infobereich deaktivierbar", "alwaysOnTop": "Wenn aktiviert, bleibt das Fenster über allen anderen Fenstern.", "keepInScreen": "Wenn aktiviert, bleibt das Fenster automatisch innerhalb der Bildschirmgrenzen.", + "obsBackground": "Fensteraufnahme schwarz?", + "obsBackgroundHelp": "OBS: Diese Option aktivieren, die Windows-7-Kompatibilitätsmethode verwenden und den Hintergrund mit einem Farbschlüssel-Filter entfernen.", "windowSize": "Bewegen Sie die Maus an den Fensterrand oder halten Sie Umschalt gedrückt und ziehen Sie mit der rechten Maustaste.", - "hideOnHover": "Wenn aktiviert, wird das Fenster beim Darüberfahren mit der Maus automatisch ausgeblendet. Die Verzögerung 0 blendet es sofort aus." + "hideOnHover": "Wenn Klickdurchlässigkeit und Immer im Vordergrund aktiviert sind, wird das Haustier beim Darüberfahren sofort ausgeblendet und beim Verlassen wieder angezeigt." } }, "general": { "title": "Allgemein", "labels": { - "appSettings": "Anwendungseinstellungen", + "appSettings": "Anwendung", "launchOnStartup": "Beim Systemstart ausführen", "showTrayIcon": "Infobereichssymbol anzeigen", - "appearanceSettings": "Darstellung", + "appearanceSettings": "Erscheinungsbild", "themeMode": "Design", "language": "Sprache", "permissionsSettings": "Berechtigungen", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "Wenn aktiviert, wird das Anwendungssymbol im Infobereich angezeigt.", - "inputMonitoringPermission": "Aktivieren Sie die Eingabeüberwachung, um Tastatur- und Mausereignisse vom System zu empfangen.", - "inputMonitoringPermissionGuide": "Die Berechtigung zur Eingabeüberwachung ist erforderlich, damit die Anwendung Tastatur- und Mausereignisse vom System empfangen kann.\n\nSchritte:\n1. Öffnen Sie Systemeinstellungen > Datenschutz & Sicherheit > Eingabeüberwachung.\n2. Falls BongoCat bereits aufgeführt ist, wählen Sie es aus und entfernen Sie es mit der Schaltfläche \"-\".\n3. Klicken Sie auf \"+\", um BongoCat zu suchen und hinzuzufügen.\n4. Starten Sie die Anwendung neu, damit die Berechtigung wirksam wird.", + "autostartFailed": "Die Einstellung für den automatischen Start konnte nicht aktualisiert werden", + "inputMonitoringPermission": "Nach dem Erlauben neu starten.", + "inputMonitoringPermissionGuide": "Die Eingabeüberwachung erlaubt BongoCat, in anderen Apps gedrückte Tasten zu sehen; die Maus funktioniert auch ohne sie. BongoCat baut die Überwachung beim Start auf, eine Berechtigungsänderung erfordert daher einen Neustart.\n\nSchritte:\n1. Öffnen Sie Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung.\n2. Schalten Sie den bereits gelisteten BongoCat-Eintrag ein; entfernen Sie keine vorhandenen Einträge.\n3. Falls BongoCat fehlt, klicken Sie auf \"+\" und fügen Sie die laufende Kopie hinzu (prüfen Sie den Speicherort).\n4. Beenden Sie BongoCat vollständig und starten Sie es erneut, damit es die Überwachung neu aufbaut.\n\nWenn die Tastatur weiterhin nicht reagiert, können Sie den BongoCat-Eintrag selbst entfernen und neu hinzufügen.", "administratorPermission": "Als Administrator kann die Anwendung bestimmte Systemtasten und Eingabeereignisse zuverlässiger erfassen.", "administratorPermissionGuide": "Für eine zuverlässigere Erfassung bestimmter Systemtasten und Eingabeereignisse wird empfohlen, die Anwendung als Administrator auszuführen.\n\nVorgehen:\n1. Beenden Sie zuerst die Anwendung.\n2. Suchen Sie die exe-Datei oder ihre Verknüpfung im Datei-Explorer.\n3. Wählen Sie die Startart:\n - Einmalig: Rechtsklick und \"Als Administrator ausführen\" wählen.\n - Immer: Rechtsklick > Eigenschaften > Kompatibilität > \"Programm als Administrator ausführen\" aktivieren > OK." }, @@ -63,7 +72,8 @@ "authorized": "Autorisiert", "adminEnabled": "Aktiviert", "authorize": "Aktivieren", - "viewGuide": "Anleitung anzeigen" + "viewGuide": "Anleitung anzeigen", + "openSettingsFailed": "Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung." }, "buttons": { "openNow": "Jetzt öffnen", @@ -74,15 +84,27 @@ }, "model": { "title": "Modell", + "nearbyTitle": "Nahe Modelle", "labels": { - "deleteModel": "Modell löschen", - "nearbyModels": "Benachbarte Ordner" + "deleteModel": "Modell löschen" }, "hints": { - "deleteSuccess": "Erfolgreich gelöscht", - "deleteModel": "Möchten Sie dieses Modell wirklich löschen?", + "deleteSuccess": "\"%s\" wurde gelöscht", + "deleteModel": "Möchten Sie \"%s\" wirklich löschen?", + "deleteFailed": "Dieses Modell konnte nicht gelöscht werden", "importSuccess": "Erfolgreich importiert", - "clickOrDragToImport": "Zum Importieren klicken oder hierher ziehen", + "importExists": "Modell ist bereits vorhanden", + "importBatchResult": "Import erfolgreich: %zu; fehlgeschlagen: %zu.\nFehlende Modelle: %s%s.\nErster Fehler: %s", + "importMoreFailures": " und %zu weitere", + "importUnknownSource": "unbekannte Quelle", + "importInvalidSource": "Die ausgewählte Quelle ist nicht mehr vorhanden oder kann nicht verwendet werden", + "importInvalidFormat": "Es wurde kein unterstütztes Modell gefunden oder das Modellpaket ist unvollständig", + "importFileAccess": "Die Modelldateien konnten nicht gelesen oder gespeichert werden. Prüfen Sie die Dateiberechtigungen und den verfügbaren Speicherplatz", + "importOutOfMemory": "Zum Importieren dieses Modells ist nicht genügend Arbeitsspeicher verfügbar", + "importLoadFailed": "Das Modell wurde importiert, konnte aber nicht angezeigt werden", + "importFailed": "Der Modellimport ist fehlgeschlagen. Prüfen Sie die ausgewählten Dateien und versuchen Sie es erneut", + "clickOrDragToImport": "Modellordner wählen oder ziehen", + "supportedImportFormats": "Unterstützt Mver und Tauri", "openDirectoryFailed": "Modellordner konnte nicht geöffnet werden" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "Weitere Modelle" }, "behaviorModal": { - "title": "Bewegungen und Ausdrücke", + "title": "Bewegungen, Ausdrücke und Audio", "labels": { "motion": "Bewegung", "expression": "Ausdruck", + "audio": "Audio", + "stopAllAudio": "Alle Audios stoppen", "motionIndex": "Bewegung {index}", "motionGroupIndex": "Bewegungsgruppe {index}", "expressionIndex": "Ausdruck {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Mehrere gleichzeitig anzeigen", + "multiplePetsLimit": "Bis zu 8 Desktop-Begleiter k\u00f6nnen gleichzeitig angezeigt werden", + "removeDesktopPet": "Diesen Desktop-Begleiter schlie\u00dfen", "modelLoadFailed": "Dieses Modell kann nicht angezeigt werden", "noBehaviors": "Keine Einträge", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat konnte nicht gestartet werden.", + "detail": "Technische Details finden Sie im Diagnoseprotokoll.", + "diagnosticLog": "Diagnoseprotokoll:" + }, "support": { - "title": "Entwicklungsteam unterstützen", + "title": "Info", + "website": "Website: bongocat.pet", "heroText": "Vielen Dank für Ihre Unterstützung. Jede Nutzung und Weitergabe hilft BongoCat, weiter zu wachsen.", "version": "Anwendungsversion", "checkUpdate": "Nach Updates suchen", "feedback": "Feedback", + "openLogs": "Protokolle öffnen", + "openLogsFailed": "Protokollordner konnte nicht geöffnet werden", "latest": "Bereits aktuell", + "checkingUpdate": "Wird geprüft...", + "updateAvailable": "Neue Version verfügbar:", + "updateFailed": "Updates konnten nicht geprüft werden", + "updateHttpFailed": "GitHub meldete den HTTP-Status %s", + "downloadUpdate": "Auf v%s aktualisieren", + "openStore": "Microsoft Store öffnen", + "viewReleases": "Versionen anzeigen", + "openUpdateFailed": "Die Update-Seite konnte nicht geöffnet werden", "works": "Weitere Anwendungen", - "worksText": "Weitere Software von vladelaina", - "refactorDeveloper": "(Entwickler, der BongoCat in C neu aufgebaut hat)", + "worksText": "Weitere Werke von vladelaina, die einen Blick wert sind", + "refactorDeveloper": "Entwickler, der BongoCat in C neu geschrieben hat und zugleich aktueller Maintainer ist", "community": "Community", "qqGroup": "QQ-Gruppe", - "coffee": "Dem Entwicklungsteam einen Kaffee spendieren?" + "coffee": "Dem Entwickler einen Kaffee spendieren?", + "starOnGitHub": "GitHub-Stern!", + "by": "von " }, "shortcut": { "toggleCat": "Ein-/ausblenden", @@ -203,6 +267,7 @@ "select": "Auswählen", "selected": "Ausgewählt", "import": "Importieren", + "importing": "Wird importiert...", "builtIn": "integriert" } } diff --git a/resources/assets/locales/en-US.json b/resources/assets/locales/en-US.json index da82d6a2..6d93b723 100644 --- a/resources/assets/locales/en-US.json +++ b/resources/assets/locales/en-US.json @@ -9,38 +9,46 @@ "preference": { "title": "Preferences", "cat": { - "title": "Display", + "title": "Preferences", "labels": { - "modelSettings": "Model Settings", + "modelSettings": "Model", "mirrorMode": "Mirror Mode", "mouseMirror": "Mouse Mirror", "mouseCentered": "Mouse Centered on Desktop Pet", "ignoreMouse": "Ignore Mouse Events", - "windowSettings": "Window Settings", + "windowSettings": "Window", "passThrough": "Pass Through", "alwaysOnTop": "Always on Top", "keepInScreen": "Keep on Screen", "windowSize": "Window Size", + "windowCorners": "Window Corners (%)", "opacity": "Opacity", - "autoReleaseDelay": "Auto Release Delay", + "randomExpression": "Random Expressions", + "obsBackground": "Solid Background", "hideOnHover": "Hide on Hover", + "hideFadeSeconds": "Fade Duration (s)", "maxFPS": "Max Frame Rate" }, "hints": { - "passThrough": "When enabled, clicks pass through the window without affecting it.", + "hoverUnavailable": "Hover hiding is unavailable in the current desktop environment.", + "gameInput": "Pet not responding in games?", + "gameInputHelp": "Try running BongoCat as administrator and setting the game to windowed mode.", + "passThrough": "You can also turn this off by right-clicking the tray icon", "alwaysOnTop": "When enabled, the window stays above all other windows.", "keepInScreen": "When enabled, the window automatically stays within the screen boundaries.", + "obsBackground": "Window capture is black?", + "obsBackgroundHelp": "OBS: enable this option, use the Windows 7 compatibility method, and remove the background with a color key filter.", "windowSize": "Move mouse to window edge, or hold Shift and right-drag to resize.", - "hideOnHover": "When enabled, the window hides automatically on mouse hover. You can set a delay; 0 means hide immediately." + "hideOnHover": "With Pass Through and Always on Top enabled, hide the pet immediately on hover and show it again when the pointer leaves." } }, "general": { "title": "General", "labels": { - "appSettings": "Application Settings", + "appSettings": "Application", "launchOnStartup": "Launch on Startup", "showTrayIcon": "Show Tray Icon", - "appearanceSettings": "Appearance Settings", + "appearanceSettings": "Appearance", "themeMode": "Theme", "language": "Language", "permissionsSettings": "Permissions Settings", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", - "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", - "inputMonitoringPermissionGuide": "Input Monitoring permission is required for the app to receive keyboard and mouse events from the system.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. If BongoCat already appears in the list, select it and click the \"-\" button to remove it.\n3. Click the \"+\" button to find and add BongoCat.\n4. Restart the app for the permission to take effect.", + "autostartFailed": "Unable to update launch-on-startup settings", + "inputMonitoringPermission": "Restart after granting it.", + "inputMonitoringPermissionGuide": "Input Monitoring lets BongoCat see keys pressed in other apps; the mouse keeps working without it. BongoCat builds the listener when it starts, so a permission change needs a restart.\n\nSteps:\n1. Open System Settings → Privacy & Security → Input Monitoring.\n2. Turn on the BongoCat entry that is already listed; do not remove existing entries.\n3. If BongoCat is not listed, click the \"+\" button and add the copy you are running (check the app location).\n4. Quit BongoCat completely and start it again, so it rebuilds the listener.\n\nIf the keyboard still stays silent, you can remove and re-add the BongoCat entry yourself.", "administratorPermission": "Running the app as administrator helps capture some system-level keys and input events more reliably.", "administratorPermissionGuide": "To capture some system-level keys and input events more reliably, it is recommended to run the app as administrator.\n\nHow to do it:\n1. Exit the app first.\n2. Find the app exe or its shortcut in File Explorer.\n3. Choose how to launch:\n • One-time: Right-click and choose \"Run as administrator\".\n • Always (default): Right-click → Properties → Compatibility tab → check \"Run this program as an administrator\" → OK." }, @@ -63,7 +72,8 @@ "authorized": "Authorized", "adminEnabled": "Enabled", "authorize": "Go to Enable", - "viewGuide": "View Instructions" + "viewGuide": "View Instructions", + "openSettingsFailed": "Cannot open System Settings. Open Privacy & Security → Input Monitoring." }, "buttons": { "openNow": "Open Now", @@ -74,15 +84,27 @@ }, "model": { "title": "Model", + "nearbyTitle": "Nearby models", "labels": { - "deleteModel": "Delete Model", - "nearbyModels": "Nearby folders" + "deleteModel": "Delete Model" }, "hints": { - "deleteSuccess": "Deleted Successfully", - "deleteModel": "Are you sure you want to delete this model?", + "deleteSuccess": "Deleted \"%s\"", + "deleteModel": "Are you sure you want to delete \"%s\"?", + "deleteFailed": "Unable to delete this model", "importSuccess": "Imported Successfully", - "clickOrDragToImport": "Click or drag here to import", + "importExists": "Model already exists", + "importBatchResult": "Import succeeded: %zu; failed: %zu.\nFailed models: %s%s.\nFirst failure: %s", + "importMoreFailures": " and %zu more", + "importUnknownSource": "unknown source", + "importInvalidSource": "The selected source no longer exists or cannot be used", + "importInvalidFormat": "No supported model was found, or the model package is incomplete", + "importFileAccess": "The model files could not be read or saved. Check file permissions and available disk space", + "importOutOfMemory": "There is not enough memory to import this model", + "importLoadFailed": "The model was imported, but it could not be displayed", + "importFailed": "Model import failed. Check the selected files and try again", + "clickOrDragToImport": "Click or drag a model folder", + "supportedImportFormats": "Supports Mver and Tauri formats", "openDirectoryFailed": "Unable to open model directory" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "More Models" }, "behaviorModal": { - "title": "Motions and Expressions", + "title": "Motions, Expressions and Audio", "labels": { "motion": "Motion", "expression": "Expression", + "audio": "Audio", + "stopAllAudio": "Stop all audio", "motionIndex": "Motion {index}", "motionGroupIndex": "Motion Group {index}", "expressionIndex": "Expression {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Display multiple", + "multiplePetsLimit": "Up to 8 desktop pets can be displayed at once", + "removeDesktopPet": "Close this desktop pet", "modelLoadFailed": "Unable to display this model", "noBehaviors": "No items", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat could not start.", + "detail": "See the diagnostic log for technical details.", + "diagnosticLog": "Diagnostic log:" + }, "support": { - "title": "Support the Dev Team", + "title": "About", + "website": "Website: bongocat.pet", "heroText": "Thank you for your support. Every use and share helps BongoCat continue to grow.", "version": "App version", "checkUpdate": "Check for updates", "feedback": "Feedback", + "openLogs": "Open logs", + "openLogsFailed": "Unable to open logs folder", "latest": "Already up to date", + "checkingUpdate": "Checking...", + "updateAvailable": "New version available:", + "updateFailed": "Unable to check for updates", + "updateHttpFailed": "GitHub returned HTTP status %s", + "downloadUpdate": "Update to v%s", + "openStore": "Open Microsoft Store", + "viewReleases": "View releases", + "openUpdateFailed": "Unable to open the update page", "works": "More apps", - "worksText": "More software from vladelaina", - "refactorDeveloper": "(Developer who rebuilt BongoCat in C)", + "worksText": "More from vladelaina, well worth a try", + "refactorDeveloper": "Developer who rewrote BongoCat in C and is also its current maintainer", "community": "Community", "qqGroup": "QQ Group", - "coffee": "Buy the development team a coffee?" + "coffee": "Buy the developer a coffee?", + "starOnGitHub": "Star on GitHub!", + "by": "by " }, "shortcut": { "toggleCat": "Show/Hide", @@ -203,6 +267,7 @@ "select": "Select", "selected": "Selected", "import": "Import", + "importing": "Importing...", "builtIn": "built-in" } } diff --git a/resources/assets/locales/es-ES.json b/resources/assets/locales/es-ES.json index f2f55a22..72f78eaa 100644 --- a/resources/assets/locales/es-ES.json +++ b/resources/assets/locales/es-ES.json @@ -9,38 +9,46 @@ "preference": { "title": "Preferencias", "cat": { - "title": "Pantalla", + "title": "Preferencias", "labels": { - "modelSettings": "Ajustes del modelo", + "modelSettings": "Modelo", "mirrorMode": "Modo espejo", "mouseMirror": "Invertir ratón", "mouseCentered": "Ratón centrado en la mascota", "ignoreMouse": "Ignorar eventos del ratón", - "windowSettings": "Ajustes de la ventana", + "windowSettings": "Ventana", "passThrough": "Dejar pasar clics", "alwaysOnTop": "Siempre visible", "keepInScreen": "Mantener en pantalla", "windowSize": "Tamaño de la ventana", + "windowCorners": "Esquinas redondeadas (%)", "opacity": "Opacidad", - "autoReleaseDelay": "Retardo de liberación automática", + "randomExpression": "Expresiones aleatorias", + "obsBackground": "Fondo sólido", "hideOnHover": "Ocultar al pasar el ratón", + "hideFadeSeconds": "Duración del fundido (s)", "maxFPS": "Frecuencia máxima de fotogramas" }, "hints": { - "passThrough": "Al activarlo, los clics atraviesan la ventana sin afectarla.", + "hoverUnavailable": "Ocultar al pasar el cursor no está disponible en el entorno de escritorio actual.", + "gameInput": "¿La mascota no responde al jugar?", + "gameInputHelp": "Prueba a ejecutar BongoCat como administrador y a poner el juego en modo ventana.", + "passThrough": "También puedes desactivarlo con un clic derecho en el icono de la bandeja", "alwaysOnTop": "Al activarlo, la ventana permanece por encima de las demás.", "keepInScreen": "Al activarlo, la ventana se mantiene automáticamente dentro de los límites de la pantalla.", + "obsBackground": "¿La captura de ventana aparece en negro?", + "obsBackgroundHelp": "OBS: activa esta opción, usa el método compatible con Windows 7 y elimina el fondo con un filtro de clave de color.", "windowSize": "Mueva el ratón al borde de la ventana o mantenga Mayús y arrastre con el botón derecho para cambiar el tamaño.", - "hideOnHover": "Al activarlo, la ventana se oculta automáticamente al pasar el ratón. Puede definir un retardo; 0 significa ocultar inmediatamente." + "hideOnHover": "Con el paso de clics y Siempre visible activados, la mascota se oculta inmediatamente al pasar el ratón y reaparece al retirarlo." } }, "general": { "title": "General", "labels": { - "appSettings": "Ajustes de la aplicación", + "appSettings": "Aplicación", "launchOnStartup": "Iniciar con el sistema", "showTrayIcon": "Mostrar icono en la bandeja", - "appearanceSettings": "Ajustes de apariencia", + "appearanceSettings": "Apariencia", "themeMode": "Tema", "language": "Idioma", "permissionsSettings": "Ajustes de permisos", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "Al activarlo, el icono de la aplicación aparece en la bandeja del sistema.", - "inputMonitoringPermission": "Active la supervisión de entrada para recibir eventos de teclado y ratón del sistema.", - "inputMonitoringPermissionGuide": "Se necesita el permiso de supervisión de entrada para que la aplicación reciba eventos de teclado y ratón del sistema.\n\nPasos:\n1. Abra Ajustes del Sistema > Privacidad y seguridad > Supervisión de entrada.\n2. Si BongoCat ya aparece en la lista, selecciónelo y pulse el botón \"-\" para eliminarlo.\n3. Pulse el botón \"+\" para buscar y añadir BongoCat.\n4. Reinicie la aplicación para que el permiso tenga efecto.", + "autostartFailed": "No se pudo actualizar la configuración de inicio automático", + "inputMonitoringPermission": "Reinicie tras autorizar.", + "inputMonitoringPermissionGuide": "La Supervisión de entrada permite que BongoCat vea las teclas pulsadas en otras aplicaciones; el ratón sigue funcionando sin ella. BongoCat crea la escucha al iniciarse, así que un cambio de permiso requiere reiniciar.\n\nPasos:\n1. Abra Ajustes del Sistema → Privacidad y seguridad → Supervisión de entrada.\n2. Active la entrada de BongoCat que ya aparece en la lista; no elimine entradas existentes.\n3. Si BongoCat no está en la lista, pulse \"+\" y añada la copia que está ejecutando (compruebe la ubicación de la aplicación).\n4. Cierre BongoCat por completo y vuelva a iniciarlo para que reconstruya la escucha.\n\nSi el teclado sigue sin responder, puede eliminar y volver a añadir la entrada de BongoCat.", "administratorPermission": "Ejecutar la aplicación como administrador ayuda a capturar con mayor fiabilidad algunas teclas del sistema y eventos de entrada.", "administratorPermissionGuide": "Para capturar con mayor fiabilidad algunas teclas del sistema y eventos de entrada, se recomienda ejecutar la aplicación como administrador.\n\nCómo hacerlo:\n1. Cierre primero la aplicación.\n2. Busque el archivo exe o su acceso directo en el Explorador de archivos.\n3. Elija cómo iniciarla:\n - Una vez: haga clic con el botón derecho y elija \"Ejecutar como administrador\".\n - Siempre: clic derecho > Propiedades > Compatibilidad > marque \"Ejecutar este programa como administrador\" > Aceptar." }, @@ -63,7 +72,8 @@ "authorized": "Autorizado", "adminEnabled": "Activado", "authorize": "Activar", - "viewGuide": "Ver instrucciones" + "viewGuide": "Ver instrucciones", + "openSettingsFailed": "Ajustes del Sistema → Privacidad y seguridad → Supervisión de entrada." }, "buttons": { "openNow": "Abrir ahora", @@ -74,15 +84,27 @@ }, "model": { "title": "Modelo", + "nearbyTitle": "Modelos cercanos", "labels": { - "deleteModel": "Eliminar modelo", - "nearbyModels": "Carpetas cercanas" + "deleteModel": "Eliminar modelo" }, "hints": { - "deleteSuccess": "Eliminado correctamente", - "deleteModel": "¿Seguro que quiere eliminar este modelo?", + "deleteSuccess": "Se eliminó \"%s\"", + "deleteModel": "¿Seguro que quiere eliminar \"%s\"?", + "deleteFailed": "No se pudo eliminar este modelo", "importSuccess": "Importado correctamente", - "clickOrDragToImport": "Haga clic o arrastre aquí para importar", + "importExists": "El modelo ya existe", + "importBatchResult": "Importación correcta: %zu; fallida: %zu.\nModelos fallidos: %s%s.\nPrimer error: %s", + "importMoreFailures": " y %zu más", + "importUnknownSource": "fuente desconocida", + "importInvalidSource": "La fuente seleccionada ya no existe o no se puede utilizar", + "importInvalidFormat": "No se encontró ningún modelo compatible o el paquete del modelo está incompleto", + "importFileAccess": "No se pudieron leer o guardar los archivos del modelo. Compruebe los permisos y el espacio disponible en el disco", + "importOutOfMemory": "No hay memoria suficiente para importar este modelo", + "importLoadFailed": "El modelo se importó, pero no se pudo mostrar", + "importFailed": "Error al importar el modelo. Compruebe los archivos seleccionados e inténtelo de nuevo", + "clickOrDragToImport": "Haz clic o arrastra una carpeta", + "supportedImportFormats": "Compatible con Mver y Tauri", "openDirectoryFailed": "No se pudo abrir la carpeta del modelo" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "Más modelos" }, "behaviorModal": { - "title": "Movimientos y expresiones", + "title": "Movimientos, expresiones y audio", "labels": { "motion": "Movimiento", "expression": "Expresión", + "audio": "Audio", + "stopAllAudio": "Detener todo el audio", "motionIndex": "Movimiento {index}", "motionGroupIndex": "Grupo de movimientos {index}", "expressionIndex": "Expresión {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Mostrar varias a la vez", + "multiplePetsLimit": "Se pueden mostrar hasta 8 mascotas a la vez", + "removeDesktopPet": "Cerrar esta mascota de escritorio", "modelLoadFailed": "No se puede mostrar este modelo", "noBehaviors": "No hay elementos", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "No se pudo iniciar BongoCat.", + "detail": "Consulte el registro de diagnóstico para ver los detalles técnicos.", + "diagnosticLog": "Registro de diagnóstico:" + }, "support": { - "title": "Apoyar al equipo de desarrollo", + "title": "Acerca de", + "website": "Sitio web: bongocat.pet", "heroText": "Gracias por su apoyo. Cada uso y cada difusión ayudan a BongoCat a seguir creciendo.", "version": "Versión de la aplicación", "checkUpdate": "Buscar actualizaciones", "feedback": "Comentarios", + "openLogs": "Abrir registros", + "openLogsFailed": "No se pudo abrir la carpeta de registros", "latest": "Ya está actualizado", + "checkingUpdate": "Comprobando...", + "updateAvailable": "Nueva versión disponible:", + "updateFailed": "No se pudieron buscar actualizaciones", + "updateHttpFailed": "GitHub devolvió el estado HTTP %s", + "downloadUpdate": "Actualizar a v%s", + "openStore": "Abrir Microsoft Store", + "viewReleases": "Ver versiones", + "openUpdateFailed": "No se pudo abrir la página de actualización", "works": "Más aplicaciones", - "worksText": "Más software de vladelaina", - "refactorDeveloper": "(Desarrollador que reconstruyó BongoCat en C)", + "worksText": "Más creaciones de vladelaina que vale la pena probar", + "refactorDeveloper": "Desarrollador que reescribió BongoCat en C y que también es su mantenedor actual", "community": "Comunidad", "qqGroup": "Grupo de QQ", - "coffee": "¿Invitar a un café al equipo de desarrollo?" + "coffee": "¿Invitar a un café al desarrollador?", + "starOnGitHub": "Estrella GitHub!", + "by": "por " }, "shortcut": { "toggleCat": "Mostrar/ocultar", @@ -203,6 +267,7 @@ "select": "Seleccionar", "selected": "Seleccionado", "import": "Importar", + "importing": "Importando...", "builtIn": "integrado" } } diff --git a/resources/assets/locales/fr-FR.json b/resources/assets/locales/fr-FR.json index 9aa0feed..d5a5d22b 100644 --- a/resources/assets/locales/fr-FR.json +++ b/resources/assets/locales/fr-FR.json @@ -9,38 +9,46 @@ "preference": { "title": "Préférences", "cat": { - "title": "Affichage", + "title": "Préférences", "labels": { - "modelSettings": "Paramètres du modèle", + "modelSettings": "Modèle", "mirrorMode": "Mode miroir", "mouseMirror": "Miroir de la souris", "mouseCentered": "Souris centrée sur le compagnon", "ignoreMouse": "Ignorer les événements de souris", - "windowSettings": "Paramètres de la fenêtre", + "windowSettings": "Fenêtre", "passThrough": "Clics traversants", "alwaysOnTop": "Toujours au premier plan", "keepInScreen": "Maintenir à l'écran", "windowSize": "Taille de la fenêtre", + "windowCorners": "Coins arrondis (%)", "opacity": "Opacité", - "autoReleaseDelay": "Délai de relâchement automatique", + "randomExpression": "Expressions aléatoires", + "obsBackground": "Arrière-plan uni", "hideOnHover": "Masquer au survol", + "hideFadeSeconds": "Durée du fondu (s)", "maxFPS": "Fréquence d'images maximale" }, "hints": { - "passThrough": "Lorsque cette option est activée, les clics traversent la fenêtre sans l'affecter.", + "hoverUnavailable": "Le masquage au survol est indisponible dans cet environnement de bureau.", + "gameInput": "La mascotte ne réagit pas en jeu ?", + "gameInputHelp": "Essayez de lancer BongoCat en tant qu’administrateur et de passer le jeu en mode fenêtré.", + "passThrough": "Désactivation possible aussi par clic droit sur l’icône de la zone de notification", "alwaysOnTop": "Lorsque cette option est activée, la fenêtre reste au-dessus des autres fenêtres.", "keepInScreen": "Lorsque cette option est activée, la fenêtre reste automatiquement dans les limites de l'écran.", + "obsBackground": "La capture de fenêtre est noire ?", + "obsBackgroundHelp": "OBS : activez cette option, utilisez la méthode compatible Windows 7 et supprimez le fond avec un filtre d'incrustation couleur.", "windowSize": "Placez la souris sur le bord de la fenêtre, ou maintenez Maj et faites glisser avec le bouton droit pour redimensionner.", - "hideOnHover": "Lorsque cette option est activée, la fenêtre se masque au survol de la souris. Vous pouvez définir un délai ; 0 signifie immédiatement." + "hideOnHover": "Lorsque le passage des clics et Toujours au premier plan sont activés, le compagnon se masque immédiatement au survol et réapparaît lorsque le pointeur s’éloigne." } }, "general": { "title": "Général", "labels": { - "appSettings": "Paramètres de l'application", + "appSettings": "Application", "launchOnStartup": "Lancer au démarrage", "showTrayIcon": "Afficher l'icône dans la zone de notification", - "appearanceSettings": "Paramètres d'apparence", + "appearanceSettings": "Apparence", "themeMode": "Thème", "language": "Langue", "permissionsSettings": "Paramètres des autorisations", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "Lorsque cette option est activée, l'icône de l'application apparaît dans la zone de notification.", - "inputMonitoringPermission": "Activez la surveillance des entrées pour recevoir les événements clavier et souris du système.", - "inputMonitoringPermissionGuide": "L'autorisation de surveillance des entrées est nécessaire pour recevoir les événements clavier et souris du système.\n\nÉtapes :\n1. Ouvrez Réglages Système > Confidentialité et sécurité > Surveillance de l'entrée.\n2. Si BongoCat figure déjà dans la liste, sélectionnez-le et cliquez sur le bouton \"-\" pour le retirer.\n3. Cliquez sur le bouton \"+\" pour rechercher et ajouter BongoCat.\n4. Redémarrez l'application pour appliquer l'autorisation.", + "autostartFailed": "Impossible de modifier le lancement au démarrage", + "inputMonitoringPermission": "Redémarrez après l'autorisation.", + "inputMonitoringPermissionGuide": "La surveillance des entrées permet à BongoCat de voir les touches pressées dans d'autres applications ; la souris continue de fonctionner sans elle. BongoCat construit l'écoute à son démarrage, un changement d'autorisation demande donc un redémarrage.\n\nÉtapes :\n1. Ouvrez Réglages Système → Confidentialité et sécurité → Surveillance de l'entrée.\n2. Activez l'entrée BongoCat déjà présente dans la liste ; ne supprimez aucune entrée.\n3. Si BongoCat n'est pas listé, cliquez sur \"+\" et ajoutez la copie que vous exécutez (vérifiez son emplacement).\n4. Quittez complètement BongoCat puis relancez-le pour qu'il reconstruise l'écoute.\n\nSi le clavier reste sans réponse, vous pouvez supprimer puis rajouter vous-même l'entrée BongoCat.", "administratorPermission": "Exécuter l'application en tant qu'administrateur permet de capturer plus fiablement certaines touches système et certains événements d'entrée.", "administratorPermissionGuide": "Pour capturer plus fiablement certaines touches système et certains événements d'entrée, il est recommandé d'exécuter l'application en tant qu'administrateur.\n\nProcédure :\n1. Quittez d'abord l'application.\n2. Trouvez le fichier exe ou son raccourci dans l'Explorateur de fichiers.\n3. Choisissez le mode de lancement :\n - Une fois : faites un clic droit et choisissez \"Exécuter en tant qu'administrateur\".\n - Toujours : clic droit > Propriétés > onglet Compatibilité > cochez \"Exécuter ce programme en tant qu'administrateur\" > OK." }, @@ -63,7 +72,8 @@ "authorized": "Autorisé", "adminEnabled": "Activé", "authorize": "Activer", - "viewGuide": "Voir les instructions" + "viewGuide": "Voir les instructions", + "openSettingsFailed": "Réglages Système → Confidentialité et sécurité → Surveillance de l'entrée." }, "buttons": { "openNow": "Ouvrir maintenant", @@ -74,15 +84,27 @@ }, "model": { "title": "Modèle", + "nearbyTitle": "Modèles à proximité", "labels": { - "deleteModel": "Supprimer le modèle", - "nearbyModels": "Dossiers voisins" + "deleteModel": "Supprimer le modèle" }, "hints": { - "deleteSuccess": "Suppression réussie", - "deleteModel": "Voulez-vous vraiment supprimer ce modèle ?", + "deleteSuccess": "« %s » a été supprimé", + "deleteModel": "Voulez-vous vraiment supprimer \"%s\" ?", + "deleteFailed": "Impossible de supprimer ce modèle", "importSuccess": "Importation réussie", - "clickOrDragToImport": "Cliquez ou déposez ici pour importer", + "importExists": "Le modèle existe déjà", + "importBatchResult": "Importations réussies : %zu ; échecs : %zu.\nModèles en échec : %s%s.\nPremier échec : %s", + "importMoreFailures": " et %zu autre(s)", + "importUnknownSource": "source inconnue", + "importInvalidSource": "La source sélectionnée n'existe plus ou ne peut pas être utilisée", + "importInvalidFormat": "Aucun modèle pris en charge n'a été trouvé, ou le paquet du modèle est incomplet", + "importFileAccess": "Impossible de lire ou d'enregistrer les fichiers du modèle. Vérifiez les autorisations et l'espace disque disponible", + "importOutOfMemory": "La mémoire disponible est insuffisante pour importer ce modèle", + "importLoadFailed": "Le modèle a été importé, mais il n'a pas pu être affiché", + "importFailed": "Échec de l'importation du modèle. Vérifiez les fichiers sélectionnés et réessayez", + "clickOrDragToImport": "Cliquez ou déposez un dossier", + "supportedImportFormats": "Formats Mver et Tauri pris en charge", "openDirectoryFailed": "Impossible d'ouvrir le dossier du modèle" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "Plus de modèles" }, "behaviorModal": { - "title": "Mouvements et expressions", + "title": "Mouvements, expressions et audio", "labels": { "motion": "Mouvement", "expression": "Expression", + "audio": "Audio", + "stopAllAudio": "Arrêter tous les sons", "motionIndex": "Mouvement {index}", "motionGroupIndex": "Groupe de mouvements {index}", "expressionIndex": "Expression {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Afficher plusieurs modèles", + "multiplePetsLimit": "Jusqu'\u00e0 8 mascottes peuvent \u00eatre affich\u00e9es \u00e0 la fois", + "removeDesktopPet": "Fermer cette mascotte de bureau", "modelLoadFailed": "Impossible d'afficher ce modèle", "noBehaviors": "Aucun élément", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "Impossible de démarrer BongoCat.", + "detail": "Consultez le journal de diagnostic pour les détails techniques.", + "diagnosticLog": "Journal de diagnostic :" + }, "support": { - "title": "Soutenir l'équipe de développement", + "title": "À propos", + "website": "Site web: bongocat.pet", "heroText": "Merci pour votre soutien. Chaque utilisation et chaque partage aide BongoCat à continuer de grandir.", "version": "Version de l'application", "checkUpdate": "Rechercher des mises à jour", "feedback": "Commentaires", + "openLogs": "Ouvrir les journaux", + "openLogsFailed": "Impossible d'ouvrir le dossier des journaux", "latest": "Déjà à jour", + "checkingUpdate": "Vérification...", + "updateAvailable": "Nouvelle version disponible :", + "updateFailed": "Impossible de vérifier les mises à jour", + "updateHttpFailed": "GitHub a renvoyé le statut HTTP %s", + "downloadUpdate": "Mettre à jour vers v%s", + "openStore": "Ouvrir Microsoft Store", + "viewReleases": "Voir les versions", + "openUpdateFailed": "Impossible d'ouvrir la page de mise à jour", "works": "Plus d'applications", - "worksText": "Plus de logiciels de vladelaina", - "refactorDeveloper": "(Développeur ayant reconstruit BongoCat en C)", + "worksText": "D’autres créations de vladelaina à découvrir", + "refactorDeveloper": "Développeur qui a réécrit BongoCat en C et qui en est aussi le mainteneur actuel", "community": "Communauté", "qqGroup": "Groupe QQ", - "coffee": "Offrir un café à l'équipe de développement ?" + "coffee": "Offrir un café au développeur ?", + "starOnGitHub": "Étoile GitHub!", + "by": "par " }, "shortcut": { "toggleCat": "Afficher/masquer", @@ -203,6 +267,7 @@ "select": "Sélectionner", "selected": "Sélectionné", "import": "Importer", + "importing": "Importation...", "builtIn": "intégré" } } diff --git a/resources/assets/locales/ja-JP.json b/resources/assets/locales/ja-JP.json index eeae10f7..5ec817aa 100644 --- a/resources/assets/locales/ja-JP.json +++ b/resources/assets/locales/ja-JP.json @@ -9,38 +9,46 @@ "preference": { "title": "設定", "cat": { - "title": "表示", + "title": "設定", "labels": { - "modelSettings": "モデル設定", + "modelSettings": "モデル", "mirrorMode": "ミラーモード", "mouseMirror": "マウスを反転", "mouseCentered": "マウスをデスクトップペット中心にする", "ignoreMouse": "マウスイベントを無視", - "windowSettings": "ウィンドウ設定", + "windowSettings": "ウィンドウ", "passThrough": "クリックを透過", "alwaysOnTop": "常に手前に表示", "keepInScreen": "画面内に保持", "windowSize": "ウィンドウサイズ", + "windowCorners": "ウィンドウの角丸 (%)", "opacity": "不透明度", - "autoReleaseDelay": "自動リリースまでの時間", + "randomExpression": "ランダム表情", + "obsBackground": "単色背景", "hideOnHover": "ホバー時に非表示", + "hideFadeSeconds": "フェード時間 (秒)", "maxFPS": "最大フレームレート" }, "hints": { - "passThrough": "有効にすると、クリックはウィンドウに影響せず背後へ通過します。", + "hoverUnavailable": "現在のデスクトップ環境では、マウスを重ねて非表示にする機能は利用できません。", + "gameInput": "ゲーム中にデスクトップペットが反応しない?", + "gameInputHelp": "BongoCat を管理者として起動し、ゲームをウィンドウモードに設定してみてください。", + "passThrough": "トレイアイコンの右クリックからもオフにできます", "alwaysOnTop": "有効にすると、ウィンドウがほかのすべてのウィンドウより手前に表示されます。", "keepInScreen": "有効にすると、ウィンドウが自動的に画面の範囲内に収まります。", + "obsBackground": "ウィンドウキャプチャが黒い?", + "obsBackgroundHelp": "OBS:「Windows 7互換」方式でキャプチャする場合はこの項目を有効にし、カラーキーフィルターで背景を除去してください。", "windowSize": "マウスをウィンドウ端へ移動するか、Shiftキーを押しながら右ドラッグしてサイズを変更します。", - "hideOnHover": "有効にすると、マウスを重ねたときにウィンドウが自動的に隠れます。遅延を設定でき、0ではすぐに隠れます。" + "hideOnHover": "クリック透過と最前面表示が有効な場合、ペットにマウスを重ねるとすぐに非表示になり、離すと再表示されます。" } }, "general": { "title": "一般", "labels": { - "appSettings": "アプリ設定", + "appSettings": "アプリ", "launchOnStartup": "起動時に実行", "showTrayIcon": "トレイアイコンを表示", - "appearanceSettings": "外観設定", + "appearanceSettings": "外観", "themeMode": "テーマ", "language": "言語", "permissionsSettings": "権限設定", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "有効にすると、システムトレイにアプリアイコンを表示します。", - "inputMonitoringPermission": "システムからキーボードとマウスのイベントを受け取るには、入力監視を有効にしてください。", - "inputMonitoringPermissionGuide": "システムからキーボードとマウスのイベントを受け取るには、入力監視の権限が必要です。\n\n手順:\n1. システム設定 > プライバシーとセキュリティ > 入力監視を開きます。\n2. BongoCatがすでに一覧にある場合は選択し、\"-\"ボタンで削除します。\n3. \"+\"ボタンをクリックしてBongoCatを探し、追加します。\n4. 権限を反映するため、アプリを再起動します。", + "autostartFailed": "起動時に実行する設定を更新できません", + "inputMonitoringPermission": "許可したら再起動してください。", + "inputMonitoringPermissionGuide": "入力監視の権限があると、BongoCat は他のアプリでのキー入力を取得できます。マウスは権限なしでも動作します。BongoCat は起動時に監視を構築するため、権限を変更した場合は再起動が必要です。\n\n手順:\n1. 「システム設定」→「プライバシーとセキュリティ」→「入力監視」を開きます。\n2. 一覧にすでにある BongoCat の項目をオンにします。既存の項目は削除しないでください。\n3. 一覧に BongoCat がない場合は「+」をクリックし、現在実行しているアプリを追加します(アプリの場所を確認してください)。\n4. BongoCat を完全に終了してから再起動し、監視を再構築させます。\n\nキーボードが反応しないままの場合は、ご自身で BongoCat の項目を削除して再追加して確認できます。", "administratorPermission": "管理者として実行すると、一部のシステムキーや入力イベントをより確実に取得できます。", "administratorPermissionGuide": "一部のシステムキーや入力イベントをより確実に取得するため、アプリを管理者として実行することを推奨します。\n\n手順:\n1. まずアプリを終了します。\n2. エクスプローラーでアプリのexeファイルまたはショートカットを探します。\n3. 起動方法を選びます:\n - 今回のみ:右クリックして\"管理者として実行\"を選びます。\n - 常に:右クリック > プロパティ > 互換性 > \"管理者としてこのプログラムを実行する\"にチェック > OK。" }, @@ -63,7 +72,8 @@ "authorized": "許可済み", "adminEnabled": "有効", "authorize": "有効にする", - "viewGuide": "手順を表示" + "viewGuide": "手順を表示", + "openSettingsFailed": "手動で:システム設定 → プライバシーとセキュリティ → 入力監視。" }, "buttons": { "openNow": "今すぐ開く", @@ -74,15 +84,27 @@ }, "model": { "title": "モデル", + "nearbyTitle": "近くのモデル", "labels": { - "deleteModel": "モデルを削除", - "nearbyModels": "近くのフォルダー" + "deleteModel": "モデルを削除" }, "hints": { - "deleteSuccess": "削除しました", - "deleteModel": "このモデルを削除しますか?", + "deleteSuccess": "「%s」を削除しました", + "deleteModel": "「%s」を削除しますか?", + "deleteFailed": "このモデルを削除できません", "importSuccess": "インポートしました", - "clickOrDragToImport": "クリックまたはここへドラッグしてインポート", + "importExists": "モデルは既に存在します", + "importBatchResult": "インポート成功: %zu件、失敗: %zu件。\n失敗したモデル: %s%s。\n最初の失敗理由: %s", + "importMoreFailures": "、他%zu件", + "importUnknownSource": "不明なソース", + "importInvalidSource": "選択したインポート元は存在しないか、使用できません", + "importInvalidFormat": "対応するモデルが見つからないか、モデルパッケージが不完全です", + "importFileAccess": "モデルファイルを読み込みまたは保存できません。ファイルの権限とディスクの空き容量を確認してください", + "importOutOfMemory": "メモリが不足しているため、このモデルをインポートできません", + "importLoadFailed": "モデルはインポートされましたが、表示できませんでした", + "importFailed": "モデルのインポートに失敗しました。選択したファイルを確認して、もう一度お試しください", + "clickOrDragToImport": "モデルフォルダーをクリックまたはドラッグ", + "supportedImportFormats": "Mver・Tauri形式に対応", "openDirectoryFailed": "モデルフォルダーを開けません" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "その他のモデル" }, "behaviorModal": { - "title": "モーションと表情", + "title": "モーション・表情・音声", "labels": { "motion": "モーション", "expression": "表情", + "audio": "音声", + "stopAllAudio": "すべての音声を停止", "motionIndex": "モーション {index}", "motionGroupIndex": "モーショングループ {index}", "expressionIndex": "表情 {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "複数を同時に表示", + "multiplePetsLimit": "\u4e00\u5ea6\u306b\u8868\u793a\u3067\u304d\u308b\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u306f 8 \u4f53\u307e\u3067\u3067\u3059", + "removeDesktopPet": "\u3053\u306e\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u30da\u30c3\u30c8\u3092\u9589\u3058\u308b", "modelLoadFailed": "このモデルを表示できません", "noBehaviors": "項目がありません", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat を起動できませんでした。", + "detail": "技術的な詳細は診断ログを確認してください。", + "diagnosticLog": "診断ログ:" + }, "support": { - "title": "開発チームを支援", + "title": "このアプリについて", + "website": "公式サイト: bongocat.pet", "heroText": "ご支援ありがとうございます。利用や共有の一つひとつがBongoCatの成長につながります。", "version": "アプリのバージョン", "checkUpdate": "更新を確認", "feedback": "フィードバック", + "openLogs": "ログを開く", + "openLogsFailed": "ログフォルダーを開けません", "latest": "最新バージョンです", + "checkingUpdate": "確認中...", + "updateAvailable": "新しいバージョンがあります:", + "updateFailed": "更新を確認できませんでした", + "updateHttpFailed": "GitHub が HTTP ステータス %s を返しました", + "downloadUpdate": "v%s に更新", + "openStore": "Microsoft Store を開く", + "viewReleases": "リリースを表示", + "openUpdateFailed": "更新ページを開けませんでした", "works": "その他のアプリ", - "worksText": "vladelainaのその他のソフトウェア", - "refactorDeveloper": "(BongoCatをCで再構築した開発者)", + "worksText": "vladelainaのほかの作品も、ぜひお試しください", + "refactorDeveloper": "BongoCatをC言語で書き直した開発者であり、現在のメンテナーでもある", "community": "コミュニティ", "qqGroup": "QQグループ", - "coffee": "開発チームにコーヒーを贈りますか?" + "coffee": "開発者にコーヒーを贈りますか?", + "starOnGitHub": "GitHubでスター!", + "by": "開発:" }, "shortcut": { "toggleCat": "表示/非表示", @@ -203,6 +267,7 @@ "select": "選択", "selected": "選択済み", "import": "インポート", + "importing": "インポート中...", "builtIn": "内蔵" } } diff --git a/resources/assets/locales/ko-KR.json b/resources/assets/locales/ko-KR.json index 3ad007fc..d57f0c11 100644 --- a/resources/assets/locales/ko-KR.json +++ b/resources/assets/locales/ko-KR.json @@ -9,38 +9,46 @@ "preference": { "title": "환경 설정", "cat": { - "title": "표시", + "title": "환경 설정", "labels": { - "modelSettings": "모델 설정", + "modelSettings": "모델", "mirrorMode": "미러 모드", "mouseMirror": "마우스 반전", "mouseCentered": "마우스를 데스크톱 펫 중심으로", "ignoreMouse": "마우스 이벤트 무시", - "windowSettings": "창 설정", + "windowSettings": "창", "passThrough": "클릭 통과", "alwaysOnTop": "항상 위", "keepInScreen": "화면 안에 유지", "windowSize": "창 크기", + "windowCorners": "창 모서리 둥글기 (%)", "opacity": "불투명도", - "autoReleaseDelay": "자동 해제 지연", + "randomExpression": "무작위 표정", + "obsBackground": "단색 배경", "hideOnHover": "마우스를 올리면 숨기기", + "hideFadeSeconds": "페이드 시간 (초)", "maxFPS": "최대 프레임 속도" }, "hints": { - "passThrough": "활성화하면 클릭이 창에 영향을 주지 않고 뒤로 통과합니다.", + "hoverUnavailable": "현재 데스크톱 환경에서는 마우스를 올려 숨기기 기능을 사용할 수 없습니다.", + "gameInput": "게임 중 데스크톱 펫이 반응하지 않나요?", + "gameInputHelp": "BongoCat을 관리자 권한으로 실행하고 게임을 창 모드로 설정해 보세요.", + "passThrough": "트레이 아이콘을 우클릭해서도 끌 수 있습니다", "alwaysOnTop": "활성화하면 창이 다른 모든 창 위에 유지됩니다.", "keepInScreen": "활성화하면 창이 자동으로 화면 경계 안에 유지됩니다.", + "obsBackground": "창 캡처가 검은 화면인가요?", + "obsBackgroundHelp": "OBS: 이 옵션을 켜고 Windows 7 호환 방식으로 캡처한 뒤 색상 키 필터로 배경을 제거하세요.", "windowSize": "마우스를 창 가장자리로 이동하거나 Shift를 누른 채 오른쪽 버튼으로 끌어 크기를 조절합니다.", - "hideOnHover": "활성화하면 마우스를 올렸을 때 창이 자동으로 숨겨집니다. 지연 시간을 설정할 수 있으며 0은 즉시 숨김입니다." + "hideOnHover": "클릭 통과와 항상 위에 표시가 모두 켜져 있으면 펫에 마우스를 올릴 때 즉시 숨기고, 마우스를 치우면 다시 표시합니다." } }, "general": { "title": "일반", "labels": { - "appSettings": "앱 설정", + "appSettings": "앱", "launchOnStartup": "시작할 때 실행", "showTrayIcon": "트레이 아이콘 표시", - "appearanceSettings": "모양 설정", + "appearanceSettings": "모양", "themeMode": "테마", "language": "언어", "permissionsSettings": "권한 설정", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "활성화하면 시스템 트레이에 앱 아이콘이 표시됩니다.", - "inputMonitoringPermission": "시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링을 활성화하세요.", - "inputMonitoringPermissionGuide": "앱이 시스템의 키보드와 마우스 이벤트를 받으려면 입력 모니터링 권한이 필요합니다.\n\n단계:\n1. 시스템 설정 > 개인정보 보호 및 보안 > 입력 모니터링을 엽니다.\n2. BongoCat이 이미 목록에 있으면 선택하고 \"-\" 버튼을 눌러 제거합니다.\n3. \"+\" 버튼을 눌러 BongoCat을 찾아 추가합니다.\n4. 권한을 적용하려면 앱을 다시 시작합니다.", + "autostartFailed": "시작 프로그램 설정을 변경할 수 없습니다", + "inputMonitoringPermission": "허용한 뒤 다시 시작하세요.", + "inputMonitoringPermissionGuide": "입력 모니터링 권한이 있으면 BongoCat이 다른 앱에서 누른 키를 받을 수 있습니다. 마우스는 권한 없이도 동작합니다. BongoCat은 시작할 때 감시를 만들기 때문에 권한을 바꾼 뒤에는 다시 시작해야 합니다.\n\n단계:\n1. 시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링을 엽니다.\n2. 목록에 이미 있는 BongoCat 항목을 켭니다. 기존 항목은 삭제하지 마세요.\n3. 목록에 BongoCat이 없으면 \"+\"를 클릭해 현재 실행 중인 앱을 추가합니다(앱 위치를 확인하세요).\n4. BongoCat을 완전히 종료한 뒤 다시 시작해 감시를 다시 만들게 합니다.\n\n키보드가 계속 반응하지 않으면 BongoCat 항목을 직접 삭제하고 다시 추가해 확인할 수 있습니다.", "administratorPermission": "앱을 관리자로 실행하면 일부 시스템 키와 입력 이벤트를 더 안정적으로 캡처할 수 있습니다.", "administratorPermissionGuide": "일부 시스템 키와 입력 이벤트를 더 안정적으로 캡처하려면 앱을 관리자로 실행하는 것이 좋습니다.\n\n방법:\n1. 먼저 앱을 종료합니다.\n2. 파일 탐색기에서 앱의 exe 파일 또는 바로 가기를 찾습니다.\n3. 실행 방법을 선택합니다:\n - 한 번만: 마우스 오른쪽 버튼을 누르고 \"관리자 권한으로 실행\"을 선택합니다.\n - 항상: 오른쪽 클릭 > 속성 > 호환성 > \"관리자 권한으로 이 프로그램 실행\" 선택 > 확인." }, @@ -63,7 +72,8 @@ "authorized": "허용됨", "adminEnabled": "활성화됨", "authorize": "활성화", - "viewGuide": "안내 보기" + "viewGuide": "안내 보기", + "openSettingsFailed": "직접 여세요: 시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링." }, "buttons": { "openNow": "지금 열기", @@ -74,15 +84,27 @@ }, "model": { "title": "모델", + "nearbyTitle": "주변 모델", "labels": { - "deleteModel": "모델 삭제", - "nearbyModels": "주변 폴더" + "deleteModel": "모델 삭제" }, "hints": { - "deleteSuccess": "삭제 완료", - "deleteModel": "이 모델을 삭제하시겠습니까?", + "deleteSuccess": "\"%s\" 모델을 삭제했습니다", + "deleteModel": "\"%s\" 모델을 삭제하시겠습니까?", + "deleteFailed": "이 모델을 삭제할 수 없습니다", "importSuccess": "가져오기 완료", - "clickOrDragToImport": "클릭하거나 여기로 끌어와 가져오기", + "importExists": "모델이 이미 존재합니다", + "importBatchResult": "가져오기 성공 %zu개, 실패 %zu개.\n실패한 모델: %s%s.\n첫 번째 실패 원인: %s", + "importMoreFailures": " 외 %zu개", + "importUnknownSource": "알 수 없는 원본", + "importInvalidSource": "선택한 원본이 더 이상 존재하지 않거나 사용할 수 없습니다", + "importInvalidFormat": "지원되는 모델을 찾을 수 없거나 모델 패키지가 완전하지 않습니다", + "importFileAccess": "모델 파일을 읽거나 저장할 수 없습니다. 파일 권한과 사용 가능한 디스크 공간을 확인하세요", + "importOutOfMemory": "메모리가 부족하여 이 모델을 가져올 수 없습니다", + "importLoadFailed": "모델을 가져왔지만 표시할 수 없습니다", + "importFailed": "모델을 가져오지 못했습니다. 선택한 파일을 확인한 후 다시 시도하세요", + "clickOrDragToImport": "모델 폴더를 클릭하거나 끌어오세요", + "supportedImportFormats": "Mver 및 Tauri 형식 지원", "openDirectoryFailed": "모델 폴더를 열 수 없습니다" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "더 많은 모델" }, "behaviorModal": { - "title": "모션 및 표정", + "title": "모션, 표정 및 오디오", "labels": { "motion": "모션", "expression": "표정", + "audio": "오디오", + "stopAllAudio": "모든 오디오 중지", "motionIndex": "모션 {index}", "motionGroupIndex": "모션 그룹 {index}", "expressionIndex": "표정 {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "여러 개 동시에 표시", + "multiplePetsLimit": "\ub370\uc2a4\ud06c\ud1b1 \ud3ab\uc740 \ud55c \ubc88\uc5d0 \ucd5c\ub300 8\uac1c\uae4c\uc9c0 \ud45c\uc2dc\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4", + "removeDesktopPet": "\uc774 \ub370\uc2a4\ud06c\ud1b1 \ud3ab \ub2eb\uae30", "modelLoadFailed": "이 모델을 표시할 수 없습니다", "noBehaviors": "항목 없음", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat을 시작할 수 없습니다.", + "detail": "기술적인 세부 정보는 진단 로그를 확인하세요.", + "diagnosticLog": "진단 로그:" + }, "support": { - "title": "개발팀 지원", + "title": "정보", + "website": "공식 웹사이트: bongocat.pet", "heroText": "지원해 주셔서 감사합니다. 사용할 때마다, 공유할 때마다 BongoCat이 계속 성장하는 데 도움이 됩니다.", "version": "앱 버전", "checkUpdate": "업데이트 확인", "feedback": "피드백", + "openLogs": "로그 열기", + "openLogsFailed": "로그 폴더를 열 수 없습니다", "latest": "최신 버전입니다", + "checkingUpdate": "확인 중...", + "updateAvailable": "새 버전 사용 가능:", + "updateFailed": "업데이트를 확인할 수 없습니다", + "updateHttpFailed": "GitHub에서 HTTP 상태 코드 %s를 반환했습니다", + "downloadUpdate": "v%s(으)로 업데이트", + "openStore": "Microsoft Store 열기", + "viewReleases": "릴리스 보기", + "openUpdateFailed": "업데이트 페이지를 열 수 없습니다", "works": "더 많은 앱", - "worksText": "vladelaina의 다른 소프트웨어", - "refactorDeveloper": "(BongoCat을 C로 다시 만든 개발자)", + "worksText": "vladelaina의 다른 작품도 만나보세요", + "refactorDeveloper": "BongoCat을 C 언어로 재작성한 개발자이자 현재 유지보수 담당자", "community": "커뮤니티", "qqGroup": "QQ 그룹", - "coffee": "개발팀에 커피 한 잔을 선물할까요?" + "coffee": "개발자에게 커피 한 잔을 선물할까요?", + "starOnGitHub": "GitHub 스타!", + "by": "제작: " }, "shortcut": { "toggleCat": "표시/숨기기", @@ -203,6 +267,7 @@ "select": "선택", "selected": "선택됨", "import": "가져오기", + "importing": "가져오는 중...", "builtIn": "내장" } } diff --git a/resources/assets/locales/pt-BR.json b/resources/assets/locales/pt-BR.json index 9846659d..669632c2 100644 --- a/resources/assets/locales/pt-BR.json +++ b/resources/assets/locales/pt-BR.json @@ -9,38 +9,46 @@ "preference": { "title": "Preferências", "cat": { - "title": "Exibição", + "title": "Preferências", "labels": { - "modelSettings": "Configurações do Modelo", + "modelSettings": "Modelo", "mirrorMode": "Modo Espelho", "mouseMirror": "Espelho do Mouse", "mouseCentered": "Mouse centrado no mascote de mesa", "ignoreMouse": "Ignorar Eventos do Mouse", - "windowSettings": "Configurações da Janela", + "windowSettings": "Janela", "passThrough": "Janela Transparente", "alwaysOnTop": "Sempre no Topo", "keepInScreen": "Manter na Tela", "windowSize": "Tamanho da Janela", + "windowCorners": "Cantos arredondados (%)", "opacity": "Opacidade", - "autoReleaseDelay": "Atraso de Liberação Automática", + "randomExpression": "Expressões Aleatórias", + "obsBackground": "Fundo sólido", "hideOnHover": "Ocultar ao Passar o Mouse", + "hideFadeSeconds": "Duração do fade (s)", "maxFPS": "Taxa Máxima de Quadros" }, "hints": { - "passThrough": "Quando ativado, a janela não afetará operações em outros aplicativos.", + "hoverUnavailable": "Ocultar ao passar o mouse não está disponível no ambiente de desktop atual.", + "gameInput": "O mascote não responde ao jogar?", + "gameInputHelp": "Tente executar o BongoCat como administrador e colocar o jogo em modo janela.", + "passThrough": "Também é possível desativar pelo clique direito no ícone da bandeja", "alwaysOnTop": "Quando ativado, a janela sempre ficará acima de outros aplicativos.", "keepInScreen": "Quando ativado, a janela permanece automaticamente dentro dos limites da tela.", + "obsBackground": "A captura da janela está preta?", + "obsBackgroundHelp": "OBS: ative esta opção, use o método compatível com o Windows 7 e remova o fundo com um filtro de chave de cor.", "windowSize": "Mova o mouse para a borda da janela ou segure Shift e arraste com o botão direito para redimensionar.", - "hideOnHover": "Quando ativado, a janela se oculta automaticamente ao passar o mouse. Você pode definir um atraso; 0 significa ocultar imediatamente." + "hideOnHover": "Com a passagem de cliques e Sempre no Topo ativados, o mascote se oculta imediatamente ao passar o mouse e reaparece ao afastá-lo." } }, "general": { "title": "Geral", "labels": { - "appSettings": "Configurações do aplicativo", + "appSettings": "Aplicativo", "launchOnStartup": "Iniciar na inicialização", "showTrayIcon": "Mostrar ícone na bandeja", - "appearanceSettings": "Configurações de aparência", + "appearanceSettings": "Aparência", "themeMode": "Tema", "language": "Idiomas", "permissionsSettings": "Configurações de Permissões", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", - "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", - "inputMonitoringPermissionGuide": "A permissão de Monitoramento de Entrada é necessária para que o aplicativo receba eventos de teclado e mouse do sistema.\n\nPassos:\n1. Abra as Configurações do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Se o BongoCat já estiver na lista, selecione-o e clique no botão \"-\" para removê-lo.\n3. Clique no botão \"+\" para localizar e adicionar o BongoCat.\n4. Reinicie o aplicativo para que a permissão entre em vigor.", + "autostartFailed": "Não foi possível atualizar a inicialização automática", + "inputMonitoringPermission": "Reinicie após autorizar.", + "inputMonitoringPermissionGuide": "O Monitoramento de Entrada permite que o BongoCat veja teclas pressionadas em outros aplicativos; o mouse continua funcionando sem ele. O BongoCat cria o monitoramento ao iniciar, então uma mudança de permissão exige reiniciar.\n\nPassos:\n1. Abra Ajustes do Sistema → Privacidade e Segurança → Monitoramento de Entrada.\n2. Ative a entrada do BongoCat que já está na lista; não remova entradas existentes.\n3. Se o BongoCat não estiver na lista, clique em \"+\" e adicione a cópia que você executa (confira o local do aplicativo).\n4. Encerre o BongoCat por completo e inicie de novo para recriar o monitoramento.\n\nSe o teclado continuar sem resposta, você pode remover e adicionar de novo a entrada do BongoCat.", "administratorPermission": "Executar o aplicativo como administrador ajuda a capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade.", "administratorPermissionGuide": "Para capturar algumas teclas e eventos de entrada em nível de sistema com mais confiabilidade, é recomendado executar o aplicativo como administrador.\n\nComo fazer:\n1. Feche o aplicativo primeiro.\n2. Encontre o exe do aplicativo ou o atalho no Explorador de Arquivos.\n3. Escolha como iniciar:\n • Apenas desta vez: Clique com o botão direito e escolha \"Executar como administrador\".\n • Sempre (padrão): Botão direito → Propriedades → aba Compatibilidade → marque \"Executar este programa como administrador\" → OK." }, @@ -63,7 +72,8 @@ "authorized": "Autorizado", "adminEnabled": "Ativado", "authorize": "Ir para Ativar", - "viewGuide": "Ver instruções" + "viewGuide": "Ver instruções", + "openSettingsFailed": "Ajustes → Privacidade e Segurança → Monitoramento de Entrada." }, "buttons": { "openNow": "Abrir Agora", @@ -74,15 +84,27 @@ }, "model": { "title": "Modelo", + "nearbyTitle": "Modelos próximos", "labels": { - "deleteModel": "Excluir modelo", - "nearbyModels": "Pastas próximas" + "deleteModel": "Excluir modelo" }, "hints": { - "deleteSuccess": "Excluído com sucesso", - "deleteModel": "Tem certeza de que deseja excluir este modelo?", + "deleteSuccess": "Modelo \"%s\" excluído", + "deleteModel": "Tem certeza de que deseja excluir \"%s\"?", + "deleteFailed": "Não foi possível excluir este modelo", "importSuccess": "Importação bem-sucedida", - "clickOrDragToImport": "Clique ou arraste para importar", + "importExists": "O modelo já existe", + "importBatchResult": "Importados com sucesso: %zu; falhas: %zu.\nModelos com falha: %s%s.\nPrimeira falha: %s", + "importMoreFailures": " e mais %zu", + "importUnknownSource": "origem desconhecida", + "importInvalidSource": "A origem selecionada não existe mais ou não pode ser usada", + "importInvalidFormat": "Nenhum modelo compatível foi encontrado ou o pacote do modelo está incompleto", + "importFileAccess": "Não foi possível ler ou salvar os arquivos do modelo. Verifique as permissões e o espaço disponível em disco", + "importOutOfMemory": "Não há memória suficiente para importar este modelo", + "importLoadFailed": "O modelo foi importado, mas não pôde ser exibido", + "importFailed": "Falha ao importar o modelo. Verifique os arquivos selecionados e tente novamente", + "clickOrDragToImport": "Clique ou arraste uma pasta", + "supportedImportFormats": "Compatível com Mver e Tauri", "openDirectoryFailed": "Não foi possível abrir a pasta do modelo" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "Mais modelos" }, "behaviorModal": { - "title": "Movimentos e Expressões", + "title": "Movimentos, expressões e áudio", "labels": { "motion": "Movimento", "expression": "Expressão", + "audio": "Áudio", + "stopAllAudio": "Parar todos os áudios", "motionIndex": "Movimento {index}", "motionGroupIndex": "Grupo de Movimento {index}", "expressionIndex": "Expressão {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Exibir vários", + "multiplePetsLimit": "At\u00e9 8 mascotes podem ser exibidos ao mesmo tempo", + "removeDesktopPet": "Fechar este mascote da \u00e1rea de trabalho", "modelLoadFailed": "N\u00e3o foi poss\u00edvel exibir este modelo", "noBehaviors": "Nenhum item", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "Não foi possível iniciar o BongoCat.", + "detail": "Consulte o log de diagnóstico para obter detalhes técnicos.", + "diagnosticLog": "Log de diagnóstico:" + }, "support": { - "title": "Apoiar a equipe de desenvolvimento", + "title": "Sobre", + "website": "Site: bongocat.pet", "heroText": "Obrigado pelo apoio. Cada uso e compartilhamento ajuda o BongoCat a continuar crescendo.", "version": "Versão do aplicativo", "checkUpdate": "Verificar atualizações", "feedback": "Feedback", + "openLogs": "Abrir logs", + "openLogsFailed": "Não foi possível abrir a pasta de logs", "latest": "Já está atualizado", + "checkingUpdate": "Verificando...", + "updateAvailable": "Nova versão disponível:", + "updateFailed": "Não foi possível verificar atualizações", + "updateHttpFailed": "O GitHub retornou o status HTTP %s", + "downloadUpdate": "Atualizar para v%s", + "openStore": "Abrir Microsoft Store", + "viewReleases": "Ver versões", + "openUpdateFailed": "Não foi possível abrir a página de atualização", "works": "Mais aplicativos", - "worksText": "Outros softwares de vladelaina", - "refactorDeveloper": "(Desenvolvedor que reescreveu BongoCat em C)", + "worksText": "Outras criações de vladelaina que valem a pena conhecer", + "refactorDeveloper": "Desenvolvedor que reescreveu o BongoCat em C e também é seu mantenedor atual", "community": "Comunidade", "qqGroup": "Grupo QQ", - "coffee": "Pagar um café para a equipe de desenvolvimento?" + "coffee": "Pagar um café para o desenvolvedor?", + "starOnGitHub": "Estrela GitHub!", + "by": "por " }, "shortcut": { "toggleCat": "Mostrar/Ocultar", @@ -203,6 +267,7 @@ "select": "Selecionar", "selected": "Selecionado", "import": "Importar", + "importing": "Importando...", "builtIn": "integrado" } } diff --git a/resources/assets/locales/ru-RU.json b/resources/assets/locales/ru-RU.json index 3c3ee100..1c74d027 100644 --- a/resources/assets/locales/ru-RU.json +++ b/resources/assets/locales/ru-RU.json @@ -9,38 +9,46 @@ "preference": { "title": "Настройки", "cat": { - "title": "Отображение", + "title": "Настройки", "labels": { - "modelSettings": "Настройки модели", + "modelSettings": "Модель", "mirrorMode": "Зеркальный режим", "mouseMirror": "Отражение мыши", "mouseCentered": "Мышь относительно питомца", "ignoreMouse": "Игнорировать события мыши", - "windowSettings": "Настройки окна", + "windowSettings": "Окно", "passThrough": "Пропускать щелчки", "alwaysOnTop": "Поверх всех окон", "keepInScreen": "Удерживать на экране", "windowSize": "Размер окна", + "windowCorners": "Скругление углов (%)", "opacity": "Непрозрачность", - "autoReleaseDelay": "Задержка автоотпускания", + "randomExpression": "Случайные выражения", + "obsBackground": "Однотонный фон", "hideOnHover": "Скрывать при наведении", + "hideFadeSeconds": "Длительность затухания (с)", "maxFPS": "Максимальная частота кадров" }, "hints": { - "passThrough": "Если включено, щелчки проходят сквозь окно, не воздействуя на него.", + "hoverUnavailable": "Скрытие при наведении недоступно в текущем окружении рабочего стола.", + "gameInput": "Питомец не реагирует в игре?", + "gameInputHelp": "Попробуйте запустить BongoCat от имени администратора и перевести игру в оконный режим.", + "passThrough": "Также можно отключить через правый клик по значку в трее", "alwaysOnTop": "Если включено, окно остаётся поверх всех остальных окон.", "keepInScreen": "Если включено, окно автоматически остаётся в границах экрана.", + "obsBackground": "Захват окна показывает чёрный экран?", + "obsBackgroundHelp": "OBS: включите этот параметр, используйте метод совместимости с Windows 7 и удалите фон фильтром цветового ключа.", "windowSize": "Подведите мышь к краю окна или удерживайте Shift и перетаскивайте правой кнопкой для изменения размера.", - "hideOnHover": "Если включено, окно автоматически скрывается при наведении мыши. Можно задать задержку; 0 означает скрытие сразу." + "hideOnHover": "Если включены пропуск кликов и отображение поверх всех окон, питомец мгновенно скрывается при наведении и появляется, когда указатель уходит." } }, "general": { "title": "Общие", "labels": { - "appSettings": "Настройки приложения", + "appSettings": "Приложение", "launchOnStartup": "Запускать при входе в систему", "showTrayIcon": "Показывать значок в трее", - "appearanceSettings": "Настройки оформления", + "appearanceSettings": "Оформление", "themeMode": "Тема", "language": "Язык", "permissionsSettings": "Настройки разрешений", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "Если включено, значок приложения отображается в системном трее.", - "inputMonitoringPermission": "Включите мониторинг ввода, чтобы получать системные события клавиатуры и мыши.", - "inputMonitoringPermissionGuide": "Разрешение на мониторинг ввода необходимо, чтобы приложение получало системные события клавиатуры и мыши.\n\nШаги:\n1. Откройте Системные настройки > Конфиденциальность и безопасность > Мониторинг ввода.\n2. Если BongoCat уже есть в списке, выберите его и нажмите \"-\", чтобы удалить.\n3. Нажмите \"+\", найдите и добавьте BongoCat.\n4. Перезапустите приложение, чтобы разрешение вступило в силу.", + "autostartFailed": "Не удалось изменить настройку автозапуска", + "inputMonitoringPermission": "Перезапустите после разрешения.", + "inputMonitoringPermissionGuide": "Мониторинг ввода позволяет BongoCat видеть нажатия клавиш в других приложениях; мышь работает и без него. BongoCat создаёт мониторинг при запуске, поэтому после изменения разрешения нужен перезапуск.\n\nШаги:\n1. Откройте Системные настройки → Конфиденциальность и безопасность → Мониторинг ввода.\n2. Включите уже имеющуюся запись BongoCat; не удаляйте существующие записи.\n3. Если BongoCat отсутствует в списке, нажмите \"+\" и добавьте запущенную копию (проверьте расположение приложения).\n4. Полностью закройте BongoCat и запустите снова, чтобы он пересоздал мониторинг.\n\nЕсли клавиатура по-прежнему не реагирует, вы можете сами удалить и заново добавить запись BongoCat.", "administratorPermission": "Запуск от имени администратора позволяет надёжнее захватывать некоторые системные клавиши и события ввода.", "administratorPermissionGuide": "Для более надёжного захвата некоторых системных клавиш и событий ввода рекомендуется запускать приложение от имени администратора.\n\nКак это сделать:\n1. Сначала закройте приложение.\n2. Найдите exe-файл приложения или его ярлык в Проводнике.\n3. Выберите способ запуска:\n - Один раз: щёлкните правой кнопкой и выберите \"Запуск от имени администратора\".\n - Всегда: правый щелчок > Свойства > Совместимость > отметьте \"Запускать эту программу от имени администратора\" > ОК." }, @@ -63,7 +72,8 @@ "authorized": "Разрешено", "adminEnabled": "Включено", "authorize": "Включить", - "viewGuide": "Показать инструкцию" + "viewGuide": "Показать инструкцию", + "openSettingsFailed": "Системные настройки → Конфиденциальность → Мониторинг ввода." }, "buttons": { "openNow": "Открыть сейчас", @@ -74,15 +84,27 @@ }, "model": { "title": "Модель", + "nearbyTitle": "Ближайшие модели", "labels": { - "deleteModel": "Удалить модель", - "nearbyModels": "Соседние папки" + "deleteModel": "Удалить модель" }, "hints": { - "deleteSuccess": "Успешно удалено", - "deleteModel": "Удалить эту модель?", + "deleteSuccess": "Модель \"%s\" удалена", + "deleteModel": "Удалить модель \"%s\"?", + "deleteFailed": "Не удалось удалить эту модель", "importSuccess": "Успешно импортировано", - "clickOrDragToImport": "Щёлкните или перетащите сюда для импорта", + "importExists": "Модель уже существует", + "importBatchResult": "Успешно импортировано: %zu; ошибок: %zu.\nОшибки моделей: %s%s.\nПервая ошибка: %s", + "importMoreFailures": " и ещё %zu", + "importUnknownSource": "неизвестный источник", + "importInvalidSource": "Выбранный источник больше не существует или не может быть использован", + "importInvalidFormat": "Поддерживаемая модель не найдена или пакет модели неполон", + "importFileAccess": "Не удалось прочитать или сохранить файлы модели. Проверьте права доступа и свободное место на диске", + "importOutOfMemory": "Недостаточно памяти для импорта этой модели", + "importLoadFailed": "Модель импортирована, но её не удалось отобразить", + "importFailed": "Не удалось импортировать модель. Проверьте выбранные файлы и повторите попытку", + "clickOrDragToImport": "Выберите или перетащите папку", + "supportedImportFormats": "Поддержка форматов Mver и Tauri", "openDirectoryFailed": "Не удалось открыть папку модели" }, "tooltips": { @@ -91,10 +113,12 @@ "moreModels": "Другие модели" }, "behaviorModal": { - "title": "Движения и выражения", + "title": "Движения, выражения и аудио", "labels": { "motion": "Движение", "expression": "Выражение", + "audio": "Аудио", + "stopAllAudio": "Остановить все звуки", "motionIndex": "Движение {index}", "motionGroupIndex": "Группа движений {index}", "expressionIndex": "Выражение {index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "WeChat", + "wechatHint": "Hover to scan and chat", + "loading": "Loading...", + "contributorsFailed": "Cannot load contributors. Click to retry.", + "qrUnavailable": "QR code unavailable", + "reload": "Reload", + "qrFailed": "QR code unavailable. Click to retry.", + "contributorsTitle": "Open source & community contributions", + "contributorsText": "Every step of BongoCat comes from the power of open source. Thank you to all community contributors for making desktop companionship freer and simpler.", + "joinTitle": "Want to appear in the contributors list?", + "joinText": "Whether you contribute code, improve documentation, report bugs or translate, every contribution makes BongoCat better.", + "code": "Code", + "docs": "Documentation", + "bugs": "Bug reports", + "translations": "Translations", + "joinGithub": "Contribute on GitHub" + }, + "multiplePets": "Показывать несколько", + "multiplePetsLimit": "\u041e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0437\u0438\u0442\u044c \u0434\u043e 8 \u043f\u0438\u0442\u043e\u043c\u0446\u0435\u0432", + "removeDesktopPet": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u044d\u0442\u043e\u0433\u043e \u043f\u0438\u0442\u043e\u043c\u0446\u0430", "modelLoadFailed": "Не удалось отобразить эту модель", "noBehaviors": "Нет элементов", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "Не удалось запустить BongoCat.", + "detail": "Технические сведения доступны в журнале диагностики.", + "diagnosticLog": "Журнал диагностики:" + }, "support": { - "title": "Поддержать команду разработки", + "title": "О программе", + "website": "Сайт: bongocat.pet", "heroText": "Спасибо за поддержку. Каждое использование и распространение помогает BongoCat развиваться дальше.", "version": "Версия приложения", "checkUpdate": "Проверить обновления", "feedback": "Обратная связь", + "openLogs": "Открыть журналы", + "openLogsFailed": "Не удалось открыть папку журналов", "latest": "Установлена последняя версия", + "checkingUpdate": "Проверка...", + "updateAvailable": "Доступна новая версия:", + "updateFailed": "Не удалось проверить обновления", + "updateHttpFailed": "GitHub вернул код HTTP %s", + "downloadUpdate": "Обновить до v%s", + "openStore": "Открыть Microsoft Store", + "viewReleases": "Посмотреть выпуски", + "openUpdateFailed": "Не удалось открыть страницу обновления", "works": "Другие приложения", - "worksText": "Другие программы от vladelaina", - "refactorDeveloper": "(Разработчик, переписавший BongoCat на C)", + "worksText": "Другие работы vladelaina тоже стоит попробовать", + "refactorDeveloper": "Разработчик, переписавший BongoCat на C, и его нынешний мейнтейнер", "community": "Сообщество", "qqGroup": "Группа QQ", - "coffee": "Угостить команду разработки кофе?" + "coffee": "Угостить разработчика кофе?", + "starOnGitHub": "Звезда GitHub!", + "by": "от " }, "shortcut": { "toggleCat": "Показать/скрыть", @@ -203,6 +267,7 @@ "select": "Выбрать", "selected": "Выбрано", "import": "Импорт", + "importing": "Импорт...", "builtIn": "встроенная" } } diff --git a/resources/assets/locales/zh-CN.json b/resources/assets/locales/zh-CN.json index c6e5baa4..9ef57391 100644 --- a/resources/assets/locales/zh-CN.json +++ b/resources/assets/locales/zh-CN.json @@ -9,38 +9,46 @@ "preference": { "title": "偏好设置", "cat": { - "title": "显示设置", + "title": "偏好", "labels": { - "modelSettings": "模型设置", + "modelSettings": "模型", "mirrorMode": "镜像模式", "mouseMirror": "鼠标镜像", "mouseCentered": "鼠标以桌宠为中心", "ignoreMouse": "忽略鼠标事件", - "windowSettings": "窗口设置", + "windowSettings": "窗口", "passThrough": "窗口穿透", "alwaysOnTop": "窗口置顶", "keepInScreen": "保持在屏幕内", "windowSize": "窗口尺寸", + "windowCorners": "窗口圆角 (%)", "opacity": "不透明度", - "autoReleaseDelay": "按键自动释放延迟", + "randomExpression": "随机表情", + "obsBackground": "纯色背景", "hideOnHover": "鼠标移入隐藏", + "hideFadeSeconds": "鼠标移入淡出时长(秒)", "maxFPS": "最大帧率" }, "hints": { - "passThrough": "启用后,窗口不影响对其他应用程序的操作。", + "hoverUnavailable": "当前桌面环境暂不支持鼠标移入隐藏。", + "gameInput": "游戏中桌宠没反应?", + "gameInputHelp": "试试以管理员身份启动 BongoCat,并将游戏设为窗口模式。", + "passThrough": "右键托盘图标也可关闭此功能", "alwaysOnTop": "启用后,窗口始终显示在其他应用程序上方。", "keepInScreen": "启用后,窗口会自动调整位置,防止超出屏幕边界。", + "obsBackground": "窗口捕获黑屏?", + "obsBackgroundHelp": "OBS:开启当前选项,使用 Windows 7 兼容捕获,并用色键滤镜移除背景。\n哔哩哔哩直播姬:无需开启当前选项,选择游戏进程捕获,并开启允许窗口透明。", "windowSize": "将鼠标移至窗口边缘,或按住 Shift 并右键拖动,也可以调整窗口大小。", - "hideOnHover": "启用后,鼠标悬停窗口时自动隐藏。可设置延迟时间,0 表示立即隐藏。" + "hideOnHover": "鼠标穿透和置顶均开启时,鼠标移到桌宠上立即隐藏,移开后恢复显示。" } }, "general": { "title": "通用设置", "labels": { - "appSettings": "应用设置", + "appSettings": "应用", "launchOnStartup": "开机自启动", "showTrayIcon": "显示托盘图标", - "appearanceSettings": "外观设置", + "appearanceSettings": "外观", "themeMode": "主题", "language": "语言", "permissionsSettings": "权限设置", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "启用后,在系统托盘中显示应用图标。", - "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", - "inputMonitoringPermissionGuide": "开启输入监控权限后,应用才能接收系统的键盘和鼠标事件,从而响应你的操作。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 若列表中已存在 BongoCat,请先选中它,点击「-」按钮将其删除。\n3. 点击「+」按钮,找到并添加 BongoCat 应用。\n4. 重启应用,以确保权限正式生效。", + "autostartFailed": "无法更新开机启动设置", + "inputMonitoringPermission": "授权后请重启", + "inputMonitoringPermissionGuide": "输入监控权限让 BongoCat 能接收其他应用中的按键;鼠标移动和点击无需该权限。BongoCat 在启动时建立监听,因此权限变更后需要重启。\n\n操作方式:\n1. 打开「系统设置」→「隐私与安全性」→「输入监控」。\n2. 直接开启列表中已有的 BongoCat 条目,不要删除现有条目。\n3. 若列表中没有 BongoCat,点击「+」按钮,添加你当前运行的那份应用(注意确认应用位置)。\n4. 完全退出 BongoCat 后重新启动,让它重建监听。\n\n若键盘仍然无响应,可由你自行删除并重新添加 BongoCat 条目来排障。", "administratorPermission": "以管理员身份运行应用,可以更稳定地捕获部分系统级按键与输入事件。", "administratorPermissionGuide": "为了能更稳定地捕获部分系统级按键与输入事件,建议以管理员身份运行应用。\n\n操作方式:\n1. 先退出应用。\n2. 在资源管理器中找到应用的 exe 或快捷方式。\n3. 根据需要选择启动方式:\n • 仅本次:右键并选择「以管理员身份运行」。\n • 永久默认:右键 → 属性 → 兼容性选项卡 → 勾选「以管理员身份运行此程序」→ 确定。" }, @@ -63,7 +72,8 @@ "authorized": "已授权", "adminEnabled": "已启用", "authorize": "去授权", - "viewGuide": "查看操作说明" + "viewGuide": "查看操作说明", + "openSettingsFailed": "无法打开系统设置,请手动打开:隐私与安全性 → 输入监控" }, "buttons": { "openNow": "前往开启", @@ -73,28 +83,42 @@ } }, "model": { - "title": "模型管理", + "title": "模型", + "nearbyTitle": "附近模型", "labels": { - "deleteModel": "删除模型", - "nearbyModels": "附近目录下" + "deleteModel": "删除模型" }, "hints": { - "deleteSuccess": "删除成功", - "deleteModel": "你确定要删除此模型吗?", + "deleteSuccess": "已删除“%s”", + "deleteModel": "你确定要删除“%s”吗?", + "deleteFailed": "无法删除此模型", "importSuccess": "导入成功", - "clickOrDragToImport": "点击或拖动至此区域导入", + "importExists": "模型已存在,无需重复导入", + "importBatchResult": "导入成功 %zu 个,导入失败 %zu 个。\n失败模型:%s%s。\n首个失败原因:%s", + "importMoreFailures": "等其余 %zu 个", + "importUnknownSource": "未知来源", + "importInvalidSource": "所选来源已不存在或无法使用", + "importInvalidFormat": "未找到支持的模型,或模型包文件不完整", + "importFileAccess": "无法读取或保存模型文件,请检查文件权限和可用磁盘空间", + "importOutOfMemory": "内存不足,无法导入此模型", + "importLoadFailed": "模型已导入,但无法正常显示", + "importFailed": "模型导入失败,请检查所选文件后重试", + "clickOrDragToImport": "点击或拖入模型文件夹", + "supportedImportFormats": "同时支持 Mver 和 Tauri 格式", "openDirectoryFailed": "无法打开模型目录" }, "tooltips": { "createModel": "制作模型", "convertModel": "转换模型", - "moreModels": "更多模型" + "moreModels": "模型库" }, "behaviorModal": { - "title": "动作与表情", + "title": "动作、表情与音频", "labels": { "motion": "动作", "expression": "表情", + "audio": "音频", + "stopAllAudio": "停止所有音频", "motionIndex": "动作{index}", "motionGroupIndex": "动作组{index}", "expressionIndex": "表情{index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "微信群", + "wechatHint": "悬停扫码,一起交流", + "loading": "正在加载...", + "contributorsFailed": "贡献者加载失败,点击重试。", + "qrUnavailable": "二维码暂时无法加载", + "reload": "重新加载", + "qrFailed": "二维码暂时无法加载,点击重新加载", + "contributorsTitle": "开源共建与 社区贡献", + "contributorsText": "BongoCat 的每一步都源于开源的力量。感谢所有社区贡献者的无私付出,是你们让桌面陪伴变得更加自由与纯粹。", + "joinTitle": "想出现在贡献者名单中吗?", + "joinText": "无论是提交代码、改善文档、报告 Bug 还是贡献多语言翻译,你的每一次参与都在让 BongoCat 变得更好。", + "code": "代码贡献", + "docs": "文档改进", + "bugs": "Bug 反馈", + "translations": "翻译国际化", + "joinGithub": "参与 GitHub 贡献" + }, + "multiplePets": "同时显示多个", + "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u65f6\u663e\u793a 8 \u4e2a\u684c\u5ba0", + "removeDesktopPet": "\u5173\u95ed\u6b64\u684c\u5ba0", "modelLoadFailed": "\u6a21\u578b\u65e0\u6cd5\u663e\u793a", "noBehaviors": "暂无可用项目", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat 无法启动。", + "detail": "请查看诊断日志了解技术详情。", + "diagnosticLog": "诊断日志:" + }, "support": { - "title": "支持开发团队", + "title": "关于", + "website": "官网: bongocat.pet", "heroText": "感谢你的支持。每一次使用与分享,都让 BongoCat 继续成长。", "version": "应用版本", "checkUpdate": "检查更新", "feedback": "反馈建议", + "openLogs": "打开日志", + "openLogsFailed": "无法打开日志文件夹", "latest": "已是最新版本", + "checkingUpdate": "正在检查...", + "updateAvailable": "发现新版本:", + "updateFailed": "无法检查更新", + "updateHttpFailed": "GitHub 返回 HTTP 状态码 %s", + "downloadUpdate": "更新至 v%s", + "openStore": "打开 Microsoft Store", + "viewReleases": "查看发布版本", + "openUpdateFailed": "无法打开更新页面", "works": "更多作品", - "worksText": "来自 vladelaina 的其他软件", - "refactorDeveloper": "(将BongoCat用C语言重构的开发者)", + "worksText": "来自 vladelaina 的其他作品,同样值得一试", + "refactorDeveloper": "将BongoCat用C语言重构的开发者,同时也是现任维护者", "community": "社区", "qqGroup": "QQ 群", - "coffee": "请开发团队喝杯咖啡?" + "coffee": "请开发者喝杯咖啡?", + "starOnGitHub": "喜欢就点颗星吧!", + "by": "by " }, "shortcut": { "toggleCat": "显示/隐藏", @@ -203,6 +267,7 @@ "select": "选择", "selected": "已选择", "import": "导入", + "importing": "导入中...", "builtIn": "内置" } } diff --git a/resources/assets/locales/zh-TW.json b/resources/assets/locales/zh-Hant.json similarity index 52% rename from resources/assets/locales/zh-TW.json rename to resources/assets/locales/zh-Hant.json index 5cd40323..2807fd46 100644 --- a/resources/assets/locales/zh-TW.json +++ b/resources/assets/locales/zh-Hant.json @@ -9,38 +9,46 @@ "preference": { "title": "偏好設定", "cat": { - "title": "顯示設定", + "title": "偏好", "labels": { - "modelSettings": "模型設定", + "modelSettings": "模型", "mirrorMode": "鏡像模式", "mouseMirror": "滑鼠游標鏡像", "mouseCentered": "滑鼠以桌寵為中心", "ignoreMouse": "忽略滑鼠事件", - "windowSettings": "視窗設定", + "windowSettings": "視窗", "passThrough": "視窗穿透", "alwaysOnTop": "視窗置頂", "keepInScreen": "保持在螢幕內", "windowSize": "視窗尺寸", + "windowCorners": "視窗圓角 (%)", "opacity": "不透明度", - "autoReleaseDelay": "按鍵自動釋放延遲", + "randomExpression": "隨機表情", + "obsBackground": "純色背景", "hideOnHover": "滑鼠游標移入隱藏", + "hideFadeSeconds": "隱藏漸變時長 (秒)", "maxFPS": "最大幀率" }, "hints": { - "passThrough": "啟用後,視窗不影響對其他應用程式的操作。", + "hoverUnavailable": "目前桌面環境暫不支援滑鼠移入隱藏。", + "gameInput": "遊戲中桌寵沒反應?", + "gameInputHelp": "試試以系統管理員身分啟動 BongoCat,並將遊戲設為視窗模式。", + "passThrough": "右鍵點擊系統匣圖示也可關閉此功能", "alwaysOnTop": "啟用後,視窗始終顯示在其他應用程式上方。", "keepInScreen": "啟用後,視窗會自動調整位置,防止超出螢幕邊界。", + "obsBackground": "視窗擷取黑屏?", + "obsBackgroundHelp": "OBS:開啟目前選項,使用 Windows 7 相容擷取,並用色鍵濾鏡移除背景。\n嗶哩嗶哩直播姬:不需要開啟目前選項,選擇遊戲程序擷取,並開啟允許視窗透明。", "windowSize": "將滑鼠游標移至視窗邊緣,或按住 Shift 並右鍵拖曳,也可以調整視窗大小。", - "hideOnHover": "啟用後,滑鼠游標懸停視窗時自動隱藏。可設定延遲時間,0 表示立即隱藏。" + "hideOnHover": "滑鼠穿透和置頂皆啟用時,滑鼠移到桌寵上立即隱藏,移開後恢復顯示。" } }, "general": { "title": "一般設定", "labels": { - "appSettings": "應用程式設定", + "appSettings": "應用程式", "launchOnStartup": "開機自動啟動", "showTrayIcon": "顯示托盤圖示", - "appearanceSettings": "外觀設定", + "appearanceSettings": "外觀", "themeMode": "主題", "language": "語言", "permissionsSettings": "權限設定", @@ -54,8 +62,9 @@ }, "hints": { "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", - "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", - "inputMonitoringPermissionGuide": "開啟輸入監控權限後,應用程式才能接收系統的鍵盤和滑鼠事件,進而回應你的操作。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 若清單中已存在 BongoCat,請先選取它,點擊「-」按鈕將其刪除。\n3. 點擊「+」按鈕,找到並新增 BongoCat 應用程式。\n4. 重新啟動應用程式,以確保權限正式生效。", + "autostartFailed": "無法更新開機啟動設定", + "inputMonitoringPermission": "授權後請重新啟動", + "inputMonitoringPermissionGuide": "輸入監控權限讓 BongoCat 能接收其他應用程式中的按鍵;滑鼠移動和點擊無需該權限。BongoCat 在啟動時建立監聽,因此權限變更後需要重新啟動。\n\n操作方式:\n1. 開啟「系統設定」→「隱私權與安全性」→「輸入監控」。\n2. 直接開啟清單中已有的 BongoCat 項目,不要刪除現有項目。\n3. 若清單中沒有 BongoCat,點擊「+」按鈕,加入你目前執行的那份應用程式(請確認應用程式位置)。\n4. 完全結束 BongoCat 後重新啟動,讓它重建監聽。\n\n若鍵盤仍然沒有反應,可由你自行移除並重新加入 BongoCat 項目來排解問題。", "administratorPermission": "以系統管理員身分執行應用程式,可以更穩定地擷取部分系統層級按鍵與輸入事件。", "administratorPermissionGuide": "為了能更穩定地擷取部分系統層級按鍵與輸入事件,建議以系統管理員身分執行應用程式。\n\n操作方式:\n1. 先退出應用程式。\n2. 在檔案總管中找到應用程式的 exe 或捷徑。\n3. 根據需求選擇啟動方式:\n • 僅本次:右鍵並選擇「以系統管理員身分執行」。\n • 永久預設:右鍵 → 內容 → 相容性索引標籤 → 勾選「以系統管理員身分執行此程式」→ 確定。" }, @@ -63,7 +72,8 @@ "authorized": "已授權", "adminEnabled": "已啟用", "authorize": "去授權", - "viewGuide": "查看操作說明" + "viewGuide": "查看操作說明", + "openSettingsFailed": "無法開啟系統設定,請手動開啟:隱私權與安全性 → 輸入監控" }, "buttons": { "openNow": "前往開啟", @@ -73,28 +83,42 @@ } }, "model": { - "title": "模型管理", + "title": "模型", + "nearbyTitle": "附近模型", "labels": { - "deleteModel": "刪除模型", - "nearbyModels": "附近資料夾" + "deleteModel": "刪除模型" }, "hints": { - "deleteSuccess": "刪除成功", - "deleteModel": "您確定要刪除此模型嗎?", + "deleteSuccess": "已刪除「%s」", + "deleteModel": "您確定要刪除「%s」嗎?", + "deleteFailed": "無法刪除此模型", "importSuccess": "匯入成功", - "clickOrDragToImport": "點擊或拖曳至此區域匯入", + "importExists": "模型已存在,無需重複匯入", + "importBatchResult": "匯入成功 %zu 個,匯入失敗 %zu 個。\n失敗模型:%s%s。\n首個失敗原因:%s", + "importMoreFailures": "等其餘 %zu 個", + "importUnknownSource": "未知來源", + "importInvalidSource": "所選來源已不存在或無法使用", + "importInvalidFormat": "找不到支援的模型,或模型套件檔案不完整", + "importFileAccess": "無法讀取或儲存模型檔案,請檢查檔案權限和可用磁碟空間", + "importOutOfMemory": "記憶體不足,無法匯入此模型", + "importLoadFailed": "模型已匯入,但無法正常顯示", + "importFailed": "模型匯入失敗,請檢查所選檔案後再試一次", + "clickOrDragToImport": "點擊或拖入模型資料夾", + "supportedImportFormats": "支援 Mver 與 Tauri 格式", "openDirectoryFailed": "無法開啟模型目錄" }, "tooltips": { "createModel": "製作模型", "convertModel": "轉換模型", - "moreModels": "更多模型" + "moreModels": "模型庫" }, "behaviorModal": { - "title": "動作與表情", + "title": "動作、表情與音訊", "labels": { "motion": "動作", "expression": "表情", + "audio": "音訊", + "stopAllAudio": "停止所有音訊", "motionIndex": "動作{index}", "motionGroupIndex": "動作組{index}", "expressionIndex": "表情{index}" @@ -171,21 +195,61 @@ } }, "native": { + "about": { + "wechat": "微信群組", + "wechatHint": "懸停掃碼,一起交流", + "loading": "正在載入...", + "contributorsFailed": "貢獻者載入失敗,點擊重試。", + "qrUnavailable": "QR 碼暫時無法載入", + "reload": "重新載入", + "qrFailed": "二維碼暫時無法載入,點擊重新載入", + "contributorsTitle": "開源共建與 社群貢獻", + "contributorsText": "BongoCat 的每一步都源於開源的力量。感謝所有社群貢獻者的無私付出,是你們讓桌面陪伴變得更加自由與純粹。", + "joinTitle": "想出現在貢獻者名單中嗎?", + "joinText": "無論是提交程式碼、改善文件、回報 Bug 還是貢獻多語言翻譯,你的每一次參與都在讓 BongoCat 變得更好。", + "code": "程式碼貢獻", + "docs": "文件改進", + "bugs": "Bug 回報", + "translations": "翻譯國際化", + "joinGithub": "參與 GitHub 貢獻" + }, + "multiplePets": "同時顯示多個", + "multiplePetsLimit": "\u6700\u591a\u53ef\u540c\u6642\u986f\u793a 8 \u500b\u684c\u5bf5", + "removeDesktopPet": "\u95dc\u9589\u6b64\u684c\u5bf5", "modelLoadFailed": "\u6a21\u578b\u7121\u6cd5\u986f\u793a", "noBehaviors": "暫無可用項目", + "preferencesWindowTitle": "BongoCat - Settings", + "startup": { + "failed": "BongoCat 無法啟動。", + "detail": "請查看診斷日誌以瞭解技術詳情。", + "diagnosticLog": "診斷日誌:" + }, "support": { - "title": "支援開發團隊", + "title": "關於", + "website": "官網: bongocat.pet", "heroText": "感謝你的支持。每一次使用與分享,都讓 BongoCat 持續成長。", "version": "應用程式版本", "checkUpdate": "檢查更新", "feedback": "意見回饋", + "openLogs": "開啟日誌", + "openLogsFailed": "無法開啟日誌資料夾", "latest": "已是最新版本", + "checkingUpdate": "正在檢查...", + "updateAvailable": "發現新版本:", + "updateFailed": "無法檢查更新", + "updateHttpFailed": "GitHub 回傳 HTTP 狀態碼 %s", + "downloadUpdate": "更新至 v%s", + "openStore": "開啟 Microsoft Store", + "viewReleases": "查看發佈版本", + "openUpdateFailed": "無法開啟更新頁面", "works": "更多作品", - "worksText": "來自 vladelaina 的其他軟體", - "refactorDeveloper": "(使用C語言重構BongoCat的開發者)", + "worksText": "來自 vladelaina 的其他作品,同樣值得一試", + "refactorDeveloper": "將BongoCat用C語言重構的開發者,同時也是現任維護者", "community": "社群", "qqGroup": "QQ 群", - "coffee": "請開發團隊喝杯咖啡?" + "coffee": "請開發者喝杯咖啡?", + "starOnGitHub": "喜歡就點顆星吧!", + "by": "by " }, "shortcut": { "toggleCat": "顯示/隱藏", @@ -203,6 +267,7 @@ "select": "選擇", "selected": "已選擇", "import": "匯入", + "importing": "匯入中...", "builtIn": "內建" } } diff --git a/resources/assets/logo-mac.png b/resources/assets/logo-mac.png deleted file mode 100644 index 16b3e715..00000000 Binary files a/resources/assets/logo-mac.png and /dev/null differ diff --git a/resources/assets/logo.png b/resources/assets/logo.png deleted file mode 100644 index ef21cb48..00000000 Binary files a/resources/assets/logo.png and /dev/null differ diff --git a/resources/assets/models/LICENSE b/resources/assets/models/LICENSE new file mode 100644 index 00000000..585a2078 --- /dev/null +++ b/resources/assets/models/LICENSE @@ -0,0 +1,30 @@ +MIT License + +SPDX-License-Identifier: MIT + +Copyright (c) 2026 vladelaina + +The bundled model assets in the `standard`, `keyboard`, and `gamepad` +directories are provided under the MIT License. This applies to those model +assets and their accompanying textures, motions, expressions, and input +artwork only; it does not change the AGPL-3.0-only license of the BongoCat +source. The canonical copy of this notice is also available at +`../../LICENSE-MIT`. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of these model assets and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/resources/assets/tray-mac.png b/resources/assets/tray-mac.png deleted file mode 100644 index 5932d39a..00000000 Binary files a/resources/assets/tray-mac.png and /dev/null differ diff --git a/resources/assets/tray.png b/resources/assets/tray.png deleted file mode 100644 index ef21cb48..00000000 Binary files a/resources/assets/tray.png and /dev/null differ diff --git a/resources/assets/ui-icons.png b/resources/assets/ui-icons.png deleted file mode 100644 index bef5a002..00000000 Binary files a/resources/assets/ui-icons.png and /dev/null differ diff --git a/resources/assets/ui-symbols-1x.png b/resources/assets/ui-symbols-1x.png deleted file mode 100644 index c22039cc..00000000 Binary files a/resources/assets/ui-symbols-1x.png and /dev/null differ diff --git a/resources/assets/ui-symbols-4x.png b/resources/assets/ui-symbols-4x.png deleted file mode 100644 index 3ebbc8e4..00000000 Binary files a/resources/assets/ui-symbols-4x.png and /dev/null differ diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png deleted file mode 100644 index 6083ccf8..00000000 Binary files a/resources/icons/128x128.png and /dev/null differ diff --git a/resources/icons/icon.icns b/resources/icons/icon.icns index 7d3d0ee3..aaaa04b4 100644 Binary files a/resources/icons/icon.icns and b/resources/icons/icon.icns differ diff --git a/resources/icons/icon.ico b/resources/icons/icon.ico index 73a2c9fe..0653770a 100644 Binary files a/resources/icons/icon.ico and b/resources/icons/icon.ico differ diff --git a/resources/icons/icon.png b/resources/icons/icon.png deleted file mode 100644 index 610221f1..00000000 Binary files a/resources/icons/icon.png and /dev/null differ diff --git a/resources/linux/bongo-cat.desktop b/resources/linux/bongo-cat.desktop deleted file mode 100644 index 22e7de04..00000000 --- a/resources/linux/bongo-cat.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Type=Application -Name=BongoCat -Comment=BongoCat Live2D desktop pet -Exec=BongoCat -Icon=bongo-cat -Categories=Game;Utility; -Terminal=false diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 9c1ec41e..821dcd8d 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -2,10 +2,18 @@ param( [ValidateSet('Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel')] [string]$Configuration = 'Release', [string]$BuildDir = '', + [ValidateSet('x64', 'Win32')] + [string]$Architecture = 'x64', [ValidateRange(1, 64)] [int]$Jobs = 2, + [switch]$SkipConfigure, + [string[]]$Target = @('bongo_cat'), [switch]$RequireCubism, - [switch]$Clean + [switch]$Package, + [switch]$Clean, + # Reapply defaults to existing caches; -SkipConfigure reuses cached values. + [bool]$OptimizeReleaseSize = $true, + [bool]$OptimizeReleaseIpo = $true ) $ErrorActionPreference = 'Continue' @@ -21,6 +29,15 @@ if (-not [IO.Path]::IsPathRooted($BuildDir)) { $BuildDir = Join-Path $root $BuildDir } $BuildDir = [IO.Path]::GetFullPath($BuildDir) +$Target = @($Target | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if ($Target.Count -eq 0) { + Write-Host 'At least one CMake target is required.' + exit 1 +} +if ($Clean -and $SkipConfigure) { + Write-Host 'The -Clean and -SkipConfigure options cannot be used together.' + exit 1 +} $esc = [char]27 $pink = '38;2;247;125;170' $muted = '38;2;80;80;80' @@ -49,11 +66,36 @@ function Show-FailureLog { } } +function Write-GitHubBuildAnnotations { + param([string]$Path) + if ($env:GITHUB_ACTIONS -ne 'true' -or + -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $pattern = 'FAILED:|fatal error|(?:warning|error) [A-Z]+\d+:|LNK\d+|MSB\d+: error|unresolved external|cannot open file|ninja: build stopped' + Get-Content -LiteralPath $Path | + Where-Object { $_ -match $pattern } | + Select-Object -Last 30 | + ForEach-Object { + $message = $_.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Output "::error title=Windows build failure::$message" + } +} + if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { Write-Host 'Error: CMake was not found in PATH.' exit 1 } +if ($Package -or $Target -contains 'package-installer') { + try { + $isccPath = & (Join-Path $root 'packaging/windows/find-inno.ps1') + $env:Path = "$(Split-Path $isccPath -Parent);$env:Path" + Write-Host "Inno Setup compiler: $isccPath" + } catch { + Write-Host $_.Exception.Message + exit 1 + } +} + if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { $rootPrefix = $root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $insideRoot = $BuildDir.StartsWith($rootPrefix, @@ -62,8 +104,38 @@ if ($Clean -and (Test-Path -LiteralPath $BuildDir)) { Write-Host "Refusing to clean unexpected directory: $BuildDir" exit 1 } - Write-BuildProgress 2 'Cleaning previous build...' - Remove-Item -LiteralPath $BuildDir -Recurse -Force + Write-BuildProgress 2 'Cleaning previous build artifacts...' + $depsPath = Join-Path $BuildDir '_deps' + $preservedDeps = Join-Path (Split-Path $BuildDir -Parent) ( + '.bongocat-deps-' + [Guid]::NewGuid().ToString('N')) + $hasDeps = Test-Path -LiteralPath $depsPath + try { + if ($hasDeps) { + Move-Item -LiteralPath $depsPath -Destination $preservedDeps ` + -Force -ErrorAction Stop + } + Remove-Item -LiteralPath $BuildDir -Recurse -Force -ErrorAction Stop + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + if ($hasDeps) { + Move-Item -LiteralPath $preservedDeps ` + -Destination (Join-Path $BuildDir '_deps') -Force ` + -ErrorAction Stop + } + } catch { + if ($hasDeps -and (Test-Path -LiteralPath $preservedDeps) -and + -not (Test-Path -LiteralPath $depsPath)) { + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + Move-Item -LiteralPath $preservedDeps -Destination $depsPath ` + -Force -ErrorAction SilentlyContinue + } + Write-Host "Cleaning failed: $($_.Exception.Message)" + exit 1 + } +} + +if ($SkipConfigure -and -not (Test-Path -LiteralPath (Join-Path $BuildDir 'CMakeCache.txt'))) { + Write-Host "Cannot skip configuration because no CMake cache exists in: $BuildDir" + exit 1 } New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null @@ -73,39 +145,46 @@ $start = [DateTime]::UtcNow Write-Host '' Write-Host "BongoCat $Configuration build" -Write-BuildProgress 5 'Configuring project...' -$configureArgs = @( - '-S', $root, '-B', $BuildDir, - '-G', 'Visual Studio 17 2022', '-A', 'x64', - '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON' -) -if ($RequireCubism) { $configureArgs += '-DBONGO_CAT_REQUIRE_CUBISM=ON' } -$configureWriter = New-Object IO.StreamWriter( - $configureLog, $false, (New-Object Text.UTF8Encoding($false))) -$configureActivity = 0 -$configurePercent = 5 -try { - & cmake @configureArgs 2>&1 | ForEach-Object { - $configureWriter.WriteLine($_.ToString()) - $configureActivity++ - $nextPercent = [Math]::Min(19, - 5 + [int][Math]::Floor([Math]::Sqrt($configureActivity))) - if ($nextPercent -ne $configurePercent) { - $configurePercent = $nextPercent - Write-BuildProgress $configurePercent 'Configuring project...' +if ($SkipConfigure) { + Write-BuildProgress 20 'Using existing CMake configuration.' -NewLine +} else { + Write-BuildProgress 5 'Configuring project...' + $configureArgs = @( + '-S', $root, '-B', $BuildDir, + '-G', 'Visual Studio 17 2022', '-A', $Architecture, + '-DBONGO_CAT_WARNINGS_AS_ERRORS=ON', + "-DBONGO_CAT_OPTIMIZE_RELEASE_SIZE=$($OptimizeReleaseSize.ToString().ToUpperInvariant())", + "-DBONGO_CAT_OPTIMIZE_RELEASE_IPO=$($OptimizeReleaseIpo.ToString().ToUpperInvariant())" + ) + if ($RequireCubism) { $configureArgs += '-DBONGO_CAT_REQUIRE_CUBISM=ON' } + $configureWriter = New-Object IO.StreamWriter( + $configureLog, $false, (New-Object Text.UTF8Encoding($false))) + $configureActivity = 0 + $configurePercent = 5 + try { + & cmake @configureArgs 2>&1 | ForEach-Object { + $configureWriter.WriteLine($_.ToString()) + $configureActivity++ + $nextPercent = [Math]::Min(19, + 5 + [int][Math]::Floor([Math]::Sqrt($configureActivity))) + if ($nextPercent -ne $configurePercent) { + $configurePercent = $nextPercent + Write-BuildProgress $configurePercent 'Configuring project...' + } } + $configureStatus = $LASTEXITCODE + } finally { + $configureWriter.Dispose() } - $configureStatus = $LASTEXITCODE -} finally { - $configureWriter.Dispose() -} -if ($configureStatus -ne 0) { - Write-BuildProgress 5 'Configuration failed.' -NewLine - Show-FailureLog @($configureLog) - Write-Host "Full log: $configureLog" - exit 1 + if ($configureStatus -ne 0) { + Write-BuildProgress 5 'Configuration failed.' -NewLine + Write-GitHubBuildAnnotations $configureLog + Show-FailureLog @($configureLog) + Write-Host "Full log: $configureLog" + exit 1 + } + Write-BuildProgress 20 'Configuration complete.' -NewLine } -Write-BuildProgress 20 'Configuration complete.' -NewLine Remove-Item -LiteralPath $buildLog -Force -ErrorAction SilentlyContinue $projects = @(Get-ChildItem -LiteralPath $BuildDir -Recurse -Filter '*.vcxproj' ` @@ -117,11 +196,11 @@ foreach ($project in $projects) { } $compileItems = [Math]::Max(1, $compileItems) $buildArgs = @('--build', $BuildDir, '--config', $Configuration, - '--target', 'bongo_cat', '--parallel', $Jobs) + '--target') + $Target + @('--parallel', $Jobs) $lastPercent = 20 $compiled = 0 $activity = 0 -Write-BuildProgress $lastPercent 'Building BongoCat...' +Write-BuildProgress $lastPercent ("Building target(s): {0}..." -f ($Target -join ', ')) $buildWriter = New-Object IO.StreamWriter( $buildLog, $false, (New-Object Text.UTF8Encoding($false))) try { @@ -141,7 +220,7 @@ try { $lastPercent = $percent $message = if ($compiled -gt 0) { "Compiling ($compiled files)..." - } else { 'Building BongoCat...' } + } else { "Building target(s): $($Target -join ', ')..." } Write-BuildProgress $lastPercent $message } } @@ -152,19 +231,55 @@ try { if ($buildStatus -ne 0) { Write-BuildProgress $lastPercent 'Build failed.' -NewLine + Write-GitHubBuildAnnotations $buildLog Show-FailureLog @($buildLog) Write-Host "Full log: $buildLog" exit $buildStatus } -$output = Join-Path (Join-Path $BuildDir $Configuration) 'BongoCat.exe' -if (-not (Test-Path -LiteralPath $output)) { - Write-BuildProgress 95 'BongoCat.exe was not produced.' -NewLine - exit 1 +if ($Target -contains 'bongo_cat') { + $output = Join-Path (Join-Path $BuildDir $Configuration) 'BongoCat.exe' + if (-not (Test-Path -LiteralPath $output)) { + Write-BuildProgress 95 'BongoCat.exe was not produced.' -NewLine + exit 1 + } } $elapsedTotal = [DateTime]::UtcNow - $start Write-BuildProgress 100 'Build complete.' -NewLine Write-Host ("Build time: {0:mm\:ss}" -f $elapsedTotal) -Write-Host "Output: $output" +if ($output) { Write-Host "Output: $output" } Write-Host "Logs: $buildLog" + +if ($Package) { + Write-Host '' + Write-Host 'Building versioned portable and installer packages...' + $packageArgs = @('--build', $BuildDir, '--config', $Configuration, + '--target', 'package-portable', 'package-installer', '--parallel', $Jobs) + & cmake @packageArgs + $packageStatus = $LASTEXITCODE + if ($packageStatus -ne 0) { + Write-Host 'Package generation failed.' + Write-Host 'Ensure Inno Setup 6.3 or newer is installed (ISCC.exe).' + Write-Host "Packaging build directory: $BuildDir" + exit $packageStatus + } + $packageNameFile = Join-Path $BuildDir 'bongocat-package-name.txt' + if (-not (Test-Path -LiteralPath $packageNameFile)) { + Write-Host "Package name file was not produced: $packageNameFile" + exit 1 + } + $packageName = (Get-Content -LiteralPath $packageNameFile -Raw).Trim() + $packageDist = Join-Path $BuildDir 'dist' + $portable = Join-Path $packageDist "$packageName-portable.exe" + $installer = Join-Path $packageDist "$packageName-setup.exe" + if (-not (Test-Path -LiteralPath $portable) -or + -not (Test-Path -LiteralPath $installer)) { + Write-Host 'Package generation completed without both expected files.' + Write-Host "Expected portable: $portable" + Write-Host "Expected installer: $installer" + exit 1 + } + Write-Host "Portable package: $portable" + Write-Host "Installer package: $installer" +} exit 0 diff --git a/scripts/generate-dial-icons.py b/scripts/generate-dial-icons.py new file mode 100644 index 00000000..3df35f31 --- /dev/null +++ b/scripts/generate-dial-icons.py @@ -0,0 +1,115 @@ +"""Convert the reference's SVG icons into static C polylines (no runtime SVG).""" +from pathlib import Path +import argparse, re, math, xml.etree.ElementTree as ET +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('reference', type=Path, help='Path to the reference index.html') +parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parents[1] / + 'src/ui/dial/dial_icons.c') +args = parser.parse_args() +html=args.reference.read_text(encoding='utf-8') +icon_block = re.search(r'const\s+icons\s*=\s*\{(.*?)\n\s*\};', html, re.S) +if not icon_block: + parser.error('Reference does not contain the SVG icons object') +icons=dict(re.findall(r"^ (\w+): '([^']+)'",icon_block.group(1),re.M)) +order=['settings','hide','pass','pin','size','opacity','motion','expression','audio','model','exit','close','passOn','pinOn'] +def arc(x,y,rx,ry,rotation,large,sweep,nx,ny): + rx,ry=abs(rx),abs(ry) + if not rx or not ry or (x==nx and y==ny): return [(nx,ny)] + phi=math.radians(rotation); co,si=math.cos(phi),math.sin(phi) + dx,dy=(x-nx)/2,(y-ny)/2 + xp,yp=co*dx+si*dy,-si*dx+co*dy + k=xp*xp/(rx*rx)+yp*yp/(ry*ry) + if k>1: rx*=math.sqrt(k); ry*=math.sqrt(k) + q=max(0,(rx*rx*ry*ry-rx*rx*yp*yp-ry*ry*xp*xp)/(rx*rx*yp*yp+ry*ry*xp*xp)) + factor=(-1 if bool(large)==bool(sweep) else 1)*math.sqrt(q) + cxp,cyp=factor*rx*yp/ry,-factor*ry*xp/rx + cx,cy=co*cxp-si*cyp+(x+nx)/2,si*cxp+co*cyp+(y+ny)/2 + a=math.atan2((yp-cyp)/ry,(xp-cxp)/rx) + end=math.atan2((-yp-cyp)/ry,(-xp-cxp)/rx) + delta=(end-a)%(2*math.pi) + if not sweep: delta-=2*math.pi + steps=max(2,math.ceil(abs(delta)*max(rx,ry)/0.5)) + return [(cx+co*rx*math.cos(a+delta*j/steps)-si*ry*math.sin(a+delta*j/steps),cy+si*rx*math.cos(a+delta*j/steps)+co*ry*math.sin(a+delta*j/steps)) for j in range(1,steps+1)] +def path(data): + tokens=re.findall(r'[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',data) + contours=[]; points=[]; x=y=0; start=(0,0); i=0; cmd=None; last_control=(0,0); previous=None + while i'+icons[name]+''): + a=el.attrib + if el.tag=='path': paths=path(a['d']) + elif el.tag=='circle': + cx,cy,r=map(float,[a['cx'],a['cy'],a['r']]); paths=[[(cx+r*math.cos(j*math.pi/24),cy+r*math.sin(j*math.pi/24)) for j in range(49)]] + elif el.tag=='line': paths=[[(float(a['x1']),float(a['y1'])),(float(a['x2']),float(a['y2']))]] + else: + v=list(map(float,re.findall(r'[-+]?[\d.]+',a['points']))); pts=list(zip(v[::2],v[1::2])); paths=[pts+[pts[0]] if el.tag=='polygon' else pts] + for pts in paths: + contours.append((len(allpoints),len(pts))); allpoints+=pts + if len(contours) == first: + raise ValueError(f'Icon {name!r} has no drawable contours') + ranges.append((first,len(contours)-first)) +lines.append('static const DialPoint points[] = {') +for j in range(0,len(allpoints),4): lines.append(' '+', '.join('{%.4ff, %.4ff}'%p for p in allpoints[j:j+4])+',') +lines+=['};','static const IconContour contours[] = {'] +for j in range(0,len(contours),6): lines.append(' '+', '.join('{%d, %d}'%p for p in contours[j:j+6])+',') +lines+=['};','static const IconContour icons[] = {',' '+', '.join('{%d, %d}'%p for p in ranges),'};', ''' +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color) { + if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; + IconContour range = icons[icon]; + for (int i = 0; i < range.count; ++i) { + IconContour contour = contours[range.offset + i]; + DialPoint transformed[512]; + if (contour.count > 512) continue; + for (int j = 0; j < contour.count; ++j) { + DialPoint p = points[contour.offset + j]; + transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, + y + (p.y - 12) * size / 24}; + } + dial_stroke(d, transformed, contour.count, 1.95f * size / 24, color, true); + } +} +'''] +assert max(n for _, n in contours) <= 512 +args.output.write_text('\n'.join(lines),encoding='utf-8') +print('Generated',len(allpoints),'icon points;',max(n for _,n in contours),'max contour points') diff --git a/src/core/app_state.c b/src/core/app_state.c index 9d0e72aa..bfd4a37b 100644 --- a/src/core/app_state.c +++ b/src/core/app_state.c @@ -4,6 +4,50 @@ #include #include +static size_t active_input_index(const BongoCatApp *app, + BongoCatInputKind kind, const char *name) { + if (!app || !name) return app ? app->active_input_count : 0; + for (size_t i = 0; i < app->active_input_count; ++i) { + const BongoCatInputEvent *active = &app->active_inputs[i]; + if (active->kind == kind && strcmp(active->name, name) == 0) return i; + } + return app->active_input_count; +} + +static void active_input_remove(BongoCatApp *app, size_t index) { + if (!app || index >= app->active_input_count) return; + if (index + 1 < app->active_input_count) + memmove(&app->active_inputs[index], &app->active_inputs[index + 1], + (app->active_input_count - index - 1) * sizeof(app->active_inputs[0])); + memset(&app->active_inputs[--app->active_input_count], 0, + sizeof(app->active_inputs[0])); +} + +static void active_input_update(BongoCatApp *app, + const BongoCatInputEvent *event) { + if (!app || !event || !event->name[0]) return; + bool tracked = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP || + event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS; + if (!tracked) return; + bool active = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON && event->value > 0.5f) || + (event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS && fabsf(event->value) > .001f); + BongoCatInputKind kind = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP + ? BONGO_CAT_INPUT_KEY_DOWN : event->kind; + size_t index = active_input_index(app, kind, event->name); + if (!active) { active_input_remove(app, index); return; } + if (index == app->active_input_count) { + if (app->active_input_count >= + sizeof(app->active_inputs) / sizeof(app->active_inputs[0])) return; + index = app->active_input_count++; + } + app->active_inputs[index] = *event; + app->active_inputs[index].kind = kind; +} + static bool stick_active(float x, float y, bool pressed) { return pressed || fabsf(x) > 0.001f || fabsf(y) > 0.001f; } @@ -21,10 +65,12 @@ static void update_hands(BongoCatApp *app) { (right_stick || bongo_cat_overlay_hand_active(app->overlay, true)) ? 1.0f : 0.0f); } -static void apply_key(BongoCatApp *app, const char *name, bool pressed) { - if (bongo_cat_overlay_key(app->overlay, name, pressed) < 0) return; +static bool apply_key(BongoCatApp *app, const char *name, bool pressed) { + int hand = bongo_cat_overlay_key(app->overlay, name, pressed); + if (hand < 0) return false; update_hands(app); app->dirty = true; + return true; } static void set_axis(BongoCatApp *app, const char *id, float input) { @@ -64,6 +110,16 @@ static void apply_gamepad(BongoCatApp *app, const BongoCatInputEvent *event) { void bongo_cat_app_reset_gamepad(BongoCatApp *app) { if (!app || !app->live2d) return; + for (size_t i = app->sound_shortcut_state.count; i > 0; --i) { + if (strncmp(app->sound_shortcut_state.held[i - 1], "Gamepad:", 8)) continue; + --app->sound_shortcut_state.count; + if (i - 1 != app->sound_shortcut_state.count) + memcpy(app->sound_shortcut_state.held[i - 1], + app->sound_shortcut_state.held[app->sound_shortcut_state.count], BONGO_CAT_ID_CAP); + } + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) + if (strstr(app->settings.behavior_shortcuts[i].shortcut, "Gamepad:")) + app->sound_shortcut_active[i] = false; app->left_stick_x = app->left_stick_y = 0.0f; app->right_stick_x = app->right_stick_y = 0.0f; app->left_stick_pressed = app->right_stick_pressed = false; @@ -79,24 +135,60 @@ void bongo_cat_app_reset_gamepad(BongoCatApp *app) { "RightPaddle2", "LeftPaddle2", "Touchpad", "LeftThumb", "RightThumb"}; for (size_t i = 0; i < sizeof(buttons) / sizeof(buttons[0]); ++i) apply_key(app, buttons[i], false); + for (size_t i = app->active_input_count; i > 0; --i) + if (app->active_inputs[i - 1].kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + app->active_inputs[i - 1].kind == BONGO_CAT_INPUT_GAMEPAD_AXIS) + active_input_remove(app, i - 1); update_hands(app); app->dirty = true; } void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event) { - if (!app || !event || !app->live2d) return; + if (!app || !event) return; + bool keyboard = event->kind == BONGO_CAT_INPUT_KEY_DOWN || + event->kind == BONGO_CAT_INPUT_KEY_UP; + if (!app->live2d) { + if (keyboard) app->input_diagnostics.no_model_keys++; + return; + } + active_input_update(app, event); switch (event->kind) { - case BONGO_CAT_INPUT_KEY_DOWN: apply_key(app, event->name, true); break; - case BONGO_CAT_INPUT_KEY_UP: apply_key(app, event->name, false); break; + case BONGO_CAT_INPUT_KEY_DOWN: + case BONGO_CAT_INPUT_KEY_UP: + if (apply_key(app, event->name, event->kind == BONGO_CAT_INPUT_KEY_DOWN)) + app->input_diagnostics.mapped_keys++; + else app->input_diagnostics.unmapped_keys++; + break; case BONGO_CAT_INPUT_MOUSE_DOWN: case BONGO_CAT_INPUT_MOUSE_UP: { - if (strcmp(event->name, "Left") != 0 && strcmp(event->name, "Right") != 0) + bool left = strcmp(event->name, "Left") == 0; + bool right = strcmp(event->name, "Right") == 0; + bool side = strcmp(event->name, "Back") == 0 || + strcmp(event->name, "Forward") == 0; + if (!left && !right && !side) break; bool down = event->kind == BONGO_CAT_INPUT_MOUSE_DOWN; - if (strcmp(event->name, "Left") == 0) app->left_mouse_down = down; - else app->right_mouse_down = down; + bool changed; + if (left) { + changed = app->left_mouse_down != down; + app->left_mouse_down = down; + } else if (right) { + changed = app->right_mouse_down != down; + app->right_mouse_down = down; + } else { + if (strcmp(event->name, "Back") == 0) app->back_mouse_down = down; + else app->forward_mouse_down = down; + bool side_down = app->back_mouse_down || app->forward_mouse_down; + changed = app->side_mouse_down != side_down; + app->side_mouse_down = side_down; + } + if (changed) app->mouse_button_event_pending = true; if (!down) app->pointer_hit_dirty = true; - const char *id = strcmp(event->name, "Left") == 0 + if (side) { + app->dirty = true; + break; + } + const char *id = left ? "ParamMouseLeftDown" : "ParamMouseRightDown"; bongo_cat_live2d_set_parameter(app->live2d, id, down ? 1.0f : 0.0f); app->dirty = true; @@ -107,3 +199,21 @@ void bongo_cat_app_apply_input(BongoCatApp *app, const BongoCatInputEvent *event default: break; } } + +void bongo_cat_app_reapply_input(BongoCatApp *app) { + if (!app || !app->live2d) return; + bongo_cat_live2d_set_parameter(app->live2d, "ParamMouseLeftDown", + app->left_mouse_down ? 1.0f : 0.0f); + bongo_cat_live2d_set_parameter(app->live2d, "ParamMouseRightDown", + app->right_mouse_down ? 1.0f : 0.0f); + for (size_t i = 0; i < app->active_input_count; ++i) { + BongoCatInputEvent *event = &app->active_inputs[i]; + if (event->kind == BONGO_CAT_INPUT_KEY_DOWN) + apply_key(app, event->name, true); + else if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event->kind == BONGO_CAT_INPUT_GAMEPAD_AXIS) + apply_gamepad(app, event); + } + update_hands(app); + app->dirty = true; +} diff --git a/src/core/config_defaults.c b/src/core/config_defaults.c index 2877b5d0..184eb904 100644 --- a/src/core/config_defaults.c +++ b/src/core/config_defaults.c @@ -2,9 +2,12 @@ #include "bongo_cat/utf8.h" #include +#include +#include #include -static float clampf(float value, float low, float high) { +static float clampf_or(float value, float low, float high, float fallback) { + if (!isfinite(value)) return fallback; return value < low ? low : value > high ? high : value; } @@ -17,111 +20,218 @@ static bool shortcut_equal(const char *left, const char *right) { return *left == *right; } -bool bongo_cat_config_shortcut_conflicts(const BongoCatConfig *config, +static bool normalize_text(char *text, size_t capacity) { + text[capacity - 1] = '\0'; + if (!bongo_cat_utf8_valid(text)) { + memset(text, 0, capacity); + return false; + } + size_t length = strlen(text); + memset(text + length + 1, 0, capacity - length - 1); + return true; +} + +bool bongo_cat_settings_shortcut_conflicts(const BongoCatSettings *config, const char *shortcut, const char *exclude) { if (!config || !shortcut || !shortcut[0]) return false; - const char *global[] = {config->shortcuts.visible_cat, + const char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) if (global[i] != exclude && shortcut_equal(global[i], shortcut)) return true; - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { + size_t behavior_count = config->behavior_shortcut_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + /* Model behaviors may share a key; global commands remain exclusive. */ + for (size_t i = 0; i < behavior_count; ++i) + if (config->behavior_shortcuts[i].shortcut == exclude) return false; + for (size_t i = 0; i < behavior_count; ++i) { const char *bound = config->behavior_shortcuts[i].shortcut; - if (bound != exclude && shortcut_equal(bound, shortcut)) return true; + if (shortcut_equal(bound, shortcut)) return true; } return false; } -static void validate_shortcuts(BongoCatConfig *config) { - char *global[] = {config->shortcuts.visible_cat, +static void validate_shortcuts(BongoCatSettings *config) { + char *global[] = {config->shortcuts.toggle_pet_visibility, config->shortcuts.visible_preferences, config->shortcuts.mirror, config->shortcuts.pass_through, config->shortcuts.always_on_top}; for (size_t i = 0; i < sizeof(global) / sizeof(global[0]); ++i) { - global[i][BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; + normalize_text(global[i], BONGO_CAT_SHORTCUT_CAP); for (size_t j = 0; j < i; ++j) - if (shortcut_equal(global[i], global[j])) global[i][0] = '\0'; + if (shortcut_equal(global[i], global[j])) + memset(global[i], 0, BONGO_CAT_SHORTCUT_CAP); } for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { char *shortcut = config->behavior_shortcuts[i].shortcut; bool duplicate = false; for (size_t j = 0; j < sizeof(global) / sizeof(global[0]); ++j) duplicate = duplicate || shortcut_equal(shortcut, global[j]); - for (size_t j = 0; j < i; ++j) - duplicate = duplicate || shortcut_equal(shortcut, - config->behavior_shortcuts[j].shortcut); if (duplicate) shortcut[0] = '\0'; } } -void bongo_cat_config_defaults(BongoCatConfig *config) { +static void compact_behavior_overrides(BongoCatSettings *config) { + size_t input_count = config->behavior_shortcut_count; + if (input_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + input_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatBehaviorShortcut entry = config->behavior_shortcuts[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + entry.shortcut[sizeof(entry.shortcut) - 1] = '\0'; + entry.label[sizeof(entry.label) - 1] = '\0'; + if (!bongo_cat_utf8_valid(entry.id) || + !bongo_cat_utf8_valid(entry.shortcut) || + !bongo_cat_utf8_valid(entry.label)) continue; + if (entry.shortcut[0]) entry.shortcut_disabled = false; + if (!entry.id[0] || (!entry.shortcut[0] && !entry.label[0] && !entry.shortcut_disabled)) continue; + BongoCatBehaviorShortcut canonical = {0}; + canonical.shortcut_disabled = entry.shortcut_disabled; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + snprintf(canonical.shortcut, sizeof(canonical.shortcut), "%s", + entry.shortcut); + snprintf(canonical.label, sizeof(canonical.label), "%s", entry.label); + size_t existing = output_count; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->behavior_shortcuts[j].id, entry.id)) { + existing = j; + break; + } + if (existing < output_count) { + if (canonical.shortcut[0] || canonical.shortcut_disabled) { + config->behavior_shortcuts[existing].shortcut_disabled = canonical.shortcut_disabled; + memset(config->behavior_shortcuts[existing].shortcut, 0, + sizeof(config->behavior_shortcuts[existing].shortcut)); + snprintf( + config->behavior_shortcuts[existing].shortcut, + sizeof(config->behavior_shortcuts[existing].shortcut), + "%s", canonical.shortcut); + } + if (canonical.label[0]) { + memset(config->behavior_shortcuts[existing].label, 0, + sizeof(config->behavior_shortcuts[existing].label)); + snprintf( + config->behavior_shortcuts[existing].label, + sizeof(config->behavior_shortcuts[existing].label), + "%s", canonical.label); + } + continue; + } + config->behavior_shortcuts[output_count++] = canonical; + } + memset(&config->behavior_shortcuts[output_count], 0, + (BONGO_CAT_BEHAVIOR_BINDING_CAP - output_count) * + sizeof(config->behavior_shortcuts[0])); + config->behavior_shortcut_count = output_count; +} + +static void compact_model_overrides(BongoCatSettings *config) { + size_t input_count = config->model_label_count; + if (input_count > BONGO_CAT_MODEL_CAP) input_count = BONGO_CAT_MODEL_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatModelLabel entry = config->model_labels[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + entry.label[sizeof(entry.label) - 1] = '\0'; + if (!entry.id[0] || !entry.label[0] || + !bongo_cat_utf8_valid(entry.id) || + !bongo_cat_utf8_valid(entry.label)) continue; + BongoCatModelLabel canonical = {0}; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + snprintf(canonical.label, sizeof(canonical.label), "%s", entry.label); + size_t existing = output_count; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->model_labels[j].id, entry.id)) { + existing = j; + break; + } + if (existing < output_count) { + memset(config->model_labels[existing].label, 0, + sizeof(config->model_labels[existing].label)); + snprintf(config->model_labels[existing].label, + sizeof(config->model_labels[existing].label), "%s", + canonical.label); + continue; + } + config->model_labels[output_count++] = canonical; + } + memset(&config->model_labels[output_count], 0, + (BONGO_CAT_MODEL_CAP - output_count) * + sizeof(config->model_labels[0])); + config->model_label_count = output_count; +} + +static void compact_removed_models(BongoCatSettings *config) { + size_t input_count = config->removed_model_count; + if (input_count > BONGO_CAT_MODEL_CAP) input_count = BONGO_CAT_MODEL_CAP; + size_t output_count = 0; + for (size_t i = 0; i < input_count; ++i) { + BongoCatRemovedModel entry = config->removed_models[i]; + entry.id[sizeof(entry.id) - 1] = '\0'; + if (!entry.id[0] || !bongo_cat_utf8_valid(entry.id)) continue; + bool duplicate = false; + for (size_t j = 0; j < output_count; ++j) + if (!strcmp(config->removed_models[j].id, entry.id)) { + duplicate = true; + break; + } + if (!duplicate) { + BongoCatRemovedModel canonical = {0}; + snprintf(canonical.id, sizeof(canonical.id), "%s", entry.id); + config->removed_models[output_count++] = canonical; + } + } + memset(&config->removed_models[output_count], 0, + (BONGO_CAT_MODEL_CAP - output_count) * + sizeof(config->removed_models[0])); + config->removed_model_count = output_count; +} + +void bongo_cat_settings_defaults(BongoCatSettings *config) { if (!config) return; memset(config, 0, sizeof(*config)); - config->model.auto_release_seconds = - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS; config->model.mouse_centered = true; config->model.max_fps = BONGO_CAT_DEFAULT_MAX_FPS; - config->window.visible = true; config->window.always_on_top = true; config->window.keep_in_screen = false; - config->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; - config->window.opacity_percent = - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; - config->window.width = 612; - config->window.height = 354; + config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; + config->window.corner_radius_percent = BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT; + config->window.hide_fade_seconds = BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS; + config->window.random_expression_interval_seconds = + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; config->app.tray_visible = true; config->app.theme = BONGO_CAT_THEME_AUTO; config->app.language = BONGO_CAT_LANG_EN_US; - config->current_mode = BONGO_CAT_MODE_GAMEPAD; - memcpy(config->current_model, "standard", sizeof("standard")); + memcpy(config->extensions_json, "{}", sizeof("{}")); } -void bongo_cat_config_validate(BongoCatConfig *config) { +void bongo_cat_settings_validate(BongoCatSettings *config) { if (!config) return; - config->model.auto_release_seconds = clampf(config->model.auto_release_seconds, 0.05f, 30.0f); - if (config->model.max_fps < 1) config->model.max_fps = 1; - if (config->model.max_fps > 240) config->model.max_fps = 240; - config->window.scale_percent = clampf(config->window.scale_percent, 10.0f, 500.0f); - config->window.opacity_percent = clampf(config->window.opacity_percent, 10.0f, 100.0f); - config->window.hide_delay_seconds = clampf(config->window.hide_delay_seconds, 0.0f, 60.0f); - if (config->window.width < 64) config->window.width = 64; - if (config->window.height < 64) config->window.height = 64; - if (config->window.width > 8192) config->window.width = 8192; - if (config->window.height > 8192) config->window.height = 8192; - if (config->app.theme > BONGO_CAT_THEME_DARK) config->app.theme = BONGO_CAT_THEME_AUTO; + config->model.max_fps = config->model.max_fps > 0 && + config->model.max_fps <= 30 ? 30 : BONGO_CAT_DEFAULT_MAX_FPS; + config->window.hide_delay_seconds = clampf_or( + config->window.hide_delay_seconds, 0.0f, 60.0f, 0.0f); + config->window.hide_fade_seconds = clampf_or( + config->window.hide_fade_seconds, 0.0f, + BONGO_CAT_MAX_HIDE_FADE_SECONDS, BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS); + config->window.corner_radius_percent = clampf_or( + config->window.corner_radius_percent, 0.0f, 50.0f, + BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT); + config->window.random_expression_interval_seconds = clampf_or( + config->window.random_expression_interval_seconds, 1.0f, 3600.0f, + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + if ((unsigned)config->window.obs_background_color >= + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT) + config->window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_GREEN; + if ((unsigned)config->app.theme > BONGO_CAT_THEME_DARK) + config->app.theme = BONGO_CAT_THEME_AUTO; if ((unsigned)config->app.language >= BONGO_CAT_LANG_COUNT) config->app.language = BONGO_CAT_LANG_EN_US; - if (config->current_mode > BONGO_CAT_MODE_GAMEPAD) config->current_mode = BONGO_CAT_MODE_STANDARD; - config->current_model[sizeof(config->current_model) - 1] = '\0'; - if (config->behavior_shortcut_count > BONGO_CAT_BEHAVIOR_CAP) - config->behavior_shortcut_count = BONGO_CAT_BEHAVIOR_CAP; - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { - config->behavior_shortcuts[i].id[BONGO_CAT_PATH_CAP - 1] = '\0'; - config->behavior_shortcuts[i].shortcut[BONGO_CAT_SHORTCUT_CAP - 1] = '\0'; - config->behavior_shortcuts[i].label[BONGO_CAT_ID_CAP - 1] = '\0'; - char normalized[BONGO_CAT_ID_CAP] = {0}; - if (bongo_cat_utf8_normalize_legacy( - config->behavior_shortcuts[i].label, normalized, - sizeof(normalized))) - memcpy(config->behavior_shortcuts[i].label, normalized, - sizeof(normalized)); - else config->behavior_shortcuts[i].label[0] = '\0'; - } + compact_behavior_overrides(config); + compact_model_overrides(config); + compact_removed_models(config); validate_shortcuts(config); -} - -const char *bongo_cat_theme_name(BongoCatTheme value) { - const char *names[] = {"auto", "light", "dark"}; - return value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; -} - -const char *bongo_cat_language_name(BongoCatLanguage value) { - const char *names[] = {"en-US", "zh-CN", "zh-TW", "fr-FR", "de-DE", - "ja-JP", "ko-KR", "pt-BR", "ru-RU", "es-ES"}; - return (unsigned)value < BONGO_CAT_LANG_COUNT ? names[value] : names[0]; -} - -const char *bongo_cat_mode_name(BongoCatModelMode value) { - const char *names[] = {"standard", "keyboard", "gamepad"}; - return value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; + compact_behavior_overrides(config); } diff --git a/src/core/config_fields.c b/src/core/config_fields.c new file mode 100644 index 00000000..54e80d32 --- /dev/null +++ b/src/core/config_fields.c @@ -0,0 +1,156 @@ +#include "config_internal.h" + +#include +#include +#include +#include + +bool bongo_cat_config_type_error(const char *description, + BongoCatError *error, const char *field, const char *expected) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "%s field '%s' must be %s", description, field, expected); + return false; +} + +bool bongo_cat_config_read_value(const char *description, yyjson_val *object, + const char *key, yyjson_val **target, BongoCatError *error) { + *target = NULL; + yyjson_obj_iter iterator = yyjson_obj_iter_with(object); + yyjson_val *name; + while ((name = yyjson_obj_iter_next(&iterator))) { + if (!yyjson_equals_str(name, key)) continue; + if (*target) return bongo_cat_config_type_error( + description, error, key, "present only once"); + *target = yyjson_obj_iter_get_val(name); + } + return true; +} + +bool bongo_cat_config_read_object(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, parent, key, &value, error)) return false; + *target = NULL; + if (!value) return true; + if (!yyjson_is_obj(value)) return bongo_cat_config_type_error( + description, error, key, "an object"); + *target = value; + return true; +} + +bool bongo_cat_config_read_array(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, parent, key, &value, error)) return false; + *target = NULL; + if (!value) return true; + if (!yyjson_is_arr(value)) return bongo_cat_config_type_error( + description, error, key, "an array"); + *target = value; + return true; +} + +bool bongo_cat_config_read_bool(const char *description, + yyjson_val *object, const char *key, bool *target, BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_bool(value)) return bongo_cat_config_type_error( + description, error, key, "a boolean"); + *target = yyjson_get_bool(value); + return true; +} + +static bool integer_value(yyjson_val *value, int *target) { + if (yyjson_is_sint(value)) { + int64_t number = yyjson_get_sint(value); + if (number >= INT_MIN && number <= INT_MAX) { + *target = (int)number; + return true; + } + } else if (yyjson_is_uint(value)) { + uint64_t number = yyjson_get_uint(value); + if (number <= INT_MAX) { + *target = (int)number; + return true; + } + } + return false; +} + +bool bongo_cat_config_read_int(const char *description, + yyjson_val *object, const char *key, int *target, bool required, + BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value && !required) return true; + if (!value || !integer_value(value, target)) + return bongo_cat_config_type_error(description, error, key, + "an integer in the supported range"); + return true; +} + +bool bongo_cat_config_read_float(const char *description, + yyjson_val *object, const char *key, float *target, BongoCatError *error) { + yyjson_val *value; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_num(value)) return bongo_cat_config_type_error( + description, error, key, "a number"); + double number = yyjson_get_num(value); + if (!isfinite(number) || number < -FLT_MAX || number > FLT_MAX) + return bongo_cat_config_type_error(description, error, key, + "a finite number in the supported range"); + *target = (float)number; + return true; +} + +bool bongo_cat_config_read_string(const char *description, + yyjson_val *object, const char *key, const char **target, size_t *length, + BongoCatError *error) { + yyjson_val *value; + *target = NULL; + *length = 0; + if (!bongo_cat_config_read_value( + description, object, key, &value, error)) return false; + if (!value) return true; + if (!yyjson_is_str(value)) return bongo_cat_config_type_error( + description, error, key, "a string"); + const char *text = yyjson_get_str(value); + size_t text_length = yyjson_get_len(value); + if (strlen(text) != text_length) + return bongo_cat_config_type_error(description, error, key, + "a string without embedded nulls"); + *target = text; + *length = text_length; + return true; +} + +bool bongo_cat_config_copy_text(const char *description, char *target, + size_t capacity, const char *value, size_t length, const char *field, + BongoCatError *error) { + if (!value) return true; + if (length >= capacity) return bongo_cat_config_type_error( + description, error, field, "a shorter string"); + memset(target, 0, capacity); + memcpy(target, value, length); + return true; +} + +bool bongo_cat_config_read_text(const char *description, + yyjson_val *object, const char *key, char *target, size_t capacity, + BongoCatError *error) { + const char *value; + size_t length; + return bongo_cat_config_read_string(description, object, key, &value, + &length, error) && + bongo_cat_config_copy_text(description, target, capacity, value, + length, key, error); +} diff --git a/src/core/config_file.c b/src/core/config_file.c index 26dfa5f1..3b9019aa 100644 --- a/src/core/config_file.c +++ b/src/core/config_file.c @@ -1,5 +1,6 @@ #include "config_internal.h" #include "bongo_cat/file.h" +#include "bongo_cat/path.h" #include #include @@ -13,32 +14,87 @@ #include #endif -yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, BongoCatError *error) { +static yyjson_val *unique_member(yyjson_val *object, const char *name, + bool *duplicate) { + yyjson_val *found = NULL; + yyjson_obj_iter iterator = yyjson_obj_iter_with(object); + yyjson_val *key; + while ((key = yyjson_obj_iter_next(&iterator))) { + if (!yyjson_equals_str(key, name)) continue; + if (found) { + *duplicate = true; + return NULL; + } + found = yyjson_obj_iter_get_val(key); + } + return found; +} + +BongoCatResult bongo_cat_config_read_document(const char *path, + const char *format, int schema, yyjson_doc **document, + BongoCatError *error) { + if (!path || !format || !document) return BONGO_CAT_ERROR_ARGUMENT; + *document = NULL; + uint64_t file_size = 0; + if (!bongo_cat_path_file_size(path, &file_size)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot inspect configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + if (!file_size || file_size > BONGO_CAT_CONFIG_FILE_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Configuration file size must be between 1 and %u bytes", + (unsigned)BONGO_CAT_CONFIG_FILE_CAP); + return BONGO_CAT_ERROR_FORMAT; + } yyjson_read_err json_error = {0}; FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, &json_error) : NULL; - if (file) fclose(file); - if (!document) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Invalid configuration JSON: %s", - json_error.msg ? json_error.msg : "cannot open file"); - return NULL; + if (!file) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot open configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + yyjson_doc *parsed = yyjson_read_fp(file, 0, NULL, &json_error); + if (fclose(file) != 0) { + yyjson_doc_free(parsed); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot finish reading configuration file: %s", path); + return BONGO_CAT_ERROR_IO; + } + if (!parsed) { + BongoCatResult result = json_error.code == + YYJSON_READ_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + json_error.code == YYJSON_READ_ERROR_FILE_OPEN || + json_error.code == YYJSON_READ_ERROR_FILE_READ ? + BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_FORMAT; + bongo_cat_error_set(error, result, + result == BONGO_CAT_ERROR_FORMAT ? + "Invalid configuration JSON: %s" : + "Cannot read configuration file: %s", + json_error.msg ? json_error.msg : path); + return result; } - yyjson_val *root = yyjson_doc_get_root(document); - yyjson_val *format_value = yyjson_obj_get(root, "format"); - yyjson_val *version = yyjson_obj_get(root, "version"); + yyjson_val *root = yyjson_doc_get_root(parsed); + bool duplicate = false; + yyjson_val *format_value = yyjson_is_obj(root) ? + unique_member(root, "format", &duplicate) : NULL; + yyjson_val *version = yyjson_is_obj(root) ? + unique_member(root, "schemaVersion", &duplicate) : NULL; + bool schema_matches = yyjson_is_sint(version) ? + yyjson_get_sint(version) == schema : yyjson_is_uint(version) && + yyjson_get_uint(version) == (uint64_t)schema; if (!yyjson_is_obj(root) || !yyjson_is_str(format_value) || + yyjson_get_len(format_value) != strlen(format) || strcmp(yyjson_get_str(format_value), format) != 0 || - !yyjson_is_int(version) || - yyjson_get_sint(version) != BONGO_CAT_CONFIG_VERSION) { - yyjson_doc_free(document); + !schema_matches || duplicate) { + yyjson_doc_free(parsed); bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Unsupported configuration format; expected %s version %d", - format, BONGO_CAT_CONFIG_VERSION); - return NULL; + "Unsupported configuration format; expected %s schema %d", + format, schema); + return BONGO_CAT_ERROR_FORMAT; } - return document; + *document = parsed; + return BONGO_CAT_OK; } static bool sync_file(const char *path) { @@ -81,17 +137,28 @@ BongoCatResult bongo_cat_config_write_document(const char *path, yyjson_mut_doc *document, const char *description, BongoCatError *error) { char temporary[BONGO_CAT_PATH_CAP + 8]; int length = snprintf(temporary, sizeof(temporary), "%s.tmp", path); - if (length < 0 || (size_t)length >= sizeof(temporary)) + if (length < 0 || (size_t)length >= sizeof(temporary)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Configuration path is too long"); return BONGO_CAT_ERROR_ARGUMENT; + } yyjson_write_err json_error = {0}; FILE *file = bongo_cat_file_open(temporary, "wb"); bool written = file && yyjson_mut_write_fp(file, document, YYJSON_WRITE_PRETTY, NULL, &json_error); if (file && fclose(file) != 0) written = false; if (!written) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write %s: %s", + bongo_cat_file_remove(temporary); + BongoCatResult result = json_error.code == + YYJSON_WRITE_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + json_error.code == YYJSON_WRITE_ERROR_INVALID_PARAMETER || + json_error.code == YYJSON_WRITE_ERROR_INVALID_VALUE_TYPE || + json_error.code == YYJSON_WRITE_ERROR_NAN_OR_INF || + json_error.code == YYJSON_WRITE_ERROR_INVALID_STRING ? + BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; + bongo_cat_error_set(error, result, "Cannot write %s: %s", description, json_error.msg ? json_error.msg : "cannot open file"); - return BONGO_CAT_ERROR_IO; + return result; } if (!sync_file(temporary)) { bongo_cat_file_remove(temporary); diff --git a/src/core/config_internal.h b/src/core/config_internal.h index 3412cd4a..1f4f9c4b 100644 --- a/src/core/config_internal.h +++ b/src/core/config_internal.h @@ -4,11 +4,40 @@ #include "bongo_cat/config.h" #include -#define BONGO_CAT_CONFIG_VERSION 2 +#define BONGO_CAT_SETTINGS_SCHEMA 1 +#define BONGO_CAT_SESSION_SCHEMA 1 +#define BONGO_CAT_CONFIG_FILE_CAP (1024u * 1024u) -yyjson_doc *bongo_cat_config_read_document(const char *path, - const char *format, BongoCatError *error); +BongoCatResult bongo_cat_config_read_document(const char *path, + const char *format, int schema, yyjson_doc **document, + BongoCatError *error); BongoCatResult bongo_cat_config_write_document(const char *path, yyjson_mut_doc *document, const char *description, BongoCatError *error); +bool bongo_cat_config_type_error(const char *description, + BongoCatError *error, const char *field, const char *expected); +bool bongo_cat_config_read_value(const char *description, yyjson_val *object, + const char *key, yyjson_val **target, BongoCatError *error); +bool bongo_cat_config_read_object(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error); +bool bongo_cat_config_read_array(const char *description, + yyjson_val *parent, const char *key, yyjson_val **target, + BongoCatError *error); +bool bongo_cat_config_read_bool(const char *description, + yyjson_val *object, const char *key, bool *target, BongoCatError *error); +bool bongo_cat_config_read_int(const char *description, + yyjson_val *object, const char *key, int *target, bool required, + BongoCatError *error); +bool bongo_cat_config_read_float(const char *description, + yyjson_val *object, const char *key, float *target, BongoCatError *error); +bool bongo_cat_config_read_string(const char *description, + yyjson_val *object, const char *key, const char **target, size_t *length, + BongoCatError *error); +bool bongo_cat_config_copy_text(const char *description, char *target, + size_t capacity, const char *value, size_t length, const char *field, + BongoCatError *error); +bool bongo_cat_config_read_text(const char *description, + yyjson_val *object, const char *key, char *target, size_t capacity, + BongoCatError *error); #endif diff --git a/src/core/config_io.c b/src/core/config_io.c index 0d8243f0..1d6f9bd1 100644 --- a/src/core/config_io.c +++ b/src/core/config_io.c @@ -1,177 +1,278 @@ #include "config_internal.h" #include "bongo_cat/path.h" -#include +#include #include -#define PREFERENCES_FORMAT "bongo-cat/preferences" +#define SETTINGS_FORMAT "bongocat/settings" +#define type_error(error, field, expected) bongo_cat_config_type_error( \ + "Settings", error, field, expected) +#define read_value(object, key, target, error) bongo_cat_config_read_value( \ + "Settings", object, key, target, error) +#define read_object(object, key, target, error) bongo_cat_config_read_object( \ + "Settings", object, key, target, error) +#define read_array(object, key, target, error) bongo_cat_config_read_array( \ + "Settings", object, key, target, error) +#define read_bool(object, key, target, error) bongo_cat_config_read_bool( \ + "Settings", object, key, target, error) +#define read_int(object, key, target, error) bongo_cat_config_read_int( \ + "Settings", object, key, target, false, error) +#define read_float(object, key, target, error) bongo_cat_config_read_float( \ + "Settings", object, key, target, error) +#define read_string(object, key, target, length, error) \ + bongo_cat_config_read_string( \ + "Settings", object, key, target, length, error) +#define read_text(object, key, target, capacity, error) \ + bongo_cat_config_read_text( \ + "Settings", object, key, target, capacity, error) +#define copy_text(target, capacity, value, length, field, error) \ + bongo_cat_config_copy_text( \ + "Settings", target, capacity, value, length, field, error) -static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_bool(value) ? yyjson_get_bool(value) : fallback; +static bool parse_theme(const char *value, BongoCatTheme *target) { + if (!strcmp(value, "auto")) *target = BONGO_CAT_THEME_AUTO; + else if (!strcmp(value, "light")) *target = BONGO_CAT_THEME_LIGHT; + else if (!strcmp(value, "dark")) *target = BONGO_CAT_THEME_DARK; + else return false; + return true; } -static int get_int(yyjson_val *obj, const char *key, int fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (int)yyjson_get_sint(value) : fallback; -} -static float get_float(yyjson_val *obj, const char *key, float fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (float)yyjson_get_num(value) : fallback; -} -static const char *get_string(yyjson_val *obj, const char *key) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_str(value) ? yyjson_get_str(value) : NULL; -} -static void copy_string(char *target, size_t capacity, const char *value) { - if (value) snprintf(target, capacity, "%s", value); -} -static BongoCatTheme parse_theme(const char *value) { - if (value && strcmp(value, "light") == 0) return BONGO_CAT_THEME_LIGHT; - if (value && strcmp(value, "dark") == 0) return BONGO_CAT_THEME_DARK; - return BONGO_CAT_THEME_AUTO; -} -static BongoCatLanguage parse_language(const char *value) { - if (value) for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) - if (strcmp(value, bongo_cat_language_name((BongoCatLanguage)i)) == 0) - return (BongoCatLanguage)i; - return BONGO_CAT_LANG_EN_US; + +static bool parse_background_color(const char *value, + BongoCatObsBackgroundColor *target) { + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) + if (!strcmp(value, bongo_cat_obs_background_color_name( + (BongoCatObsBackgroundColor)i))) { + *target = (BongoCatObsBackgroundColor)i; + return true; + } + return false; } -static void read_model(yyjson_val *obj, BongoCatModelOptions *value) { - if (!yyjson_is_obj(obj)) return; - value->mirror = get_bool(obj, "mirror", value->mirror); - value->mouse_mirror = get_bool(obj, "mouseMirror", value->mouse_mirror); - value->mouse_centered = get_bool(obj, "mouseCenterTracking", value->mouse_centered); - value->ignore_mouse = get_bool(obj, "ignoreMouse", value->ignore_mouse); - value->auto_release_seconds = get_float(obj, "autoReleaseDelay", value->auto_release_seconds); - value->max_fps = get_int(obj, "maxFPS", value->max_fps); +static bool read_model(yyjson_val *object, BongoCatModelPreferences *value, + BongoCatError *error) { + return read_bool(object, "multiplePets", &value->multiple_pets, error) && + read_bool(object, "modelMirrored", &value->mirror, error) && + read_bool(object, "pointerMirrored", &value->mouse_mirror, error) && + read_bool(object, "centerPointerTracking", &value->mouse_centered, + error) && + read_bool(object, "ignorePointerInput", &value->ignore_mouse, error) && + read_int(object, "maximumFps", &value->max_fps, error); } -static void read_window(yyjson_val *obj, BongoCatWindowOptions *value) { - if (!yyjson_is_obj(obj)) return; - value->pass_through = get_bool(obj, "passThrough", value->pass_through); - value->always_on_top = get_bool(obj, "alwaysOnTop", value->always_on_top); - value->hide_on_hover = get_bool(obj, "hideOnHover", value->hide_on_hover); - value->keep_in_screen = get_bool(obj, "keepInScreen", value->keep_in_screen); - value->hide_delay_seconds = get_float(obj, "hideOnHoverDelay", value->hide_delay_seconds); + +static bool read_window(yyjson_val *object, BongoCatWindowPreferences *value, + BongoCatError *error) { + if (!read_bool(object, "clickThrough", &value->pass_through, error) || + !read_bool(object, "alwaysOnTop", &value->always_on_top, error) || + !read_bool(object, "hideOnPointerOver", &value->hide_on_hover, error) || + !read_bool(object, "keepOnScreen", &value->keep_in_screen, error) || + !read_bool(object, "captureBackground", &value->obs_background, + error) || + !read_bool(object, "randomExpression", &value->random_expression, + error) || + !read_bool(object, "roundedCorners", &value->rounded_corners, error) || + !read_float(object, "cornerRadiusPercent", &value->corner_radius_percent, + error) || + !read_float(object, "hideDelaySeconds", &value->hide_delay_seconds, + error) || + !read_float(object, "hideFadeSeconds", &value->hide_fade_seconds, + error) || + !read_float(object, "randomExpressionIntervalSeconds", + &value->random_expression_interval_seconds, + error)) return false; + const char *color; + size_t length; + if (!read_string(object, "captureBackgroundColor", &color, &length, + error)) return false; + (void)length; + if (color && !parse_background_color(color, &value->obs_background_color)) + return type_error(error, "captureBackgroundColor", + "a supported color string"); + return true; } -static void read_app(yyjson_val *obj, BongoCatAppOptions *value) { - if (!yyjson_is_obj(obj)) return; - value->autostart = get_bool(obj, "autostart", value->autostart); - value->tray_visible = get_bool(obj, "trayVisible", value->tray_visible); - value->theme = parse_theme(get_string(obj, "theme")); - value->language = parse_language(get_string(obj, "language")); + +static bool read_app(yyjson_val *object, BongoCatApplicationPreferences *value, + BongoCatError *error) { + if (!read_bool(object, "launchAtLogin", &value->autostart, error) || + !read_bool(object, "showTrayIcon", &value->tray_visible, error)) + return false; + const char *text; + size_t length; + if (!read_string(object, "theme", &text, &length, error)) return false; + (void)length; + if (text && !parse_theme(text, &value->theme)) + return type_error(error, "theme", "auto, light, or dark"); + if (!read_string(object, "language", &text, &length, error)) return false; + if (text && !bongo_cat_language_parse(text, &value->language)) + return type_error(error, "language", "a supported locale string"); + return true; } -static void read_shortcuts(yyjson_val *obj, BongoCatShortcutOptions *value) { - if (!yyjson_is_obj(obj)) return; - copy_string(value->visible_cat, sizeof(value->visible_cat), get_string(obj, "visibleCat")); - copy_string(value->visible_preferences, sizeof(value->visible_preferences), - get_string(obj, "visiblePreference")); - copy_string(value->mirror, sizeof(value->mirror), get_string(obj, "mirrorMode")); - copy_string(value->pass_through, sizeof(value->pass_through), get_string(obj, "penetrable")); - copy_string(value->always_on_top, sizeof(value->always_on_top), get_string(obj, "alwaysOnTop")); + +static bool read_shortcuts(yyjson_val *object, + BongoCatShortcutPreferences *value, BongoCatError *error) { + return read_text(object, "toggleVisibility", value->toggle_pet_visibility, + sizeof(value->toggle_pet_visibility), error) && + read_text(object, "openSettings", value->visible_preferences, + sizeof(value->visible_preferences), error) && + read_text(object, "toggleModelMirror", value->mirror, + sizeof(value->mirror), error) && + read_text(object, "toggleClickThrough", value->pass_through, + sizeof(value->pass_through), error) && + read_text(object, "toggleAlwaysOnTop", value->always_on_top, + sizeof(value->always_on_top), error); } -static void read_behaviors(yyjson_val *array, BongoCatConfig *config) { - if (!yyjson_is_arr(array)) return; - config->behavior_shortcut_count = 0; - size_t index, count; yyjson_val *item; + +static bool read_behaviors(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_BEHAVIOR_BINDING_CAP) + return type_error(error, "behaviorOverrides", "a smaller array"); + settings->behavior_shortcut_count = 0; + size_t index, count; + yyjson_val *item; yyjson_arr_foreach(array, index, count, item) { - const char *id = get_string(item, "id"); - const char *shortcut = get_string(item, "shortcut"); - const char *label = get_string(item, "label"); - if (!id || (!shortcut && !label) || - config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) - continue; - BongoCatBehaviorShortcut *entry = - &config->behavior_shortcuts[config->behavior_shortcut_count++]; - copy_string(entry->id, sizeof(entry->id), id); - copy_string(entry->shortcut, sizeof(entry->shortcut), shortcut); - copy_string(entry->label, sizeof(entry->label), label); + if (!yyjson_is_obj(item)) + return type_error(error, "behaviorOverrides[]", "an object"); + const char *id, *shortcut, *label; + size_t id_length, shortcut_length, label_length; + if (!read_string(item, "behaviorId", &id, &id_length, error) || + !read_string(item, "shortcut", &shortcut, &shortcut_length, + error) || + !read_string(item, "displayName", &label, &label_length, error)) + return false; + if (!id || !id_length) + return type_error(error, "behaviorId", "a non-empty string"); + BongoCatBehaviorShortcut *entry = &settings->behavior_shortcuts[ + settings->behavior_shortcut_count++]; + memset(entry, 0, sizeof(*entry)); + if (!read_bool(item, "shortcutDisabled", &entry->shortcut_disabled, error)) return false; + if (!copy_text(entry->id, sizeof(entry->id), id, id_length, + "behaviorId", error) || + !copy_text(entry->shortcut, sizeof(entry->shortcut), shortcut, + shortcut_length, "shortcut", error) || + !copy_text(entry->label, sizeof(entry->label), label, + label_length, "displayName", error)) return false; } + return true; } -BongoCatResult bongo_cat_preferences_load(const char *path, - BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; - if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, PREFERENCES_FORMAT, error); - if (!document) return BONGO_CAT_ERROR_FORMAT; - BongoCatConfig loaded = *config; - yyjson_val *root = yyjson_doc_get_root(document); - read_model(yyjson_obj_get(root, "model"), &loaded.model); - read_window(yyjson_obj_get(root, "window"), &loaded.window); - read_app(yyjson_obj_get(root, "app"), &loaded.app); - read_shortcuts(yyjson_obj_get(root, "shortcuts"), &loaded.shortcuts); - read_behaviors(yyjson_obj_get(root, "behaviorShortcuts"), &loaded); - yyjson_doc_free(document); - bongo_cat_config_validate(&loaded); - *config = loaded; - return BONGO_CAT_OK; +static bool read_model_labels(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_MODEL_CAP) + return type_error(error, "modelOverrides", "a smaller array"); + settings->model_label_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + if (!yyjson_is_obj(item)) + return type_error(error, "modelOverrides[]", "an object"); + const char *id, *label; + size_t id_length, label_length; + if (!read_string(item, "modelId", &id, &id_length, error) || + !read_string(item, "displayName", &label, &label_length, error)) + return false; + if (!id || !id_length || !label || !label_length) + return type_error(error, "modelOverrides[]", + "non-empty modelId and displayName strings"); + BongoCatModelLabel *entry = + &settings->model_labels[settings->model_label_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->id, sizeof(entry->id), id, id_length, + "modelId", error) || + !copy_text(entry->label, sizeof(entry->label), label, + label_length, "displayName", error)) return false; + } + return true; } -static void write_model(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatModelOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "mirror", v->mirror); - yyjson_mut_obj_add_bool(doc, obj, "mouseMirror", v->mouse_mirror); - yyjson_mut_obj_add_bool(doc, obj, "mouseCenterTracking", v->mouse_centered); - yyjson_mut_obj_add_bool(doc, obj, "ignoreMouse", v->ignore_mouse); - yyjson_mut_obj_add_real(doc, obj, "autoReleaseDelay", v->auto_release_seconds); - yyjson_mut_obj_add_int(doc, obj, "maxFPS", v->max_fps); -} -static void write_window(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatWindowOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "passThrough", v->pass_through); - yyjson_mut_obj_add_bool(doc, obj, "alwaysOnTop", v->always_on_top); - yyjson_mut_obj_add_bool(doc, obj, "hideOnHover", v->hide_on_hover); - yyjson_mut_obj_add_bool(doc, obj, "keepInScreen", v->keep_in_screen); - yyjson_mut_obj_add_real(doc, obj, "hideOnHoverDelay", v->hide_delay_seconds); -} -static void write_app(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatAppOptions *v) { - yyjson_mut_obj_add_bool(doc, obj, "autostart", v->autostart); - yyjson_mut_obj_add_bool(doc, obj, "trayVisible", v->tray_visible); - yyjson_mut_obj_add_strcpy(doc, obj, "theme", bongo_cat_theme_name(v->theme)); - yyjson_mut_obj_add_strcpy(doc, obj, "language", bongo_cat_language_name(v->language)); -} -static void write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const BongoCatShortcutOptions *v) { - yyjson_mut_obj_add_strcpy(doc, obj, "visibleCat", v->visible_cat); - yyjson_mut_obj_add_strcpy(doc, obj, "visiblePreference", v->visible_preferences); - yyjson_mut_obj_add_strcpy(doc, obj, "mirrorMode", v->mirror); - yyjson_mut_obj_add_strcpy(doc, obj, "penetrable", v->pass_through); - yyjson_mut_obj_add_strcpy(doc, obj, "alwaysOnTop", v->always_on_top); +static bool read_removed_models(yyjson_val *array, BongoCatSettings *settings, + BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_MODEL_CAP) + return type_error(error, "removedModels", "a smaller array"); + settings->removed_model_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + const char *id = yyjson_get_str(item); + size_t length = yyjson_get_len(item); + if (!yyjson_is_str(item) || !id || !length || strlen(id) != length) + return type_error(error, "removedModels[]", + "a non-empty string without embedded nulls"); + BongoCatRemovedModel *entry = + &settings->removed_models[settings->removed_model_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->id, sizeof(entry->id), id, length, + "removedModels[]", error)) return false; + } + return true; } -static void write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, - const BongoCatConfig *config) { - yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_obj_add_val(doc, root, "behaviorShortcuts", array); - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *value = &config->behavior_shortcuts[i]; - if (!value->id[0] || (!value->shortcut[0] && !value->label[0])) continue; - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "id", value->id); - if (value->shortcut[0]) - yyjson_mut_obj_add_strcpy(doc, item, "shortcut", value->shortcut); - if (value->label[0]) - yyjson_mut_obj_add_strcpy(doc, item, "label", value->label); - yyjson_mut_arr_add_val(array, item); + +static BongoCatResult read_extensions(yyjson_val *value, + BongoCatSettings *settings, BongoCatError *error) { + if (!value) return BONGO_CAT_OK; + if (!yyjson_is_obj(value)) { + type_error(error, "extensions", "an object"); + return BONGO_CAT_ERROR_FORMAT; } + size_t length = 0; + char *json = yyjson_val_write(value, YYJSON_WRITE_NOFLAG, &length); + if (!json) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot preserve settings extensions"); + return BONGO_CAT_ERROR_MEMORY; + } + if (length >= sizeof(settings->extensions_json)) { + free(json); + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions exceed the %u-byte limit", + (unsigned)(sizeof(settings->extensions_json) - 1)); + return BONGO_CAT_ERROR_FORMAT; + } + memset(settings->extensions_json, 0, sizeof(settings->extensions_json)); + memcpy(settings->extensions_json, json, length + 1); + free(json); + return BONGO_CAT_OK; } -BongoCatResult bongo_cat_preferences_save(const char *path, - const BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) return BONGO_CAT_ERROR_MEMORY; - yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_strcpy(doc, root, "format", PREFERENCES_FORMAT); - yyjson_mut_obj_add_int(doc, root, "version", BONGO_CAT_CONFIG_VERSION); - write_model(doc, yyjson_mut_obj_add_obj(doc, root, "model"), &config->model); - write_window(doc, yyjson_mut_obj_add_obj(doc, root, "window"), &config->window); - write_app(doc, yyjson_mut_obj_add_obj(doc, root, "app"), &config->app); - write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), &config->shortcuts); - write_behaviors(doc, root, config); - BongoCatResult result = bongo_cat_config_write_document(path, doc, - "preferences file", error); - yyjson_mut_doc_free(doc); - return result; +BongoCatResult bongo_cat_settings_load(const char *path, + BongoCatSettings *settings, BongoCatError *error) { + if (!path || !settings) return BONGO_CAT_ERROR_ARGUMENT; + if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; + yyjson_doc *document = NULL; + BongoCatResult result = bongo_cat_config_read_document(path, + SETTINGS_FORMAT, BONGO_CAT_SETTINGS_SCHEMA, &document, error); + if (result != BONGO_CAT_OK) return result; + BongoCatSettings loaded = *settings; + yyjson_val *root = yyjson_doc_get_root(document); + yyjson_val *model = NULL; + yyjson_val *window = NULL; + yyjson_val *application = NULL; + yyjson_val *shortcuts = NULL; + yyjson_val *behaviors = NULL; + yyjson_val *models = NULL; + yyjson_val *removed_models = NULL; + yyjson_val *extensions_value = NULL; + bool valid = read_object(root, "rendering", &model, error) && + read_object(root, "window", &window, error) && + read_object(root, "application", &application, error) && + read_object(root, "shortcuts", &shortcuts, error) && + read_array(root, "behaviorOverrides", &behaviors, error) && + read_array(root, "modelOverrides", &models, error) && + read_array(root, "removedModels", &removed_models, error) && + read_value(root, "extensions", &extensions_value, error) && + (!model || read_model(model, &loaded.model, error)) && + (!window || read_window(window, &loaded.window, error)) && + (!application || read_app(application, &loaded.app, error)) && + (!shortcuts || read_shortcuts(shortcuts, &loaded.shortcuts, error)) && + read_behaviors(behaviors, &loaded, error) && + read_model_labels(models, &loaded, error) && + read_removed_models(removed_models, &loaded, error); + if (!valid) result = BONGO_CAT_ERROR_FORMAT; + if (valid) result = read_extensions(extensions_value, &loaded, error); + yyjson_doc_free(document); + if (result != BONGO_CAT_OK) return result; + bongo_cat_settings_validate(&loaded); + *settings = loaded; + return BONGO_CAT_OK; } diff --git a/src/core/config_model_state.c b/src/core/config_model_state.c new file mode 100644 index 00000000..db92414e --- /dev/null +++ b/src/core/config_model_state.c @@ -0,0 +1,132 @@ +#include "bongo_cat/config.h" +#include "bongo_cat/utf8.h" + +#include +#include +#include + +const char *bongo_cat_settings_model_label(const BongoCatSettings *config, + const char *id) { + if (!config || !id) return NULL; + size_t count = config->model_label_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(config->model_labels[i].id, id)) + return config->model_labels[i].label; + return NULL; +} + +bool bongo_cat_settings_set_model_label(BongoCatSettings *config, + const char *id, const char *label) { + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); + size_t index = config->model_label_count; + for (size_t i = 0; i < config->model_label_count; ++i) + if (!strcmp(config->model_labels[i].id, id)) { + index = i; + break; + } + if (!label || !label[0]) { + if (index == config->model_label_count) return false; + if (index + 1 < config->model_label_count) + memmove(&config->model_labels[index], + &config->model_labels[index + 1], + (config->model_label_count - index - 1) * + sizeof(config->model_labels[0])); + config->model_label_count--; + memset(&config->model_labels[config->model_label_count], 0, + sizeof(config->model_labels[0])); + return true; + } + if (strlen(label) >= BONGO_CAT_ID_CAP || !bongo_cat_utf8_valid(label)) + return false; + if (index < config->model_label_count) { + if (!strcmp(config->model_labels[index].label, label)) return false; + memset(config->model_labels[index].label, 0, + sizeof(config->model_labels[index].label)); + snprintf(config->model_labels[index].label, + sizeof(config->model_labels[index].label), "%s", label); + return true; + } + if (config->model_label_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatModelLabel *value = + &config->model_labels[config->model_label_count++]; + memset(value, 0, sizeof(*value)); + snprintf(value->id, sizeof(value->id), "%s", id); + snprintf(value->label, sizeof(value->label), "%s", label); + return true; +} + +bool bongo_cat_settings_model_removed(const BongoCatSettings *config, + const char *id) { + if (!config || !id) return false; + size_t count = config->removed_model_count; + if (count > BONGO_CAT_MODEL_CAP) count = BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(config->removed_models[i].id, id)) return true; + return false; +} + +bool bongo_cat_settings_set_model_removed(BongoCatSettings *config, + const char *id, bool removed) { + if (!config || !id || !id[0] || strlen(id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(id)) return false; + bongo_cat_settings_validate(config); + size_t index = config->removed_model_count; + for (size_t i = 0; i < config->removed_model_count; ++i) + if (!strcmp(config->removed_models[i].id, id)) { + index = i; + break; + } + if (removed) { + if (index < config->removed_model_count) return false; + if (config->removed_model_count >= BONGO_CAT_MODEL_CAP) return false; + BongoCatRemovedModel *entry = + &config->removed_models[config->removed_model_count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", id); + return true; + } + if (index == config->removed_model_count) return false; + if (index + 1 < config->removed_model_count) + memmove(&config->removed_models[index], + &config->removed_models[index + 1], + (config->removed_model_count - index - 1) * + sizeof(config->removed_models[0])); + config->removed_model_count--; + memset(&config->removed_models[config->removed_model_count], 0, + sizeof(config->removed_models[0])); + return true; +} + +static bool package_model_id(const char *id, const char *package_id) { + size_t length = package_id ? strlen(package_id) : 0; + if (!id || !length || strncmp(id, package_id, length) != 0) return false; + const char *suffix = id + length; + if (!suffix[0]) return true; + if (*suffix++ != '~' || !*suffix) return false; + while (*suffix) + if (!isdigit((unsigned char)*suffix++)) return false; + return true; +} + +bool bongo_cat_settings_restore_model_package(BongoCatSettings *config, + const char *package_id) { + if (!config || !package_id || !package_id[0]) return false; + bongo_cat_settings_validate(config); + size_t output = 0; + bool changed = false; + for (size_t i = 0; i < config->removed_model_count; ++i) { + BongoCatRemovedModel entry = config->removed_models[i]; + if (package_model_id(entry.id, package_id)) { + changed = true; + continue; + } + config->removed_models[output++] = entry; + } + memset(&config->removed_models[output], 0, + (BONGO_CAT_MODEL_CAP - output) * sizeof(config->removed_models[0])); + config->removed_model_count = output; + return changed; +} diff --git a/src/core/config_names.c b/src/core/config_names.c new file mode 100644 index 00000000..26f56c98 --- /dev/null +++ b/src/core/config_names.c @@ -0,0 +1,117 @@ +#include "bongo_cat/config.h" + +#include + +const char *bongo_cat_theme_name(BongoCatTheme value) { + const char *names[] = {"auto", "light", "dark"}; + return (unsigned)value <= BONGO_CAT_THEME_DARK ? names[value] : names[0]; +} + +const char *bongo_cat_language_name(BongoCatLanguage value) { + const char *names[] = {"en-US", "zh-CN", "zh-Hant", "fr-FR", "de-DE", + "ja-JP", "ko-KR", "pt-BR", "ru-RU", "es-ES"}; + return (unsigned)value < BONGO_CAT_LANG_COUNT ? names[value] : names[0]; +} + +bool bongo_cat_language_parse(const char *name, BongoCatLanguage *value) { + if (!name || !value) return false; + if (!strcmp(name, "zh-TW")) { + *value = BONGO_CAT_LANG_ZH_HANT; + return true; + } + for (int i = 0; i < BONGO_CAT_LANG_COUNT; ++i) { + if (strcmp(name, bongo_cat_language_name((BongoCatLanguage)i))) + continue; + *value = (BongoCatLanguage)i; + return true; + } + return false; +} + +static bool locale_part_equal(const char *text, size_t length, + const char *expected) { + if (length != strlen(expected)) return false; + for (size_t i = 0; i < length; ++i) { + unsigned char left = (unsigned char)text[i]; + unsigned char right = (unsigned char)expected[i]; + if (left >= 'A' && left <= 'Z') left += 'a' - 'A'; + if (right >= 'A' && right <= 'Z') right += 'a' - 'A'; + if (left != right) return false; + } + return true; +} + +static bool locale_has_part(const char *text, const char *expected) { + if (!text) return false; + while (*text) { + while (*text == '-' || *text == '_') text++; + const char *end = text; + while (*end && *end != '-' && *end != '_') end++; + if (locale_part_equal(text, (size_t)(end - text), expected)) + return true; + text = end; + } + return false; +} + +static bool locale_language_is(const char *language, const char *expected) { + if (!language) return false; + const char *end = language; + while (*end && *end != '-' && *end != '_') end++; + return locale_part_equal(language, (size_t)(end - language), expected); +} + +bool bongo_cat_language_from_locale(const char *language, + const char *country, BongoCatLanguage *value) { + if (!language || !value) return false; + if (locale_language_is(language, "zh")) { + bool hant = locale_has_part(language, "Hant") || + locale_has_part(country, "Hant"); + bool hans = locale_has_part(language, "Hans") || + locale_has_part(country, "Hans"); + bool traditional_region = locale_has_part(language, "TW") || + locale_has_part(language, "HK") || + locale_has_part(language, "MO") || + locale_has_part(country, "TW") || + locale_has_part(country, "HK") || + locale_has_part(country, "MO"); + *value = hant || (!hans && traditional_region) ? + BONGO_CAT_LANG_ZH_HANT : BONGO_CAT_LANG_ZH_CN; + return true; + } + static const struct { + const char *code; + BongoCatLanguage value; + } supported[] = {{"en", BONGO_CAT_LANG_EN_US}, + {"fr", BONGO_CAT_LANG_FR_FR}, {"de", BONGO_CAT_LANG_DE_DE}, + {"ja", BONGO_CAT_LANG_JA_JP}, {"ko", BONGO_CAT_LANG_KO_KR}, + {"pt", BONGO_CAT_LANG_PT_BR}, {"ru", BONGO_CAT_LANG_RU_RU}, + {"es", BONGO_CAT_LANG_ES_ES}}; + for (size_t i = 0; i < sizeof(supported) / sizeof(supported[0]); ++i) + if (locale_language_is(language, supported[i].code)) { + *value = supported[i].value; + return true; + } + return false; +} + +const char *bongo_cat_mode_name(BongoCatModelMode value) { + const char *names[] = {"standard", "keyboard", "gamepad"}; + return (unsigned)value <= BONGO_CAT_MODE_GAMEPAD ? names[value] : names[0]; +} + +const char *bongo_cat_obs_background_color_name( + BongoCatObsBackgroundColor value) { + static const char *names[] = { + "#00ff00", "#0000ff", "#ff0000", "#ff00ff"}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + names[value] : names[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} + +uint32_t bongo_cat_obs_background_color_rgb( + BongoCatObsBackgroundColor value) { + static const uint32_t colors[] = { + 0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + return (unsigned)value < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT ? + colors[value] : colors[BONGO_CAT_OBS_BACKGROUND_GREEN]; +} diff --git a/src/core/config_session_defaults.c b/src/core/config_session_defaults.c new file mode 100644 index 00000000..42cac653 --- /dev/null +++ b/src/core/config_session_defaults.c @@ -0,0 +1,178 @@ +#include "bongo_cat/config.h" +#include "bongo_cat/utf8.h" + +#include +#include +#include + +static float clampf_or(float value, float low, float high, float fallback) { + if (!isfinite(value)) return fallback; + return value < low ? low : value > high ? high : value; +} + +static bool normalize_text(char *text, size_t capacity) { + text[capacity - 1] = '\0'; + if (!bongo_cat_utf8_valid(text)) { + memset(text, 0, capacity); + return false; + } + size_t length = strlen(text); + memset(text + length + 1, 0, capacity - length - 1); + return true; +} + +void bongo_cat_session_defaults(BongoCatSessionState *session) { + if (!session) return; + memset(session, 0, sizeof(*session)); + session->window.visible = true; + session->window.scale_percent = BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT; + session->window.opacity_percent = + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT; + session->window.width = 612; + session->window.height = 354; + session->window.content_width = 612; + session->window.content_height = 354; + memcpy(session->active_model_id, "standard", sizeof("standard")); +} + +static void compact_active_behaviors(BongoCatSessionState *session) { + size_t count = session->active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + BongoCatActiveBehavior entry = session->active_behaviors[i]; + if (!normalize_text(entry.model_id, sizeof(entry.model_id)) || + !normalize_text(entry.behavior_id, sizeof(entry.behavior_id)) || + !entry.model_id[0] || !entry.behavior_id[0]) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (!strcmp(session->active_behaviors[j].model_id, + entry.model_id) && + !strcmp(session->active_behaviors[j].behavior_id, + entry.behavior_id)) { + duplicate = true; + break; + } + if (!duplicate) session->active_behaviors[output++] = entry; + } + if (output < BONGO_CAT_BEHAVIOR_BINDING_CAP) + memset(&session->active_behaviors[output], 0, + (BONGO_CAT_BEHAVIOR_BINDING_CAP - output) * + sizeof(session->active_behaviors[0])); + session->active_behavior_count = output; +} + +static void compact_additional_models(BongoCatSessionState *session) { + size_t count = session->additional_model_count; + if (count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + char model_id[BONGO_CAT_ID_CAP]; + memcpy(model_id, session->additional_model_ids[i], sizeof(model_id)); + if (!normalize_text(model_id, sizeof(model_id)) || !model_id[0] || + !strcmp(model_id, session->active_model_id)) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (!strcmp(session->additional_model_ids[j], model_id)) { + duplicate = true; + break; + } + if (!duplicate) { + memset(session->additional_model_ids[output], 0, + sizeof(session->additional_model_ids[0])); + snprintf(session->additional_model_ids[output++], + sizeof(session->additional_model_ids[0]), "%s", model_id); + } + } + memset(&session->additional_model_ids[output], 0, + (BONGO_CAT_ADDITIONAL_MODEL_CAP - output) * + sizeof(session->additional_model_ids[0])); + session->additional_model_count = output; +} + +void bongo_cat_session_validate(BongoCatSessionState *session) { + if (!session) return; + session->window.scale_percent = clampf_or(session->window.scale_percent, + 10.0f, 500.0f, BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); + session->window.opacity_percent = clampf_or( + session->window.opacity_percent, 10.0f, 100.0f, + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + if (session->window.width < 64) session->window.width = 64; + if (session->window.height < 64) session->window.height = 64; + if (session->window.width > 8192) session->window.width = 8192; + if (session->window.height > 8192) session->window.height = 8192; + if (session->window.content_width < 64) + session->window.content_width = session->window.width; + if (session->window.content_height < 64) + session->window.content_height = session->window.height; + if (session->window.content_width > 8192) + session->window.content_width = 8192; + if (session->window.content_height > 8192) + session->window.content_height = 8192; + if (!normalize_text(session->active_model_id, + sizeof(session->active_model_id)) || + !session->active_model_id[0]) + memcpy(session->active_model_id, "standard", sizeof("standard")); + if (session->last_update_check_day < 0 || + session->last_update_check_day > 99991231) + session->last_update_check_day = 0; + normalize_text(session->last_update_check_version, + sizeof(session->last_update_check_version)); + normalize_text(session->available_update_version, + sizeof(session->available_update_version)); + compact_additional_models(session); + compact_active_behaviors(session); +} + +bool bongo_cat_session_model_active(const BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id || !model_id[0]) return false; + if (!strcmp(session->active_model_id, model_id)) return true; + size_t count = session->additional_model_count; + if (count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + for (size_t i = 0; i < count; ++i) + if (!strcmp(session->additional_model_ids[i], model_id)) return true; + return false; +} + +bool bongo_cat_session_add_model(BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id || !model_id[0] || + strlen(model_id) >= BONGO_CAT_ID_CAP || + !bongo_cat_utf8_valid(model_id)) return false; + bongo_cat_session_validate(session); + if (bongo_cat_session_model_active(session, model_id)) return true; + if (session->additional_model_count >= BONGO_CAT_ADDITIONAL_MODEL_CAP) + return false; + snprintf(session->additional_model_ids[session->additional_model_count++], + sizeof(session->additional_model_ids[0]), "%s", model_id); + return true; +} + +bool bongo_cat_session_remove_model(BongoCatSessionState *session, + const char *model_id) { + if (!session || !model_id) return false; + for (size_t i = 0; i < session->additional_model_count; ++i) { + if (strcmp(session->additional_model_ids[i], model_id)) continue; + if (i + 1 < session->additional_model_count) + memmove(&session->additional_model_ids[i], + &session->additional_model_ids[i + 1], + (session->additional_model_count - i - 1) * + sizeof(session->additional_model_ids[0])); + memset(&session->additional_model_ids[ + --session->additional_model_count], 0, + sizeof(session->additional_model_ids[0])); + return true; + } + return false; +} + +void bongo_cat_session_clear_additional_models(BongoCatSessionState *session) { + if (!session) return; + memset(session->additional_model_ids, 0, + sizeof(session->additional_model_ids)); + session->additional_model_count = 0; +} diff --git a/src/core/config_session_io.c b/src/core/config_session_io.c index da5b943f..fcbd3e9c 100644 --- a/src/core/config_session_io.c +++ b/src/core/config_session_io.c @@ -2,66 +2,258 @@ #include "bongo_cat/path.h" #include +#include -#define SESSION_FORMAT "bongo-cat/session" +#define SESSION_FORMAT "bongocat/session" +#define type_error(error, field, expected) bongo_cat_config_type_error( \ + "Session", error, field, expected) +#define read_value(object, key, target, error) bongo_cat_config_read_value( \ + "Session", object, key, target, error) +#define read_object(object, key, target, error) bongo_cat_config_read_object( \ + "Session", object, key, target, error) +#define read_array(object, key, target, error) bongo_cat_config_read_array( \ + "Session", object, key, target, error) +#define read_bool(object, key, target, error) bongo_cat_config_read_bool( \ + "Session", object, key, target, error) +#define read_int(object, key, target, required, error) \ + bongo_cat_config_read_int( \ + "Session", object, key, target, required, error) +#define read_float(object, key, target, error) bongo_cat_config_read_float( \ + "Session", object, key, target, error) +#define read_string(object, key, target, length, error) \ + bongo_cat_config_read_string( \ + "Session", object, key, target, length, error) +#define copy_text(target, capacity, value, length, field, error) \ + bongo_cat_config_copy_text( \ + "Session", target, capacity, value, length, field, error) -static bool get_bool(yyjson_val *obj, const char *key, bool fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_bool(value) ? yyjson_get_bool(value) : fallback; +static bool read_active_model(yyjson_val *root, + BongoCatSessionState *session, BongoCatError *error) { + yyjson_val *value; + if (!read_value(root, "activeModelId", &value, error)) return false; + if (!value) return true; + if (!yyjson_is_str(value)) + return type_error(error, "activeModelId", "a string"); + const char *text = yyjson_get_str(value); + size_t length = yyjson_get_len(value); + if (!length || length >= sizeof(session->active_model_id) || + strlen(text) != length) + return type_error(error, "activeModelId", + "a non-empty string within the supported length"); + memset(session->active_model_id, 0, sizeof(session->active_model_id)); + memcpy(session->active_model_id, text, length); + return true; } -static int get_int(yyjson_val *obj, const char *key, int fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (int)yyjson_get_sint(value) : fallback; + +static bool read_active_behaviors(yyjson_val *array, + BongoCatSessionState *session, BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_BEHAVIOR_BINDING_CAP) + return type_error(error, "activeBehaviors", "a smaller array"); + session->active_behavior_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + if (!yyjson_is_obj(item)) + return type_error(error, "activeBehaviors[]", "an object"); + const char *model_id, *behavior_id; + size_t model_length, behavior_length; + if (!read_string(item, "modelId", &model_id, &model_length, + error) || + !read_string(item, "behaviorId", &behavior_id, + &behavior_length, error)) return false; + if (!model_id || !model_length || !behavior_id || !behavior_length) + return type_error(error, "activeBehaviors[]", + "non-empty modelId and behaviorId strings"); + BongoCatActiveBehavior *entry = &session->active_behaviors[ + session->active_behavior_count++]; + memset(entry, 0, sizeof(*entry)); + if (!copy_text(entry->model_id, sizeof(entry->model_id), model_id, + model_length, "modelId", error) || + !copy_text(entry->behavior_id, sizeof(entry->behavior_id), + behavior_id, behavior_length, "behaviorId", error)) + return false; + } + return true; } -static float get_float(yyjson_val *obj, const char *key, float fallback) { - yyjson_val *value = yyjson_obj_get(obj, key); - return yyjson_is_num(value) ? (float)yyjson_get_num(value) : fallback; + +static bool read_additional_models(yyjson_val *array, + BongoCatSessionState *session, BongoCatError *error) { + if (!array) return true; + if (yyjson_arr_size(array) > BONGO_CAT_ADDITIONAL_MODEL_CAP) + return type_error(error, "additionalModelIds", "a smaller array"); + session->additional_model_count = 0; + size_t index, count; + yyjson_val *item; + yyjson_arr_foreach(array, index, count, item) { + if (!yyjson_is_str(item) || !yyjson_get_len(item) || + yyjson_get_len(item) >= BONGO_CAT_ID_CAP || + strlen(yyjson_get_str(item)) != yyjson_get_len(item)) + return type_error(error, "additionalModelIds[]", + "a non-empty model id within the supported length"); + snprintf(session->additional_model_ids[ + session->additional_model_count++], BONGO_CAT_ID_CAP, "%s", + yyjson_get_str(item)); + } + return true; } BongoCatResult bongo_cat_session_load(const char *path, - BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSessionState *session, BongoCatError *error) { + if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; if (!bongo_cat_path_is_file(path)) return BONGO_CAT_OK; - yyjson_doc *document = bongo_cat_config_read_document(path, SESSION_FORMAT, error); - if (!document) return BONGO_CAT_ERROR_FORMAT; - BongoCatConfig loaded = *config; + yyjson_doc *document = NULL; + BongoCatResult result = bongo_cat_config_read_document(path, + SESSION_FORMAT, BONGO_CAT_SESSION_SCHEMA, &document, error); + if (result != BONGO_CAT_OK) return result; + BongoCatSessionState loaded = *session; yyjson_val *root = yyjson_doc_get_root(document); - yyjson_val *window = yyjson_obj_get(root, "window"); - if (yyjson_is_obj(window)) { - loaded.window.visible = get_bool(window, "visible", loaded.window.visible); - loaded.window.scale_percent = get_float(window, "scale", loaded.window.scale_percent); - loaded.window.opacity_percent = get_float(window, "opacity", loaded.window.opacity_percent); - loaded.window.x = get_int(window, "x", loaded.window.x); - loaded.window.y = get_int(window, "y", loaded.window.y); - loaded.window.width = get_int(window, "width", loaded.window.width); - loaded.window.height = get_int(window, "height", loaded.window.height); + yyjson_val *window = NULL; + yyjson_val *active_behaviors = NULL; + yyjson_val *additional_models = NULL; + bool valid = read_object(root, "window", &window, error) && + read_array(root, "activeBehaviors", &active_behaviors, error) && + read_array(root, "additionalModelIds", &additional_models, error); + if (valid && window) { + valid = read_bool(window, "visible", &loaded.window.visible, error) && + read_float(window, "scalePercent", + &loaded.window.scale_percent, error) && + read_float(window, "opacityPercent", + &loaded.window.opacity_percent, error); + yyjson_val *position = NULL; + yyjson_val *size = NULL; + if (valid) valid = read_object( + window, "position", &position, error); + if (valid) valid = read_object(window, "size", &size, error); + if (valid && position) { + valid = read_int(position, "x", &loaded.window.x, true, error) && + read_int(position, "y", &loaded.window.y, true, error); + if (valid) loaded.window.position_known = true; + } + if (valid && size) { + int content_width = 0, content_height = 0; + valid = read_int(size, "width", &loaded.window.width, true, + error) && + read_int(size, "height", &loaded.window.height, true, error) && + read_int(size, "contentWidth", &content_width, false, error) && + read_int(size, "contentHeight", &content_height, false, error); + if (valid) { + loaded.window.content_width = content_width > 0 + ? content_width : loaded.window.width; + loaded.window.content_height = content_height > 0 + ? content_height : loaded.window.height; + } + } } - yyjson_val *model = yyjson_obj_get(root, "currentModel"); - if (yyjson_is_str(model)) snprintf(loaded.current_model, - sizeof(loaded.current_model), "%s", yyjson_get_str(model)); + if (valid) valid = read_active_model(root, &loaded, error) && + read_int(root, "lastUpdateCheckDay", + &loaded.last_update_check_day, false, error) && + bongo_cat_config_read_text("Session", root, + "lastUpdateCheckVersion", loaded.last_update_check_version, + sizeof(loaded.last_update_check_version), error) && + bongo_cat_config_read_text("Session", root, + "availableUpdateVersion", loaded.available_update_version, + sizeof(loaded.available_update_version), error) && + read_additional_models(additional_models, &loaded, error) && + read_active_behaviors(active_behaviors, &loaded, error); yyjson_doc_free(document); - bongo_cat_config_validate(&loaded); - *config = loaded; + if (!valid) return BONGO_CAT_ERROR_FORMAT; + bongo_cat_session_validate(&loaded); + *session = loaded; return BONGO_CAT_OK; } +static BongoCatResult build_error(BongoCatError *error) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate session JSON"); + return BONGO_CAT_ERROR_MEMORY; +} + +static bool write_active_behaviors(yyjson_mut_doc *doc, + yyjson_mut_val *root, const BongoCatSessionState *session) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "activeBehaviors", array)) return false; + for (size_t i = 0; i < session->active_behavior_count; ++i) { + const BongoCatActiveBehavior *entry = + &session->active_behaviors[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "modelId", entry->model_id) || + !yyjson_mut_obj_add_strcpy( + doc, item, "behaviorId", entry->behavior_id) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + +static bool write_additional_models(yyjson_mut_doc *doc, + yyjson_mut_val *root, const BongoCatSessionState *session) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "additionalModelIds", array)) return false; + for (size_t i = 0; i < session->additional_model_count; ++i) + if (!yyjson_mut_arr_add_strcpy( + doc, array, session->additional_model_ids[i])) return false; + return true; +} + BongoCatResult bongo_cat_session_save(const char *path, - const BongoCatConfig *config, BongoCatError *error) { - if (!path || !config) return BONGO_CAT_ERROR_ARGUMENT; + const BongoCatSessionState *session, BongoCatError *error) { + if (!path || !session) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSessionState canonical = *session; + bongo_cat_session_validate(&canonical); yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) return BONGO_CAT_ERROR_MEMORY; - yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_strcpy(doc, root, "format", SESSION_FORMAT); - yyjson_mut_obj_add_int(doc, root, "version", BONGO_CAT_CONFIG_VERSION); - yyjson_mut_val *window = yyjson_mut_obj_add_obj(doc, root, "window"); - yyjson_mut_obj_add_bool(doc, window, "visible", config->window.visible); - yyjson_mut_obj_add_real(doc, window, "scale", config->window.scale_percent); - yyjson_mut_obj_add_real(doc, window, "opacity", config->window.opacity_percent); - yyjson_mut_obj_add_int(doc, window, "x", config->window.x); - yyjson_mut_obj_add_int(doc, window, "y", config->window.y); - yyjson_mut_obj_add_int(doc, window, "width", config->window.width); - yyjson_mut_obj_add_int(doc, window, "height", config->window.height); - yyjson_mut_obj_add_strcpy(doc, root, "currentModel", config->current_model); + if (!doc) return build_error(error); + yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + yyjson_mut_doc_set_root(doc, root); + bool built = yyjson_mut_obj_add_strcpy( + doc, root, "format", SESSION_FORMAT) && + yyjson_mut_obj_add_int(doc, root, "schemaVersion", + BONGO_CAT_SESSION_SCHEMA); + yyjson_mut_val *window = built ? + yyjson_mut_obj_add_obj(doc, root, "window") : NULL; + built = built && window && + yyjson_mut_obj_add_bool(doc, window, "visible", + canonical.window.visible) && + yyjson_mut_obj_add_real(doc, window, "scalePercent", + canonical.window.scale_percent) && + yyjson_mut_obj_add_real(doc, window, "opacityPercent", + canonical.window.opacity_percent); + if (built && canonical.window.position_known) { + yyjson_mut_val *position = + yyjson_mut_obj_add_obj(doc, window, "position"); + built = position && + yyjson_mut_obj_add_int(doc, position, "x", canonical.window.x) && + yyjson_mut_obj_add_int(doc, position, "y", canonical.window.y); + } + yyjson_mut_val *size = built ? + yyjson_mut_obj_add_obj(doc, window, "size") : NULL; + built = built && size && + yyjson_mut_obj_add_int(doc, size, "width", canonical.window.width) && + yyjson_mut_obj_add_int(doc, size, "height", canonical.window.height) && + yyjson_mut_obj_add_int(doc, size, "contentWidth", + canonical.window.content_width) && + yyjson_mut_obj_add_int(doc, size, "contentHeight", + canonical.window.content_height) && + yyjson_mut_obj_add_strcpy(doc, root, "activeModelId", + canonical.active_model_id) && + yyjson_mut_obj_add_int(doc, root, "lastUpdateCheckDay", + canonical.last_update_check_day) && + yyjson_mut_obj_add_strcpy(doc, root, "lastUpdateCheckVersion", + canonical.last_update_check_version) && + yyjson_mut_obj_add_strcpy(doc, root, "availableUpdateVersion", + canonical.available_update_version) && + write_additional_models(doc, root, &canonical) && + write_active_behaviors(doc, root, &canonical); + if (!built) { + yyjson_mut_doc_free(doc); + return build_error(error); + } BongoCatResult result = bongo_cat_config_write_document(path, doc, "session file", error); yyjson_mut_doc_free(doc); diff --git a/src/core/config_settings_save.c b/src/core/config_settings_save.c new file mode 100644 index 00000000..e6104783 --- /dev/null +++ b/src/core/config_settings_save.c @@ -0,0 +1,222 @@ +#include "config_internal.h" + +#include + +#define SETTINGS_FORMAT "bongocat/settings" + +static bool write_model(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatModelPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "multiplePets", + value->multiple_pets) && + yyjson_mut_obj_add_bool(doc, object, "modelMirrored", value->mirror) && + yyjson_mut_obj_add_bool(doc, object, "pointerMirrored", + value->mouse_mirror) && + yyjson_mut_obj_add_bool(doc, object, "centerPointerTracking", + value->mouse_centered) && + yyjson_mut_obj_add_bool(doc, object, "ignorePointerInput", + value->ignore_mouse) && + yyjson_mut_obj_add_int(doc, object, "maximumFps", value->max_fps); +} + +static bool write_window(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatWindowPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "clickThrough", + value->pass_through) && + yyjson_mut_obj_add_bool(doc, object, "alwaysOnTop", + value->always_on_top) && + yyjson_mut_obj_add_bool(doc, object, "hideOnPointerOver", + value->hide_on_hover) && + yyjson_mut_obj_add_bool(doc, object, "keepOnScreen", + value->keep_in_screen) && + yyjson_mut_obj_add_bool(doc, object, "captureBackground", + value->obs_background) && + yyjson_mut_obj_add_bool(doc, object, "randomExpression", + value->random_expression) && + yyjson_mut_obj_add_bool(doc, object, "roundedCorners", + value->rounded_corners) && + yyjson_mut_obj_add_real(doc, object, "cornerRadiusPercent", + value->corner_radius_percent) && + yyjson_mut_obj_add_strcpy(doc, object, "captureBackgroundColor", + bongo_cat_obs_background_color_name( + value->obs_background_color)) && + yyjson_mut_obj_add_real(doc, object, "hideDelaySeconds", + value->hide_delay_seconds) && + yyjson_mut_obj_add_real(doc, object, "hideFadeSeconds", + value->hide_fade_seconds) && + yyjson_mut_obj_add_real(doc, object, + "randomExpressionIntervalSeconds", + value->random_expression_interval_seconds); +} + +static bool write_app(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatApplicationPreferences *value) { + return object && + yyjson_mut_obj_add_bool(doc, object, "launchAtLogin", + value->autostart) && + yyjson_mut_obj_add_bool(doc, object, "showTrayIcon", + value->tray_visible) && + yyjson_mut_obj_add_strcpy(doc, object, "theme", + bongo_cat_theme_name(value->theme)) && + yyjson_mut_obj_add_strcpy(doc, object, "language", + bongo_cat_language_name(value->language)); +} + +static bool write_shortcuts(yyjson_mut_doc *doc, yyjson_mut_val *object, + const BongoCatShortcutPreferences *value) { + return object && + yyjson_mut_obj_add_strcpy(doc, object, "toggleVisibility", + value->toggle_pet_visibility) && + yyjson_mut_obj_add_strcpy(doc, object, "openSettings", + value->visible_preferences) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleModelMirror", + value->mirror) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleClickThrough", + value->pass_through) && + yyjson_mut_obj_add_strcpy(doc, object, "toggleAlwaysOnTop", + value->always_on_top); +} + +static bool write_behaviors(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "behaviorOverrides", array)) return false; + for (size_t i = 0; i < settings->behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *value = + &settings->behavior_shortcuts[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "behaviorId", value->id) || + (value->shortcut_disabled && !yyjson_mut_obj_add_bool( + doc, item, "shortcutDisabled", true)) || + (value->shortcut[0] && !yyjson_mut_obj_add_strcpy( + doc, item, "shortcut", value->shortcut)) || + (value->label[0] && !yyjson_mut_obj_add_strcpy( + doc, item, "displayName", value->label)) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + +static bool write_model_labels(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "modelOverrides", array)) return false; + for (size_t i = 0; i < settings->model_label_count; ++i) { + const BongoCatModelLabel *value = &settings->model_labels[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + if (!item || !yyjson_mut_obj_add_strcpy( + doc, item, "modelId", value->id) || + !yyjson_mut_obj_add_strcpy( + doc, item, "displayName", value->label) || + !yyjson_mut_arr_add_val(array, item)) return false; + } + return true; +} + +static bool write_removed_models(yyjson_mut_doc *doc, yyjson_mut_val *root, + const BongoCatSettings *settings) { + yyjson_mut_val *array = yyjson_mut_arr(doc); + if (!array || !yyjson_mut_obj_add_val( + doc, root, "removedModels", array)) return false; + for (size_t i = 0; i < settings->removed_model_count; ++i) + if (!yyjson_mut_arr_add_strcpy(doc, array, + settings->removed_models[i].id)) return false; + return true; +} + +static yyjson_mut_val *write_extensions(yyjson_mut_doc *target, + const BongoCatSettings *settings, BongoCatResult *failure, + BongoCatError *error) { + *failure = BONGO_CAT_OK; + const char *json = settings->extensions_json; + const char *end = memchr(json, '\0', sizeof(settings->extensions_json)); + if (!end) { + *failure = BONGO_CAT_ERROR_FORMAT; + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Settings extensions are not null-terminated"); + return NULL; + } + size_t length = (size_t)(end - json); + if (!length) { + json = "{}"; + length = 2; + } + yyjson_read_err read_error = {0}; + yyjson_doc *source = yyjson_read_opts( + (char *)json, length, 0, NULL, &read_error); + yyjson_val *root = source ? yyjson_doc_get_root(source) : NULL; + if (!yyjson_is_obj(root)) { + yyjson_doc_free(source); + BongoCatResult result = read_error.code == + YYJSON_READ_ERROR_MEMORY_ALLOCATION ? BONGO_CAT_ERROR_MEMORY : + BONGO_CAT_ERROR_FORMAT; + *failure = result; + bongo_cat_error_set(error, result, + "Settings extensions are not a valid JSON object"); + return NULL; + } + yyjson_mut_val *copy = yyjson_val_mut_copy(target, root); + yyjson_doc_free(source); + if (!copy) { + *failure = BONGO_CAT_ERROR_MEMORY; + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate settings extensions"); + } + return copy; +} + +static BongoCatResult build_error(BongoCatError *error) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate settings JSON"); + return BONGO_CAT_ERROR_MEMORY; +} + +BongoCatResult bongo_cat_settings_save(const char *path, + const BongoCatSettings *settings, BongoCatError *error) { + if (!path || !settings) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatSettings canonical = *settings; + bongo_cat_settings_validate(&canonical); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) return build_error(error); + yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + yyjson_mut_doc_set_root(doc, root); + BongoCatResult extension_result = BONGO_CAT_OK; + yyjson_mut_val *extensions = write_extensions( + doc, &canonical, &extension_result, error); + if (!extensions) { + yyjson_mut_doc_free(doc); + return extension_result; + } + bool built = yyjson_mut_obj_add_strcpy( + doc, root, "format", SETTINGS_FORMAT) && + yyjson_mut_obj_add_int(doc, root, "schemaVersion", + BONGO_CAT_SETTINGS_SCHEMA) && + write_model(doc, yyjson_mut_obj_add_obj(doc, root, "rendering"), + &canonical.model) && + write_window(doc, yyjson_mut_obj_add_obj(doc, root, "window"), + &canonical.window) && + write_app(doc, yyjson_mut_obj_add_obj(doc, root, "application"), + &canonical.app) && + write_shortcuts(doc, yyjson_mut_obj_add_obj(doc, root, "shortcuts"), + &canonical.shortcuts) && + write_behaviors(doc, root, &canonical) && + write_model_labels(doc, root, &canonical) && + write_removed_models(doc, root, &canonical) && + yyjson_mut_obj_add_val(doc, root, "extensions", extensions); + if (!built) { + yyjson_mut_doc_free(doc); + return build_error(error); + } + BongoCatResult result = bongo_cat_config_write_document(path, doc, + "settings file", error); + yyjson_mut_doc_free(doc); + return result; +} diff --git a/src/core/file.c b/src/core/file.c index e299d6d9..bb3d0c77 100644 --- a/src/core/file.c +++ b/src/core/file.c @@ -21,6 +21,37 @@ FILE *bongo_cat_file_open(const char *path, const char *mode) { free(path_wide); return file; } +bool bongo_cat_file_append(const char *path, const void *data, size_t size) { + if (!path || !path[0] || (!data && size)) return false; + wchar_t *wide = bongo_cat_windows_wide(path); + HANDLE file = wide ? CreateFileW(wide, GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL) : INVALID_HANDLE_VALUE; + free(wide); + if (file == INVALID_HANDLE_VALUE) return false; + OVERLAPPED lock = {0}; + bool locked = LockFileEx(file, LOCKFILE_EXCLUSIVE_LOCK, 0, + MAXDWORD, MAXDWORD, &lock) != FALSE; + LARGE_INTEGER end = {0}; + bool positioned = locked && SetFilePointerEx(file, end, NULL, + FILE_END) != FALSE; + const unsigned char *cursor = data; + size_t remaining = size; + bool written = positioned; + while (written && remaining) { + DWORD chunk = remaining > MAXDWORD ? MAXDWORD : (DWORD)remaining; + DWORD count = 0; + written = WriteFile(file, cursor, chunk, &count, NULL) != FALSE && + count > 0; + cursor += count; + remaining -= count; + } + if (locked && !UnlockFileEx(file, 0, MAXDWORD, MAXDWORD, &lock)) + written = false; + if (!CloseHandle(file)) written = false; + return written; +} + bool bongo_cat_file_remove(const char *path) { wchar_t *wide = bongo_cat_windows_wide(path); bool removed = wide && _wremove(wide) == 0; @@ -36,9 +67,38 @@ bool bongo_cat_file_replace(const char *source, const char *target, bool durable free(source_wide); free(target_wide); return replaced; } #else +#include +#include #include +#include +#include FILE *bongo_cat_file_open(const char *path, const char *mode) { return fopen(path, mode); } + +static bool lock_file(int file, int operation) { + int result; + do result = flock(file, operation); while (result != 0 && errno == EINTR); + return result == 0; +} + +bool bongo_cat_file_append(const char *path, const void *data, size_t size) { + if (!path || !path[0] || (!data && size)) return false; + int file = open(path, O_WRONLY | O_CREAT | O_APPEND, 0666); + if (file < 0) return false; + bool locked = lock_file(file, LOCK_EX); + const unsigned char *cursor = data; + size_t remaining = size; + bool written = locked; + while (written && remaining) { + ssize_t count = write(file, cursor, remaining); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) written = false; + else { cursor += count; remaining -= (size_t)count; } + } + if (locked && !lock_file(file, LOCK_UN)) written = false; + if (close(file) != 0) written = false; + return written; +} bool bongo_cat_file_remove(const char *path) { return remove(path) == 0; } bool bongo_cat_file_replace(const char *source, const char *target, bool durable) { (void)durable; return rename(source, target) == 0; diff --git a/src/core/input_state.c b/src/core/input_state.c index 872d6b7f..4ab9508b 100644 --- a/src/core/input_state.c +++ b/src/core/input_state.c @@ -5,6 +5,7 @@ void bongo_cat_input_init(BongoCatInputState *state) { if (!state) return; + atomic_init(&state->queue_busy, false); memset(state->queue, 0, sizeof(state->queue)); atomic_init(&state->head, 0); atomic_init(&state->tail, 0); @@ -18,8 +19,8 @@ void bongo_cat_input_init(BongoCatInputState *state) { memset(state->recovery, 0, sizeof(state->recovery)); atomic_init(&state->recovery_head, 0); atomic_init(&state->recovery_tail, 0); - memset(state->releases, 0, sizeof(state->releases)); - state->release_count = 0; + memset(state->scheduled_releases, 0, sizeof(state->scheduled_releases)); + state->scheduled_release_count = 0; } bool bongo_cat_input_edge(bool states[BONGO_CAT_INPUT_KEY_STATE_CAP], @@ -46,8 +47,7 @@ static void update_modifiers(BongoCatInputState *state, const BongoCatInputEvent else atomic_fetch_and_explicit(value, (uint8_t)~mask, memory_order_release); } -bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *event) { - if (!state || !event) return false; +static bool push_locked(BongoCatInputState *state, const BongoCatInputEvent *event) { update_modifiers(state, event); uint64_t sequence = atomic_fetch_add_explicit(&state->next_sequence, 1, memory_order_relaxed); @@ -77,8 +77,7 @@ bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *e return true; } -bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { - if (!state || !event) return false; +static bool pop_locked(BongoCatInputState *state, BongoCatInputEvent *event) { uint16_t tail = (uint16_t)atomic_load_explicit(&state->tail, memory_order_relaxed); uint16_t head = (uint16_t)atomic_load_explicit(&state->head, memory_order_acquire); uint8_t recovery_tail = (uint8_t)atomic_load_explicit( @@ -102,6 +101,29 @@ bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { return true; } +/* Platform listeners and SDL/gamepad events can produce concurrently. Protect + both rings together so recovery releases remain ordered with normal events. + The critical section only copies fixed-size events; it never waits on I/O. */ +static void lock_queue(BongoCatInputState *state) { + while (atomic_exchange_explicit(&state->queue_busy, true, memory_order_acquire)) {} +} + +bool bongo_cat_input_push(BongoCatInputState *state, const BongoCatInputEvent *event) { + if (!state || !event) return false; + lock_queue(state); + bool pushed = push_locked(state, event); + atomic_store_explicit(&state->queue_busy, false, memory_order_release); + return pushed; +} + +bool bongo_cat_input_pop(BongoCatInputState *state, BongoCatInputEvent *event) { + if (!state || !event) return false; + lock_queue(state); + bool popped = pop_locked(state, event); + atomic_store_explicit(&state->queue_busy, false, memory_order_release); + return popped; +} + bool bongo_cat_input_mouse(BongoCatInputState *state, double x, double y) { if (!state) return false; atomic_store_explicit(&state->mouse_x, x, memory_order_relaxed); @@ -127,19 +149,20 @@ bool bongo_cat_input_shift_down(const BongoCatInputState *state) { } static size_t release_index(const BongoCatInputState *state, const char *name) { - for (size_t i = 0; i < state->release_count; ++i) - if (strcmp(state->releases[i].name, name) == 0) return i; - return state->release_count; + for (size_t i = 0; i < state->scheduled_release_count; ++i) + if (strcmp(state->scheduled_releases[i].name, name) == 0) return i; + return state->scheduled_release_count; } static void remove_release(BongoCatInputState *state, size_t index) { - if (index >= state->release_count) return; - state->releases[index] = state->releases[state->release_count - 1]; - memset(&state->releases[--state->release_count], 0, - sizeof(state->releases[0])); + if (index >= state->scheduled_release_count) return; + state->scheduled_releases[index] = + state->scheduled_releases[state->scheduled_release_count - 1]; + memset(&state->scheduled_releases[--state->scheduled_release_count], 0, + sizeof(state->scheduled_releases[0])); } -void bongo_cat_input_auto_release(BongoCatInputState *state, +void bongo_cat_input_schedule_release(BongoCatInputState *state, const BongoCatInputEvent *event, uint64_t delay_ms) { if (!state || !event || !event->name[0] || (event->kind != BONGO_CAT_INPUT_KEY_DOWN && event->kind != BONGO_CAT_INPUT_KEY_UP)) return; @@ -148,24 +171,27 @@ void bongo_cat_input_auto_release(BongoCatInputState *state, remove_release(state, index); return; } - if (index == state->release_count) { - if (state->release_count >= BONGO_CAT_AUTO_RELEASE_CAP) return; - index = state->release_count++; - snprintf(state->releases[index].name, - sizeof(state->releases[index].name), "%s", event->name); + if (index == state->scheduled_release_count) { + if (state->scheduled_release_count >= + BONGO_CAT_SCHEDULED_RELEASE_CAP) return; + index = state->scheduled_release_count++; + snprintf(state->scheduled_releases[index].name, + sizeof(state->scheduled_releases[index].name), "%s", event->name); } - state->releases[index].deadline_ms = event->timestamp_ms + delay_ms; + state->scheduled_releases[index].deadline_ms = + event->timestamp_ms + delay_ms; } -bool bongo_cat_input_take_release(BongoCatInputState *state, uint64_t now_ms, - BongoCatInputEvent *event) { +bool bongo_cat_input_take_scheduled_release(BongoCatInputState *state, + uint64_t now_ms, BongoCatInputEvent *event) { if (!state || !event) return false; - for (size_t i = 0; i < state->release_count; ++i) { - if (state->releases[i].deadline_ms > now_ms) continue; + for (size_t i = 0; i < state->scheduled_release_count; ++i) { + if (state->scheduled_releases[i].deadline_ms > now_ms) continue; memset(event, 0, sizeof(*event)); event->kind = BONGO_CAT_INPUT_KEY_UP; event->timestamp_ms = now_ms; - snprintf(event->name, sizeof(event->name), "%s", state->releases[i].name); + snprintf(event->name, sizeof(event->name), "%s", + state->scheduled_releases[i].name); remove_release(state, i); return true; } diff --git a/src/core/model_behavior.c b/src/core/model_behavior.c index 1ac255aa..50902e93 100644 --- a/src/core/model_behavior.c +++ b/src/core/model_behavior.c @@ -70,11 +70,11 @@ static bool read_expressions(BongoCatBehaviorCatalog *catalog, return true; } -static bool read_mver_assets(BongoCatBehaviorCatalog *catalog, +static bool read_adapter_assets(BongoCatBehaviorCatalog *catalog, const BongoCatModelEntry *model) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), model->adapter_directory, - ".bongo-cat-mver.json")) return false; + if (!bongo_cat_model_adapter_metadata_path(model->adapter_directory, + path, sizeof(path))) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); if (!document) return true; yyjson_val *items = yyjson_obj_get(yyjson_doc_get_root(document), "bindings"); @@ -97,6 +97,7 @@ static bool read_mver_assets(BongoCatBehaviorCatalog *catalog, model->adapter_directory)) { ok = false; break; } + catalog->entries[catalog->count - 1].sound_clear = sound; continue; } const char *asset = yyjson_get_str(yyjson_obj_get(item, effect ? "effect" : "sound")); @@ -109,8 +110,11 @@ static bool read_mver_assets(BongoCatBehaviorCatalog *catalog, if (!add_behavior(catalog, model, effect ? BONGO_CAT_BEHAVIOR_EFFECT : BONGO_CAT_BEHAVIOR_SOUND, NULL, current, label, asset, model->adapter_directory)) { ok = false; break; } - catalog->entries[catalog->count - 1].momentary = - yyjson_get_bool(yyjson_obj_get(item, "momentary")); + BongoCatBehaviorEntry *entry = &catalog->entries[catalog->count - 1]; + bool momentary = yyjson_get_bool(yyjson_obj_get(item, "momentary")); + entry->momentary = effect && momentary; + yyjson_val *overlap = yyjson_obj_get(item, "overlap"); + entry->sound_overlap = sound && (overlap ? yyjson_get_bool(overlap) : true); } yyjson_doc_free(document); return ok; @@ -123,19 +127,16 @@ BongoCatResult bongo_cat_behaviors_load(BongoCatBehaviorCatalog *catalog, char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), model->directory, model->setting_file)) return BONGO_CAT_ERROR_FORMAT; - yyjson_read_err json_error = {0}; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, &json_error) : NULL; - if (file) fclose(file); + yyjson_doc *document = bongo_cat_model_json_read(path, NULL); if (!document) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Cannot read model setting: %s", - json_error.msg ? json_error.msg : "cannot open file"); + path); return BONGO_CAT_ERROR_FORMAT; } yyjson_val *references = yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences"); bool ok = read_motions(catalog, model, yyjson_obj_get(references, "Motions")) && read_expressions(catalog, model, yyjson_obj_get(references, "Expressions")) && - read_mver_assets(catalog, model); + read_adapter_assets(catalog, model); yyjson_doc_free(document); if (!ok) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Too many model behaviors"); diff --git a/src/core/model_catalog.c b/src/core/model_catalog.c index 3065d324..03dbec95 100644 --- a/src/core/model_catalog.c +++ b/src/core/model_catalog.c @@ -1,11 +1,9 @@ #include "bongo_cat/file.h" -#include "bongo_cat/json.h" #include "bongo_cat/model.h" #include "bongo_cat/path.h" #include #include -#include #ifdef _WIN32 #include "windows_utf8.h" @@ -19,6 +17,13 @@ void bongo_cat_models_init(BongoCatModelCatalog *catalog) { if (catalog) memset(catalog, 0, sizeof(*catalog)); } +bool bongo_cat_model_adapter_metadata_path(const char *directory, + char *path, size_t capacity) { + return directory && path && capacity && + bongo_cat_path_join(path, capacity, directory, + BONGO_CAT_MODEL_ADAPTER_FILE); +} + static BongoCatModelMode infer_mode(const char *directory) { char path[BONGO_CAT_PATH_CAP], stored[16] = {0}; bongo_cat_path_join(path, sizeof(path), directory, ".bongo-cat-mode"); @@ -41,79 +46,30 @@ static BongoCatModelMode infer_mode(const char *directory) { return bongo_cat_path_is_dir(path) ? BONGO_CAT_MODE_KEYBOARD : BONGO_CAT_MODE_STANDARD; } -static bool safe_relative(const char *value) { - if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || strchr(value, ':')) - return false; - const char *cursor = value; - while (*cursor) { - while (*cursor == '/' || *cursor == '\\') cursor++; - if (cursor[0] == '.' && cursor[1] == '.' && - (!cursor[2] || cursor[2] == '/' || cursor[2] == '\\')) return false; - cursor = strpbrk(cursor, "/\\"); - if (!cursor) break; - } - return true; -} - -static BongoCatModelMode stored_mode(const char *value) { - if (value && strcmp(value, "keyboard") == 0) return BONGO_CAT_MODE_KEYBOARD; - if (value && strcmp(value, "gamepad") == 0) return BONGO_CAT_MODE_GAMEPAD; - return BONGO_CAT_MODE_STANDARD; -} - -static bool add_package(BongoCatModelCatalog *catalog, const char *directory, - bool preset, bool *handled) { - char descriptor[BONGO_CAT_PATH_CAP]; - *handled = bongo_cat_path_join(descriptor, sizeof(descriptor), directory, - ".bongo-cat-package.json") && bongo_cat_path_is_file(descriptor); - if (!*handled) return true; - yyjson_doc *document = bongo_cat_json_read_file(descriptor, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *model = yyjson_get_str(yyjson_obj_get(root, "directory")); - const char *adapter = yyjson_get_str(yyjson_obj_get(root, "adapter")); - const char *setting = yyjson_get_str(yyjson_obj_get(root, "setting")); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == 1 && - safe_relative(model) && safe_relative(adapter) && safe_relative(setting) && - catalog->count < BONGO_CAT_MODEL_CAP; - BongoCatModelEntry *entry = valid ? &catalog->entries[catalog->count] : NULL; - if (entry) valid = bongo_cat_path_join(entry->directory, - sizeof(entry->directory), directory, model) && - bongo_cat_path_join(entry->adapter_directory, - sizeof(entry->adapter_directory), directory, adapter); - char setting_path[BONGO_CAT_PATH_CAP]; - if (entry) valid = bongo_cat_path_join(setting_path, sizeof(setting_path), - entry->directory, setting) && bongo_cat_path_is_file(setting_path) && - bongo_cat_path_is_dir(entry->adapter_directory); - if (valid) { - snprintf(entry->id, sizeof(entry->id), "%s", bongo_cat_path_name(directory)); - snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", directory); - snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); - entry->mode = stored_mode(yyjson_get_str(yyjson_obj_get(root, "mode"))); - entry->preset = preset; - catalog->count++; - } - yyjson_doc_free(document); - return valid; +static bool builtin_marker(const char *directory) { + char marker[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(marker, sizeof(marker), directory, + BONGO_CAT_MODEL_BUILTIN_MARKER) && bongo_cat_path_is_file(marker); } static bool add_model(BongoCatModelCatalog *catalog, const char *directory, bool preset) { - bool handled = false; - bool package_ok = add_package(catalog, directory, preset, &handled); - if (handled) return package_ok; - if (!package_ok) return false; - if (!preset) return true; + if (!preset && !builtin_marker(directory)) return true; if (catalog->count >= BONGO_CAT_MODEL_CAP) return false; char setting[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_find_suffix(directory, ".model3.json", setting, sizeof(setting))) return true; BongoCatModelEntry *entry = &catalog->entries[catalog->count++]; + memset(entry, 0, sizeof(*entry)); snprintf(entry->id, sizeof(entry->id), "%s", bongo_cat_path_name(directory)); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", entry->id); snprintf(entry->directory, sizeof(entry->directory), "%s", directory); snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", directory); snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", directory); snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", setting); entry->mode = infer_mode(directory); - entry->preset = preset; + entry->source_format = BONGO_CAT_MODEL_SOURCE_BUILTIN; + entry->capabilities = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_PREVIEW; + entry->preset = preset || builtin_marker(directory); return true; } @@ -183,3 +139,19 @@ const BongoCatModelEntry *bongo_cat_models_find(const BongoCatModelCatalog *cata } return NULL; } + +const char *bongo_cat_model_default_name(const BongoCatModelEntry *entry) { + if (!entry) return "model"; + if (entry->display_name[0]) return entry->display_name; + if (!strcmp(entry->id, "standard")) return "Standard"; + if (!strcmp(entry->id, "keyboard")) return "Keyboard"; + if (!strcmp(entry->id, "gamepad")) return "Gamepad"; + return entry->id; +} + +const char *bongo_cat_model_name(const BongoCatSettings *settings, + const BongoCatModelEntry *entry) { + if (!entry) return "model"; + const char *custom = bongo_cat_settings_model_label(settings, entry->id); + return custom && custom[0] ? custom : bongo_cat_model_default_name(entry); +} diff --git a/src/core/model_json.c b/src/core/model_json.c new file mode 100644 index 00000000..6c19af74 --- /dev/null +++ b/src/core/model_json.c @@ -0,0 +1,61 @@ +#include "bongo_cat/json.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static size_t skip_space(const char *data, size_t size, size_t pos) { + while (pos < size && (data[pos] == ' ' || data[pos] == '\t' || + data[pos] == '\r' || data[pos] == '\n')) pos++; + return pos; +} + +yyjson_doc *bongo_cat_model_json_parse(const char *data, size_t size, + bool *normalized) { + if (normalized) *normalized = false; + if (!data || !size || size > 4u * 1024u * 1024u) return NULL; + yyjson_doc *document = yyjson_read(data, size, 0); + if (document) return document; + yyjson_read_flag flags = YYJSON_READ_ALLOW_COMMENTS | + YYJSON_READ_ALLOW_TRAILING_COMMAS | YYJSON_READ_ALLOW_BOM; + yyjson_read_err error = {0}; + document = yyjson_read_opts((char *)data, size, flags, NULL, &error); + if (!document && error.pos < size && data[error.pos] == ']') { + size_t second = skip_space(data, size, error.pos + 1); + size_t third = skip_space(data, size, second + 1); + /* Some hand-edited Mver manifests have one extra '] }' pair. */ + if (second < size && third < size && data[second] == '}' && data[third] == '}') { + char *copy = malloc(size); + if (!copy) return NULL; + memcpy(copy, data, size); + copy[error.pos] = copy[second] = ' '; + document = yyjson_read_opts(copy, size, flags, NULL, NULL); + free(copy); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + if (!yyjson_is_obj(root) || + !yyjson_is_obj(yyjson_obj_get(root, "FileReferences"))) { + yyjson_doc_free(document); + document = NULL; + } + } + } + if (normalized) *normalized = document != NULL; + return document; +} + +yyjson_doc *bongo_cat_model_json_read(const char *path, bool *normalized) { + if (normalized) *normalized = false; + uint64_t size; + if (!bongo_cat_path_file_size(path, &size) || !size || + size > 4u * 1024u * 1024u) return NULL; + char *data = malloc((size_t)size); + if (!data) return NULL; + FILE *file = bongo_cat_file_open(path, "rb"); + bool loaded = file && fread(data, 1, (size_t)size, file) == (size_t)size; + if (file && fclose(file) != 0) loaded = false; + yyjson_doc *document = loaded + ? bongo_cat_model_json_parse(data, (size_t)size, normalized) : NULL; + free(data); + return document; +} diff --git a/src/core/mouse_tracking.c b/src/core/mouse_tracking.c index ed68f331..d8fa7ced 100644 --- a/src/core/mouse_tracking.c +++ b/src/core/mouse_tracking.c @@ -2,6 +2,11 @@ #include +bool bongo_cat_mouse_sample_fresh(uint64_t now_ns, uint64_t sample_ns) { + return sample_ns && now_ns >= sample_ns && + now_ns - sample_ns < 100000000ull; +} + void bongo_cat_mouse_target(BongoCatMouseTracking *tracking, double x, double y) { if (!tracking) return; tracking->target_x = x; @@ -36,16 +41,57 @@ bool bongo_cat_mouse_step(BongoCatMouseTracking *tracking, float delta_seconds, return true; } +float bongo_cat_mouse_centered_ratio(double position, double center, + double minimum, double maximum) { + if (!isfinite(position) || !isfinite(center) || !isfinite(minimum) || + !isfinite(maximum) || maximum <= minimum) return 0.5f; + if (center < minimum) center = minimum; + if (center > maximum) center = maximum; + if (position < center) { + double span = center - minimum; + if (span <= 0.0) return 0.5f; + double ratio = 0.5 * (position - minimum) / span; + return (float)(ratio < 0.0 ? 0.0 : ratio); + } + if (position > center) { + double span = maximum - center; + if (span <= 0.0) return 0.5f; + double ratio = 0.5 + 0.5 * (position - center) / span; + return (float)(ratio > 1.0 ? 1.0 : ratio); + } + return 0.5f; +} + +static float clamp_pointer_ratio(float value) { + if (!isfinite(value)) return 0.5f; + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +void bongo_cat_mouse_drag_coordinates(float x_ratio, float y_ratio, + bool horizontal_mirror, float *x, float *y) { + float horizontal = clamp_pointer_ratio(x_ratio); + float vertical = clamp_pointer_ratio(y_ratio); + if (horizontal_mirror) horizontal = 1.0f - horizontal; + if (x) *x = 2.0f * horizontal - 1.0f; + if (y) *y = 1.0f - 2.0f * vertical; +} + +static float parameter_from_normalized(float minimum, float maximum, + float normalized) { + if (!isfinite(minimum) || !isfinite(maximum) || maximum < minimum) + return 0.0f; + if (minimum <= 0.0f && maximum >= 0.0f) + return normalized >= 0.0f ? normalized * maximum : + -normalized * minimum; + return minimum + (normalized + 1.0f) * 0.5f * (maximum - minimum); +} + float bongo_cat_mouse_parameter_value(float minimum, float maximum, float x_ratio, float y_ratio, char axis, bool mirror) { - float value; - if (axis == 'Z') { - float x = 1.0f - 2.0f * x_ratio; - float y = 1.0f - 2.0f * y_ratio; - value = x * y * minimum; - } else { - float ratio = axis == 'Y' ? y_ratio : x_ratio; - value = maximum - ratio * (maximum - minimum); - } - return axis != 'Y' && mirror ? -value : value; + float x = 0.0f, y = 0.0f; + bongo_cat_mouse_drag_coordinates(x_ratio, y_ratio, mirror, &x, &y); + float normalized = axis == 'Y' ? y : axis == 'Z' ? -x * y : x; + return parameter_from_normalized(minimum, maximum, normalized); } diff --git a/src/core/path_io.c b/src/core/path_io.c index 5dd763ac..1005144b 100644 --- a/src/core/path_io.c +++ b/src/core/path_io.c @@ -90,7 +90,8 @@ bool bongo_cat_path_copy_file(const char *source, const char *target) { unsigned char buffer[8192]; bool ok = true; size_t count; while ((count = fread(buffer, 1, sizeof(buffer), input)) > 0) if (fwrite(buffer, 1, count, output) != count) { ok = false; break; } - if (ferror(input) || fclose(input) != 0) ok = false; + if (ferror(input)) ok = false; + if (fclose(input) != 0) ok = false; if (fclose(output) != 0) ok = false; if (!ok) remove(target); return ok; diff --git a/src/core/sha256.c b/src/core/sha256.c index dac0ded6..6f27bf99 100644 --- a/src/core/sha256.c +++ b/src/core/sha256.c @@ -92,7 +92,8 @@ BongoCatResult bongo_cat_sha256_file(const char *path, char output[65], BongoCat Sha256 value; initialize(&value); unsigned char buffer[8192]; size_t count; while ((count = fread(buffer, 1, sizeof(buffer), file)) > 0) update(&value, buffer, count); - bool ok = !ferror(file) && fclose(file) == 0; + bool ok = !ferror(file); + if (fclose(file) != 0) ok = false; if (!ok) { bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot read file"); return BONGO_CAT_ERROR_IO; } finish(&value, output); return BONGO_CAT_OK; } diff --git a/src/core/shortcut.c b/src/core/shortcut.c index 3568c9ea..280ea7cf 100644 --- a/src/core/shortcut.c +++ b/src/core/shortcut.c @@ -58,6 +58,68 @@ static const char *canonical_key(const char *name, char output[16]) { return name; } +static const char *display_key(const char *token, size_t length) { + static const struct { const char *name; const char *label; } names[] = { + {"Control", "Ctrl"}, {"Ctrl", "Ctrl"}, +#if defined(_WIN32) + {"Meta", "Win"}, {"Super", "Win"}, {"Command", "Win"}, +#elif defined(__APPLE__) + {"Meta", "Cmd"}, {"Super", "Cmd"}, {"Command", "Cmd"}, +#else + {"Meta", "Super"}, {"Command", "Super"}, +#endif + {"Escape", "Esc"}, {"Delete", "Del"}, {"Insert", "Ins"}, + {"PageUp", "PgUp"}, {"PageDown", "PgDn"}, + {"PrintScreen", "PrtSc"}, {"Return", "Enter"}, + {"ArrowUp", "\xE2\x86\x91"}, {"ArrowDown", "\xE2\x86\x93"}, + {"ArrowLeft", "\xE2\x86\x90"}, {"ArrowRight", "\xE2\x86\x92"}, + {"UpArrow", "\xE2\x86\x91"}, {"DownArrow", "\xE2\x86\x93"}, + {"LeftArrow", "\xE2\x86\x90"}, {"RightArrow", "\xE2\x86\x92"}, + {"BackQuote", "`"}, {"Minus", "-"}, {"Equal", "="}, + {"BracketLeft", "["}, {"BracketRight", "]"}, + {"Backslash", "\\"}, {"Semicolon", ";"}, {"Quote", "'"}, + {"Comma", ","}, {"Period", "."}, {"Slash", "/"}, + {"Kp0", "Num 0"}, {"Kp1", "Num 1"}, {"Kp2", "Num 2"}, + {"Kp3", "Num 3"}, {"Kp4", "Num 4"}, {"Kp5", "Num 5"}, + {"Kp6", "Num 6"}, {"Kp7", "Num 7"}, {"Kp8", "Num 8"}, + {"Kp9", "Num 9"}, {"KpMultiply", "Num *"}, {"KpPlus", "Num +"}, + {"KpMinus", "Num -"}, {"KpDecimal", "Num ."}, {"KpDivide", "Num /"} + }; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (strlen(names[i].name) != length) continue; + size_t j = 0; + while (j < length && tolower((unsigned char)token[j]) == + tolower((unsigned char)names[i].name[j])) ++j; + if (j == length) return names[i].label; + } + return NULL; +} + +void bongo_cat_shortcut_format(const char *shortcut, char *output, size_t capacity) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!shortcut) return; + if (strncmp(shortcut, "Gamepad:", 8) == 0) { + snprintf(output, capacity, "%s", shortcut); + return; + } + size_t used = 0; + while (*shortcut && used < capacity - 1) { + const char *plus = strchr(shortcut, '+'); + size_t length = plus ? (size_t)(plus - shortcut) : strlen(shortcut); + const char *label = display_key(shortcut, length); + const char *shown = label ? label : shortcut; + size_t count = label ? strlen(label) : length; + if (count > capacity - 1 - used) count = capacity - 1 - used; + memcpy(output + used, shown, count); + used += count; + if (!plus) break; + if (used < capacity - 1) output[used++] = '+'; + shortcut = plus + 1; + } + output[used] = '\0'; +} + static bool modifier_token(const char *token, size_t length, bool *control, bool *shift, bool *alt, bool *meta) { char value[24]; diff --git a/src/core/sound_shortcut.c b/src/core/sound_shortcut.c new file mode 100644 index 00000000..108b3239 --- /dev/null +++ b/src/core/sound_shortcut.c @@ -0,0 +1,84 @@ +#include "bongo_cat/sound_shortcut.h" + +#include +#include +#include + +static bool equal(const char *a, const char *b) { + while (*a && *b) { + if (tolower((unsigned char)*a++) != tolower((unsigned char)*b++)) return false; + } + return *a == *b; +} + +static const char *canonical(const char *key) { + if ((strncmp(key, "Key", 3) == 0 || strncmp(key, "Num", 3) == 0) && + key[3] && !key[4]) return key + 3; + if (strcmp(key, "UpArrow") == 0) return "ArrowUp"; + if (strcmp(key, "DownArrow") == 0) return "ArrowDown"; + if (strcmp(key, "LeftArrow") == 0) return "ArrowLeft"; + if (strcmp(key, "RightArrow") == 0) return "ArrowRight"; + if (strcmp(key, "Return") == 0) return "Enter"; + return key; +} + +static bool token_matches(const char *token, const char *held) { + if (equal(token, canonical(held))) return true; + if (equal(token, "Control") || equal(token, "Ctrl")) + return equal(held, "ControlLeft") || equal(held, "ControlRight"); + if (equal(token, "Shift")) + return equal(held, "ShiftLeft") || equal(held, "ShiftRight"); + if (equal(token, "Alt")) return equal(held, "Alt") || equal(held, "AltGr"); + if (equal(token, "Super") || equal(token, "Command")) return equal(held, "Meta"); + if (equal(token, "-")) return equal(held, "Minus"); + if (equal(token, "=")) return equal(held, "Equal"); + return false; +} + +bool bongo_cat_sound_shortcut_update(BongoCatSoundShortcutState *state, + const BongoCatInputEvent *event) { + if (!state || !event || !event->name[0]) return false; + bool down; + char name[BONGO_CAT_ID_CAP]; + if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { + down = event->value > 0.5f; + if (strlen(event->name) + 8 >= sizeof(name)) return false; + snprintf(name, sizeof(name), "Gamepad:%s", event->name); + } else { + if (event->kind != BONGO_CAT_INPUT_KEY_DOWN && event->kind != BONGO_CAT_INPUT_KEY_UP && + event->kind != BONGO_CAT_INPUT_MOUSE_DOWN && event->kind != BONGO_CAT_INPUT_MOUSE_UP) + return false; + down = event->kind == BONGO_CAT_INPUT_KEY_DOWN || event->kind == BONGO_CAT_INPUT_MOUSE_DOWN; + snprintf(name, sizeof(name), "%s", event->name); + } + for (size_t i = 0; i < state->count; ++i) { + if (strcmp(state->held[i], name) != 0) continue; + if (down) return false; + --state->count; + if (i != state->count) memcpy(state->held[i], state->held[state->count], sizeof(name)); + return true; + } + if (!down || state->count >= BONGO_CAT_INPUT_KEY_STATE_CAP) return false; + snprintf(state->held[state->count++], sizeof(name), "%s", name); + return true; +} + +bool bongo_cat_sound_shortcut_down(const BongoCatSoundShortcutState *state, + const char *shortcut) { + if (!state || !shortcut || !*shortcut) return false; + const char *cursor = shortcut; + while (*cursor) { + const char *plus = strchr(cursor, '+'); + size_t length = plus ? (size_t)(plus - cursor) : strlen(cursor); + char token[BONGO_CAT_ID_CAP]; + if (!length || length >= sizeof(token)) return false; + memcpy(token, cursor, length); token[length] = '\0'; + bool found = false; + for (size_t i = 0; i < state->count && !found; ++i) + found = token_matches(token, state->held[i]); + if (!found) return false; + if (!plus) return true; + cursor = plus + 1; + } + return false; +} diff --git a/src/core/update_release.c b/src/core/update_release.c new file mode 100644 index 00000000..6e9c7590 --- /dev/null +++ b/src/core/update_release.c @@ -0,0 +1,119 @@ +#include "bongo_cat/update.h" + +#include +#include +#include + +#define RELEASE_PREFIX \ + "https://github.com/vladelaina/BongoCat/releases/tag/" +#define DOWNLOAD_PREFIX \ + "https://github.com/vladelaina/BongoCat/releases/download/" + +static bool copy_text(char *target, size_t capacity, yyjson_val *value, + bool required, bool truncate) { + if (!value || yyjson_is_null(value)) return !required; + if (!yyjson_is_str(value)) return false; + const char *text = yyjson_get_str(value); + size_t length = yyjson_get_len(value); + if (!text || strlen(text) != length || (required && !length)) return false; + if (length >= capacity) { + if (!truncate) return false; + length = capacity - 1; + } + memcpy(target, text, length); + target[length] = '\0'; + return true; +} + +static bool safe_url(const char *url, const char *prefix) { + if (!url || strncmp(url, prefix, strlen(prefix)) != 0) return false; + for (const unsigned char *at = (const unsigned char *)url; *at; ++at) + if (*at <= 0x20 || *at == '\\') return false; + return true; +} + +static bool copy_asset_url(yyjson_val *asset, const char *expected, + char *target, size_t capacity) { + yyjson_val *name_value = yyjson_obj_get(asset, "name"); + yyjson_val *url_value = yyjson_obj_get(asset, "browser_download_url"); + if (!yyjson_is_str(name_value) || !yyjson_is_str(url_value) || + strcmp(yyjson_get_str(name_value), expected) != 0) return false; + const char *url = yyjson_get_str(url_value); + const char *filename = url ? strrchr(url, '/') : NULL; + if (!safe_url(url, DOWNLOAD_PREFIX) || !filename || + strcmp(filename + 1, expected) != 0 || strlen(url) >= capacity) + return false; + snprintf(target, capacity, "%s", url); + return true; +} + +static bool read_assets(yyjson_val *root, const char *platform, + BongoCatUpdateRelease *release) { + yyjson_val *assets = yyjson_obj_get(root, "assets"); + if (!yyjson_is_arr(assets)) return false; + char installer[128] = {0}, portable[128] = {0}; + int installer_length = 0, portable_length = 0; + if (strncmp(platform, "windows-", 8) == 0) { + installer_length = snprintf(installer, sizeof(installer), + "BongoCat-%s-%s-setup.exe", release->version, platform); + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s-portable.exe", release->version, platform); + } else if (strcmp(platform, "linux-x64") == 0) { + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s.tar.gz", release->version, platform); + } else if (strcmp(platform, "macos-x64") == 0 || + strcmp(platform, "macos-arm64") == 0) { + portable_length = snprintf(portable, sizeof(portable), + "BongoCat-%s-%s.zip", release->version, platform); + } else return false; + if (installer_length < 0 || (size_t)installer_length >= sizeof(installer) || + portable_length < 0 || (size_t)portable_length >= sizeof(portable)) + return false; + size_t index, count; + yyjson_val *asset; + yyjson_arr_foreach(assets, index, count, asset) { + if (!yyjson_is_obj(asset)) continue; + if (installer[0] && !release->installer_url[0]) + copy_asset_url(asset, installer, release->installer_url, + sizeof(release->installer_url)); + if (!release->portable_url[0]) copy_asset_url(asset, portable, + release->portable_url, sizeof(release->portable_url)); + } + return true; +} + +bool bongo_cat_update_parse_release(const char *json, const char *platform, + BongoCatUpdateRelease *release, BongoCatError *error) { + if (!json || !platform || !release) return false; + memset(release, 0, sizeof(*release)); + yyjson_doc *document = yyjson_read(json, strlen(json), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *draft = yyjson_is_obj(root) + ? yyjson_obj_get(root, "draft") : NULL; + yyjson_val *prerelease = yyjson_is_obj(root) + ? yyjson_obj_get(root, "prerelease") : NULL; + bool valid = yyjson_is_obj(root) && yyjson_is_bool(draft) && + yyjson_is_bool(prerelease) && !yyjson_get_bool(draft) && + !yyjson_get_bool(prerelease); + char tag[BONGO_CAT_UPDATE_VERSION_CAP + 1] = {0}; + if (valid) valid = copy_text(tag, sizeof(tag), + yyjson_obj_get(root, "tag_name"), true, false); + const char *version = tag[0] == 'v' || tag[0] == 'V' ? tag + 1 : tag; + if (valid) valid = bongo_cat_update_version_valid(version); + if (valid) snprintf(release->version, sizeof(release->version), "%s", + version); + if (valid) valid = copy_text(release->release_url, + sizeof(release->release_url), yyjson_obj_get(root, "html_url"), true, + false) && + safe_url(release->release_url, RELEASE_PREFIX); + if (valid) { + yyjson_val *body = yyjson_obj_get(root, "body"); + if (body && !copy_text(release->notes, sizeof(release->notes), + body, false, true)) valid = false; + } + if (valid) valid = read_assets(root, platform, release); + yyjson_doc_free(document); + if (!valid) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "GitHub returned invalid BongoCat release metadata"); + return valid; +} diff --git a/src/core/update_version.c b/src/core/update_version.c new file mode 100644 index 00000000..672e16c9 --- /dev/null +++ b/src/core/update_version.c @@ -0,0 +1,138 @@ +#include "bongo_cat/update.h" + +#include +#include +#include + +typedef struct ParsedVersion { + uint64_t core[3]; + const char *prerelease; + size_t prerelease_length; + bool valid; +} ParsedVersion; + +static bool identifier_character(char value) { + unsigned char byte = (unsigned char)value; + return isalnum(byte) || value == '-'; +} + +static bool parse_number(const char **cursor, uint64_t *number) { + const char *start = *cursor; + if (!isdigit((unsigned char)*start)) return false; + if (*start == '0' && isdigit((unsigned char)start[1])) return false; + uint64_t value = 0; + while (isdigit((unsigned char)**cursor)) { + unsigned digit = (unsigned)(**cursor - '0'); + if (value > (UINT64_MAX - digit) / 10) return false; + value = value * 10 + digit; + (*cursor)++; + } + *number = value; + return true; +} + +static bool validate_identifiers(const char *value, size_t length) { + if (!length) return false; + size_t identifier_start = 0; + for (size_t i = 0; i <= length; ++i) { + if (i < length && value[i] != '.') { + if (!identifier_character(value[i])) return false; + continue; + } + if (i == identifier_start) return false; + bool numeric = true; + for (size_t j = identifier_start; j < i; ++j) + if (!isdigit((unsigned char)value[j])) numeric = false; + if (numeric && i - identifier_start > 1 && + value[identifier_start] == '0') return false; + identifier_start = i + 1; + } + return true; +} + +static ParsedVersion parse_version(const char *text) { + ParsedVersion parsed = {0}; + if (!text || !text[0]) return parsed; + const char *cursor = text; + if (*cursor == 'v' || *cursor == 'V') cursor++; + for (size_t i = 0; i < 3; ++i) { + if (!parse_number(&cursor, &parsed.core[i])) return parsed; + if (i < 2) { + if (*cursor != '.') return parsed; + cursor++; + } + } + if (*cursor == '-') { + const char *start = ++cursor; + while (*cursor && *cursor != '+') cursor++; + parsed.prerelease = start; + parsed.prerelease_length = (size_t)(cursor - start); + if (!validate_identifiers(start, parsed.prerelease_length)) + return (ParsedVersion){0}; + } + if (*cursor == '+') { + const char *start = ++cursor; + while (*cursor) cursor++; + if (!validate_identifiers(start, (size_t)(cursor - start))) + return (ParsedVersion){0}; + } + parsed.valid = *cursor == '\0'; + return parsed; +} + +static int compare_identifier(const char *left, size_t left_length, + const char *right, size_t right_length) { + bool left_numeric = true, right_numeric = true; + for (size_t i = 0; i < left_length; ++i) + if (!isdigit((unsigned char)left[i])) left_numeric = false; + for (size_t i = 0; i < right_length; ++i) + if (!isdigit((unsigned char)right[i])) right_numeric = false; + if (left_numeric != right_numeric) return left_numeric ? -1 : 1; + if (left_numeric && left_length != right_length) + return left_length < right_length ? -1 : 1; + size_t common = left_length < right_length ? left_length : right_length; + int compared = memcmp(left, right, common); + if (compared) return compared < 0 ? -1 : 1; + return left_length == right_length ? 0 : left_length < right_length ? -1 : 1; +} + +static int compare_prerelease(const ParsedVersion *left, + const ParsedVersion *right) { + if (!left->prerelease_length || !right->prerelease_length) { + if (left->prerelease_length == right->prerelease_length) return 0; + return left->prerelease_length ? -1 : 1; + } + size_t left_at = 0, right_at = 0; + while (left_at < left->prerelease_length && + right_at < right->prerelease_length) { + size_t left_end = left_at, right_end = right_at; + while (left_end < left->prerelease_length && + left->prerelease[left_end] != '.') left_end++; + while (right_end < right->prerelease_length && + right->prerelease[right_end] != '.') right_end++; + int compared = compare_identifier(left->prerelease + left_at, + left_end - left_at, right->prerelease + right_at, + right_end - right_at); + if (compared) return compared; + left_at = left_end + 1; + right_at = right_end + 1; + } + bool left_done = left_at > left->prerelease_length; + bool right_done = right_at > right->prerelease_length; + return left_done == right_done ? 0 : left_done ? -1 : 1; +} + +int bongo_cat_update_compare_versions(const char *left, const char *right) { + ParsedVersion parsed_left = parse_version(left); + ParsedVersion parsed_right = parse_version(right); + if (!parsed_left.valid || !parsed_right.valid) return 0; + for (size_t i = 0; i < 3; ++i) { + if (parsed_left.core[i] == parsed_right.core[i]) continue; + return parsed_left.core[i] < parsed_right.core[i] ? -1 : 1; + } + return compare_prerelease(&parsed_left, &parsed_right); +} + +bool bongo_cat_update_version_valid(const char *version) { + return parse_version(version).valid; +} diff --git a/src/core/utf8.c b/src/core/utf8.c index dfdc6b3b..e05440d3 100644 --- a/src/core/utf8.c +++ b/src/core/utf8.c @@ -45,7 +45,7 @@ bool bongo_cat_utf8_valid(const char *text) { return true; } -bool bongo_cat_utf8_normalize_legacy(const char *text, +bool bongo_cat_utf8_normalize_mver(const char *text, char *output, size_t capacity) { if (!text || !output || !capacity) return false; size_t length = strlen(text); diff --git a/src/live2d/cubism_bridge.cpp b/src/live2d/cubism_bridge.cpp index 3f86f2e3..d2257b69 100644 --- a/src/live2d/cubism_bridge.cpp +++ b/src/live2d/cubism_bridge.cpp @@ -1,26 +1,24 @@ #include "bongo_cat/file.h" -#include "bongo_cat/memory.h" #include "bongo_cat/model.h" -#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) || defined(CSM_TARGET_MAC_GL) #include #endif -#include "cubism_model.hpp" +#include "cubism_runtime.hpp" #include #include +#include +#include #include #include -#include #include #ifdef _WIN32 #include #endif - using Csm::CubismFramework; namespace { - class Allocator final : public Csm::ICubismAllocator { public: void *Allocate(const Csm::csmSizeType size) override { return std::malloc(size); } @@ -78,7 +76,7 @@ void release_file(Csm::csmByte *bytes) { std::free(bytes); } bool start_framework(BongoCatError *error) { if (runtime_count++) return true; -#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) || defined(CSM_TARGET_MAC_GL) glewExperimental = GL_TRUE; GLenum glew_result = glewInit(); glGetError(); @@ -89,7 +87,10 @@ bool start_framework(BongoCatError *error) { return false; } if (!glCreateShader || !glShaderSource || !glCompileShader || - !glGetShaderiv || !glCreateProgram || !glGenFramebuffers) { + !glGetShaderiv || !glCreateProgram || !glGenFramebuffers || + !glGenBuffers || !glBindBuffer || !glBufferData || !glDeleteBuffers || + !glGenVertexArrays || !glBindVertexArray || !glDeleteVertexArrays || + !glEnableVertexAttribArray || !glVertexAttribPointer) { runtime_count = 0; bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Required OpenGL 3.3 functions are unavailable"); @@ -119,43 +120,7 @@ void stop_framework() { } // namespace -struct BongoCatLive2D { - bongo_cat::NativeModel *model; - int width = 612; - int height = 354; -}; - -static bool restore_previous(BongoCatLive2D *runtime, - bongo_cat::NativeModel *previous, BongoCatError *primary) noexcept { - if (!previous) { runtime->model = nullptr; return true; } - BongoCatError restore_error = {}; - try { - if (previous->load_textures(&restore_error)) { - runtime->model = previous; - return true; - } - } catch (const std::bad_alloc &) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_MEMORY, - "Out of memory while restoring the previous model"); - } catch (const std::exception &exception) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_CUBISM, - "Previous model restore failed: %s", exception.what()); - } catch (...) { - bongo_cat_error_set(&restore_error, BONGO_CAT_ERROR_CUBISM, - "Previous model restore failed with an unknown exception"); - } - if (primary) { - char original[sizeof(primary->message)]; - std::snprintf(original, sizeof(original), "%s", primary->message); - bongo_cat_error_set(primary, primary->code, - "%s; restore failed: %s", original, restore_error.message); - } - delete previous; - runtime->model = nullptr; - return false; -} - -extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, +static BongoCatLive2D *create_runtime(const char *asset_root, BongoCatError *error) { resource_root = asset_root ? asset_root : ""; if (!start_framework(error)) return nullptr; @@ -167,76 +132,44 @@ extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, return runtime; } +extern "C" BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, + BongoCatError *error) { + return create_runtime(asset_root, error); +} + extern "C" void bongo_cat_live2d_destroy(BongoCatLive2D *runtime) { if (!runtime) return; + if (runtime->retired_count) SDL_Log("[runtime] Live2D resource handoff: " + "stage=retirement-flush queue=%u current_context=%p", + runtime->retired_count, (void *)SDL_GL_GetCurrentContext()); + for (unsigned i = 0; i < runtime->retired_count; ++i) + delete runtime->retired[i].model; delete runtime->model; delete runtime; stop_framework(); } -extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, - const char *directory, const char *setting, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { - if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; - bongo_cat::NativeModel *previous = runtime->model; - bongo_cat::NativeModel *model = nullptr; - bool previous_released = false; - try { - model = new(std::nothrow) bongo_cat::NativeModel(); - if (!model) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate Live2D model"); - return BONGO_CAT_ERROR_MEMORY; - } - if (render_options) model->set_render_options(*render_options); - if (!model->load(directory, setting, preset, error)) { - delete model; - return error ? error->code : BONGO_CAT_ERROR_CUBISM; - } - if (previous) { - previous_released = true; - previous->release_render_resources(); - glFinish(); - } - model->reshape(runtime->width, runtime->height); - if (!model->load_textures(error)) { - BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; - delete model; - glFinish(); - restore_previous(runtime, previous, error); - glFinish(); - bongo_cat_platform_trim_memory(); - return result; - } - delete previous; - runtime->model = model; - glFinish(); - bongo_cat_platform_trim_memory(); - return BONGO_CAT_OK; - } catch (const std::bad_alloc &) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Out of memory while loading the Live2D model"); - } catch (const std::exception &exception) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Live2D model load failed: %s", exception.what()); - } catch (...) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Live2D model load failed with an unknown exception"); - } - delete model; - if (previous_released) { - glFinish(); - restore_previous(runtime, previous, error); - glFinish(); - bongo_cat_platform_trim_memory(); - } - return error ? error->code : BONGO_CAT_ERROR_CUBISM; -} - extern "C" bool bongo_cat_live2d_ready(const BongoCatLive2D *runtime) { return runtime && runtime->model; } +extern "C" bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *runtime, + int *width, int *height) { + return runtime && runtime->model && + runtime->model->canvas_size(width, height); +} + +extern "C" bool bongo_cat_live2d_frame(const BongoCatLive2D *runtime, + BongoCatLive2DFrame *frame) { + return runtime && runtime->model && runtime->model->frame(frame); +} + +extern "C" bool bongo_cat_live2d_viewport(const BongoCatLive2D *runtime, + int *x, int *y, int *width, int *height) { + return runtime && runtime->model && + runtime->model->viewport(x, y, width, height); +} + extern "C" void bongo_cat_live2d_resize(BongoCatLive2D *runtime, int width, int height) { if (!runtime) return; if (width > 0 && height > 0) { @@ -254,26 +187,49 @@ extern "C" void bongo_cat_live2d_reshape(BongoCatLive2D *runtime, int width, int if (runtime->model) runtime->model->reshape(width, height); } extern "C" bool bongo_cat_live2d_update(BongoCatLive2D *runtime, float elapsed) { - return runtime && runtime->model && runtime->model->update(elapsed); + if (!runtime) return false; + bool changed = runtime->model && runtime->model->update(elapsed); + // Finish deferred releases even when the replacement model is static. + return changed || runtime->retired_count > 0; } extern "C" void bongo_cat_live2d_draw(BongoCatLive2D *runtime) { - if (runtime && runtime->model) runtime->model->draw(); + if (!runtime) return; + unsigned keep = 0, released = 0; + for (unsigned i = 0; i < runtime->retired_count; ++i) { + BongoCatRetiredModel item = runtime->retired[i]; + if (item.frames_remaining) item.frames_remaining--; + if (!item.frames_remaining) { + delete item.model; + released++; + } else runtime->retired[keep++] = item; + } + runtime->retired_count = keep; + if (released) SDL_Log("[runtime] Live2D resource handoff: " + "stage=retirement-complete released=%u queue=%u current_context=%p " + "gl_error=0x%x", released, keep, (void *)SDL_GL_GetCurrentContext(), + (unsigned)glGetError()); + if (runtime->model) runtime->model->draw(); } extern "C" void bongo_cat_live2d_set_mirror(BongoCatLive2D *runtime, bool mirror) { - if (runtime && runtime->model) runtime->model->set_mirror(mirror); -} + if (runtime && runtime->model) runtime->model->set_mirror(mirror); } extern "C" void bongo_cat_live2d_set_render_options(BongoCatLive2D *runtime, const BongoCatLive2DRenderOptions *options) { if (runtime && runtime->model && options) - runtime->model->set_render_options(*options); -} + runtime->model->set_render_options(*options); } extern "C" void bongo_cat_live2d_set_dragging(BongoCatLive2D *runtime, float x, float y) { - if (runtime && runtime->model) runtime->model->set_dragging(x, y); -} + if (runtime && runtime->model) runtime->model->set_dragging(x, y); } +extern "C" void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *runtime, + float x, float y) { + if (runtime && runtime->model) runtime->model->set_dragging(x, y, true); } extern "C" void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *runtime) { if (runtime && runtime->model) runtime->model->prepare_viewer_audit(); } +extern "C" bool bongo_cat_live2d_prepare_cover_capture( + BongoCatLive2D *runtime) { + return runtime && runtime->model && + runtime->model->prepare_cover_capture(); +} extern "C" bool bongo_cat_live2d_set_parameter(BongoCatLive2D *runtime, const char *id, float value) { return runtime && runtime->model && runtime->model->set_parameter(id, value); } @@ -285,13 +241,29 @@ extern "C" bool bongo_cat_live2d_parameter(BongoCatLive2D *runtime, const char * extern "C" bool bongo_cat_live2d_start_motion(BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->start_motion(group, index); } -extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int index) { - return runtime && runtime->model && runtime->model->set_expression(index); +extern "C" bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *runtime, + const char *group, int index) { + return runtime && runtime->model && + runtime->model->restore_motion_state(group, index); } +extern "C" bool bongo_cat_live2d_preview_motion(BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->preview_motion(group, index); } +extern "C" bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *runtime) { return runtime && runtime->model && runtime->model->restore_motion_preview(); } +extern "C" bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->commit_motion_preview(group, index); } +extern "C" bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *runtime, const char *group, int index) { return runtime && runtime->model && runtime->model->motion_selected(group, index); } +extern "C" bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *runtime, + const char *group, int index) { + return runtime && runtime->model && + runtime->model->motion_persistent(group, index); } +extern "C" bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *runtime, const char *group, int index) { + return runtime && runtime->model && runtime->model->motion_visible(group, index); } +extern "C" bool bongo_cat_live2d_motion_same_toggle( + const BongoCatLive2D *runtime, const char *left_group, int left_index, + const char *right_group, int right_index) { + return runtime && runtime->model && runtime->model->motion_same_toggle( + left_group, left_index, right_group, right_index); } +extern "C" bool bongo_cat_live2d_set_expression(BongoCatLive2D *runtime, int index) { + return runtime && runtime->model && runtime->model->set_expression(index); } extern "C" int bongo_cat_live2d_expression(const BongoCatLive2D *runtime) { - return runtime && runtime->model ? runtime->model->expression() : -1; -} -extern "C" bool bongo_cat_live2d_visual_state(const BongoCatLive2D *runtime, - BongoCatLive2DVisualState *state) { - return runtime && runtime->model && runtime->model->visual_state(state); -} + return runtime && runtime->model ? runtime->model->expression() : -1; } diff --git a/src/live2d/cubism_bridge_load.cpp b/src/live2d/cubism_bridge_load.cpp new file mode 100644 index 00000000..1df2ea95 --- /dev/null +++ b/src/live2d/cubism_bridge_load.cpp @@ -0,0 +1,89 @@ +#include "bongo_cat/model.h" +#if defined(CSM_TARGET_WIN_GL) || defined(CSM_TARGET_LINUX_GL) +#include +#endif +#include "cubism_runtime.hpp" + +#include +#include +#include +#include + +static void retire_previous(BongoCatLive2D *runtime, + bongo_cat::NativeModel *previous) { + if (!runtime || !previous) return; + if (runtime->retired_count == runtime->retired_capacity) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Live2D retirement queue reached its limit; releasing the oldest model"); + delete runtime->retired[0].model; + for (unsigned i = 1; i < runtime->retired_count; ++i) + runtime->retired[i - 1] = runtime->retired[i]; + runtime->retired_count--; + } + BongoCatRetiredModel *slot = &runtime->retired[runtime->retired_count++]; + slot->model = previous; + slot->frames_remaining = 3; +} + +extern "C" BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *runtime, + const char *directory, const char *setting, bool preset, + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { + if (!runtime) return BONGO_CAT_ERROR_ARGUMENT; + bongo_cat::NativeModel *previous = runtime->model; + bongo_cat::NativeModel *model = nullptr; + try { + model = new(std::nothrow) bongo_cat::NativeModel(); + if (!model) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate Live2D model"); + return BONGO_CAT_ERROR_MEMORY; + } + if (render_options) model->set_render_options(*render_options); + if (!model->load(directory, setting, preset, progress, userdata, error)) { + delete model; + return error ? error->code : BONGO_CAT_ERROR_CUBISM; + } + /* Keep the previous renderer alive until the replacement is complete. */ + model->reshape(runtime->width, runtime->height); + if (!model->load_textures(error, progress, userdata)) { + BongoCatResult result = error ? error->code : BONGO_CAT_ERROR_CUBISM; + delete model; + return result; + } + GLenum ready_error = glGetError(); + SDL_Log("[runtime] Live2D resource handoff: stage=replacement-ready " + "new_textures=%zu previous_textures=%zu current_window=%p " + "current_context=%p gl_error=0x%x", + model->texture_count(), previous ? previous->texture_count() : 0, + (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)ready_error); + if (progress) progress(userdata, 1.0f); + runtime->model = model; + retire_previous(runtime, previous); + SDL_Log("[runtime] Live2D resource handoff: stage=retirement-queued " + "previous=%d previous_textures=%zu queue=%u frames=%u", + previous != nullptr, + previous ? previous->texture_count() : 0, runtime->retired_count, + previous ? 3u : 0u); + GLenum retired_error = glGetError(); + SDL_Log("[runtime] Live2D resource handoff: stage=complete " + "new_textures=%zu current_window=%p current_context=%p gl_error=0x%x", + model->texture_count(), + (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)retired_error); + return BONGO_CAT_OK; + } catch (const std::bad_alloc &) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Out of memory while loading the Live2D model"); + } catch (const std::exception &exception) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Live2D model load failed: %s", exception.what()); + } catch (...) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Live2D model load failed with an unknown exception"); + } + delete model; + return error ? error->code : BONGO_CAT_ERROR_CUBISM; +} diff --git a/src/live2d/cubism_bridge_visual.cpp b/src/live2d/cubism_bridge_visual.cpp new file mode 100644 index 00000000..acc20b43 --- /dev/null +++ b/src/live2d/cubism_bridge_visual.cpp @@ -0,0 +1,6 @@ +#include "cubism_runtime.hpp" + +extern "C" bool bongo_cat_live2d_visual_state( + const BongoCatLive2D *runtime, BongoCatLive2DVisualState *state) { + return runtime && runtime->model && runtime->model->visual_state(state); +} diff --git a/src/live2d/cubism_core_profile.hpp b/src/live2d/cubism_core_profile.hpp new file mode 100644 index 00000000..cddc45db --- /dev/null +++ b/src/live2d/cubism_core_profile.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace bongo_cat { + +struct CoreProfileBuffers { + GLuint vao = 0; + GLuint buffers[3] = {}; + + CoreProfileBuffers() = default; + CoreProfileBuffers(const CoreProfileBuffers &) = delete; + CoreProfileBuffers &operator=(const CoreProfileBuffers &) = delete; + + void release() { + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(3, buffers); + vao = 0; + for (auto &buffer : buffers) buffer = 0; + } +}; + +// SDK callbacks use the buffers of the model currently being drawn. +class CoreProfileBinding { +public: + explicit CoreProfileBinding(CoreProfileBuffers &resources) + : resources_(resources), previous_(active_) { + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &previous_vao_); + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_); + if (!resources_.vao) glGenVertexArrays(1, &resources_.vao); + if (!resources_.buffers[0]) glGenBuffers(3, resources_.buffers); + glBindVertexArray(resources_.vao); + active_ = this; + } + + ~CoreProfileBinding() { + glBindVertexArray((GLuint)previous_vao_); + glBindBuffer(GL_ARRAY_BUFFER, (GLuint)previous_array_); + active_ = previous_; + } + + CoreProfileBinding(const CoreProfileBinding &) = delete; + CoreProfileBinding &operator=(const CoreProfileBinding &) = delete; + + static void attribute(unsigned slot, GLuint location, + const void *data, GLsizeiptr size) { + glBindBuffer(GL_ARRAY_BUFFER, active_->resources_.buffers[slot]); + glBufferData(GL_ARRAY_BUFFER, size, data, GL_STREAM_DRAW); + glVertexAttribPointer(location, 2, GL_FLOAT, GL_FALSE, + sizeof(GLfloat) * 2, nullptr); + } + + static void draw(GLsizei count, const GLushort *indices) { + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, active_->resources_.buffers[2]); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, count * sizeof(GLushort), + indices, GL_STREAM_DRAW); + glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_SHORT, nullptr); + } + +private: + CoreProfileBuffers &resources_; + CoreProfileBinding *previous_; + GLint previous_vao_ = 0; + GLint previous_array_ = 0; + inline static thread_local CoreProfileBinding *active_ = nullptr; +}; + +} // namespace bongo_cat diff --git a/src/live2d/cubism_expression.cpp b/src/live2d/cubism_expression.cpp new file mode 100644 index 00000000..09b25718 --- /dev/null +++ b/src/live2d/cubism_expression.cpp @@ -0,0 +1,78 @@ +#include "cubism_model.hpp" + +#include +#include +#include + +namespace bongo_cat { + +bool NativeModel::set_expression(int index) { + if (index == -1) { + expression_index_ = -1; + expression_frame_pending_ = false; + auto *entries = _expressionManager->GetCubismMotionQueueEntries(); + expression_clearing_ = entries && entries->GetSize() > 0; + if (!expression_clearing_) { + _expressionManager->StopAllMotions(); + return true; + } + for (Csm::csmUint32 i = 0; i < entries->GetSize(); ++i) { + Csm::CubismMotionQueueEntry *entry = entries->At(i); + if (!entry) continue; + Csm::ACubismMotion *motion = entry->GetCubismMotion(); + if (motion) { + float fade_out = motion->GetFadeOutTime(); + entry->SetFadeout(fade_out < 0.0f ? 0.0f : fade_out); + } + } + return true; + } + if (index < 0 || (size_t)index >= expression_names_.size()) return false; + auto found = expressions_.find(expression_names_[(size_t)index]); + if (found == expressions_.end()) return false; + Csm::CubismMotionQueueEntryHandle handle = _expressionManager->StartMotion( + found->second, false); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; + expression_clearing_ = false; + expression_index_ = index; + expression_frame_pending_ = true; + return true; +} + +void NativeModel::settle_pending_expression_for_cover() { + if (!expression_frame_pending_ || !_model || expression_index_ < 0 || + (size_t)expression_index_ >= expression_names_.size()) return; + auto found = expressions_.find(expression_names_[(size_t)expression_index_]); + if (found == expressions_.end()) { + expression_frame_pending_ = false; + return; + } + float fade_seconds = found->second->GetFadeInTime(); + if (!std::isfinite(fade_seconds) || fade_seconds < 0.0f) + fade_seconds = 0.0f; + /* Only the expression clock is advanced. The model baseline already + contains restored persistent motions, while idle effects stay put. */ + _model->LoadParameters(); + _expressionManager->UpdateMotion(_model, 0.0f); + if (fade_seconds > 0.0f) + _expressionManager->UpdateMotion(_model, fade_seconds); + expression_frame_pending_ = false; +} + +void NativeModel::expire_expression_fade() { + if (!expression_clearing_) return; + auto *entries = _expressionManager->GetCubismMotionQueueEntries(); + if (!entries || entries->GetSize() == 0) { + expression_clearing_ = false; + return; + } + for (Csm::csmUint32 i = 0; i < entries->GetSize(); ++i) { + Csm::CubismMotionQueueEntry *entry = entries->At(i); + if (entry && (entry->GetEndTime() < 0.0f || + entry->GetStateTime() < entry->GetEndTime())) return; + } + _expressionManager->StopAllMotions(); + expression_clearing_ = false; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model.hpp b/src/live2d/cubism_model.hpp index f296ddb9..c91b5aca 100644 --- a/src/live2d/cubism_model.hpp +++ b/src/live2d/cubism_model.hpp @@ -1,6 +1,10 @@ #ifndef BONGO_CAT_CUBISM_MODEL_HPP #define BONGO_CAT_CUBISM_MODEL_HPP +#ifdef CSM_TARGET_MAC_GL +#include "cubism_core_profile.hpp" +#endif + #include "bongo_cat/model.h" #include "bongo_cat/image.h" @@ -11,6 +15,7 @@ #include #include #include +#include #include #include @@ -19,32 +24,74 @@ namespace bongo_cat { bool validate_model_setting_json(const std::vector &json, const char *setting_file, BongoCatError *error); class ViewerLookUpdater; +class ParameterOverrideUpdater; class NativeModel final : public Csm::CubismUserModel { public: NativeModel(); ~NativeModel() override; bool load(const char *directory, const char *setting_file, bool direct_textures, + BongoCatLive2DLoadProgress progress, void *userdata, BongoCatError *error); - bool load_textures(BongoCatError *error); + bool load_textures(BongoCatError *error, + BongoCatLive2DLoadProgress progress, void *userdata); + size_t texture_count() const { return textures_.size(); } void release_render_resources(); + bool canvas_size(int *width, int *height) const; + bool frame(BongoCatLive2DFrame *frame) const; + bool viewport(int *x, int *y, int *width, int *height) const; void resize(int width, int height); void reshape(int width, int height); bool update(float delta_seconds); void draw(); void set_mirror(bool mirror); void set_render_options(const BongoCatLive2DRenderOptions &options); - void set_dragging(float x, float y); + void set_dragging(float x, float y, bool angle_z = false); void prepare_viewer_audit(); + bool prepare_cover_capture(); bool set_parameter(const char *id, float value); bool parameter(const char *id, float *minimum, float *maximum, float *value); bool start_motion(const char *group, int index); + bool restore_motion_state(const char *group, int index); + bool preview_motion(const char *group, int index); + bool restore_motion_preview(); + bool commit_motion_preview(const char *group, int index); + bool motion_selected(const char *group, int index) const; + bool motion_persistent(const char *group, int index) const; + bool motion_visible(const char *group, int index) const; + bool motion_same_toggle(const char *left_group, int left_index, + const char *right_group, int right_index) const; bool set_expression(int index); int expression() const { return expression_index_; } bool visual_state(BongoCatLive2DVisualState *state) const; -private: +public: using MotionMap = std::map; + using MotionSignatures = std::map; + struct MotionStateCurve { + std::string target, id; + float start = 0.0f, end = 0.0f; + float normal = 0.0f; + int parameter = -1; + int part = -1; + bool model_opacity = false; + }; + struct MotionState { + std::string group; + int index = -1; + std::vector curves; + bool self_contained = false; + }; +private: + friend class ParameterOverrideUpdater; + struct MotionRun { + Csm::CubismMotionQueueEntryHandle handle = + Csm::InvalidMotionQueueEntryHandleValue; + std::string key; + bool one_shot = false; + bool selected = false; + bool committed = false; + }; struct ModelBounds { float min_x = 0.0f; float min_y = 0.0f; @@ -52,23 +99,44 @@ class NativeModel final : public Csm::CubismUserModel { float max_y = 0.0f; bool valid = false; }; - struct LockMotion { - std::vector parameters; - std::vector initial_values; - bool enabled = false; - }; + struct DrawableBounds { ModelBounds bounds; float area; }; bool load_model(BongoCatError *error); void load_expressions(); void load_effects(); - void load_motions(); + void load_motions(BongoCatLive2DLoadProgress progress, void *userdata); void start_idle_motion(); ModelBounds capture_visible_bounds() const; + void prepare_expression_frame(); + void build_projection(Csm::CubismMatrix44 &projection, + int width, int height); + void apply_viewport_projection(Csm::CubismMatrix44 &projection) const; + void update_viewport(); void record_visible_state(Csm::CubismMatrix44 &projection); - void load_lock_motion(const std::string &key, + void capture_motion_preview(); + void restore_motion_preview_state(); + void load_motion_state(const std::string &key, const char *group, int index, const std::vector &bytes); - bool toggle_lock_motion(const std::string &key, Csm::ACubismMotion *motion); + void pair_motion_states(); + std::string motion_to_play(const std::string &key, bool *selected) const; + bool motion_is_persistent(const std::string &key) const; + void record_motion_run(Csm::CubismMotionQueueEntryHandle handle, + const std::string &key, bool selected, bool committed); + void stop_motion_runs(const std::string &key); + void expire_motion_runs(); + void clear_motion_runs(); + void expire_expression_fade(); + void settle_pending_expression_for_cover(); + void capture_parameter_baseline(); + void save_parameters(); + void add_parameter_override_updater(); + void apply_parameter_overrides(); + bool apply_motion_curve(const MotionStateCurve &curve, float value); + bool restore_motion_defaults(const std::string &key); + void select_motion(const std::string &key, bool selected); void release_textures(); void release_renderer(); + bool create_renderer(BongoCatError *error); + void update_mask_buffers(); void bind_textures(); std::vector read(const std::string &path, size_t maximum = (size_t)-1) const; @@ -76,23 +144,48 @@ class NativeModel final : public Csm::CubismUserModel { Csm::CubismModelSettingJson *setting_ = nullptr; MotionMap motions_; - std::map lock_motions_; + MotionSignatures motion_signatures_; + std::map motion_states_; + std::map motion_toggle_partners_; + std::map motion_toggle_owners_; + std::set selected_motion_keys_; MotionMap expressions_; std::vector expression_names_; std::vector textures_; std::vector texture_alpha_; + mutable std::vector> triangle_alpha_; + mutable std::vector bounds_scratch_; std::vector parameter_snapshot_; std::vector part_snapshot_; - std::vector pending_parameter_values_; - std::vector pending_parameters_; + std::vector parameter_override_values_; + std::vector parameter_baseline_values_; + std::vector parameter_save_scratch_; + std::vector parameter_overrides_; + std::vector motion_preview_parameters_; + std::vector motion_preview_parts_; Csm::csmVector eye_blink_ids_; Csm::csmVector lip_sync_ids_; std::string directory_; +#ifdef CSM_TARGET_MAC_GL + CoreProfileBuffers core_buffers_; +#endif int width_ = 612; int height_ = 354; int renderer_width_ = 0; int renderer_height_ = 0; + int mask_texture_limit_ = 0; + int mask_buffer_size_ = 0; + int mask_layout_divisions_ = 1; + int mask_last_width_ = 0; + int mask_last_height_ = 0; + int viewport_x_ = 0; + int viewport_y_ = 0; + int viewport_width_ = 612; + int viewport_height_ = 354; int expression_index_ = -1; + bool expression_clearing_ = false; + bool expression_frame_pending_ = false; + BongoCatLive2DFrame frame_{}; BongoCatLive2DVisualState visual_state_{}; bool visual_state_ready_ = false; bool motion_updated_ = false; @@ -101,13 +194,26 @@ class NativeModel final : public Csm::CubismUserModel { bool mirror_ = false; BongoCatLive2DRenderOptions render_options_{}; bool direct_textures_ = false; - bool external_parameters_dirty_ = false; + bool parameter_overrides_applied_ = false; std::vector idle_motion_keys_; + std::vector motion_runs_; + std::vector motion_finished_scratch_; ViewerLookUpdater *viewer_look_ = nullptr; int last_idle_motion_ = -1; float opacity_snapshot_ = -1.0f; + float motion_preview_opacity_ = 1.0f; + std::string motion_preview_key_; + bool motion_preview_selected_ = false; + bool motion_preview_active_ = false; + bool motion_preview_completed_ = false; }; +bool motion_toggle_pair(const NativeModel::MotionState &left, + const NativeModel::MotionState &right, bool *left_enabled); +bool motion_enables_state(const NativeModel::MotionState &state); +bool motion_run_clears_selection(bool one_shot, bool committed, + bool replacement_running); + } // namespace bongo_cat #endif diff --git a/src/live2d/cubism_model_bounds.cpp b/src/live2d/cubism_model_bounds.cpp index c3bb8330..f81c6c36 100644 --- a/src/live2d/cubism_model_bounds.cpp +++ b/src/live2d/cubism_model_bounds.cpp @@ -1,16 +1,38 @@ #include "cubism_model.hpp" +#include #include #include #include +#include namespace bongo_cat { +bool NativeModel::canvas_size(int *width, int *height) const { + if (!_model || !width || !height) return false; + const float canvas_width = _model->GetCanvasWidthPixel(); + const float canvas_height = _model->GetCanvasHeightPixel(); + const float maximum = (float)std::numeric_limits::max(); + if (!std::isfinite(canvas_width) || !std::isfinite(canvas_height) || + canvas_width <= 0.0f || canvas_height <= 0.0f || + canvas_width > maximum || canvas_height > maximum) + return false; + const int rounded_width = (int)std::lround(canvas_width); + const int rounded_height = (int)std::lround(canvas_height); + if (rounded_width <= 0 || rounded_height <= 0) return false; + *width = rounded_width; + *height = rounded_height; + return true; +} + NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { ModelBounds bounds; if (!_model) return bounds; - struct DrawableBounds { ModelBounds bounds; float area; }; - std::vector drawables; + auto &drawables = bounds_scratch_; + drawables.clear(); + const size_t drawable_count = (size_t)_model->GetDrawableCount(); + drawables.reserve(drawable_count); + triangle_alpha_.resize(drawable_count); size_t largest = 0; for (int i = 0; i < _model->GetDrawableCount(); ++i) { if (!_model->GetDrawableDynamicFlagIsVisible(i) || @@ -24,6 +46,11 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { const auto *uvs = _model->GetDrawableVertexUvs(i); const auto *indices = _model->GetDrawableVertexIndices(i); int index_count = _model->GetDrawableVertexIndexCount(i); + if (!indices || !uvs || index_count < 3) continue; + auto &triangle_alpha = triangle_alpha_[(size_t)i]; + const size_t triangle_count = (size_t)index_count / 3; + const bool sample_alpha = triangle_alpha.size() != triangle_count; + if (sample_alpha) triangle_alpha.assign(triangle_count, 0); ModelBounds drawable = {FLT_MAX, FLT_MAX, -FLT_MAX, -FLT_MAX, true}; float visible_area = 0.0f; auto alpha = [&](float u, float v) { @@ -35,16 +62,20 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { for (int j = 0; indices && uvs && j + 2 < index_count; j += 3) { int a = indices[j], b = indices[j + 1], c = indices[j + 2]; if (a >= count || b >= count || c >= count) continue; + // Cubism UVs and mesh indices are static; only positions deform. + if (sample_alpha) { + float u = (uvs[a].X + uvs[b].X + uvs[c].X) / 3.0f; + float v = (uvs[a].Y + uvs[b].Y + uvs[c].Y) / 3.0f; + triangle_alpha[(size_t)j / 3] = (unsigned char)std::max({alpha(u, v), + alpha((uvs[a].X + uvs[b].X) * 0.5f, (uvs[a].Y + uvs[b].Y) * 0.5f), + alpha((uvs[b].X + uvs[c].X) * 0.5f, (uvs[b].Y + uvs[c].Y) * 0.5f), + alpha((uvs[c].X + uvs[a].X) * 0.5f, (uvs[c].Y + uvs[a].Y) * 0.5f)}); + } + int opacity = triangle_alpha[(size_t)j / 3]; + if (opacity <= 8) continue; float ax = vertices[a * 2], ay = vertices[a * 2 + 1]; float bx = vertices[b * 2], by = vertices[b * 2 + 1]; float cx = vertices[c * 2], cy = vertices[c * 2 + 1]; - float u = (uvs[a].X + uvs[b].X + uvs[c].X) / 3.0f; - float v = (uvs[a].Y + uvs[b].Y + uvs[c].Y) / 3.0f; - int opacity = std::max({alpha(u, v), - alpha((uvs[a].X + uvs[b].X) * 0.5f, (uvs[a].Y + uvs[b].Y) * 0.5f), - alpha((uvs[b].X + uvs[c].X) * 0.5f, (uvs[b].Y + uvs[c].Y) * 0.5f), - alpha((uvs[c].X + uvs[a].X) * 0.5f, (uvs[c].Y + uvs[a].Y) * 0.5f)}); - if (opacity <= 8) continue; float area = std::fabs((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)); visible_area += area * opacity / 255.0f; drawable.min_x = std::min({drawable.min_x, ax, bx, cx}); @@ -68,9 +99,10 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { drawable.bounds.min_x - anchor.bounds.max_x, 0.0f}); float dy = std::max({anchor.bounds.min_y - drawable.bounds.max_y, drawable.bounds.min_y - anchor.bounds.max_y, 0.0f}); - bool nearby = dx <= padding && dy <= padding; + bool within_padding = dx <= padding && dy <= padding; bool major = drawable.area >= anchor.area * 0.05f; - if (drawable.area < anchor.area * 0.0005f || (!nearby && !major)) continue; + if (drawable.area < anchor.area * 0.0005f || + (!within_padding && !major)) continue; bounds.min_x = std::min(bounds.min_x, drawable.bounds.min_x); bounds.min_y = std::min(bounds.min_y, drawable.bounds.min_y); bounds.max_x = std::max(bounds.max_x, drawable.bounds.max_x); @@ -79,39 +111,110 @@ NativeModel::ModelBounds NativeModel::capture_visible_bounds() const { return bounds; } -void NativeModel::record_visible_state(Csm::CubismMatrix44 &projection) { - visual_state_.drawable_count = _model->GetDrawableCount(); - for (int i = 0; i < _model->GetDrawableCount(); ++i) { - if (_model->GetDrawableDynamicFlagIsVisible(i) && - _model->GetDrawableOpacity(i) > 0.001f) - ++visual_state_.drawable_visible; - if (_model->GetDrawableDynamicFlagVertexPositionsDidChange(i)) - ++visual_state_.drawable_vertex_changed; - } - visual_state_.offscreen_count = _model->GetOffscreenCount(); - for (int i = 0; i < _model->GetOffscreenCount(); ++i) - if (_model->GetOffscreenOpacity(i) > 0.001f) - ++visual_state_.offscreen_positive; - visual_state_.part_count = _model->GetPartCount(); - for (int i = 0; i < _model->GetPartCount(); ++i) - if (_model->GetPartOpacity(i) > 0.001f) ++visual_state_.part_positive; - ModelBounds bounds = capture_visible_bounds(); - if (!bounds.valid) return; - float x0 = projection.TransformX(bounds.min_x); - float x1 = projection.TransformX(bounds.max_x); - float y0 = projection.TransformY(bounds.min_y); - float y1 = projection.TransformY(bounds.max_y); - visual_state_.visible_min_x = std::min(x0, x1); - visual_state_.visible_max_x = std::max(x0, x1); - visual_state_.visible_min_y = std::min(y0, y1); - visual_state_.visible_max_y = std::max(y0, y1); - visual_state_.visible = true; +static float frame_margin(float overflow, float padding) { + /* Tiny mathematical overhangs are common at transparent mesh edges and + do not produce visible clipping. */ + return overflow > 0.03f ? (overflow + padding) * 0.5f : 0.0f; } -bool NativeModel::visual_state(BongoCatLive2DVisualState *state) const { - if (!state || !visual_state_ready_) return false; - *state = visual_state_; - return true; +void NativeModel::prepare_expression_frame() { + frame_ = BongoCatLive2DFrame{}; + /* Authored Mver calibration keeps its exact frame. Legacy conversions + opt into extra space because their inferred projection can clip geometry. + The shared content viewport keeps background/input layers aligned. */ + if (!_model || (render_options_.mver_projection && + !render_options_.auto_frame)) { + update_viewport(); + return; + } + const int parameter_count = _model->GetParameterCount(); + std::vector base((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) + base[(size_t)i] = _model->GetParameterValue(i); + + _model->Update(); + ModelBounds envelope = capture_visible_bounds(); + auto include = [&envelope](const ModelBounds &source) { + if (!source.valid) return; + if (!envelope.valid) { + envelope = source; + return; + } + envelope.min_x = std::min(envelope.min_x, source.min_x); + envelope.min_y = std::min(envelope.min_y, source.min_y); + envelope.max_x = std::max(envelope.max_x, source.max_x); + envelope.max_y = std::max(envelope.max_y, source.max_y); + }; + for (size_t i = 0; i < expression_names_.size(); ++i) { + auto found = expressions_.find(expression_names_[i]); + if (found == expressions_.end()) continue; + for (int parameter = 0; parameter < parameter_count; ++parameter) + _model->SetParameterValue(parameter, base[(size_t)parameter]); + auto *expression = static_cast( + found->second); + const auto parameters = expression->GetExpressionParameters(); + for (Csm::csmUint32 parameter = 0; + parameter < parameters.GetSize(); ++parameter) { + const auto &value = parameters[parameter]; + int index = _model->GetParameterIndex(value.ParameterId); + if (index < 0 || index >= parameter_count) continue; + switch (value.BlendType) { + case Csm::CubismExpressionMotion::Additive: + _model->AddParameterValue(index, value.Value); + break; + case Csm::CubismExpressionMotion::Multiply: + _model->MultiplyParameterValue(index, value.Value); + break; + case Csm::CubismExpressionMotion::Overwrite: + _model->SetParameterValue(index, value.Value); + break; + } + } + _model->Update(); + include(capture_visible_bounds()); + } + for (int parameter = 0; parameter < parameter_count; ++parameter) + _model->SetParameterValue(parameter, base[(size_t)parameter]); + _model->Update(); + + int reference_width = 0, reference_height = 0; + if (render_options_.mver_projection) { + reference_width = render_options_.reference_width; + reference_height = render_options_.reference_height; + } + if ((reference_width <= 0 || reference_height <= 0) && + !canvas_size(&reference_width, &reference_height)) { + update_viewport(); + return; + } + if (!envelope.valid) { + update_viewport(); + return; + } + Csm::CubismMatrix44 projection; + build_projection(projection, reference_width, reference_height); + float x0 = projection.TransformX(envelope.min_x); + float x1 = projection.TransformX(envelope.max_x); + float y0 = projection.TransformY(envelope.min_y); + float y1 = projection.TransformY(envelope.max_y); + float min_x = std::min(x0, x1), max_x = std::max(x0, x1); + float min_y = std::min(y0, y1), max_y = std::max(y0, y1); + if (!std::isfinite(min_x) || !std::isfinite(max_x) || + !std::isfinite(min_y) || !std::isfinite(max_y)) { + update_viewport(); + return; + } + float span = std::max(max_x - min_x, max_y - min_y); + float padding = std::max(0.04f, std::min(0.16f, span * 0.05f)); + float horizontal = std::max( + frame_margin(-1.0f - min_x, padding), + frame_margin(max_x - 1.0f, padding)); + /* Mirroring can move either horizontal overhang to the opposite side. */ + frame_.left = horizontal; + frame_.right = horizontal; + frame_.bottom = frame_margin(-1.0f - min_y, padding); + frame_.top = frame_margin(max_y - 1.0f, padding); + update_viewport(); } } // namespace bongo_cat diff --git a/src/live2d/cubism_model_idle.cpp b/src/live2d/cubism_model_idle.cpp new file mode 100644 index 00000000..d8d4d175 --- /dev/null +++ b/src/live2d/cubism_model_idle.cpp @@ -0,0 +1,22 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +void NativeModel::start_idle_motion() { + if (idle_motion_keys_.empty()) return; + constexpr int priority = 1; + if (!_motionManager->ReserveMotion(priority)) return; + int next = std::rand() % (int)idle_motion_keys_.size(); + if (next == last_idle_motion_) + next = (next + 1) % (int)idle_motion_keys_.size(); + last_idle_motion_ = next; + const std::string &key = idle_motion_keys_[(size_t)next]; + auto found = motions_.find(key); + if (found != motions_.end()) + _motionManager->StartMotionPriority(found->second, false, priority); +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model_load.cpp b/src/live2d/cubism_model_load.cpp index 035343d3..d2a73acd 100644 --- a/src/live2d/cubism_model_load.cpp +++ b/src/live2d/cubism_model_load.cpp @@ -2,10 +2,13 @@ #include "cubism_viewer_look.hpp" #include "bongo_cat/file.h" #include "bongo_cat/image.h" +#include "bongo_cat/json.h" +#include #include #include #include +#include #include #include #include @@ -13,17 +16,28 @@ #include #include #include -#include #include #include #include #include #include -#include -#include namespace bongo_cat { +struct TextureProgressContext { + BongoCatLive2DLoadProgress callback; + void *userdata; + float start; + float span; +}; + +static void texture_progress(void *userdata, float progress) { + auto *context = static_cast(userdata); + if (context && context->callback) + context->callback(context->userdata, + context->start + context->span * progress); +} + NativeModel::NativeModel() { _mocConsistency = true; _motionConsistency = true; @@ -61,7 +75,8 @@ std::string NativeModel::path(const char *relative) const { } bool NativeModel::load(const char *directory, const char *setting_file, - bool direct_textures, BongoCatError *error) { + bool direct_textures, BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { if (!directory || !setting_file) return false; visual_state_ready_ = false; visual_state_ = BongoCatLive2DVisualState{}; @@ -74,7 +89,24 @@ bool NativeModel::load(const char *directory, const char *setting_file, bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot read model setting: %s", setting_file); return false; } + bool normalized = false; + yyjson_doc *document = bongo_cat_model_json_parse( + reinterpret_cast(json.data()), json.size(), &normalized); + if (document && normalized) { + size_t size = 0; + char *canonical = yyjson_write(document, 0, &size); + if (!canonical) { + yyjson_doc_free(document); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot prepare model setting: %s", setting_file); + return false; + } + json.assign(canonical, canonical + size); + std::free(canonical); + } + yyjson_doc_free(document); if (!validate_model_setting_json(json, setting_file, error)) return false; + if (progress) progress(userdata, .10f); setting_ = new(std::nothrow) Csm::CubismModelSettingJson(json.data(), (Csm::csmSizeInt)json.size()); if (!setting_) { @@ -82,13 +114,17 @@ bool NativeModel::load(const char *directory, const char *setting_file, return false; } if (!load_model(error)) return false; + if (progress) progress(userdata, .25f); load_expressions(); + if (progress) progress(userdata, .31f); load_effects(); - load_motions(); + if (progress) progress(userdata, .35f); + load_motions(progress, userdata); Csm::csmMap layout; setting_->GetLayoutMap(layout); _modelMatrix->SetupFromLayout(layout); - _model->SaveParameters(); + save_parameters(); + if (progress) progress(userdata, .49f); return true; } bool NativeModel::load_model(BongoCatError *error) { @@ -103,8 +139,13 @@ bool NativeModel::load_model(BongoCatError *error) { bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, "Cubism rejected moc3: %s", name); return false; } - pending_parameter_values_.resize((size_t)_model->GetParameterCount()); - pending_parameters_.resize((size_t)_model->GetParameterCount()); + const size_t parameter_count = (size_t)_model->GetParameterCount(); + parameter_override_values_.assign(parameter_count, 0.0f); + parameter_baseline_values_.resize(parameter_count); + parameter_overrides_.assign(parameter_count, 0); + for (size_t i = 0; i < parameter_count; ++i) + parameter_baseline_values_[i] = _model->GetParameterValue((int)i); + parameter_overrides_applied_ = false; return true; } void NativeModel::load_expressions() { @@ -115,7 +156,8 @@ void NativeModel::load_expressions() { if (bytes.empty()) continue; Csm::ACubismMotion *motion = LoadExpression(bytes.data(), (Csm::csmSizeInt)bytes.size(), name); - if (!motion) continue; expressions_[name] = motion; + if (!motion) continue; + expressions_[name] = motion; expression_names_[(size_t)i] = name; } if (!expressions_.empty()) @@ -141,6 +183,25 @@ void NativeModel::load_effects() { } viewer_look_ = CSM_NEW ViewerLookUpdater(*_model); _updateScheduler.AddUpdatableList(viewer_look_); + _breath = Csm::CubismBreath::Create(); + if (_breath) { + Csm::csmVector parameters; + Csm::CubismIdManager *ids = Csm::CubismFramework::GetIdManager(); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleX"), 0.0f, 15.0f, 6.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleY"), 0.0f, 8.0f, 3.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamAngleZ"), 0.0f, 10.0f, 5.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamBodyAngleX"), 0.0f, 4.0f, 15.5345f, 0.5f)); + parameters.PushBack(Csm::CubismBreath::BreathParameterData( + ids->GetId("ParamBreath"), 0.5f, 0.5f, 3.2345f, 0.5f)); + _breath->SetParameters(parameters); + _updateScheduler.AddUpdatableList( + CSM_NEW Csm::CubismBreathUpdater(*_breath)); + } + add_parameter_override_updater(); for (int i = 0; i < setting_->GetEyeBlinkParameterCount(); ++i) eye_blink_ids_.PushBack(setting_->GetEyeBlinkParameterId(i)); for (int i = 0; i < setting_->GetLipSyncParameterCount(); ++i) @@ -153,11 +214,24 @@ void NativeModel::load_effects() { _updateScheduler.SortUpdatableList(); } -void NativeModel::load_motions() { +void NativeModel::load_motions(BongoCatLive2DLoadProgress progress, + void *userdata) { idle_motion_keys_.clear(); + motion_signatures_.clear(); + motion_states_.clear(); + motion_toggle_partners_.clear(); + motion_toggle_owners_.clear(); + selected_motion_keys_.clear(); + clear_motion_runs(); + int total = 0, completed = 0; + for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); + ++group_index) + total += setting_->GetMotionCount(setting_->GetMotionGroupName(group_index)); for (int group_index = 0; group_index < setting_->GetMotionGroupCount(); ++group_index) { const char *group = setting_->GetMotionGroupName(group_index); for (int i = 0; i < setting_->GetMotionCount(group); ++i) { + if (progress) progress(userdata, .35f + .14f * + (float)(++completed) / (float)(total > 0 ? total : 1)); auto bytes = read(path(setting_->GetMotionFileName(group, i))); if (bytes.empty()) continue; std::string key = std::string(group) + "_" + std::to_string(i); @@ -167,81 +241,44 @@ void NativeModel::load_motions() { if (!motion) continue; motion->SetEffectIds(eye_blink_ids_, lip_sync_ids_); motions_[key] = motion; + load_motion_state(key, group, i, bytes); if (std::strcmp(group, "Idle") == 0) idle_motion_keys_.push_back(key); - size_t suffix = std::strlen(group); - if (suffix >= 5 && std::strcmp(group + suffix - 5, "_lock") == 0) - load_lock_motion(key, bytes); } } + pair_motion_states(); _motionManager->StopAllMotions(); } -void NativeModel::load_lock_motion(const std::string &key, - const std::vector &bytes) { - yyjson_doc *document = yyjson_read( - reinterpret_cast(bytes.data()), bytes.size(), 0); - yyjson_val *root = document ? yyjson_doc_get_root(document) : nullptr; - yyjson_val *curves = yyjson_is_obj(root) ? yyjson_obj_get(root, "Curves") : nullptr; - LockMotion lock; - size_t index, count; - yyjson_val *curve; - if (yyjson_is_arr(curves)) yyjson_arr_foreach(curves, index, count, curve) { - const char *target = yyjson_get_str(yyjson_obj_get(curve, "Target")); - const char *id = yyjson_get_str(yyjson_obj_get(curve, "Id")); - if (!target || std::strcmp(target, "Parameter") != 0 || !id) continue; - Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); - int parameter = _model->GetParameterIndex(handle); - if (parameter < 0 || parameter >= _model->GetParameterCount()) continue; - if (std::find(lock.parameters.begin(), lock.parameters.end(), parameter) == - lock.parameters.end()) lock.parameters.push_back(parameter); - } - if (document) yyjson_doc_free(document); - if (!lock.parameters.empty()) lock_motions_[key] = std::move(lock); -} - -bool NativeModel::load_textures(BongoCatError *error) { +bool NativeModel::load_textures(BongoCatError *error, + BongoCatLive2DLoadProgress progress, void *userdata) { release_textures(); - textures_.assign((size_t)setting_->GetTextureCount(), 0); - texture_alpha_.assign((size_t)setting_->GetTextureCount(), {}); - for (int i = 0; i < setting_->GetTextureCount(); ++i) { + int count = setting_->GetTextureCount(); + textures_.assign((size_t)count, 0); + texture_alpha_.assign((size_t)count, {}); + TextureProgressContext texture_context = {progress, userdata, .50f, + .45f / (float)(count > 0 ? count : 1)}; + for (int i = 0; i < count; ++i) { + texture_context.start = .50f + .45f * (float)i / + (float)(count > 0 ? count : 1); textures_[(size_t)i] = bongo_cat_image_texture_model( path(setting_->GetTextureFileName(i)).c_str(), direct_textures_, - nullptr, nullptr, &texture_alpha_[(size_t)i], error); + nullptr, nullptr, &texture_alpha_[(size_t)i], + progress ? texture_progress : nullptr, &texture_context, error); if (!textures_[(size_t)i]) { release_textures(); return false; } + if (progress) progress(userdata, .50f + .45f * (float)(i + 1) / + (float)(count > 0 ? count : 1)); } + prepare_expression_frame(); release_renderer(); - CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + if (!create_renderer(error)) { + release_textures(); + return false; + } renderer_width_ = width_; renderer_height_ = height_; - bind_textures(); return true; } -void NativeModel::release_textures() { - if (!textures_.empty()) - glDeleteTextures((GLsizei)textures_.size(), textures_.data()); - textures_.clear(); - texture_alpha_.clear(); -} -void NativeModel::release_renderer() { - DeleteRenderer(); - renderer_width_ = 0; - renderer_height_ = 0; -} - -void NativeModel::release_render_resources() { - release_textures(); - release_renderer(); -} - -void NativeModel::bind_textures() { - auto *renderer = GetRenderer(); - if (!renderer) return; - for (size_t i = 0; i < textures_.size(); ++i) - if (textures_[i]) renderer->BindTexture((Csm::csmInt32)i, textures_[i]); - renderer->IsPremultipliedAlpha(false); -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_model_renderer.cpp b/src/live2d/cubism_model_renderer.cpp new file mode 100644 index 00000000..f46095fc --- /dev/null +++ b/src/live2d/cubism_model_renderer.cpp @@ -0,0 +1,166 @@ +#include "cubism_model.hpp" +#include "bongo_cat/gl_api.h" + +#include +#include +#include +#include + +namespace bongo_cat { + +static size_t mask_combination_count(Csm::csmInt32 object_count, + const Csm::csmInt32 *mask_counts, const Csm::csmInt32 *const *masks) { + std::set> combinations; + for (Csm::csmInt32 i = 0; i < object_count; ++i) { + if (mask_counts[i] <= 0) continue; + std::vector ids(masks[i], masks[i] + mask_counts[i]); + // Cubism shares a clipping context for the same IDs in any order. + std::sort(ids.begin(), ids.end()); + combinations.insert(std::move(ids)); + } + // Include hidden objects so expressions cannot exceed the allocated capacity. + return combinations.size(); +} + +static Csm::csmInt32 mask_buffer_count(size_t count) { + if (count <= (size_t)Csm::Rendering::ClippingMaskMaxCountOnDefault) return 1; + const size_t capacity = Csm::Rendering::ClippingMaskMaxCountOnMultiRenderTexture; + return (Csm::csmInt32)(1 + (count - 1) / capacity); +} + +void NativeModel::bind_textures() { + auto *renderer = GetRenderer(); + if (!renderer) return; + for (size_t i = 0; i < textures_.size(); ++i) + if (textures_[i]) + renderer->BindTexture((Csm::csmInt32)i, textures_[i]); + renderer->IsPremultipliedAlpha(true); +} + +void NativeModel::release_textures() { + if (!textures_.empty()) + glDeleteTextures((GLsizei)textures_.size(), textures_.data()); + textures_.clear(); + texture_alpha_.clear(); + triangle_alpha_.clear(); +} + +void NativeModel::release_renderer() { + DeleteRenderer(); +#ifdef CSM_TARGET_MAC_GL + core_buffers_.release(); +#endif + renderer_width_ = 0; + renderer_height_ = 0; + mask_texture_limit_ = 0; + mask_buffer_size_ = 0; + mask_layout_divisions_ = 1; + mask_last_width_ = mask_last_height_ = 0; +} + +void NativeModel::release_render_resources() { + release_textures(); + release_renderer(); +} + +void NativeModel::update_mask_buffers() { + if (width_ == mask_last_width_ && height_ == mask_last_height_) return; + auto *renderer = GetRenderer(); + if (!renderer || !_model || mask_texture_limit_ <= 0) return; + mask_last_width_ = width_; + mask_last_height_ = height_; + const bool drawable_masks = _model->IsUsingMasking(); + const bool offscreen_masks = _model->IsUsingMaskingForOffscreen(); + if (!drawable_masks && !offscreen_masks) return; + + // Account for the actual atlas subdivision, including hidden masks. + const int extent = std::min(std::max(width_, height_), mask_texture_limit_); + const int requested = std::max(512, mask_layout_divisions_ * extent); + const int size = std::min(mask_texture_limit_, ((requested + 511) / 512) * 512); + if (size == mask_buffer_size_) return; + // Grow immediately, but avoid grow/shrink churn near a size boundary. + if (size < mask_buffer_size_ && requested > mask_buffer_size_ - 768) return; + + GLint previous_texture = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture); + auto prepare = [size, &previous_texture]( + Csm::Rendering::CubismRenderTarget_OpenGLES2 *buffer) { + const bool was_bound = previous_texture != 0 && + (GLuint)previous_texture == buffer->GetColorBuffer(); + // Allocate now so the SDK cannot replace the filtering on first draw. + buffer->CreateRenderTarget((Csm::csmUint32)size, (Csm::csmUint32)size); + if (was_bound) previous_texture = (GLint)buffer->GetColorBuffer(); + glBindTexture(GL_TEXTURE_2D, buffer->GetColorBuffer()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + }; + if (drawable_masks) { + renderer->SetDrawableClippingMaskBufferSize((float)size, (float)size); + for (int i = 0; i < renderer->GetDrawableRenderTextureCount(); ++i) + prepare(renderer->GetDrawableMaskBuffer(i)); + } + if (offscreen_masks) { + renderer->SetOffscreenClippingMaskBufferSize((float)size, (float)size); + for (int i = 0; i < renderer->GetOffscreenRenderTextureCount(); ++i) + prepare(renderer->GetOffscreenMaskBuffer(i)); + } + glBindTexture(GL_TEXTURE_2D, (GLuint)previous_texture); + mask_buffer_size_ = size; +} + +bool NativeModel::create_renderer(BongoCatError *error) { + if (!SDL_GL_GetCurrentContext()) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot create the Live2D renderer without an OpenGL context"); + return false; + } + if (!bongo_cat_gl_clear_errors()) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot clear the OpenGL error state before creating the Live2D renderer"); + return false; + } +#ifdef CSM_TARGET_MAC_GL + CoreProfileBinding binding(core_buffers_); +#endif + const size_t drawable_combinations = mask_combination_count( + _model->GetDrawableCount(), _model->GetDrawableMaskCounts(), + _model->GetDrawableMasks()); + const size_t offscreen_combinations = mask_combination_count( + _model->GetOffscreenCount(), _model->GetOffscreenMaskCounts(), + _model->GetOffscreenMasks()); + const Csm::csmInt32 buffer_count = std::max( + mask_buffer_count(drawable_combinations), mask_buffer_count(offscreen_combinations)); + CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_, + buffer_count); + auto *renderer = GetRenderer(); + if (renderer) { + GLint texture_limit = 0; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &texture_limit); + mask_texture_limit_ = std::min(4096, (int)texture_limit); + const size_t per_buffer = (std::max(drawable_combinations, + offscreen_combinations) + (size_t)buffer_count - 1) / (size_t)buffer_count; + mask_layout_divisions_ = per_buffer <= 4 ? 1 : (per_buffer <= 16 ? 2 : 3); + const uint64_t total_buffers = (uint64_t)buffer_count * + ((drawable_combinations > 0 ? 1u : 0u) + + (offscreen_combinations > 0 ? 1u : 0u)); + // Target 32 MiB of RGBA8 masks per model, retaining a 256px floor. + // This excludes model atlases, blend targets, and driver overhead. + constexpr uint64_t mask_budget = 32ull * 1024 * 1024; + while (mask_texture_limit_ > 256 && total_buffers * 4 * + (uint64_t)mask_texture_limit_ * (uint64_t)mask_texture_limit_ > mask_budget) + mask_texture_limit_ = std::max(256, mask_texture_limit_ - 256); + bind_textures(); + update_mask_buffers(); + } + GLenum renderer_error = glGetError(); + if (renderer && renderer_error == GL_NO_ERROR) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot create the Live2D renderer (OpenGL 0x%x)", + (unsigned)renderer_error); + release_renderer(); + return false; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_model_update.cpp b/src/live2d/cubism_model_update.cpp index 03bc7a26..1f16c128 100644 --- a/src/live2d/cubism_model_update.cpp +++ b/src/live2d/cubism_model_update.cpp @@ -8,31 +8,36 @@ #include #include #include +#include #include #include -#include - namespace bongo_cat { void NativeModel::resize(int width, int height) { if (width <= 0 || height <= 0) return; width_ = width; height_ = height; + update_viewport(); if (!_model || (width == renderer_width_ && height == renderer_height_)) return; if (!_model->IsBlendModeEnabled()) { renderer_width_ = width_; renderer_height_ = height_; return; } - DeleteRenderer(); - CreateRenderer((Csm::csmUint32)width_, (Csm::csmUint32)height_); + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Live2D render target resized in place: %dx%d -> %dx%d", + renderer_width_, renderer_height_, width_, height_); + SetRenderTargetSize((Csm::csmUint32)width_, (Csm::csmUint32)height_); renderer_width_ = width_; renderer_height_ = height_; - bind_textures(); } void NativeModel::reshape(int width, int height) { - if (width > 0 && height > 0) { width_ = width; height_ = height; } + if (width > 0 && height > 0) { + width_ = width; + height_ = height; + update_viewport(); + } } template @@ -50,20 +55,19 @@ static bool changed(std::vector &snapshot, int count, Getter value) { bool NativeModel::update(float delta_seconds) { if (!_model || delta_seconds <= 0.0f) return false; if (delta_seconds > 0.25f) delta_seconds = 0.25f; - external_parameters_dirty_ = false; motion_updated_ = suppress_eye_blink_; _model->LoadParameters(); - for (int i = 0; i < _model->GetParameterCount(); ++i) { - if (!pending_parameters_[(size_t)i]) continue; - _model->SetParameterValue(i, pending_parameter_values_[(size_t)i]); - pending_parameters_[(size_t)i] = 0; - } + parameter_overrides_applied_ = false; if (automatic_idle_ && _motionManager->IsFinished()) start_idle_motion(); else if (!_motionManager->IsFinished()) motion_updated_ = _motionManager->UpdateMotion(_model, delta_seconds); - _model->SaveParameters(); + expire_motion_runs(); + save_parameters(); _updateScheduler.OnLateUpdate(_model, delta_seconds); + expression_frame_pending_ = false; + expire_expression_fade(); + apply_parameter_overrides(); _opacity = _model->GetModelOpacity(); _model->Update(); bool result = changed(parameter_snapshot_, _model->GetParameterCount(), @@ -75,11 +79,9 @@ bool NativeModel::update(float delta_seconds) { return result; } -void NativeModel::draw() { - if (!_model || width_ <= 0 || height_ <= 0) return; - auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); - manager->BeginFrameProcess(); - Csm::CubismMatrix44 projection; +void NativeModel::build_projection(Csm::CubismMatrix44 &projection, + int width, int height) { + if (!_model || width <= 0 || height <= 0) return; if (render_options_.mver_projection) { float aspect = (float)render_options_.reference_width / (float)render_options_.reference_height; @@ -87,12 +89,13 @@ void NativeModel::draw() { render_options_.projection_scale, render_options_.projection_scale * aspect); projection.Translate(render_options_.offset_x, render_options_.offset_y); - } else if (_model->GetCanvasWidth() > 1.0f && width_ < height_) { + } else if (_model->GetCanvasWidth() > 1.0f && width < height) { _modelMatrix->SetWidth(2.0f); projection.Scale(mirror_ ? -1.0f : 1.0f, - (float)width_ / (float)height_); + (float)width / (float)height); } else { - projection.Scale((mirror_ ? -1.0f : 1.0f) * (float)height_ / (float)width_, 1.0f); + projection.Scale((mirror_ ? -1.0f : 1.0f) * + (float)height / (float)width, 1.0f); } projection.MultiplyByMatrix(_modelMatrix); if (render_options_.mver_projection) { @@ -105,6 +108,19 @@ void NativeModel::draw() { matrix[4] *= mver_core_canvas_scale; matrix[5] *= mver_core_canvas_scale; } +} + +void NativeModel::draw() { + if (!_model || width_ <= 0 || height_ <= 0) return; +#ifdef CSM_TARGET_MAC_GL + CoreProfileBinding binding(core_buffers_); +#endif + update_mask_buffers(); + auto *manager = Csm::Rendering::CubismOffscreenManager_OpenGLES2::GetInstance(); + manager->BeginFrameProcess(); + Csm::CubismMatrix44 projection; + build_projection(projection, viewport_width_, viewport_height_); + apply_viewport_projection(projection); visual_state_ = BongoCatLive2DVisualState{}; visual_state_.fit_scale = 1.0f; visual_state_.mver_projection = render_options_.mver_projection; @@ -122,21 +138,37 @@ void NativeModel::set_render_options(const BongoCatLive2DRenderOptions &options) render_options_ = options; } -void NativeModel::set_dragging(float x, float y) { +void NativeModel::set_dragging(float x, float y, bool angle_z) { if (!viewer_look_) return; x = std::max(-1.0f, std::min(1.0f, x)); y = std::max(-1.0f, std::min(1.0f, y)); - viewer_look_->set_target(x, y); - external_parameters_dirty_ = true; + viewer_look_->set_target(x, y, angle_z); } void NativeModel::prepare_viewer_audit() { + if (!_model) return; automatic_idle_ = false; suppress_eye_blink_ = true; _motionManager->StopAllMotions(); - for (int i = 0; i < _model->GetParameterCount(); ++i) + clear_motion_runs(); + std::fill(parameter_overrides_.begin(), parameter_overrides_.end(), 0); + std::fill(parameter_override_values_.begin(), + parameter_override_values_.end(), 0.0f); + parameter_overrides_applied_ = false; + for (int i = 0; i < _model->GetParameterCount(); ++i) { _model->SetParameterValue(i, _model->GetParameterDefaultValue(i)); - _model->SaveParameters(); + } + save_parameters(); +} + +bool NativeModel::prepare_cover_capture() { + if (!_model) return false; + settle_pending_expression_for_cover(); + apply_parameter_overrides(); + /* Persistent motions are already stored in the Cubism baseline. Updating + drawables does not advance idle motion, physics, breathing, or blinking. */ + _model->Update(); + return true; } bool NativeModel::set_parameter(const char *id, float value) { @@ -144,10 +176,11 @@ bool NativeModel::set_parameter(const char *id, float value) { Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); int index = _model->GetParameterIndex(handle); if (index < 0 || index >= _model->GetParameterCount()) return false; - _model->SetParameterValue(index, value); - pending_parameter_values_[(size_t)index] = value; - pending_parameters_[(size_t)index] = 1; - external_parameters_dirty_ = true; + parameter_override_values_[(size_t)index] = value; + parameter_overrides_[(size_t)index] = 1; + if (parameter_overrides_applied_) + _model->SetParameterValue(index, value); + else apply_parameter_overrides(); return true; } @@ -162,73 +195,4 @@ bool NativeModel::parameter(const char *id, float *minimum, float *maximum, floa return true; } -bool NativeModel::start_motion(const char *group, int index) { - if (!group || index < 0) return false; - std::string key = std::string(group) + "_" + std::to_string(index); - auto found = motions_.find(key); - if (found == motions_.end()) return false; - if (lock_motions_.find(key) != lock_motions_.end()) - return toggle_lock_motion(key, found->second); - constexpr int priority = 2; - if (!_motionManager->ReserveMotion(priority)) return false; - _motionManager->StartMotionPriority(found->second, false, priority); - return true; -} - -void NativeModel::start_idle_motion() { - if (idle_motion_keys_.empty()) return; - constexpr int priority = 1; - if (!_motionManager->ReserveMotion(priority)) return; - int next = std::rand() % (int)idle_motion_keys_.size(); - if (next == last_idle_motion_) next = (next + 1) % (int)idle_motion_keys_.size(); - last_idle_motion_ = next; - const std::string &key = idle_motion_keys_[(size_t)next]; - auto found = motions_.find(key); - if (found != motions_.end()) - _motionManager->StartMotionPriority(found->second, false, priority); -} - -bool NativeModel::toggle_lock_motion(const std::string &key, - Csm::ACubismMotion *motion) { - LockMotion &lock = lock_motions_.at(key); - if (lock.enabled) { - _motionManager->StopAllMotions(); - _motionManager->UpdateMotion(_model, 0.0f); - for (size_t i = 0; i < lock.parameters.size(); ++i) { - int parameter = lock.parameters[i]; - float value = lock.initial_values[i]; - pending_parameter_values_[(size_t)parameter] = value; - pending_parameters_[(size_t)parameter] = 1; - } - lock.initial_values.clear(); - lock.enabled = false; - external_parameters_dirty_ = true; - return true; - } - constexpr int priority = 2; - if (!_motionManager->ReserveMotion(priority)) return false; - lock.initial_values.reserve(lock.parameters.size()); - for (int parameter : lock.parameters) - lock.initial_values.push_back(_model->GetParameterValue(parameter)); - lock.enabled = true; - _motionManager->StartMotionPriority(motion, false, priority); - return true; -} - -bool NativeModel::set_expression(int index) { - if (index == -1) { - _expressionManager->StopAllMotions(); - expression_index_ = -1; - return true; - } - if (index < 0 || (size_t)index >= expression_names_.size()) return false; - auto found = expressions_.find(expression_names_[(size_t)index]); - if (found == expressions_.end()) return false; - Csm::CubismMotionQueueEntryHandle handle = - _expressionManager->StartMotion(found->second, false); - if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; - expression_index_ = index; - return true; -} - } // namespace bongo_cat diff --git a/src/live2d/cubism_model_validate.cpp b/src/live2d/cubism_model_validate.cpp index 82f154ce..a92b5460 100644 --- a/src/live2d/cubism_model_validate.cpp +++ b/src/live2d/cubism_model_validate.cpp @@ -24,7 +24,8 @@ bool safe_path_value(yyjson_val *value) { bool optional_path(yyjson_val *object, const char *key) { yyjson_val *value = yyjson_obj_get(object, key); - return !value || safe_path_value(value); + return !value || (yyjson_is_str(value) && !yyjson_get_str(value)[0]) || + safe_path_value(value); } bool optional_number(yyjson_val *object, const char *key) { diff --git a/src/live2d/cubism_model_viewport.cpp b/src/live2d/cubism_model_viewport.cpp new file mode 100644 index 00000000..2442278a --- /dev/null +++ b/src/live2d/cubism_model_viewport.cpp @@ -0,0 +1,102 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +void NativeModel::update_viewport() { + float horizontal = 1.0f + frame_.left + frame_.right; + float vertical = 1.0f + frame_.top + frame_.bottom; + if (!std::isfinite(horizontal) || !std::isfinite(vertical) || + horizontal <= 0.0f || vertical <= 0.0f || width_ <= 0 || height_ <= 0) { + viewport_x_ = viewport_y_ = 0; + viewport_width_ = std::max(1, width_); + viewport_height_ = std::max(1, height_); + return; + } + float content_width = width_ / horizontal; + float content_height = height_ / vertical; + int left = (int)std::lround(frame_.left * content_width); + int right = (int)std::lround(frame_.right * content_width); + int bottom = (int)std::lround(frame_.bottom * content_height); + int top = (int)std::lround(frame_.top * content_height); + viewport_x_ = std::max(0, std::min(width_ - 1, left)); + viewport_y_ = std::max(0, std::min(height_ - 1, bottom)); + viewport_width_ = std::max(1, + width_ - viewport_x_ - std::max(0, right)); + viewport_height_ = std::max(1, + height_ - viewport_y_ - std::max(0, top)); +} + +bool NativeModel::frame(BongoCatLive2DFrame *frame) const { + if (!_model || !frame) return false; + *frame = frame_; + return true; +} + +bool NativeModel::viewport(int *x, int *y, int *width, int *height) const { + if (!_model || !x || !y || !width || !height) return false; + *x = viewport_x_; + *y = viewport_y_; + *width = viewport_width_; + *height = viewport_height_; + return true; +} + +void NativeModel::apply_viewport_projection( + Csm::CubismMatrix44 &projection) const { + if (width_ <= 0 || height_ <= 0 || viewport_width_ <= 0 || + viewport_height_ <= 0) return; + float scale_x = (float)viewport_width_ / (float)width_; + float scale_y = (float)viewport_height_ / (float)height_; + float translate_x = (2.0f * viewport_x_ + viewport_width_) / + (float)width_ - 1.0f; + float translate_y = (2.0f * viewport_y_ + viewport_height_) / + (float)height_ - 1.0f; + float *matrix = projection.GetArray(); + matrix[0] *= scale_x; + matrix[4] *= scale_x; + matrix[12] = matrix[12] * scale_x + translate_x; + matrix[1] *= scale_y; + matrix[5] *= scale_y; + matrix[13] = matrix[13] * scale_y + translate_y; +} + +void NativeModel::record_visible_state(Csm::CubismMatrix44 &projection) { + visual_state_.drawable_count = _model->GetDrawableCount(); + for (int i = 0; i < _model->GetDrawableCount(); ++i) { + if (_model->GetDrawableDynamicFlagIsVisible(i) && + _model->GetDrawableOpacity(i) > 0.001f) + ++visual_state_.drawable_visible; + if (_model->GetDrawableDynamicFlagVertexPositionsDidChange(i)) + ++visual_state_.drawable_vertex_changed; + } + visual_state_.offscreen_count = _model->GetOffscreenCount(); + for (int i = 0; i < _model->GetOffscreenCount(); ++i) + if (_model->GetOffscreenOpacity(i) > 0.001f) + ++visual_state_.offscreen_positive; + visual_state_.part_count = _model->GetPartCount(); + for (int i = 0; i < _model->GetPartCount(); ++i) + if (_model->GetPartOpacity(i) > 0.001f) + ++visual_state_.part_positive; + ModelBounds bounds = capture_visible_bounds(); + if (!bounds.valid) return; + float x0 = projection.TransformX(bounds.min_x); + float x1 = projection.TransformX(bounds.max_x); + float y0 = projection.TransformY(bounds.min_y); + float y1 = projection.TransformY(bounds.max_y); + visual_state_.visible_min_x = std::min(x0, x1); + visual_state_.visible_max_x = std::max(x0, x1); + visual_state_.visible_min_y = std::min(y0, y1); + visual_state_.visible_max_y = std::max(y0, y1); + visual_state_.visible = true; +} + +bool NativeModel::visual_state(BongoCatLive2DVisualState *state) const { + if (!state || !visual_state_ready_) return false; + *state = visual_state_; + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_playback.cpp b/src/live2d/cubism_motion_playback.cpp new file mode 100644 index 00000000..a60292c0 --- /dev/null +++ b/src/live2d/cubism_motion_playback.cpp @@ -0,0 +1,152 @@ +#include "cubism_model.hpp" + +#include +#include +#include + +namespace bongo_cat { + +bool motion_run_clears_selection(bool one_shot, bool committed, + bool replacement_running) { + return one_shot && committed && !replacement_running; +} + +bool NativeModel::start_motion(const char *group, int index) { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + bool selected = false; + std::string playback = motion_to_play(key, &selected); + if (playback.empty()) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (!restore_motion_defaults(canonical)) return false; + select_motion(canonical, false); + return true; + } + auto found = motions_.find(playback); + if (found == motions_.end()) return false; + constexpr int priority = 2; + Csm::CubismMotionQueueEntryHandle handle = + _motionManager->StartMotionPriority(found->second, false, priority); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) return false; + select_motion(key, selected); + record_motion_run(handle, key, selected, true); + return true; +} + +void NativeModel::record_motion_run(Csm::CubismMotionQueueEntryHandle handle, + const std::string &key, bool selected, bool committed) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + MotionRun run; + run.handle = handle; + run.key = canonical; + run.one_shot = !motion_is_persistent(canonical); + run.selected = selected; + run.committed = committed; + motion_runs_.push_back(std::move(run)); +} + +void NativeModel::stop_motion_runs(const std::string &key) { + for (const MotionRun &run : motion_runs_) { + if (run.key != key) continue; + auto *entry = _motionManager->GetCubismMotionQueueEntry(run.handle); + if (entry) entry->IsFinished(true); + } +} + +void NativeModel::expire_motion_runs() { + if (motion_runs_.empty()) return; + auto &finished = motion_finished_scratch_; + finished.resize(motion_runs_.size()); + for (size_t i = 0; i < motion_runs_.size(); ++i) + finished[i] = _motionManager->IsFinished(motion_runs_[i].handle); + bool restored_persistent_state = false; + for (size_t i = 0; i < motion_runs_.size(); ++i) { + const MotionRun &run = motion_runs_[i]; + if (!finished[i]) continue; + if (!run.committed) { + if (motion_preview_active_ && run.key == motion_preview_key_) + motion_preview_completed_ = true; + continue; + } + if (!run.one_shot) { + bool currently_selected = selected_motion_keys_.find(run.key) != + selected_motion_keys_.end(); + if (currently_selected == run.selected) { + auto state = motion_states_.find(run.key); + if (state != motion_states_.end()) + for (const auto &curve : state->second.curves) + restored_persistent_state = apply_motion_curve(curve, + run.selected ? curve.end : curve.normal) || + restored_persistent_state; + } + } + bool replacement_running = false; + if (run.one_shot) { + auto source_signature = motion_signatures_.find(run.key); + for (size_t j = 0; j < motion_runs_.size(); ++j) { + auto replacement_signature = motion_signatures_.find( + motion_runs_[j].key); + if (j != i && !finished[j] && motion_runs_[j].committed && + (motion_runs_[j].key == run.key || + (source_signature != motion_signatures_.end() && + !source_signature->second.empty() && + replacement_signature != motion_signatures_.end() && + source_signature->second == replacement_signature->second))) { + replacement_running = true; + break; + } + } + } + if (motion_run_clears_selection(run.one_shot, run.committed, + replacement_running)) select_motion(run.key, false); + } + if (restored_persistent_state) { + save_parameters(); + } + size_t output = 0; + for (size_t i = 0; i < motion_runs_.size(); ++i) + if (!finished[i]) motion_runs_[output++] = std::move(motion_runs_[i]); + motion_runs_.resize(output); +} + +void NativeModel::clear_motion_runs() { + for (const MotionRun &run : motion_runs_) + if (run.committed && run.one_shot) select_motion(run.key, false); + motion_runs_.clear(); +} + +bool NativeModel::motion_selected(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + if (owner != motion_toggle_owners_.end()) key = owner->second; + return selected_motion_keys_.find(key) != selected_motion_keys_.end(); +} + +bool NativeModel::motion_visible(const char *group, int index) const { + if (!group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + return owner == motion_toggle_owners_.end() || owner->second == key; +} + +bool NativeModel::motion_same_toggle(const char *left_group, int left_index, + const char *right_group, int right_index) const { + if (!left_group || !right_group || left_index < 0 || right_index < 0) + return false; + std::string left = std::string(left_group) + "_" + + std::to_string(left_index); + std::string right = std::string(right_group) + "_" + + std::to_string(right_index); + auto left_owner = motion_toggle_owners_.find(left); + auto right_owner = motion_toggle_owners_.find(right); + if (left_owner == motion_toggle_owners_.end() || + right_owner == motion_toggle_owners_.end()) return false; + return left_owner->second == right_owner->second; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_preview.cpp b/src/live2d/cubism_motion_preview.cpp new file mode 100644 index 00000000..b1fe8c77 --- /dev/null +++ b/src/live2d/cubism_motion_preview.cpp @@ -0,0 +1,118 @@ +#include "cubism_model.hpp" + +#include + +namespace bongo_cat { + +void NativeModel::capture_motion_preview() { + if (!_model || motion_preview_active_) return; + const int parameter_count = _model->GetParameterCount(); + const bool overrides_were_applied = parameter_overrides_applied_; + std::vector current_parameters((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) + current_parameters[(size_t)i] = _model->GetParameterValue(i); + _model->LoadParameters(); + parameter_overrides_applied_ = false; + capture_parameter_baseline(); + motion_preview_parameters_.resize((size_t)parameter_count); + for (int i = 0; i < parameter_count; ++i) { + motion_preview_parameters_[(size_t)i] = _model->GetParameterValue(i); + _model->SetParameterValue(i, current_parameters[(size_t)i]); + } + if (overrides_were_applied) apply_parameter_overrides(); + const int part_count = _model->GetPartCount(); + motion_preview_parts_.resize((size_t)part_count); + for (int i = 0; i < part_count; ++i) + motion_preview_parts_[(size_t)i] = _model->GetPartOpacity(i); + motion_preview_opacity_ = _model->GetModelOpacity(); + motion_preview_active_ = true; +} + +void NativeModel::restore_motion_preview_state() { + if (!_model || !motion_preview_active_) return; + _motionManager->StopAllMotions(); + clear_motion_runs(); + parameter_overrides_applied_ = false; + const int parameter_count = _model->GetParameterCount(); + for (int i = 0; i < parameter_count && + (size_t)i < motion_preview_parameters_.size(); ++i) + _model->SetParameterValue(i, motion_preview_parameters_[(size_t)i]); + save_parameters(); + const int part_count = _model->GetPartCount(); + for (int i = 0; i < part_count && + (size_t)i < motion_preview_parts_.size(); ++i) + _model->SetPartOpacity(i, motion_preview_parts_[(size_t)i]); + _model->SetModelOpacity(motion_preview_opacity_); + _opacity = motion_preview_opacity_; + apply_parameter_overrides(); +} + +bool NativeModel::preview_motion(const char *group, int index) { + if (!_model || !group || index < 0) return false; + if (motion_preview_active_) restore_motion_preview_state(); + else capture_motion_preview(); + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + motion_preview_key_ = owner == motion_toggle_owners_.end() ? + key : owner->second; + bool active = selected_motion_keys_.find(motion_preview_key_) != + selected_motion_keys_.end(); + std::string playback = motion_to_play(key, &motion_preview_selected_); + /* A running one-shot is previewed as a cancellation, while a committed + click keeps the normal replay behavior in start_motion(). */ + if (active && !motion_is_persistent(motion_preview_key_)) { + motion_preview_selected_ = false; + playback.clear(); + } + motion_preview_completed_ = false; + if (playback.empty()) + return restore_motion_defaults(motion_preview_key_); + auto found = motions_.find(playback); + if (found == motions_.end()) { + restore_motion_preview(); + return false; + } + constexpr int priority = 2; + Csm::CubismMotionQueueEntryHandle handle = + _motionManager->StartMotionPriority(found->second, false, priority); + if (handle == Csm::InvalidMotionQueueEntryHandleValue) { + restore_motion_preview(); + return false; + } + record_motion_run(handle, motion_preview_key_, + motion_preview_selected_, false); + return true; +} + +bool NativeModel::commit_motion_preview(const char *group, int index) { + if (!motion_preview_active_ || !group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (canonical != motion_preview_key_) return false; + select_motion(canonical, motion_preview_selected_); + for (auto &run : motion_runs_) + if (!run.committed && run.key == canonical) run.committed = true; + if (motion_preview_completed_ && !motion_is_persistent(canonical)) + select_motion(canonical, false); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; + motion_preview_completed_ = false; + return true; +} + +bool NativeModel::restore_motion_preview() { + if (!motion_preview_active_) return false; + restore_motion_preview_state(); + motion_preview_parameters_.clear(); + motion_preview_parts_.clear(); + motion_preview_key_.clear(); + motion_preview_active_ = false; + motion_preview_completed_ = false; + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_selection.cpp b/src/live2d/cubism_motion_selection.cpp new file mode 100644 index 00000000..ac93cf0b --- /dev/null +++ b/src/live2d/cubism_motion_selection.cpp @@ -0,0 +1,22 @@ +#include "cubism_model.hpp" + +namespace bongo_cat { + +void NativeModel::select_motion(const std::string &key, bool selected) { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (!selected) { selected_motion_keys_.erase(canonical); return; } + const std::string &signature = motion_signatures_[canonical]; + for (auto item = selected_motion_keys_.begin(); + item != selected_motion_keys_.end();) { + auto existing = motion_signatures_.find(*item); + if (!signature.empty() && existing != motion_signatures_.end() && + existing->second == signature) + item = selected_motion_keys_.erase(item); + else ++item; + } + selected_motion_keys_.insert(canonical); +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_motion_state.cpp b/src/live2d/cubism_motion_state.cpp new file mode 100644 index 00000000..61388c80 --- /dev/null +++ b/src/live2d/cubism_motion_state.cpp @@ -0,0 +1,279 @@ +#include "cubism_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bongo_cat { + +static bool curve_endpoints(yyjson_val *segments, float *start, float *end) { + if (!yyjson_is_arr(segments) || yyjson_arr_size(segments) < 2) return false; + yyjson_val *first = yyjson_arr_get(segments, 1); + yyjson_val *last = yyjson_arr_get(segments, yyjson_arr_size(segments) - 1); + if (!yyjson_is_num(first) || !yyjson_is_num(last)) return false; + *start = (float)yyjson_get_num(first); + *end = (float)yyjson_get_num(last); + return true; +} + +static bool curve_returns_to_default(yyjson_val *segments, float normal) { + float start = 0.0f, end = 0.0f; + if (!curve_endpoints(segments, &start, &end) || + std::fabs(start - normal) > .0001f || + std::fabs(end - normal) > .0001f) return false; + bool deviates = false; + size_t cursor = 2, count = yyjson_arr_size(segments); + while (cursor < count) { + yyjson_val *kind_value = yyjson_arr_get(segments, cursor); + if (!yyjson_is_int(kind_value) && !yyjson_is_uint(kind_value)) + return false; + int kind = (int)yyjson_get_int(kind_value); + size_t points = kind == 1 ? 3 : + (kind == 0 || kind == 2 || kind == 3 ? 1 : 0); + if (!points || cursor + points * 2 >= count) return false; + for (size_t point = 0; point < points; ++point) { + yyjson_val *value = yyjson_arr_get(segments, + cursor + 2 + point * 2); + if (!yyjson_is_num(value)) return false; + if (std::fabs((float)yyjson_get_num(value) - normal) > .0001f) + deviates = true; + } + cursor += 1 + points * 2; + } + return deviates; +} + +static bool curve_has_state_target(const NativeModel::MotionStateCurve &curve) { + return curve.parameter >= 0 || curve.part >= 0 || curve.model_opacity; +} + +void NativeModel::load_motion_state(const std::string &key, const char *group, + int motion_index, const std::vector &bytes) { + yyjson_doc *document = yyjson_read( + reinterpret_cast(bytes.data()), bytes.size(), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : nullptr; + yyjson_val *curves = yyjson_is_obj(root) ? yyjson_obj_get(root, "Curves") : nullptr; + MotionState state; state.group = group ? group : ""; + state.index = motion_index; + std::vector targets; + size_t index, count; yyjson_val *curve; + if (yyjson_is_arr(curves)) yyjson_arr_foreach(curves, index, count, curve) { + const char *target = yyjson_get_str(yyjson_obj_get(curve, "Target")); + const char *id = yyjson_get_str(yyjson_obj_get(curve, "Id")); + if (!target || !id) continue; + targets.push_back(std::string(target) + ":" + id); + MotionStateCurve value{target, id}; + yyjson_val *segments = yyjson_obj_get(curve, "Segments"); + if (std::strcmp(target, "Parameter") == 0) { + auto handle = Csm::CubismFramework::GetIdManager()->GetId(id); + value.parameter = _model->GetParameterIndex(handle); + if (value.parameter >= 0 && + value.parameter < _model->GetParameterCount()) + value.normal = _model->GetParameterDefaultValue(value.parameter); + else value.parameter = -1; + } else if (std::strcmp(target, "PartOpacity") == 0) { + auto handle = Csm::CubismFramework::GetIdManager()->GetId(id); + value.part = _model->GetPartIndex(handle); + if (value.part >= 0 && value.part < _model->GetPartCount()) + value.normal = _model->GetPartOpacity(value.part); + else value.part = -1; + } else if (std::strcmp(target, "Model") == 0 && + std::strcmp(id, "Opacity") == 0) { + value.model_opacity = true; + value.normal = _model->GetModelOpacity(); + } + if (curve_has_state_target(value) && + curve_returns_to_default(segments, value.normal)) + state.self_contained = true; + if (curve_endpoints(segments, &value.start, &value.end)) + state.curves.push_back(value); + } + if (document) yyjson_doc_free(document); + std::sort(targets.begin(), targets.end()); + targets.erase(std::unique(targets.begin(), targets.end()), targets.end()); + for (const std::string &target : targets) + motion_signatures_[key] += target + '\n'; + std::sort(state.curves.begin(), state.curves.end(), + [](const MotionStateCurve &a, const MotionStateCurve &b) { + return a.target == b.target ? a.id < b.id : a.target < b.target; + }); + motion_states_[key] = std::move(state); +} + +bool motion_toggle_pair(const NativeModel::MotionState &a, + const NativeModel::MotionState &b, bool *first_enabled) { + if (!first_enabled || a.group != b.group || + a.curves.size() != b.curves.size() || a.curves.empty()) return false; + bool direction_known = false; + for (size_t i = 0; i < a.curves.size(); ++i) { + const auto &left = a.curves[i]; + const auto &right = b.curves[i]; + if (left.target != right.target || left.id != right.id) return false; + bool same = std::fabs(left.start - right.start) <= .0001f && + std::fabs(left.end - right.end) <= .0001f; + bool reversed = std::fabs(left.start - right.end) <= .0001f && + std::fabs(left.end - right.start) <= .0001f; + if (!same && !reversed) return false; + if (!reversed || same) continue; + if (!curve_has_state_target(left) || !curve_has_state_target(right)) + return false; + bool left_normal = std::fabs(left.end - left.normal) <= .0001f; + bool right_normal = std::fabs(right.end - right.normal) <= .0001f; + if (left_normal == right_normal) return false; + bool enabled = !left_normal; + if (direction_known && enabled != *first_enabled) return false; + *first_enabled = enabled; + direction_known = true; + } + return direction_known; +} + +bool motion_enables_state(const NativeModel::MotionState &state) { + if (state.self_contained) return false; + for (const auto &curve : state.curves) + if (curve_has_state_target(curve) && + std::fabs(curve.end - curve.normal) > .0001f) return true; + return false; +} + +static bool same_signature(const NativeModel::MotionSignatures &signatures, + const std::string &left, const std::string &right) { + auto a = signatures.find(left), b = signatures.find(right); + return a != signatures.end() && b != signatures.end() && + !a->second.empty() && a->second == b->second; +} + +static size_t pair_candidates( + const std::map &states, + const NativeModel::MotionSignatures &signatures, const std::string &key, + std::string *candidate, bool *key_enabled) { + auto source = states.find(key); + if (source == states.end()) return 0; + size_t count = 0; + for (const auto &item : states) { + if (item.first == key || !same_signature(signatures, key, item.first)) + continue; + bool enabled = false; + if (!motion_toggle_pair(source->second, item.second, &enabled)) continue; + if (++count == 1) { + if (candidate) *candidate = item.first; + if (key_enabled) *key_enabled = enabled; + } + } + return count; +} + +void NativeModel::pair_motion_states() { + for (const auto &item : motion_states_) { + if (motion_toggle_owners_.find(item.first) != motion_toggle_owners_.end()) + continue; + std::string candidate; + bool item_enabled = false; + if (pair_candidates(motion_states_, motion_signatures_, item.first, + &candidate, &item_enabled) != 1 || + pair_candidates(motion_states_, motion_signatures_, candidate, + nullptr, nullptr) != 1) continue; + const std::string &owner = item_enabled ? item.first : candidate; + const std::string &partner = item_enabled ? candidate : item.first; + motion_toggle_partners_[owner] = partner; + motion_toggle_owners_[owner] = owner; + motion_toggle_owners_[partner] = owner; + } +} + +std::string NativeModel::motion_to_play(const std::string &key, + bool *selected) const { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + auto partner = motion_toggle_partners_.find(canonical); + bool active = selected_motion_keys_.find(canonical) != selected_motion_keys_.end(); + bool persistent = motion_is_persistent(canonical); + /* One-shot actions stay checked only while their own playback is alive. */ + if (selected) *selected = persistent ? !active : true; + if (active && persistent && partner == motion_toggle_partners_.end()) return {}; + return active && partner != motion_toggle_partners_.end() ? + partner->second : canonical; +} + +bool NativeModel::motion_is_persistent(const std::string &key) const { + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + if (motion_toggle_partners_.find(canonical) != motion_toggle_partners_.end()) + return true; + auto state = motion_states_.find(canonical); + return state != motion_states_.end() && motion_enables_state(state->second); +} + +bool NativeModel::motion_persistent(const char *group, int index) const { + if (!group || index < 0) return false; + return motion_is_persistent( + std::string(group) + "_" + std::to_string(index)); +} + +bool NativeModel::restore_motion_defaults(const std::string &key) { + auto state = motion_states_.find(key); + if (_model == nullptr || state == motion_states_.end()) return false; + bool had_run = false; + for (const MotionRun &run : motion_runs_) + if (run.key == key) { had_run = true; break; } + bool restored = false; + stop_motion_runs(key); + for (const auto &curve : state->second.curves) { + restored = apply_motion_curve(curve, curve.normal) || restored; + } + if (!restored) return had_run; + save_parameters(); + return true; +} + +bool NativeModel::apply_motion_curve(const MotionStateCurve &curve, + float value) { + if (!_model) return false; + if (curve.parameter >= 0 && + curve.parameter < _model->GetParameterCount()) { + const size_t index = (size_t)curve.parameter; + parameter_baseline_values_[index] = value; + float displayed = parameter_overrides_applied_ && + parameter_overrides_[index] ? parameter_override_values_[index] : value; + _model->SetParameterValue(curve.parameter, displayed); + return true; + } + if (curve.part >= 0 && curve.part < _model->GetPartCount()) { + _model->SetPartOpacity(curve.part, value); + return true; + } + if (curve.model_opacity) { + _model->SetModelOpacity(value); + _opacity = value; + return true; + } + return false; +} + +bool NativeModel::restore_motion_state(const char *group, int index) { + if (!_model || !group || index < 0) return false; + std::string key = std::string(group) + "_" + std::to_string(index); + auto owner = motion_toggle_owners_.find(key); + const std::string &canonical = owner == motion_toggle_owners_.end() ? + key : owner->second; + auto state = motion_states_.find(canonical); + if (state == motion_states_.end() || !motion_is_persistent(canonical)) + return false; + bool restored = false; + stop_motion_runs(canonical); + for (const auto &curve : state->second.curves) + restored = apply_motion_curve(curve, curve.end) || restored; + if (!restored) return false; + select_motion(canonical, true); + save_parameters(); + return true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_parameter_overrides.cpp b/src/live2d/cubism_parameter_overrides.cpp new file mode 100644 index 00000000..9b0f0959 --- /dev/null +++ b/src/live2d/cubism_parameter_overrides.cpp @@ -0,0 +1,72 @@ +#include "cubism_model.hpp" + +#include +#include + +namespace bongo_cat { + +/* External input has to be visible to CubismPhysics, but must remain higher + priority than physics and pose at frame end. Running once immediately + before Physics and once after the scheduler provides both guarantees. */ +class ParameterOverrideUpdater final : public Csm::ICubismUpdater { +public: + explicit ParameterOverrideUpdater(NativeModel &owner) + : Csm::ICubismUpdater(Csm::CubismUpdateOrder_Physics - 1), + owner_(owner) {} + + void OnLateUpdate(Csm::CubismModel *model, + Csm::csmFloat32 delta_seconds) override { + (void)model; + (void)delta_seconds; + owner_.apply_parameter_overrides(); + } + +private: + NativeModel &owner_; +}; + +void NativeModel::add_parameter_override_updater() { + _updateScheduler.AddUpdatableList( + CSM_NEW ParameterOverrideUpdater(*this)); +} + +void NativeModel::capture_parameter_baseline() { + if (!_model) return; + const int count = _model->GetParameterCount(); + parameter_baseline_values_.resize((size_t)count); + for (int i = 0; i < count; ++i) + parameter_baseline_values_[(size_t)i] = _model->GetParameterValue(i); +} + +void NativeModel::save_parameters() { + if (!_model) return; + if (!parameter_overrides_applied_) { + capture_parameter_baseline(); + _model->SaveParameters(); + return; + } + /* Cubism's saved state is the motion baseline. Never let frame-end input + overrides become the starting values for a later motion frame. */ + const int count = _model->GetParameterCount(); + auto ¤t = parameter_save_scratch_; + current.resize((size_t)count); + for (int i = 0; i < count; ++i) { + current[(size_t)i] = _model->GetParameterValue(i); + _model->SetParameterValue(i, parameter_baseline_values_[(size_t)i]); + } + _model->SaveParameters(); + for (int i = 0; i < count; ++i) + _model->SetParameterValue(i, current[(size_t)i]); +} + +void NativeModel::apply_parameter_overrides() { + if (!_model) return; + const int count = _model->GetParameterCount(); + for (int i = 0; i < count; ++i) { + if (!parameter_overrides_[(size_t)i]) continue; + _model->SetParameterValue(i, parameter_override_values_[(size_t)i]); + } + parameter_overrides_applied_ = true; +} + +} // namespace bongo_cat diff --git a/src/live2d/cubism_runtime.hpp b/src/live2d/cubism_runtime.hpp new file mode 100644 index 00000000..bb8080cf --- /dev/null +++ b/src/live2d/cubism_runtime.hpp @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_CUBISM_RUNTIME_HPP +#define BONGO_CAT_CUBISM_RUNTIME_HPP + +#include "cubism_model.hpp" + +struct BongoCatRetiredModel { + bongo_cat::NativeModel *model = nullptr; + unsigned frames_remaining = 0; +}; + +struct BongoCatLive2D { + static constexpr unsigned retired_capacity = 4; + bongo_cat::NativeModel *model; + BongoCatRetiredModel retired[retired_capacity]{}; + unsigned retired_count = 0; + int width = 612; + int height = 354; +}; + +#endif diff --git a/src/live2d/cubism_viewer_look.cpp b/src/live2d/cubism_viewer_look.cpp index 95c24d14..0f65aee2 100644 --- a/src/live2d/cubism_viewer_look.cpp +++ b/src/live2d/cubism_viewer_look.cpp @@ -20,6 +20,7 @@ ViewerLookUpdater::ViewerLookUpdater(Csm::CubismModel &model) : Csm::ICubismUpdater(Csm::CubismUpdateOrder_Look) { add_parameter(model, "ParamAngleX", Axis::X); add_parameter(model, "ParamAngleY", Axis::Y); + add_parameter(model, "ParamAngleZ", Axis::Z); add_parameter(model, "ParamEyeBallX", Axis::X); add_parameter(model, "ParamEyeBallY", Axis::Y); add_parameter(model, "ParamBodyAngleX", Axis::X); @@ -27,7 +28,7 @@ ViewerLookUpdater::ViewerLookUpdater(Csm::CubismModel &model) void ViewerLookUpdater::add_parameter(Csm::CubismModel &model, const char *id, Axis axis) { - if (parameter_count_ >= 5) return; + if (parameter_count_ >= 6) return; Csm::CubismIdHandle handle = Csm::CubismFramework::GetIdManager()->GetId(id); Csm::csmInt32 index = model.GetParameterIndex(handle); @@ -36,9 +37,10 @@ void ViewerLookUpdater::add_parameter(Csm::CubismModel &model, } void ViewerLookUpdater::set_target(Csm::csmFloat32 x, - Csm::csmFloat32 y) { + Csm::csmFloat32 y, bool angle_z_enabled) { target_x_ = std::max(-1.0f, std::min(1.0f, x)); target_y_ = std::max(-1.0f, std::min(1.0f, y)); + angle_z_enabled_ = angle_z_enabled; } void ViewerLookUpdater::update_target(Csm::csmFloat32 delta_seconds) { @@ -96,15 +98,22 @@ void ViewerLookUpdater::OnLateUpdate(Csm::CubismModel *model, const Csm::csmFloat32 input_y = y(); for (Csm::csmInt32 i = 0; i < parameter_count_; ++i) { const Parameter ¶meter = parameters_[i]; - const Csm::csmFloat32 current = - model->GetParameterValue(parameter.index); - const Csm::csmFloat32 bound = current >= 0.0f + if (parameter.axis == Axis::Z && !angle_z_enabled_) continue; + const Csm::csmFloat32 current = model->GetParameterValue(parameter.index); + Csm::csmFloat32 input = parameter.axis == Axis::X ? input_x : input_y; + Csm::csmFloat32 gain = viewer_input_gain; + Csm::csmFloat32 bound = current >= 0.0f ? model->GetParameterMaximumValue(parameter.index) : model->GetParameterMinimumValue(parameter.index); - const Csm::csmFloat32 input = - parameter.axis == Axis::X ? input_x : input_y; + if (parameter.axis == Axis::Z) { + input = -input_x * input_y; + gain = 1.0f; + bound = input >= 0.0f + ? model->GetParameterMaximumValue(parameter.index) + : model->GetParameterMinimumValue(parameter.index); + } model->AddParameterValue(parameter.index, - input * std::fabs(bound) * viewer_input_gain, parameter.weight); + input * std::fabs(bound) * gain, parameter.weight); } } diff --git a/src/live2d/cubism_viewer_look.hpp b/src/live2d/cubism_viewer_look.hpp index 261f95e0..559c3dd2 100644 --- a/src/live2d/cubism_viewer_look.hpp +++ b/src/live2d/cubism_viewer_look.hpp @@ -8,12 +8,13 @@ namespace bongo_cat { class ViewerLookUpdater final : public Csm::ICubismUpdater { public: explicit ViewerLookUpdater(Csm::CubismModel &model); - void set_target(Csm::csmFloat32 x, Csm::csmFloat32 y); + void set_target(Csm::csmFloat32 x, Csm::csmFloat32 y, + bool angle_z_enabled); void OnLateUpdate(Csm::CubismModel *model, Csm::csmFloat32 delta_seconds) override; private: - enum class Axis { X, Y }; + enum class Axis { X, Y, Z }; struct Parameter { Csm::csmInt32 index; Axis axis; @@ -25,7 +26,7 @@ class ViewerLookUpdater final : public Csm::ICubismUpdater { Csm::csmFloat32 x() const; Csm::csmFloat32 y() const; - Parameter parameters_[5]{}; + Parameter parameters_[6]{}; Csm::csmInt32 parameter_count_ = 0; Csm::csmFloat32 target_x_ = 0.0f; Csm::csmFloat32 target_y_ = 0.0f; @@ -33,6 +34,7 @@ class ViewerLookUpdater final : public Csm::ICubismUpdater { Csm::csmFloat32 face_y_ = 0.0f; Csm::csmFloat32 velocity_x_ = 0.0f; Csm::csmFloat32 velocity_y_ = 0.0f; + bool angle_z_enabled_ = false; bool initialized_ = false; }; diff --git a/src/live2d/live2d_stub.c b/src/live2d/live2d_stub.c index 1cca2d8b..7e655582 100644 --- a/src/live2d/live2d_stub.c +++ b/src/live2d/live2d_stub.c @@ -1,27 +1,60 @@ #include "bongo_cat/model.h" -#include "../runtime/model_import.h" +#include "model_import_manifest.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" +#include #include -struct BongoCatLive2D { int width; int height; bool loaded; }; +struct BongoCatLive2D { + int width; + int height; + bool loaded; +}; -BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, BongoCatError *error) { +static BongoCatLive2D *create_runtime(const char *asset_root, + BongoCatError *error) { + static bool warning_logged; (void)asset_root; + if (!warning_logged) { + warning_logged = true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cubism SDK unavailable: diagnostic backend active; Live2D " + "rendering, animation, pointer tracking, and cover generation " + "are disabled"); + } BongoCatLive2D *value = calloc(1, sizeof(*value)); if (!value) bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate Live2D runtime"); return value; } +BongoCatLive2D *bongo_cat_live2d_create(const char *asset_root, + BongoCatError *error) { + return create_runtime(asset_root, error); +} + void bongo_cat_live2d_destroy(BongoCatLive2D *live2d) { free(live2d); } BongoCatResult bongo_cat_live2d_load(BongoCatLive2D *live2d, const char *model_dir, const char *setting_file, bool preset, - const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { + const BongoCatLive2DRenderOptions *render_options, + BongoCatLive2DLoadProgress progress, void *userdata, + BongoCatError *error) { (void)preset; (void)render_options; if (!live2d || !model_dir || !setting_file) return BONGO_CAT_ERROR_ARGUMENT; - if (!bongo_cat_import_manifest_valid(model_dir, setting_file, error)) + if (progress) progress(userdata, 0.1f); + char path[BONGO_CAT_PATH_CAP]; + yyjson_doc *document = bongo_cat_path_join(path, sizeof(path), model_dir, + setting_file) ? bongo_cat_model_json_read(path, NULL) : NULL; + bool valid = bongo_cat_import_manifest_document_valid(model_dir, document, true); + yyjson_doc_free(document); + if (!valid) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model manifest or required assets are invalid: %s", setting_file); return BONGO_CAT_ERROR_FORMAT; + } live2d->loaded = true; + if (progress) progress(userdata, 1.0f); return BONGO_CAT_OK; } @@ -29,6 +62,28 @@ bool bongo_cat_live2d_ready(const BongoCatLive2D *live2d) { return live2d && live2d->loaded; } +bool bongo_cat_live2d_canvas_size(const BongoCatLive2D *live2d, + int *width, int *height) { + (void)live2d; (void)width; (void)height; return false; +} + +bool bongo_cat_live2d_frame(const BongoCatLive2D *live2d, + BongoCatLive2DFrame *frame) { + if (!live2d || !frame) return false; + *frame = (BongoCatLive2DFrame){0}; + return true; +} + +bool bongo_cat_live2d_viewport(const BongoCatLive2D *live2d, + int *x, int *y, int *width, int *height) { + if (!live2d || !x || !y || !width || !height) return false; + *x = 0; + *y = 0; + *width = live2d->width; + *height = live2d->height; + return true; +} + void bongo_cat_live2d_resize(BongoCatLive2D *live2d, int width, int height) { if (!live2d) return; live2d->width = width; @@ -52,7 +107,12 @@ void bongo_cat_live2d_set_render_options(BongoCatLive2D *live2d, void bongo_cat_live2d_set_dragging(BongoCatLive2D *live2d, float x, float y) { (void)live2d; (void)x; (void)y; } +void bongo_cat_live2d_set_centered_dragging(BongoCatLive2D *live2d, + float x, float y) { (void)live2d; (void)x; (void)y; } void bongo_cat_live2d_prepare_viewer_audit(BongoCatLive2D *live2d) { (void)live2d; } +bool bongo_cat_live2d_prepare_cover_capture(BongoCatLive2D *live2d) { + return live2d && live2d->loaded; +} bool bongo_cat_live2d_set_parameter(BongoCatLive2D *live2d, const char *id, float value) { (void)live2d; (void)id; (void)value; return false; } @@ -62,6 +122,39 @@ bool bongo_cat_live2d_parameter(BongoCatLive2D *value, const char *id, BongoCatP bool bongo_cat_live2d_start_motion(BongoCatLive2D *value, const char *group, int index) { (void)value; (void)group; (void)index; return false; } +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_preview_motion(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_restore_motion_preview(BongoCatLive2D *value) { + (void)value; return false; +} +bool bongo_cat_live2d_commit_motion_preview(BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return false; +} +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *value, + const char *group, int index) { + (void)value; (void)group; (void)index; return true; +} +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *value, + const char *left_group, int left_index, + const char *right_group, int right_index) { + (void)value; (void)left_group; (void)left_index; + (void)right_group; (void)right_index; return false; +} bool bongo_cat_live2d_set_expression(BongoCatLive2D *value, int index) { (void)value; (void)index; return false; } diff --git a/src/media/audio.c b/src/media/audio.c deleted file mode 100644 index 105f8450..00000000 --- a/src/media/audio.c +++ /dev/null @@ -1,71 +0,0 @@ -#include "bongo_cat/audio.h" - -#include -#include -#include - -struct BongoCatAudio { - ma_engine engine; - ma_sound sound; - bool initialized; - bool sound_ready; - bool enabled; -}; - -BongoCatAudio *bongo_cat_audio_create(BongoCatError *error) { - (void)error; - BongoCatAudio *audio = calloc(1, sizeof(*audio)); - if (!audio) return NULL; - audio->enabled = true; - return audio; -} - -void bongo_cat_audio_stop(BongoCatAudio *audio) { - if (!audio || !audio->sound_ready) return; - ma_sound_stop(&audio->sound); - ma_sound_uninit(&audio->sound); - audio->sound_ready = false; -} - -BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error) { - if (!audio || !path) return BONGO_CAT_ERROR_ARGUMENT; - if (!audio->enabled) return BONGO_CAT_OK; - if (!audio->initialized) { - ma_result initialized = ma_engine_init(NULL, &audio->engine); - if (initialized != MA_SUCCESS) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Audio initialization failed: %d", initialized); - return BONGO_CAT_ERROR_PLATFORM; - } - audio->initialized = true; - } - bongo_cat_audio_stop(audio); - ma_result result = ma_sound_init_from_file(&audio->engine, path, - MA_SOUND_FLAG_DECODE | MA_SOUND_FLAG_ASYNC, NULL, NULL, &audio->sound); - if (result != MA_SUCCESS) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot load motion sound: %s", path); - return BONGO_CAT_ERROR_IO; - } - audio->sound_ready = true; - result = ma_sound_start(&audio->sound); - if (result != MA_SUCCESS) { - bongo_cat_audio_stop(audio); - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot start motion sound: %s", path); - return BONGO_CAT_ERROR_PLATFORM; - } - return BONGO_CAT_OK; -} - -void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled) { - if (!audio) return; - audio->enabled = enabled; - if (!enabled) bongo_cat_audio_stop(audio); -} - -void bongo_cat_audio_destroy(BongoCatAudio *audio) { - if (!audio) return; - bongo_cat_audio_stop(audio); - if (audio->initialized) ma_engine_uninit(&audio->engine); - free(audio); -} diff --git a/src/media/audio/audio.c b/src/media/audio/audio.c new file mode 100644 index 00000000..0e815b45 --- /dev/null +++ b/src/media/audio/audio.c @@ -0,0 +1,105 @@ +#include "audio_internal.h" + +#include +#include +#include + +/* App-thread owned. Streaming bounds decoded memory independently of clip + duration; the pool bounds decoder/file handles during rapid key presses. */ +void bongo_cat_audio_voice_release(AudioVoice *voice) { + if (!voice->ready) return; + ma_sound_stop(&voice->sound); + /* uninit waits for pending decoding jobs before releasing their data. */ + ma_sound_uninit(&voice->sound); + memset(voice, 0, sizeof(*voice)); +} + +BongoCatAudio *bongo_cat_audio_create(BongoCatError *error) { + BongoCatAudio *audio = calloc(1, sizeof(*audio)); + if (!audio) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, "Cannot allocate audio player"); + return NULL; + } + audio->enabled = true; + return audio; +} + +void bongo_cat_audio_stop(BongoCatAudio *audio) { + if (!audio) return; + for (size_t i = 0; i < AUDIO_VOICES; ++i) bongo_cat_audio_voice_release(&audio->voices[i]); +} + +void bongo_cat_audio_reset(BongoCatAudio *audio) { + if (!audio) return; + bongo_cat_audio_stop(audio); + if (audio->initialized) ma_engine_uninit(&audio->engine); + audio->initialized = false; + audio->next_collect = 0; +} + +void bongo_cat_audio_collect(BongoCatAudio *audio, uint64_t now) { + if (!audio || !audio->initialized || now < audio->next_collect) return; + audio->next_collect = now + 1000; + bool active = false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + AudioVoice *voice = &audio->voices[i]; + if (!voice->ready) continue; + ma_result result = ma_resource_manager_data_source_result( + voice->sound.pResourceManagerDataSource); + if (result != MA_SUCCESS && result != MA_BUSY) { + SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "Cannot decode audio (%d): %s", result, voice->path); + bongo_cat_audio_voice_release(voice); + } else if (ma_sound_at_end(&voice->sound) || !ma_sound_is_playing(&voice->sound)) { + if (now - voice->used >= AUDIO_IDLE_MS) bongo_cat_audio_voice_release(voice); + } else active = true; + } + /* Idle players retain neither device callbacks nor decoder workers. */ + if (!active && now - audio->last_play >= AUDIO_IDLE_MS) bongo_cat_audio_reset(audio); +} + +void bongo_cat_audio_update(BongoCatAudio *audio) { + bongo_cat_audio_collect(audio, SDL_GetTicks()); +} + +void bongo_cat_audio_set_enabled(BongoCatAudio *audio, bool enabled) { + if (!audio) return; + audio->enabled = enabled; + if (!enabled) bongo_cat_audio_reset(audio); +} + +void bongo_cat_audio_destroy(BongoCatAudio *audio) { + if (!audio) return; + bongo_cat_audio_reset(audio); + free(audio); +} + +bool bongo_cat_audio_is_playing(const BongoCatAudio *audio, const char *path) { + if (!audio || !path || !path[0]) return false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + const AudioVoice *voice = &audio->voices[i]; + if (!voice->ready || strcmp(voice->path, path)) continue; + ma_result result = ma_resource_manager_data_source_result(voice->sound.pResourceManagerDataSource); + if ((result == MA_SUCCESS || result == MA_BUSY) && + ma_sound_is_playing(&voice->sound) && !ma_sound_at_end(&voice->sound)) return true; + } + return false; +} + +void bongo_cat_audio_stop_path(BongoCatAudio *audio, const char *path) { + if (!audio || !path || !path[0]) return; + for (size_t i = 0; i < AUDIO_VOICES; ++i) + if (audio->voices[i].ready && !strcmp(audio->voices[i].path, path)) + bongo_cat_audio_voice_release(&audio->voices[i]); +} + +bool bongo_cat_audio_any_playing(const BongoCatAudio *audio) { + if (!audio) return false; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + const AudioVoice *voice = &audio->voices[i]; + if (!voice->ready) continue; + ma_result result = ma_resource_manager_data_source_result(voice->sound.pResourceManagerDataSource); + if ((result == MA_SUCCESS || result == MA_BUSY) && + ma_sound_is_playing(&voice->sound) && !ma_sound_at_end(&voice->sound)) return true; + } + return false; +} diff --git a/src/media/audio/audio_internal.h b/src/media/audio/audio_internal.h new file mode 100644 index 00000000..5d2c3d93 --- /dev/null +++ b/src/media/audio/audio_internal.h @@ -0,0 +1,33 @@ +#ifndef BONGO_CAT_AUDIO_INTERNAL_H +#define BONGO_CAT_AUDIO_INTERNAL_H + +#include "bongo_cat/audio.h" +#include + +#define AUDIO_VOICES 50 +#define AUDIO_IDLE_MS 5000 + +typedef struct AudioVoice { + ma_sound sound; + char path[BONGO_CAT_PATH_CAP]; + uint64_t used; + uint64_t order; + bool ready; +} AudioVoice; + +struct BongoCatAudio { + ma_engine engine; + AudioVoice voices[AUDIO_VOICES]; + uint64_t sequence; + uint64_t last_play; + uint64_t next_collect; + bool initialized; + bool enabled; +}; + +void bongo_cat_audio_voice_release(AudioVoice *voice); + +/* Explicit time keeps idle reclamation deterministic in offline tests. */ +void bongo_cat_audio_collect(BongoCatAudio *audio, uint64_t now_ms); + +#endif diff --git a/src/media/audio/audio_playback.c b/src/media/audio/audio_playback.c new file mode 100644 index 00000000..a5ec74e2 --- /dev/null +++ b/src/media/audio/audio_playback.c @@ -0,0 +1,83 @@ +#include "audio_internal.h" + +#include +#include +#ifdef _WIN32 +#include +#endif + +static ma_result load_voice(BongoCatAudio *audio, AudioVoice *voice, const char *path) { + ma_uint32 flags = MA_SOUND_FLAG_STREAM | MA_SOUND_FLAG_ASYNC | + MA_SOUND_FLAG_NO_SPATIALIZATION; +#ifdef _WIN32 + wchar_t wide[BONGO_CAT_PATH_CAP]; + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, + wide, BONGO_CAT_PATH_CAP)) return MA_INVALID_ARGS; + return ma_sound_init_from_file_w(&audio->engine, wide, flags, NULL, NULL, &voice->sound); +#else + return ma_sound_init_from_file(&audio->engine, path, flags, NULL, NULL, &voice->sound); +#endif +} + +BongoCatResult bongo_cat_audio_play_sound(BongoCatAudio *audio, const char *path, + bool overlap, BongoCatError *error) { + if (!audio || !path || !path[0] || strlen(path) >= BONGO_CAT_PATH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Invalid audio path or player"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (!audio->enabled) return BONGO_CAT_OK; + if (!audio->initialized) { + ma_result result = ma_engine_init(NULL, &audio->engine); + if (result != MA_SUCCESS) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Audio initialization failed: %d", result); + return BONGO_CAT_ERROR_PLATFORM; + } + audio->initialized = true; + } + AudioVoice *selected = NULL, *idle = NULL, *oldest = &audio->voices[0]; + for (size_t i = 0; i < AUDIO_VOICES; ++i) { + AudioVoice *voice = &audio->voices[i]; + bool playing = voice->ready && ma_sound_is_playing(&voice->sound) && + !ma_sound_at_end(&voice->sound); + if (voice->ready && strcmp(voice->path, path) == 0 && (!overlap || !playing)) { + selected = voice; + break; + } + if (!playing && (!idle || !voice->ready)) idle = voice; + if (voice->order < oldest->order) oldest = voice; + } + if (!selected) { + selected = idle ? idle : oldest; + bongo_cat_audio_voice_release(selected); + ma_result result = load_voice(audio, selected, path); + if (result != MA_SUCCESS) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot load audio (%d): %s", result, path); + return BONGO_CAT_ERROR_IO; + } + selected->ready = true; + memcpy(selected->path, path, strlen(path) + 1); + } else { + ma_sound_stop(&selected->sound); + ma_result result = ma_sound_seek_to_pcm_frame(&selected->sound, 0); + if (result != MA_SUCCESS) { + bongo_cat_audio_voice_release(selected); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot rewind audio: %s", path); + return BONGO_CAT_ERROR_IO; + } + } + selected->used = audio->last_play = SDL_GetTicks(); + selected->order = ++audio->sequence; + if (ma_sound_start(&selected->sound) != MA_SUCCESS) { + bongo_cat_audio_voice_release(selected); + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start audio: %s", path); + return BONGO_CAT_ERROR_PLATFORM; + } + return BONGO_CAT_OK; +} + +BongoCatResult bongo_cat_audio_play(BongoCatAudio *audio, const char *path, BongoCatError *error) { + return bongo_cat_audio_play_sound(audio, path, false, error); +} + diff --git a/src/media/audio/miniaudio_impl.c b/src/media/audio/miniaudio_impl.c new file mode 100644 index 00000000..5f1fff65 --- /dev/null +++ b/src/media/audio/miniaudio_impl.c @@ -0,0 +1,11 @@ +/* stb_vorbis must be declared before miniaudio to enable its Ogg backend. */ +#ifndef BONGO_CAT_VORBIS_HEADER +#define BONGO_CAT_VORBIS_HEADER +#endif +#define STB_VORBIS_NO_INTEGER_CONVERSION +#define STB_VORBIS_HEADER_ONLY +#include BONGO_CAT_VORBIS_HEADER +#define MINIAUDIO_IMPLEMENTATION +#include +#undef STB_VORBIS_HEADER_ONLY +#include BONGO_CAT_VORBIS_HEADER diff --git a/src/media/image.c b/src/media/image.c index 7278bbad..6346a045 100644 --- a/src/media/image.c +++ b/src/media/image.c @@ -1,124 +1,19 @@ -#include "bongo_cat/file.h" #include "bongo_cat/gl_api.h" #include "bongo_cat/image.h" +#include "image_internal.h" -#ifdef _WIN32 -#define COBJMACROS -#include -#include -#include -#endif #include #include +#include #include +#include #include #include #define BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT 2048 - -#ifdef _WIN32 -static bool needs_wic_scaling(const char *path, int limit) { - FILE *file = bongo_cat_file_open(path, "rb"); - int width = 0, height = 0, channels = 0; - bool known = file && stbi_info_from_file(file, &width, &height, &channels); - if (file) fclose(file); - return !known || width > limit || height > limit; -} - -static wchar_t *wide_path(const char *path) { - int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, - path, -1, NULL, 0); - wchar_t *wide = count > 0 ? malloc((size_t)count * sizeof(*wide)) : NULL; - if (wide && !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, - path, -1, wide, count)) { - free(wide); return NULL; - } - return wide; -} - -static bool wic_scaled_image(const char *path, BongoCatImage *image, - UINT max_width, UINT max_height) { - memset(image, 0, sizeof(*image)); - HRESULT initialized = CoInitializeEx(NULL, COINIT_MULTITHREADED); - bool uninitialize = initialized == S_OK || initialized == S_FALSE; - if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return false; - IWICImagingFactory *factory = NULL; - IWICBitmapDecoder *decoder = NULL; - IWICBitmapFrameDecode *frame = NULL; - IWICBitmapScaler *scaler = NULL; - IWICFormatConverter *converter = NULL; - wchar_t *wide = wide_path(path); - HRESULT result = wide ? CoCreateInstance(&CLSID_WICImagingFactory, NULL, - CLSCTX_INPROC_SERVER, &IID_IWICImagingFactory, (void **)&factory) : E_FAIL; - if (SUCCEEDED(result)) result = IWICImagingFactory_CreateDecoderFromFilename(factory, - wide, NULL, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder); - if (SUCCEEDED(result)) result = IWICBitmapDecoder_GetFrame(decoder, 0, &frame); - UINT source_width = 0, source_height = 0; - if (SUCCEEDED(result)) result = IWICBitmapFrameDecode_GetSize(frame, - &source_width, &source_height); - UINT target_width = source_width, target_height = source_height; - if (max_width && max_height && - (source_width > max_width || source_height > max_height)) { - if ((uint64_t)max_width * source_height <= - (uint64_t)max_height * source_width) { - target_width = max_width; - target_height = (UINT)((uint64_t)source_height * max_width / - source_width); - } else { - target_height = max_height; - target_width = (UINT)((uint64_t)source_width * max_height / - source_height); - } - if (!target_width) target_width = 1; - if (!target_height) target_height = 1; - if (SUCCEEDED(result)) result = IWICImagingFactory_CreateBitmapScaler(factory, &scaler); - if (SUCCEEDED(result)) result = IWICBitmapScaler_Initialize(scaler, - (IWICBitmapSource *)frame, target_width, target_height, - WICBitmapInterpolationModeFant); - } - IWICBitmapSource *source = scaler ? (IWICBitmapSource *)scaler : - (IWICBitmapSource *)frame; - if (SUCCEEDED(result)) - result = IWICImagingFactory_CreateFormatConverter(factory, &converter); - if (SUCCEEDED(result)) result = IWICFormatConverter_Initialize(converter, source, - &GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, NULL, 0, - WICBitmapPaletteTypeCustom); - size_t stride = (size_t)target_width * 4; - size_t bytes = stride * target_height; - unsigned char *pixels = bytes <= UINT_MAX ? malloc(bytes) : NULL; - if (SUCCEEDED(result) && pixels) result = IWICFormatConverter_CopyPixels(converter, - NULL, (UINT)stride, (UINT)bytes, pixels); - bool ok = SUCCEEDED(result) && pixels; - if (ok) { - image->pixels = pixels; - image->width = (int)target_width; - image->height = (int)target_height; - } else free(pixels); - if (converter) IWICFormatConverter_Release(converter); - if (scaler) IWICBitmapScaler_Release(scaler); - if (frame) IWICBitmapFrameDecode_Release(frame); - if (decoder) IWICBitmapDecoder_Release(decoder); - if (factory) IWICImagingFactory_Release(factory); - free(wide); - if (uninitialize) CoUninitialize(); - return ok; -} -#endif - BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, BongoCatError *error) { - if (!path || !image) return BONGO_CAT_ERROR_ARGUMENT; - memset(image, 0, sizeof(*image)); - int channels; - FILE *file = bongo_cat_file_open(path, "rb"); - image->pixels = file ? stbi_load_from_file(file, &image->width, - &image->height, &channels, STBI_rgb_alpha) : NULL; - image->pixels_stbi = image->pixels != NULL; - if (file) fclose(file); - if (!image->pixels) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot decode image: %s", path); - return BONGO_CAT_ERROR_IO; - } + BongoCatResult result = bongo_cat_image_decode_pixels(path, image, error); + if (result != BONGO_CAT_OK) return result; image->surface = SDL_CreateSurfaceFrom(image->width, image->height, SDL_PIXELFORMAT_RGBA32, image->pixels, image->width * 4); if (!image->surface) { @@ -128,7 +23,6 @@ BongoCatResult bongo_cat_image_load(const char *path, BongoCatImage *image, Bong } return BONGO_CAT_OK; } - void bongo_cat_image_free(BongoCatImage *image) { if (!image) return; if (image->surface) SDL_DestroySurface(image->surface); @@ -138,15 +32,24 @@ void bongo_cat_image_free(BongoCatImage *image) { } memset(image, 0, sizeof(*image)); } - -static unsigned int upload(const BongoCatImage *image, GLuint texture, bool model_texture) { +static unsigned int upload(const BongoCatImage *image, GLuint texture, + bool model_texture) { + /* Premultiply in place before any GPU filtering. Cubism must use its + * premultiplied shader for these model textures, including fallback. */ + if (model_texture) { + size_t count = (size_t)image->width * image->height; + for (size_t i = 0; i < count; ++i) { + unsigned char *pixel = image->pixels + i * 4; + for (int c = 0; c < 3; ++c) + pixel[c] = (unsigned char)((pixel[c] * pixel[3] + 127) / 255); + } + } bool created = texture == 0; if (created) glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); if (created) { - /* Pixi's default Live2D texture source uses linear filtering without - * generated mipmaps. This keeps the same sharpness and saves texture - * memory for large model atlases. */ + /* Keep direct image/UI textures on the inexpensive linear path. Model + * textures switch to alpha-safe trilinear filtering after upload. */ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); @@ -161,13 +64,23 @@ static unsigned int upload(const BongoCatImage *image, GLuint texture, bool mode } } glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (created) glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, - image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); - else glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, image->width, image->height, - GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + if (created && model_texture) { + if (!bongo_cat_image_upload_mipmaps(image)) { + bongo_cat_gl_clear_errors(); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, + image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } + } else if (created) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, + image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, image->width, image->height, + GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); return texture; } - unsigned int bongo_cat_image_texture(const char *path, int *width, int *height, BongoCatError *error) { BongoCatImage image; if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; @@ -182,8 +95,9 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, int max_height, int *width, int *height, BongoCatError *error) { BongoCatImage image; #ifdef _WIN32 - if (max_width > 0 && max_height > 0 && wic_scaled_image(path, &image, - (UINT)max_width, (UINT)max_height)) { + if (max_width > 0 && max_height > 0 && + bongo_cat_image_decode_wic_responsive(path, &image, + max_width, max_height, NULL, NULL)) { GLuint texture = upload(&image, 0, false); if (width) *width = image.width; if (height) *height = image.height; @@ -220,22 +134,86 @@ unsigned int bongo_cat_image_texture_thumbnail(const char *path, int max_width, bongo_cat_image_free(&image); return texture; } +typedef struct ImageProgressStage { + BongoCatImageProgress progress; + void *userdata; + float start; + float span; +} ImageProgressStage; + +static void report_stage_progress(void *userdata, float progress) { + ImageProgressStage *stage = userdata; + if (stage && stage->progress) + stage->progress(stage->userdata, stage->start + stage->span * progress); +} + +/* stb decodes a model atlas at full resolution, so an oversized sheet still + costs its whole pixel buffer transiently. Constraining the pixels before the + alpha mask and the upload bounds what stays resident in the GL texture and + its mipmap chain for as long as the model is loaded. Leaves the image + untouched and reports failure when the resize cannot be allocated. */ +static bool constrain_model_texture(BongoCatImage *image, int limit) { + if (!image || !image->pixels || limit < 1 || + (image->width <= limit && image->height <= limit)) return true; + float scale = SDL_min((float)limit / (float)image->width, + (float)limit / (float)image->height); + int target_width = SDL_max(1, (int)lroundf((float)image->width * scale)); + int target_height = SDL_max(1, (int)lroundf((float)image->height * scale)); + size_t count = (size_t)target_width * (size_t)target_height; + unsigned char *pixels = count <= SIZE_MAX / 4 ? malloc(count * 4) : NULL; + if (!pixels || !stbir_resize_uint8_srgb(image->pixels, image->width, + image->height, image->width * 4, pixels, target_width, target_height, + target_width * 4, STBIR_RGBA)) { + free(pixels); + return false; + } + unsigned char *previous_pixels = image->pixels; + bool previous_stbi = image->pixels_stbi; + SDL_Surface *previous_surface = image->surface; + image->pixels = pixels; + image->pixels_stbi = false; + image->surface = NULL; + image->width = target_width; + image->height = target_height; + if (previous_surface) SDL_DestroySurface(previous_surface); + if (previous_stbi) stbi_image_free(previous_pixels); + else free(previous_pixels); + return true; +} + unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, - int *width, int *height, BongoCatImageAlphaMask *alpha, BongoCatError *error) { - BongoCatImage image; + int *width, int *height, BongoCatImageAlphaMask *alpha, + BongoCatImageProgress progress, void *userdata, BongoCatError *error) { + BongoCatImage image = {0}; + ImageProgressStage stage = {progress, userdata, 0.0f, .30f}; + BongoCatImageProgress staged = progress ? report_stage_progress : NULL; GLint hardware_limit = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &hardware_limit); if (hardware_limit < 1) hardware_limit = BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT; #ifdef _WIN32 - int decode_limit = hardware_limit; + /* + * A model atlas can advertise 8192px even when the pet is rendered in a + * few hundred pixels. Decoding that atlas at full size costs hundreds of + * MB of temporary RAM and makes alpha-mask generation and glTexImage2D + * dominate model switching. Keep a bounded working size for normal loads; + * this is still well above the largest bundled 1024px atlas. The hardware + * limit remains authoritative on low-end GPUs. + */ + int decode_limit = SDL_min(hardware_limit, + BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); /* Preset atlases are verified byte-identical in WIC and stb. Keep WIC for custom PNG color metadata and for memory-bounded oversized decoding. */ - bool scaled = needs_wic_scaling(path, decode_limit); + bool scaled = bongo_cat_image_needs_wic_scaling(path, decode_limit); if (!direct_decode || scaled) { - if (!wic_scaled_image(path, &image, (UINT)decode_limit, - (UINT)decode_limit) && - bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; - } else if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; + stage.span = .20f; + if (!bongo_cat_image_decode_wic_responsive(path, &image, + decode_limit, decode_limit, staged, &stage)) { + stage = (ImageProgressStage){progress, userdata, .20f, .10f}; + if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; + } + } else if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; if (scaled) SDL_Log("Live2D texture constrained to %dx%d by a %d pixel " "texture limit: %s", image.width, image.height, decode_limit, path); else if (image.width > BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT || @@ -244,9 +222,26 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, image.width, image.height, path); #else (void)direct_decode; - if (bongo_cat_image_load(path, &image, error) != BONGO_CAT_OK) return 0; + if (bongo_cat_image_decode_pixels_responsive(path, &image, + staged, &stage, error) != BONGO_CAT_OK) return 0; + /* WIC scales an oversized atlas while decoding; stb cannot, so the same + bound is applied to the decoded pixels. Only the resident texture and + its mipmap chain shrink; the decode peak is unchanged. */ + int efficient_limit = SDL_min(hardware_limit, + BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); + if (image.width > efficient_limit || image.height > efficient_limit) { + if (constrain_model_texture(&image, efficient_limit)) + SDL_Log("Live2D texture constrained to %dx%d by a %d pixel " + "texture limit: %s", image.width, image.height, + efficient_limit, path); + else SDL_LogWarn(SDL_LOG_CATEGORY_RENDER, "Live2D texture kept at " + "%dx%d because constraining it to %d pixels failed: %s", + image.width, image.height, efficient_limit, path); + } #endif - bongo_cat_image_make_alpha_mask(&image, alpha); + if (progress) progress(userdata, .30f); + stage = (ImageProgressStage){progress, userdata, .30f, .30f}; + bongo_cat_image_make_alpha_mask_progress(&image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); GLuint texture = upload(&image, 0, true); GLenum upload_error = glGetError(); @@ -259,9 +254,12 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, bongo_cat_image_free(&image); int fallback_limit = SDL_min(hardware_limit, BONGO_CAT_LIVE2D_EFFICIENT_TEXTURE_LIMIT); - if (wic_scaled_image(path, &image, (UINT)fallback_limit, - (UINT)fallback_limit)) { - bongo_cat_image_make_alpha_mask(&image, alpha); + stage = (ImageProgressStage){progress, userdata, .90f, .04f}; + if (bongo_cat_image_decode_wic_responsive(path, &image, + fallback_limit, fallback_limit, staged, &stage)) { + stage = (ImageProgressStage){progress, userdata, .94f, .03f}; + bongo_cat_image_make_alpha_mask_progress( + &image, alpha, staged, &stage); bongo_cat_gl_clear_errors(); texture = upload(&image, 0, true); upload_error = glGetError(); @@ -282,6 +280,7 @@ unsigned int bongo_cat_image_texture_model(const char *path, bool direct_decode, if (width) *width = image.width; if (height) *height = image.height; bongo_cat_image_free(&image); + if (progress) progress(userdata, 1.0f); return texture; } diff --git a/src/media/image_alpha.c b/src/media/image_alpha.c index 246eb8ba..2497a05a 100644 --- a/src/media/image_alpha.c +++ b/src/media/image_alpha.c @@ -2,24 +2,49 @@ #include -void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, - BongoCatImageAlphaMask *mask) { - if (!mask) return; +void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, + BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, + void *userdata) { + if (!mask) { + if (progress) progress(userdata, 1.0f); + return; + } memset(mask, 0, sizeof(*mask)); - if (!image || !image->pixels || image->width <= 0 || image->height <= 0) return; + if (!image || !image->pixels || image->width <= 0 || image->height <= 0) { + if (progress) progress(userdata, 1.0f); + return; + } mask->width = image->width < BONGO_CAT_ALPHA_MASK_SIZE ? image->width : BONGO_CAT_ALPHA_MASK_SIZE; mask->height = image->height < BONGO_CAT_ALPHA_MASK_SIZE ? image->height : BONGO_CAT_ALPHA_MASK_SIZE; - for (int y = 0; y < image->height; ++y) { - int target_y = y * mask->height / image->height; - for (int x = 0; x < image->width; ++x) { - int target_x = x * mask->width / image->width; - unsigned char alpha = image->pixels[ - ((size_t)y * image->width + x) * 4 + 3]; - unsigned char *target = &mask->pixels[ - (size_t)target_y * mask->width + target_x]; - if (alpha > *target) *target = alpha; + for (int target_y = 0; target_y < mask->height; ++target_y) { + int y_begin = (int)(((int64_t)target_y * image->height + + mask->height - 1) / mask->height); + int y_end = (int)(((int64_t)(target_y + 1) * image->height + + mask->height - 1) / mask->height); + for (int target_x = 0; target_x < mask->width; ++target_x) { + int x_begin = (int)(((int64_t)target_x * image->width + + mask->width - 1) / mask->width); + int x_end = (int)(((int64_t)(target_x + 1) * image->width + + mask->width - 1) / mask->width); + unsigned char maximum = 0; + for (int y = y_begin; y < y_end && maximum < 255; ++y) { + const unsigned char *pixel = image->pixels + + ((size_t)y * image->width + x_begin) * 4 + 3; + for (int x = x_begin; x < x_end; ++x, pixel += 4) { + if (*pixel > maximum) maximum = *pixel; + if (maximum == 255) break; + } + } + mask->pixels[(size_t)target_y * mask->width + target_x] = maximum; } + if (progress) + progress(userdata, (float)(target_y + 1) / mask->height); } } + +void bongo_cat_image_make_alpha_mask(const BongoCatImage *image, + BongoCatImageAlphaMask *mask) { + bongo_cat_image_make_alpha_mask_progress(image, mask, NULL, NULL); +} diff --git a/src/media/image_decode.c b/src/media/image_decode.c new file mode 100644 index 00000000..105edce9 --- /dev/null +++ b/src/media/image_decode.c @@ -0,0 +1,221 @@ +#include "image_internal.h" + +#include "bongo_cat/file.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define COBJMACROS +#include +#include +#include +#endif + +BongoCatResult bongo_cat_image_decode_pixels(const char *path, + BongoCatImage *image, BongoCatError *error) { + if (!path || !image) return BONGO_CAT_ERROR_ARGUMENT; + memset(image, 0, sizeof(*image)); + int channels; + FILE *file = bongo_cat_file_open(path, "rb"); + image->pixels = file ? stbi_load_from_file(file, &image->width, + &image->height, &channels, STBI_rgb_alpha) : NULL; + image->pixels_stbi = image->pixels != NULL; + if (file) fclose(file); + if (image->pixels) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot decode image: %s", path); + return BONGO_CAT_ERROR_IO; +} + +typedef struct PixelDecodeJob { + const char *path; + BongoCatImage *image; + BongoCatError *error; + BongoCatResult result; +} PixelDecodeJob; + +static int SDLCALL decode_pixels_worker(void *userdata) { + /* Workers only produce CPU pixels; Cubism and OpenGL remain on the caller. */ + PixelDecodeJob *job = userdata; + job->result = bongo_cat_image_decode_pixels(job->path, + job->image, job->error); + return (int)job->result; +} + +static void wait_for_decode(SDL_Thread *worker, + BongoCatImageProgress progress, void *userdata) { + while (SDL_GetThreadState(worker) == SDL_THREAD_ALIVE) { + if (progress) progress(userdata, .5f); + SDL_Delay(2); + } + SDL_WaitThread(worker, NULL); + if (progress) progress(userdata, 1.0f); +} + +BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, + BongoCatImage *image, BongoCatImageProgress progress, void *userdata, + BongoCatError *error) { + if (!progress) + return bongo_cat_image_decode_pixels(path, image, error); + PixelDecodeJob job = {path, image, error, BONGO_CAT_ERROR_PLATFORM}; + SDL_Thread *worker = SDL_CreateThread(decode_pixels_worker, + BONGO_CAT_SLUG "-image-decode", &job); + if (!worker) + return bongo_cat_image_decode_pixels(path, image, error); + wait_for_decode(worker, progress, userdata); + return job.result; +} + +#ifdef _WIN32 +bool bongo_cat_image_needs_wic_scaling(const char *path, int limit) { + int width = 0, height = 0; + bool known = bongo_cat_image_info(path, &width, &height); + return !known || width > limit || height > limit; +} + +static wchar_t *wide_path(const char *path) { + int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + path, -1, NULL, 0); + wchar_t *wide = count > 0 ? malloc((size_t)count * sizeof(*wide)) : NULL; + if (wide && !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + path, -1, wide, count)) { + free(wide); + return NULL; + } + return wide; +} + +static bool decode_wic(const char *path, BongoCatImage *image, + UINT max_width, UINT max_height, BongoCatImageProgress progress, + void *userdata) { + memset(image, 0, sizeof(*image)); + HRESULT initialized = CoInitializeEx(NULL, COINIT_MULTITHREADED); + bool uninitialize = initialized == S_OK || initialized == S_FALSE; + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return false; + IWICImagingFactory *factory = NULL; + IWICBitmapDecoder *decoder = NULL; + IWICBitmapFrameDecode *frame = NULL; + IWICBitmapScaler *scaler = NULL; + IWICFormatConverter *premultiplied = NULL; + IWICFormatConverter *converter = NULL; + wchar_t *wide = wide_path(path); + HRESULT result = wide ? CoCreateInstance(&CLSID_WICImagingFactory, NULL, + CLSCTX_INPROC_SERVER, &IID_IWICImagingFactory, + (void **)&factory) : E_FAIL; + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateDecoderFromFilename(factory, wide, + NULL, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder); + if (SUCCEEDED(result)) result = IWICBitmapDecoder_GetFrame(decoder, 0, &frame); + UINT source_width = 0, source_height = 0; + if (SUCCEEDED(result)) + result = IWICBitmapFrameDecode_GetSize(frame, + &source_width, &source_height); + UINT target_width = source_width, target_height = source_height; + if (max_width && max_height && + (source_width > max_width || source_height > max_height)) { + if ((uint64_t)max_width * source_height <= + (uint64_t)max_height * source_width) { + target_width = max_width; + target_height = (UINT)((uint64_t)source_height * max_width / + source_width); + } else { + target_height = max_height; + target_width = (UINT)((uint64_t)source_width * max_height / + source_height); + } + if (!target_width) target_width = 1; + if (!target_height) target_height = 1; + /* Keep transparent RGB out of the resize filter. WIC pulls pixels + * lazily, so this does not allocate a full-size RGBA atlas. */ + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateFormatConverter(factory, &premultiplied); + if (SUCCEEDED(result)) + result = IWICFormatConverter_Initialize(premultiplied, + (IWICBitmapSource *)frame, &GUID_WICPixelFormat32bppPRGBA, + WICBitmapDitherTypeNone, NULL, 0, WICBitmapPaletteTypeCustom); + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateBitmapScaler(factory, &scaler); + if (SUCCEEDED(result)) + result = IWICBitmapScaler_Initialize(scaler, + (IWICBitmapSource *)premultiplied, target_width, target_height, + WICBitmapInterpolationModeFant); + } + IWICBitmapSource *source = scaler ? (IWICBitmapSource *)scaler : + (IWICBitmapSource *)frame; + if (SUCCEEDED(result)) + result = IWICImagingFactory_CreateFormatConverter(factory, &converter); + if (SUCCEEDED(result)) + result = IWICFormatConverter_Initialize(converter, source, + &GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, NULL, 0, + WICBitmapPaletteTypeCustom); + size_t stride = target_width <= SIZE_MAX / 4 + ? (size_t)target_width * 4 : 0; + size_t bytes = stride && target_height <= SIZE_MAX / stride + ? stride * target_height : 0; + unsigned char *pixels = bytes <= UINT_MAX ? malloc(bytes) : NULL; + if (SUCCEEDED(result) && pixels) { + for (UINT y = 0; y < target_height && SUCCEEDED(result); y += 256) { + UINT rows = SDL_min(256u, target_height - y); + WICRect rect = {0, (INT)y, (INT)target_width, (INT)rows}; + result = IWICFormatConverter_CopyPixels(converter, &rect, + (UINT)stride, (UINT)(stride * rows), + pixels + (size_t)y * stride); + if (progress) + progress(userdata, + (float)(y + rows) / (float)target_height); + } + } + bool ok = SUCCEEDED(result) && pixels; + if (ok) { + image->pixels = pixels; + image->width = (int)target_width; + image->height = (int)target_height; + } else { + free(pixels); + } + if (converter) IWICFormatConverter_Release(converter); + if (scaler) IWICBitmapScaler_Release(scaler); + if (premultiplied) IWICFormatConverter_Release(premultiplied); + if (frame) IWICBitmapFrameDecode_Release(frame); + if (decoder) IWICBitmapDecoder_Release(decoder); + if (factory) IWICImagingFactory_Release(factory); + free(wide); + if (uninitialize) CoUninitialize(); + return ok; +} + +typedef struct WicDecodeJob { + const char *path; + BongoCatImage *image; + UINT max_width; + UINT max_height; + bool result; +} WicDecodeJob; + +static int SDLCALL decode_wic_worker(void *userdata) { + WicDecodeJob *job = userdata; + job->result = decode_wic(job->path, job->image, + job->max_width, job->max_height, NULL, NULL); + return job->result ? 0 : -1; +} + +bool bongo_cat_image_decode_wic_responsive(const char *path, + BongoCatImage *image, int max_width, int max_height, + BongoCatImageProgress progress, void *userdata) { + if (!progress) + return decode_wic(path, image, (UINT)max_width, + (UINT)max_height, NULL, NULL); + WicDecodeJob job = {path, image, (UINT)max_width, (UINT)max_height, false}; + SDL_Thread *worker = SDL_CreateThread(decode_wic_worker, + BONGO_CAT_SLUG "-wic-decode", &job); + if (!worker) + return decode_wic(path, image, (UINT)max_width, + (UINT)max_height, progress, userdata); + wait_for_decode(worker, progress, userdata); + return job.result; +} +#endif diff --git a/src/media/image_info.c b/src/media/image_info.c new file mode 100644 index 00000000..453ef46e --- /dev/null +++ b/src/media/image_info.c @@ -0,0 +1,18 @@ +#include "bongo_cat/file.h" +#include "bongo_cat/image.h" + +#include + +bool bongo_cat_image_info(const char *path, int *width, int *height) { + if (width) *width = 0; + if (height) *height = 0; + FILE *file = path ? bongo_cat_file_open(path, "rb") : NULL; + int image_width = 0, image_height = 0, channels = 0; + bool known = file && stbi_info_from_file(file, &image_width, + &image_height, &channels) && image_width > 0 && image_height > 0 && + channels > 0; + if (file) fclose(file); + if (known && width) *width = image_width; + if (known && height) *height = image_height; + return known; +} diff --git a/src/media/image_internal.h b/src/media/image_internal.h new file mode 100644 index 00000000..49d108fd --- /dev/null +++ b/src/media/image_internal.h @@ -0,0 +1,24 @@ +#ifndef BONGO_CAT_IMAGE_INTERNAL_H +#define BONGO_CAT_IMAGE_INTERNAL_H + +#include "bongo_cat/image.h" + +BongoCatResult bongo_cat_image_decode_pixels(const char *path, + BongoCatImage *image, BongoCatError *error); +BongoCatResult bongo_cat_image_decode_pixels_responsive(const char *path, + BongoCatImage *image, BongoCatImageProgress progress, void *userdata, + BongoCatError *error); +void bongo_cat_image_make_alpha_mask_progress(const BongoCatImage *image, + BongoCatImageAlphaMask *mask, BongoCatImageProgress progress, + void *userdata); +/* Input pixels must use premultiplied RGBA. */ +bool bongo_cat_image_upload_mipmaps(const BongoCatImage *image); + +#ifdef _WIN32 +bool bongo_cat_image_needs_wic_scaling(const char *path, int limit); +bool bongo_cat_image_decode_wic_responsive(const char *path, + BongoCatImage *image, int max_width, int max_height, + BongoCatImageProgress progress, void *userdata); +#endif + +#endif diff --git a/src/media/image_mipmap.c b/src/media/image_mipmap.c new file mode 100644 index 00000000..081475db --- /dev/null +++ b/src/media/image_mipmap.c @@ -0,0 +1,69 @@ +#include "image_internal.h" + +#include +#include +#include +#include + +static unsigned char *next_level(const unsigned char *source, + int source_width, int source_height, int *width, int *height) { + *width = source_width > 1 ? source_width / 2 : 1; + *height = source_height > 1 ? source_height / 2 : 1; + size_t count = (size_t)*width * (size_t)*height; + unsigned char *target = count <= SIZE_MAX / 4 ? malloc(count * 4) : NULL; + if (!target) return NULL; + for (int y = 0; y < *height; ++y) for (int x = 0; x < *width; ++x) { + int sx = x * 2, sy = y * 2; + int sx1 = SDL_min(sx + 1, source_width - 1); + int sy1 = SDL_min(sy + 1, source_height - 1); + const unsigned char *samples[] = { + source + ((size_t)sy * source_width + sx) * 4, + source + ((size_t)sy * source_width + sx1) * 4, + source + ((size_t)sy1 * source_width + sx) * 4, + source + ((size_t)sy1 * source_width + sx1) * 4}; + unsigned alpha = 0, red = 0, green = 0, blue = 0; + for (size_t i = 0; i < 4; ++i) { + unsigned sample_alpha = samples[i][3]; + alpha += sample_alpha; + red += samples[i][0]; + green += samples[i][1]; + blue += samples[i][2]; + } + unsigned char *pixel = target + ((size_t)y * *width + x) * 4; + pixel[3] = (unsigned char)((alpha + 2) / 4); + /* Source RGB already includes alpha; averaging it again with alpha + * weights would darken translucent edges at every mip level. */ + pixel[0] = (unsigned char)((red + 2) / 4); + pixel[1] = (unsigned char)((green + 2) / 4); + pixel[2] = (unsigned char)((blue + 2) / 4); + } + return target; +} + +bool bongo_cat_image_upload_mipmaps(const BongoCatImage *image) { + if (!image || !image->pixels || image->width < 1 || image->height < 1) + return false; + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, + 0, GL_RGBA, GL_UNSIGNED_BYTE, image->pixels); + if (glGetError() != GL_NO_ERROR) return false; + const unsigned char *source = image->pixels; + unsigned char *owned = NULL; + int source_width = image->width, source_height = image->height, level = 1; + while (source_width > 1 || source_height > 1) { + int width, height; + unsigned char *target = next_level(source, source_width, source_height, + &width, &height); + if (!target) { free(owned); return false; } + glTexImage2D(GL_TEXTURE_2D, level++, GL_RGBA8, width, height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, target); + if (glGetError() != GL_NO_ERROR) { + free(target); free(owned); return false; + } + free(owned); + owned = target; + source = target; source_width = width; source_height = height; + } + free(owned); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + return glGetError() == GL_NO_ERROR; +} diff --git a/src/media/stb_image_impl.c b/src/media/stb_image_impl.c index 513f7b1e..211999da 100644 --- a/src/media/stb_image_impl.c +++ b/src/media/stb_image_impl.c @@ -1,6 +1,7 @@ #define STB_IMAGE_IMPLEMENTATION #define STBI_ONLY_PNG #define STBI_ONLY_JPEG +#define STBI_NO_LINEAR #include #define STB_IMAGE_WRITE_IMPLEMENTATION diff --git a/src/platform/memory.c b/src/platform/common/memory.c similarity index 100% rename from src/platform/memory.c rename to src/platform/common/memory.c diff --git a/src/platform/common/update_shutdown.c b/src/platform/common/update_shutdown.c new file mode 100644 index 00000000..69f9c516 --- /dev/null +++ b/src/platform/common/update_shutdown.c @@ -0,0 +1,13 @@ +#include "bongo_cat/platform.h" + +#ifndef _WIN32 +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv) { + (void)argc; + (void)argv; + return false; +} + +bool bongo_cat_platform_single_instance_take_update_shutdown(void) { + return false; +} +#endif diff --git a/src/platform/linux.c b/src/platform/linux/linux.c similarity index 75% rename from src/platform/linux.c rename to src/platform/linux/linux.c index 66caa9f0..3edb9b5c 100644 --- a/src/platform/linux.c +++ b/src/platform/linux/linux.c @@ -3,6 +3,7 @@ #endif #include "bongo_cat/platform.h" +#include "bongo_cat/common.h" #include "bongo_cat/path.h" #include "linux_internal.h" @@ -24,6 +25,10 @@ static int instance_lock = -1; static BongoCatPlatform *active_platform; +void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { + (void)window; +} + static void publish_instance_window(SDL_Window *window) { if (instance_lock < 0 || !window) return; Window id = (Window)SDL_GetNumberProperty(SDL_GetWindowProperties(window), @@ -70,21 +75,49 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w platform->window = window; platform->input = input; platform->window_opacity = 1.0f; + const char *driver = SDL_GetCurrentVideoDriver(); + platform->hover_hide_unavailable = !driver || strcmp(driver, "x11") != 0; + if (platform->hover_hide_unavailable) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Hover hiding requires the X11 backend on Linux"); platform->wake_event_type = SDL_RegisterEvents(1); if (platform->wake_event_type == (Uint32)-1) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot reserve the Linux input wake event"); return BONGO_CAT_ERROR_PLATFORM; } + LinuxPlatformState *native = calloc(1, sizeof(*native)); + if (!native) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot allocate Linux platform state"); + return BONGO_CAT_ERROR_PLATFORM; + } + platform->native = native; active_platform = platform; publish_instance_window(window); + const char *wayland = getenv("WAYLAND_DISPLAY"); + const char *session = getenv("XDG_SESSION_TYPE"); + native->wayland = (wayland && wayland[0]) || + (driver && strcmp(driver, "wayland") == 0) || + (session && strcmp(session, "wayland") == 0); + native->evdev_selected = bongo_cat_linux_evdev_requested( + getenv("BONGOCAT_ENABLE_EVDEV"), native->wayland); BongoCatError input_error = {0}; if (!bongo_cat_linux_x11_start(platform, &input_error) && input_error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); + if (native->evdev_selected) { + input_error = (BongoCatError){0}; + if (!bongo_cat_linux_evdev_start(platform, &input_error) && input_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", input_error.message); + } return BONGO_CAT_OK; } void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { + if (!platform) return; bongo_cat_linux_x11_stop(platform); + bongo_cat_linux_evdev_stop(platform); + free(platform->native); + platform->native = NULL; if (active_platform == platform) active_platform = NULL; } void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, @@ -93,6 +126,8 @@ void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, } bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; + opacity = SDL_clamp(opacity, 0.0f, 1.0f); + if (opacity == platform->window_opacity) return true; if (!SDL_SetWindowOpacity(platform->window, opacity)) return false; platform->window_opacity = opacity; return true; @@ -112,7 +147,11 @@ bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible) { if (!platform || !platform->window) return; visible ? SDL_ShowWindow(platform->window) : SDL_HideWindow(platform->window); - if (visible) bongo_cat_linux_x11_configure_capture_window(platform); + if (visible) { + bongo_cat_linux_x11_configure_capture_window(platform); + const LinuxPlatformState *native = platform->native; + if (native) bongo_cat_linux_x11_set_above(platform, native->always_on_top); + } } bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_x, double screen_y, float *local_x, float *local_y) { @@ -123,23 +162,32 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ *local_x = (float)(screen_x - x); *local_y = (float)(screen_y - y); return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + return bongo_cat_linux_evdev_pointer_active(platform); +} bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { - (void)platform; (void)x; (void)y; - return false; + return bongo_cat_linux_evdev_relative_pointer(platform, x, y); } void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { - (void)platform; + bongo_cat_linux_evdev_relative_pointer_reset(platform); +} +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + bongo_cat_linux_evdev_relative_pointer_reset(platform); } void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { + if (!platform || !platform->window) return; + LinuxPlatformState *native = platform->native; + if (native) native->always_on_top = enabled; SDL_SetWindowAlwaysOnTop(platform->window, enabled); + bongo_cat_linux_x11_set_above(platform, enabled); bongo_cat_linux_x11_configure_capture_window(platform); } void bongo_cat_platform_raise_window(SDL_Window *window) { if (!window) return; - SDL_ShowWindow(window); if (active_platform && active_platform->window == window) - bongo_cat_linux_x11_configure_capture_window(active_platform); + bongo_cat_platform_set_visible(active_platform, true); + else SDL_ShowWindow(window); SDL_RaiseWindow(window); } @@ -162,7 +210,11 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bongo_cat_linux_x11_begin_drag(platform); } bool bongo_cat_platform_dynamic_hit_supported(void) { - return bongo_cat_linux_x11_supported(active_platform); + /* XWayland only forwards pointer events while one of its surfaces has + pointer focus, so an empty input region would hide the pointer + permanently and the pet could never become clickable again. */ + return bongo_cat_linux_x11_supported(active_platform) && + !bongo_cat_linux_x11_xwayland(active_platform); } bool bongo_cat_platform_open_directory(const char *path) { @@ -184,11 +236,13 @@ bool bongo_cat_platform_open_directory(const char *path) { void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { - (void)tray; (void)left_click; (void)modal_tick; (void)userdata; + BongoCatTrayRestore restore, void *userdata) { + (void)tray; (void)left_click; (void)modal_tick; + (void)restore; (void)userdata; } bool bongo_cat_platform_single_instance_begin(void) { - char path[96]; snprintf(path, sizeof(path), "/tmp/bongo-cat-%lu.lock", + char path[96]; snprintf(path, sizeof(path), "/tmp/%s-%lu.lock", + BONGO_CAT_SLUG, (unsigned long)getuid()); instance_lock = open(path, O_CREAT | O_RDWR, 0600); if (instance_lock < 0 || flock(instance_lock, LOCK_EX | LOCK_NB) == 0) return true; @@ -206,7 +260,7 @@ BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *err else if (home && bongo_cat_path_join(config, sizeof(config), home, ".config")) {} else return BONGO_CAT_ERROR_PLATFORM; if (!bongo_cat_path_join(directory, sizeof(directory), config, "autostart") || - !bongo_cat_path_join(path, sizeof(path), directory, "bongo-cat.desktop")) + !bongo_cat_path_join(path, sizeof(path), directory, BONGO_CAT_SLUG ".desktop")) return BONGO_CAT_ERROR_IO; if (!enabled) { if (remove(path) == 0 || errno == ENOENT) return BONGO_CAT_OK; @@ -228,10 +282,6 @@ BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *err remove(path); bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write Linux autostart entry"); return BONGO_CAT_ERROR_IO; } -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - return bongo_cat_linux_context_menu(platform, labels); -} BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error) { (void)target; (void)error; return BONGO_CAT_ERROR_PLATFORM; } diff --git a/src/platform/linux/linux_evdev.c b/src/platform/linux/linux_evdev.c new file mode 100644 index 00000000..70e689f6 --- /dev/null +++ b/src/platform/linux/linux_evdev.c @@ -0,0 +1,110 @@ +#include "linux_evdev_internal.h" +#include "bongo_cat/log.h" +#include +#include +#include +#include +#include + +bool bongo_cat_linux_evdev_requested(const char *option, bool wayland) { + return wayland && option && strcmp(option, "1") == 0; +} + +static LinuxEvdevState *evdev_state(const BongoCatPlatform *platform) { + const LinuxPlatformState *native = platform ? platform->native : NULL; + return native ? native->evdev : NULL; +} + +static int SDLCALL evdev_thread(void *userdata) { + LinuxEvdevState *state = userdata; + bongo_cat_evdev_scan(state); + struct epoll_event events[EVDEV_MAX_DEVICES]; + uint64_t next_scan = SDL_GetTicksNS() + EVDEV_SCAN_INTERVAL_NS; + while (atomic_load(&state->running)) { + int ready = epoll_wait(state->epoll_fd, events, EVDEV_MAX_DEVICES, + EVDEV_POLL_MS); + if (ready < 0) { + if (errno == EINTR) continue; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "evdev input wait failed"); + break; + } + for (int i = 0; i < ready; ++i) { + size_t index = 0; + for (; index < state->device_count; ++index) + if (state->devices[index].fd == events[i].data.fd) break; + if (index == state->device_count) continue; + if ((events[i].events & (EPOLLHUP | EPOLLERR)) || + !bongo_cat_evdev_read(state, &state->devices[index])) + bongo_cat_evdev_remove(state, index); + } + uint64_t now = SDL_GetTicksNS(); + if (now >= next_scan) { + next_scan = now + EVDEV_SCAN_INTERVAL_NS; + bongo_cat_evdev_scan(state); + } + } + while (state->device_count) bongo_cat_evdev_remove(state, 0); + atomic_store(&state->running, false); + return 0; +} + +bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error) { + LinuxPlatformState *native = platform ? platform->native : NULL; + if (!native || !native->evdev_selected || !platform->input) return false; + if (native->evdev) return true; + LinuxEvdevState *state = calloc(1, sizeof(*state)); + if (!state) goto failed; + state->platform = platform; + state->epoll_fd = epoll_create1(EPOLL_CLOEXEC); + state->motion_lock = SDL_CreateMutex(); + atomic_init(&state->running, true); + atomic_init(&state->pointer_active, false); + state->reported_count = SIZE_MAX; + if (state->epoll_fd < 0 || !state->motion_lock) goto failed; + state->thread = SDL_CreateThread(evdev_thread, BONGO_CAT_SLUG "-evdev-input", state); + if (!state->thread) goto failed; + native->evdev = state; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] experimental evdev input enabled"); + return true; +failed: + if (state) { + if (state->epoll_fd >= 0) close(state->epoll_fd); + SDL_DestroyMutex(state->motion_lock); + free(state); + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start evdev input listener"); + return false; +} + +void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform) { + LinuxEvdevState *state = evdev_state(platform); + if (!state) return; + atomic_store(&state->running, false); + SDL_WaitThread(state->thread, NULL); + close(state->epoll_fd); + SDL_DestroyMutex(state->motion_lock); + free(state); + ((LinuxPlatformState *)platform->native)->evdev = NULL; +} + +bool bongo_cat_linux_evdev_pointer_active(const BongoCatPlatform *platform) { + LinuxEvdevState *state = evdev_state(platform); + return state && atomic_load(&state->pointer_active); +} + +bool bongo_cat_linux_evdev_relative_pointer(BongoCatPlatform *platform, + double *dx, double *dy) { + LinuxEvdevState *state = evdev_state(platform); + if (!state || !dx || !dy) return false; + SDL_LockMutex(state->motion_lock); + *dx = state->relative_x; + *dy = state->relative_y; + state->relative_x = state->relative_y = 0; + SDL_UnlockMutex(state->motion_lock); + return *dx != 0 || *dy != 0; +} + +void bongo_cat_linux_evdev_relative_pointer_reset(BongoCatPlatform *platform) { + LinuxEvdevState *state = evdev_state(platform); + if (state) bongo_cat_evdev_motion_reset(state); +} diff --git a/src/platform/linux/linux_evdev_devices.c b/src/platform/linux/linux_evdev_devices.c new file mode 100644 index 00000000..f91bd931 --- /dev/null +++ b/src/platform/linux/linux_evdev_devices.c @@ -0,0 +1,141 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include "linux_evdev_internal.h" +#include "bongo_cat/log.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define EVDEV_DEVICE_DIRECTORY "/dev/input" +#define EVDEV_SYSFS_CLASS "/sys/class/input" + +bool bongo_cat_evdev_node_valid(const char *node) { + if (!node || strncmp(node, "event", 5) || !node[5] || + strlen(node) >= EVDEV_NODE_CAP) return false; + for (const char *p = node + 5; *p; ++p) + if (*p < '0' || *p > '9') return false; + return true; +} + +bool bongo_cat_evdev_mask_bit(const char *text, unsigned word_bits, unsigned code) { + if (!text || (word_bits != 32 && word_bits != 64)) return false; + unsigned long long words[KEY_CNT / 32 + 1]; + size_t count = 0; + while (*text) { + if (*text == ' ' || *text == '\n' || *text == '\t') { ++text; continue; } + if (count == sizeof(words) / sizeof(words[0]) || + !((*text >= '0' && *text <= '9') || (*text >= 'a' && *text <= 'f') || + (*text >= 'A' && *text <= 'F'))) return false; + char *end = NULL; + errno = 0; + unsigned long long word = strtoull(text, &end, 16); + if (errno || end == text || (*end && *end != ' ' && *end != '\n' && + *end != '\t') || (word_bits == 32 && word > UINT32_MAX)) return false; + words[count++] = word; + text = end; + } + unsigned block = code / word_bits, bit = code % word_bits; + return block < count && ((words[count - block - 1] >> bit) & 1u); +} + +static bool read_mask(const char *node, const char *name, char text[512]) { + char path[BONGO_CAT_PATH_CAP]; + int length = snprintf(path, sizeof(path), "%s/%s/device/capabilities/%s", + EVDEV_SYSFS_CLASS, node, name); + if (length <= 0 || (size_t)length >= sizeof(path)) return false; + FILE *file = fopen(path, "r"); + if (!file) return false; + bool ok = fgets(text, 512, file) != NULL && !ferror(file); + if (ok && !strchr(text, '\n') && !feof(file)) ok = false; + fclose(file); + return ok; +} + +static bool classify_device(const char *node, EvdevDevice *device) { + char keys[512], relative[512]; + if (!read_mask(node, "key", keys)) return false; + /* Sysfs uses the native kernel unsigned-long bitmap representation. */ + unsigned bits = sizeof(unsigned long) * CHAR_BIT; + bool left = bongo_cat_evdev_mask_bit(keys, bits, BTN_LEFT); + device->pointer = left; + device->keyboard = !left && bongo_cat_evdev_mask_bit(keys, bits, KEY_A) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_Z) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_ENTER) && + bongo_cat_evdev_mask_bit(keys, bits, KEY_SPACE); + device->relative = left && read_mask(node, "rel", relative) && + bongo_cat_evdev_mask_bit(relative, bits, REL_X) && + bongo_cat_evdev_mask_bit(relative, bits, REL_Y); + return device->keyboard || device->pointer; +} + +static void report_devices(LinuxEvdevState *state) { + bool relative = false; + for (size_t i = 0; i < state->device_count; ++i) + relative |= state->devices[i].relative; + atomic_store(&state->pointer_active, relative); + if (!relative) bongo_cat_evdev_motion_reset(state); + if (state->reported_count == state->device_count) return; + state->reported_count = state->device_count; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] evdev listener: %zu readable input devices", state->device_count); +} + +void bongo_cat_evdev_remove(LinuxEvdevState *state, size_t index) { + if (index >= state->device_count) return; + EvdevDevice *device = &state->devices[index]; + bongo_cat_evdev_release(state, device); + epoll_ctl(state->epoll_fd, EPOLL_CTL_DEL, device->fd, NULL); + close(device->fd); + --state->device_count; + if (index != state->device_count) *device = state->devices[state->device_count]; + memset(&state->devices[state->device_count], 0, sizeof(*device)); + report_devices(state); +} + +void bongo_cat_evdev_scan(LinuxEvdevState *state) { + DIR *directory = opendir(EVDEV_DEVICE_DIRECTORY); + if (!directory) return; + struct dirent *entry; + while ((entry = readdir(directory)) && state->device_count < EVDEV_MAX_DEVICES) { + const char *node = entry->d_name; + if (!bongo_cat_evdev_node_valid(node)) continue; + size_t i = 0; + for (; i < state->device_count; ++i) + if (!strcmp(state->devices[i].node, node)) break; + if (i < state->device_count) continue; + EvdevDevice device = {0}; + if (!classify_device(node, &device)) continue; + int fd = openat(dirfd(directory), node, + O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + if ((errno == EACCES || errno == EPERM) && !state->denied_reported) { + state->denied_reported = true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "evdev input permission denied; see SECURITY.md. " + "BongoCat does not change device permissions."); + } + continue; + } + struct stat info; + if (fstat(fd, &info) || !S_ISCHR(info.st_mode)) { close(fd); continue; } + struct epoll_event interest = {.events = EPOLLIN}; + interest.data.fd = fd; + if (epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, fd, &interest)) { + close(fd); + continue; + } + device.fd = fd; + memcpy(device.node, node, strlen(node) + 1); + state->devices[state->device_count++] = device; + } + closedir(directory); + report_devices(state); +} diff --git a/src/platform/linux/linux_evdev_events.c b/src/platform/linux/linux_evdev_events.c new file mode 100644 index 00000000..9236b381 --- /dev/null +++ b/src/platform/linux/linux_evdev_events.c @@ -0,0 +1,93 @@ +#include "linux_evdev_internal.h" +#include +#include +#include + +static void wake_consumer(LinuxEvdevState *state) { + SDL_Event wake = {0}; + wake.type = state->platform->wake_event_type; + SDL_PushEvent(&wake); +} + +static const char *button_name(unsigned code) { + switch (code) { + case BTN_LEFT: return "Left"; + case BTN_MIDDLE: return "Middle"; + case BTN_RIGHT: return "Right"; + case BTN_SIDE: return "Back"; + case BTN_EXTRA: return "Forward"; + default: return NULL; + } +} + +static void key_event(LinuxEvdevState *state, EvdevDevice *device, + unsigned code, bool down) { + if (code >= KEY_CNT || device->down[code] == down) return; + bool mouse = code >= BTN_MISC; + const char *name = mouse ? button_name(code) : bongo_cat_evdev_key_name(code); + if (!name || (mouse ? !device->pointer : !device->keyboard)) return; + unsigned index = bongo_cat_evdev_key_index(code); + device->down[code] = down; + if (down) { + if (state->held[index]++ > 0) return; + } else { + if (!state->held[index] || --state->held[index] > 0) return; + } + BongoCatInputEvent input = { + .kind = mouse ? (down ? BONGO_CAT_INPUT_MOUSE_DOWN : BONGO_CAT_INPUT_MOUSE_UP) : + (down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP), + .timestamp_ms = SDL_GetTicks(), .value = down ? 1.0f : 0.0f + }; + snprintf(input.name, sizeof(input.name), "%s", name); + if (bongo_cat_input_push(state->platform->input, &input)) wake_consumer(state); +} + +void bongo_cat_evdev_release(LinuxEvdevState *state, EvdevDevice *device) { + for (unsigned code = 0; code < KEY_CNT; ++code) + if (device->down[code]) key_event(state, device, code, false); +} + +void bongo_cat_evdev_motion_reset(LinuxEvdevState *state) { + SDL_LockMutex(state->motion_lock); + state->relative_x = state->relative_y = 0; + SDL_UnlockMutex(state->motion_lock); +} + +void bongo_cat_evdev_event(LinuxEvdevState *state, EvdevDevice *device, + const struct input_event *event) { + if (event->type == EV_SYN && event->code == SYN_DROPPED) { + bongo_cat_evdev_release(state, device); + bongo_cat_evdev_motion_reset(state); + device->desynchronized = true; + return; + } + /* No state-query ioctl is permitted. After overflow, release known keys + and ignore the incomplete packet; held keys resume on their next press. */ + if (device->desynchronized) { + if (event->type == EV_SYN && event->code == SYN_REPORT) + device->desynchronized = false; + return; + } + if (event->type == EV_KEY && (event->value == 0 || event->value == 1)) { + key_event(state, device, event->code, event->value == 1); + } else if (event->type == EV_REL && device->relative && + (event->code == REL_X || event->code == REL_Y) && event->value) { + SDL_LockMutex(state->motion_lock); + bool wake = state->relative_x == 0 && state->relative_y == 0; + double *axis = event->code == REL_X ? &state->relative_x : &state->relative_y; + *axis = SDL_clamp(*axis + (double)event->value, -32768.0, 32768.0); + SDL_UnlockMutex(state->motion_lock); + if (wake) wake_consumer(state); + } +} + +bool bongo_cat_evdev_read(LinuxEvdevState *state, EvdevDevice *device) { + struct input_event events[64]; + ssize_t bytes = read(device->fd, events, sizeof(events)); + if (bytes < 0) return errno == EAGAIN || errno == EINTR; + if (!bytes || (size_t)bytes % sizeof(events[0])) return false; + size_t count = (size_t)bytes / sizeof(events[0]); + for (size_t i = 0; i < count; ++i) + bongo_cat_evdev_event(state, device, &events[i]); + return true; +} diff --git a/src/platform/linux/linux_evdev_internal.h b/src/platform/linux/linux_evdev_internal.h new file mode 100644 index 00000000..994403e8 --- /dev/null +++ b/src/platform/linux/linux_evdev_internal.h @@ -0,0 +1,51 @@ +#ifndef BONGO_CAT_LINUX_EVDEV_INTERNAL_H +#define BONGO_CAT_LINUX_EVDEV_INTERNAL_H + +#include "linux_internal.h" +#include +#include + +#define EVDEV_MAX_DEVICES 32 +#define EVDEV_NODE_CAP 32 +#define EVDEV_SCAN_INTERVAL_NS 2000000000ull +#define EVDEV_POLL_MS 250 + +typedef struct EvdevDevice { + int fd; + bool keyboard; + bool pointer; + bool relative; + bool desynchronized; + bool down[KEY_CNT]; + char node[EVDEV_NODE_CAP]; +} EvdevDevice; + +struct LinuxEvdevState { + BongoCatPlatform *platform; + SDL_Thread *thread; + SDL_Mutex *motion_lock; + atomic_bool running; + atomic_bool pointer_active; + int epoll_fd; + EvdevDevice devices[EVDEV_MAX_DEVICES]; + size_t device_count; + size_t reported_count; + bool denied_reported; + unsigned held[KEY_CNT]; + double relative_x; + double relative_y; +}; + +const char *bongo_cat_evdev_key_name(unsigned code); +unsigned bongo_cat_evdev_key_index(unsigned code); +bool bongo_cat_evdev_node_valid(const char *node); +bool bongo_cat_evdev_mask_bit(const char *text, unsigned word_bits, unsigned code); +void bongo_cat_evdev_event(LinuxEvdevState *state, EvdevDevice *device, + const struct input_event *event); +void bongo_cat_evdev_release(LinuxEvdevState *state, EvdevDevice *device); +bool bongo_cat_evdev_read(LinuxEvdevState *state, EvdevDevice *device); +void bongo_cat_evdev_scan(LinuxEvdevState *state); +void bongo_cat_evdev_remove(LinuxEvdevState *state, size_t index); +void bongo_cat_evdev_motion_reset(LinuxEvdevState *state); + +#endif diff --git a/src/platform/linux/linux_evdev_keys.c b/src/platform/linux/linux_evdev_keys.c new file mode 100644 index 00000000..5faaab1f --- /dev/null +++ b/src/platform/linux/linux_evdev_keys.c @@ -0,0 +1,56 @@ +#include "linux_evdev_internal.h" + +static const char *const evdev_key_names[KEY_CNT] = { + [KEY_A] = "KeyA", [KEY_B] = "KeyB", [KEY_C] = "KeyC", [KEY_D] = "KeyD", + [KEY_E] = "KeyE", [KEY_F] = "KeyF", [KEY_G] = "KeyG", [KEY_H] = "KeyH", + [KEY_I] = "KeyI", [KEY_J] = "KeyJ", [KEY_K] = "KeyK", [KEY_L] = "KeyL", + [KEY_M] = "KeyM", [KEY_N] = "KeyN", [KEY_O] = "KeyO", [KEY_P] = "KeyP", + [KEY_Q] = "KeyQ", [KEY_R] = "KeyR", [KEY_S] = "KeyS", [KEY_T] = "KeyT", + [KEY_U] = "KeyU", [KEY_V] = "KeyV", [KEY_W] = "KeyW", [KEY_X] = "KeyX", + [KEY_Y] = "KeyY", [KEY_Z] = "KeyZ", + [KEY_0] = "Num0", [KEY_1] = "Num1", [KEY_2] = "Num2", [KEY_3] = "Num3", + [KEY_4] = "Num4", [KEY_5] = "Num5", [KEY_6] = "Num6", [KEY_7] = "Num7", + [KEY_8] = "Num8", [KEY_9] = "Num9", + [KEY_F1] = "F1", [KEY_F2] = "F2", [KEY_F3] = "F3", [KEY_F4] = "F4", + [KEY_F5] = "F5", [KEY_F6] = "F6", [KEY_F7] = "F7", [KEY_F8] = "F8", + [KEY_F9] = "F9", [KEY_F10] = "F10", [KEY_F11] = "F11", [KEY_F12] = "F12", + [KEY_F13] = "F13", [KEY_F14] = "F14", [KEY_F15] = "F15", [KEY_F16] = "F16", + [KEY_F17] = "F17", [KEY_F18] = "F18", [KEY_F19] = "F19", [KEY_F20] = "F20", + [KEY_F21] = "F21", [KEY_F22] = "F22", [KEY_F23] = "F23", [KEY_F24] = "F24", + [KEY_ESC] = "Escape", [KEY_TAB] = "Tab", [KEY_PAUSE] = "Pause", + [KEY_SYSRQ] = "PrintScreen", [KEY_MENU] = "Apps", + [KEY_CAPSLOCK] = "CapsLock", [KEY_SPACE] = "Space", + [KEY_BACKSPACE] = "Backspace", [KEY_DELETE] = "Delete", + [KEY_INSERT] = "Insert", [KEY_HOME] = "Home", [KEY_END] = "End", + [KEY_PAGEUP] = "PageUp", [KEY_PAGEDOWN] = "PageDown", + [KEY_UP] = "UpArrow", [KEY_DOWN] = "DownArrow", + [KEY_LEFT] = "LeftArrow", [KEY_RIGHT] = "RightArrow", + [KEY_LEFTMETA] = "Meta", [KEY_RIGHTMETA] = "Meta", + [KEY_LEFTSHIFT] = "ShiftLeft", [KEY_RIGHTSHIFT] = "ShiftRight", + [KEY_LEFTCTRL] = "ControlLeft", [KEY_RIGHTCTRL] = "ControlRight", + [KEY_LEFTALT] = "Alt", [KEY_RIGHTALT] = "AltGr", + [KEY_ENTER] = "Return", [KEY_GRAVE] = "BackQuote", + [KEY_NUMLOCK] = "NumLock", [KEY_SCROLLLOCK] = "ScrollLock", + [KEY_KP0] = "Kp0", [KEY_KP1] = "Kp1", [KEY_KP2] = "Kp2", + [KEY_KP3] = "Kp3", [KEY_KP4] = "Kp4", [KEY_KP5] = "Kp5", + [KEY_KP6] = "Kp6", [KEY_KP7] = "Kp7", [KEY_KP8] = "Kp8", + [KEY_KP9] = "Kp9", [KEY_KPDOT] = "KpDecimal", + [KEY_KPASTERISK] = "KpMultiply", [KEY_KPPLUS] = "KpPlus", + [KEY_KPMINUS] = "KpMinus", [KEY_KPSLASH] = "KpDivide", + [KEY_KPENTER] = "Return", + [KEY_MINUS] = "Minus", [KEY_EQUAL] = "Equal", + [KEY_LEFTBRACE] = "BracketLeft", [KEY_RIGHTBRACE] = "BracketRight", + [KEY_BACKSLASH] = "Backslash", [KEY_SEMICOLON] = "Semicolon", + [KEY_APOSTROPHE] = "Quote", [KEY_COMMA] = "Comma", + [KEY_DOT] = "Period", [KEY_SLASH] = "Slash" +}; + +const char *bongo_cat_evdev_key_name(unsigned code) { + return code < KEY_CNT ? evdev_key_names[code] : NULL; +} + +unsigned bongo_cat_evdev_key_index(unsigned code) { + if (code == KEY_RIGHTMETA) return KEY_LEFTMETA; + if (code == KEY_KPENTER) return KEY_ENTER; + return code; +} diff --git a/src/platform/linux/linux_internal.h b/src/platform/linux/linux_internal.h new file mode 100644 index 00000000..220e8509 --- /dev/null +++ b/src/platform/linux/linux_internal.h @@ -0,0 +1,38 @@ +#ifndef BONGO_CAT_LINUX_INTERNAL_H +#define BONGO_CAT_LINUX_INTERNAL_H + +#include "bongo_cat/platform.h" + +typedef struct LinuxEvdevState LinuxEvdevState; +typedef struct LinuxPlatformState { + void *x11; + LinuxEvdevState *evdev; + bool wayland; + bool evdev_selected; + bool always_on_top; +} LinuxPlatformState; + +bool bongo_cat_linux_evdev_requested(const char *option, bool wayland); + +bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error); +void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); +/* Read-only evdev keyboard listener. Wayland compositors never forward other + applications' keys to an X11 client, so XInput2 alone cannot show key + presses on Wayland; this backend observes the kernel key stream instead. */ +bool bongo_cat_linux_evdev_start(BongoCatPlatform *platform, BongoCatError *error); +void bongo_cat_linux_evdev_stop(BongoCatPlatform *platform); +bool bongo_cat_linux_evdev_pointer_active(const BongoCatPlatform *platform); +/* Relative pointer delta accumulated by the evdev listener. Wayland never + exposes the global cursor position to other clients, so the pet follows + relative motion from the kernel instead of absolute coordinates. */ +bool bongo_cat_linux_evdev_relative_pointer(BongoCatPlatform *platform, + double *dx, double *dy); +void bongo_cat_linux_evdev_relative_pointer_reset(BongoCatPlatform *platform); +bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); +bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform); +void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); +void bongo_cat_linux_x11_set_above(BongoCatPlatform *platform, bool enabled); +void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform); +void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform); + +#endif diff --git a/src/platform/linux_x11.c b/src/platform/linux/linux_x11.c similarity index 81% rename from src/platform/linux_x11.c rename to src/platform/linux/linux_x11.c index c74a6c4b..9da241a7 100644 --- a/src/platform/linux_x11.c +++ b/src/platform/linux/linux_x11.c @@ -1,4 +1,6 @@ #include "linux_internal.h" +#include "bongo_cat/common.h" +#include "bongo_cat/log.h" #if !defined(_WIN32) && !defined(__APPLE__) #include @@ -21,10 +23,16 @@ typedef struct LinuxX11State { Window window; SDL_Thread *thread; bool key_down[BONGO_CAT_INPUT_KEY_STATE_CAP]; + bool xwayland; atomic_bool running; atomic_bool supported; } LinuxX11State; +static LinuxX11State *x11_state(const BongoCatPlatform *platform) { + const LinuxPlatformState *native = platform ? platform->native : NULL; + return native ? native->x11 : NULL; +} + static const char *key_name(KeySym key, char output[16]) { if (key >= XK_a && key <= XK_z) { snprintf(output, 16, "Key%c", (char)('A' + key - XK_a)); return output; @@ -153,18 +161,36 @@ static int SDLCALL input_thread(void *userdata) { } bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) { + if (!platform || !platform->native || !platform->window) return false; + if (x11_state(platform)) return true; SDL_PropertiesID properties = SDL_GetWindowProperties(platform->window); Display *display = SDL_GetPointerProperty(properties, SDL_PROP_WINDOW_X11_DISPLAY_POINTER, NULL); Window window = (Window)SDL_GetNumberProperty(properties, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0); LinuxX11State *state = calloc(1, sizeof(*state)); - if (!state) return false; + if (!state) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot allocate X11 input state"); + return false; + } state->platform = platform; state->display = display; state->window = window; atomic_init(&state->running, true); atomic_init(&state->supported, false); - platform->native = state; - if (!display || !window) return true; - state->thread = SDL_CreateThread(input_thread, "bongo-cat-x11-input", state); + LinuxPlatformState *native = platform->native; + native->x11 = state; + /* XWayland advertises a marker extension that native X servers lack. + XWayland stops delivering pointer events for a window whose input region + is empty, so dynamic hit testing cannot restore the region by itself. */ + int marker_opcode = 0, marker_event = 0, marker_error = 0; + state->xwayland = native->wayland || (display && XQueryExtension(display, + "XWAYLAND", &marker_opcode, &marker_event, &marker_error)); + if (state->xwayland) SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "XWayland detected; automatic transparent-pixel click-through is disabled"); + /* Select one input backend for the session. Device hotplug must not + switch producers midway through a held key or mouse button. */ + if (!display || !window || native->evdev_selected) return true; + state->thread = SDL_CreateThread(input_thread, + BONGO_CAT_SLUG "-x11-input", state); if (!state->thread) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start X11 input listener"); return false; @@ -173,20 +199,26 @@ bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error) } void bongo_cat_linux_x11_stop(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state) return; atomic_store(&state->running, false); if (state->thread) SDL_WaitThread(state->thread, NULL); - free(state); platform->native = NULL; + free(state); + ((LinuxPlatformState *)platform->native)->x11 = NULL; } bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform) { - const LinuxX11State *state = platform ? platform->native : NULL; + const LinuxX11State *state = x11_state(platform); return state && atomic_load(&state->supported); } +bool bongo_cat_linux_x11_xwayland(const BongoCatPlatform *platform) { + const LinuxX11State *state = x11_state(platform); + return state && state->xwayland; +} + void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state || !state->display || !state->window) return; XserverRegion region = enabled ? XFixesCreateRegion(state->display, NULL, 0) : None; XFixesSetWindowShapeRegion(state->display, state->window, ShapeInput, 0, 0, region); @@ -204,14 +236,21 @@ static void state_message(LinuxX11State *state, const char *name, long action) { SubstructureRedirectMask | SubstructureNotifyMask, &event); XFlush(state->display); } +void bongo_cat_linux_x11_set_above(BongoCatPlatform *platform, bool enabled) { + LinuxX11State *state = x11_state(platform); + if (!state || !state->display || !state->window) return; + /* Some XWayland compositors need an explicit EWMH request after mapping. */ + state_message(state, "_NET_WM_STATE_ABOVE", enabled ? 1 : 0); +} + void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (state && state->display && state->window) state_message(state, "_NET_WM_STATE_SKIP_TASKBAR", 1); } void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform) { - LinuxX11State *state = platform ? platform->native : NULL; + LinuxX11State *state = x11_state(platform); if (!state || !state->display || !state->window) return; Window root = DefaultRootWindow(state->display), child; int x, y, wx, wy; unsigned int mask; diff --git a/src/platform/linux_internal.h b/src/platform/linux_internal.h deleted file mode 100644 index e5256d7c..00000000 --- a/src/platform/linux_internal.h +++ /dev/null @@ -1,15 +0,0 @@ -#ifndef BONGO_CAT_LINUX_INTERNAL_H -#define BONGO_CAT_LINUX_INTERNAL_H - -#include "bongo_cat/platform.h" - -bool bongo_cat_linux_x11_start(BongoCatPlatform *platform, BongoCatError *error); -void bongo_cat_linux_x11_stop(BongoCatPlatform *platform); -bool bongo_cat_linux_x11_supported(const BongoCatPlatform *platform); -void bongo_cat_linux_x11_click_through(BongoCatPlatform *platform, bool enabled); -void bongo_cat_linux_x11_configure_capture_window(BongoCatPlatform *platform); -void bongo_cat_linux_x11_begin_drag(BongoCatPlatform *platform); -BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels); - -#endif diff --git a/src/platform/linux_menu.c b/src/platform/linux_menu.c deleted file mode 100644 index 268dd67a..00000000 --- a/src/platform/linux_menu.c +++ /dev/null @@ -1,217 +0,0 @@ -#include "linux_internal.h" - -#if !defined(_WIN32) && !defined(__APPLE__) -#include -#include -#include -#include -#include -#include - -typedef struct LinuxMenuRow { - const char *label; - BongoCatMenuAction action; -} LinuxMenuRow; - -#define LINUX_MENU_HINT ((BongoCatMenuAction)-4) -#define LINUX_MENU_MOTIONS ((BongoCatMenuAction)-5) -#define LINUX_MENU_EXPRESSIONS ((BongoCatMenuAction)-6) - -typedef struct LinuxMenuPalette { - unsigned long surface, field, border, text, muted, accent; -} LinuxMenuPalette; - -static BongoCatMenuAction finish(const BongoCatMenuLabels *labels, - BongoCatMenuAction action) { - if (labels->restore) labels->restore(labels->preview_userdata, action); - return action; -} - -static unsigned long color(Display *display, int screen, const char *value) { - XColor resolved; - Colormap map = DefaultColormap(display, screen); - return XParseColor(display, map, value, &resolved) && - XAllocColor(display, map, &resolved) ? resolved.pixel : - WhitePixel(display, screen); -} - -static LinuxMenuPalette palette(Display *display, bool dark) { - int screen = DefaultScreen(display); - LinuxMenuPalette value; - value.surface = color(display, screen, dark ? "#21242b" : "#ffffff"); - value.field = color(display, screen, dark ? "#2a2e37" : "#f3f5f8"); - value.border = color(display, screen, dark ? "#3b424f" : "#d8dee8"); - value.text = color(display, screen, dark ? "#f4f7fb" : "#182230"); - value.muted = color(display, screen, dark ? "#9aa4b2" : "#667085"); - value.accent = color(display, screen, "#54aeff"); - return value; -} - -static void draw_menu(Display *display, Window window, GC gc, - const LinuxMenuRow *rows, int count, int hover, - const LinuxMenuPalette *colors) { - XSetForeground(display, gc, colors->surface); XFillRectangle(display, window, gc, - 0, 0, 340, (unsigned)(count * 34 + 16)); - XSetForeground(display, gc, colors->border); XDrawRectangle(display, window, gc, - 0, 0, 339, (unsigned)(count * 34 + 15)); - for (int index = 0; index < count; ++index) { - int y = 8 + index * 34; - bool hint = rows[index].action == LINUX_MENU_HINT; - if (index == hover && !hint) { - XSetForeground(display, gc, colors->field); - XFillRectangle(display, window, gc, 8, y, 324, 32); - } - XSetForeground(display, gc, hint ? colors->muted : - index == hover ? colors->accent : colors->text); - XDrawString(display, window, gc, 20, y + 21, rows[index].label, - (int)strlen(rows[index].label)); - } - XFlush(display); -} - -static int popup_rows(Display *display, Window owner, const LinuxMenuRow *rows, - int count, const BongoCatMenuLabels *labels) { - if (!display || !rows || count < 1) return -1; - Window root; int root_x, root_y, win_x, win_y; unsigned mask; - XQueryPointer(display, DefaultRootWindow(display), &root, &owner, - &root_x, &root_y, &win_x, &win_y, &mask); - int height = count * 34 + 16; - int screen_width = DisplayWidth(display, DefaultScreen(display)); - int screen_height = DisplayHeight(display, DefaultScreen(display)); - if (root_x > screen_width - 342) root_x = screen_width - 342; - if (root_y > screen_height - height - 2) root_y = screen_height - height - 2; - LinuxMenuPalette colors = palette(display, labels->dark_theme); - XSetWindowAttributes attributes = {0}; - attributes.override_redirect = True; - attributes.background_pixel = colors.surface; - attributes.border_pixel = colors.border; - Window menu = XCreateWindow(display, DefaultRootWindow(display), root_x, - root_y, 340, (unsigned)height, 1, CopyFromParent, InputOutput, - CopyFromParent, CWOverrideRedirect | CWBackPixel | CWBorderPixel, - &attributes); - if (!menu) return -1; - XSelectInput(display, menu, ExposureMask | ButtonPressMask | - PointerMotionMask | LeaveWindowMask | KeyPressMask); - XMapRaised(display, menu); - int pointer_grab = XGrabPointer(display, menu, False, - ButtonPressMask | PointerMotionMask, GrabModeAsync, GrabModeAsync, - None, None, CurrentTime); - int keyboard_grab = XGrabKeyboard(display, menu, False, - GrabModeAsync, GrabModeAsync, CurrentTime); - GC gc = XCreateGC(display, menu, 0, NULL); - if (pointer_grab != GrabSuccess || keyboard_grab != GrabSuccess || !gc) { - if (pointer_grab == GrabSuccess) XUngrabPointer(display, CurrentTime); - if (keyboard_grab == GrabSuccess) XUngrabKeyboard(display, CurrentTime); - if (gc) XFreeGC(display, gc); - XDestroyWindow(display, menu); XFlush(display); - return -1; - } - int selected = -1, hover = -1; - while (selected < 0) { - if (!XPending(display) && labels->preview_tick) { - labels->preview_tick(labels->preview_userdata); - SDL_Delay(16); - continue; - } - XEvent event; XNextEvent(display, &event); - if (event.type == Expose) draw_menu(display, menu, gc, rows, count, - hover, &colors); - else if (event.type == MotionNotify) { - int next = event.xmotion.y >= 8 ? (event.xmotion.y - 8) / 34 : -1; - next = next >= 0 && next < count && - rows[next].action != LINUX_MENU_HINT ? next : -1; - if (next != hover && labels->preview) - labels->preview(labels->preview_userdata, next >= 0 ? - rows[next].action : BONGO_CAT_MENU_NONE); - hover = next; - draw_menu(display, menu, gc, rows, count, hover, &colors); - } else if (event.type == LeaveNotify) { - hover = -1; - if (labels->preview) labels->preview(labels->preview_userdata, - BONGO_CAT_MENU_NONE); - draw_menu(display, menu, gc, rows, count, hover, &colors); - } else if (event.type == ButtonPress) { - int next = event.xbutton.y >= 8 ? (event.xbutton.y - 8) / 34 : -1; - if (next < 0 || next >= count) selected = -2; - else if (rows[next].action != LINUX_MENU_HINT) selected = next; - } else if (event.type == KeyPress && - XLookupKeysym(&event.xkey, 0) == XK_Escape) selected = -2; - } - XUngrabPointer(display, CurrentTime); XUngrabKeyboard(display, CurrentTime); - XFreeGC(display, gc); - XDestroyWindow(display, menu); XFlush(display); - return selected >= 0 ? rows[selected].action : BONGO_CAT_MENU_NONE; -} - -BongoCatMenuAction bongo_cat_linux_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - if (!platform || !labels) return BONGO_CAT_MENU_NONE; - SDL_PropertiesID properties = SDL_GetWindowProperties(platform->window); - Display *display = SDL_GetPointerProperty(properties, - SDL_PROP_WINDOW_X11_DISPLAY_POINTER, NULL); - Window owner = (Window)SDL_GetNumberProperty(properties, - SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0); - if (!display || !owner) return finish(labels, BONGO_CAT_MENU_NONE); - char pass[BONGO_CAT_ID_CAP], top[BONGO_CAT_ID_CAP]; - snprintf(pass, sizeof(pass), "%s%s", labels->pass_through_checked ? "[x] " : "", - labels->pass_through); - snprintf(top, sizeof(top), "%s%s", labels->always_on_top_checked ? "[x] " : "", - labels->always_on_top); - LinuxMenuRow main_rows[10]; int main_count = 0; - main_rows[main_count++] = (LinuxMenuRow){labels->preferences, BONGO_CAT_MENU_PREFERENCES}; - main_rows[main_count++] = (LinuxMenuRow){labels->hide, BONGO_CAT_MENU_HIDE}; - main_rows[main_count++] = (LinuxMenuRow){pass, BONGO_CAT_MENU_PASS_THROUGH}; - main_rows[main_count++] = (LinuxMenuRow){top, BONGO_CAT_MENU_ALWAYS_ON_TOP}; - main_rows[main_count++] = (LinuxMenuRow){labels->window_size, -1}; - main_rows[main_count++] = (LinuxMenuRow){labels->opacity, -2}; - if (labels->motion_count) main_rows[main_count++] = - (LinuxMenuRow){labels->motion, LINUX_MENU_MOTIONS}; - if (labels->expression_count) main_rows[main_count++] = - (LinuxMenuRow){labels->expression, LINUX_MENU_EXPRESSIONS}; - main_rows[main_count++] = (LinuxMenuRow){labels->model, -3}; - main_rows[main_count++] = (LinuxMenuRow){labels->exit, BONGO_CAT_MENU_EXIT}; - BongoCatMenuAction action = popup_rows(display, owner, main_rows, - main_count, labels); - if (action == (BongoCatMenuAction)-1) { - const int values[] = {50,60,70,80,90,100,110,120,130,140,150,160,170,180,190,200}; - LinuxMenuRow rows[17]; char text[16][16]; - for (int i = 0; i < 16; ++i) { snprintf(text[i], sizeof(text[i]), "%d%%", values[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_SCALE_50 + i}; } - rows[16] = (LinuxMenuRow){labels->wheel_size_hint, LINUX_MENU_HINT}; - action = popup_rows(display, owner, rows, 17, labels); - } else if (action == (BongoCatMenuAction)-2) { - const int values[] = {10,20,30,40,50,60,70,80,90,100}; - LinuxMenuRow rows[11]; char text[10][16]; - for (int i = 0; i < 10; ++i) { snprintf(text[i], sizeof(text[i]), "%d%%", values[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_OPACITY_10 + i}; } - rows[10] = (LinuxMenuRow){labels->wheel_opacity_hint, LINUX_MENU_HINT}; - action = popup_rows(display, owner, rows, 11, labels); - } else if (action == (BongoCatMenuAction)-3) { - LinuxMenuRow rows[BONGO_CAT_MODEL_CAP + 1]; - for (size_t i = 0; i < labels->model_count; ++i) - rows[i] = (LinuxMenuRow){labels->model_names[i], - BONGO_CAT_MENU_MODEL_FIRST + (int)i}; - rows[labels->model_count] = (LinuxMenuRow){labels->add_model, - BONGO_CAT_MENU_MODEL_ADD}; - action = popup_rows(display, owner, rows, - (int)labels->model_count + 1, labels); - } else if (action == LINUX_MENU_MOTIONS) { - LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - for (size_t i = 0; i < labels->motion_count; ++i) - rows[i] = (LinuxMenuRow){labels->motion_names[i], - BONGO_CAT_MENU_MOTION_FIRST + (int)i}; - action = popup_rows(display, owner, rows, (int)labels->motion_count, labels); - } else if (action == LINUX_MENU_EXPRESSIONS) { - LinuxMenuRow rows[BONGO_CAT_BEHAVIOR_CAP]; - char text[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_ID_CAP]; - for (size_t i = 0; i < labels->expression_count; ++i) { - snprintf(text[i], sizeof(text[i]), "%s%s", - i == labels->current_expression ? "[x] " : "", - labels->expression_names[i]); - rows[i] = (LinuxMenuRow){text[i], BONGO_CAT_MENU_EXPRESSION_FIRST + (int)i}; - } - action = popup_rows(display, owner, rows, (int)labels->expression_count, labels); - } - return finish(labels, action); -} -#endif diff --git a/src/platform/macos.m b/src/platform/macos/macos.m similarity index 66% rename from src/platform/macos.m rename to src/platform/macos/macos.m index a478b656..d8b1d440 100644 --- a/src/platform/macos.m +++ b/src/platform/macos/macos.m @@ -16,59 +16,6 @@ static NSObject *instance_observer; static bool instance_show_pending; -/* SDL 3.2's pinned Cocoa tray layout; kept local to the platform adapter. */ -typedef struct BongoCatSDLTrayMenu { NSMenu *menu; } BongoCatSDLTrayMenu; -typedef struct BongoCatSDLTray { - NSStatusBar *status_bar; NSStatusItem *status_item; BongoCatSDLTrayMenu *menu; -} BongoCatSDLTray; - -@interface BongoCatTrayTarget : NSObject { - NSStatusItem *item_; NSMenu *menu_; BongoCatTrayClick callback_; - BongoCatModalTick modal_tick_; void *userdata_; -} -- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu - callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick - userdata:(void *)userdata; -- (void)clicked:(id)sender; -- (void)tickModal:(NSTimer *)timer; -- (void)unbind; -@end - -@implementation BongoCatTrayTarget -- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu - callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick - userdata:(void *)userdata { - self = [super init]; - if (self) { item_ = item; menu_ = menu; callback_ = callback; - modal_tick_ = modalTick; userdata_ = userdata; } - return self; -} -- (void)clicked:(id)sender { - (void)sender; - NSEvent *event = [NSApp currentEvent]; - if ([event type] == NSEventTypeLeftMouseUp) callback_(userdata_); - else { - if (modal_tick_) modal_tick_(userdata_); - NSTimer *timer = modal_tick_ ? [NSTimer timerWithTimeInterval:1.0 / 60.0 - target:self selector:@selector(tickModal:) userInfo:nil repeats:YES] : nil; - if (timer) [[NSRunLoop currentRunLoop] addTimer:timer - forMode:NSRunLoopCommonModes]; - [NSMenu popUpContextMenu:menu_ withEvent:event forView:[item_ button]]; - [timer invalidate]; - } -} -- (void)tickModal:(NSTimer *)timer { - (void)timer; - if (modal_tick_) modal_tick_(userdata_); -} -- (void)unbind { - [[item_ button] setTarget:nil]; [[item_ button] setAction:nil]; - [item_ setMenu:menu_]; -} -@end - -static BongoCatTrayTarget *tray_target; - @interface BongoCatModalTarget : NSObject { BongoCatModalTick callback_; void *userdata_; } - (id)initWithCallback:(BongoCatModalTick)callback userdata:(void *)userdata; - (void)tick:(NSTimer *)timer; @@ -145,12 +92,38 @@ void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { bongo_cat_macos_input_stop(platform); if (active_platform == platform) active_platform = NULL; } +/* SDL rewrites NSWindow.ignoresMouseEvents from the window shape whenever a mouse move reaches the + window, and derives "ignore the mouse" from a transparent shape pixel. Telling SDL the same state + through that shape keeps the two from disagreeing, which would otherwise hand the pet back to the + window server on the next move. The native property is written afterwards: SDL's answer depends on + where the pointer is, while this state has to hold wherever the pointer is. */ +static void apply_click_through_shape(BongoCatPlatform *platform, bool enabled) { + if (!(SDL_GetWindowFlags(platform->window) & SDL_WINDOW_TRANSPARENT)) return; + SDL_Surface *shape = SDL_CreateSurface(1, 1, SDL_PIXELFORMAT_ARGB32); + if (!shape) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Click-through window has no shape SDL can follow: %s", SDL_GetError()); + return; + } + if (!SDL_WriteSurfacePixel(shape, 0, 0, 0, 0, 0, + enabled ? SDL_ALPHA_TRANSPARENT : SDL_ALPHA_OPAQUE) || + !SDL_SetWindowShape(platform->window, shape)) + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Click-through state is not described to SDL: %s", SDL_GetError()); + SDL_DestroySurface(shape); +} + void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, bool forced, bool pointer_transparent) { - [native_window(platform) setIgnoresMouseEvents:forced || pointer_transparent]; + if (!platform || !platform->window) return; + bool enabled = forced || pointer_transparent; + apply_click_through_shape(platform, enabled); + [native_window(platform) setIgnoresMouseEvents:enabled]; } bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { if (!platform || !platform->window) return false; + opacity = SDL_clamp(opacity, 0.0f, 1.0f); + if (opacity == platform->window_opacity) return true; if (!SDL_SetWindowOpacity(platform->window, opacity)) return false; platform->window_opacity = opacity; return true; @@ -180,6 +153,10 @@ bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, double screen_ *local_x = (float)(screen_x - x); *local_y = (float)(screen_y - y); return *local_x >= 0 && *local_x < width && *local_y >= 0 && *local_y < height; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + (void)platform; + return false; +} bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, double *x, double *y) { (void)platform; (void)x; (void)y; @@ -188,6 +165,9 @@ bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { (void)platform; } +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + (void)platform; +} void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabled) { [native_window(platform) setLevel:enabled ? NSFloatingWindowLevel : NSNormalWindowLevel]; } @@ -234,6 +214,14 @@ bool bongo_cat_platform_dynamic_hit_supported(void) { return bongo_cat_macos_input_supported(); } +bool bongo_cat_platform_input_monitoring_authorized(void) { + return bongo_cat_macos_input_monitoring_authorized(); +} + +bool bongo_cat_platform_input_monitoring_request(void) { + return bongo_cat_macos_input_monitoring_request(); +} + bool bongo_cat_platform_open_directory(const char *path) { if (!path || !path[0]) return false; @autoreleasepool { @@ -243,25 +231,9 @@ bool bongo_cat_platform_open_directory(const char *path) { } } -void bongo_cat_platform_set_tray_callbacks(void *tray, - BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { - BongoCatSDLTray *native = tray; - if (tray_target) { - [tray_target unbind]; [tray_target release]; tray_target = nil; - } - if (!native || !left_click || !native->status_item || !native->menu) return; - tray_target = [[BongoCatTrayTarget alloc] initWithItem:native->status_item - menu:native->menu->menu callback:left_click modalTick:modal_tick - userdata:userdata]; - [native->status_item setMenu:nil]; - [[native->status_item button] setTarget:tray_target]; - [[native->status_item button] setAction:@selector(clicked:)]; - [[native->status_item button] sendActionOn:NSEventMaskLeftMouseUp | - NSEventMaskRightMouseUp]; -} bool bongo_cat_platform_single_instance_begin(void) { - char path[96]; snprintf(path, sizeof(path), "/tmp/bongo-cat-%lu.lock", + char path[96]; snprintf(path, sizeof(path), + "/tmp/" BONGO_CAT_SLUG "-%lu.lock", (unsigned long)getuid()); instance_lock = open(path, O_CREAT | O_RDWR, 0600); if (instance_lock < 0 || flock(instance_lock, LOCK_EX | LOCK_NB) == 0) { @@ -281,36 +253,6 @@ void bongo_cat_platform_single_instance_end(void) { [instance_observer release]; instance_observer = nil; } } -BongoCatResult bongo_cat_platform_set_autostart(bool enabled, BongoCatError *error) { - @autoreleasepool { - NSString *directory = [NSHomeDirectory() - stringByAppendingPathComponent:@"Library/LaunchAgents"]; - NSString *path = [directory - stringByAppendingPathComponent:@"com.bongocat.desktop.plist"]; - NSFileManager *files = [NSFileManager defaultManager]; - if (!enabled) { - if (![files fileExistsAtPath:path] || [files removeItemAtPath:path error:nil]) - return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot remove macOS launch agent"); - return BONGO_CAT_ERROR_IO; - } - NSString *executable = [[NSBundle mainBundle] executablePath]; - if (!executable || ![files createDirectoryAtPath:directory - withIntermediateDirectories:YES attributes:nil error:nil]) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot create macOS launch agent directory"); - return BONGO_CAT_ERROR_IO; - } - NSDictionary *plist = @{ @"Label": @"com.bongocat.desktop", - @"ProgramArguments": @[executable, @"--autostart"], @"RunAtLoad": @YES }; - if ([plist writeToFile:path atomically:YES]) return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot write macOS launch agent"); - return BONGO_CAT_ERROR_IO; - } -} -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - return bongo_cat_macos_context_menu(platform, labels); -} BongoCatResult bongo_cat_platform_embedded_assets(const char *target, BongoCatError *error) { (void)target; (void)error; return BONGO_CAT_ERROR_PLATFORM; } diff --git a/src/platform/macos_input.m b/src/platform/macos/macos_input.m similarity index 77% rename from src/platform/macos_input.m rename to src/platform/macos/macos_input.m index 8eeee9ec..bb54eee1 100644 --- a/src/platform/macos_input.m +++ b/src/platform/macos/macos_input.m @@ -19,10 +19,17 @@ bool key_down[BONGO_CAT_INPUT_KEY_STATE_CAP]; atomic_bool supported; atomic_bool stop_requested; + atomic_uint references; } MacInputState; static atomic_bool global_supported = ATOMIC_VAR_INIT(false); +static void release_state(MacInputState *state) { + if (atomic_fetch_sub(&state->references, 1) != 1) return; + SDL_DestroySemaphore(state->ready); + free(state); +} + static void push(MacInputState *state, BongoCatInputKind kind, const char *name, float value) { if (!state || !name) return; @@ -97,10 +104,16 @@ static int SDLCALL input_thread(void *userdata) { CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) | CGEventMaskBit(kCGEventMouseMoved) | CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) | CGEventMaskBit(kCGEventOtherMouseDragged); - state->tap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, + /* The permission this listener is built with, kept only to word the + warning below; the page reads the current permission itself. */ + bool permitted = false; + if (@available(macOS 10.15, *)) permitted = CGPreflightListenEventAccess(); + state->tap = CGEventTapCreate(kCGSessionEventTap, kCGTailAppendEventTap, kCGEventTapOptionListenOnly, mask, event_tap, state); if (state->tap) { state->source = CFMachPortCreateRunLoopSource(NULL, state->tap, 0); + } + if (state->source && !atomic_load(&state->stop_requested)) { state->loop = CFRunLoopGetCurrent(); CFRetain(state->loop); CFRunLoopAddSource(state->loop, state->source, kCFRunLoopCommonModes); CGEventTapEnable(state->tap, true); @@ -108,24 +121,32 @@ static int SDLCALL input_thread(void *userdata) { atomic_store(&global_supported, true); } SDL_SignalSemaphore(state->ready); - if (!atomic_load(&state->supported)) SDL_LogWarn( - SDL_LOG_CATEGORY_APPLICATION, - "macOS input monitoring permission is required for global input"); + if (!atomic_load(&state->supported)) { + /* Not a permission verdict: a listener can also fail to build with + the permission already granted. */ + if (permitted) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "macOS input listener failed to initialize"); + else + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "macOS input " + "monitoring permission is required for global keyboard input"); + } while (atomic_load(&state->supported) && !atomic_load(&state->stop_requested)) CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, true); + if (state->loop && state->source) + CFRunLoopRemoveSource(state->loop, state->source, kCFRunLoopCommonModes); + if (state->tap) CFMachPortInvalidate(state->tap); if (state->source) CFRelease(state->source); if (state->tap) CFRelease(state->tap); if (state->loop) CFRelease(state->loop); atomic_store(&global_supported, false); } + release_state(state); return 0; } bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *error) { - if (@available(macOS 10.15, *)) { - if (!CGPreflightListenEventAccess()) CGRequestListenEventAccess(); - } MacInputState *state = calloc(1, sizeof(*state)); if (!state) { bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, @@ -135,9 +156,11 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *erro state->platform = platform; atomic_init(&state->supported, false); atomic_init(&state->stop_requested, false); + /* Caller and worker each release one reference, including after timeout. */ + atomic_init(&state->references, 2); state->ready = SDL_CreateSemaphore(0); state->thread = state->ready ? SDL_CreateThread(input_thread, - "bongo-cat-macos-input", state) : NULL; + BONGO_CAT_SLUG "-macos-input", state) : NULL; if (!state->ready || !state->thread) { bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Cannot start the macOS input listener"); @@ -158,14 +181,25 @@ void bongo_cat_macos_input_stop(BongoCatPlatform *platform) { !SDL_WaitSemaphoreTimeout(state->ready, 3000)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "macOS input thread did not initialize before shutdown"); + SDL_DetachThread(state->thread); platform->native = NULL; + release_state(state); return; } if (state->thread) SDL_WaitThread(state->thread, NULL); - if (state->ready) SDL_DestroySemaphore(state->ready); - free(state); platform->native = NULL; + release_state(state); } bool bongo_cat_macos_input_supported(void) { return atomic_load(&global_supported); } + +bool bongo_cat_macos_input_monitoring_authorized(void) { + if (@available(macOS 10.15, *)) return CGPreflightListenEventAccess(); + return false; +} + +bool bongo_cat_macos_input_monitoring_request(void) { + if (@available(macOS 10.15, *)) return CGRequestListenEventAccess(); + return false; +} #endif diff --git a/src/platform/macos_internal.h b/src/platform/macos/macos_internal.h similarity index 72% rename from src/platform/macos_internal.h rename to src/platform/macos/macos_internal.h index 4a715188..090597fd 100644 --- a/src/platform/macos_internal.h +++ b/src/platform/macos/macos_internal.h @@ -6,7 +6,7 @@ bool bongo_cat_macos_input_start(BongoCatPlatform *platform, BongoCatError *error); void bongo_cat_macos_input_stop(BongoCatPlatform *platform); bool bongo_cat_macos_input_supported(void); -BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels); +bool bongo_cat_macos_input_monitoring_authorized(void); +bool bongo_cat_macos_input_monitoring_request(void); #endif diff --git a/src/platform/macos_keys.h b/src/platform/macos/macos_keys.h similarity index 100% rename from src/platform/macos_keys.h rename to src/platform/macos/macos_keys.h diff --git a/src/platform/macos_keys.m b/src/platform/macos/macos_keys.m similarity index 100% rename from src/platform/macos_keys.m rename to src/platform/macos/macos_keys.m diff --git a/src/platform/macos/macos_preferences.m b/src/platform/macos/macos_preferences.m new file mode 100644 index 00000000..11840538 --- /dev/null +++ b/src/platform/macos/macos_preferences.m @@ -0,0 +1,61 @@ +#include "bongo_cat/platform.h" + +#ifdef __APPLE__ +#import +#include +#include + +BongoCatResult bongo_cat_platform_set_autostart(bool enabled, + BongoCatError *error) { + @autoreleasepool { + NSString *directory = [NSHomeDirectory() + stringByAppendingPathComponent:@"Library/LaunchAgents"]; + NSString *path = [directory + stringByAppendingPathComponent:@"com.bongocat.desktop.plist"]; + NSFileManager *files = [NSFileManager defaultManager]; + if (!enabled) { + if (![files fileExistsAtPath:path] || + [files removeItemAtPath:path error:nil]) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot remove macOS launch agent"); + return BONGO_CAT_ERROR_IO; + } + NSString *executable = [[NSBundle mainBundle] executablePath]; + if (!executable || ![files createDirectoryAtPath:directory + withIntermediateDirectories:YES attributes:nil error:nil]) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create macOS launch agent directory"); + return BONGO_CAT_ERROR_IO; + } + NSDictionary *plist = @{ @"Label": @"com.bongocat.desktop", + @"ProgramArguments": @[executable, @"--autostart"], + @"RunAtLoad": @YES }; + if ([plist writeToFile:path atomically:YES]) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write macOS launch agent"); + return BONGO_CAT_ERROR_IO; + } +} + +void bongo_cat_platform_configure_preferences_window(SDL_Window *sdl_window) { + if (!sdl_window) return; + NSWindow *window = (__bridge NSWindow *)SDL_GetPointerProperty( + SDL_GetWindowProperties(sdl_window), + SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL); + if (!window) return; + NSWindowStyleMask style = [window styleMask]; + style |= NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable | + NSWindowStyleMaskFullSizeContentView; + [window setStyleMask:style]; + [window setTitle:@""]; + [window setTitleVisibility:NSWindowTitleHidden]; + [window setTitlebarAppearsTransparent:YES]; + [window setToolbar:nil]; + [window setTabbingMode:NSWindowTabbingModeDisallowed]; + [window setMovableByWindowBackground:NO]; + [[window standardWindowButton:NSWindowCloseButton] setHidden:NO]; + [[window standardWindowButton:NSWindowMiniaturizeButton] setHidden:NO]; + [[window standardWindowButton:NSWindowZoomButton] setHidden:NO]; +} +#endif diff --git a/src/platform/macos/macos_tray.m b/src/platform/macos/macos_tray.m new file mode 100644 index 00000000..a62e558f --- /dev/null +++ b/src/platform/macos/macos_tray.m @@ -0,0 +1,96 @@ +#include "bongo_cat/platform.h" + +#ifdef __APPLE__ +#import + +/* SDL 3.2's pinned Cocoa tray layout; kept local to the tray adapter. */ +typedef struct BongoCatSDLTrayMenu { NSMenu *menu; } BongoCatSDLTrayMenu; +typedef struct BongoCatSDLTray { + NSStatusBar *status_bar; + NSStatusItem *status_item; + BongoCatSDLTrayMenu *menu; +} BongoCatSDLTray; + +@interface BongoCatTrayTarget : NSObject { + NSStatusItem *item_; + NSMenu *menu_; + BongoCatTrayClick callback_; + BongoCatModalTick modal_tick_; + void *userdata_; +} +- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu + callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick + userdata:(void *)userdata; +- (void)clicked:(id)sender; +- (void)tickModal:(NSTimer *)timer; +- (void)unbind; +@end + +@implementation BongoCatTrayTarget +- (id)initWithItem:(NSStatusItem *)item menu:(NSMenu *)menu + callback:(BongoCatTrayClick)callback modalTick:(BongoCatModalTick)modalTick + userdata:(void *)userdata { + self = [super init]; + if (self) { + item_ = item; + menu_ = menu; + callback_ = callback; + modal_tick_ = modalTick; + userdata_ = userdata; + } + return self; +} + +- (void)clicked:(id)sender { + (void)sender; + NSEvent *event = [NSApp currentEvent]; + if ([event type] == NSEventTypeLeftMouseUp) callback_(userdata_); + else { + if (modal_tick_) modal_tick_(userdata_); + NSTimer *timer = modal_tick_ ? + [NSTimer timerWithTimeInterval:1.0 / 60.0 target:self + selector:@selector(tickModal:) userInfo:nil repeats:YES] : nil; + if (timer) + [[NSRunLoop currentRunLoop] addTimer:timer + forMode:NSRunLoopCommonModes]; + [NSMenu popUpContextMenu:menu_ withEvent:event forView:[item_ button]]; + [timer invalidate]; + } +} + +- (void)tickModal:(NSTimer *)timer { + (void)timer; + if (modal_tick_) modal_tick_(userdata_); +} + +- (void)unbind { + [[item_ button] setTarget:nil]; + [[item_ button] setAction:nil]; + [item_ setMenu:menu_]; +} +@end + +static BongoCatTrayTarget *tray_target; + +void bongo_cat_platform_set_tray_callbacks(void *tray, + BongoCatTrayClick left_click, BongoCatModalTick modal_tick, + BongoCatTrayRestore restore, void *userdata) { + (void)restore; + BongoCatSDLTray *native = tray; + if (tray_target) { + [tray_target unbind]; + [tray_target release]; + tray_target = nil; + } + if (!native || !left_click || !native->status_item || !native->menu) + return; + tray_target = [[BongoCatTrayTarget alloc] initWithItem:native->status_item + menu:native->menu->menu callback:left_click modalTick:modal_tick + userdata:userdata]; + [native->status_item setMenu:nil]; + [[native->status_item button] setTarget:tray_target]; + [[native->status_item button] setAction:@selector(clicked:)]; + [[native->status_item button] sendActionOn:NSEventMaskLeftMouseUp | + NSEventMaskRightMouseUp]; +} +#endif diff --git a/src/platform/macos_menu.m b/src/platform/macos_menu.m deleted file mode 100644 index 9dd31f60..00000000 --- a/src/platform/macos_menu.m +++ /dev/null @@ -1,127 +0,0 @@ -#include "macos_internal.h" - -#ifdef __APPLE__ -#import -#include - -@interface BongoCatMenuTarget : NSObject { - NSInteger selected_; const BongoCatMenuLabels *labels_; -} -- (id)initWithLabels:(const BongoCatMenuLabels *)labels; -- (void)choose:(id)sender; -- (NSInteger)selected; -- (void)tickPreview:(NSTimer *)timer; -@end - -@implementation BongoCatMenuTarget -- (id)initWithLabels:(const BongoCatMenuLabels *)labels { - self = [super init]; if (self) labels_ = labels; return self; -} -- (void)choose:(id)sender { selected_ = [sender tag]; } -- (NSInteger)selected { return selected_; } -- (void)tickPreview:(NSTimer *)timer { - (void)timer; - if (labels_->preview_tick) labels_->preview_tick(labels_->preview_userdata); -} -- (void)menu:(NSMenu *)menu willHighlightItem:(NSMenuItem *)item { - (void)menu; - if (labels_->preview) labels_->preview(labels_->preview_userdata, - item ? (BongoCatMenuAction)[item tag] : BONGO_CAT_MENU_NONE); -} -@end - -static NSString *text(const char *value) { - return value ? [NSString stringWithUTF8String:value] : @""; -} - -static NSMenuItem *add_item(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, NSInteger tag, bool checked) { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:text(label) - action:@selector(choose:) keyEquivalent:@""]; - [item setTarget:target]; [item setTag:tag]; - [item setState:checked ? NSControlStateValueOn : NSControlStateValueOff]; - [menu addItem:item]; [item release]; return item; -} - -static void add_scale_menu(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, const char *hint_label, bool opacity) { - NSMenuItem *root = [[NSMenuItem alloc] initWithTitle:text(label) - action:nil keyEquivalent:@""]; - NSMenu *submenu = [[NSMenu alloc] initWithTitle:text(label)]; - [submenu setDelegate:target]; - const int values[] = {10,20,30,40,50,60,70,80,90,100}; - int count = opacity ? 10 : 16; - for (int i = 0; i < count; ++i) { - int value = opacity ? values[i] : 50 + i * 10; - char title[16]; snprintf(title, sizeof(title), "%d%%", value); - NSInteger tag = opacity ? BONGO_CAT_MENU_OPACITY_10 + i : BONGO_CAT_MENU_SCALE_50 + i; - add_item(submenu, target, title, tag, false); - } - NSMenuItem *hint = [[NSMenuItem alloc] initWithTitle:text(hint_label) - action:nil keyEquivalent:@""]; - [hint setEnabled:NO]; [submenu addItem:hint]; [hint release]; - [root setSubmenu:submenu]; [menu addItem:root]; - [submenu release]; [root release]; -} - -static void add_named_menu(NSMenu *menu, BongoCatMenuTarget *target, - const char *label, const char *const *names, size_t count, NSInteger first, - size_t selected) { - if (!count) return; - NSMenuItem *root = [[NSMenuItem alloc] initWithTitle:text(label) - action:nil keyEquivalent:@""]; - NSMenu *submenu = [[NSMenu alloc] initWithTitle:text(label)]; - [submenu setDelegate:target]; - for (size_t i = 0; i < count; ++i) - add_item(submenu, target, names[i], first + (NSInteger)i, i == selected); - [root setSubmenu:submenu]; [menu addItem:root]; - [submenu release]; [root release]; -} - -BongoCatMenuAction bongo_cat_macos_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - (void)platform; - if (!labels) return BONGO_CAT_MENU_NONE; - BongoCatMenuTarget *target = [[BongoCatMenuTarget alloc] initWithLabels:labels]; - NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; - [menu setDelegate:target]; - add_item(menu, target, labels->preferences, BONGO_CAT_MENU_PREFERENCES, false); - add_item(menu, target, labels->hide, BONGO_CAT_MENU_HIDE, false); - [menu addItem:[NSMenuItem separatorItem]]; - add_item(menu, target, labels->pass_through, BONGO_CAT_MENU_PASS_THROUGH, - labels->pass_through_checked); - add_item(menu, target, labels->always_on_top, BONGO_CAT_MENU_ALWAYS_ON_TOP, - labels->always_on_top_checked); - add_scale_menu(menu, target, labels->window_size, labels->wheel_size_hint, false); - add_scale_menu(menu, target, labels->opacity, labels->wheel_opacity_hint, true); - add_named_menu(menu, target, labels->motion, labels->motion_names, - labels->motion_count, BONGO_CAT_MENU_MOTION_FIRST, (size_t)-1); - add_named_menu(menu, target, labels->expression, labels->expression_names, - labels->expression_count, BONGO_CAT_MENU_EXPRESSION_FIRST, - labels->current_expression); - NSMenuItem *modelRoot = [[NSMenuItem alloc] initWithTitle:text(labels->model) - action:nil keyEquivalent:@""]; - NSMenu *models = [[NSMenu alloc] initWithTitle:text(labels->model)]; - [models setDelegate:target]; - for (size_t i = 0; i < labels->model_count; ++i) - add_item(models, target, labels->model_names[i], BONGO_CAT_MENU_MODEL_FIRST + i, - i == labels->current_model); - if (labels->model_count) [models addItem:[NSMenuItem separatorItem]]; - add_item(models, target, labels->add_model, BONGO_CAT_MENU_MODEL_ADD, false); - [modelRoot setSubmenu:models]; [menu addItem:modelRoot]; - [models release]; [modelRoot release]; - [menu addItem:[NSMenuItem separatorItem]]; - add_item(menu, target, labels->exit, BONGO_CAT_MENU_EXIT, false); - NSTimer *previewTimer = nil; - if (labels->preview_tick) { - previewTimer = [NSTimer timerWithTimeInterval:1.0 / 60.0 target:target - selector:@selector(tickPreview:) userInfo:nil repeats:YES]; - [[NSRunLoop currentRunLoop] addTimer:previewTimer forMode:NSRunLoopCommonModes]; - } - [menu popUpMenuPositioningItem:nil atLocation:[NSEvent mouseLocation] inView:nil]; - [previewTimer invalidate]; - BongoCatMenuAction result = (BongoCatMenuAction)[target selected]; - if (labels->restore) labels->restore(labels->preview_userdata, result); - [menu release]; [target release]; return result; -} -#endif diff --git a/src/platform/windows.c b/src/platform/windows/windows.c similarity index 67% rename from src/platform/windows.c rename to src/platform/windows/windows.c index 39ed70d6..889f2a48 100644 --- a/src/platform/windows.c +++ b/src/platform/windows/windows.c @@ -1,7 +1,6 @@ #include "bongo_cat/platform.h" #include "windows_borderless.h" #include "windows_capture.h" -#include "windows_direct_input.h" #include "windows_input.h" #include "windows_layered.h" #include "windows_startup.h" @@ -9,7 +8,6 @@ #include #include #include -#include #include #include static HWND native_window(BongoCatPlatform *platform) { @@ -17,6 +15,28 @@ static HWND native_window(BongoCatPlatform *platform) { SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); } +static bool SDLCALL windows_message_hook(void *userdata, MSG *message) { + (void)userdata; + if (!message || !message->hwnd) return true; + /* The capture handler filters by its per-window properties. Returning + false only consumes the refresh/timer messages that it owns. */ + return !bongo_cat_windows_capture_handle_message(message->hwnd, + message->message, message->wParam); +} + +void bongo_cat_platform_configure_preferences_window(SDL_Window *window) { + if (!window) return; + HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); + bool transparent = (SDL_GetWindowFlags(window) & + SDL_WINDOW_TRANSPARENT) != 0; + bongo_cat_windows_capture_mark_transparent(handle, transparent); + if (transparent) { + bongo_cat_windows_capture_install_transparency_handler(handle); + bongo_cat_windows_capture_repair_transparency(handle); + } +} + BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *window, BongoCatInputState *input, BongoCatError *error) { if (!platform || !window || !input) return BONGO_CAT_ERROR_ARGUMENT; @@ -37,34 +57,36 @@ BongoCatResult bongo_cat_platform_init(BongoCatPlatform *platform, SDL_Window *w } if (!bongo_cat_windows_input_start(platform)) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Global input hooks are unavailable; the window will continue without global input"); + "Raw Input is unavailable; the window will continue without global input"); } HWND hwnd = native_window(platform); - if (!bongo_cat_windows_direct_input_create(platform, hwnd)) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Mver-compatible DirectInput mouse tracking is unavailable"); SetWindowTextW(hwnd, bongo_cat_windows_instance_title()); bongo_cat_windows_borderless_install(hwnd); + bool transparent = (SDL_GetWindowFlags(window) & + SDL_WINDOW_TRANSPARENT) != 0; + /* Mark transparency before any OBS style transaction. Removing + WS_EX_TOOLWINDOW may recreate the DWM surface, so the configure path + must be able to repair it before the window is first shown. */ + bongo_cat_windows_capture_mark_transparent(hwnd, transparent); bongo_cat_windows_capture_configure(hwnd); + SDL_SetWindowsMessageHook(windows_message_hook, NULL); if (!SDL_SetWindowResizable(window, true)) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Borderless resize is unavailable: %s", SDL_GetError()); } - if (SDL_GetWindowFlags(window) & SDL_WINDOW_TRANSPARENT) { - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(hwnd, &margins); - } SetWindowPos(hwnd, NULL, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + bongo_cat_windows_capture_repair_transparency(hwnd); + bongo_cat_windows_capture_log(hwnd, "initialized"); return BONGO_CAT_OK; } void bongo_cat_platform_shutdown(BongoCatPlatform *platform) { if (!platform) return; HWND window = native_window(platform); if (window) bongo_cat_windows_borderless_uninstall(window); - bongo_cat_windows_direct_input_destroy(platform); bongo_cat_windows_input_stop(platform); bongo_cat_windows_layered_destroy(platform); + SDL_SetWindowsMessageHook(NULL, NULL); } static HWND desktop_anchor(void) { @@ -108,6 +130,7 @@ void bongo_cat_platform_set_always_on_top(BongoCatPlatform *platform, bool enabl apply_window_level(window, enabled); bongo_cat_windows_layered_set_always_on_top(platform, enabled); bongo_cat_windows_capture_configure(window); + bongo_cat_windows_capture_log(window, "window-level"); } void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, BongoCatModalTick modal_tick, void *userdata) { @@ -115,7 +138,17 @@ void bongo_cat_platform_begin_drag(BongoCatPlatform *platform, bongo_cat_windows_begin_drag(hwnd, modal_tick, userdata); } bool bongo_cat_platform_dynamic_hit_supported(void) { return true; } +bool bongo_cat_platform_pointer_locked(BongoCatPlatform *platform) { + return bongo_cat_windows_input_pointer_locked(platform); +} +bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, + double *x, double *y) { + return bongo_cat_windows_input_take_relative(platform, x, y, NULL); +} void bongo_cat_platform_relative_pointer_reset(BongoCatPlatform *platform) { - bongo_cat_windows_direct_input_reset(platform); + bongo_cat_windows_input_reset_relative(platform); +} +void bongo_cat_platform_relative_pointer_release(BongoCatPlatform *platform) { + bongo_cat_windows_input_release_relative(platform); } #endif diff --git a/src/platform/windows_assets.c b/src/platform/windows/windows_assets.c similarity index 95% rename from src/platform/windows_assets.c rename to src/platform/windows/windows_assets.c index d20f6ca1..0b438b8f 100644 --- a/src/platform/windows_assets.c +++ b/src/platform/windows/windows_assets.c @@ -53,7 +53,11 @@ static bool write_file(const char *path, const unsigned char *data, size_t size, static bool verify_contents(const char *name) { const char *model = "assets/models/standard/"; return strcmp(name, "assets/locales/en-US.json") == 0 || - strcmp(name, "assets/tray.png") == 0 || + strcmp(name, "assets/bongocat.png") == 0 || + strcmp(name, "assets/ui-symbols.png") == 0 || + strcmp(name, "assets/ui-symbols@4x.png") == 0 || + strcmp(name, "assets/catime.png") == 0 || + strcmp(name, "assets/vlaina.jpg") == 0 || (strncmp(name, model, strlen(model)) == 0 && !strstr(name, "/resources/")); } @@ -69,7 +73,8 @@ static bool content_matches(const char *path, const unsigned char *expected, siz } offset += count; } - bool ok = fgetc(file) == EOF && !ferror(file) && fclose(file) == 0; + bool ok = fgetc(file) == EOF && !ferror(file); + if (fclose(file) != 0) ok = false; return ok; } diff --git a/src/platform/windows_borderless.c b/src/platform/windows/windows_borderless.c similarity index 56% rename from src/platform/windows_borderless.c rename to src/platform/windows/windows_borderless.c index ae320664..a90f47a8 100644 --- a/src/platform/windows_borderless.c +++ b/src/platform/windows/windows_borderless.c @@ -1,5 +1,7 @@ #include "windows_borderless.h" #include "windows_capture.h" +#include "windows_keys.h" +#include "windows_tray.h" #ifdef _WIN32 #include @@ -8,8 +10,17 @@ static const wchar_t original_proc_property[] = L"BongoCat.BorderlessWindowProc" static const wchar_t click_through_property[] = L"BongoCat.ClickThrough"; static const wchar_t menu_binding_property[] = L"BongoCat.MenuBinding"; static const wchar_t drag_binding_property[] = L"BongoCat.DragBinding"; +static const wchar_t preserve_screen_property[] = L"BongoCat.PreserveScreenPixels"; #define BONGO_CAT_MENU_PREVIEW_TIMER ((UINT_PTR)0xBC4E) #define BONGO_CAT_DRAG_MODAL_TIMER ((UINT_PTR)0xBC50) +#define BONGO_CAT_DRAG_FRAME_INTERVAL_MS 16 +#define BONGO_CAT_CLICK_THROUGH_FORCED ((INT_PTR)1) +#define BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL ((INT_PTR)2) + +bool bongo_cat_windows_borderless_hit_transparent(bool forced, + bool pointer_transparent, bool right_button_down) { + return forced || (pointer_transparent && !right_button_down); +} typedef struct WindowsMenuBinding { BongoCatMenuPreview preview; @@ -20,8 +31,21 @@ typedef struct WindowsMenuBinding { typedef struct WindowsDragBinding { BongoCatModalTick tick; void *userdata; + ULONGLONG last_tick_ms; + bool ticking; } WindowsDragBinding; +static void drag_tick(WindowsDragBinding *drag) { + if (!drag || !drag->tick || drag->ticking) return; + ULONGLONG now = GetTickCount64(); + if (drag->last_tick_ms && now - drag->last_tick_ms < + BONGO_CAT_DRAG_FRAME_INTERVAL_MS) return; + drag->last_tick_ms = now; + drag->ticking = true; + drag->tick(drag->userdata); + drag->ticking = false; +} + static LONG_PTR borderless_style(LONG_PTR style) { return (style & ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX)) | WS_POPUP; @@ -29,13 +53,44 @@ static LONG_PTR borderless_style(LONG_PTR style) { static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + bongo_cat_windows_tray_handle_message(message); WNDPROC original = (WNDPROC)GetPropW(window, original_proc_property); WindowsMenuBinding *menu = GetPropW(window, menu_binding_property); WindowsDragBinding *drag = GetPropW(window, drag_binding_property); + if (message == WM_NCCALCSIZE && wparam && lparam && + GetPropW(window, preserve_screen_property)) { + NCCALCSIZE_PARAMS *sizes = (NCCALCSIZE_PARAMS *)lparam; + RECT previous_client = sizes->rgrc[2]; + /* SDL must still calculate the new client rectangle and update its + border bookkeeping. Its redraw/alignment flags are replaced only + for our synchronous move-and-resize transaction. */ + CallWindowProcW(original ? original : DefWindowProcW, + window, message, wparam, lparam); + RECT overlap; + if (IntersectRect(&overlap, &previous_client, &sizes->rgrc[0])) { + /* Both rectangles use screen coordinates. Equal source and + destination preserve the old frame in place, rather than + shifting it with the new window origin before the GL swap. */ + sizes->rgrc[1] = overlap; + sizes->rgrc[2] = overlap; + return WVR_VALIDRECTS; + } + return WVR_REDRAW; + } if (bongo_cat_windows_capture_handle_message(window, message, wparam)) { return 0; - } else if (message == WM_NCHITTEST && GetPropW(window, click_through_property)) { - return HTTRANSPARENT; + } else if (message == WM_NCHITTEST) { + INT_PTR click_through = (INT_PTR)GetPropW( + window, click_through_property); + if (bongo_cat_windows_borderless_hit_transparent( + click_through == BONGO_CAT_CLICK_THROUGH_FORCED, + click_through == BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL, + bongo_cat_windows_right_button_down() || + GetCapture() == window)) return HTTRANSPARENT; + } else if (message == WM_RBUTTONDOWN && + (INT_PTR)GetPropW(window, click_through_property) == + BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL) { + SetCapture(window); } else if (message == WM_SYSCOMMAND && (wparam & 0xFFF0u) == SC_MINIMIZE) { return 0; @@ -56,21 +111,29 @@ static LRESULT CALLBACK borderless_window_proc(HWND window, UINT message, UINT id = LOWORD(wparam), flags = HIWORD(wparam); if (flags == 0xffff && !lparam) return CallWindowProcW( original ? original : DefWindowProcW, window, message, wparam, lparam); - if ((flags & (MF_POPUP | MF_SEPARATOR)) || !id) + if ((flags & (MF_POPUP | MF_SEPARATOR)) || !id) { menu->preview(menu->userdata, BONGO_CAT_MENU_NONE); - else - menu->preview(menu->userdata, (BongoCatMenuAction)id); + } else { + BongoCatMenuAction action = (BongoCatMenuAction)id; + menu->preview(menu->userdata, action); + } } else if (message == WM_TIMER && wparam == BONGO_CAT_MENU_PREVIEW_TIMER && menu && menu->tick) { menu->tick(menu->userdata); return 0; } else if (message == WM_TIMER && wparam == BONGO_CAT_DRAG_MODAL_TIMER && drag && drag->tick) { - drag->tick(drag->userdata); + drag_tick(drag); return 0; } - return CallWindowProcW(original ? original : DefWindowProcW, + LRESULT result = CallWindowProcW(original ? original : DefWindowProcW, window, message, wparam, lparam); + if (message == WM_RBUTTONUP && GetCapture() == window) + ReleaseCapture(); + /* Move messages keep frames flowing when the low-priority timer is starved. */ + if (drag && (message == WM_MOUSEMOVE || message == WM_NCMOUSEMOVE)) + drag_tick(drag); + return result; } void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, @@ -79,8 +142,9 @@ void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, WindowsDragBinding binding = {modal_tick, userdata}; bool bound = modal_tick && SetPropW(window, drag_binding_property, &binding); if (bound) { - modal_tick(userdata); - SetTimer(window, BONGO_CAT_DRAG_MODAL_TIMER, 16, NULL); + drag_tick(&binding); + SetTimer(window, BONGO_CAT_DRAG_MODAL_TIMER, + BONGO_CAT_DRAG_FRAME_INTERVAL_MS, NULL); } ReleaseCapture(); SendMessageW(window, WM_NCLBUTTONDOWN, HTCAPTION, 0); @@ -136,19 +200,30 @@ void bongo_cat_windows_borderless_uninstall(HWND window) { SetWindowLongPtrW(window, GWLP_WNDPROC, (LONG_PTR)original); RemovePropW(window, original_proc_property); RemovePropW(window, click_through_property); + RemovePropW(window, preserve_screen_property); } -void bongo_cat_windows_borderless_set_click_through(HWND window, bool enabled) { +bool bongo_cat_windows_borderless_preserve_screen(HWND window, bool enabled) { + if (!window) return false; + if (!enabled) { + RemovePropW(window, preserve_screen_property); + return true; + } + return GetPropW(window, original_proc_property) && + SetPropW(window, preserve_screen_property, (HANDLE)1); +} + +void bongo_cat_windows_borderless_set_click_through(HWND window, + bool forced, bool pointer_transparent) { if (!window) return; - if (enabled) SetPropW(window, click_through_property, (HANDLE)(INT_PTR)1); + INT_PTR mode = forced ? BONGO_CAT_CLICK_THROUGH_FORCED : + pointer_transparent ? BONGO_CAT_CLICK_THROUGH_TRANSPARENT_PIXEL : 0; + if (mode) SetPropW(window, click_through_property, (HANDLE)mode); else RemovePropW(window, click_through_property); LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); - LONG_PTR next = enabled ? style | WS_EX_TRANSPARENT : + LONG_PTR next = forced ? style | WS_EX_TRANSPARENT : style & ~WS_EX_TRANSPARENT; - if (next != style) { - SetWindowLongPtrW(window, GWL_EXSTYLE, next); - SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - } + /* The hit-test property is immediate; a frame refresh flickers OpenGL windows. */ + if (next != style) SetWindowLongPtrW(window, GWL_EXSTYLE, next); } #endif diff --git a/src/platform/windows_borderless.h b/src/platform/windows/windows_borderless.h similarity index 70% rename from src/platform/windows_borderless.h rename to src/platform/windows/windows_borderless.h index 33d72d6b..11644a65 100644 --- a/src/platform/windows_borderless.h +++ b/src/platform/windows/windows_borderless.h @@ -7,7 +7,11 @@ void bongo_cat_windows_borderless_install(HWND window); void bongo_cat_windows_borderless_uninstall(HWND window); -void bongo_cat_windows_borderless_set_click_through(HWND window, bool enabled); +bool bongo_cat_windows_borderless_preserve_screen(HWND window, bool enabled); +bool bongo_cat_windows_borderless_hit_transparent(bool forced, + bool pointer_transparent, bool right_button_down); +void bongo_cat_windows_borderless_set_click_through(HWND window, + bool forced, bool pointer_transparent); void bongo_cat_windows_menu_preview(HWND window, BongoCatMenuPreview preview, void (*tick)(void *userdata), void *userdata); void bongo_cat_windows_begin_drag(HWND window, BongoCatModalTick modal_tick, diff --git a/src/platform/windows/windows_capture.c b/src/platform/windows/windows_capture.c new file mode 100644 index 00000000..25328211 --- /dev/null +++ b/src/platform/windows/windows_capture.c @@ -0,0 +1,194 @@ +#include "windows_capture.h" + +#ifdef _WIN32 +#include +#include +#include +#include + +static UINT taskbar_created_message; +static UINT capture_refresh_message; +static const wchar_t capture_property[] = L"BongoCat.CaptureWindow"; +static bool removal_warning_emitted; +static bool style_warning_emitted; +#define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) + +static bool has_property(HWND window, const wchar_t *name) { + return window && name && GetPropW(window, name) != NULL; +} + +bool bongo_cat_windows_capture_is_configured(HWND window) { + return has_property(window, capture_property); +} + +static bool read_extended_style(HWND window, LONG_PTR *style) { + SetLastError(ERROR_SUCCESS); + LONG_PTR value = GetWindowLongPtrW(window, GWL_EXSTYLE); + if (!value && GetLastError() != ERROR_SUCCESS) return false; + *style = value; + return true; +} + +static bool write_extended_style(HWND window, LONG_PTR style, DWORD *error) { + SetLastError(ERROR_SUCCESS); + LONG_PTR previous = SetWindowLongPtrW(window, GWL_EXSTYLE, style); + DWORD result = GetLastError(); + if (error) *error = result; + return previous || result == ERROR_SUCCESS; +} + +static void register_messages(void) { + if (!taskbar_created_message) + taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); + if (!capture_refresh_message) + capture_refresh_message = RegisterWindowMessageW( + L"BongoCat.CaptureWindow.RefreshTaskbar"); +} + +static HRESULT remove_taskbar_tab(HWND window) { + HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) + return initialized; + ITaskbarList *taskbar = NULL; + HRESULT result = CoCreateInstance(&CLSID_TaskbarList, NULL, + CLSCTX_INPROC_SERVER, &IID_ITaskbarList, (void **)&taskbar); + if (SUCCEEDED(result)) result = taskbar->lpVtbl->HrInit(taskbar); + if (SUCCEEDED(result)) result = taskbar->lpVtbl->DeleteTab(taskbar, window); + if (taskbar) taskbar->lpVtbl->Release(taskbar); + if (SUCCEEDED(initialized)) CoUninitialize(); + return result; +} + +void bongo_cat_windows_capture_log(HWND window, const char *stage) { + if (!window || !IsWindow(window)) return; + if (SDL_GetLogPriority(SDL_LOG_CATEGORY_VIDEO) > SDL_LOG_PRIORITY_DEBUG) return; + RECT bounds = {0}; + DWORD cloaked = 0, affinity = 0; + HRESULT cloak_result = DwmGetWindowAttribute(window, DWMWA_CLOAKED, + &cloaked, sizeof(cloaked)); + bool affinity_known = GetWindowDisplayAffinity(window, &affinity) != FALSE; + LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + LONG_PTR extended = GetWindowLongPtrW(window, GWL_EXSTYLE); + GetWindowRect(window, &bounds); + SDL_LogDebug(SDL_LOG_CATEGORY_VIDEO, + "Windows capture state (%s): hwnd=%p visible=%d iconic=%d " + "cloaked=%lu cloak_known=%d rect=%ld,%ld %ldx%ld style=0x%llx " + "exstyle=0x%llx owner=%p affinity=0x%lx affinity_known=%d", + stage ? stage : "unknown", (void *)window, + IsWindowVisible(window) != FALSE, IsIconic(window) != FALSE, + (unsigned long)cloaked, SUCCEEDED(cloak_result), + (long)bounds.left, (long)bounds.top, + (long)(bounds.right - bounds.left), + (long)(bounds.bottom - bounds.top), + (unsigned long long)style, (unsigned long long)extended, + (void *)GetWindowLongPtrW(window, GWLP_HWNDPARENT), + (unsigned long)affinity, affinity_known); +} + +static void refresh_taskbar(HWND window) { + if (!window) return; + HRESULT result = remove_taskbar_tab(window); + if (FAILED(result) && IsWindowVisible(window) && !removal_warning_emitted) { + removal_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot remove the capture window from the taskbar (0x%08lx)", + (unsigned long)result); + } +} + +static void schedule_refresh(HWND window) { + register_messages(); + if (!window) return; + if (capture_refresh_message) + PostMessageW(window, capture_refresh_message, 0, 0); + SetTimer(window, BONGO_CAT_CAPTURE_REFRESH_TIMER, 250, NULL); +} + +bool bongo_cat_windows_capture_configure(HWND window) { + if (!window || !IsWindow(window)) return false; + register_messages(); + SetPropW(window, capture_property, (HANDLE)1); + LONG_PTR style = 0; + if (!read_extended_style(window, &style)) { + if (!style_warning_emitted) { + style_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot read the window style required for OBS discovery"); + } + bongo_cat_windows_capture_repair_transparency(window); + return false; + } + /* OBS does not require APPWINDOW. Avoid a hide/show style transition for + ordinary SDL windows because it can recreate their DWM redirection. */ + LONG_PTR next = style & ~WS_EX_TOOLWINDOW; + if (next != style) { + bool shown = IsWindowVisible(window) != FALSE; + if (shown) ShowWindow(window, SW_HIDE); + DWORD style_error = ERROR_SUCCESS; + if (write_extended_style(window, next, &style_error)) { + SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | + SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + } else if (!style_warning_emitted) { + style_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot update the window style required for OBS discovery " + "(Win32 error %lu, old=0x%llx, new=0x%llx)", + (unsigned long)style_error, (unsigned long long)style, + (unsigned long long)next); + } + if (shown) { + ShowWindow(window, SW_SHOWNOACTIVATE); + UpdateWindow(window); + } + } + /* Style/frame changes can recreate the DWM redirection surface. Repair + after the complete hide/show transaction, and on the idempotent path + as well so repeated configure calls remain safe. */ + bongo_cat_windows_capture_repair_transparency(window); + LONG_PTR applied = 0; + bool ready = read_extended_style(window, &applied) && + !(applied & (WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP)); + if (!ready && !style_warning_emitted) { + style_warning_emitted = true; + if (applied & WS_EX_NOREDIRECTIONBITMAP) + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Window capture is incompatible with WS_EX_NOREDIRECTIONBITMAP " + "(exstyle=0x%llx)", (unsigned long long)applied); + else + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot configure the window for OBS discovery"); + } + refresh_taskbar(window); + schedule_refresh(window); + return ready; +} + +bool bongo_cat_windows_capture_handle_message( + HWND window, UINT message, WPARAM wparam) { + register_messages(); + if (bongo_cat_windows_capture_handle_transparency_message( + window, message, wparam)) return true; + if (capture_refresh_message && message == capture_refresh_message) { + if (!has_property(window, capture_property)) return false; + refresh_taskbar(window); + return true; + } + if (message == WM_TIMER && + wparam == BONGO_CAT_CAPTURE_REFRESH_TIMER && + has_property(window, capture_property)) { + KillTimer(window, BONGO_CAT_CAPTURE_REFRESH_TIMER); + refresh_taskbar(window); + return true; + } + if (taskbar_created_message && message == taskbar_created_message) { + bool capture_window = has_property(window, capture_property); + if (capture_window) { + refresh_taskbar(window); + schedule_refresh(window); + } + bongo_cat_windows_capture_repair_transparency(window); + return capture_window; + } + return false; +} +#endif diff --git a/src/platform/windows/windows_capture.h b/src/platform/windows/windows_capture.h new file mode 100644 index 00000000..de7890eb --- /dev/null +++ b/src/platform/windows/windows_capture.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_WINDOWS_CAPTURE_H +#define BONGO_CAT_WINDOWS_CAPTURE_H + +#ifdef _WIN32 +#include +#include + +bool bongo_cat_windows_capture_configure(HWND window); +/* Mark windows whose OpenGL back buffer relies on DWM alpha composition. */ +void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled); +/* Returns whether OBS/capture discovery has been configured for this HWND. */ +bool bongo_cat_windows_capture_is_configured(HWND window); +/* Install a small native shim for synchronous DWM/shell notifications. */ +void bongo_cat_windows_capture_install_transparency_handler(HWND window); +bool bongo_cat_windows_capture_restore_transparency(HWND window); +/* Re-apply DWM alpha composition after a shell, style, or display change. */ +void bongo_cat_windows_capture_repair_transparency(HWND window); +bool bongo_cat_windows_capture_handle_transparency_message( + HWND window, UINT message, WPARAM wparam); +void bongo_cat_windows_capture_log(HWND window, const char *stage); +bool bongo_cat_windows_capture_handle_message( + HWND window, UINT message, WPARAM wparam); +#endif + +#endif diff --git a/src/platform/windows/windows_capture_probe.c b/src/platform/windows/windows_capture_probe.c new file mode 100644 index 00000000..23683538 --- /dev/null +++ b/src/platform/windows/windows_capture_probe.c @@ -0,0 +1,231 @@ +#include "windows_diagnostics.h" + +#ifdef _WIN32 +#include +#include +#include +#include + +#ifndef PW_RENDERFULLCONTENT +#define PW_RENDERFULLCONTENT 0x00000002 +#endif + +typedef struct CaptureSurface { + HDC dc; + HBITMAP bitmap; + HGDIOBJ previous; + unsigned char *pixels; + size_t bytes; + int width; + int height; +} CaptureSurface; + +typedef struct CaptureObservation { + bool called; + bool wrote_pixels; + bool nonblack; + unsigned long long hash; +} CaptureObservation; + +static bool capture_surface_create(CaptureSurface *surface, + int width, int height) { + if (!surface || width < 1 || height < 1 || width > 8192 || height > 8192) + return false; + size_t pixels = (size_t)width * (size_t)height; + if (pixels / (size_t)width != (size_t)height || pixels > SIZE_MAX / 4) + return false; + *surface = (CaptureSurface){.width = width, .height = height, + .bytes = pixels * 4}; + surface->dc = CreateCompatibleDC(NULL); + if (!surface->dc) return false; + BITMAPINFO info = {0}; + info.bmiHeader.biSize = sizeof(info.bmiHeader); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void *bits = NULL; + surface->bitmap = CreateDIBSection(surface->dc, &info, + DIB_RGB_COLORS, &bits, NULL, 0); + if (!surface->bitmap || !bits) { + if (surface->bitmap) DeleteObject(surface->bitmap); + DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; + return false; + } + surface->pixels = bits; + surface->previous = SelectObject(surface->dc, surface->bitmap); + if (!surface->previous || surface->previous == HGDI_ERROR) { + DeleteObject(surface->bitmap); + DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; + return false; + } + return true; +} + +static void capture_surface_destroy(CaptureSurface *surface) { + if (!surface) return; + if (surface->dc && surface->previous) + SelectObject(surface->dc, surface->previous); + if (surface->bitmap) DeleteObject(surface->bitmap); + if (surface->dc) DeleteDC(surface->dc); + *surface = (CaptureSurface){0}; +} + +static void capture_surface_prepare(CaptureSurface *surface) { + for (size_t index = 0; index < surface->bytes; index += 4) { + surface->pixels[index] = 0x5a; + surface->pixels[index + 1] = 0xa5; + surface->pixels[index + 2] = 0x3c; + surface->pixels[index + 3] = 0xc7; + } +} + +static CaptureObservation log_capture_pixels(const char *stage, + const char *method, const CaptureSurface *surface, + bool called, DWORD error) { + CaptureObservation observation = {.called = called}; + if (!surface || !surface->pixels || !surface->bytes) return observation; + unsigned long long total = (unsigned long long)surface->width * + (unsigned long long)surface->height; + unsigned long long untouched = 0, black = 0, colored = 0; + unsigned long long key_green = 0, key_blue = 0; + unsigned long long key_red = 0, key_magenta = 0; + unsigned long long red = 0, green = 0, blue = 0; + uint64_t hash = UINT64_C(1469598103934665603); + for (size_t index = 0; index < surface->bytes; index += 4) { + unsigned b = surface->pixels[index]; + unsigned g = surface->pixels[index + 1]; + unsigned r = surface->pixels[index + 2]; + bool unchanged = b == 0x5a && g == 0xa5 && r == 0x3c && + surface->pixels[index + 3] == 0xc7; + untouched += unchanged; + for (int component = 0; component < 4; ++component) { + hash ^= surface->pixels[index + (size_t)component]; + hash *= UINT64_C(1099511628211); + } + if (unchanged) continue; + unsigned maximum = r > g ? (r > b ? r : b) : (g > b ? g : b); + unsigned minimum = r < g ? (r < b ? r : b) : (g < b ? g : b); + unsigned sum = r + g + b; + black += sum <= 24; + colored += sum > 40 && maximum - minimum > 16; + key_green += g > 160 && r < 96 && b < 96; + key_blue += b > 160 && r < 96 && g < 96; + key_red += r > 160 && g < 96 && b < 96; + key_magenta += r > 160 && b > 160 && g < 96; + red += r; green += g; blue += b; + } + unsigned long long written = total - untouched; + unsigned long long nonblack = written - black; + unsigned long long measured = written ? written : 1; + observation.wrote_pixels = written > 0; + observation.nonblack = written > 0 && nonblack * 1000 >= written; + observation.hash = hash; + SDL_Log("Legacy capture probe (%s/%s): call=%d error=%lu wrote_pixels=%d " + "size=%dx%d total=%llu written=%llu (%.2f%%) nonblack=%llu " + "(%.2f%%) colored=%llu (%.2f%%) key_green=%llu key_blue=%llu " + "key_red=%llu key_magenta=%llu avg_rgb=%.1f,%.1f,%.1f hash=0x%016llx", + stage ? stage : "unknown", method, called, (unsigned long)error, + observation.wrote_pixels, surface->width, surface->height, total, + written, 100.0 * written / total, nonblack, + 100.0 * nonblack / measured, colored, 100.0 * colored / measured, + key_green, key_blue, key_red, key_magenta, + (double)red / measured, (double)green / measured, + (double)blue / measured, + (unsigned long long)hash); + return observation; +} + +static CaptureObservation probe_bitblt(HDC source, DWORD source_error, + CaptureSurface *surface, const char *stage, const char *method, + DWORD operation) { + capture_surface_prepare(surface); + if (source) SetLastError(ERROR_SUCCESS); + bool called = source && BitBlt(surface->dc, 0, 0, + surface->width, surface->height, source, 0, 0, operation) != FALSE; + DWORD error = called ? ERROR_SUCCESS : source ? GetLastError() : source_error; + GdiFlush(); + return log_capture_pixels(stage, method, surface, called, error); +} + +static CaptureObservation probe_print_window(HWND window, + CaptureSurface *surface, const char *stage, const char *method, + UINT flags) { + capture_surface_prepare(surface); + SetLastError(ERROR_SUCCESS); + bool called = PrintWindow(window, surface->dc, flags) != FALSE; + DWORD error = called ? ERROR_SUCCESS : GetLastError(); + GdiFlush(); + return log_capture_pixels(stage, method, surface, called, error); +} + +bool bongo_cat_windows_diagnostics_probe_capture( + HWND window, const char *stage) { + if (!window || !IsWindow(window)) return false; + RECT client = {0}; + if (!GetClientRect(window, &client)) return false; + int width = client.right - client.left; + int height = client.bottom - client.top; + CaptureSurface surface; + if (!capture_surface_create(&surface, width, height)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture probe (%s): cannot allocate %dx%d surface " + "(Win32 error %lu)", stage ? stage : "unknown", width, height, + (unsigned long)GetLastError()); + return false; + } + SDL_Log("Legacy capture probe begin: stage=%s hwnd=%p visible=%d " + "iconic=%d client=%dx%d", + stage ? stage : "unknown", (void *)window, + IsWindowVisible(window) != FALSE, IsIconic(window) != FALSE, + width, height); + HRESULT flush = DwmFlush(); + SDL_Log("Legacy capture probe composition sync: stage=%s result=0x%08lx", + stage ? stage : "unknown", (unsigned long)flush); + SetLastError(ERROR_SUCCESS); + HDC source = GetDC(window); + DWORD source_error = source ? ERROR_SUCCESS : GetLastError(); + CaptureObservation bitblt = probe_bitblt(source, source_error, &surface, + stage, "bitblt", SRCCOPY); + CaptureObservation captureblt = probe_bitblt(source, source_error, &surface, + stage, "bitblt-captureblt", SRCCOPY | CAPTUREBLT); + if (source) ReleaseDC(window, source); + CaptureObservation print_client = probe_print_window(window, &surface, + stage, "printwindow-client", PW_CLIENTONLY); + CaptureObservation print_full = probe_print_window(window, &surface, + stage, "printwindow-fullcontent", PW_CLIENTONLY | PW_RENDERFULLCONTENT); + const CaptureObservation observations[] = { + bitblt, captureblt, print_client, print_full}; + unsigned called = 0, wrote = 0, nonblack = 0; + for (size_t index = 0; index < 4; ++index) { + called += observations[index].called; + wrote += observations[index].wrote_pixels; + nonblack += observations[index].nonblack; + } + SDL_Log("Legacy capture probe summary: stage=%s calls=%u/4 writes=%u/4 " + "nonblack=%u/4 bitblt=%d captureblt=%d print_client=%d print_full=%d", + stage ? stage : "unknown", called, wrote, nonblack, + bitblt.nonblack, captureblt.nonblack, + print_client.nonblack, print_full.nonblack); + if (!called) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): every API call failed", + stage ? stage : "unknown"); + else if (!wrote) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): APIs reported success without writing pixels", + stage ? stage : "unknown"); + else if (!nonblack) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): written results contain only black pixels", + stage ? stage : "unknown"); + else if (nonblack < wrote) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Legacy capture diagnosis (%s): pixel availability depends on the API", + stage ? stage : "unknown"); + else SDL_Log("Legacy capture diagnosis (%s): every written result contains " + "nonblack pixels; this does not prove a third-party capturer uses that path", + stage ? stage : "unknown"); + capture_surface_destroy(&surface); + return true; +} +#endif diff --git a/src/platform/windows/windows_diagnostics.h b/src/platform/windows/windows_diagnostics.h new file mode 100644 index 00000000..a1cfce71 --- /dev/null +++ b/src/platform/windows/windows_diagnostics.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_WINDOWS_DIAGNOSTICS_H +#define BONGO_CAT_WINDOWS_DIAGNOSTICS_H + +#ifdef _WIN32 +#include +#include + +bool bongo_cat_windows_diagnostics_probe_capture( + HWND window, const char *stage); +#endif + +#endif diff --git a/src/platform/windows/windows_dialog.c b/src/platform/windows/windows_dialog.c new file mode 100644 index 00000000..cac7d1b6 --- /dev/null +++ b/src/platform/windows/windows_dialog.c @@ -0,0 +1,212 @@ +#include "windows_dialog.h" + +#ifdef _WIN32 + +#include +#include + +#include +#include +#include +#include + +typedef struct BongoCatWindowsFolderDialogArgs { + SDL_DialogFileCallback callback; + void *userdata; + HWND owner; + char *default_location; + bool allow_many; + Uint64 started_ns; +} BongoCatWindowsFolderDialogArgs; + +static void notify_cancel(BongoCatWindowsFolderDialogArgs *args) { + const char *files[] = {NULL}; + args->callback(args->userdata, files, -1); +} + +static char *wide_to_utf8(const wchar_t *value) { + if (!value) return NULL; + int length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value, -1, NULL, 0, NULL, NULL); + if (length <= 0) return NULL; + char *result = SDL_malloc((size_t)length); + if (!result || !WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value, -1, result, length, NULL, NULL)) { + SDL_free(result); + return NULL; + } + return result; +} + +static HWND owner_window(SDL_Window *window) { + if (!window) return NULL; + return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); +} + +static bool show_folder_dialog(BongoCatWindowsFolderDialogArgs *args) { + IFileOpenDialog *dialog = NULL; + IShellItem *default_folder = NULL; + IShellItem *item = NULL; + IShellItemArray *items = NULL; + wchar_t *default_location = NULL; + LPWSTR path = NULL; + char **files = NULL; + bool callback_called = false; + bool com_initialized = false; + HRESULT result; + + result = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(result)) goto done; + com_initialized = true; + + result = CoCreateInstance(&CLSID_FileOpenDialog, NULL, + CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&dialog); + if (FAILED(result)) goto done; + + FILEOPENDIALOGOPTIONS options = 0; + result = dialog->lpVtbl->GetOptions(dialog, &options); + if (FAILED(result)) goto done; + options |= FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | + FOS_NOCHANGEDIR; + if (args->allow_many) options |= FOS_ALLOWMULTISELECT; + result = dialog->lpVtbl->SetOptions(dialog, options); + if (FAILED(result)) goto done; + + if (args->default_location && args->default_location[0]) { + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + args->default_location, -1, NULL, 0); + if (length > 0) { + default_location = SDL_malloc((size_t)length * sizeof(*default_location)); + if (!default_location || !MultiByteToWideChar(CP_UTF8, + MB_ERR_INVALID_CHARS, args->default_location, -1, + default_location, length)) goto done; + result = SHCreateItemFromParsingName(default_location, NULL, + &IID_IShellItem, (void **)&default_folder); + if (SUCCEEDED(result)) { + if (FAILED(dialog->lpVtbl->SetFolder(dialog, default_folder))) { + default_folder->lpVtbl->Release(default_folder); + default_folder = NULL; + } + } + /* An invalid remembered location should not prevent opening the + * picker; Windows will use its normal last-location fallback. */ + result = S_OK; + } + } + + result = dialog->lpVtbl->Show(dialog, args->owner); + if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + notify_cancel(args); + callback_called = true; + goto done; + } + if (FAILED(result)) goto done; + + if (args->allow_many) { + DWORD count = 0; + result = dialog->lpVtbl->GetResults(dialog, &items); + if (FAILED(result) || !items || + FAILED(items->lpVtbl->GetCount(items, &count)) || count == 0) + goto done; + + files = SDL_calloc((size_t)count + 1, sizeof(*files)); + if (!files) goto done; + for (DWORD index = 0; index < count; ++index) { + IShellItem *selected = NULL; + result = items->lpVtbl->GetItemAt(items, index, &selected); + if (FAILED(result) || !selected || + FAILED(selected->lpVtbl->GetDisplayName(selected, + SIGDN_FILESYSPATH, &path))) { + if (selected) selected->lpVtbl->Release(selected); + goto done; + } + files[index] = wide_to_utf8(path); + CoTaskMemFree(path); + path = NULL; + selected->lpVtbl->Release(selected); + if (!files[index]) goto done; + } + args->callback(args->userdata, (const char *const *)files, -1); + callback_called = true; + } else { + result = dialog->lpVtbl->GetResult(dialog, &item); + if (FAILED(result) || !item || + FAILED(item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, + &path))) goto done; + char *file = wide_to_utf8(path); + if (!file) goto done; + const char *selected[] = {file, NULL}; + args->callback(args->userdata, selected, -1); + callback_called = true; + SDL_free(file); + CoTaskMemFree(path); + path = NULL; + } + +done: + if (!callback_called) notify_cancel(args); + if (path) CoTaskMemFree(path); + if (files) { + for (char **file = files; *file; ++file) SDL_free(*file); + SDL_free(files); + } + if (item) item->lpVtbl->Release(item); + if (items) items->lpVtbl->Release(items); + if (default_folder) default_folder->lpVtbl->Release(default_folder); + if (dialog) dialog->lpVtbl->Release(dialog); + SDL_free(default_location); + if (com_initialized) CoUninitialize(); + return callback_called; +} + +static int SDLCALL folder_dialog_thread(void *userdata) { + BongoCatWindowsFolderDialogArgs *args = userdata; + show_folder_dialog(args); + SDL_Log("[runtime] Folder dialog: stage=completed owner=%p elapsed_ms=%.2f", + (void *)args->owner, + (double)(SDL_GetTicksNS() - args->started_ns) / 1000000.0); + SDL_free(args->default_location); + SDL_free(args); + return 0; +} + +void bongo_cat_windows_show_open_folder_dialog( + SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, + const char *default_location, bool allow_many) { + if (!callback) return; + BongoCatWindowsFolderDialogArgs *args = SDL_calloc(1, sizeof(*args)); + if (!args) { + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + args->callback = callback; + args->userdata = userdata; + args->owner = owner_window(window); + args->allow_many = allow_many; + args->started_ns = SDL_GetTicksNS(); + if (default_location) { + args->default_location = SDL_strdup(default_location); + if (!args->default_location) { + SDL_free(args); + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + } + SDL_Thread *thread = SDL_CreateThread(folder_dialog_thread, + "BongoCat_WindowsFolderDialog", args); + if (!thread) { + SDL_free(args->default_location); + SDL_free(args); + const char *files[] = {NULL}; + callback(userdata, files, -1); + return; + } + SDL_Log("[runtime] Folder dialog: stage=opened owner=%p allow_many=%d", + (void *)args->owner, allow_many); + SDL_DetachThread(thread); +} + +#endif /* _WIN32 */ diff --git a/src/platform/windows/windows_dialog.h b/src/platform/windows/windows_dialog.h new file mode 100644 index 00000000..3b8aa5db --- /dev/null +++ b/src/platform/windows/windows_dialog.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_WINDOWS_DIALOG_H +#define BONGO_CAT_WINDOWS_DIALOG_H + +#ifdef _WIN32 + +#include +#include + +/* + * SDL 3.2's Windows folder-dialog backend still uses + * SHBrowseForFolderW(), which is the small, tree-only "Browse for Folder" + * dialog. Keep the same asynchronous callback contract here, but use the + * modern IFileDialog implementation instead. + */ +void bongo_cat_windows_show_open_folder_dialog( + SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, + const char *default_location, bool allow_many); + +#endif /* _WIN32 */ + +#endif /* BONGO_CAT_WINDOWS_DIALOG_H */ diff --git a/src/platform/windows_directory.c b/src/platform/windows/windows_directory.c similarity index 100% rename from src/platform/windows_directory.c rename to src/platform/windows/windows_directory.c diff --git a/src/platform/windows/windows_input.c b/src/platform/windows/windows_input.c new file mode 100644 index 00000000..de866282 --- /dev/null +++ b/src/platform/windows/windows_input.c @@ -0,0 +1,241 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +#include +#include +#include + +static volatile LONG receiver_claimed; + +static void free_state(WindowsInputState *state); + +static void release_state(WindowsInputState *state) { + if (InterlockedDecrement(&state->references) == 0) free_state(state); +} + +void bongo_cat_windows_input_wake(WindowsInputState *state) { + if (!state->wake_pending) return; + AcquireSRWLockShared(&state->platform_lock); + BongoCatPlatform *platform = state->platform; + if (platform && platform->wake_event_type >= SDL_EVENT_USER) { + SDL_Event wake; + SDL_zero(wake); + wake.type = platform->wake_event_type; + if (SDL_PushEvent(&wake)) state->wake_pending = false; + else state->diagnostic_wake_failures++; + } + ReleaseSRWLockShared(&state->platform_lock); +} + +bool bongo_cat_windows_input_push_event(WindowsInputState *state, + BongoCatInputKind kind, const char *name, float value) { + if (!state || !name) return false; + AcquireSRWLockShared(&state->platform_lock); + BongoCatPlatform *platform = state->platform; + bool pushed = false; + if (platform) { + BongoCatInputEvent event = {0}; + event.kind = kind; + event.timestamp_ms = SDL_GetTicks(); + event.value = value; + snprintf(event.name, sizeof(event.name), "%s", name); + pushed = bongo_cat_input_push(platform->input, &event); + if (!pushed) state->diagnostic_queue_failures++; + else if (kind == BONGO_CAT_INPUT_KEY_DOWN || kind == BONGO_CAT_INPUT_KEY_UP) + state->diagnostic_queued_keys++; + } + ReleaseSRWLockShared(&state->platform_lock); + if (pushed) state->wake_pending = true; + return pushed; +} + +static bool input_desktop_available(void) { + HDESK active = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS); + if (!active) return false; + wchar_t active_name[256] = {0}, receiver_name[256] = {0}; + DWORD needed = 0; + bool available = GetUserObjectInformationW(active, UOI_NAME, + active_name, sizeof(active_name), &needed) && + GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), + UOI_NAME, receiver_name, sizeof(receiver_name), &needed) && + wcscmp(active_name, receiver_name) == 0; + CloseDesktop(active); + return available; +} + +static void check_receiver(WindowsInputState *state) { + unsigned ownership = bongo_cat_windows_input_ownership(state); + bool inspected = state->registration_error == ERROR_SUCCESS; + if (!inspected) ownership = state->ownership; + bool unavailable = !input_desktop_available(); + if (ownership != state->ownership || unavailable != state->desktop_unavailable) { + if (ownership != state->ownership) SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input ownership changed: previous=%u current=%u " + "mouse_flags=%lu keyboard_flags=%lu", state->ownership, ownership, + (unsigned long)state->mouse_registration_flags, + (unsigned long)state->keyboard_registration_flags); + if (ownership != state->ownership || unavailable) + bongo_cat_windows_input_clear_devices(state); + else bongo_cat_windows_input_clear_motion(state); + state->ownership = ownership; + state->desktop_unavailable = unavailable; + } + /* Repair a lost subscription on the normal input desktop, after releasing + stale keys. Never infer failure from silence or foreground monitor. */ + if (inspected && !unavailable) + ownership = bongo_cat_windows_input_restore(state, ownership); + state->ownership = ownership; + AcquireSRWLockExclusive(&state->relative_lock); + /* Desktop inspection may be denied even when valid WM_INPUT still arrives. */ + state->receiving = (ownership & 1u) != 0; + ReleaseSRWLockExclusive(&state->relative_lock); +} + +static void log_receiver(WindowsInputState *state, const char *phase) { + HWND foreground = GetForegroundWindow(); + DWORD pid = 0; + if (foreground) GetWindowThreadProcessId(foreground, &pid); + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + bool monitor_known = foreground && GetMonitorInfoW( + MonitorFromWindow(foreground, MONITOR_DEFAULTTONULL), &monitor); + /* Snapshot only: counters span the whole interval, not just this window. + No process access, titles, device identifiers, or key values are logged. */ + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] receiver phase=%s uptime_ms=%llu keys=%llu mouse=%llu " + "background_keys=%llu filtered_keys=%llu duplicate_keys=%llu " + "queued_keys=%llu queue_failures=%llu wake_failures=%llu " + "invalid=%llu device_drops=%llu read_failures=%llu device_changes=%llu " + "owned=%u mouse_flags=%lu keyboard_flags=%lu desktop_unavailable=%d " + "query_error=%lu recovery_error=%lu last_read_error=%lu devices=%u " + "monitors=%d foreground_pid=%lu foreground_monitor_known=%d " + "foreground_primary=%d foreground_rect=%ld,%ld,%ld,%ld", + phase, (unsigned long long)GetTickCount64(), + state->diagnostic_keys, state->diagnostic_mouse, + state->diagnostic_background_keys, state->diagnostic_filtered_keys, + state->diagnostic_duplicate_keys, + state->diagnostic_queued_keys, state->diagnostic_queue_failures, + state->diagnostic_wake_failures, state->diagnostic_invalid, + state->diagnostic_device_drops, state->diagnostic_read_failures, + state->diagnostic_device_changes, state->ownership, + (unsigned long)state->mouse_registration_flags, + (unsigned long)state->keyboard_registration_flags, + state->desktop_unavailable, (unsigned long)state->registration_error, + (unsigned long)state->recovery_error, (unsigned long)state->read_error, + state->device_count, + GetSystemMetrics(SM_CMONITORS), (unsigned long)pid, monitor_known, + (monitor.dwFlags & MONITORINFOF_PRIMARY) != 0, + monitor.rcMonitor.left, monitor.rcMonitor.top, + monitor.rcMonitor.right, monitor.rcMonitor.bottom); +} + +static DWORD WINAPI input_thread(void *context) { + WindowsInputState *state = context; + if (WaitForSingleObject(state->stop, state->test_start_delay_ms) == WAIT_OBJECT_0) { + SetEvent(state->ready); + release_state(state); + return 0; + } + state->registered = bongo_cat_windows_input_receiver_create(state); + if (!state->registered) SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "Raw Input initialization failed: error=%lu", (unsigned long)state->startup_error); + if (state->registered) { + state->ownership = 3; + check_receiver(state); + log_receiver(state, "started"); + state->diagnostic_ms = GetTickCount64(); + } + SetEvent(state->ready); + ULONGLONG last_check = GetTickCount64(), last_wake = 0; + while (state->registered) { + DWORD timeout = state->wake_pending || state->retry_events ? 8 : 1000; + DWORD wait = MsgWaitForMultipleObjectsEx(1, &state->stop, timeout, + QS_ALLINPUT, MWMO_INPUTAVAILABLE); + if (wait == WAIT_OBJECT_0) break; + if (wait == WAIT_FAILED) { + SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input wait failed: error=%lu", (unsigned long)GetLastError()); + break; + } + if (!bongo_cat_windows_input_dispatch(state)) break; + ULONGLONG now = GetTickCount64(); + if (now - last_check >= 1000) { + check_receiver(state); + last_check = now; + } + if (now - state->diagnostic_ms >= 10000) { + log_receiver(state, "periodic"); + state->diagnostic_ms = now; + } + bongo_cat_windows_input_flush(state); + if (now - last_wake >= 8) { + bongo_cat_windows_input_wake(state); + last_wake = now; + } + } + AcquireSRWLockExclusive(&state->relative_lock); + state->receiving = false; + ReleaseSRWLockExclusive(&state->relative_lock); + bongo_cat_windows_input_clear_devices(state); + bongo_cat_windows_input_wake(state); + if (state->registered) log_receiver(state, "stopping"); + bongo_cat_windows_input_receiver_destroy(state); + release_state(state); + return 0; +} + +static void free_state(WindowsInputState *state) { + if (state->thread) CloseHandle(state->thread); + if (state->ready) CloseHandle(state->ready); + if (state->stop) CloseHandle(state->stop); + free(state); + InterlockedExchange(&receiver_claimed, 0); +} + +bool bongo_cat_windows_input_start(BongoCatPlatform *platform) { + if (!platform || !platform->input || platform->native) return false; + if (InterlockedCompareExchange(&receiver_claimed, 1, 0) != 0) return false; + /* SDL retains legacy keyboard messages; this receiver owns Raw Input. */ + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); + WindowsInputState *state = calloc(1, sizeof(*state)); + if (!state) { InterlockedExchange(&receiver_claimed, 0); return false; } + InitializeSRWLock(&state->platform_lock); + InitializeSRWLock(&state->relative_lock); + state->platform = platform; + const char *delay_text = SDL_getenv("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS"); + if (delay_text) { + unsigned long delay = strtoul(delay_text, NULL, 10); + state->test_start_delay_ms = delay > 10000 ? 10000 : (DWORD)delay; + } + state->stop = CreateEventW(NULL, TRUE, FALSE, NULL); + state->ready = CreateEventW(NULL, TRUE, FALSE, NULL); + /* The caller retains ownership until the thread handle is published and + stop finishes waiting; the worker retains ownership until cleanup. */ + state->references = 2; + state->thread = state->stop && state->ready ? + CreateThread(NULL, 0, input_thread, state, 0, NULL) : NULL; + if (!state->thread) { free_state(state); return false; } + platform->native = state; + DWORD wait = WaitForSingleObject(state->ready, 1500); + if (wait == WAIT_OBJECT_0 && state->registered) return true; + SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input startup failed: wait=%lu", (unsigned long)wait); + bongo_cat_windows_input_stop(platform); + return false; +} + +void bongo_cat_windows_input_stop(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->platform_lock); + state->platform = NULL; + ReleaseSRWLockExclusive(&state->platform_lock); + SetEvent(state->stop); + platform->native = NULL; + if (WaitForSingleObject(state->thread, 3000) != WAIT_OBJECT_0) { + SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input thread did not stop; cleanup is deferred until thread exit"); + } + release_state(state); +} +#endif diff --git a/src/platform/windows/windows_input.h b/src/platform/windows/windows_input.h new file mode 100644 index 00000000..c50ae60a --- /dev/null +++ b/src/platform/windows/windows_input.h @@ -0,0 +1,18 @@ +#ifndef BONGO_CAT_WINDOWS_INPUT_H +#define BONGO_CAT_WINDOWS_INPUT_H + +#include "bongo_cat/platform.h" + +#ifdef _WIN32 +#include + +bool bongo_cat_windows_input_start(BongoCatPlatform *platform); +void bongo_cat_windows_input_stop(BongoCatPlatform *platform); +bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, + double *x, double *y, unsigned long long *sample_count); +void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform); +void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform); +bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform); +#endif + +#endif diff --git a/src/platform/windows/windows_input_detection.c b/src/platform/windows/windows_input_detection.c new file mode 100644 index 00000000..bb870318 --- /dev/null +++ b/src/platform/windows/windows_input_detection.c @@ -0,0 +1,126 @@ +#include "windows_input_detection.h" + +#ifdef _WIN32 +#include +#include + +static bool interior(POINT point, const RECT *bounds) { + const int margin = 4; + return (long long)point.x > (long long)bounds->left + margin && + (long long)point.x < (long long)bounds->right - 1 - margin && + (long long)point.y > (long long)bounds->top + margin && + (long long)point.y < (long long)bounds->bottom - 1 - margin; +} + +bool bongo_cat_windows_pointer_clip_locked(const RECT *clip) { + if (!clip || clip->right < clip->left || clip->bottom < clip->top) + return false; + /* Ordered LONG endpoints have an exact DWORD distance, even across zero. + Keep the subtraction unsigned instead of widening signed coordinates. */ + DWORD width = (DWORD)clip->right - (DWORD)clip->left; + DWORD height = (DWORD)clip->bottom - (DWORD)clip->top; + return width <= 2 && height <= 2; +} + +static WindowsPointerReason lock_hint(const WindowsPointerObservation *sample) { + if (sample->clip_known && bongo_cat_windows_pointer_clip_locked(&sample->clip)) + return WINDOWS_POINTER_CLIPPED; + if (sample->cursor_known && sample->cursor_flags == 0) + return WINDOWS_POINTER_HIDDEN; + return WINDOWS_POINTER_DESKTOP; +} + +static void begin_window(WindowsPointerDetection *state, POINT point, + ULONGLONG now_ms) { + state->anchor = point; + state->window_ms = now_ms; + state->raw_x = state->raw_y = 0; + state->absolute_x = state->absolute_y = 0; + state->moving_samples = state->returns = 0; + state->away = false; + state->edge_seen = false; + state->excursion = 0; +} + +bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, + const WindowsPointerObservation *sample, ULONGLONG now_ms) { + if (!state || !sample) return false; + if (!sample->foreign || !sample->foreground || !sample->position_known) { + *state = (WindowsPointerDetection){0}; + return false; + } + /* Never reuse movement from another foreground window or a suspended loop. */ + if (!state->initialized || state->foreground != sample->foreground || + state->pid != sample->pid || now_ms < state->last_ms || + now_ms - state->last_ms > 500) { + *state = (WindowsPointerDetection){.initialized = true, + .foreground = sample->foreground, .pid = sample->pid, + .last_ms = now_ms}; + begin_window(state, sample->position, now_ms); + return false; + } + state->last_ms = now_ms; + state->raw_x += sample->raw_x; + state->raw_y += sample->raw_y; + state->absolute_x += sample->absolute_x; + state->absolute_y += sample->absolute_y; + if (sample->motion_packets) state->moving_samples++; + long long dx = llabs((long long)sample->position.x - state->anchor.x); + long long dy = llabs((long long)sample->position.y - state->anchor.y); + long long distance = dx > dy ? dx : dy; + if (distance > state->excursion) state->excursion = distance; + if (distance > 2) state->away = true; + else if (state->away) { state->returns++; state->away = false; } + bool inside = sample->monitor_known && + interior(sample->position, &sample->monitor) && + interior(state->anchor, &sample->monitor) && + (!sample->clip_known || (interior(sample->position, &sample->clip) && + interior(state->anchor, &sample->clip))); + if (!inside) state->edge_seen = true; + + WindowsPointerReason hint = lock_hint(sample); + if (hint == WINDOWS_POINTER_CLIPPED) { + if (!state->hint_pending) { + state->hint_pending = true; + state->hint_ms = now_ms; + } + /* Only a tiny clip is direct evidence. Hidden cursors may still travel + normally, so they use the same motion checks as visible cursors. */ + if (state->relative || (now_ms - state->hint_ms >= 32 && + state->moving_samples >= 2)) { + state->relative = true; + state->reason = hint; + state->evidence_ms = now_ms; + } + } else state->hint_pending = false; + + ULONGLONG window_age = now_ms - state->window_ms; + if (window_age >= 128 && (state->moving_samples >= 4 || window_age >= 256)) { + /* Net device counts reject ordinary back-and-forth movement. The + monitor and clipping edges reject a cursor pushed against a border. */ + /* Device counts and absolute-device pixels remain separate units. */ + bool raw_travel = fabs(state->raw_x) >= 32 || fabs(state->raw_y) >= 32 || + fabs(state->absolute_x) >= 32 || fabs(state->absolute_y) >= 32; + bool evidence = !state->edge_seen && raw_travel && state->moving_samples >= 4; + bool stalled = evidence && distance <= 1 && state->excursion <= 1; + bool recentered = evidence && state->returns >= 2 && state->excursion <= 48; + if (hint != WINDOWS_POINTER_CLIPPED && (stalled || recentered)) { + state->relative = true; + state->reason = hint == WINDOWS_POINTER_HIDDEN ? WINDOWS_POINTER_HIDDEN : + stalled ? WINDOWS_POINTER_STALLED : WINDOWS_POINTER_RECENTERED; + state->evidence_ms = now_ms; + } else if (hint != WINDOWS_POINTER_CLIPPED && state->relative && + now_ms - state->evidence_ms >= 192 && + (distance > 2 || state->reason == WINDOWS_POINTER_CLIPPED || + (hint == WINDOWS_POINTER_DESKTOP && + state->reason == WINDOWS_POINTER_HIDDEN))) { + state->relative = false; + state->reason = WINDOWS_POINTER_DESKTOP; + } + /* A stationary inferred lock stays active until actual cursor travel + disproves it; stopping the mouse must not reset the virtual position. */ + begin_window(state, sample->position, now_ms); + } + return state->relative; +} +#endif diff --git a/src/platform/windows/windows_input_detection.h b/src/platform/windows/windows_input_detection.h new file mode 100644 index 00000000..87a5ed5b --- /dev/null +++ b/src/platform/windows/windows_input_detection.h @@ -0,0 +1,46 @@ +#ifndef BONGO_CAT_WINDOWS_INPUT_DETECTION_H +#define BONGO_CAT_WINDOWS_INPUT_DETECTION_H + +#ifdef _WIN32 +#include +#include + +typedef enum WindowsPointerReason { + WINDOWS_POINTER_DESKTOP, + WINDOWS_POINTER_HIDDEN, + WINDOWS_POINTER_CLIPPED, + WINDOWS_POINTER_STALLED, + WINDOWS_POINTER_RECENTERED +} WindowsPointerReason; + +typedef struct WindowsPointerObservation { + HWND foreground; + DWORD pid; + bool foreign, position_known, monitor_known, clip_known, cursor_known; + POINT position; + RECT monitor, clip; + DWORD cursor_flags; + double raw_x, raw_y; + double absolute_x, absolute_y; + unsigned long long motion_packets; +} WindowsPointerObservation; + +/* Owned by the model thread; device statistics are transferred under its lock. */ +typedef struct WindowsPointerDetection { + HWND foreground; + DWORD pid; + bool initialized, relative, hint_pending, away, edge_seen; + ULONGLONG last_ms, window_ms, hint_ms, evidence_ms; + POINT anchor; + double raw_x, raw_y; + double absolute_x, absolute_y; + unsigned moving_samples, returns; + long long excursion; + WindowsPointerReason reason; +} WindowsPointerDetection; + +bool bongo_cat_windows_pointer_clip_locked(const RECT *clip); +bool bongo_cat_windows_pointer_detect(WindowsPointerDetection *state, + const WindowsPointerObservation *sample, ULONGLONG now_ms); +#endif +#endif diff --git a/src/platform/windows/windows_input_devices.c b/src/platform/windows/windows_input_devices.c new file mode 100644 index 00000000..dc7a4070 --- /dev/null +++ b/src/platform/windows/windows_input_devices.c @@ -0,0 +1,153 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +#include +#include + +static void publish(WindowsInputState *state, WindowsRawHeld *held, bool mouse) { + bool down = held->references != 0; + if (down == held->emitted) return; + BongoCatInputKind kind = mouse ? (down ? BONGO_CAT_INPUT_MOUSE_DOWN : + BONGO_CAT_INPUT_MOUSE_UP) : (down ? BONGO_CAT_INPUT_KEY_DOWN : + BONGO_CAT_INPUT_KEY_UP); + if (!bongo_cat_windows_input_push_event(state, kind, held->name, + down ? 1.0f : 0.0f)) { + state->retry_events = true; + return; + } + held->emitted = down; +} + +void bongo_cat_windows_input_flush(WindowsInputState *state) { + if (!state->retry_events) return; + state->retry_events = false; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_HELD_LIMIT; ++i) + publish(state, &state->keys[i], false); + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) + publish(state, &state->buttons[i], true); +} + +WindowsRawDevice *bongo_cat_windows_input_device(WindowsInputState *state, + HANDLE handle) { + for (WindowsRawDevice *device = state->devices; device; device = device->next) + if (device->handle == handle) return device; + if (state->device_count >= BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT) return NULL; + WindowsRawDevice *device = calloc(1, sizeof(*device)); + if (!device) return NULL; + device->handle = handle; + device->next = state->devices; + state->devices = device; + state->device_count++; + return device; +} + +static int held_slot(WindowsInputState *state, const char *name) { + int available = -1; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_HELD_LIMIT; ++i) { + WindowsRawHeld *held = &state->keys[i]; + if (held->references || held->emitted) { + if (strcmp(held->name, name) == 0) return (int)i; + } else if (available < 0) available = (int)i; + } + if (available >= 0) snprintf(state->keys[available].name, + sizeof(state->keys[available].name), "%s", name); + return available; +} + +void bongo_cat_windows_input_key(WindowsInputState *state, + WindowsRawDevice *device, const RAWKEYBOARD *key) { + if ((key->Flags & RI_KEY_E0) && + (key->MakeCode == 0x2a || key->MakeCode == 0x36)) { + state->diagnostic_filtered_keys++; + return; + } + if ((key->Flags & RI_KEY_E1) && key->MakeCode == 0x1d) { + state->diagnostic_filtered_keys++; + device->pending_e1 = true; + return; + } + RAWKEYBOARD normalized = *key; + if (device->pending_e1) { + device->pending_e1 = false; + if (key->MakeCode == 0x45) normalized.VKey = VK_PAUSE; + } + key = &normalized; + char buffer[16]; + const char *name = bongo_cat_windows_key_name(key, buffer); + if (!name) { state->diagnostic_filtered_keys++; return; } + unsigned index = bongo_cat_windows_key_index(key); + if (index >= BONGO_CAT_WINDOWS_RAW_KEY_COUNT) { + state->diagnostic_filtered_keys++; + return; + } + unsigned slot = device->keys[index]; + bool down = !(key->Flags & RI_KEY_BREAK); + if (down == (slot != 0)) { state->diagnostic_duplicate_keys++; return; } + if (down) { + int found = held_slot(state, name); + if (found < 0) { state->diagnostic_filtered_keys++; return; } + slot = (unsigned)found + 1; + device->keys[index] = (unsigned short)slot; + state->keys[slot - 1].references++; + } else { + device->keys[index] = 0; + state->keys[slot - 1].references--; + } + publish(state, &state->keys[slot - 1], false); + /* Pause has no hardware break packet on standard keyboards. */ + if (down && key->VKey == VK_PAUSE) { + device->keys[index] = 0; + state->keys[slot - 1].references--; + publish(state, &state->keys[slot - 1], false); + } +} + +void bongo_cat_windows_input_buttons(WindowsInputState *state, + WindowsRawDevice *device, USHORT flags) { + if (!flags) return; + static const USHORT presses[] = {RI_MOUSE_LEFT_BUTTON_DOWN, + RI_MOUSE_RIGHT_BUTTON_DOWN, RI_MOUSE_MIDDLE_BUTTON_DOWN, + RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN}; + static const USHORT releases[] = {RI_MOUSE_LEFT_BUTTON_UP, + RI_MOUSE_RIGHT_BUTTON_UP, RI_MOUSE_MIDDLE_BUTTON_UP, + RI_MOUSE_BUTTON_4_UP, RI_MOUSE_BUTTON_5_UP}; + static const char *names[] = {"Left", "Right", "Middle", "Back", "Forward"}; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) { + WindowsRawHeld *held = &state->buttons[i]; + snprintf(held->name, sizeof(held->name), "%s", names[i]); + if ((flags & presses[i]) && !device->buttons[i]) { + device->buttons[i] = true; + held->references++; + publish(state, held, true); + } + if ((flags & releases[i]) && device->buttons[i]) { + device->buttons[i] = false; + held->references--; + publish(state, held, true); + } + } +} + +void bongo_cat_windows_input_remove_device(WindowsInputState *state, HANDLE handle) { + WindowsRawDevice **link = &state->devices; + while (*link && (*link)->handle != handle) link = &(*link)->next; + if (!*link) return; + WindowsRawDevice *device = *link; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_RAW_KEY_COUNT; ++i) + if (device->keys[i]) state->keys[device->keys[i] - 1].references--; + for (unsigned i = 0; i < BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT; ++i) + if (device->buttons[i]) state->buttons[i].references--; + *link = device->next; + free(device); + state->device_count--; + state->retry_events = true; + bongo_cat_windows_input_flush(state); +} + +void bongo_cat_windows_input_clear_devices(WindowsInputState *state) { + while (state->devices) + bongo_cat_windows_input_remove_device(state, state->devices->handle); + bongo_cat_windows_input_clear_motion(state); +} +#endif diff --git a/src/platform/windows/windows_input_internal.h b/src/platform/windows/windows_input_internal.h new file mode 100644 index 00000000..70a7b007 --- /dev/null +++ b/src/platform/windows/windows_input_internal.h @@ -0,0 +1,101 @@ +#ifndef BONGO_CAT_WINDOWS_INPUT_INTERNAL_H +#define BONGO_CAT_WINDOWS_INPUT_INTERNAL_H + +#include "windows_input.h" +#include "windows_input_detection.h" +#include "windows_keys.h" +#include "bongo_cat/log.h" + +#ifdef _WIN32 +#define BONGO_CAT_WINDOWS_RAW_DEVICE_LIMIT 64 +#define BONGO_CAT_WINDOWS_RAW_HELD_LIMIT 256 +#define BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT 5 + +typedef struct WindowsRawHeld { + char name[16]; + unsigned references; + bool emitted; +} WindowsRawHeld; + +typedef struct WindowsRawDevice { + struct WindowsRawDevice *next; + HANDLE handle; + unsigned short keys[BONGO_CAT_WINDOWS_RAW_KEY_COUNT]; + bool buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; + bool absolute_known, pending_e1; + RECT absolute_bounds; + double absolute_x, absolute_y; + unsigned long long generation; +} WindowsRawDevice; + +typedef struct WindowsInputState { + BongoCatPlatform *platform; + SRWLOCK platform_lock; + SRWLOCK relative_lock; + HANDLE thread, stop, ready; + volatile LONG references; + HWND window; + ATOM window_class; + bool registered; + DWORD startup_error, read_error, registration_error; + DWORD recovery_error; + /* Receiver-thread-only cumulative diagnostics; never store typed content. */ + unsigned long long diagnostic_keys, diagnostic_mouse, diagnostic_invalid; + unsigned long long diagnostic_background_keys, diagnostic_filtered_keys; + unsigned long long diagnostic_duplicate_keys; + unsigned long long diagnostic_device_drops, diagnostic_read_failures; + unsigned long long diagnostic_queued_keys, diagnostic_queue_failures; + unsigned long long diagnostic_wake_failures, diagnostic_device_changes; + ULONGLONG diagnostic_ms; + DWORD mouse_registration_flags, keyboard_registration_flags; + DWORD test_start_delay_ms; + unsigned ownership; + WindowsRawDevice *devices; + unsigned device_count; + WindowsRawHeld keys[BONGO_CAT_WINDOWS_RAW_HELD_LIMIT]; + WindowsRawHeld buttons[BONGO_CAT_WINDOWS_MOUSE_BUTTON_COUNT]; + bool desktop_unavailable, wake_pending, retry_events; + /* Shared motion buffers are protected by relative_lock. Detection and + model consumption have separate totals; neither drains the other's data. */ + bool relative_active, receiving; + long long relative_x, relative_y; + double absolute_x, absolute_y; + unsigned long long relative_samples, absolute_samples, generation; + long long observed_x, observed_y; + double observed_absolute_x, observed_absolute_y; + unsigned long long observed_motion, observed_generation; + /* Model-thread-only state. The receiver publishes resets through generation. */ + WindowsPointerDetection pointer_detection; + ULONGLONG pointer_probe_ms; + unsigned long long pointer_generation; + bool pointer_probe_ready; +} WindowsInputState; + +bool bongo_cat_windows_input_push_event(WindowsInputState *state, + BongoCatInputKind kind, const char *name, float value); +void bongo_cat_windows_input_wake(WindowsInputState *state); +void bongo_cat_windows_input_flush(WindowsInputState *state); +void bongo_cat_windows_input_packet(WindowsInputState *state, + const RAWINPUT *packet, UINT bytes); +WindowsRawDevice *bongo_cat_windows_input_device(WindowsInputState *state, + HANDLE handle); +void bongo_cat_windows_input_remove_device(WindowsInputState *state, HANDLE handle); +void bongo_cat_windows_input_clear_devices(WindowsInputState *state); +void bongo_cat_windows_input_key(WindowsInputState *state, + WindowsRawDevice *device, const RAWKEYBOARD *key); +void bongo_cat_windows_input_buttons(WindowsInputState *state, + WindowsRawDevice *device, USHORT flags); +void bongo_cat_windows_input_motion(WindowsInputState *state, + WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds); +void bongo_cat_windows_input_clear_motion(WindowsInputState *state); +unsigned long long bongo_cat_windows_input_take_observation( + WindowsInputState *state, WindowsPointerObservation *sample); +bool bongo_cat_windows_input_register(WindowsInputState *state); +void bongo_cat_windows_input_unregister(WindowsInputState *state); +unsigned bongo_cat_windows_input_ownership(WindowsInputState *state); +unsigned bongo_cat_windows_input_restore(WindowsInputState *state, unsigned owned); +bool bongo_cat_windows_input_receiver_create(WindowsInputState *state); +void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state); +bool bongo_cat_windows_input_dispatch(WindowsInputState *state); +#endif +#endif diff --git a/src/platform/windows/windows_input_packets.c b/src/platform/windows/windows_input_packets.c new file mode 100644 index 00000000..9ad48045 --- /dev/null +++ b/src/platform/windows/windows_input_packets.c @@ -0,0 +1,51 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include + +void bongo_cat_windows_input_packet(WindowsInputState *state, + const RAWINPUT *packet, UINT bytes) { + if (!state || !packet) return; + if (bytes < sizeof(RAWINPUTHEADER) || packet->header.dwSize != bytes) { + state->diagnostic_invalid++; + return; + } + UINT payload; + switch (packet->header.dwType) { + case RIM_TYPEKEYBOARD: payload = sizeof(RAWKEYBOARD); break; + case RIM_TYPEMOUSE: payload = sizeof(RAWMOUSE); break; + default: return; + } + if (bytes < offsetof(RAWINPUT, data) + payload) { + state->diagnostic_invalid++; + return; + } + if (packet->header.dwType == RIM_TYPEKEYBOARD) { + state->diagnostic_keys++; + if (GET_RAWINPUT_CODE_WPARAM(packet->header.wParam) == RIM_INPUTSINK) + state->diagnostic_background_keys++; + } + else state->diagnostic_mouse++; + WindowsRawDevice *device = bongo_cat_windows_input_device(state, + packet->header.hDevice); + if (!device) { state->diagnostic_device_drops++; return; } + if (packet->header.dwType == RIM_TYPEKEYBOARD) { + bongo_cat_windows_input_key(state, device, &packet->data.keyboard); + return; + } + const RAWMOUSE *mouse = &packet->data.mouse; + bongo_cat_windows_input_buttons(state, device, mouse->usButtonFlags); + RECT bounds = {0}; + if (mouse->usFlags & MOUSE_MOVE_ABSOLUTE) { + bool virtual_desktop = (mouse->usFlags & MOUSE_VIRTUAL_DESKTOP) != 0; + bounds.left = virtual_desktop ? GetSystemMetrics(SM_XVIRTUALSCREEN) : 0; + bounds.top = virtual_desktop ? GetSystemMetrics(SM_YVIRTUALSCREEN) : 0; + bounds.right = bounds.left + GetSystemMetrics(virtual_desktop ? + SM_CXVIRTUALSCREEN : SM_CXSCREEN); + bounds.bottom = bounds.top + GetSystemMetrics(virtual_desktop ? + SM_CYVIRTUALSCREEN : SM_CYSCREEN); + } + bongo_cat_windows_input_motion(state, device, mouse, &bounds); + state->wake_pending = true; +} +#endif diff --git a/src/platform/windows/windows_input_probe.c b/src/platform/windows/windows_input_probe.c new file mode 100644 index 00000000..b24980f1 --- /dev/null +++ b/src/platform/windows/windows_input_probe.c @@ -0,0 +1,45 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +static void observe_cursor(WindowsPointerObservation *sample) { + CURSORINFO cursor = {.cbSize = sizeof(cursor)}; + sample->cursor_known = GetCursorInfo(&cursor) != FALSE; + sample->cursor_flags = cursor.flags; + if (sample->cursor_known) { + sample->position = cursor.ptScreenPos; + sample->position_known = true; + } else sample->position_known = GetCursorPos(&sample->position) != FALSE; + sample->clip_known = GetClipCursor(&sample->clip) != FALSE; + if (sample->position_known) { + MONITORINFO monitor = {.cbSize = sizeof(monitor)}; + sample->monitor_known = GetMonitorInfoW(MonitorFromPoint(sample->position, + MONITOR_DEFAULTTONULL), &monitor) != FALSE; + sample->monitor = monitor.rcMonitor; + } +} + +bool bongo_cat_windows_input_pointer_locked(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return false; + ULONGLONG now_ms = GetTickCount64(); + if (state->pointer_probe_ready && now_ms - state->pointer_probe_ms < 16) + return state->pointer_detection.relative; + state->pointer_probe_ready = true; + state->pointer_probe_ms = now_ms; + WindowsPointerObservation sample = {0}; + sample.foreground = GetForegroundWindow(); + if (sample.foreground) GetWindowThreadProcessId(sample.foreground, &sample.pid); + sample.foreign = sample.pid && sample.pid != GetCurrentProcessId(); + /* Our own windows cannot trigger game mode. Still consume observations so + old desktop movement cannot leak into the next foreground application. */ + if (sample.foreign) observe_cursor(&sample); + unsigned long long generation = + bongo_cat_windows_input_take_observation(state, &sample); + if (state->pointer_generation != generation) { + state->pointer_detection = (WindowsPointerDetection){0}; + state->pointer_generation = generation; + } + return bongo_cat_windows_pointer_detect(&state->pointer_detection, + &sample, now_ms); +} +#endif diff --git a/src/platform/windows/windows_input_receiver.c b/src/platform/windows/windows_input_receiver.c new file mode 100644 index 00000000..dab9c9ea --- /dev/null +++ b/src/platform/windows/windows_input_receiver.c @@ -0,0 +1,80 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +static const wchar_t receiver_class[] = L"BongoCat Raw Input Receiver"; + +static LRESULT CALLBACK receiver_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + WindowsInputState *state = (WindowsInputState *)GetWindowLongPtrW(window, + GWLP_USERDATA); + if (message == WM_NCCREATE) { + const CREATESTRUCTW *create = (const CREATESTRUCTW *)lparam; + state = create->lpCreateParams; + SetWindowLongPtrW(window, GWLP_USERDATA, (LONG_PTR)state); + } + if (state && message == WM_INPUT) { + RAWINPUT packet = {0}; + UINT bytes = sizeof(packet); + UINT received = GetRawInputData((HRAWINPUT)lparam, RID_INPUT, &packet, + &bytes, sizeof(RAWINPUTHEADER)); + if (received == (UINT)-1) { + DWORD error = GetLastError(); + state->diagnostic_read_failures++; + if (error != state->read_error) SDL_LogError(SDL_LOG_CATEGORY_INPUT, + "Raw Input read failed: error=%lu", (unsigned long)error); + state->read_error = error; + } else bongo_cat_windows_input_packet(state, &packet, received); + /* DefWindowProc performs the required foreground WM_INPUT cleanup. */ + } else if (state && message == WM_INPUT_DEVICE_CHANGE) { + if (wparam == GIDC_REMOVAL || wparam == GIDC_ARRIVAL) { + state->diagnostic_device_changes++; + bongo_cat_windows_input_remove_device(state, (HANDLE)lparam); + bongo_cat_windows_input_clear_motion(state); + } + } else if (message == WM_NCDESTROY) { + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + } + return DefWindowProcW(window, message, wparam, lparam); +} + +bool bongo_cat_windows_input_receiver_create(WindowsInputState *state) { + HINSTANCE instance = GetModuleHandleW(NULL); + WNDCLASSW type = {.lpfnWndProc = receiver_proc, + .hInstance = instance, .lpszClassName = receiver_class}; + state->window_class = RegisterClassW(&type); + if (!state->window_class && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + state->startup_error = GetLastError(); + return false; + } + state->window = CreateWindowExW(0, receiver_class, L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, instance, state); + if (!state->window) { state->startup_error = GetLastError(); return false; } + return bongo_cat_windows_input_register(state); +} + +void bongo_cat_windows_input_receiver_destroy(WindowsInputState *state) { + if (state->window) { + bongo_cat_windows_input_unregister(state); + DestroyWindow(state->window); + state->window = NULL; + } + if (state->window_class) { + UnregisterClassW(receiver_class, GetModuleHandleW(NULL)); + state->window_class = 0; + } +} + +bool bongo_cat_windows_input_dispatch(WindowsInputState *state) { + MSG message; + /* Bound each batch so high-rate mice cannot starve shutdown. */ + for (unsigned i = 0; i < 256 && PeekMessageW(&message, + NULL, 0, 0, PM_REMOVE); ++i) { + if (message.message == WM_QUIT || + WaitForSingleObject(state->stop, 0) == WAIT_OBJECT_0) return false; + TranslateMessage(&message); + DispatchMessageW(&message); + } + return true; +} +#endif diff --git a/src/platform/windows/windows_input_registration.c b/src/platform/windows/windows_input_registration.c new file mode 100644 index 00000000..599cf859 --- /dev/null +++ b/src/platform/windows/windows_input_registration.c @@ -0,0 +1,147 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include +#include + +static bool registrations(WindowsInputState *state, RAWINPUTDEVICE **items, + UINT *count) { + *items = NULL; + *count = 0; + state->registration_error = ERROR_SUCCESS; + for (unsigned attempt = 0; attempt < 3; ++attempt) { + UINT queried = GetRegisteredRawInputDevices(NULL, count, sizeof(**items)); + if (queried == (UINT)-1) { + DWORD error = GetLastError(); + /* A size query may report the required count as insufficient buffer. */ + if (error != ERROR_INSUFFICIENT_BUFFER || !*count) { + state->registration_error = error; + return false; + } + } + if (!*count) return true; + if (*count > 1024) { + state->registration_error = ERROR_INVALID_DATA; + return false; + } + *items = calloc(*count, sizeof(**items)); + if (!*items) { + state->registration_error = ERROR_NOT_ENOUGH_MEMORY; + return false; + } + UINT received = GetRegisteredRawInputDevices(*items, count, sizeof(**items)); + if (received != (UINT)-1) { *count = received; return true; } + DWORD error = GetLastError(); + free(*items); + *items = NULL; + if (error != ERROR_INSUFFICIENT_BUFFER) { + state->registration_error = error; + return false; + } + } + state->registration_error = ERROR_INSUFFICIENT_BUFFER; + return false; +} + +static unsigned device_class(const RAWINPUTDEVICE *device) { + if (device->usUsagePage != 1) return 0; + return device->usUsage == 2 ? 1u : device->usUsage == 6 ? 2u : 0u; +} + +unsigned bongo_cat_windows_input_ownership(WindowsInputState *state) { + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return 0; + unsigned owned = 0; + state->mouse_registration_flags = state->keyboard_registration_flags = 0; + for (UINT i = 0; i < count; ++i) { + if (items[i].hwndTarget != state->window) continue; + unsigned kind = device_class(&items[i]); + if (kind == 1) state->mouse_registration_flags = items[i].dwFlags; + if (kind == 2) state->keyboard_registration_flags = items[i].dwFlags; + /* Windows may omit DEVNOTIFY when querying a successful registration. */ + if (items[i].dwFlags & RIDEV_INPUTSINK) owned |= kind; + } + free(items); + return owned; +} + +bool bongo_cat_windows_input_register(WindowsInputState *state) { + if (SDL_getenv("BONGO_CAT_TEST_RAW_INPUT_FAILURE")) { + state->startup_error = ERROR_ACCESS_DENIED; + return false; + } + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) { + state->startup_error = state->registration_error; + return false; + } + bool occupied = false; + for (UINT i = 0; i < count; ++i) + if (device_class(&items[i])) occupied = true; + free(items); + if (occupied) { state->startup_error = ERROR_BUSY; return false; } + const RAWINPUTDEVICE devices[] = { + {1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}, + {1, 6, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window} + }; + if (!RegisterRawInputDevices(devices, 2, sizeof(devices[0]))) { + state->startup_error = GetLastError(); + return false; + } + return true; +} + +unsigned bongo_cat_windows_input_restore(WindowsInputState *state, unsigned owned) { + if (owned == 3) return owned; + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return owned; + unsigned blocked = 0; + for (UINT i = 0; i < count; ++i) { + /* Registrations are process-local. Leave another receiver (including + a foreground-only registration with no target) alone. */ + if (items[i].hwndTarget != state->window) + blocked |= device_class(&items[i]); + } + free(items); + unsigned missing = 3u & ~(owned | blocked); + if (!missing) return owned; + RAWINPUTDEVICE devices[2]; + UINT pending = 0; + if (missing & 1u) devices[pending++] = (RAWINPUTDEVICE){ + 1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}; + if (missing & 2u) devices[pending++] = (RAWINPUTDEVICE){ + 1, 6, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, state->window}; + if (!RegisterRawInputDevices(devices, pending, sizeof(devices[0]))) { + DWORD error = GetLastError(); + if (error != state->recovery_error) SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input recovery failed: classes=%u error=%lu", + missing, (unsigned long)error); + state->recovery_error = error; + return owned; + } + state->recovery_error = ERROR_SUCCESS; + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] Raw Input background registration restored: classes=%u", missing); + return owned | missing; +} + +void bongo_cat_windows_input_unregister(WindowsInputState *state) { + RAWINPUTDEVICE *items; + UINT count; + if (!registrations(state, &items, &count)) return; + for (UINT i = 0; i < count; ++i) { + if (!device_class(&items[i]) || items[i].hwndTarget != state->window) + continue; + items[i].dwFlags = RIDEV_REMOVE; + items[i].hwndTarget = NULL; + if (!RegisterRawInputDevices(&items[i], 1, sizeof(items[i]))) + SDL_LogWarn(SDL_LOG_CATEGORY_INPUT, + "[input] Raw Input unregister failed: error=%lu", + (unsigned long)GetLastError()); + } + free(items); +} +#endif diff --git a/src/platform/windows/windows_input_relative.c b/src/platform/windows/windows_input_relative.c new file mode 100644 index 00000000..5d71ed7a --- /dev/null +++ b/src/platform/windows/windows_input_relative.c @@ -0,0 +1,133 @@ +#include "windows_input_internal.h" + +#ifdef _WIN32 +#include + +void bongo_cat_windows_input_motion(WindowsInputState *state, + WindowsRawDevice *device, const RAWMOUSE *mouse, const RECT *bounds) { + AcquireSRWLockExclusive(&state->relative_lock); + if (!(mouse->usFlags & MOUSE_MOVE_ABSOLUTE)) { + device->absolute_known = false; + if (mouse->lLastX || mouse->lLastY) { + state->observed_x += mouse->lLastX; + state->observed_y += mouse->lLastY; + state->observed_motion++; + if (state->relative_active) { + state->relative_x += mouse->lLastX; + state->relative_y += mouse->lLastY; + state->relative_samples++; + } + } + } else { + if (!bounds || bounds->right <= bounds->left || + bounds->bottom <= bounds->top || mouse->lLastX < 0 || + mouse->lLastX > 65535 || mouse->lLastY < 0 || mouse->lLastY > 65535) { + device->absolute_known = false; + ReleaseSRWLockExclusive(&state->relative_lock); + return; + } + double x = bounds->left + (double)mouse->lLastX * + ((double)bounds->right - bounds->left - 1.0) / 65535.0; + double y = bounds->top + (double)mouse->lLastY * + ((double)bounds->bottom - bounds->top - 1.0) / 65535.0; + bool baseline = device->absolute_known && + !(mouse->usFlags & MOUSE_ATTRIBUTES_CHANGED) && + device->generation == state->generation && + memcmp(bounds, &device->absolute_bounds, sizeof(*bounds)) == 0; + if (baseline && (x != device->absolute_x || y != device->absolute_y)) { + state->observed_absolute_x += x - device->absolute_x; + state->observed_absolute_y += y - device->absolute_y; + state->observed_motion++; + if (state->relative_active) { + state->absolute_x += x - device->absolute_x; + state->absolute_y += y - device->absolute_y; + state->absolute_samples++; + } + } + device->absolute_x = x; + device->absolute_y = y; + device->absolute_bounds = *bounds; + device->absolute_known = true; + device->generation = state->generation; + } + ReleaseSRWLockExclusive(&state->relative_lock); +} + +static void clear_motion(WindowsInputState *state) { + state->relative_x = state->relative_y = 0; + state->absolute_x = state->absolute_y = 0.0; + state->relative_samples = state->absolute_samples = 0; + state->generation++; +} + +void bongo_cat_windows_input_clear_motion(WindowsInputState *state) { + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + state->observed_x = state->observed_y = 0; + state->observed_absolute_x = state->observed_absolute_y = 0; + state->observed_motion = 0; + state->observed_generation++; + ReleaseSRWLockExclusive(&state->relative_lock); +} + +unsigned long long bongo_cat_windows_input_take_observation( + WindowsInputState *state, WindowsPointerObservation *sample) { + if (!state || !sample) return 0; + AcquireSRWLockExclusive(&state->relative_lock); + sample->raw_x = (double)state->observed_x; + sample->raw_y = (double)state->observed_y; + sample->absolute_x = state->observed_absolute_x; + sample->absolute_y = state->observed_absolute_y; + sample->motion_packets = state->observed_motion; + unsigned long long generation = state->observed_generation; + state->observed_x = state->observed_y = 0; + state->observed_absolute_x = state->observed_absolute_y = 0; + state->observed_motion = 0; + ReleaseSRWLockExclusive(&state->relative_lock); + return generation; +} + +bool bongo_cat_windows_input_take_relative(BongoCatPlatform *platform, + double *x, double *y, unsigned long long *sample_count) { + if (!x || !y) return false; + *x = *y = 0.0; + if (sample_count) *sample_count = 0; + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return false; + AcquireSRWLockExclusive(&state->relative_lock); + /* Delivered packets remain valid even if the registration probe disagrees. */ + bool delivered = state->relative_samples || state->absolute_samples; + bool available = state->relative_active && (state->receiving || delivered); + if (available) { + /* Device counts and absolute-device pixels must never be added together. */ + bool relative = state->relative_samples != 0; + *x = relative ? (double)state->relative_x : state->absolute_x; + *y = relative ? (double)state->relative_y : state->absolute_y; + if (sample_count) *sample_count = relative ? state->relative_samples : + state->absolute_samples; + } + state->relative_x = state->relative_y = 0; + state->absolute_x = state->absolute_y = 0.0; + state->relative_samples = state->absolute_samples = 0; + ReleaseSRWLockExclusive(&state->relative_lock); + return available; +} + +void bongo_cat_windows_input_reset_relative(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + state->relative_active = true; + ReleaseSRWLockExclusive(&state->relative_lock); +} + +void bongo_cat_windows_input_release_relative(BongoCatPlatform *platform) { + WindowsInputState *state = platform ? platform->native : NULL; + if (!state) return; + AcquireSRWLockExclusive(&state->relative_lock); + clear_motion(state); + state->relative_active = false; + ReleaseSRWLockExclusive(&state->relative_lock); +} +#endif diff --git a/src/platform/windows_keys.c b/src/platform/windows/windows_keys.c similarity index 55% rename from src/platform/windows_keys.c rename to src/platform/windows/windows_keys.c index 9a8c1192..cf4db61f 100644 --- a/src/platform/windows_keys.c +++ b/src/platform/windows/windows_keys.c @@ -2,10 +2,18 @@ #ifdef _WIN32 #include -#include -const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[16]) { - DWORD code = key->vkCode; +unsigned bongo_cat_windows_key_index(const RAWKEYBOARD *key) { + if (key->MakeCode && key->MakeCode < 256) + return key->MakeCode + ((key->Flags & RI_KEY_E1) ? 512u : + (key->Flags & RI_KEY_E0) ? 256u : 0u); + return 768u + key->VKey; +} + +const char *bongo_cat_windows_key_name(const RAWKEYBOARD *key, char output[16]) { + if (!key || key->VKey < VK_BACK || key->VKey >= 255 || + key->MakeCode == KEYBOARD_OVERRUN_MAKE_CODE) return NULL; + unsigned code = key->VKey; if (code >= 'A' && code <= 'Z') { snprintf(output, 16, "Key%c", (char)code); return output; @@ -15,10 +23,10 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 return output; } if (code >= VK_F1 && code <= VK_F24) { - snprintf(output, 16, "F%lu", code - VK_F1 + 1); + snprintf(output, 16, "F%u", code - VK_F1 + 1); return output; } - if (!(key->flags & LLKHF_EXTENDED)) switch (code) { + if (!(key->Flags & RI_KEY_E0)) switch (code) { case VK_INSERT: return "Kp0"; case VK_END: return "Kp1"; case VK_DOWN: return "Kp2"; case VK_NEXT: return "Kp3"; case VK_LEFT: return "Kp4"; case VK_CLEAR: return "Kp5"; @@ -52,9 +60,9 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 case VK_RCONTROL: return "ControlRight"; case VK_LMENU: return "Alt"; case VK_RMENU: return "AltGr"; - case VK_SHIFT: return key->scanCode == 0x36 ? "ShiftRight" : "ShiftLeft"; - case VK_CONTROL: return key->flags & LLKHF_EXTENDED ? "ControlRight" : "ControlLeft"; - case VK_MENU: return key->flags & LLKHF_EXTENDED ? "AltGr" : "Alt"; + case VK_SHIFT: return key->MakeCode == 0x36 ? "ShiftRight" : "ShiftLeft"; + case VK_CONTROL: return key->Flags & RI_KEY_E0 ? "ControlRight" : "ControlLeft"; + case VK_MENU: return key->Flags & RI_KEY_E0 ? "AltGr" : "Alt"; case VK_RETURN: return "Return"; case VK_NUMLOCK: return "NumLock"; case VK_SCROLL: return "ScrollLock"; @@ -77,47 +85,14 @@ const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[1 case VK_OEM_COMMA: return "Comma"; case VK_OEM_PERIOD: return "Period"; case VK_OEM_2: return "Slash"; - default: return NULL; - } -} - -bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, - const KBDLLHOOKSTRUCT *key, WPARAM message, UINT *drop_key_up, - BongoCatWindowsKeyEmit emit, void *userdata) { - if (!state || !key || !emit) return false; - bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; - bool up = message == WM_KEYUP || message == WM_SYSKEYUP; - if (!down && !up) return false; - if (up && drop_key_up && *drop_key_up == key->vkCode) { - *drop_key_up = 0; return true; - } - char buffer[16]; - const char *name = bongo_cat_windows_key_name(key, buffer); - if (!name || !bongo_cat_input_edge(state->down, key->vkCode, down)) - return false; - if (key->vkCode < BONGO_CAT_INPUT_KEY_STATE_CAP) { - state->changed_ms[key->vkCode] = GetTickCount64(); - if (down) snprintf(state->name[key->vkCode], - sizeof(state->name[key->vkCode]), "%s", name); - else state->name[key->vkCode][0] = '\0'; + default: + snprintf(output, 16, "%u", code); + return output; } - emit(down, name, userdata); - return true; } -void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, - uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata) { - if (!state || !emit) return; - for (unsigned code = 0; code < BONGO_CAT_INPUT_KEY_STATE_CAP; ++code) { - if (!state->down[code] || !state->name[code][0] || - now_ms - state->changed_ms[code] < 50 || - (GetAsyncKeyState((int)code) & 0x8000)) continue; - char name[BONGO_CAT_ID_CAP]; - snprintf(name, sizeof(name), "%s", state->name[code]); - state->down[code] = false; - state->name[code][0] = '\0'; - state->changed_ms[code] = now_ms; - emit(false, name, userdata); - } +bool bongo_cat_windows_right_button_down(void) { + /* Native window drag/capture state, not global model input. */ + return (GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0; } #endif diff --git a/src/platform/windows/windows_keys.h b/src/platform/windows/windows_keys.h new file mode 100644 index 00000000..3bc0c521 --- /dev/null +++ b/src/platform/windows/windows_keys.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_WINDOWS_KEYS_H +#define BONGO_CAT_WINDOWS_KEYS_H + +#ifdef _WIN32 +#include +#include + +#define BONGO_CAT_WINDOWS_RAW_KEY_COUNT 1024 + +const char *bongo_cat_windows_key_name(const RAWKEYBOARD *key, char output[16]); +unsigned bongo_cat_windows_key_index(const RAWKEYBOARD *key); +bool bongo_cat_windows_right_button_down(void); +#endif +#endif diff --git a/src/platform/windows_layered.c b/src/platform/windows/windows_layered.c similarity index 80% rename from src/platform/windows_layered.c rename to src/platform/windows/windows_layered.c index 058b4e94..7215a224 100644 --- a/src/platform/windows_layered.c +++ b/src/platform/windows/windows_layered.c @@ -1,36 +1,21 @@ #include "windows_layered.h" +#include "windows_layered_internal.h" #include "windows_capture.h" - #ifdef _WIN32 #include #include #include -#include #include #include #include static const wchar_t proxy_class[] = L"BongoCat.LayeredPresenter"; static const wchar_t proxy_property[] = L"BongoCat.LayeredProxy"; - /* SDL's OpenGL window owns a DC, so a separate layered window presents frames. */ -typedef struct BongoCatWindowsLayered { - HDC memory_dc; - HBITMAP bitmap; - HGDIOBJ original_bitmap; - unsigned char *pixels; - unsigned char *readback; - size_t readback_capacity; - HWND proxy; - int width, height, source_width, source_height; - bool readback_valid, active, has_frame; - bool source_transparent, visible, topmost; -} BongoCatWindowsLayered; static HWND native_window(BongoCatPlatform *platform) { return platform && platform->window ? (HWND)SDL_GetPointerProperty( SDL_GetWindowProperties(platform->window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; } - void *bongo_cat_windows_layered_create(void) { BongoCatWindowsLayered *value = calloc(1, sizeof(*value)); if (!value) return NULL; @@ -49,7 +34,6 @@ static void release_bitmap(BongoCatWindowsLayered *value) { value->pixels = NULL; value->width = value->height = 0; } - static void restore_source(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; HWND source = native_window(platform); @@ -59,12 +43,11 @@ static void restore_source(BongoCatPlatform *platform) { style & ~(WS_EX_LAYERED | WS_EX_TRANSPARENT)); SetWindowPos(source, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(source, &margins); value->source_transparent = false; SDL_SetWindowOpacity(platform->window, platform->window_opacity); + bongo_cat_windows_capture_restore_transparency(source); + bongo_cat_windows_capture_log(source, "layered-source-restored"); } - void bongo_cat_windows_layered_destroy(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; if (!value) return; @@ -78,7 +61,6 @@ void bongo_cat_windows_layered_destroy(BongoCatPlatform *platform) { free(value); platform->presenter = NULL; } - static bool register_proxy_class(void) { WNDCLASSEXW existing = {.cbSize = sizeof(existing)}; HINSTANCE instance = GetModuleHandleW(NULL); @@ -91,7 +73,6 @@ static bool register_proxy_class(void) { type.lpszClassName = proxy_class; return RegisterClassExW(&type) != 0; } - static bool ensure_proxy(BongoCatPlatform *platform) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; HWND source = native_window(platform); @@ -112,10 +93,10 @@ static bool ensure_proxy(BongoCatPlatform *platform) { if (small_icon) SendMessageW(value->proxy, WM_SETICON, ICON_SMALL, (LPARAM)small_icon); SetPropW(source, proxy_property, value->proxy); + bongo_cat_windows_capture_log(value->proxy, "layered-proxy-created"); return true; } - -static bool update_proxy(BongoCatPlatform *platform, +bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, BongoCatWindowsLayered *value) { HWND source_window = native_window(platform); RECT bounds; @@ -135,6 +116,8 @@ static bool update_proxy(BongoCatPlatform *platform, if (screen) ReleaseDC(NULL, screen); if (!presented) return SDL_SetError( "UpdateLayeredWindow failed (%lu)", (unsigned long)failure); + value->applied_alpha = blend.SourceConstantAlpha; + value->applied_alpha_valid = true; return true; } @@ -153,21 +136,28 @@ static bool make_source_transparent(BongoCatPlatform *platform) { SetWindowPos(source, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); value->source_transparent = true; + bongo_cat_windows_capture_log(source, "layered-source-suppressed"); return true; } void bongo_cat_windows_layered_set_click_through( BongoCatPlatform *platform, bool enabled) { BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; - if (!value || value->active == enabled) return; + if (!value || (value->mode_logged && value->active == enabled)) return; + value->mode_logged = true; value->active = enabled; + SDL_Log("Windows presentation path: forced_click_through=%d", enabled); if (enabled) { value->has_frame = false; ensure_proxy(platform); if (value->proxy) ShowWindow(value->proxy, SW_HIDE); + bongo_cat_windows_capture_log(native_window(platform), + "layered-presenter-ready"); } else { if (value->proxy) ShowWindow(value->proxy, SW_HIDE); restore_source(platform); + bongo_cat_windows_capture_log(native_window(platform), + "direct-opengl-presenter"); } } @@ -185,40 +175,6 @@ HWND bongo_cat_windows_layered_proxy(HWND source) { return source ? (HWND)GetPropW(source, proxy_property) : NULL; } -bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { - if (!platform || !platform->window) return false; - BongoCatWindowsLayered *value = platform->presenter; - platform->window_opacity = SDL_clamp(opacity, 0.0f, 1.0f); - if (value && value->active) - return !value->has_frame || update_proxy(platform, value); - return SDL_SetWindowOpacity(platform->window, platform->window_opacity); -} - -float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { - return platform ? platform->window_opacity : 1.0f; -} - -bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, - int width, int height, int x, int y, uint8_t *alpha) { - const BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; - const unsigned char *pixels = value && value->readback_valid ? - value->readback : value ? value->pixels : NULL; - if (!value || !value->active || !value->has_frame || !pixels || - value->source_width != width || value->source_height != height || !alpha || - x < 0 || y < 0 || x >= width || y >= height) return false; - *alpha = pixels[((size_t)y * (size_t)width + (size_t)x) * 4 + 3]; - return true; -} - -void bongo_cat_platform_set_visible(BongoCatPlatform *platform, bool visible) { - if (!platform || !platform->window) return; - BongoCatWindowsLayered *value = platform->presenter; - if (value) value->visible = visible; - if (!visible && value && value->proxy) ShowWindow(value->proxy, SW_HIDE); - visible ? SDL_ShowWindow(platform->window) : SDL_HideWindow(platform->window); - if (visible) bongo_cat_windows_capture_configure(native_window(platform)); -} - static bool resize_bitmap(BongoCatWindowsLayered *value, int width, int height) { if (value->bitmap && value->width == width && value->height == height) return true; release_bitmap(value); @@ -274,7 +230,9 @@ bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int heigh HWND source = native_window(platform); if (!source || !value->visible || !IsWindowVisible(source) || IsIconic(source)) { if (value->proxy) ShowWindow(value->proxy, SW_HIDE); - return source != NULL; + /* A hidden source has no frame to expose through the proxy. Let the + startup path show it before retrying the first presentation. */ + return false; } RECT bounds; if (!GetWindowRect(source, &bounds)) return false; @@ -287,7 +245,10 @@ bool bongo_cat_platform_present(BongoCatPlatform *platform, int width, int heigh value->has_frame = false; return SDL_SetError("Cannot allocate the Windows layered frame"); } - if (!update_proxy(platform, value)) { value->has_frame = false; return false; } + if (!bongo_cat_windows_layered_update_proxy(platform, value)) { + value->has_frame = false; + return false; + } value->has_frame = true; if (!make_source_transparent(platform)) return SDL_SetError("Cannot suppress the OpenGL source window"); diff --git a/src/platform/windows_layered.h b/src/platform/windows/windows_layered.h similarity index 100% rename from src/platform/windows_layered.h rename to src/platform/windows/windows_layered.h diff --git a/src/platform/windows/windows_layered_internal.h b/src/platform/windows/windows_layered_internal.h new file mode 100644 index 00000000..d8efa4a7 --- /dev/null +++ b/src/platform/windows/windows_layered_internal.h @@ -0,0 +1,28 @@ +#ifndef BONGO_CAT_WINDOWS_LAYERED_INTERNAL_H +#define BONGO_CAT_WINDOWS_LAYERED_INTERNAL_H + +#include "windows_layered.h" + +#ifdef _WIN32 +#include + +typedef struct BongoCatWindowsLayered { + HDC memory_dc; + HBITMAP bitmap; + HGDIOBJ original_bitmap; + unsigned char *pixels; + unsigned char *readback; + size_t readback_capacity; + HWND proxy; + int width, height, source_width, source_height; + bool readback_valid, active, has_frame, mode_logged; + bool source_transparent, visible, topmost; + BYTE applied_alpha; + bool applied_alpha_valid; +} BongoCatWindowsLayered; + +bool bongo_cat_windows_layered_update_proxy(BongoCatPlatform *platform, + BongoCatWindowsLayered *value); +#endif + +#endif diff --git a/src/platform/windows/windows_layered_state.c b/src/platform/windows/windows_layered_state.c new file mode 100644 index 00000000..ff139efa --- /dev/null +++ b/src/platform/windows/windows_layered_state.c @@ -0,0 +1,62 @@ +#include "windows_layered_internal.h" +#include "windows_capture.h" + +#ifdef _WIN32 +#include +#include +#include + +static HWND native_window(BongoCatPlatform *platform) { + return platform && platform->window ? (HWND)SDL_GetPointerProperty( + SDL_GetWindowProperties(platform->window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; +} + +bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, + float opacity) { + if (!platform || !platform->window) return false; + BongoCatWindowsLayered *value = platform->presenter; + float previous = platform->window_opacity; + platform->window_opacity = SDL_clamp(opacity, 0.0f, 1.0f); + bool updated; + if (value && value->active) { + BYTE alpha = (BYTE)(platform->window_opacity * 255.0f + 0.5f); + updated = !value->has_frame || + (value->applied_alpha_valid && value->applied_alpha == alpha) || + bongo_cat_windows_layered_update_proxy(platform, value); + } else { + updated = SDL_SetWindowOpacity(platform->window, platform->window_opacity); + } + if (!updated) platform->window_opacity = previous; + return updated; +} + +bool bongo_cat_platform_frame_alpha(const BongoCatPlatform *platform, + int width, int height, int x, int y, uint8_t *alpha) { + const BongoCatWindowsLayered *value = platform ? platform->presenter : NULL; + const unsigned char *pixels = value && value->readback_valid ? + value->readback : value ? value->pixels : NULL; + if (!value || !value->active || !value->has_frame || !pixels || + value->source_width != width || value->source_height != height || + !alpha || x < 0 || y < 0 || x >= width || y >= height) + return false; + *alpha = pixels[((size_t)y * (size_t)width + (size_t)x) * 4 + 3]; + return true; +} + +void bongo_cat_platform_set_visible(BongoCatPlatform *platform, + bool visible) { + if (!platform || !platform->window) return; + BongoCatWindowsLayered *value = platform->presenter; + if (value) value->visible = visible; + if (!visible && value && value->proxy) ShowWindow(value->proxy, SW_HIDE); + if (visible) SDL_ShowWindow(platform->window); + else SDL_HideWindow(platform->window); + if (visible) { + HWND source = native_window(platform); + bongo_cat_windows_capture_configure(source); + bongo_cat_windows_capture_repair_transparency(source); + bongo_cat_windows_capture_log(source, "visible"); + } +} +#endif diff --git a/src/platform/windows/windows_opacity.c b/src/platform/windows/windows_opacity.c new file mode 100644 index 00000000..4ef1077b --- /dev/null +++ b/src/platform/windows/windows_opacity.c @@ -0,0 +1,7 @@ +#include "bongo_cat/platform.h" + +#ifdef _WIN32 +float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { + return platform ? platform->window_opacity : 1.0f; +} +#endif diff --git a/src/platform/windows/windows_package.c b/src/platform/windows/windows_package.c new file mode 100644 index 00000000..0f71eac6 --- /dev/null +++ b/src/platform/windows/windows_package.c @@ -0,0 +1,75 @@ +#include "windows_package.h" +#include "windows_utf8.h" + +#ifdef _WIN32 +#include "bongo_cat/common.h" + +#include +#include +#include +#include + +typedef LONG (WINAPI *BongoCatGetPackageName)(UINT32 *, PWSTR); + +static BongoCatGetPackageName package_api(const char *name) { + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + return kernel ? (BongoCatGetPackageName)(void *)GetProcAddress(kernel, name) + : NULL; +} + +bool bongo_cat_windows_is_packaged(void) { + BongoCatGetPackageName get_name = package_api("GetCurrentPackageFullName"); + if (!get_name) return false; + UINT32 length = 0; + return get_name(&length, NULL) == ERROR_INSUFFICIENT_BUFFER; +} + +static wchar_t *package_family(void) { + BongoCatGetPackageName get_family = + package_api("GetCurrentPackageFamilyName"); + if (!get_family) return NULL; + UINT32 length = 0; + if (get_family(&length, NULL) != ERROR_INSUFFICIENT_BUFFER || !length) + return NULL; + wchar_t *family = calloc(length, sizeof(*family)); + if (!family || get_family(&length, family) != ERROR_SUCCESS) { + free(family); + return NULL; + } + return family; +} + +static bool user_profile(wchar_t output[BONGO_CAT_PATH_CAP]) { + DWORD length = GetEnvironmentVariableW(L"USERPROFILE", output, + BONGO_CAT_PATH_CAP); + if (length > 0 && length < BONGO_CAT_PATH_CAP) return true; + output[0] = L'\0'; + return SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_PROFILE, NULL, 0, output)); +} + +bool bongo_cat_windows_package_storage_root_for(const wchar_t *profile, + const wchar_t *family, char *output, size_t capacity) { + if (!output || !capacity || capacity > INT_MAX) return false; + output[0] = '\0'; + if (!profile || !profile[0] || !family || !family[0]) return false; + wchar_t path[BONGO_CAT_PATH_CAP]; + int length = swprintf(path, BONGO_CAT_PATH_CAP, + L"%ls\\AppData\\Local\\Packages\\%ls\\LocalCache\\Local\\%ls", + profile, family, BONGO_CAT_NAME_W); + return length > 0 && length < BONGO_CAT_PATH_CAP && + bongo_cat_windows_utf8(path, output, capacity); +} + +bool bongo_cat_windows_package_storage_root(char *output, size_t capacity) { + if (!output || !capacity) return false; + output[0] = '\0'; + if (!bongo_cat_windows_is_packaged()) return false; + wchar_t profile[BONGO_CAT_PATH_CAP] = {0}; + wchar_t *family = package_family(); + bool resolved = family && user_profile(profile) && + bongo_cat_windows_package_storage_root_for(profile, family, + output, capacity); + free(family); + return resolved; +} +#endif diff --git a/src/platform/windows/windows_package.h b/src/platform/windows/windows_package.h new file mode 100644 index 00000000..39438712 --- /dev/null +++ b/src/platform/windows/windows_package.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_WINDOWS_PACKAGE_H +#define BONGO_CAT_WINDOWS_PACKAGE_H + +#ifdef _WIN32 +#include +#include +#include + +bool bongo_cat_windows_is_packaged(void); +bool bongo_cat_windows_package_storage_root(char *output, size_t capacity); +bool bongo_cat_windows_package_storage_root_for(const wchar_t *profile, + const wchar_t *family, char *output, size_t capacity); +#endif + +#endif diff --git a/src/platform/windows_pointer.c b/src/platform/windows/windows_pointer.c similarity index 70% rename from src/platform/windows_pointer.c rename to src/platform/windows/windows_pointer.c index b62e09fb..6809ddfd 100644 --- a/src/platform/windows_pointer.c +++ b/src/platform/windows/windows_pointer.c @@ -30,8 +30,8 @@ void bongo_cat_platform_set_click_through(BongoCatPlatform *platform, bool forced, bool pointer_transparent) { HWND window = native_window(platform); bongo_cat_windows_layered_set_click_through(platform, forced); - bongo_cat_windows_borderless_set_click_through(window, - forced || pointer_transparent); + bongo_cat_windows_borderless_set_click_through(window, forced, + pointer_transparent); } void bongo_cat_platform_raise_window(SDL_Window *window) { @@ -39,18 +39,20 @@ void bongo_cat_platform_raise_window(SDL_Window *window) { SDL_ShowWindow(window); HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); - if (!handle) { SDL_RaiseWindow(window); return; } + if (!handle) return; HWND proxy = bongo_cat_windows_layered_proxy(handle); if (IsIconic(handle)) ShowWindow(handle, SW_RESTORE); - HWND foreground = GetForegroundWindow(); - DWORD foreground_thread = foreground ? GetWindowThreadProcessId(foreground, NULL) : 0; - DWORD current_thread = GetCurrentThreadId(); - bool attached = foreground_thread && foreground_thread != current_thread && - AttachThreadInput(current_thread, foreground_thread, TRUE); - BringWindowToTop(handle); SetForegroundWindow(handle); SetActiveWindow(handle); + BringWindowToTop(handle); + SetForegroundWindow(handle); if (proxy) BringWindowToTop(proxy); - if (attached) AttachThreadInput(current_thread, foreground_thread, FALSE); - bongo_cat_windows_capture_configure(handle); + /* Preferences windows are transparent too, but are not OBS capture + sources. Running the capture style transaction on them can recreate the + DWM surface and reintroduce an opaque edge. Only configure windows that + were explicitly registered as capture sources. */ + if (bongo_cat_windows_capture_is_configured(handle)) + bongo_cat_windows_capture_configure(handle); + else + bongo_cat_windows_capture_repair_transparency(handle); } bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, @@ -71,11 +73,20 @@ bool bongo_cat_platform_set_geometry(BongoCatPlatform *platform, !(SDL_GetWindowFlags(platform->window) & SDL_WINDOW_RESIZABLE) && !SDL_SetWindowResizable(platform->window, true)) return false; + UINT flags = SWP_NOZORDER | SWP_NOACTIVATE; + if (!position_changed) flags |= SWP_NOMOVE; + if (!size_changed) flags |= SWP_NOSIZE; + bool preserve_screen = size_changed && position_changed && + bongo_cat_windows_borderless_preserve_screen(window, true); bool changed = SetWindowPos(window, NULL, position_changed ? x : current_x, position_changed ? y : current_y, size_changed ? width : current_width, size_changed ? height : current_height, - SWP_NOZORDER | SWP_NOACTIVATE) != 0; + flags) != 0; + if (preserve_screen) + bongo_cat_windows_borderless_preserve_screen(window, false); if (!changed) return false; + /* Geometry changes retain DWM alpha composition. Reapplying it here + invalidates the surface on every animation frame. */ return SDL_SyncWindow(platform->window); } #endif diff --git a/src/platform/windows_popup.c b/src/platform/windows/windows_popup.c similarity index 100% rename from src/platform/windows_popup.c rename to src/platform/windows/windows_popup.c diff --git a/src/platform/windows_popup.h b/src/platform/windows/windows_popup.h similarity index 100% rename from src/platform/windows_popup.h rename to src/platform/windows/windows_popup.h diff --git a/src/platform/windows/windows_snapshot.c b/src/platform/windows/windows_snapshot.c new file mode 100644 index 00000000..c21da96d --- /dev/null +++ b/src/platform/windows/windows_snapshot.c @@ -0,0 +1,82 @@ +#include "windows_snapshot_internal.h" +#include "windows_capture.h" + +#include + +static HWND native_window(SDL_Window *window) { + return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); +} + +void bongo_cat_windows_snapshot_destroy(BongoCatWindowsSnapshot *s) { + if (!s) return; + if (s->suppressed) { + SDL_SetWindowOpacity(s->source, s->opacity); + bongo_cat_windows_capture_restore_transparency(s->source_handle); + } + if (s->handle) { + ShowWindow(s->handle, SW_HIDE); + bongo_cat_windows_snapshot_unbind_input(s); + } + if (s->texture) SDL_DestroyTexture(s->texture); + if (s->renderer) SDL_DestroyRenderer(s->renderer); + if (s->window) SDL_DestroyWindow(s->window); + SDL_free(s->pixels); + SDL_free(s); +} + +BongoCatWindowsSnapshot *bongo_cat_windows_snapshot_create(SDL_Window *source, + float opacity) { + BongoCatWindowsSnapshot *s = SDL_calloc(1, sizeof(*s)); + if (!s) return NULL; + s->source = source; + s->source_handle = native_window(source); + s->opacity = opacity; + int x = GetSystemMetrics(SM_XVIRTUALSCREEN), y = GetSystemMetrics(SM_YVIRTUALSCREEN); + int width = GetSystemMetrics(SM_CXVIRTUALSCREEN), height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + s->desktop = (RECT){x, y, x + width, y + height}; + /* Bound the temporary desktop surface as well as the captured texture. */ + if (width <= 0 || height <= 0 || (size_t)width * height > SNAPSHOT_MAX_PIXELS || + !bongo_cat_windows_snapshot_capture(s)) goto failed; + s->window = SDL_CreateWindow("BongoCat interaction preview", width, height, + SDL_WINDOW_HIDDEN | SDL_WINDOW_BORDERLESS | SDL_WINDOW_TRANSPARENT | + SDL_WINDOW_UTILITY | SDL_WINDOW_NOT_FOCUSABLE); + if (!s->window) goto failed; + s->handle = native_window(s->window); + if (!s->handle) goto failed; + /* A fixed desktop-sized surface means DWM only sees image changes, never + a sequence of native window moves/resizes during the gesture. */ + LONG_PTR style = GetWindowLongPtrW(s->handle, GWL_EXSTYLE); + SetWindowLongPtrW(s->handle, GWL_EXSTYLE, style | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); + if (!SetWindowPos(s->handle, s->source_handle, x, y, width, height, SWP_NOACTIVATE)) + goto failed; + s->renderer = SDL_CreateRenderer(s->window, "direct3d11"); + if (!s->renderer) goto failed; + SDL_SetRenderVSync(s->renderer, 1); + s->texture = SDL_CreateTexture(s->renderer, SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STATIC, s->width, s->height); + if (!s->texture || !SDL_UpdateTexture(s->texture, NULL, s->pixels, s->width * 4)) + goto failed; + /* The framebuffer already contains premultiplied alpha. Replace pixels + directly, multiplying both RGB and alpha by the window opacity. */ + if (!SDL_SetTextureBlendMode(s->texture, SDL_BLENDMODE_NONE) || + !SDL_SetTextureScaleMode(s->texture, SDL_SCALEMODE_LINEAR)) goto failed; + Uint8 alpha = (Uint8)(SDL_clamp(opacity, 0.0f, 1.0f) * 255.0f + 0.5f); + SDL_SetTextureColorMod(s->texture, alpha, alpha, alpha); + SDL_SetTextureAlphaMod(s->texture, alpha); + if (!bongo_cat_windows_snapshot_bind_input(s)) goto failed; + if (!bongo_cat_windows_capture_restore_transparency(s->handle) || + !bongo_cat_windows_snapshot_present(s)) goto failed; + ShowWindow(s->handle, SW_SHOWNOACTIVATE); + SetWindowPos(s->handle, s->source_handle, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + s->has_frame = false; + if (!bongo_cat_windows_snapshot_present(s)) goto failed; + bongo_cat_windows_snapshot_pointer(s); + if (!SDL_SetWindowOpacity(source, 0.0f)) goto failed; + s->suppressed = true; + return s; +failed: + bongo_cat_windows_snapshot_destroy(s); + return NULL; +} diff --git a/src/platform/windows/windows_snapshot.h b/src/platform/windows/windows_snapshot.h new file mode 100644 index 00000000..e6628081 --- /dev/null +++ b/src/platform/windows/windows_snapshot.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_WINDOWS_SNAPSHOT_H +#define BONGO_CAT_WINDOWS_SNAPSHOT_H + +#include + +typedef struct BongoCatWindowsSnapshot BongoCatWindowsSnapshot; + +/* Capture requires the source OpenGL context to be current. */ +BongoCatWindowsSnapshot *bongo_cat_windows_snapshot_create(SDL_Window *source, + float opacity); +bool bongo_cat_windows_snapshot_present(BongoCatWindowsSnapshot *snapshot); +void bongo_cat_windows_snapshot_geometry(BongoCatWindowsSnapshot *snapshot, + const SDL_FRect *rect); +void bongo_cat_windows_snapshot_pointer(BongoCatWindowsSnapshot *snapshot); +bool bongo_cat_windows_snapshot_hit(BongoCatWindowsSnapshot *snapshot, + float x, float y); +void bongo_cat_windows_snapshot_destroy(BongoCatWindowsSnapshot *snapshot); + +#endif diff --git a/src/platform/windows/windows_snapshot_image.c b/src/platform/windows/windows_snapshot_image.c new file mode 100644 index 00000000..2ce61394 --- /dev/null +++ b/src/platform/windows/windows_snapshot_image.c @@ -0,0 +1,85 @@ +#include "windows_snapshot_internal.h" + +#include + +void bongo_cat_windows_snapshot_geometry(BongoCatWindowsSnapshot *s, + const SDL_FRect *rect) { + if (!s) return; + s->custom_destination = rect != NULL; + if (rect) s->destination = *rect; +} + +bool bongo_cat_windows_snapshot_present(BongoCatWindowsSnapshot *s) { + RECT rect; + if (!s || !GetWindowRect(s->source_handle, &rect)) return false; + SDL_FRect image = s->custom_destination ? s->destination : + (SDL_FRect){(float)rect.left, (float)rect.top, + (float)(rect.right - rect.left), (float)(rect.bottom - rect.top)}; + if (s->has_frame && image.x == s->presented.x && image.y == s->presented.y && + image.w == s->presented.w && image.h == s->presented.h) return true; + int pixel_width, pixel_height; + if (!SDL_GetRenderOutputSize(s->renderer, &pixel_width, &pixel_height)) return false; + float sx = (float)pixel_width / (s->desktop.right - s->desktop.left); + float sy = (float)pixel_height / (s->desktop.bottom - s->desktop.top); + SDL_FRect destination = {(image.x - s->desktop.left) * sx, + (image.y - s->desktop.top) * sy, image.w * sx, image.h * sy}; + SDL_SetRenderDrawColor(s->renderer, 0, 0, 0, 0); + if (!SDL_RenderClear(s->renderer) || + !SDL_RenderTextureRotated(s->renderer, s->texture, NULL, &destination, + 0.0, NULL, SDL_FLIP_VERTICAL) || !SDL_RenderPresent(s->renderer)) + return false; + /* Keep the native hit-test footprint local to the picture, while the + swapchain and window origin remain fixed for the whole interaction. */ + HRGN region = CreateRectRgn((int)SDL_floorf(image.x - s->desktop.left), + (int)SDL_floorf(image.y - s->desktop.top), + (int)SDL_ceilf(image.x + image.w - s->desktop.left), + (int)SDL_ceilf(image.y + image.h - s->desktop.top)); + if (!region) return false; + if (!SetWindowRgn(s->handle, region, FALSE)) { + DeleteObject(region); + return false; + } + s->presented = image; + s->has_frame = true; + return true; +} + +bool bongo_cat_windows_snapshot_capture(BongoCatWindowsSnapshot *s) { + if (!SDL_GetWindowSizeInPixels(s->source, &s->width, &s->height) || + s->width <= 0 || s->height <= 0 || + (size_t)s->width * s->height > SNAPSHOT_MAX_PIXELS) return false; + s->pixels = SDL_malloc((size_t)s->width * s->height * 4); + if (!s->pixels) return false; + typedef void (APIENTRY *BindBufferFn)(GLenum, GLuint); + BindBufferFn bind_buffer = (BindBufferFn)SDL_GL_GetProcAddress("glBindBuffer"); + if (!bind_buffer) return false; + GLint pack_buffer; + glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &pack_buffer); + bind_buffer(GL_PIXEL_PACK_BUFFER, 0); + GLint buffer, alignment, row_length, skip_pixels, skip_rows; + glGetIntegerv(GL_READ_BUFFER, &buffer); + glGetIntegerv(GL_PACK_ALIGNMENT, &alignment); + glGetIntegerv(GL_PACK_ROW_LENGTH, &row_length); + glGetIntegerv(GL_PACK_SKIP_PIXELS, &skip_pixels); + glGetIntegerv(GL_PACK_SKIP_ROWS, &skip_rows); + glReadBuffer(GL_FRONT); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glReadPixels(0, 0, s->width, s->height, GL_RGBA, GL_UNSIGNED_BYTE, s->pixels); + GLenum error = glGetError(); + glPixelStorei(GL_PACK_SKIP_PIXELS, skip_pixels); + glPixelStorei(GL_PACK_SKIP_ROWS, skip_rows); + glPixelStorei(GL_PACK_ROW_LENGTH, row_length); + glPixelStorei(GL_PACK_ALIGNMENT, alignment); + glReadBuffer((GLenum)buffer); + bind_buffer(GL_PIXEL_PACK_BUFFER, (GLuint)pack_buffer); + if (error != GL_NO_ERROR) return false; + /* Some drivers cannot expose a transparent front buffer. Never replace + a live pet with an empty capture in that case. */ + size_t pixels = (size_t)s->width * s->height; + for (size_t i = 0; i < pixels; ++i) + if (s->pixels[i * 4 + 3] > 8) return true; + return false; +} diff --git a/src/platform/windows/windows_snapshot_input.c b/src/platform/windows/windows_snapshot_input.c new file mode 100644 index 00000000..d30a11db --- /dev/null +++ b/src/platform/windows/windows_snapshot_input.c @@ -0,0 +1,68 @@ +#include "windows_snapshot_internal.h" + +#include + +static const wchar_t snapshot_property[] = L"BongoCat.InteractionSnapshot"; + +bool bongo_cat_windows_snapshot_hit(BongoCatWindowsSnapshot *s, float x, float y) { + RECT rect; + if (!s || !GetWindowRect(s->source_handle, &rect) || + rect.right <= rect.left || rect.bottom <= rect.top || + x < 0 || y < 0 || x >= rect.right - rect.left || y >= rect.bottom - rect.top) + return false; + SDL_FRect destination = s->custom_destination ? s->destination : + (SDL_FRect){(float)rect.left, (float)rect.top, + (float)(rect.right - rect.left), (float)(rect.bottom - rect.top)}; + x += rect.left - destination.x; + y += rect.top - destination.y; + if (x < 0 || y < 0 || x >= destination.w || y >= destination.h) return false; + int px = SDL_clamp((int)(x * s->width / destination.w), 0, s->width - 1); + int py = s->height - 1 - SDL_clamp( + (int)(y * s->height / destination.h), 0, s->height - 1); + return s->pixels[((size_t)py * s->width + px) * 4 + 3] > 8; +} + +void bongo_cat_windows_snapshot_pointer(BongoCatWindowsSnapshot *s) { + POINT point; + if (!s || !GetCursorPos(&point) || !ScreenToClient(s->source_handle, &point)) return; + bool hit = bongo_cat_windows_snapshot_hit(s, (float)point.x, (float)point.y); + LONG_PTR style = GetWindowLongPtrW(s->handle, GWL_EXSTYLE); + LONG_PTR next = hit ? style & ~WS_EX_TRANSPARENT : style | WS_EX_TRANSPARENT; + if (next != style) SetWindowLongPtrW(s->handle, GWL_EXSTYLE, next); +} + +static LRESULT CALLBACK snapshot_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + BongoCatWindowsSnapshot *s = GetPropW(window, snapshot_property); + if (!s) return DefWindowProcW(window, message, wparam, lparam); + if (message == WM_NCHITTEST) { + POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; + ScreenToClient(s->source_handle, &point); + return bongo_cat_windows_snapshot_hit(s, (float)point.x, (float)point.y) + ? HTCLIENT : HTTRANSPARENT; + } + if (message == WM_MOUSEACTIVATE) return MA_NOACTIVATE; + if (message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) { + /* Wheel coordinates are already screen-relative. Other mouse messages + must look as though SDL received them on the real pet window. */ + if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) { + POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; + MapWindowPoints(window, s->source_handle, &point, 1); + lparam = MAKELPARAM(point.x, point.y); + } + return SendMessageW(s->source_handle, message, wparam, lparam); + } + return CallWindowProcW(s->original, window, message, wparam, lparam); +} + +bool bongo_cat_windows_snapshot_bind_input(BongoCatWindowsSnapshot *s) { + if (!SetPropW(s->handle, snapshot_property, s)) return false; + s->original = (WNDPROC)GetWindowLongPtrW(s->handle, GWLP_WNDPROC); + return SetWindowLongPtrW(s->handle, GWLP_WNDPROC, (LONG_PTR)snapshot_proc) != 0; +} + +void bongo_cat_windows_snapshot_unbind_input(BongoCatWindowsSnapshot *s) { + if (s->original) + SetWindowLongPtrW(s->handle, GWLP_WNDPROC, (LONG_PTR)s->original); + RemovePropW(s->handle, snapshot_property); +} diff --git a/src/platform/windows/windows_snapshot_internal.h b/src/platform/windows/windows_snapshot_internal.h new file mode 100644 index 00000000..44a5d968 --- /dev/null +++ b/src/platform/windows/windows_snapshot_internal.h @@ -0,0 +1,27 @@ +#ifndef BONGO_CAT_WINDOWS_SNAPSHOT_INTERNAL_H +#define BONGO_CAT_WINDOWS_SNAPSHOT_INTERNAL_H + +#include "windows_snapshot.h" +#include + +#define SNAPSHOT_MAX_PIXELS (16u * 1024u * 1024u) + +struct BongoCatWindowsSnapshot { + SDL_Window *source, *window; + SDL_Renderer *renderer; + SDL_Texture *texture; + HWND source_handle, handle; + WNDPROC original; + unsigned char *pixels; + int width, height; + RECT desktop; + SDL_FRect destination, presented; + float opacity; + bool suppressed, has_frame, custom_destination; +}; + +bool bongo_cat_windows_snapshot_capture(BongoCatWindowsSnapshot *snapshot); +bool bongo_cat_windows_snapshot_bind_input(BongoCatWindowsSnapshot *snapshot); +void bongo_cat_windows_snapshot_unbind_input(BongoCatWindowsSnapshot *snapshot); + +#endif diff --git a/src/platform/windows/windows_startup.c b/src/platform/windows/windows_startup.c new file mode 100644 index 00000000..0aa16651 --- /dev/null +++ b/src/platform/windows/windows_startup.c @@ -0,0 +1,265 @@ +#include "bongo_cat/platform.h" +#include "windows_startup.h" + +#ifdef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#include + +static HANDLE instance_mutex; +static HANDLE instance_wake_event; +static HANDLE instance_update_shutdown_event; +static HANDLE instance_stopped_event; +static HANDLE instance_info_mapping; +static BongoCatWindowsInstanceInfo *instance_info; +static wchar_t instance_title[96] = BONGO_CAT_PET_WINDOW_TITLE_W; +static wchar_t instance_mutex_name[128] = L"Local\\BongoCat.SingleInstance"; +static wchar_t instance_wake_name[128] = L"Local\\BongoCat.WakeInstance"; +static wchar_t instance_update_shutdown_name[128] = + L"Local\\BongoCat.UpdateShutdown"; +static wchar_t instance_stopped_name[128] = L"Local\\BongoCat.InstanceStopped"; +static wchar_t instance_info_name[128] = L"Local\\BongoCat.InstanceInfo"; +static bool identity_ready; + +static bool safe_identity(const char *value) { + if (!value || !value[0] || strlen(value) > 32) return false; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) + if (!isalnum(*cursor) && *cursor != '-' && *cursor != '_') return false; + return true; +} + +static void initialize_identity(void) { + if (identity_ready) return; + identity_ready = true; + const char *value = SDL_getenv_unsafe("BONGO_CAT_TEST_INSTANCE_ID"); + if (!safe_identity(value)) return; + swprintf(instance_title, sizeof(instance_title) / sizeof(instance_title[0]), + L"%ls [%hs]", BONGO_CAT_PET_WINDOW_TITLE_W, value); + swprintf(instance_mutex_name, + sizeof(instance_mutex_name) / sizeof(instance_mutex_name[0]), + L"Local\\BongoCat.SingleInstance.%hs", value); + swprintf(instance_wake_name, + sizeof(instance_wake_name) / sizeof(instance_wake_name[0]), + L"Local\\BongoCat.WakeInstance.%hs", value); + swprintf(instance_update_shutdown_name, + sizeof(instance_update_shutdown_name) / + sizeof(instance_update_shutdown_name[0]), + L"Local\\BongoCat.UpdateShutdown.%hs", value); + swprintf(instance_stopped_name, + sizeof(instance_stopped_name) / sizeof(instance_stopped_name[0]), + L"Local\\BongoCat.InstanceStopped.%hs", value); + swprintf(instance_info_name, + sizeof(instance_info_name) / sizeof(instance_info_name[0]), + L"Local\\BongoCat.InstanceInfo.%hs", value); +} + +const wchar_t *bongo_cat_windows_instance_title(void) { + initialize_identity(); return instance_title; +} + +const wchar_t *bongo_cat_windows_update_shutdown_name(void) { + initialize_identity(); return instance_update_shutdown_name; +} + +const wchar_t *bongo_cat_windows_instance_stopped_name(void) { + initialize_identity(); return instance_stopped_name; +} + +const wchar_t *bongo_cat_windows_instance_info_name(void) { + initialize_identity(); return instance_info_name; +} + +static void create_instance_events(void) { + if (!instance_wake_event) + instance_wake_event = CreateEventW(NULL, FALSE, FALSE, instance_wake_name); + if (!instance_update_shutdown_event) + instance_update_shutdown_event = CreateEventW(NULL, FALSE, FALSE, + instance_update_shutdown_name); + if (!instance_stopped_event) + instance_stopped_event = CreateEventW(NULL, TRUE, FALSE, + instance_stopped_name); + if (!instance_info_mapping) { + instance_info_mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, + PAGE_READWRITE, 0, sizeof(BongoCatWindowsInstanceInfo), + instance_info_name); + if (instance_info_mapping) + instance_info = MapViewOfFile(instance_info_mapping, + FILE_MAP_WRITE, 0, 0, sizeof(BongoCatWindowsInstanceInfo)); + if (instance_info) { + memset(instance_info, 0, sizeof(*instance_info)); + instance_info->magic = 0x42434D49u; + GetModuleFileNameW(NULL, instance_info->executable_path, + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP); + snprintf(instance_info->version, sizeof(instance_info->version), + "%s", BONGO_CAT_VERSION); + } + } +} + +static void wake_existing_instance(void) { + for (int attempt = 0; attempt < 30; ++attempt) { + HANDLE wake = OpenEventW(EVENT_MODIFY_STATE, FALSE, instance_wake_name); + if (wake) { + bool signaled = SetEvent(wake) != FALSE; + CloseHandle(wake); + if (signaled) return; + } + HWND existing = FindWindowW(NULL, instance_title); + if (existing) { + ShowWindowAsync(existing, IsIconic(existing) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(existing); + return; + } + Sleep(100); + } +} + +bool bongo_cat_platform_single_instance_begin(void) { + initialize_identity(); + if (SDL_getenv_unsafe("BONGO_CAT_ALLOW_TEST_INSTANCES")) return true; + instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); + if (!instance_mutex) return true; + if (GetLastError() != ERROR_ALREADY_EXISTS) { + create_instance_events(); return true; + } + CloseHandle(instance_mutex); instance_mutex = NULL; + if (bongo_cat_windows_update_handoff()) { + for (int attempt = 0; attempt < 30; ++attempt) { + instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); + if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { + create_instance_events(); return true; + } + if (instance_mutex) CloseHandle(instance_mutex); + instance_mutex = NULL; + Sleep(100); + } + } + wake_existing_instance(); + instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); + if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { + create_instance_events(); return true; + } + if (instance_mutex) CloseHandle(instance_mutex); + instance_mutex = NULL; + return false; +} + +bool bongo_cat_platform_single_instance_take_wake(void) { + return instance_wake_event && + WaitForSingleObject(instance_wake_event, 0) == WAIT_OBJECT_0; +} + +bool bongo_cat_platform_update_shutdown_argument(int argc, char **argv) { + bool requested = false; + for (int i = 1; i < argc; ++i) + if (argv && argv[i] && strcmp(argv[i], "--shutdown-for-update") == 0) + requested = true; + if (!requested) return false; + initialize_identity(); + HANDLE stopped = OpenEventW(SYNCHRONIZE, FALSE, instance_stopped_name); + HANDLE shutdown = OpenEventW(EVENT_MODIFY_STATE, FALSE, + instance_update_shutdown_name); + if (shutdown) { + SetEvent(shutdown); + CloseHandle(shutdown); + } + HWND existing = FindWindowW(NULL, instance_title); + if (existing) PostMessageW(existing, WM_CLOSE, 0, 0); + if (stopped) { + WaitForSingleObject(stopped, 15000); + CloseHandle(stopped); + } + return true; +} + +bool bongo_cat_platform_single_instance_take_update_shutdown(void) { + return instance_update_shutdown_event && + WaitForSingleObject(instance_update_shutdown_event, 0) == WAIT_OBJECT_0; +} + +void bongo_cat_platform_single_instance_end(void) { + if (instance_stopped_event) SetEvent(instance_stopped_event); + if (instance_update_shutdown_event) + CloseHandle(instance_update_shutdown_event); + instance_update_shutdown_event = NULL; + if (instance_stopped_event) CloseHandle(instance_stopped_event); + instance_stopped_event = NULL; + if (instance_info) UnmapViewOfFile(instance_info); + instance_info = NULL; + if (instance_info_mapping) CloseHandle(instance_info_mapping); + instance_info_mapping = NULL; + if (instance_wake_event) CloseHandle(instance_wake_event); + instance_wake_event = NULL; + if (instance_mutex) CloseHandle(instance_mutex); + instance_mutex = NULL; +} + +BongoCatResult bongo_cat_platform_set_autostart(bool enabled, + BongoCatError *error) { + PWSTR startup = NULL; + HRESULT result = SHGetKnownFolderPath(&FOLDERID_Startup, KF_FLAG_DEFAULT, + NULL, &startup); + wchar_t shortcut[BONGO_CAT_PATH_CAP]; + int written = SUCCEEDED(result) && startup + ? swprintf(shortcut, BONGO_CAT_PATH_CAP, L"%ls\\%ls.lnk", startup, + BONGO_CAT_NAME_W) : -1; + CoTaskMemFree(startup); + if (FAILED(result) || written < 0 || written >= BONGO_CAT_PATH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot locate the Windows Startup folder"); + return BONGO_CAT_ERROR_PLATFORM; + } + if (!enabled) { + if (DeleteFileW(shortcut) || GetLastError() == ERROR_FILE_NOT_FOUND) + return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot remove the Windows Startup shortcut"); + return BONGO_CAT_ERROR_PLATFORM; + } + + wchar_t executable[BONGO_CAT_PATH_CAP]; + DWORD length = GetModuleFileNameW(NULL, executable, BONGO_CAT_PATH_CAP); + if (!length || length >= BONGO_CAT_PATH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot determine executable path"); + return BONGO_CAT_ERROR_PLATFORM; + } + wchar_t working_directory[BONGO_CAT_PATH_CAP]; + memcpy(working_directory, executable, (length + 1) * sizeof(wchar_t)); + wchar_t *separator = wcsrchr(working_directory, L'\\'); + if (separator) *separator = L'\0'; + + HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) result = initialized; + else { + IShellLinkW *link = NULL; + IPersistFile *persist = NULL; + result = CoCreateInstance(&CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, + &IID_IShellLinkW, (void **)&link); + if (SUCCEEDED(result)) result = link->lpVtbl->SetPath(link, executable); + if (SUCCEEDED(result)) result = link->lpVtbl->SetArguments(link, + L"--autostart"); + if (SUCCEEDED(result) && separator) + result = link->lpVtbl->SetWorkingDirectory(link, working_directory); + if (SUCCEEDED(result)) result = link->lpVtbl->SetDescription(link, + L"Start BongoCat when signing in"); + if (SUCCEEDED(result)) result = link->lpVtbl->QueryInterface(link, + &IID_IPersistFile, (void **)&persist); + if (SUCCEEDED(result)) result = persist->lpVtbl->Save(persist, + shortcut, TRUE); + if (persist) persist->lpVtbl->Release(persist); + if (link) link->lpVtbl->Release(link); + } + if (SUCCEEDED(initialized)) CoUninitialize(); + if (SUCCEEDED(result)) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot create the Windows Startup shortcut (0x%08lx)", + (unsigned long)result); + return BONGO_CAT_ERROR_PLATFORM; +} +#endif diff --git a/src/platform/windows/windows_startup.h b/src/platform/windows/windows_startup.h new file mode 100644 index 00000000..58079151 --- /dev/null +++ b/src/platform/windows/windows_startup.h @@ -0,0 +1,23 @@ +#ifndef BONGO_CAT_WINDOWS_STARTUP_H +#define BONGO_CAT_WINDOWS_STARTUP_H + +#ifdef _WIN32 +#include "bongo_cat/common.h" +#include + +#define BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP 2048 + +typedef struct BongoCatWindowsInstanceInfo { + uint32_t magic; + wchar_t executable_path[BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP]; + char version[BONGO_CAT_UPDATE_VERSION_CAP]; +} BongoCatWindowsInstanceInfo; + +const wchar_t *bongo_cat_windows_instance_title(void); +const wchar_t *bongo_cat_windows_update_shutdown_name(void); +const wchar_t *bongo_cat_windows_instance_stopped_name(void); +const wchar_t *bongo_cat_windows_instance_info_name(void); +bool bongo_cat_windows_update_handoff(void); +#endif + +#endif diff --git a/src/platform/windows/windows_transparency.c b/src/platform/windows/windows_transparency.c new file mode 100644 index 00000000..57859a0b --- /dev/null +++ b/src/platform/windows/windows_transparency.c @@ -0,0 +1,130 @@ +#include "windows_capture.h" + +#ifdef _WIN32 +#include +#include + +static UINT transparency_repair_message; +static const wchar_t transparent_property[] = L"BongoCat.TransparentWindow"; +static const wchar_t transparency_proc_property[] = + L"BongoCat.TransparentWindowProc"; +static const wchar_t transparency_repair_property[] = + L"BongoCat.TransparentWindowRepairPending"; +static bool transparency_warning_emitted; + +static bool is_transparent(HWND window) { + return window && GetPropW(window, transparent_property) != NULL; +} + +void bongo_cat_windows_capture_mark_transparent(HWND window, bool enabled) { + if (!window) return; + if (enabled) SetPropW(window, transparent_property, (HANDLE)1); + else { + RemovePropW(window, transparent_property); + RemovePropW(window, transparency_repair_property); + } +} + +static void register_message(void) { + if (!transparency_repair_message) + transparency_repair_message = RegisterWindowMessageW( + L"BongoCat.TransparentWindow.Repair"); +} + +static LRESULT CALLBACK transparency_window_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (bongo_cat_windows_capture_handle_message(window, message, wparam)) + return 0; + WNDPROC original = (WNDPROC)GetPropW(window, transparency_proc_property); + return CallWindowProcW(original ? original : DefWindowProcW, window, + message, wparam, lparam); +} + +void bongo_cat_windows_capture_install_transparency_handler(HWND window) { + if (!window || GetPropW(window, transparency_proc_property)) return; + WNDPROC original = (WNDPROC)GetWindowLongPtrW(window, GWLP_WNDPROC); + if (!original || !SetPropW(window, transparency_proc_property, + (HANDLE)original)) return; + if (!SetWindowLongPtrW(window, GWLP_WNDPROC, + (LONG_PTR)transparency_window_proc)) + RemovePropW(window, transparency_proc_property); +} + +bool bongo_cat_windows_capture_restore_transparency(HWND window) { + if (!window) return false; + HRGN region = CreateRectRgn(-1, -1, 0, 0); + if (!region) return false; + DWM_BLURBEHIND blur = {0}; + blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + blur.fEnable = TRUE; + blur.hRgnBlur = region; + HRESULT result = DwmEnableBlurBehindWindow(window, &blur); + DeleteObject(region); + if (SUCCEEDED(result)) { + /* DWM may restore a border or rounded corner after a shell/DPI + transition. Keep transparent popup windows borderless as well. */ + const int no_rounding = 1; /* DWMWCP_DONOTROUND */ + const DWORD no_border = 0xfffffffeu; /* DWMWA_COLOR_NONE */ + DwmSetWindowAttribute(window, 33, &no_rounding, sizeof(no_rounding)); + DwmSetWindowAttribute(window, 34, &no_border, sizeof(no_border)); + } + if (FAILED(result) && !transparency_warning_emitted) { + transparency_warning_emitted = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore Windows transparent composition (0x%08lx)", + (unsigned long)result); + } + return SUCCEEDED(result); +} + +void bongo_cat_windows_capture_repair_transparency(HWND window) { + if (!is_transparent(window)) return; + if (bongo_cat_windows_capture_restore_transparency(window)) { + /* A composition reset need not produce a paint message. */ + InvalidateRect(window, NULL, FALSE); + } +} + +static void schedule_repair(HWND window) { + if (!is_transparent(window)) return; + register_message(); + if (!transparency_repair_message || + GetPropW(window, transparency_repair_property)) return; + if (SetPropW(window, transparency_repair_property, (HANDLE)1) && + !PostMessageW(window, transparency_repair_message, 0, 0)) + RemovePropW(window, transparency_repair_property); +} + +bool bongo_cat_windows_capture_handle_transparency_message( + HWND window, UINT message, WPARAM wparam) { + (void)wparam; + register_message(); + if (is_transparent(window)) { + /* SDL's default erase handler fills the client area with black. */ + if (message == WM_ERASEBKGND) return true; + /* Ordinary moves/resizes preserve transparency. Repairing on every + WM_WINDOWPOSCHANGED repeatedly invalidates the animated surface. */ + switch (message) { + case WM_DWMCOMPOSITIONCHANGED: + case WM_DWMNCRENDERINGCHANGED: + case WM_DWMCOLORIZATIONCOLORCHANGED: + case WM_DISPLAYCHANGE: + case WM_DPICHANGED: + case WM_THEMECHANGED: + case WM_NCACTIVATE: + case WM_SHOWWINDOW: + case WM_STYLECHANGED: + schedule_repair(window); + break; + default: + break; + } + } + if (transparency_repair_message && message == transparency_repair_message) { + RemovePropW(window, transparency_repair_property); + bongo_cat_windows_capture_repair_transparency(window); + return true; + } + return false; +} +#endif diff --git a/src/platform/windows_tray.c b/src/platform/windows/windows_tray.c similarity index 78% rename from src/platform/windows_tray.c rename to src/platform/windows/windows_tray.c index 035bd21c..9117a25f 100644 --- a/src/platform/windows_tray.c +++ b/src/platform/windows/windows_tray.c @@ -1,5 +1,7 @@ #include "bongo_cat/platform.h" #include "windows_popup.h" +#include "windows_tray.h" +#include "bongo_cat/tray.h" #ifdef _WIN32 #include @@ -7,6 +9,10 @@ #define BONGO_CAT_TRAY_MODAL_TIMER ((UINT_PTR)0xBC4F) static const wchar_t tray_binding_property[] = L"BongoCat.TrayBinding"; +static UINT taskbar_created_message; +static void *restore_tray; +static BongoCatTrayRestore restore_callback; +static void *restore_userdata; typedef struct WindowsTrayBinding { WNDPROC original; @@ -25,6 +31,7 @@ static LRESULT CALLBACK tray_window_proc(HWND window, UINT message, return 0; } if (message == WM_USER + 1 && LOWORD(lparam) == WM_CONTEXTMENU && binding) { + bongo_cat_tray_prepare_menu(binding->userdata, window); if (binding->modal_tick) { binding->modal_tick(binding->userdata); SetTimer(window, BONGO_CAT_TRAY_MODAL_TIMER, 16, NULL); @@ -86,10 +93,29 @@ static void bind_tray_window(HWND window, void *tray, void bongo_cat_platform_set_tray_callbacks(void *tray, BongoCatTrayClick left_click, BongoCatModalTick modal_tick, - void *userdata) { + BongoCatTrayRestore restore, void *userdata) { + if (!taskbar_created_message) + taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); + if (tray && restore) { + restore_tray = tray; + restore_callback = restore; + restore_userdata = userdata; + } else if (!tray || tray == restore_tray) { + restore_tray = NULL; + restore_callback = NULL; + restore_userdata = NULL; + } /* Pinned SDL stores SDL_Tray* in its private message window userdata. */ HWND window = NULL; while ((window = FindWindowExW(HWND_MESSAGE, window, L"Message", NULL)) != NULL) bind_tray_window(window, tray, left_click, modal_tick, userdata); } + +void bongo_cat_windows_tray_handle_message(UINT message) { + if (!taskbar_created_message) + taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); + if (taskbar_created_message && message == taskbar_created_message && + restore_callback) + restore_callback(restore_userdata); +} #endif diff --git a/src/platform/windows/windows_tray.h b/src/platform/windows/windows_tray.h new file mode 100644 index 00000000..22c11fa1 --- /dev/null +++ b/src/platform/windows/windows_tray.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_WINDOWS_TRAY_H +#define BONGO_CAT_WINDOWS_TRAY_H + +#ifdef _WIN32 +#include + +void bongo_cat_windows_tray_handle_message(UINT message); +#endif + +#endif diff --git a/src/platform/windows/windows_update_handoff.c b/src/platform/windows/windows_update_handoff.c new file mode 100644 index 00000000..20cc26b1 --- /dev/null +++ b/src/platform/windows/windows_update_handoff.c @@ -0,0 +1,48 @@ +#include "windows_startup.h" +#include "bongo_cat/update.h" + +#ifdef _WIN32 +#include + +static bool request_shutdown(HWND existing) { + HANDLE stopped = OpenEventW(SYNCHRONIZE, FALSE, + bongo_cat_windows_instance_stopped_name()); + HANDLE shutdown = OpenEventW(EVENT_MODIFY_STATE, FALSE, + bongo_cat_windows_update_shutdown_name()); + if (!stopped || !shutdown || !existing) { + if (stopped) CloseHandle(stopped); + if (shutdown) CloseHandle(shutdown); + return false; + } + bool signaled = SetEvent(shutdown) != 0; + if (signaled) PostMessageW(existing, WM_CLOSE, 0, 0); + DWORD result = signaled ? WaitForSingleObject(stopped, 15000) : WAIT_FAILED; + CloseHandle(shutdown); + CloseHandle(stopped); + return result == WAIT_OBJECT_0; +} + +bool bongo_cat_windows_update_handoff(void) { + HWND existing = FindWindowW(NULL, bongo_cat_windows_instance_title()); + HANDLE mapping = existing ? OpenFileMappingW(FILE_MAP_READ, FALSE, + bongo_cat_windows_instance_info_name()) : NULL; + BongoCatWindowsInstanceInfo info = {0}; + BongoCatWindowsInstanceInfo *shared = mapping ? MapViewOfFile(mapping, + FILE_MAP_READ, 0, 0, sizeof(info)) : NULL; + if (shared) info = *shared; + if (shared) UnmapViewOfFile(shared); + if (mapping) CloseHandle(mapping); + wchar_t current_path[BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP] = {0}; + DWORD current_length = GetModuleFileNameW(NULL, current_path, + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP); + if (!existing || !current_length || current_length >= + BONGO_CAT_WINDOWS_INSTANCE_PATH_CAP || + (info.magic == 0x42434D49u && + (_wcsicmp(current_path, info.executable_path) == 0 || + !info.version[0] || bongo_cat_update_compare_versions( + BONGO_CAT_VERSION, info.version) <= 0))) + return false; + if (info.magic != 0x42434D49u || !info.version[0]) return false; + return request_shutdown(existing); +} +#endif diff --git a/src/core/windows_utf8.c b/src/platform/windows/windows_utf8.c similarity index 100% rename from src/core/windows_utf8.c rename to src/platform/windows/windows_utf8.c diff --git a/src/core/windows_utf8.h b/src/platform/windows/windows_utf8.h similarity index 100% rename from src/core/windows_utf8.h rename to src/platform/windows/windows_utf8.h diff --git a/src/platform/windows_capture.c b/src/platform/windows_capture.c deleted file mode 100644 index 851818ef..00000000 --- a/src/platform/windows_capture.c +++ /dev/null @@ -1,139 +0,0 @@ -#include "windows_capture.h" - -#ifdef _WIN32 -#include -#include -#include -#include - -static UINT taskbar_created_message; -static UINT capture_refresh_message; -static bool removal_warning_emitted; -static bool style_warning_emitted; -#define BONGO_CAT_CAPTURE_REFRESH_TIMER ((UINT_PTR)0xBC51) - -static bool read_extended_style(HWND window, LONG_PTR *style) { - SetLastError(ERROR_SUCCESS); - LONG_PTR value = GetWindowLongPtrW(window, GWL_EXSTYLE); - if (!value && GetLastError() != ERROR_SUCCESS) return false; - *style = value; - return true; -} - -static bool write_extended_style(HWND window, LONG_PTR style) { - SetLastError(ERROR_SUCCESS); - LONG_PTR previous = SetWindowLongPtrW(window, GWL_EXSTYLE, style); - return previous || GetLastError() == ERROR_SUCCESS; -} - -static void register_messages(void) { - if (!taskbar_created_message) - taskbar_created_message = RegisterWindowMessageW(L"TaskbarCreated"); - if (!capture_refresh_message) - capture_refresh_message = RegisterWindowMessageW( - L"BongoCat.CaptureWindow.RefreshTaskbar"); -} - -static HRESULT remove_taskbar_tab(HWND window) { - HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) - return initialized; - ITaskbarList *taskbar = NULL; - HRESULT result = CoCreateInstance(&CLSID_TaskbarList, NULL, - CLSCTX_INPROC_SERVER, &IID_ITaskbarList, (void **)&taskbar); - if (SUCCEEDED(result)) result = taskbar->lpVtbl->HrInit(taskbar); - if (SUCCEEDED(result)) result = taskbar->lpVtbl->DeleteTab(taskbar, window); - if (taskbar) taskbar->lpVtbl->Release(taskbar); - if (SUCCEEDED(initialized)) CoUninitialize(); - return result; -} - -static void refresh_transparency(HWND window) { - MARGINS margins = {-1, -1, -1, -1}; - DwmExtendFrameIntoClientArea(window, &margins); - HRGN region = CreateRectRgn(-1, -1, 0, 0); - if (!region) return; - DWM_BLURBEHIND blur = {0}; - blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; - blur.fEnable = TRUE; - blur.hRgnBlur = region; - DwmEnableBlurBehindWindow(window, &blur); - DeleteObject(region); -} - -static void refresh_taskbar(HWND window) { - if (!window) return; - HRESULT result = remove_taskbar_tab(window); - if (FAILED(result) && IsWindowVisible(window) && !removal_warning_emitted) { - removal_warning_emitted = true; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Cannot remove the capture window from the taskbar (0x%08lx)", - (unsigned long)result); - } -} - -static void schedule_refresh(HWND window) { - register_messages(); - if (!window) return; - if (capture_refresh_message) - PostMessageW(window, capture_refresh_message, 0, 0); - SetTimer(window, BONGO_CAT_CAPTURE_REFRESH_TIMER, 250, NULL); -} - -bool bongo_cat_windows_capture_configure(HWND window) { - if (!window || !IsWindow(window)) return false; - register_messages(); - LONG_PTR style = 0; - if (!read_extended_style(window, &style)) { - if (!style_warning_emitted) { - style_warning_emitted = true; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Cannot read the window style required for OBS discovery"); - } - return false; - } - LONG_PTR next = (style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW; - if (next != style) { - bool shown = IsWindowVisible(window) != FALSE; - if (shown) ShowWindow(window, SW_HIDE); - if (write_extended_style(window, next)) { - SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); - refresh_transparency(window); - } - if (shown) { - ShowWindow(window, SW_SHOWNOACTIVATE); - UpdateWindow(window); - } - } - LONG_PTR applied = 0; - bool ready = read_extended_style(window, &applied) && - (applied & WS_EX_APPWINDOW) && !(applied & WS_EX_TOOLWINDOW); - if (!ready && !style_warning_emitted) { - style_warning_emitted = true; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Cannot configure the window for OBS discovery"); - } - refresh_taskbar(window); - schedule_refresh(window); - return ready; -} - -bool bongo_cat_windows_capture_handle_message( - HWND window, UINT message, WPARAM wparam) { - register_messages(); - if (capture_refresh_message && message == capture_refresh_message) { - refresh_taskbar(window); - return true; - } - if (message == WM_TIMER && - wparam == BONGO_CAT_CAPTURE_REFRESH_TIMER) { - KillTimer(window, BONGO_CAT_CAPTURE_REFRESH_TIMER); - refresh_taskbar(window); - return true; - } - if (taskbar_created_message && message == taskbar_created_message) - schedule_refresh(window); - return false; -} -#endif diff --git a/src/platform/windows_capture.h b/src/platform/windows_capture.h deleted file mode 100644 index 55596d30..00000000 --- a/src/platform/windows_capture.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_CAPTURE_H -#define BONGO_CAT_WINDOWS_CAPTURE_H - -#ifdef _WIN32 -#include -#include - -bool bongo_cat_windows_capture_configure(HWND window); -bool bongo_cat_windows_capture_handle_message( - HWND window, UINT message, WPARAM wparam); -#endif - -#endif diff --git a/src/platform/windows_direct_input.c b/src/platform/windows_direct_input.c deleted file mode 100644 index 58c349b6..00000000 --- a/src/platform/windows_direct_input.c +++ /dev/null @@ -1,155 +0,0 @@ -#include "windows_direct_input.h" -#include "bongo_cat/file.h" - -#ifdef _WIN32 -#ifndef DIRECTINPUT_VERSION -#define DIRECTINPUT_VERSION 0x0800 -#endif -#include -#include -#include -#include - -typedef struct BongoCatDirectInput { - LPDIRECTINPUT8A input; - LPDIRECTINPUTDEVICE8A mouse; - POINT absolute; - bool absolute_ready; - bool rebase_pending; - ULONGLONG last_read_ms; - FILE *audit; - unsigned long long reads; - unsigned long long reacquires; - unsigned long long failures; - long long total_x; - long long total_y; -} BongoCatDirectInput; - -void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform) { - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (!state) return; - if (state->mouse) { - state->mouse->lpVtbl->Unacquire(state->mouse); - state->mouse->lpVtbl->Release(state->mouse); - } - if (state->input) state->input->lpVtbl->Release(state->input); - if (state->audit) { - fprintf(state->audit, - "summary reads=%llu reacquires=%llu failures=%llu total_x=%lld total_y=%lld\n", - state->reads, state->reacquires, state->failures, - state->total_x, state->total_y); - fclose(state->audit); - } - free(state); - platform->relative_pointer = NULL; -} - -void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform) { - double x = 0.0, y = 0.0; - bongo_cat_platform_relative_pointer(platform, &x, &y); - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (state) state->rebase_pending = true; -} - -bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, - void *window) { - if (!platform || !window) return false; - BongoCatDirectInput *state = calloc(1, sizeof(*state)); - if (!state || FAILED(DirectInput8Create(GetModuleHandleW(NULL), - DIRECTINPUT_VERSION, &IID_IDirectInput8A, (void **)&state->input, - NULL))) { - free(state); - return false; - } - platform->relative_pointer = state; - HRESULT result = state->input->lpVtbl->CreateDevice(state->input, - &GUID_SysMouse, &state->mouse, NULL); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetCooperativeLevel( - state->mouse, (HWND)window, DISCL_NONEXCLUSIVE | DISCL_BACKGROUND); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetDataFormat( - state->mouse, &c_dfDIMouse); - DIPROPDWORD property = {{sizeof(property), sizeof(property.diph), - 0, DIPH_DEVICE}, 16}; - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->SetProperty( - state->mouse, DIPROP_BUFFERSIZE, &property.diph); - if (SUCCEEDED(result)) result = state->mouse->lpVtbl->Acquire(state->mouse); - if (SUCCEEDED(result)) { - state->absolute_ready = GetPhysicalCursorPos(&state->absolute) != FALSE; - state->rebase_pending = true; - state->last_read_ms = GetTickCount64(); - const char *audit_path = getenv("BONGO_CAT_DIRECT_INPUT_AUDIT_FILE"); - if (audit_path && audit_path[0]) { - state->audit = bongo_cat_file_open(audit_path, "wb"); - if (state->audit) { - fprintf(state->audit, "direct_input initialized=1\n"); - fflush(state->audit); - } - } - return true; - } - bongo_cat_windows_direct_input_destroy(platform); - return false; -} - -bool bongo_cat_platform_relative_pointer(BongoCatPlatform *platform, - double *x, double *y) { - BongoCatDirectInput *state = platform ? platform->relative_pointer : NULL; - if (!state || !state->mouse || !x || !y) return false; - ULONGLONG now_ms = GetTickCount64(); - bool stale = state->last_read_ms && now_ms - state->last_read_ms > 250; - state->last_read_ms = now_ms; - DIMOUSESTATE mouse = {0}; - POINT absolute = {0}; - bool absolute_ready = GetPhysicalCursorPos(&absolute) != FALSE; - bool fallback_ready = absolute_ready && state->absolute_ready; - LONG fallback_x = fallback_ready ? absolute.x - state->absolute.x : 0; - LONG fallback_y = fallback_ready ? absolute.y - state->absolute.y : 0; - if (absolute_ready) { - state->absolute = absolute; - state->absolute_ready = true; - } else state->absolute_ready = false; - HRESULT result = state->mouse->lpVtbl->GetDeviceState( - state->mouse, sizeof(mouse), &mouse); - bool reacquired = false; - if (FAILED(result)) { - result = state->mouse->lpVtbl->Acquire(state->mouse); - reacquired = SUCCEEDED(result); - if (reacquired) result = state->mouse->lpVtbl->GetDeviceState( - state->mouse, sizeof(mouse), &mouse); - } - state->reads++; - if (reacquired) state->reacquires++; - bool sample_ready = SUCCEEDED(result); - bool available = sample_ready || fallback_ready; - bool rebase = state->rebase_pending || stale || reacquired || !sample_ready; - bool use_fallback = !stale && fallback_ready && (!sample_ready || - ((mouse.lX == 0 && mouse.lY == 0) && - (fallback_x != 0 || fallback_y != 0))); - if (rebase) { - *x = 0.0; - *y = 0.0; - } else if (sample_ready && !use_fallback) { - *x = mouse.lX; - *y = mouse.lY; - } else if (fallback_ready) { - *x = fallback_x; - *y = fallback_y; - } else { - *x = 0.0; - *y = 0.0; - } - state->total_x += (long long)*x; - state->total_y += (long long)*y; - state->rebase_pending = !available; - if (!sample_ready) state->failures++; - if (state->audit) { - fprintf(state->audit, - "read=%llu result=0x%08lx reacquired=%d stale=%d rebase=%d fallback=%d dx=%.0f dy=%.0f total_x=%lld total_y=%lld\n", - state->reads, (unsigned long)result, reacquired, stale, rebase, - use_fallback, *x, *y, - state->total_x, state->total_y); - fflush(state->audit); - } - return available && !rebase; -} -#endif diff --git a/src/platform/windows_direct_input.h b/src/platform/windows_direct_input.h deleted file mode 100644 index 6457a8c6..00000000 --- a/src/platform/windows_direct_input.h +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_DIRECT_INPUT_H -#define BONGO_CAT_WINDOWS_DIRECT_INPUT_H - -#include "bongo_cat/platform.h" - -bool bongo_cat_windows_direct_input_create(BongoCatPlatform *platform, - void *window); -void bongo_cat_windows_direct_input_destroy(BongoCatPlatform *platform); -void bongo_cat_windows_direct_input_reset(BongoCatPlatform *platform); - -#endif diff --git a/src/platform/windows_input.c b/src/platform/windows_input.c deleted file mode 100644 index 846ebf08..00000000 --- a/src/platform/windows_input.c +++ /dev/null @@ -1,183 +0,0 @@ -#include "windows_input.h" -#include "windows_keys.h" - -#ifdef _WIN32 -#include -#include -#include -#include - -typedef struct WindowsInputState { - BongoCatPlatform *platform; - SRWLOCK platform_lock; - HANDLE thread; - HANDLE stop; - HHOOK keyboard; - HHOOK mouse; - BongoCatWindowsKeyboard keyboard_state; - UINT test_drop_key_up; - DWORD test_start_delay_ms; -} WindowsInputState; - -static WindowsInputState *global_state; - -static void wake_main_thread(WindowsInputState *state) { - if (!state || !state->platform) return; - SDL_Event wake = {0}; - wake.type = state->platform->wake_event_type; - SDL_PushEvent(&wake); -} - -static void push_event(BongoCatInputKind kind, const char *name, float value) { - WindowsInputState *state = global_state; - if (!state || !name) return; - AcquireSRWLockShared(&state->platform_lock); - BongoCatPlatform *platform = state->platform; - if (!platform) { - ReleaseSRWLockShared(&state->platform_lock); - return; - } - BongoCatInputEvent event = {0}; - event.kind = kind; - event.timestamp_ms = GetTickCount64(); - event.value = value; - snprintf(event.name, sizeof(event.name), "%s", name); - if (bongo_cat_input_push(platform->input, &event)) wake_main_thread(state); - ReleaseSRWLockShared(&state->platform_lock); -} - -static void emit_key(bool down, const char *name, void *userdata) { - (void)userdata; - push_event(down ? BONGO_CAT_INPUT_KEY_DOWN : BONGO_CAT_INPUT_KEY_UP, - name, down ? 1.0f : 0.0f); -} - -static LRESULT CALLBACK keyboard_hook(int code, WPARAM message, LPARAM data) { - WindowsInputState *state = global_state; - if (code == HC_ACTION && state) bongo_cat_windows_keyboard_event( - &state->keyboard_state, (const KBDLLHOOKSTRUCT *)data, message, - &state->test_drop_key_up, emit_key, NULL); - return CallNextHookEx(NULL, code, message, data); -} - -static const char *mouse_button(WPARAM message, DWORD mouse_data) { - switch (message) { - case WM_LBUTTONDOWN: case WM_LBUTTONUP: return "Left"; - case WM_RBUTTONDOWN: case WM_RBUTTONUP: return "Right"; - case WM_MBUTTONDOWN: case WM_MBUTTONUP: return "Middle"; - case WM_XBUTTONDOWN: case WM_XBUTTONUP: - return HIWORD(mouse_data) == XBUTTON1 ? "Back" : "Forward"; - default: return NULL; - } -} - -static LRESULT CALLBACK mouse_hook(int code, WPARAM message, LPARAM data) { - WindowsInputState *state = global_state; - if (code == HC_ACTION && state) { - const MSLLHOOKSTRUCT *mouse = (const MSLLHOOKSTRUCT *)data; - if (message == WM_MOUSEMOVE) { - AcquireSRWLockShared(&state->platform_lock); - BongoCatPlatform *platform = state->platform; - if (platform && bongo_cat_input_mouse(platform->input, - mouse->pt.x, mouse->pt.y)) wake_main_thread(state); - ReleaseSRWLockShared(&state->platform_lock); - } else { - const char *name = mouse_button(message, mouse->mouseData); - bool down = message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN || - message == WM_MBUTTONDOWN || message == WM_XBUTTONDOWN; - if (name) push_event(down ? BONGO_CAT_INPUT_MOUSE_DOWN : - BONGO_CAT_INPUT_MOUSE_UP, name, down ? 1.0f : 0.0f); - } - } - return CallNextHookEx(NULL, code, message, data); -} - -static void dispatch_messages(void) { - MSG message; - while (PeekMessageW(&message, NULL, 0, 0, PM_REMOVE)) { - TranslateMessage(&message); - DispatchMessageW(&message); - } -} - -static DWORD WINAPI input_thread(void *context) { - WindowsInputState *state = context; - if (state->test_start_delay_ms && WaitForSingleObject(state->stop, - state->test_start_delay_ms) == WAIT_OBJECT_0) return 0; - if (WaitForSingleObject(state->stop, 0) == WAIT_OBJECT_0) return 0; - global_state = state; - state->keyboard = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_hook, NULL, 0); - state->mouse = SetWindowsHookExW(WH_MOUSE_LL, mouse_hook, NULL, 0); - if (!state->keyboard || !state->mouse) SDL_LogWarn( - SDL_LOG_CATEGORY_APPLICATION, - "Global input hooks are unavailable; global input may be incomplete"); - ULONGLONG last_reconcile_ms = GetTickCount64(); - for (;;) { - DWORD wait = MsgWaitForMultipleObjects(1, &state->stop, FALSE, 25, - QS_ALLINPUT); - if (wait == WAIT_OBJECT_0) break; - if (wait == WAIT_OBJECT_0 + 1) dispatch_messages(); - else if (wait == WAIT_FAILED) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Windows input thread wait failed"); - break; - } - ULONGLONG now_ms = GetTickCount64(); - if (now_ms - last_reconcile_ms >= 25) { - bongo_cat_windows_keyboard_reconcile(&state->keyboard_state, - now_ms, emit_key, NULL); - last_reconcile_ms = now_ms; - } - } - if (state->keyboard) UnhookWindowsHookEx(state->keyboard); - if (state->mouse) UnhookWindowsHookEx(state->mouse); - global_state = NULL; - return 0; -} - -bool bongo_cat_windows_input_start(BongoCatPlatform *platform) { - if (!platform || SDL_getenv("BONGO_CAT_TEST_HOOK_FAILURE")) return false; - WindowsInputState *state = calloc(1, sizeof(*state)); - if (!state) return false; - InitializeSRWLock(&state->platform_lock); - state->platform = platform; - const char *drop_key_up = SDL_getenv("BONGO_CAT_TEST_DROP_KEY_UP"); - if (drop_key_up) state->test_drop_key_up = - (UINT)strtoul(drop_key_up, NULL, 10); - const char *start_delay = SDL_getenv("BONGO_CAT_TEST_HOOK_DELAY_MS"); - if (start_delay) { - unsigned long delay = strtoul(start_delay, NULL, 10); - state->test_start_delay_ms = delay > 10000 ? 10000 : (DWORD)delay; - } - state->stop = CreateEventW(NULL, TRUE, FALSE, NULL); - state->thread = state->stop ? - CreateThread(NULL, 0, input_thread, state, 0, NULL) : NULL; - if (!state->stop || !state->thread) { - if (state->thread) CloseHandle(state->thread); - if (state->stop) CloseHandle(state->stop); - free(state); - return false; - } - platform->native = state; - return true; -} - -void bongo_cat_windows_input_stop(BongoCatPlatform *platform) { - WindowsInputState *state = platform ? platform->native : NULL; - if (!state) return; - AcquireSRWLockExclusive(&state->platform_lock); - state->platform = NULL; - ReleaseSRWLockExclusive(&state->platform_lock); - SetEvent(state->stop); - if (WaitForSingleObject(state->thread, 3000) != WAIT_OBJECT_0) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Windows input thread did not stop; its state will remain isolated until exit"); - platform->native = NULL; - return; - } - CloseHandle(state->thread); - CloseHandle(state->stop); - free(state); - platform->native = NULL; -} -#endif diff --git a/src/platform/windows_input.h b/src/platform/windows_input.h deleted file mode 100644 index 8dfed282..00000000 --- a/src/platform/windows_input.h +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_INPUT_H -#define BONGO_CAT_WINDOWS_INPUT_H - -#include "bongo_cat/platform.h" - -#ifdef _WIN32 -bool bongo_cat_windows_input_start(BongoCatPlatform *platform); -void bongo_cat_windows_input_stop(BongoCatPlatform *platform); -#endif - -#endif diff --git a/src/platform/windows_keys.h b/src/platform/windows_keys.h deleted file mode 100644 index db49f564..00000000 --- a/src/platform/windows_keys.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_KEYS_H -#define BONGO_CAT_WINDOWS_KEYS_H - -#ifdef _WIN32 -#include "bongo_cat/input.h" -#include - -typedef struct BongoCatWindowsKeyboard { - bool down[BONGO_CAT_INPUT_KEY_STATE_CAP]; - uint64_t changed_ms[BONGO_CAT_INPUT_KEY_STATE_CAP]; - char name[BONGO_CAT_INPUT_KEY_STATE_CAP][BONGO_CAT_ID_CAP]; -} BongoCatWindowsKeyboard; - -typedef void (*BongoCatWindowsKeyEmit)(bool down, const char *name, - void *userdata); - -const char *bongo_cat_windows_key_name(const KBDLLHOOKSTRUCT *key, char output[16]); -bool bongo_cat_windows_keyboard_event(BongoCatWindowsKeyboard *state, - const KBDLLHOOKSTRUCT *key, WPARAM message, UINT *drop_key_up, - BongoCatWindowsKeyEmit emit, void *userdata); -void bongo_cat_windows_keyboard_reconcile(BongoCatWindowsKeyboard *state, - uint64_t now_ms, BongoCatWindowsKeyEmit emit, void *userdata); -#endif - -#endif diff --git a/src/platform/windows_menu.c b/src/platform/windows_menu.c deleted file mode 100644 index de5aa622..00000000 --- a/src/platform/windows_menu.c +++ /dev/null @@ -1,114 +0,0 @@ -#include "bongo_cat/platform.h" -#include "windows_borderless.h" -#include "windows_popup.h" -#include "../ui/ui_native_theme.h" - -#ifdef _WIN32 -#include -#include -#include -#include - -static HWND native_window(BongoCatPlatform *platform) { - return (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(platform->window), - SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); -} - -static wchar_t *wide(const char *text) { - if (!text) return NULL; - int length = MultiByteToWideChar(CP_UTF8, 0, text, -1, NULL, 0); - wchar_t *value = length > 0 ? calloc((size_t)length, sizeof(*value)) : NULL; - if (value) MultiByteToWideChar(CP_UTF8, 0, text, -1, value, length); - return value; -} - -static void menu_text(HMENU menu, UINT flags, UINT_PTR id, const char *text) { - wchar_t *label = wide(text); - AppendMenuW(menu, flags, id, label ? label : L""); - free(label); -} - -static void destroy_unattached(HMENU menu, HMENU sizes, HMENU opacity, - HMENU models, HMENU motions, HMENU expressions) { - if (menu) DestroyMenu(menu); - if (sizes) DestroyMenu(sizes); - if (opacity) DestroyMenu(opacity); - if (models) DestroyMenu(models); - if (motions) DestroyMenu(motions); - if (expressions) DestroyMenu(expressions); -} - -BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, - const BongoCatMenuLabels *labels) { - if (!platform || !labels) return BONGO_CAT_MENU_NONE; - HMENU menu = CreatePopupMenu(), sizes = CreatePopupMenu(), opacity = CreatePopupMenu(); - HMENU models = CreatePopupMenu(); - HMENU motions = labels->motion_count ? CreatePopupMenu() : NULL; - HMENU expressions = labels->expression_count ? CreatePopupMenu() : NULL; - if (!menu || !sizes || !opacity || !models || - (labels->motion_count && !motions) || - (labels->expression_count && !expressions)) { - destroy_unattached(menu, sizes, opacity, models, motions, expressions); - return BONGO_CAT_MENU_NONE; - } - menu_text(menu, MF_STRING, BONGO_CAT_MENU_PREFERENCES, labels->preferences); - menu_text(menu, MF_STRING, BONGO_CAT_MENU_HIDE, labels->hide); - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - menu_text(menu, MF_STRING | (labels->pass_through_checked ? MF_CHECKED : 0), - BONGO_CAT_MENU_PASS_THROUGH, labels->pass_through); - menu_text(menu, MF_STRING | (labels->always_on_top_checked ? MF_CHECKED : 0), - BONGO_CAT_MENU_ALWAYS_ON_TOP, labels->always_on_top); - for (int i = 0; i < 16; ++i) { - int scale = 50 + i * 10; - wchar_t label[16]; swprintf(label, 16, L"%d%%", scale); - UINT flags = MF_STRING | (SDL_fabsf(labels->scale_percent - scale) < .5f ? MF_CHECKED : 0); - AppendMenuW(sizes, flags, BONGO_CAT_MENU_SCALE_50 + i, label); - } - menu_text(sizes, MF_STRING | MF_DISABLED | MF_GRAYED, 0, - labels->wheel_size_hint); - const int opacities[] = {10,20,30,40,50,60,70,80,90,100}; - for (int i = 0; i < 10; ++i) { - wchar_t label[16]; swprintf(label, 16, L"%d%%", opacities[i]); - UINT flags = MF_STRING | (SDL_fabsf(labels->opacity_percent - opacities[i]) < .5f ? MF_CHECKED : 0); - AppendMenuW(opacity, flags, BONGO_CAT_MENU_OPACITY_10 + i, label); - } - menu_text(opacity, MF_STRING | MF_DISABLED | MF_GRAYED, 0, - labels->wheel_opacity_hint); - for (size_t i = 0; i < labels->motion_count; ++i) - menu_text(motions, MF_STRING, BONGO_CAT_MENU_MOTION_FIRST + i, - labels->motion_names[i]); - for (size_t i = 0; i < labels->expression_count; ++i) - menu_text(expressions, MF_STRING | (i == labels->current_expression ? MF_CHECKED : 0), - BONGO_CAT_MENU_EXPRESSION_FIRST + i, labels->expression_names[i]); - for (size_t i = 0; i < labels->model_count; ++i) - menu_text(models, MF_STRING | (i == labels->current_model ? MF_CHECKED : 0), - BONGO_CAT_MENU_MODEL_FIRST + i, labels->model_names[i]); - if (labels->model_count) AppendMenuW(models, MF_SEPARATOR, 0, NULL); - menu_text(models, MF_STRING, BONGO_CAT_MENU_MODEL_ADD, labels->add_model); - wchar_t *size_label = wide(labels->window_size), *opacity_label = wide(labels->opacity); - wchar_t *model_label = wide(labels->model), *motion_label = wide(labels->motion); - wchar_t *expression_label = wide(labels->expression); - AppendMenuW(menu, MF_POPUP, (UINT_PTR)sizes, size_label ? size_label : L""); - AppendMenuW(menu, MF_POPUP, (UINT_PTR)opacity, opacity_label ? opacity_label : L""); - if (labels->motion_count) AppendMenuW(menu, MF_POPUP, (UINT_PTR)motions, - motion_label ? motion_label : L""); - if (labels->expression_count) AppendMenuW(menu, MF_POPUP, (UINT_PTR)expressions, - expression_label ? expression_label : L""); - AppendMenuW(menu, MF_POPUP, (UINT_PTR)models, model_label ? model_label : L""); - free(size_label); free(opacity_label); free(model_label); free(motion_label); - free(expression_label); - AppendMenuW(menu, MF_SEPARATOR, 0, NULL); - menu_text(menu, MF_STRING, BONGO_CAT_MENU_EXIT, labels->exit); - POINT point; GetCursorPos(&point); - HWND window = native_window(platform); - bongo_cat_ui_native_menu_prepare(platform->window, labels->dark_theme); - bongo_cat_windows_menu_preview(window, labels->preview, - labels->preview_tick, labels->preview_userdata); - UINT command = bongo_cat_windows_popup_track(window, menu, - TPM_RETURNCMD | TPM_RIGHTBUTTON, point.x, point.y); - bongo_cat_windows_menu_preview(window, NULL, NULL, NULL); - if (labels->restore) labels->restore(labels->preview_userdata, (BongoCatMenuAction)command); - DestroyMenu(menu); - return (BongoCatMenuAction)command; -} -#endif diff --git a/src/platform/windows_startup.c b/src/platform/windows_startup.c deleted file mode 100644 index e35d1e61..00000000 --- a/src/platform/windows_startup.c +++ /dev/null @@ -1,129 +0,0 @@ -#include "bongo_cat/platform.h" -#include "windows_startup.h" - -#ifdef _WIN32 -#include -#include -#include -#include - -static HANDLE instance_mutex; -static HANDLE instance_wake_event; -static wchar_t instance_title[96] = BONGO_CAT_PET_WINDOW_TITLE_W; -static wchar_t instance_mutex_name[128] = L"Local\\BongoCat.SingleInstance"; -static wchar_t instance_wake_name[128] = L"Local\\BongoCat.WakeInstance"; -static bool identity_ready; - -static bool safe_identity(const char *value) { - if (!value || !value[0] || strlen(value) > 32) return false; - for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) - if (!isalnum(*cursor) && *cursor != '-' && *cursor != '_') return false; - return true; -} - -static void initialize_identity(void) { - if (identity_ready) return; - identity_ready = true; - const char *value = SDL_getenv_unsafe("BONGO_CAT_TEST_INSTANCE_ID"); - if (!safe_identity(value)) return; - swprintf(instance_title, sizeof(instance_title) / sizeof(instance_title[0]), - L"%ls [%hs]", BONGO_CAT_PET_WINDOW_TITLE_W, value); - swprintf(instance_mutex_name, - sizeof(instance_mutex_name) / sizeof(instance_mutex_name[0]), - L"Local\\BongoCat.SingleInstance.%hs", value); - swprintf(instance_wake_name, - sizeof(instance_wake_name) / sizeof(instance_wake_name[0]), - L"Local\\BongoCat.WakeInstance.%hs", value); -} - -const wchar_t *bongo_cat_windows_instance_title(void) { - initialize_identity(); return instance_title; -} - -static void create_wake_event(void) { - if (!instance_wake_event) - instance_wake_event = CreateEventW(NULL, FALSE, FALSE, instance_wake_name); -} - -static void wake_existing_instance(void) { - for (int attempt = 0; attempt < 30; ++attempt) { - HANDLE wake = OpenEventW(EVENT_MODIFY_STATE, FALSE, instance_wake_name); - if (wake) { - bool signaled = SetEvent(wake) != FALSE; - CloseHandle(wake); - if (signaled) return; - } - HWND existing = FindWindowW(NULL, instance_title); - if (existing) { - ShowWindowAsync(existing, IsIconic(existing) ? SW_RESTORE : SW_SHOW); - SetForegroundWindow(existing); - return; - } - Sleep(100); - } -} - -bool bongo_cat_platform_single_instance_begin(void) { - initialize_identity(); - if (SDL_getenv_unsafe("BONGO_CAT_ALLOW_TEST_INSTANCES")) return true; - instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); - if (!instance_mutex) return true; - if (GetLastError() != ERROR_ALREADY_EXISTS) { - create_wake_event(); return true; - } - wake_existing_instance(); - CloseHandle(instance_mutex); instance_mutex = NULL; - instance_mutex = CreateMutexW(NULL, FALSE, instance_mutex_name); - if (instance_mutex && GetLastError() != ERROR_ALREADY_EXISTS) { - create_wake_event(); return true; - } - if (instance_mutex) CloseHandle(instance_mutex); - instance_mutex = NULL; - return false; -} - -bool bongo_cat_platform_single_instance_take_wake(void) { - return instance_wake_event && - WaitForSingleObject(instance_wake_event, 0) == WAIT_OBJECT_0; -} - -void bongo_cat_platform_single_instance_end(void) { - if (instance_wake_event) CloseHandle(instance_wake_event); - instance_wake_event = NULL; - if (instance_mutex) CloseHandle(instance_mutex); - instance_mutex = NULL; -} - -BongoCatResult bongo_cat_platform_set_autostart(bool enabled, - BongoCatError *error) { - HKEY key; - LONG result = RegCreateKeyExW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, NULL, 0, - KEY_SET_VALUE, NULL, &key, NULL); - if (result != ERROR_SUCCESS) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot open autostart registry key"); - return BONGO_CAT_ERROR_PLATFORM; - } - if (enabled) { - wchar_t executable[BONGO_CAT_PATH_CAP]; - DWORD length = GetModuleFileNameW(NULL, executable, BONGO_CAT_PATH_CAP); - if (!length || length >= BONGO_CAT_PATH_CAP) { - RegCloseKey(key); bongo_cat_error_set(error, - BONGO_CAT_ERROR_PLATFORM, "Cannot determine executable path"); - return BONGO_CAT_ERROR_PLATFORM; - } - wchar_t command[BONGO_CAT_PATH_CAP + 20]; - swprintf(command, BONGO_CAT_PATH_CAP + 20, L"\"%ls\" --autostart", executable); - result = RegSetValueExW(key, BONGO_CAT_NAME_W, 0, REG_SZ, - (const BYTE *)command, (DWORD)((wcslen(command) + 1) * sizeof(wchar_t))); - } else result = RegDeleteValueW(key, BONGO_CAT_NAME_W); - RegCloseKey(key); - if (result != ERROR_SUCCESS && result != ERROR_FILE_NOT_FOUND) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Cannot update autostart setting"); - return BONGO_CAT_ERROR_PLATFORM; - } - return BONGO_CAT_OK; -} -#endif diff --git a/src/platform/windows_startup.h b/src/platform/windows_startup.h deleted file mode 100644 index caaea18a..00000000 --- a/src/platform/windows_startup.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef BONGO_CAT_WINDOWS_STARTUP_H -#define BONGO_CAT_WINDOWS_STARTUP_H - -#ifdef _WIN32 -#include - -const wchar_t *bongo_cat_windows_instance_title(void); -#endif - -#endif diff --git a/src/render/mver_pointer_overlay.c b/src/render/mver_pointer_overlay.c index 1b3a874e..384a2f13 100644 --- a/src/render/mver_pointer_overlay.c +++ b/src/render/mver_pointer_overlay.c @@ -1,6 +1,7 @@ #include "mver_pointer_overlay_internal.h" #include "bongo_cat/image.h" #include "bongo_cat/json.h" +#include "bongo_cat/model.h" #include "bongo_cat/path.h" #include @@ -72,6 +73,10 @@ void bongo_cat_mver_pointer_overlay_destroy(BongoCatMverPointerOverlay *value) { free(value); } +void bongo_cat_mver_pointer_overlay_clear(BongoCatMverPointerOverlay *value) { + if (value) clear_textures(value); +} + static bool safe_relative(const char *path) { if (!path || !path[0] || path[0] == '/' || path[0] == '\\' || strchr(path, ':')) return false; @@ -117,8 +122,8 @@ bool bongo_cat_mver_pointer_overlay_load(BongoCatMverPointerOverlay *value, if (!value || !directory) return false; clear_textures(value); char metadata[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(metadata, sizeof(metadata), directory, - ".bongo-cat-mver.json")) return false; + if (!bongo_cat_model_adapter_metadata_path(directory, metadata, + sizeof(metadata))) return false; yyjson_doc *document = bongo_cat_json_read_file(metadata, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *pointer = yyjson_obj_get(root, "standardPointer"); diff --git a/src/render/mver_pointer_overlay.h b/src/render/mver_pointer_overlay.h index 206eccc8..5fff103c 100644 --- a/src/render/mver_pointer_overlay.h +++ b/src/render/mver_pointer_overlay.h @@ -8,6 +8,7 @@ typedef struct BongoCatMverPointerOverlay BongoCatMverPointerOverlay; BongoCatMverPointerOverlay *bongo_cat_mver_pointer_overlay_create( BongoCatError *error); void bongo_cat_mver_pointer_overlay_destroy(BongoCatMverPointerOverlay *value); +void bongo_cat_mver_pointer_overlay_clear(BongoCatMverPointerOverlay *value); bool bongo_cat_mver_pointer_overlay_load(BongoCatMverPointerOverlay *value, const char *directory, BongoCatError *error); bool bongo_cat_mver_pointer_overlay_enabled( diff --git a/src/render/overlay.c b/src/render/overlay.c index 757500c8..a8e738cd 100644 --- a/src/render/overlay.c +++ b/src/render/overlay.c @@ -1,52 +1,17 @@ -#include "bongo_cat/overlay.h" -#include "bongo_cat/gl_api.h" -#include "bongo_cat/image.h" -#include "bongo_cat/path.h" -#include "mver_pointer_overlay.h" -#include -#include -#include -#include +#include "overlay_internal.h" -typedef struct TextureSlot { - char path[BONGO_CAT_PATH_CAP]; - GLuint texture; - uint64_t used; -} TextureSlot; -struct BongoCatOverlay { - BongoCatGL gl; - BongoCatMverPointerOverlay *mver_pointer; - GLuint program; - GLint mirror_location; - GLint image_location; - GLint erase_left_location; - GLint erase_right_location; - GLuint vao; - GLuint vbo; - GLuint background; - GLuint composite; - bool composed_cover; - bool clean_paws; - bool composite_dirty; - TextureSlot cache[4]; - GLuint left; - GLuint right; - GLuint effect; - char left_name[BONGO_CAT_ID_CAP]; - char right_name[BONGO_CAT_ID_CAP]; - char background_path[BONGO_CAT_PATH_CAP]; - char left_path[BONGO_CAT_PATH_CAP]; - char right_path[BONGO_CAT_PATH_CAP]; - char effect_path[BONGO_CAT_PATH_CAP]; - char directory[BONGO_CAT_PATH_CAP]; - uint64_t clock; -}; +#include static const char *vertex_source = "#version 330 core\n" "layout(location=0) in vec2 pos; layout(location=1) in vec2 uv;\n" "out vec2 tex; uniform bool mirror;\n" - "void main(){float x=mirror?-pos.x:pos.x;gl_Position=vec4(x,pos.y,0,1);tex=uv;}"; + "uniform sampler2D image; uniform int reference_width,reference_height;\n" + "void main(){vec2 p=pos;" + "if(reference_width>0&&reference_height>0){" + "vec2 extent=vec2(textureSize(image,0))/vec2(reference_width,reference_height);" + "p=(p+vec2(1,-1))*extent+vec2(-1,1);}" + "if(mirror)p.x=-p.x;gl_Position=vec4(p,0,1);tex=uv;}"; static const char *fragment_source = "#version 330 core\n" "in vec2 tex; out vec4 color; uniform sampler2D image;\n" @@ -56,23 +21,6 @@ static const char *fragment_source = "vec2 dr=(tex-vec2(.275,.397))/vec2(.070,.160);" "bool l=dot(dl,dl)<1.;bool r=dot(dr,dr)<1.;" "if((erase_left&&l)||(erase_right&&r))color.rgb=vec3(1);color.rgb*=color.a;}"; -static void clear_textures(BongoCatOverlay *value) { - if (value->background) glDeleteTextures(1, &value->background); - if (value->composite) glDeleteTextures(1, &value->composite); - value->background = 0; - value->composite = 0; - value->clean_paws = false; - value->composed_cover = false; - for (size_t i = 0; i < 4; ++i) { - if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); - memset(&value->cache[i], 0, sizeof(value->cache[i])); - } - value->left = value->right = 0; - value->effect = 0; - value->left_name[0] = value->right_name[0] = '\0'; - value->left_path[0] = value->right_path[0] = '\0'; - value->effect_path[0] = '\0'; -} BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { BongoCatOverlay *value = calloc(1, sizeof(*value)); @@ -84,6 +32,10 @@ BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { if (!value->program) { free(value); return NULL; } value->mirror_location = value->gl.uniform_location(value->program, "mirror"); value->image_location = value->gl.uniform_location(value->program, "image"); + value->reference_width_location = value->gl.uniform_location(value->program, + "reference_width"); + value->reference_height_location = value->gl.uniform_location(value->program, + "reference_height"); value->erase_left_location = value->gl.uniform_location(value->program, "erase_left"); value->erase_right_location = value->gl.uniform_location(value->program, "erase_right"); const float vertices[] = {-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0}; @@ -108,193 +60,31 @@ BongoCatOverlay *bongo_cat_overlay_create(BongoCatError *error) { void bongo_cat_overlay_destroy(BongoCatOverlay *value) { if (!value) return; bongo_cat_mver_pointer_overlay_destroy(value->mver_pointer); - clear_textures(value); + bongo_cat_overlay_clear_textures(value); if (value->vbo) value->gl.delete_buffers(1, &value->vbo); if (value->vao) value->gl.delete_vertex_arrays(1, &value->vao); if (value->program) value->gl.delete_program(value->program); free(value); } -BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, const char *directory, BongoCatError *error) { - if (!value || !directory) return BONGO_CAT_ERROR_ARGUMENT; - clear_textures(value); - snprintf(value->directory, sizeof(value->directory), "%s", directory); - char path[BONGO_CAT_PATH_CAP]; - /* Without the licensed Cubism runtime there is no model renderer. Use the - model's composed preview so the desktop pet remains visually complete; - Cubism builds keep the background-only layer behind the animated model. */ -#ifdef BONGO_CAT_HAS_CUBISM - bongo_cat_path_join(path, sizeof(path), directory, "resources/background.png"); -#else - bongo_cat_path_join(path, sizeof(path), directory, "resources/cover.png"); - value->composed_cover = true; - value->clean_paws = true; -#endif - snprintf(value->background_path, sizeof(value->background_path), "%s", path); - if (bongo_cat_path_is_file(path)) value->background = bongo_cat_image_texture(path, NULL, NULL, error); - return bongo_cat_mver_pointer_overlay_load(value->mver_pointer, - directory, error) ? BONGO_CAT_OK : BONGO_CAT_ERROR_IO; -} - -#ifdef BONGO_CAT_HAS_CUBISM -static GLuint cached_texture(BongoCatOverlay *value, const char *path) { - value->clock++; - TextureSlot *oldest = NULL; - for (size_t i = 0; i < 4; ++i) { - TextureSlot *slot = &value->cache[i]; - if (slot->texture && strcmp(slot->path, path) == 0) { - slot->used = value->clock; - return slot->texture; - } - if (slot->texture && - (slot->texture == value->left || slot->texture == value->right || - slot->texture == value->effect)) continue; - if (!oldest || !slot->texture || slot->used < oldest->used) oldest = slot; - } - if (!oldest) return 0; - if (oldest->texture) glDeleteTextures(1, &oldest->texture); - BongoCatError ignored = {0}; - oldest->texture = bongo_cat_image_texture(path, NULL, NULL, &ignored); - snprintf(oldest->path, sizeof(oldest->path), "%s", path); - oldest->used = value->clock; - return oldest->texture; -} -#endif - -static bool key_path(BongoCatOverlay *value, const char *group, const char *name, - char path[BONGO_CAT_PATH_CAP]) { - char relative[BONGO_CAT_PATH_CAP]; - snprintf(relative, sizeof(relative), "resources/%s/%s.png", group, name); - bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); - if (bongo_cat_path_is_file(path)) return true; - if (name[0] == 'F' && name[1] >= '0' && name[1] <= '9') { - snprintf(relative, sizeof(relative), "resources/%s/Fn.png", group); - bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); - return bongo_cat_path_is_file(path); - } - return false; -} - -int bongo_cat_overlay_key(BongoCatOverlay *value, const char *name, bool pressed) { - if (!value || !name) return -1; - bool right; - char path[BONGO_CAT_PATH_CAP]; - if (key_path(value, "right-keys", name, path)) right = true; - else if (key_path(value, "left-keys", name, path)) right = false; - else return -1; - char *active_name = right ? value->right_name : value->left_name; - GLuint *active = right ? &value->right : &value->left; - char *active_path = right ? value->right_path : value->left_path; - if (pressed) { - snprintf(active_name, BONGO_CAT_ID_CAP, "%s", name); -#ifdef BONGO_CAT_HAS_CUBISM - *active = cached_texture(value, path); -#else - snprintf(active_path, BONGO_CAT_PATH_CAP, "%s", path); - *active = 1; -#endif - } else if (strcmp(active_name, name) == 0) { - active_name[0] = '\0'; - *active = 0; - active_path[0] = '\0'; - } -#ifndef BONGO_CAT_HAS_CUBISM - value->composite_dirty = true; -#endif - return right ? 1 : 0; -} - -bool bongo_cat_overlay_hand_active(const BongoCatOverlay *value, bool right) { - return value && (right ? value->right : value->left) != 0; +void bongo_cat_overlay_clear(BongoCatOverlay *value) { + if (!value) return; + bongo_cat_overlay_clear_textures(value); + bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); + value->directory[0] = '\0'; } bool bongo_cat_overlay_mver_pointer_enabled(const BongoCatOverlay *value) { - return value && bongo_cat_mver_pointer_overlay_enabled(value->mver_pointer); + return value && !value->model_pointer_preferred && + bongo_cat_mver_pointer_overlay_enabled(value->mver_pointer); } bool bongo_cat_overlay_mver_pointer_left_handed(const BongoCatOverlay *value) { - return value && bongo_cat_mver_pointer_overlay_left_handed(value->mver_pointer); + return bongo_cat_overlay_mver_pointer_enabled(value) && + bongo_cat_mver_pointer_overlay_left_handed(value->mver_pointer); } void bongo_cat_overlay_set_mver_pointer(BongoCatOverlay *value, float x_ratio, float y_ratio, bool left, bool right, bool side) { - if (value) bongo_cat_mver_pointer_overlay_set(value->mver_pointer, - x_ratio, y_ratio, left, right, side); -} -bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { - if (!value) return false; - value->effect = 0; - value->effect_path[0] = '\0'; - if (!path || !*path) return true; - if (!bongo_cat_path_is_file(path)) return false; -#ifdef BONGO_CAT_HAS_CUBISM - value->effect = cached_texture(value, path); -#else - value->effect = 1; -#endif - snprintf(value->effect_path, sizeof(value->effect_path), "%s", path); - return value->effect != 0; -} - -static void draw(BongoCatOverlay *value, GLuint texture, bool mirror, bool blend) { - if (!value || !texture) return; - if (blend) { - glEnable(GL_BLEND); - value->gl.blend_func_separate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, - GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - } else glDisable(GL_BLEND); - value->gl.use_program(value->program); - value->gl.uniform_1i(value->mirror_location, mirror); - value->gl.uniform_1i(value->image_location, 0); - value->gl.uniform_1i(value->erase_left_location, - !blend && value->composed_cover && !value->composite && value->left != 0); - value->gl.uniform_1i(value->erase_right_location, - !blend && value->composed_cover && !value->composite && value->right != 0); - value->gl.active_texture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, texture); - value->gl.bind_vertex_array(value->vao); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - value->gl.bind_vertex_array(0); -} - -void bongo_cat_overlay_draw_background(BongoCatOverlay *value, bool mirror) { - if (value) { -#ifndef BONGO_CAT_HAS_CUBISM - if (value->composite_dirty) { - value->composite_dirty = false; - if (value->left || value->right) { - BongoCatError ignored = {0}; - value->composite = bongo_cat_image_composite_texture(value->background_path, - value->left_path, value->right_path, value->composite, - value->clean_paws && value->left, - value->clean_paws && value->right, &ignored); - } - } -#endif - bool active = value->left || value->right; - draw(value, active && value->composite ? value->composite : value->background, - mirror, false); - } -} - -void bongo_cat_overlay_draw_keys(BongoCatOverlay *value, bool mirror) { - if (!value) return; -#ifdef BONGO_CAT_HAS_CUBISM - draw(value, value->left, mirror, true); - draw(value, value->right, mirror, true); -#else - (void)mirror; -#endif -} -void bongo_cat_overlay_draw_pointer_before_keys(BongoCatOverlay *value) { - if (value) bongo_cat_mver_pointer_overlay_draw_before_keys(value->mver_pointer); -} -void bongo_cat_overlay_draw_effect(BongoCatOverlay *value, bool mirror) { - if (!value) return; -#ifdef BONGO_CAT_HAS_CUBISM - draw(value, value->effect, mirror, true); -#else - (void)mirror; -#endif -} -void bongo_cat_overlay_draw_pointer_after_keys(BongoCatOverlay *value) { - if (value) bongo_cat_mver_pointer_overlay_draw_after_keys(value->mver_pointer); + if (bongo_cat_overlay_mver_pointer_enabled(value)) + bongo_cat_mver_pointer_overlay_set(value->mver_pointer, x_ratio, + y_ratio, left, right, side); } diff --git a/src/render/overlay_assets.c b/src/render/overlay_assets.c new file mode 100644 index 00000000..658985a1 --- /dev/null +++ b/src/render/overlay_assets.c @@ -0,0 +1,141 @@ +#include "overlay_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void bongo_cat_overlay_clear_textures(BongoCatOverlay *value) { + if (value->background) glDeleteTextures(1, &value->background); + if (value->composite) glDeleteTextures(1, &value->composite); + value->background = 0; + value->composite = 0; + value->clean_paws = false; + value->composed_cover = false; + value->composite_dirty = false; + value->model_pointer_preferred = false; + value->reference_width = value->reference_height = 0; + for (size_t i = 0; i < 4; ++i) { + if (value->cache[i].texture) glDeleteTextures(1, &value->cache[i].texture); + memset(&value->cache[i], 0, sizeof(value->cache[i])); + } + value->left = value->right = 0; + value->effect = 0; + value->left_name[0] = value->right_name[0] = '\0'; + value->left_path[0] = value->right_path[0] = '\0'; + value->effect_path[0] = '\0'; + value->background_path[0] = '\0'; +} + +BongoCatResult bongo_cat_overlay_load(BongoCatOverlay *value, + const char *directory, bool model_pointer_preferred, + const BongoCatLive2DRenderOptions *render_options, BongoCatError *error) { + if (!value || !directory) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model overlay state or directory"); + return BONGO_CAT_ERROR_ARGUMENT; + } + BongoCatError local = {0}; + BongoCatError *failure = error ? error : &local; + *failure = (BongoCatError){0}; + char path[BONGO_CAT_PATH_CAP]; + /* Without the licensed Cubism runtime there is no model renderer. Use the + model's composed preview so the desktop pet remains visually complete; + Cubism builds keep the background-only layer behind the animated model. */ +#ifdef BONGO_CAT_HAS_CUBISM + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/background.png")) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } +#else + if (!bongo_cat_path_join(path, sizeof(path), directory, + "resources/cover.png")) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, "Model overlay path is too long"); + return BONGO_CAT_ERROR_IO; + } +#endif + bongo_cat_overlay_clear_textures(value); + bongo_cat_mver_pointer_overlay_clear(value->mver_pointer); + GLuint background = 0; + int background_width = 0, background_height = 0; + BongoCatError background_error = {0}; + if (bongo_cat_path_is_file(path)) background = bongo_cat_image_texture(path, + &background_width, &background_height, &background_error); + BongoCatError pointer_error = {0}; + if (!model_pointer_preferred && + !bongo_cat_mver_pointer_overlay_load(value->mver_pointer, directory, + &pointer_error)) { + if (background) glDeleteTextures(1, &background); + *failure = pointer_error; + if (!failure->message[0] && background_error.message[0]) + *failure = background_error; + if (!failure->message[0]) bongo_cat_error_set(failure, BONGO_CAT_ERROR_IO, + "Unable to load model overlay"); + return BONGO_CAT_ERROR_IO; + } + if (!background && background_error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", background_error.message); + value->background = background; + /* SFML sprites use original pixels at the top-left of the configured view. + Their image size is not necessarily the size of that view. */ +#ifdef BONGO_CAT_HAS_CUBISM + if (render_options && render_options->mver_projection) { + value->reference_width = render_options->reference_width; + value->reference_height = render_options->reference_height; + /* Some Mver packages enlarge the window while retaining the original + full-canvas sprites. Keep their shared sprite coordinates in memory. + Do not infer a canvas from arbitrary partial images or pointer art. */ + if (background_width == 612 && + (background_height == 352 || background_height == 354) && + !bongo_cat_overlay_mver_pointer_enabled(value) && + value->reference_width > background_width && + value->reference_width % background_width == 0 && + value->reference_height % background_height == 0 && + value->reference_width / background_width == + value->reference_height / background_height) { + value->reference_width = background_width; + value->reference_height = background_height; + } + } +#else + (void)render_options; +#endif + value->model_pointer_preferred = model_pointer_preferred; + value->composed_cover = false; + value->clean_paws = false; +#ifndef BONGO_CAT_HAS_CUBISM + value->composed_cover = true; + value->clean_paws = true; +#endif + snprintf(value->directory, sizeof(value->directory), "%s", directory); + snprintf(value->background_path, sizeof(value->background_path), "%s", path); + return BONGO_CAT_OK; +} + +#ifdef BONGO_CAT_HAS_CUBISM +GLuint bongo_cat_overlay_cached_texture(BongoCatOverlay *value, const char *path) { + value->clock++; + TextureSlot *oldest = NULL; + for (size_t i = 0; i < 4; ++i) { + TextureSlot *slot = &value->cache[i]; + if (slot->texture && strcmp(slot->path, path) == 0) { + slot->used = value->clock; + return slot->texture; + } + if (slot->texture && + (slot->texture == value->left || slot->texture == value->right || + slot->texture == value->effect)) continue; + if (!oldest || !slot->texture || slot->used < oldest->used) oldest = slot; + } + if (!oldest) return 0; + if (oldest->texture) glDeleteTextures(1, &oldest->texture); + BongoCatError ignored = {0}; + oldest->texture = bongo_cat_image_texture(path, NULL, NULL, &ignored); + snprintf(oldest->path, sizeof(oldest->path), "%s", path); + oldest->used = value->clock; + return oldest->texture; +} +#endif + diff --git a/src/render/overlay_draw.c b/src/render/overlay_draw.c new file mode 100644 index 00000000..da811c55 --- /dev/null +++ b/src/render/overlay_draw.c @@ -0,0 +1,77 @@ +#include "overlay_internal.h" + +#include "bongo_cat/image.h" + +static void draw(BongoCatOverlay *value, GLuint texture, + bool mirror, bool blend) { + if (!value || !texture) return; + if (blend) { + glEnable(GL_BLEND); + value->gl.blend_func_separate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, + GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + } else glDisable(GL_BLEND); + value->gl.use_program(value->program); + value->gl.uniform_1i(value->mirror_location, mirror); + value->gl.uniform_1i(value->image_location, 0); + value->gl.uniform_1i(value->reference_width_location, value->reference_width); + value->gl.uniform_1i(value->reference_height_location, value->reference_height); + value->gl.uniform_1i(value->erase_left_location, + !blend && value->composed_cover && !value->composite && + value->left != 0); + value->gl.uniform_1i(value->erase_right_location, + !blend && value->composed_cover && !value->composite && + value->right != 0); + value->gl.active_texture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + value->gl.bind_vertex_array(value->vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + value->gl.bind_vertex_array(0); +} + +void bongo_cat_overlay_draw_background(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifndef BONGO_CAT_HAS_CUBISM + if (value->composite_dirty) { + value->composite_dirty = false; + if (value->left || value->right) { + BongoCatError ignored = {0}; + value->composite = bongo_cat_image_composite_texture( + value->background_path, value->left_path, value->right_path, + value->composite, value->clean_paws && value->left, + value->clean_paws && value->right, &ignored); + } + } +#endif + bool active = value->left || value->right; + draw(value, active && value->composite ? value->composite : + value->background, mirror, false); +} + +void bongo_cat_overlay_draw_keys(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifdef BONGO_CAT_HAS_CUBISM + draw(value, value->left, mirror, true); + draw(value, value->right, mirror, true); +#else + (void)mirror; +#endif +} + +void bongo_cat_overlay_draw_pointer_before_keys(BongoCatOverlay *value) { + if (bongo_cat_overlay_mver_pointer_enabled(value)) + bongo_cat_mver_pointer_overlay_draw_before_keys(value->mver_pointer); +} + +void bongo_cat_overlay_draw_effect(BongoCatOverlay *value, bool mirror) { + if (!value) return; +#ifdef BONGO_CAT_HAS_CUBISM + draw(value, value->effect, mirror, true); +#else + (void)mirror; +#endif +} + +void bongo_cat_overlay_draw_pointer_after_keys(BongoCatOverlay *value) { + if (bongo_cat_overlay_mver_pointer_enabled(value)) + bongo_cat_mver_pointer_overlay_draw_after_keys(value->mver_pointer); +} diff --git a/src/render/overlay_input.c b/src/render/overlay_input.c new file mode 100644 index 00000000..96bcc827 --- /dev/null +++ b/src/render/overlay_input.c @@ -0,0 +1,67 @@ +#include "overlay_internal.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool key_path(BongoCatOverlay *value, const char *group, const char *name, + char path[BONGO_CAT_PATH_CAP]) { + char relative[BONGO_CAT_PATH_CAP]; + snprintf(relative, sizeof(relative), "resources/%s/%s.png", group, name); + bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); + if (bongo_cat_path_is_file(path)) return true; + if (name[0] == 'F' && name[1] >= '0' && name[1] <= '9') { + snprintf(relative, sizeof(relative), "resources/%s/Fn.png", group); + bongo_cat_path_join(path, BONGO_CAT_PATH_CAP, value->directory, relative); + return bongo_cat_path_is_file(path); + } + return false; +} + +int bongo_cat_overlay_key(BongoCatOverlay *value, const char *name, bool pressed) { + if (!value || !name) return -1; + bool right; + char path[BONGO_CAT_PATH_CAP]; + if (key_path(value, "right-keys", name, path)) right = true; + else if (key_path(value, "left-keys", name, path)) right = false; + else return -1; + char *active_name = right ? value->right_name : value->left_name; + GLuint *active = right ? &value->right : &value->left; + char *active_path = right ? value->right_path : value->left_path; + if (pressed) { + snprintf(active_name, BONGO_CAT_ID_CAP, "%s", name); +#ifdef BONGO_CAT_HAS_CUBISM + *active = bongo_cat_overlay_cached_texture(value, path); +#else + snprintf(active_path, BONGO_CAT_PATH_CAP, "%s", path); + *active = 1; +#endif + } else if (strcmp(active_name, name) == 0) { + active_name[0] = '\0'; + *active = 0; + active_path[0] = '\0'; + } +#ifndef BONGO_CAT_HAS_CUBISM + value->composite_dirty = true; +#endif + return right ? 1 : 0; +} + +bool bongo_cat_overlay_hand_active(const BongoCatOverlay *value, bool right) { + return value && (right ? value->right : value->left) != 0; +} + +bool bongo_cat_overlay_effect(BongoCatOverlay *value, const char *path) { + if (!value) return false; + value->effect = 0; + value->effect_path[0] = '\0'; + if (!path || !*path) return true; + if (!bongo_cat_path_is_file(path)) return false; +#ifdef BONGO_CAT_HAS_CUBISM + value->effect = bongo_cat_overlay_cached_texture(value, path); +#else + value->effect = 1; +#endif + snprintf(value->effect_path, sizeof(value->effect_path), "%s", path); + return value->effect != 0; +} diff --git a/src/render/overlay_internal.h b/src/render/overlay_internal.h new file mode 100644 index 00000000..b95c9634 --- /dev/null +++ b/src/render/overlay_internal.h @@ -0,0 +1,56 @@ +#ifndef BONGO_CAT_OVERLAY_INTERNAL_H +#define BONGO_CAT_OVERLAY_INTERNAL_H + +#include "bongo_cat/overlay.h" +#include "bongo_cat/gl_api.h" +#include "mver_pointer_overlay.h" + +#include + +typedef struct TextureSlot { + char path[BONGO_CAT_PATH_CAP]; + GLuint texture; + uint64_t used; +} TextureSlot; + +struct BongoCatOverlay { + BongoCatGL gl; + BongoCatMverPointerOverlay *mver_pointer; + GLuint program; + GLint mirror_location; + GLint image_location; + GLint reference_width_location; + GLint reference_height_location; + GLint erase_left_location; + GLint erase_right_location; + GLuint vao; + GLuint vbo; + GLuint background; + GLuint composite; + bool composed_cover; + bool clean_paws; + bool composite_dirty; + bool model_pointer_preferred; + int reference_width; + int reference_height; + TextureSlot cache[4]; + GLuint left; + GLuint right; + GLuint effect; + char left_name[BONGO_CAT_ID_CAP]; + char right_name[BONGO_CAT_ID_CAP]; + char background_path[BONGO_CAT_PATH_CAP]; + char left_path[BONGO_CAT_PATH_CAP]; + char right_path[BONGO_CAT_PATH_CAP]; + char effect_path[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP]; + uint64_t clock; +}; + +/* Shared texture ownership for model loading and input/effect activation. */ +void bongo_cat_overlay_clear_textures(BongoCatOverlay *value); +#ifdef BONGO_CAT_HAS_CUBISM +GLuint bongo_cat_overlay_cached_texture(BongoCatOverlay *value, const char *path); +#endif + +#endif diff --git a/src/runtime/app.c b/src/runtime/app.c deleted file mode 100644 index c9b16740..00000000 --- a/src/runtime/app.c +++ /dev/null @@ -1,300 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/audio.h" -#include "bongo_cat/file.h" -#include "bongo_cat/i18n.h" -#include "bongo_cat/path.h" -#include "bongo_cat/overlay.h" -#include "bongo_cat/preferences.h" -#include "bongo_cat/tray.h" -#include -#include -#include -#include -static void scan_models(BongoCatApp *app) { - bongo_cat_app_rescan_models(app); -} -static bool load_selected_model(BongoCatApp *app, BongoCatError *error) { - const char *candidates[] = {app->config.current_model, "standard", "keyboard", "gamepad"}; - for (size_t i = 0; i < 4; ++i) { - bool duplicate = false; - for (size_t j = 0; j < i; ++j) - if (strcmp(candidates[i], candidates[j]) == 0) duplicate = true; - if (!duplicate && bongo_cat_models_find(&app->models, candidates[i]) && - bongo_cat_app_select_model(app, candidates[i])) { - if (i) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Selected model was unavailable; loaded fallback model %s", candidates[i]); - return true; - } - } - for (size_t i = 0; i < app->models.count; ++i) - if (bongo_cat_app_select_model(app, app->models.entries[i].id)) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "No usable Live2D model could be loaded"); - return false; -} -static bool initialize(BongoCatApp *app, int argc, char **argv, BongoCatError *error) { - memset(app, 0, sizeof(*app)); - app->smoke_language = -1; - app->smoke_theme = -1; - app->smoke_preference_page = -1; - bongo_cat_config_defaults(&app->config); - bongo_cat_input_init(&app->input); - bongo_cat_shortcut_init(&app->shortcut_state); - bongo_cat_models_init(&app->models); - if (!bongo_cat_startup_prepare(app, argc, argv, error)) return false; - BongoCatResult loaded = bongo_cat_preferences_load( - app->preferences_path, &app->config, error); - app->preferences_store_valid = loaded == BONGO_CAT_OK && - bongo_cat_path_is_file(app->preferences_path); - if (loaded != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Preferences ignored: %s", error->message); - *error = (BongoCatError){0}; - loaded = bongo_cat_session_load(app->session_path, &app->config, error); - app->session_store_valid = loaded == BONGO_CAT_OK && - bongo_cat_path_is_file(app->session_path); - if (loaded != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Session ignored: %s", error->message); - *error = (BongoCatError){0}; - bongo_cat_config_store_initialize(app); - if (app->smoke_language >= 0) - app->config.app.language = (BongoCatLanguage)app->smoke_language; - if (app->smoke_theme >= 0) app->config.app.theme = (BongoCatTheme)app->smoke_theme; - if (app->smoke_pass_through) app->config.window.pass_through = true; - if (app->smoke_model[0]) - snprintf(app->config.current_model, sizeof(app->config.current_model), - "%s", app->smoke_model); - if (!app->autostart_launch) app->config.window.visible = true; - bongo_cat_startup_stage(app, "configuration-ready"); - if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; - bongo_cat_startup_stage(app, "window-ready"); - if (bongo_cat_app_locate_assets(app, error) != BONGO_CAT_OK) return false; - bongo_cat_startup_stage(app, "assets-ready"); - BongoCatError optional = {0}; - app->i18n = bongo_cat_i18n_create(app->locale_root, app->config.app.language, &optional); - if (!app->i18n) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); - if (bongo_cat_platform_init(&app->platform, app->window, &app->input, error) != BONGO_CAT_OK) return false; - bongo_cat_window_apply(app); - bongo_cat_startup_stage(app, "platform-ready"); - app->live2d = bongo_cat_live2d_create(app->asset_root, error); - if (!app->live2d) return false; - optional = (BongoCatError){0}; app->overlay = bongo_cat_overlay_create(&optional); - if (!app->overlay) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Overlay disabled: %s", optional.message); - optional = (BongoCatError){0}; app->audio = bongo_cat_audio_create(&optional); - if (!app->audio) SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "%s", optional.message); - else bongo_cat_audio_set_enabled(app->audio, true); - scan_models(app); - if (!load_selected_model(app, error)) return false; - bongo_cat_startup_stage(app, "model-ready"); - if (app->smoke_import_path[0]) { - BongoCatError import_error = {0}; - if (bongo_cat_app_import_model(app, app->smoke_import_path, &import_error) != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", import_error.message); - bongo_cat_startup_ci_failure(app, &import_error); - } else if (app->smoke_remove_imported) { - char imported[BONGO_CAT_PATH_CAP]; - snprintf(imported, sizeof(imported), "%s", app->config.current_model); - if (bongo_cat_app_remove_model(app, imported, &import_error) != BONGO_CAT_OK) - bongo_cat_startup_ci_failure(app, &import_error); - } - } - app->running = true; - optional = (BongoCatError){0}; app->tray = bongo_cat_tray_create(app, &optional); - if (!app->tray && app->config.app.tray_visible) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); - app->preferences = bongo_cat_preferences_create(app); - if (!app->preferences) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Preferences are unavailable because their state could not be allocated"); - if (!app->tray && !app->config.window.visible) { - bongo_cat_window_set_visible(app, true); - } - bongo_cat_live2d_audit_run(app); - if (app->smoke_context_menu) bongo_cat_window_show_context_menu(app); - if (app->smoke_shortcuts && !bongo_cat_app_shortcuts_self_test(app)) { - BongoCatError shortcut_error = {0}; - bongo_cat_error_set(&shortcut_error, BONGO_CAT_ERROR_PLATFORM, "Shortcut action self-test failed"); - bongo_cat_startup_ci_failure(app, &shortcut_error); - } - if (app->smoke_menu) { - bool menu = bongo_cat_window_menu_self_test(app); - bool geometry = bongo_cat_window_geometry_self_test(app); - bool display = bongo_cat_window_display_self_test(app); - bool wheel = bongo_cat_window_wheel_self_test(app); - bool tray = bongo_cat_tray_self_test(app->tray); - bool wait = bongo_cat_window_wait_timeout_self_test(); - SDL_Log("Menu self-test: menu=%d geometry=%d display=%d wheel=%d tray=%d wait=%d", - menu, geometry, display, wheel, tray, wait); - if (!menu || !geometry || !display || !wheel || !tray || !wait) { - BongoCatError menu_error = {0}; - bongo_cat_error_set(&menu_error, BONGO_CAT_ERROR_PLATFORM, - "Context menu action self-test failed (menu=%d geometry=%d display=%d wheel=%d tray=%d wait=%d)", - menu, geometry, display, wheel, tray, wait); - bongo_cat_startup_ci_failure(app, &menu_error); - } - } - if (app->smoke_preferences) bongo_cat_preferences_show(app->preferences); - app->last_frame_ns = SDL_GetTicksNS(); - app->dirty = true; - app->startup_raise_due_ns = !app->smoke && !app->autostart_launch && app->config.window.visible ? app->last_frame_ns + 250000000ull : 0; - if (app->smoke_deadline_ns) app->smoke_deadline_ns += app->last_frame_ns; - if (!app->config.window.visible) bongo_cat_startup_ready(app); - return true; -} -static void handle_event(BongoCatApp *app, const SDL_Event *event) { - if (bongo_cat_preferences_event(app->preferences, event)) return; - if (!bongo_cat_window_event(app, event)) app->running = false; - if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && - event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) bongo_cat_gamepad_event(app, event); -} -void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { - BongoCatInputEvent event; - while (bongo_cat_input_pop(&app->input, &event)) { - if (app->smoke_ignore_global_input) continue; - if (app->smoke_input_audit) { - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "input-audit.txt"); - FILE *file = bongo_cat_file_open(path, "ab"); - if (file) { fprintf(file, "kind=%d name=%s value=%.3f\n", - event.kind, event.name, event.value); fclose(file); } - } - uint64_t delay = strcmp(event.name, "CapsLock") == 0 ? 100 : 0; -#ifdef _WIN32 - if (event.kind == BONGO_CAT_INPUT_KEY_DOWN && !delay) - delay = (uint64_t)(app->config.model.auto_release_seconds * 1000.0f); -#endif - bongo_cat_input_auto_release(&app->input, &event, delay); - if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); - } - uint64_t now = SDL_GetTicks(); - while (bongo_cat_input_take_release(&app->input, now, &event)) { - if (allow_shortcuts && !bongo_cat_preferences_shortcuts_blocked(app->preferences)) - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); - } - if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); -} -static void update_model(BongoCatApp *app, uint64_t now) { - float elapsed = (float)((now - app->last_frame_ns) / 1000000000.0); - if (elapsed > 0.25f) elapsed = 0.25f; - app->last_frame_ns = now; - if (app->smoke_freeze_model) return; - bongo_cat_app_step_live2d(app, elapsed); -} -static void render(BongoCatApp *app) { - uint64_t now = SDL_GetTicksNS(); - if (app->render_retry_ns > now) return; - if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { - app->dirty = true; - app->render_retry_ns = now + 1000000000ull; - SDL_LogError(SDL_LOG_CATEGORY_VIDEO, - "Main GL context could not be activated: %s", SDL_GetError()); - return; - } - app->render_retry_ns = 0; - int width, height; - SDL_GetWindowSizeInPixels(app->window, &width, &height); - glViewport(0, 0, width, height); glEnable(GL_MULTISAMPLE); - glDisable(GL_SCISSOR_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glClearColor(0.0f, 0.0f, 0.0f, 0.0f); - glClear(GL_COLOR_BUFFER_BIT); - bongo_cat_overlay_draw_background(app->overlay, app->config.model.mirror); - bongo_cat_live2d_set_mirror(app->live2d, app->config.model.mirror); - bongo_cat_live2d_draw(app->live2d); if (!app->model_mouse_parameters) bongo_cat_overlay_draw_pointer_before_keys(app->overlay); - if (app->config.model.mouse_centered && app->pointer_known && !app->model_pointer_anchor_ready) bongo_cat_app_apply_mouse_position(app, app->pointer_x, app->pointer_y, 0.0f); - bongo_cat_overlay_draw_keys(app->overlay, app->config.model.mirror); - bongo_cat_overlay_draw_effect(app->overlay, app->config.model.mirror); if (!app->model_mouse_parameters) bongo_cat_overlay_draw_pointer_after_keys(app->overlay); - bongo_cat_frame_capture_pending(app, width, height); - bongo_cat_frame_audit(app, width, height); - bongo_cat_window_capture_pointer_hit(app); - if (!bongo_cat_platform_present(&app->platform, width, height)) { - app->dirty = true; - app->render_retry_ns = now + 1000000000ull; - SDL_LogError(SDL_LOG_CATEGORY_VIDEO, - "Main frame presentation failed: %s", SDL_GetError()); - return; - } - bongo_cat_startup_ready(app); bongo_cat_memory_policy_frame_presented(); app->dirty = false; - bongo_cat_window_sync_click_through(app); bongo_cat_window_schedule_hit_check(app); -} -void bongo_cat_app_render_now(BongoCatApp *app) { if (app && app->window && - app->config.window.visible && !app->window_minimized) render(app); } -static void take_instance_wake(BongoCatApp *app) { - if (!bongo_cat_platform_single_instance_take_wake()) return; - bongo_cat_window_set_visible(app, true); - bongo_cat_platform_raise_window(app->window); - SDL_Log("Existing instance requested window reveal"); -} -static void loop(BongoCatApp *app) { - uint64_t iterations = 0, wakes = 0, zero_waits = 0; - while (app->running) { - iterations++; - int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); - if (!wait_ms) zero_waits++; - bongo_cat_preferences_input_begin(app->preferences); - SDL_Event event; - if (bongo_cat_wait_event(&event, wait_ms)) { - wakes++; - handle_event(app, &event); - unsigned queued = 0; - while (queued++ < 256 && SDL_PollEvent(&event)) - handle_event(app, &event); - } - bongo_cat_preferences_input_end(app->preferences); - take_instance_wake(app); - uint64_t now = SDL_GetTicksNS(); bongo_cat_window_update_wheel_animation(app, now); - bongo_cat_window_update_display_recovery(app, now); - bongo_cat_runtime_flow_update(app, now); - bongo_cat_window_apply_pending_resize(app); - bongo_cat_app_update_hover(app, now); - bongo_cat_app_drain_input(app, true); - if (bongo_cat_model_frame_due(app, now)) update_model(app, now); - else if (!app->config.window.visible || app->window_minimized) { app->last_frame_ns = now; bongo_cat_memory_policy_idle(); } - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app); - bongo_cat_preferences_render(app->preferences); - if (app->config.window.visible && !app->window_minimized && app->dirty) render(app); - bongo_cat_tray_sync(app->tray); bongo_cat_window_raise_when_due(app, now); - bongo_cat_config_store_update(app, now); - if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) app->running = false; - } - if (app->smoke) SDL_Log("Smoke loop: iterations=%llu wakes=%llu zero_waits=%llu", - (unsigned long long)iterations, (unsigned long long)wakes, - (unsigned long long)zero_waits); -} -static void shutdown(BongoCatApp *app) { - bongo_cat_config_store_flush(app); - bongo_cat_preferences_destroy(app->preferences); - bongo_cat_i18n_destroy(app->i18n); bongo_cat_tray_destroy(app->tray); - bongo_cat_gamepads_set_enabled(app, false); - bongo_cat_audio_destroy(app->audio); - bongo_cat_overlay_destroy(app->overlay); - bongo_cat_live2d_destroy(app->live2d); - bongo_cat_platform_shutdown(&app->platform); - bongo_cat_window_destroy(app); -} - -int bongo_cat_app_run(int argc, char **argv) { - if (!bongo_cat_platform_single_instance_begin()) return 0; - BongoCatApp *app = calloc(1, sizeof(*app)); - if (!app) { - BongoCatError memory = {0}; bongo_cat_error_set(&memory, - BONGO_CAT_ERROR_MEMORY, "Cannot allocate application state"); - bongo_cat_startup_failure(NULL, &memory); - bongo_cat_platform_single_instance_end(); return 1; - } - BongoCatError error = {0}; - if (!initialize(app, argc, argv, &error)) { - bongo_cat_startup_failure(app, &error); - if (app->smoke) bongo_cat_startup_ci_failure(app, &error); - shutdown(app); free(app); - bongo_cat_platform_single_instance_end(); - return 1; - } - loop(app); - shutdown(app); - int exit_code = app->exit_code; - free(app); bongo_cat_platform_single_instance_end(); - return exit_code; -} diff --git a/src/runtime/config_store.c b/src/runtime/config_store.c deleted file mode 100644 index 38e228e4..00000000 --- a/src/runtime/config_store.c +++ /dev/null @@ -1,113 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/path.h" - -#include - -#define SAVE_DELAY_NS 300000000ull -#define RETRY_DELAY_NS 1000000000ull - -static uint64_t hash_bytes(uint64_t hash, const void *data, size_t size) { - const unsigned char *bytes = data; - for (size_t i = 0; i < size; ++i) { - hash ^= bytes[i]; - hash *= 1099511628211ull; - } - return hash; -} - -static uint64_t preferences_hash(const BongoCatConfig *config) { - uint64_t hash = 1469598103934665603ull; -#define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) - HASH_FIELD(config->model); - HASH_FIELD(config->window.pass_through); - HASH_FIELD(config->window.always_on_top); - HASH_FIELD(config->window.hide_on_hover); - HASH_FIELD(config->window.keep_in_screen); - HASH_FIELD(config->window.hide_delay_seconds); - HASH_FIELD(config->app); - HASH_FIELD(config->shortcuts); - HASH_FIELD(config->behavior_shortcut_count); - hash = hash_bytes(hash, config->behavior_shortcuts, - config->behavior_shortcut_count * sizeof(config->behavior_shortcuts[0])); -#undef HASH_FIELD - return hash; -} - -static uint64_t session_hash(const BongoCatConfig *config) { - uint64_t hash = 1469598103934665603ull; -#define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) - HASH_FIELD(config->window.visible); - HASH_FIELD(config->window.scale_percent); - HASH_FIELD(config->window.opacity_percent); - HASH_FIELD(config->window.x); - HASH_FIELD(config->window.y); - HASH_FIELD(config->window.width); - HASH_FIELD(config->window.height); - HASH_FIELD(config->current_model); -#undef HASH_FIELD - return hash; -} - -void bongo_cat_config_store_initialize(BongoCatApp *app) { - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - app->preferences_observed_hash = preferences; - app->session_observed_hash = session; - app->preferences_saved_hash = app->preferences_store_valid ? preferences : 0; - app->session_saved_hash = app->session_store_valid ? session : 0; -} - -static bool save_preferences(BongoCatApp *app, uint64_t hash) { - BongoCatError error = {0}; - if (bongo_cat_preferences_save(app->preferences_path, - &app->config, &error) != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - return false; - } - app->preferences_saved_hash = hash; - return true; -} - -static bool save_session(BongoCatApp *app, uint64_t hash) { - BongoCatError error = {0}; - if (bongo_cat_session_save(app->session_path, - &app->config, &error) != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - return false; - } - app->session_saved_hash = hash; - return true; -} - -void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { - if (!app || app->smoke || !app->preferences_path[0] || !app->session_path[0]) return; - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - if (preferences != app->preferences_observed_hash) { - app->preferences_observed_hash = preferences; - app->preferences_save_due_ns = now + SAVE_DELAY_NS; - } else if (preferences != app->preferences_saved_hash && - !app->preferences_save_due_ns) app->preferences_save_due_ns = now + SAVE_DELAY_NS; - if (session != app->session_observed_hash) { - app->session_observed_hash = session; - app->session_save_due_ns = now + SAVE_DELAY_NS; - } else if (session != app->session_saved_hash && - !app->session_save_due_ns) app->session_save_due_ns = now + SAVE_DELAY_NS; - if (app->preferences_save_due_ns && now >= app->preferences_save_due_ns) - app->preferences_save_due_ns = save_preferences(app, preferences) - ? 0 : now + RETRY_DELAY_NS; - if (app->session_save_due_ns && now >= app->session_save_due_ns) - app->session_save_due_ns = save_session(app, session) - ? 0 : now + RETRY_DELAY_NS; -} - -void bongo_cat_config_store_flush(BongoCatApp *app) { - if (!app || app->smoke || !app->preferences_path[0] || !app->session_path[0]) return; - uint64_t preferences = preferences_hash(&app->config); - uint64_t session = session_hash(&app->config); - if (preferences != app->preferences_saved_hash) save_preferences(app, preferences); - if (session != app->session_saved_hash) save_session(app, session); - app->preferences_observed_hash = preferences; - app->session_observed_hash = session; - app->preferences_save_due_ns = app->session_save_due_ns = 0; -} diff --git a/src/runtime/diagnostics/frame_audit.c b/src/runtime/diagnostics/frame_audit.c new file mode 100644 index 00000000..917dd775 --- /dev/null +++ b/src/runtime/diagnostics/frame_audit.c @@ -0,0 +1,168 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +typedef struct FrameStats { + unsigned visible; + unsigned alpha; +} FrameStats; + +static FrameStats frame_stats(const unsigned char *pixels, + int width, int height, size_t pitch) { + FrameStats stats = {0}; + for (int y = 0; y < height; y += 4) { + const unsigned char *row = pixels + (size_t)y * pitch; + for (int x = 0; x < width; x += 4) { + const unsigned char *pixel = row + (size_t)x * 4; + if ((unsigned)pixel[0] + pixel[1] + pixel[2] > 60) stats.visible++; + if (pixel[3] > 8) stats.alpha++; + } + } + return stats; +} + +static void log_first_frame(BongoCatApp *app, int width, int height) { + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnostic skipped for size %dx%d", width, height); + return; + } + size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; + if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) return; + unsigned char *pixels = calloc(1, bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnostic allocation failed for %zu bytes", bytes); + return; + } + GLenum before = glGetError(); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum after = glGetError(); + unsigned long long alpha = 0, opaque = 0, rgb_colored = 0; + unsigned long long visible_colored = 0, black = 0; + unsigned long long red = 0, green = 0, blue = 0, alpha_sum = 0; + for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { + const unsigned char *pixel = pixels + (size_t)y * pitch + (size_t)x * 4; + unsigned sum = (unsigned)pixel[0] + pixel[1] + pixel[2]; + alpha += pixel[3] > 8; opaque += pixel[3] > 247; + rgb_colored += sum > 30; + visible_colored += pixel[3] > 8 && sum > 30; + black += pixel[3] > 8 && sum <= 30; + red += pixel[0]; green += pixel[1]; blue += pixel[2]; + alpha_sum += pixel[3]; + } + unsigned long long total = (unsigned long long)width * height; + int sample_buffers = 0, sample_count = 0; + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); + SDL_Log("First-frame pixels: size=%dx%d total=%llu alpha=%llu opaque=%llu " + "rgb_colored=%llu visible_colored=%llu black_with_alpha=%llu " + "transparent=%llu " + "avg_rgba=%.1f,%.1f,%.1f,%.1f gl_error_before=0x%x gl_error_after=0x%x " + "msaa=%d/%d", width, height, total, alpha, opaque, rgb_colored, + visible_colored, black, + total - alpha, (double)red / total, (double)green / total, + (double)blue / total, (double)alpha_sum / total, before, after, + sample_buffers, sample_count); + SDL_Log("First-frame ratios: alpha=%.2f%% rgb_colored=%.2f%% " + "visible_colored=%.2f%% black_with_alpha=%.2f%%", + 100.0 * alpha / total, 100.0 * rgb_colored / total, + 100.0 * visible_colored / total, 100.0 * black / total); + if (after != GL_NO_ERROR) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: OpenGL framebuffer readback failed (0x%x)", after); + else if (!rgb_colored && !alpha) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer is blank and fully transparent"); + else if (!rgb_colored) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer contains only black pixels"); + else if (!visible_colored) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "First-frame diagnosis: framebuffer has RGB content but no visible color alpha"); + else SDL_Log("First-frame diagnosis: OpenGL framebuffer contains visible content"); + bongo_cat_frame_presentation_prepare(app, pixels, width, height, + after == GL_NO_ERROR && visible_colored > 0); + if (app->smoke) { + const int points[][2] = {{0, 0}, {width - 1, 0}, {0, height - 1}, + {width - 1, height - 1}, {width / 2, height / 2}}; + unsigned transparent = 0, opaque_samples = 0; + for (size_t i = 0; i < sizeof(points) / sizeof(points[0]); ++i) { + unsigned char value = pixels[(size_t)points[i][1] * pitch + + (size_t)points[i][0] * 4 + 3]; + transparent += value < 16; opaque_samples += value > 239; + } + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), app->state_root, + "frame-alpha.txt"); + FILE *file = bongo_cat_file_open(path, "wb"); + if (file) { + fprintf(file, "samples=5 transparent=%u opaque=%u sample_buffers=%d " + "sample_count=%d gl_error=%u\n", transparent, opaque_samples, + sample_buffers, sample_count, (unsigned)after); + fclose(file); + } + } + free(pixels); +} + +static void record_frame(BongoCatApp *app, const unsigned char *pixels, + int width, int height, size_t pitch) { + if (!app->smoke_frame_series) return; + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), app->state_root, + "frame-series.csv"); + bool header = !bongo_cat_path_is_file(path); + FILE *file = bongo_cat_file_open(path, "ab"); + if (!file) return; + if (header) fputs("ticks_ns,width,height,visible_pixels,alpha_pixels," + "scale_percent,opacity_percent,window_opacity,model_mode," + "model_state_consistent,selection_serial,window_config_visible," + "window_os_visible\n", file); + FrameStats stats = frame_stats(pixels, width, height, pitch); + bool model_consistent = bongo_cat_live2d_ready(app->live2d) && + app->loaded_model[0] && + strcmp(app->loaded_model, app->session.active_model_id) == 0; + bool os_visible = (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) == 0; + fprintf(file, "%llu,%d,%d,%u,%u,%.3f,%.3f,%.5f,%s,%d,%u,%d,%d\n", + (unsigned long long)SDL_GetTicksNS(), width, height, + stats.visible, stats.alpha, + app->session.window.scale_percent, app->session.window.opacity_percent, + bongo_cat_platform_get_opacity(&app->platform), + bongo_cat_mode_name(app->loaded_mode), model_consistent, + app->model_selection_serial, app->session.window.visible, os_visible); + fclose(file); +} + +void bongo_cat_frame_audit(BongoCatApp *app, int width, int height) { + if (!app || !app->smoke || width < 2 || height < 2) return; + if (!app->smoke_frame_audited) { + app->smoke_frame_audited = true; + log_first_frame(app, width, height); + } + char path[BONGO_CAT_PATH_CAP]; + size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; + unsigned char *pixels = malloc(bytes); + if (!pixels) return; + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + record_frame(app, pixels, width, height, pitch); + uint64_t now = SDL_GetTicksNS(); + if (app->frame_audit_bmp_ns && now >= app->frame_audit_bmp_ns && + now - app->frame_audit_bmp_ns < 50000000ull) { + free(pixels); return; + } + app->frame_audit_bmp_ns = now; + unsigned char *row = malloc(pitch); + if (!row) { free(pixels); return; } + for (int y = 0; y < height / 2; ++y) { + unsigned char *top = pixels + (size_t)y * pitch; + unsigned char *bottom = pixels + (size_t)(height - 1 - y) * pitch; + memcpy(row, top, pitch); memcpy(top, bottom, pitch); memcpy(bottom, row, pitch); + } + SDL_Surface *surface = SDL_CreateSurfaceFrom(width, height, + SDL_PIXELFORMAT_RGBA32, pixels, (int)pitch); + bongo_cat_path_join(path, sizeof(path), app->state_root, "frame.bmp"); + if (surface) { SDL_SaveBMP(surface, path); SDL_DestroySurface(surface); } + free(row); free(pixels); +} diff --git a/src/runtime/diagnostics/frame_presentation_audit.c b/src/runtime/diagnostics/frame_presentation_audit.c new file mode 100644 index 00000000..7cfa9002 --- /dev/null +++ b/src/runtime/diagnostics/frame_presentation_audit.c @@ -0,0 +1,179 @@ +#include "runtime.h" + +#include +#include +#include + +#ifdef _WIN32 +#include "windows_capture.h" +#include "windows_diagnostics.h" +#include "windows_layered.h" +#include +#endif + +#ifndef GL_DRAW_FRAMEBUFFER_BINDING +#define GL_DRAW_FRAMEBUFFER_BINDING 0x8CA6 +#endif +#ifndef GL_READ_FRAMEBUFFER_BINDING +#define GL_READ_FRAMEBUFFER_BINDING 0x8CAA +#endif +#ifndef GL_SAMPLE_BUFFERS +#define GL_SAMPLE_BUFFERS 0x80A8 +#endif +#ifndef GL_SAMPLES +#define GL_SAMPLES 0x80A9 +#endif + +typedef struct PresentationAudit { + bool prepared; + bool presented; + bool visible; + int width; + int height; + uint64_t rendered_hash; +} PresentationAudit; + +static PresentationAudit audit; + +static uint64_t pixel_hash(const unsigned char *pixels, size_t bytes) { + uint64_t hash = UINT64_C(1469598103934665603); + for (size_t index = 0; index < bytes; ++index) { + hash ^= pixels[index]; + hash *= UINT64_C(1099511628211); + } + return hash; +} + +static void log_gl_presentation_state(BongoCatApp *app) { + GLint draw_buffer = 0, read_buffer = 0; + GLint draw_framebuffer = 0, read_framebuffer = 0; + GLint red_bits = 0, green_bits = 0, blue_bits = 0, alpha_bits = 0; + GLint depth_bits = 0, stencil_bits = 0, sample_buffers = 0, samples = 0; + GLboolean double_buffer = GL_FALSE, stereo = GL_FALSE; + GLenum before = glGetError(); + glGetIntegerv(GL_DRAW_BUFFER, &draw_buffer); + glGetIntegerv(GL_READ_BUFFER, &read_buffer); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw_framebuffer); + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read_framebuffer); + glGetIntegerv(GL_RED_BITS, &red_bits); + glGetIntegerv(GL_GREEN_BITS, &green_bits); + glGetIntegerv(GL_BLUE_BITS, &blue_bits); + glGetIntegerv(GL_ALPHA_BITS, &alpha_bits); + glGetIntegerv(GL_DEPTH_BITS, &depth_bits); + glGetIntegerv(GL_STENCIL_BITS, &stencil_bits); + glGetIntegerv(GL_SAMPLE_BUFFERS, &sample_buffers); + glGetIntegerv(GL_SAMPLES, &samples); + glGetBooleanv(GL_DOUBLEBUFFER, &double_buffer); + glGetBooleanv(GL_STEREO, &stereo); + GLenum after = glGetError(); + int sdl_double_buffer = -1, accelerated = -1, retained = -1; + SDL_GL_GetAttribute(SDL_GL_DOUBLEBUFFER, &sdl_double_buffer); + SDL_GL_GetAttribute(SDL_GL_ACCELERATED_VISUAL, &accelerated); + SDL_GL_GetAttribute(SDL_GL_RETAINED_BACKING, &retained); + SDL_WindowFlags flags = app && app->window ? + SDL_GetWindowFlags(app->window) : 0; + SDL_Log("OpenGL presentation state: draw_buffer=0x%x read_buffer=0x%x " + "draw_fbo=%d read_fbo=%d rgba_bits=%d,%d,%d,%d depth_bits=%d " + "stencil_bits=%d sample_buffers=%d samples=%d double_buffer=%d " + "stereo=%d sdl_double_buffer=%d accelerated=%d retained=%d " + "window_flags=0x%llx state_error_before=0x%x state_error_after=0x%x", + draw_buffer, read_buffer, draw_framebuffer, read_framebuffer, + red_bits, green_bits, blue_bits, alpha_bits, depth_bits, stencil_bits, + sample_buffers, samples, double_buffer != GL_FALSE, + stereo != GL_FALSE, sdl_double_buffer, accelerated, retained, + (unsigned long long)flags, before, after); +} + +void bongo_cat_frame_presentation_prepare(BongoCatApp *app, + const unsigned char *pixels, int width, int height, bool visible) { + if (!app || !pixels || width < 1 || height < 1) return; + size_t bytes = (size_t)width * (size_t)height * 4; + if (bytes / 4 != (size_t)width * (size_t)height) return; + audit = (PresentationAudit){.prepared = true, .visible = visible, + .width = width, .height = height, + .rendered_hash = pixel_hash(pixels, bytes)}; + SDL_Log("Pre-presentation framebuffer: size=%dx%d visible=%d " + "rgba_hash=0x%016llx", width, height, visible, + (unsigned long long)audit.rendered_hash); + log_gl_presentation_state(app); +} + +static void log_post_present_buffer(const char *name, GLenum buffer) { + size_t total = (size_t)audit.width * (size_t)audit.height; + size_t bytes = total * 4; + unsigned char *pixels = calloc(1, bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Post-presentation %s allocation failed for %zu bytes", name, bytes); + return; + } + GLint previous = GL_BACK; + glGetIntegerv(GL_READ_BUFFER, &previous); + GLenum before = glGetError(); + glReadBuffer(buffer); + glReadPixels(0, 0, audit.width, audit.height, + GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum after = glGetError(); + glReadBuffer((GLenum)previous); + GLenum restore_error = glGetError(); + unsigned long long alpha = 0, visible = 0, nonblack = 0; + for (size_t index = 0; index < bytes; index += 4) { + unsigned sum = (unsigned)pixels[index] + pixels[index + 1] + + pixels[index + 2]; + alpha += pixels[index + 3] > 8; + nonblack += sum > 30; + visible += pixels[index + 3] > 8 && sum > 30; + } + uint64_t hash = pixel_hash(pixels, bytes); + bool valid = after == GL_NO_ERROR; + SDL_Log("Post-presentation OpenGL buffer: name=%s enum=0x%x size=%dx%d " + "valid=%d alpha=%.2f%% nonblack=%.2f%% visible=%.2f%% " + "rgba_hash=0x%016llx matches_pre_swap=%d error_before=0x%x " + "error_after=0x%x restore_error=0x%x", + name, buffer, audit.width, audit.height, valid, + 100.0 * alpha / total, 100.0 * nonblack / total, + 100.0 * visible / total, (unsigned long long)hash, + valid && hash == audit.rendered_hash, before, after, restore_error); + free(pixels); +} + +void bongo_cat_frame_presented_audit(BongoCatApp *app) { + if (!app || !app->smoke || audit.presented || !audit.prepared) return; + audit.presented = true; + SDL_Log("First-frame presentation: success=1 framebuffer_visible=%d " + "window_opacity=%.3f obs_background=%d obs_background_color=%s", + audit.visible, bongo_cat_platform_get_opacity(&app->platform), + app->settings.window.obs_background, + bongo_cat_obs_background_color_name( + app->settings.window.obs_background_color)); +#ifdef _WIN32 + HWND source = app->window ? (HWND)SDL_GetPointerProperty( + SDL_GetWindowProperties(app->window), + SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL) : NULL; + if (!source) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Capture diagnostic skipped because the Win32 window is unavailable"); + return; + } + HWND proxy = bongo_cat_windows_layered_proxy(source); + bool proxy_visible = proxy && IsWindow(proxy) && IsWindowVisible(proxy); + SDL_Log("Windows presented path: source=%p proxy=%p proxy_visible=%d", + (void *)source, (void *)proxy, proxy_visible); + if (!proxy_visible) { + log_post_present_buffer("front", GL_FRONT); + log_post_present_buffer("back", GL_BACK); + } else { + SDL_Log("Post-presentation OpenGL front-buffer readback skipped: " + "the visible output is the layered proxy"); + } + bongo_cat_windows_capture_log(source, "first-presented-source"); + bongo_cat_windows_diagnostics_probe_capture(source, "opengl-source"); + if (proxy && IsWindow(proxy)) { + bongo_cat_windows_capture_log(proxy, "first-presented-layered-proxy"); + bongo_cat_windows_diagnostics_probe_capture(proxy, "layered-proxy"); + } +#else + log_post_present_buffer("front", GL_FRONT); + log_post_present_buffer("back", GL_BACK); +#endif +} diff --git a/src/runtime/live2d_audit.c b/src/runtime/diagnostics/live2d_audit.c similarity index 54% rename from src/runtime/live2d_audit.c rename to src/runtime/diagnostics/live2d_audit.c index e877de93..4eccbe6e 100644 --- a/src/runtime/live2d_audit.c +++ b/src/runtime/diagnostics/live2d_audit.c @@ -1,4 +1,6 @@ #include "runtime.h" +#include "live2d_audit_scenario.h" +#include "live2d_pointer_audit.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -6,21 +8,9 @@ #include #include -typedef struct PointerAudit { - bool ran; - bool mver; - bool has_mouse; - float angle_x[4]; - float angle_y[4]; - float mouse_x[4]; - float mouse_y[4]; - float maximum_step; -} PointerAudit; - -static PointerAudit pointer_audit; - -/* Viewer 5.3.03: (7.2727275 / 30) target speed * 30-degree range * 1.2 gain. */ -#define POINTER_VISIBLE_STEP_LIMIT 8.8f +#define input bongo_cat_live2d_audit_input +#define motion bongo_cat_live2d_audit_motion +#define parameter_override_scenario bongo_cat_live2d_audit_parameter_override static bool value(BongoCatApp *app, const char *id, float *output) { BongoCatParameterRange range; @@ -29,87 +19,8 @@ static bool value(BongoCatApp *app, const char *id, float *output) { return true; } -static void input(BongoCatApp *app, BongoCatInputKind kind, const char *name, float value) { - BongoCatInputEvent event = {.kind = kind, .value = value}; - snprintf(event.name, sizeof(event.name), "%s", name); - bongo_cat_app_shortcuts(app, &event); - bongo_cat_app_apply_input(app, &event); -} - -static bool pointer_test_center(BongoCatApp *app, SDL_Rect *bounds, - double *center_x, double *center_y) { - int window_x, window_y, width, height; - if (!app || !app->window || !bounds || !center_x || !center_y || - !SDL_GetWindowPosition(app->window, &window_x, &window_y) || - !SDL_GetWindowSize(app->window, &width, &height)) return false; - *center_x = window_x + width * 0.5; - *center_y = window_y + height * 0.5; - SDL_Point point = {(int)*center_x, (int)*center_y}; - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - return display && SDL_GetDisplayBounds(display, bounds) && - bounds->w > 0 && bounds->h > 0; -} - -static bool motion(BongoCatApp *app, const char *scenario) { - if (strcmp(scenario, "motion-0") == 0) - return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 0); - if (strcmp(scenario, "motion-1") == 0) - return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 1); - if (strncmp(scenario, "expression-", 11) == 0) - return bongo_cat_live2d_set_expression(app->live2d, atoi(scenario + 11)); - return true; -} - -static bool pointer(BongoCatApp *app, bool mirror) { - SDL_Rect bounds; - double center_x, center_y; - if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - app->config.model.mouse_mirror = mirror; - app->left_mouse_down = false; - bongo_cat_app_apply_mouse_position(app, center_x + bounds.w * 0.4, - center_y - bounds.h * 0.4, 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - return true; -} - -static bool reverse_pointer(BongoCatApp *app) { - SDL_Rect bounds; - double center_x, center_y; - if (!pointer_test_center(app, &bounds, ¢er_x, ¢er_y)) return false; - pointer_audit = (PointerAudit){.ran = true, - .mver = app->model_render_options.mver_projection}; - app->config.model.mouse_mirror = false; - app->left_mouse_down = false; - const float ratios[4][2] = { - {-0.4f, -0.4f}, {0.4f, -0.4f}, {-0.4f, 0.4f}, {0.4f, 0.4f}}; - float previous_x = 0.0f; - bool previous_ready = value(app, "ParamAngleX", &previous_x); - for (int corner = 0; corner < 4; ++corner) { - bongo_cat_app_apply_mouse_position(app, - center_x + bounds.w * ratios[corner][0], - center_y + bounds.h * ratios[corner][1], 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) { - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - float current_x = previous_x; - if (!value(app, "ParamAngleX", ¤t_x)) return false; - if (previous_ready) { - float step = SDL_fabsf(current_x - previous_x); - if (step > pointer_audit.maximum_step) - pointer_audit.maximum_step = step; - } - previous_x = current_x; - previous_ready = true; - } - if (!value(app, "ParamAngleX", &pointer_audit.angle_x[corner]) || - !value(app, "ParamAngleY", &pointer_audit.angle_y[corner])) return false; - bool mouse_x = value(app, "ParamMouseX", &pointer_audit.mouse_x[corner]); - bool mouse_y = value(app, "ParamMouseY", &pointer_audit.mouse_y[corner]); - if (corner == 0) pointer_audit.has_mouse = mouse_x && mouse_y; - else if (pointer_audit.has_mouse != (mouse_x && mouse_y)) return false; - } - return true; -} +/* Viewer 5.3.03: (7.2727275 / 30) target speed * 30-degree range * 1.2 gain. */ +#define POINTER_VISIBLE_STEP_LIMIT 8.8f static bool apply(BongoCatApp *app, const char *scenario) { if (strncmp(scenario, "switch:", 7) == 0) @@ -118,12 +29,17 @@ static bool apply(BongoCatApp *app, const char *scenario) { return bongo_cat_live2d_visual_audit_run(app); if (strcmp(scenario, "viewer-sequence") == 0) return bongo_cat_live2d_viewer_audit_run(app); - if (strcmp(scenario, "mouse-screen") == 0) return bongo_cat_app_audit_screen_pointer(app); if (strcmp(scenario, "mouse-hand-screen") == 0) return bongo_cat_app_audit_display_pointer(app); - if (strcmp(scenario, "mirror") == 0) app->config.model.mirror = true; - else if (strcmp(scenario, "mouse-move") == 0) return pointer(app, false); - else if (strcmp(scenario, "mouse-move-mirror") == 0) return pointer(app, true); + if (strcmp(scenario, "mouse-screen") == 0) + return bongo_cat_app_audit_screen_pointer(app); + if (strcmp(scenario, "mouse-hand-screen") == 0) + return bongo_cat_app_audit_display_pointer(app); + if (strcmp(scenario, "mirror") == 0) app->settings.model.mirror = true; + else if (strcmp(scenario, "mouse-move") == 0) + return bongo_cat_live2d_pointer_audit_run(app, false); + else if (strcmp(scenario, "mouse-move-mirror") == 0) + return bongo_cat_live2d_pointer_audit_run(app, true); else if (strcmp(scenario, "mouse-reverse") == 0) - return reverse_pointer(app); + return bongo_cat_live2d_pointer_reverse_audit_run(app); else if (strcmp(scenario, "key-left") == 0) input(app, BONGO_CAT_INPUT_KEY_DOWN, "KeyA", 1.0f); else if (strcmp(scenario, "key-tab-left") == 0) @@ -181,6 +97,11 @@ static bool active(BongoCatApp *app, const char *id) { return value(app, id, ¤t) && current > 0.5f; } +static bool inactive(BongoCatApp *app, const char *id) { + float current = 0.0f; + return value(app, id, ¤t) && current <= 0.5f; +} + static bool signed_value(BongoCatApp *app, const char *id, bool positive) { float current = 0.0f; return value(app, id, ¤t) && (positive ? current > 0.05f : current < -0.05f); @@ -189,13 +110,10 @@ static bool signed_value(BongoCatApp *app, const char *id, bool positive) { static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { if (!operation) return false; if (strncmp(scenario, "switch:", 7) == 0) - return strcmp(app->config.current_model, scenario + 7) == 0; + return strcmp(app->session.active_model_id, scenario + 7) == 0; if (strcmp(scenario, "idle") == 0 || strcmp(scenario, "mouse-screen") == 0 || strcmp(scenario, "mouse-hand-screen") == 0 || strcmp(scenario, "mirror") == 0 || strcmp(scenario, "visual-consistency") == 0 || strcmp(scenario, "viewer-sequence") == 0 || - strcmp(scenario, "key-left-release") == 0 || - strcmp(scenario, "keys-both-release") == 0 || - strcmp(scenario, "key-stress") == 0 || strncmp(scenario, "motion-", 7) == 0) return true; if (strncmp(scenario, "expression-", 11) == 0) return bongo_cat_live2d_expression(app->live2d) == atoi(scenario + 11); @@ -207,40 +125,39 @@ static bool assertions(BongoCatApp *app, const char *scenario, bool operation) { strcmp(scenario, "gamepad-buttons") == 0) return active(app, "CatParamLeftHandDown") && active(app, "CatParamRightHandDown"); + if (strcmp(scenario, "key-left-release") == 0) + return inactive(app, "CatParamLeftHandDown"); + if (strcmp(scenario, "keys-both-release") == 0 || + strcmp(scenario, "key-stress") == 0) + return inactive(app, "CatParamLeftHandDown") && + inactive(app, "CatParamRightHandDown"); if (strcmp(scenario, "mouse-left") == 0) return active(app, "ParamMouseLeftDown"); if (strcmp(scenario, "mouse-right") == 0) return active(app, "ParamMouseRightDown"); - if (strcmp(scenario, "mouse-move") == 0) - return signed_value(app, "ParamAngleX", - app->model_render_options.mver_projection) && - signed_value(app, "ParamAngleY", true); - if (strcmp(scenario, "mouse-move-mirror") == 0) + if (strcmp(scenario, "mouse-move") == 0) { + float angle_z = 0.0f; + bool z_valid = !value(app, "ParamAngleZ", &angle_z) || + angle_z < -0.05f; return signed_value(app, "ParamAngleX", true) && + signed_value(app, "ParamAngleY", true) && z_valid; + } + if (strcmp(scenario, "mouse-move-mirror") == 0) + return signed_value(app, "ParamAngleX", false) && signed_value(app, "ParamAngleY", true); if (strcmp(scenario, "mouse-reverse") == 0) { - bool horizontal = pointer_audit.mver ? - pointer_audit.angle_x[0] < -5.0f && - pointer_audit.angle_x[1] > 5.0f && - pointer_audit.angle_x[2] < -5.0f && - pointer_audit.angle_x[3] > 5.0f : - pointer_audit.angle_x[0] > 5.0f && - pointer_audit.angle_x[1] < -5.0f && - pointer_audit.angle_x[2] > 5.0f && - pointer_audit.angle_x[3] < -5.0f; - bool direction = pointer_audit.ran && horizontal && - pointer_audit.angle_y[0] > 5.0f && - pointer_audit.angle_y[1] > 5.0f && - pointer_audit.angle_y[2] < -5.0f && - pointer_audit.angle_y[3] < -5.0f; - bool custom = true; - if (!pointer_audit.mver && pointer_audit.has_mouse) { - for (int corner = 0; corner < 4; ++corner) - custom = SDL_fabsf(pointer_audit.mouse_x[corner]) < 0.001f && - SDL_fabsf(pointer_audit.mouse_y[corner]) < 0.001f && custom; - } - return direction && custom && - pointer_audit.maximum_step < POINTER_VISIBLE_STEP_LIMIT; + bool horizontal = + bongo_cat_pointer_audit.angle_x[0] < -5.0f && + bongo_cat_pointer_audit.angle_x[1] > 5.0f && + bongo_cat_pointer_audit.angle_x[2] < -5.0f && + bongo_cat_pointer_audit.angle_x[3] > 5.0f; + bool direction = bongo_cat_pointer_audit.ran && horizontal && + bongo_cat_pointer_audit.angle_y[0] > 5.0f && + bongo_cat_pointer_audit.angle_y[1] > 5.0f && + bongo_cat_pointer_audit.angle_y[2] < -5.0f && + bongo_cat_pointer_audit.angle_y[3] < -5.0f; + return direction && + bongo_cat_pointer_audit.maximum_step < POINTER_VISIBLE_STEP_LIMIT; } if (strcmp(scenario, "gamepad-sticks") == 0) return signed_value(app, "CatParamStickLX", true) && @@ -256,16 +173,20 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app) { if (!app || !app->smoke_live2d_scenario[0]) return; uint64_t started = SDL_GetTicksNS(); bool result = apply(app, app->smoke_live2d_scenario); + if (result && parameter_override_scenario(app->smoke_live2d_scenario)) + for (int frame = 0; frame < 4; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); double duration_ms = (SDL_GetTicksNS() - started) / 1000000.0; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, "live2d-audit.txt")) return; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, + "live2d-audit.txt")) return; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; bool verified = assertions(app, app->smoke_live2d_scenario, result); fprintf(file, "scenario=%s\nmodel=%s\nmode=%s\noperation=%s\nassertions=%s\n" "duration_ms=%.3f\n", - app->smoke_live2d_scenario, app->config.current_model, - bongo_cat_mode_name(app->config.current_mode), result ? "accepted" : "rejected", + app->smoke_live2d_scenario, app->session.active_model_id, + bongo_cat_mode_name(app->loaded_mode), result ? "accepted" : "rejected", verified ? "passed" : "failed", duration_ms); #ifdef BONGO_CAT_HAS_CUBISM fputs("renderer=cubism-native\n", file); @@ -282,19 +203,20 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app) { "ParamEyeBallX", "ParamEyeBallY"}; for (size_t i = 0; i < sizeof(parameters) / sizeof(parameters[0]); ++i) parameter(file, app, parameters[i]); - if (pointer_audit.ran) { + if (bongo_cat_pointer_audit.ran) { static const char *names[] = {"tl", "tr", "bl", "br"}; fprintf(file, "pointer.maximum_step=%.4f\npointer.has_mouse=%d\n", - pointer_audit.maximum_step, pointer_audit.has_mouse); + bongo_cat_pointer_audit.maximum_step, + bongo_cat_pointer_audit.has_mouse); for (int corner = 0; corner < 4; ++corner) fprintf(file, "pointer.%s.angle_x=%.4f\n" "pointer.%s.angle_y=%.4f\n" "pointer.%s.mouse_x=%.4f\n" "pointer.%s.mouse_y=%.4f\n", names[corner], - pointer_audit.angle_x[corner], names[corner], - pointer_audit.angle_y[corner], names[corner], - pointer_audit.mouse_x[corner], names[corner], - pointer_audit.mouse_y[corner]); + bongo_cat_pointer_audit.angle_x[corner], names[corner], + bongo_cat_pointer_audit.angle_y[corner], names[corner], + bongo_cat_pointer_audit.mouse_x[corner], names[corner], + bongo_cat_pointer_audit.mouse_y[corner]); } fclose(file); } diff --git a/src/runtime/diagnostics/live2d_audit_scenario.c b/src/runtime/diagnostics/live2d_audit_scenario.c new file mode 100644 index 00000000..15521394 --- /dev/null +++ b/src/runtime/diagnostics/live2d_audit_scenario.c @@ -0,0 +1,32 @@ +#include "live2d_audit_scenario.h" + +#include +#include +#include + +void bongo_cat_live2d_audit_input(BongoCatApp *app, BongoCatInputKind kind, + const char *name, float value) { + BongoCatInputEvent event = {.kind = kind, .value = value}; + snprintf(event.name, sizeof(event.name), "%s", name); + bongo_cat_app_shortcuts(app, &event); + bongo_cat_app_apply_input(app, &event); +} + +bool bongo_cat_live2d_audit_motion(BongoCatApp *app, const char *scenario) { + if (strcmp(scenario, "motion-0") == 0) + return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 0); + if (strcmp(scenario, "motion-1") == 0) + return bongo_cat_live2d_start_motion(app->live2d, "CAT_motion", 1); + if (strncmp(scenario, "expression-", 11) == 0) + return bongo_cat_live2d_set_expression(app->live2d, + atoi(scenario + 11)); + return true; +} + +bool bongo_cat_live2d_audit_parameter_override(const char *scenario) { + return strncmp(scenario, "key-", 4) == 0 || + strncmp(scenario, "keys-", 5) == 0 || + strncmp(scenario, "gamepad-", 8) == 0 || + strcmp(scenario, "mouse-left") == 0 || + strcmp(scenario, "mouse-right") == 0; +} diff --git a/src/runtime/diagnostics/live2d_audit_scenario.h b/src/runtime/diagnostics/live2d_audit_scenario.h new file mode 100644 index 00000000..5fcee1f9 --- /dev/null +++ b/src/runtime/diagnostics/live2d_audit_scenario.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_LIVE2D_AUDIT_SCENARIO_H +#define BONGO_CAT_LIVE2D_AUDIT_SCENARIO_H + +#include "runtime.h" + +void bongo_cat_live2d_audit_input(BongoCatApp *app, BongoCatInputKind kind, + const char *name, float value); +bool bongo_cat_live2d_audit_motion(BongoCatApp *app, const char *scenario); +bool bongo_cat_live2d_audit_parameter_override(const char *scenario); + +#endif diff --git a/src/runtime/diagnostics/live2d_mouse_mapping_audit.c b/src/runtime/diagnostics/live2d_mouse_mapping_audit.c new file mode 100644 index 00000000..9bc24b9b --- /dev/null +++ b/src/runtime/diagnostics/live2d_mouse_mapping_audit.c @@ -0,0 +1,53 @@ +#include "runtime.h" +#include "mouse_internal.h" + +#include + +bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { + SDL_Rect bounds; + SDL_DisplayID display = SDL_GetPrimaryDisplay(); + if (!app || !display || !SDL_GetDisplayBounds(display, &bounds)) return false; + bool mouse_centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = false; + bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.5, + bounds.y + bounds.h * 0.5, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + BongoCatParameterRange x, y, z; + bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && + bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && + fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; + bool has_z = bongo_cat_live2d_parameter(app->live2d, "ParamAngleZ", &z); + bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.8, + bounds.y + bounds.h * 0.2, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + passed = (!has_z || (bongo_cat_live2d_parameter(app->live2d, + "ParamAngleZ", &z) && fabsf(z.value) < 0.25f)) && passed; + app->settings.model.mouse_centered = mouse_centered; + return passed; +} + +bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { + BongoCatMouseProjection projection; + if (!app) return false; + bool centered = app->settings.model.mouse_centered; + app->settings.model.mouse_centered = true; + if (!bongo_cat_app_mouse_projection(app, 0.0, 0.0, &projection) || + !projection.centered) { + app->settings.model.mouse_centered = centered; + return false; + } + BongoCatMverPointerBounds bounds = projection.bounds; + BongoCatParameterRange tl_x = {0}, tl_y = {0}, br_x = {0}, br_y = {0}; + bongo_cat_app_apply_mouse_position(app, bounds.left, bounds.top, 0.0f); + bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &tl_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &tl_y); + bongo_cat_app_apply_mouse_position(app, bounds.left + bounds.width - 1, + bounds.top + bounds.height - 1, 0.0f); + passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &br_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &br_y) && passed; + app->settings.model.mouse_centered = centered; + return passed && tl_x.value < -20.0f && tl_y.value > 20.0f && + br_x.value > 20.0f && br_y.value < -20.0f; +} diff --git a/src/runtime/diagnostics/live2d_pointer_audit.c b/src/runtime/diagnostics/live2d_pointer_audit.c new file mode 100644 index 00000000..ba9e6c4a --- /dev/null +++ b/src/runtime/diagnostics/live2d_pointer_audit.c @@ -0,0 +1,80 @@ +#include "live2d_pointer_audit.h" + +BongoCatPointerAudit bongo_cat_pointer_audit; + +static bool value(BongoCatApp *app, const char *id, float *output) { + BongoCatParameterRange range; + if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return false; + if (output) *output = range.value; + return true; +} + +static bool test_center(BongoCatApp *app, SDL_Rect *bounds, + double *center_x, double *center_y) { + int window_x, window_y, width, height; + if (!app || !app->window || !bounds || !center_x || !center_y || + !SDL_GetWindowPosition(app->window, &window_x, &window_y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + *center_x = window_x + width * 0.5; + *center_y = window_y + height * 0.5; + SDL_Point point = {(int)*center_x, (int)*center_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + return display && SDL_GetDisplayBounds(display, bounds) && + bounds->w > 0 && bounds->h > 0; +} + +bool bongo_cat_live2d_pointer_audit_run(BongoCatApp *app, bool mirror) { + SDL_Rect bounds; + double center_x, center_y; + if (!test_center(app, &bounds, ¢er_x, ¢er_y)) return false; + app->settings.model.mouse_mirror = mirror; + app->left_mouse_down = false; + bongo_cat_app_apply_mouse_position(app, center_x + bounds.w * 0.4, + center_y - bounds.h * 0.4, 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + return true; +} + +bool bongo_cat_live2d_pointer_reverse_audit_run(BongoCatApp *app) { + SDL_Rect bounds; + double center_x, center_y; + if (!test_center(app, &bounds, ¢er_x, ¢er_y)) return false; + bongo_cat_pointer_audit = (BongoCatPointerAudit){.ran = true}; + app->settings.model.mouse_mirror = false; + app->left_mouse_down = false; + const float ratios[4][2] = { + {-0.4f, -0.4f}, {0.4f, -0.4f}, {-0.4f, 0.4f}, {0.4f, 0.4f}}; + float previous_x = 0.0f; + bool previous_ready = value(app, "ParamAngleX", &previous_x); + for (int corner = 0; corner < 4; ++corner) { + bongo_cat_app_apply_mouse_position(app, + center_x + bounds.w * ratios[corner][0], + center_y + bounds.h * ratios[corner][1], 1.0f / 60.0f); + for (int frame = 0; frame < 90; ++frame) { + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + float current_x = previous_x; + if (!value(app, "ParamAngleX", ¤t_x)) return false; + if (previous_ready) { + float step = SDL_fabsf(current_x - previous_x); + if (step > bongo_cat_pointer_audit.maximum_step) + bongo_cat_pointer_audit.maximum_step = step; + } + previous_x = current_x; + previous_ready = true; + } + if (!value(app, "ParamAngleX", + &bongo_cat_pointer_audit.angle_x[corner]) || + !value(app, "ParamAngleY", + &bongo_cat_pointer_audit.angle_y[corner])) return false; + bool mouse_x = value(app, "ParamMouseX", + &bongo_cat_pointer_audit.mouse_x[corner]); + bool mouse_y = value(app, "ParamMouseY", + &bongo_cat_pointer_audit.mouse_y[corner]); + if (corner == 0) + bongo_cat_pointer_audit.has_mouse = mouse_x && mouse_y; + else if (bongo_cat_pointer_audit.has_mouse != (mouse_x && mouse_y)) + return false; + } + return true; +} diff --git a/src/runtime/diagnostics/live2d_pointer_audit.h b/src/runtime/diagnostics/live2d_pointer_audit.h new file mode 100644 index 00000000..d0b848b4 --- /dev/null +++ b/src/runtime/diagnostics/live2d_pointer_audit.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_LIVE2D_POINTER_AUDIT_H +#define BONGO_CAT_LIVE2D_POINTER_AUDIT_H + +#include "runtime.h" + +typedef struct BongoCatPointerAudit { + bool ran; + bool has_mouse; + float angle_x[4]; + float angle_y[4]; + float mouse_x[4]; + float mouse_y[4]; + float maximum_step; +} BongoCatPointerAudit; + +extern BongoCatPointerAudit bongo_cat_pointer_audit; + +bool bongo_cat_live2d_pointer_audit_run(BongoCatApp *app, bool mirror); +bool bongo_cat_live2d_pointer_reverse_audit_run(BongoCatApp *app); + +#endif diff --git a/src/runtime/live2d_viewer_audit.c b/src/runtime/diagnostics/live2d_viewer_audit.c similarity index 85% rename from src/runtime/live2d_viewer_audit.c rename to src/runtime/diagnostics/live2d_viewer_audit.c index e401a240..b2ed6da3 100644 --- a/src/runtime/live2d_viewer_audit.c +++ b/src/runtime/diagnostics/live2d_viewer_audit.c @@ -1,4 +1,5 @@ #include "runtime.h" +#include "live2d_viewer_timing.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -23,65 +24,27 @@ static const char *track_names[] = {"track-001", "track-002", "track-004", "track-008", "track-015", "track-030"}; static const char *return_names[] = {"return-001", "return-002", "return-004", "return-008", "return-015", "return-030"}; - -static bool viewer_timing(const char *path, int *track, int *returning) { - if (!path || !path[0]) return true; - FILE *file = bongo_cat_file_open(path, "rb"); - if (!file) return false; - bool found_track[6] = {0}, found_return[6] = {0}; - char line[512], name[32]; - while (fgets(line, sizeof(line), file)) { - char *first = strchr(line, ','); - size_t name_length = first ? (size_t)(first - line) : 0; - if (!first || !name_length || name_length >= sizeof(name)) continue; - memcpy(name, line, name_length); name[name_length] = '\0'; - char *after_phase = NULL, *after_mid = NULL; - (void)strtod(first + 1, &after_phase); - if (after_phase == first + 1 || *after_phase != ',') continue; - double capture_mid_ms = strtod(after_phase + 1, &after_mid); - if (after_mid == after_phase + 1) continue; - int frame = (int)lround(capture_mid_ms * 60.0 / 1000.0); - if (frame < 1) frame = 1; - for (size_t i = 0; i < 6; ++i) { - if (strcmp(name, track_names[i]) == 0) { - track[i] = frame; found_track[i] = true; - } else if (strcmp(name, return_names[i]) == 0) { - returning[i] = frame; found_return[i] = true; - } - } - } - fclose(file); - for (size_t i = 0; i < 6; ++i) - if (!found_track[i] || !found_return[i] || - (i && (track[i] < track[i - 1] || - returning[i] < returning[i - 1]))) return false; - return true; -} static bool parameter(BongoCatApp *app, const char *id, float *value) { BongoCatParameterRange range; if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return false; *value = range.value; return true; } - static bool parameter_range(BongoCatApp *app, const char *id, BongoCatParameterRange *range) { return bongo_cat_live2d_parameter(app->live2d, id, range); } - static float viewer_target(const BongoCatParameterRange *range, float direction, float weight) { float extent = direction < 0.0f ? fabsf(range->minimum) : fabsf(range->maximum); return direction * extent * weight; } - static bool parameter_near(BongoCatApp *app, const char *id, float expected, float tolerance) { float actual = 0.0f; return parameter(app, id, &actual) && fabsf(actual - expected) <= tolerance; } - static bool save_frame(BongoCatApp *app, const char *directory, const char *name, FILE *metrics) { int width = 0, height = 0; @@ -134,7 +97,6 @@ static bool save_frame(BongoCatApp *app, const char *directory, } return saved && required; } - static void render_step(BongoCatApp *app) { int width = 0, height = 0; SDL_GetWindowSizeInPixels(app->window, &width, &height); @@ -147,21 +109,18 @@ static void render_step(BongoCatApp *app) { bongo_cat_live2d_draw(app->live2d); SDL_GL_SwapWindow(app->window); } - static void advance(BongoCatApp *app, int frames) { for (int frame = 0; frame < frames; ++frame) { bongo_cat_live2d_update(app->live2d, 1.0f / 60.0f); render_step(app); } } - static bool save_timed(BongoCatApp *app, const char *directory, const char *name, int frame, FILE *metrics, FILE *timing) { if (timing) fprintf(timing, "%s,%d,%.3f\n", name, frame, frame * (1000.0 / 60.0)); return save_frame(app, directory, name, metrics); } - static bool advance_tracking(BongoCatApp *app, int frames, float *previous_x, float *maximum_step) { for (int frame = 0; frame < frames; ++frame) { @@ -186,7 +145,7 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { SDL_GetWindowSizeInPixels(app->window, &audit_width, &audit_height); bongo_cat_live2d_resize(app->live2d, audit_width, audit_height); char root[BONGO_CAT_PATH_CAP], native[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(root, sizeof(root), app->data_root, + if (!bongo_cat_path_join(root, sizeof(root), app->state_root, "cubism-viewer-audit") || !bongo_cat_path_join(native, sizeof(native), root, "native") || !bongo_cat_path_create_directory(root) || @@ -212,15 +171,20 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); advance(app, 120); + const char *expression_id = "Param6"; float expression_before = 0.0f; - bool expression_parameter = parameter(app, "Param6", &expression_before); + bool expression_parameter = parameter(app, expression_id, &expression_before); + if (!expression_parameter) { + expression_id = "Param5"; + expression_parameter = parameter(app, expression_id, &expression_before); + } if (bongo_cat_live2d_set_expression(app->live2d, 2)) { advance(app, 60); passed = save_frame(app, native, "track-000", metrics) && passed; passed = save_frame(app, native, "idle", metrics) && passed; passed = save_frame(app, native, "expression3", metrics) && passed; float expression_after = expression_before; - passed = expression_parameter && parameter(app, "Param6", + passed = expression_parameter && parameter(app, expression_id, &expression_after) && expression_after > expression_before + 0.5f && passed; } else passed = false; @@ -228,13 +192,14 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { advance(app, 12); const float target_drag_x = 0.497041464f; const float target_drag_y = 0.598930478f; - BongoCatParameterRange angle_x_range, angle_y_range; + BongoCatParameterRange angle_x_range, angle_y_range, angle_z_range; BongoCatParameterRange eye_x_range, eye_y_range, body_x_range; passed = parameter_range(app, "ParamAngleX", &angle_x_range) && parameter_range(app, "ParamAngleY", &angle_y_range) && parameter_range(app, "ParamEyeBallX", &eye_x_range) && parameter_range(app, "ParamEyeBallY", &eye_y_range) && passed; bool has_body_x = parameter_range(app, "ParamBodyAngleX", &body_x_range); + bool has_angle_z = parameter_range(app, "ParamAngleZ", &angle_z_range); const float viewer_gain = 1.2f; const float target_x = viewer_target(&angle_x_range, target_drag_x, viewer_gain); @@ -244,11 +209,14 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { target_drag_x, viewer_gain); const float target_eye_y = viewer_target(&eye_y_range, target_drag_y, viewer_gain); - bongo_cat_live2d_set_dragging(app->live2d, target_drag_x, target_drag_y); + const float target_z = has_angle_z ? viewer_target(&angle_z_range, + -target_drag_x * target_drag_y, 1.0f) : 0.0f; + bongo_cat_live2d_set_centered_dragging(app->live2d, target_drag_x, target_drag_y); float previous_x = 0.0f, maximum_step = 0.0f; int track_frames[] = {1, 2, 4, 8, 15, 30}; int return_frames[] = {1, 2, 4, 8, 15, 30}; - passed = viewer_timing(app->smoke_viewer_trace, track_frames, + passed = bongo_cat_live2d_viewer_timing(app->smoke_viewer_trace, + track_frames, return_frames) && passed; passed = parameter(app, "ParamAngleX", &previous_x) && passed; int previous = 0; @@ -270,14 +238,14 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { parameter_near(app, "ParamAngleY", target_y, 0.4f) && parameter_near(app, "ParamEyeBallX", target_eye_x, 0.02f) && parameter_near(app, "ParamEyeBallY", target_eye_y, 0.02f) && passed; + if (has_angle_z) passed = parameter_near(app, "ParamAngleZ", target_z, 0.4f) && passed; if (has_body_x) passed = parameter_near(app, "ParamBodyAngleX", viewer_target(&body_x_range, target_drag_x, viewer_gain), 0.2f) && passed; - bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); + bongo_cat_live2d_set_centered_dragging(app->live2d, 0.0f, 0.0f); previous = 0; for (size_t i = 0; i < 6; ++i) { - passed = advance_tracking(app, return_frames[i] - previous, - &previous_x, &maximum_step) && passed; + passed = advance_tracking(app, return_frames[i] - previous, &previous_x, &maximum_step) && passed; passed = save_timed(app, native, return_names[i], return_frames[i], metrics, timing) && passed; previous = return_frames[i]; @@ -289,6 +257,7 @@ bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app) { parameter_near(app, "ParamEyeBallY", 0.0f, 0.01f) && parameter_near(app, "ParamMouseX", 0.0f, 0.001f) && parameter_near(app, "ParamMouseY", 0.0f, 0.001f) && passed; + if (has_angle_z) passed = parameter_near(app, "ParamAngleZ", 0.0f, 0.25f) && passed; fclose(timing); fclose(metrics); SDL_SetWindowSize(app->window, original_width, original_height); diff --git a/src/runtime/diagnostics/live2d_viewer_timing.c b/src/runtime/diagnostics/live2d_viewer_timing.c new file mode 100644 index 00000000..29cffcf9 --- /dev/null +++ b/src/runtime/diagnostics/live2d_viewer_timing.c @@ -0,0 +1,46 @@ +#include "live2d_viewer_timing.h" +#include "bongo_cat/file.h" + +#include +#include +#include +#include + +bool bongo_cat_live2d_viewer_timing(const char *path, int *track, + int *returning) { + static const char *track_names[] = {"track-001", "track-002", + "track-004", "track-008", "track-015", "track-030"}; + static const char *return_names[] = {"return-001", "return-002", + "return-004", "return-008", "return-015", "return-030"}; + if (!path || !path[0]) return true; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + bool found_track[6] = {0}, found_return[6] = {0}; + char line[512], name[32]; + while (fgets(line, sizeof(line), file)) { + char *first = strchr(line, ','); + size_t name_length = first ? (size_t)(first - line) : 0; + if (!first || !name_length || name_length >= sizeof(name)) continue; + memcpy(name, line, name_length); name[name_length] = '\0'; + char *after_phase = NULL, *after_mid = NULL; + (void)strtod(first + 1, &after_phase); + if (after_phase == first + 1 || *after_phase != ',') continue; + double capture_mid_ms = strtod(after_phase + 1, &after_mid); + if (after_mid == after_phase + 1) continue; + int frame = (int)lround(capture_mid_ms * 60.0 / 1000.0); + if (frame < 1) frame = 1; + for (size_t i = 0; i < 6; ++i) { + if (strcmp(name, track_names[i]) == 0) { + track[i] = frame; found_track[i] = true; + } else if (strcmp(name, return_names[i]) == 0) { + returning[i] = frame; found_return[i] = true; + } + } + } + fclose(file); + for (size_t i = 0; i < 6; ++i) + if (!found_track[i] || !found_return[i] || + (i && (track[i] < track[i - 1] || + returning[i] < returning[i - 1]))) return false; + return true; +} diff --git a/src/runtime/diagnostics/live2d_viewer_timing.h b/src/runtime/diagnostics/live2d_viewer_timing.h new file mode 100644 index 00000000..e1737070 --- /dev/null +++ b/src/runtime/diagnostics/live2d_viewer_timing.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_LIVE2D_VIEWER_TIMING_H +#define BONGO_CAT_LIVE2D_VIEWER_TIMING_H + +#include + +bool bongo_cat_live2d_viewer_timing(const char *path, int *track, + int *returning); + +#endif diff --git a/src/runtime/live2d_visual_audit.c b/src/runtime/diagnostics/live2d_visual_audit.c similarity index 94% rename from src/runtime/live2d_visual_audit.c rename to src/runtime/diagnostics/live2d_visual_audit.c index ad451bd1..22ea1321 100644 --- a/src/runtime/live2d_visual_audit.c +++ b/src/runtime/diagnostics/live2d_visual_audit.c @@ -135,9 +135,9 @@ static size_t behavior_indexes(const BongoCatApp *app, BongoCatBehaviorKind kind return count; } -static bool lock_motion_matrix(FILE *file, BongoCatApp *app, +static bool motion_replay_matrix(FILE *file, BongoCatApp *app, const BongoCatBehaviorEntry *entry, bool mver) { - BongoCatLive2DVisualState active = {0}, stable = {0}, reset = {0}; + BongoCatLive2DVisualState active = {0}, stable = {0}, replayed = {0}; if (!bongo_cat_live2d_start_motion(app->live2d, entry->group, entry->index)) return false; advance(app, 1); @@ -150,19 +150,19 @@ static bool lock_motion_matrix(FILE *file, BongoCatApp *app, if (!bongo_cat_live2d_start_motion(app->live2d, entry->group, entry->index)) return false; advance(app, 1); - snprintf(label, sizeof(label), "motion-%d-reset", entry->index); - passed = record(file, app, label, !mver, &reset) && passed; - if (mver) passed = !active.fitted && !stable.fitted && !reset.fitted && + snprintf(label, sizeof(label), "motion-%d-replayed", entry->index); + passed = record(file, app, label, !mver, &replayed) && passed; + if (mver) passed = !active.fitted && !stable.fitted && !replayed.fitted && close_scale(active.fit_scale, 1.0f) && close_scale(stable.fit_scale, 1.0f) && - close_scale(reset.fit_scale, 1.0f) && passed; + close_scale(replayed.fit_scale, 1.0f) && passed; return passed; } bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app) { if (!app || !app->live2d || !app->window) return false; char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, "live2d-visual-audit.csv")) return false; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return false; @@ -210,7 +210,7 @@ bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app) { const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; if (entry->kind != BONGO_CAT_BEHAVIOR_MOTION || strstr(entry->group, "_lock") == NULL) continue; - passed = lock_motion_matrix(file, app, entry, true) && passed; + passed = motion_replay_matrix(file, app, entry, true) && passed; } } bongo_cat_live2d_set_dragging(app->live2d, 0.0f, 0.0f); diff --git a/src/runtime/frame_audit.c b/src/runtime/frame_audit.c deleted file mode 100644 index 03dd619f..00000000 --- a/src/runtime/frame_audit.c +++ /dev/null @@ -1,141 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include -#include - -typedef struct CoverWriter { FILE *file; bool ok; } CoverWriter; - -static void cover_write(void *context, void *data, int size) { - CoverWriter *writer = context; - if (writer->ok && fwrite(data, 1, (size_t)size, writer->file) != (size_t)size) - writer->ok = false; -} - -void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height) { - if (!app || !app->pending_model_cover_path[0]) return; - char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s", app->pending_model_cover_path); - app->pending_model_cover_path[0] = '\0'; - if (width < 2 || height < 2 || width > 2048 || height > 2048) return; - size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; - unsigned char *pixels = malloc(bytes); - if (!pixels) return; - glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - for (int y = 0; y < height / 2; ++y) { - unsigned char *top = pixels + (size_t)y * pitch; - unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; - for (size_t i = 0; i < pitch; ++i) { - unsigned char value = top[i]; top[i] = bottom[i]; bottom[i] = value; - } - } - FILE *file = bongo_cat_file_open(path, "wb"); - CoverWriter writer = {file, file != NULL}; - bool ok = file && stbi_write_png_to_func(cover_write, &writer, - width, height, 4, pixels, (int)pitch) && writer.ok; - if (file && fclose(file) != 0) ok = false; - if (!ok) bongo_cat_file_remove(path); - free(pixels); -} - -typedef struct FrameStats { - unsigned visible; - unsigned alpha; -} FrameStats; - -static FrameStats frame_stats(const unsigned char *pixels, - int width, int height, size_t pitch) { - FrameStats stats = {0}; - for (int y = 0; y < height; y += 4) { - const unsigned char *row = pixels + (size_t)y * pitch; - for (int x = 0; x < width; x += 4) { - const unsigned char *pixel = row + (size_t)x * 4; - if ((unsigned)pixel[0] + pixel[1] + pixel[2] > 60) stats.visible++; - if (pixel[3] > 8) stats.alpha++; - } - } - return stats; -} - -static void record_frame(BongoCatApp *app, const unsigned char *pixels, - int width, int height, size_t pitch) { - if (!app->smoke_frame_series) return; - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-series.csv"); - bool header = !bongo_cat_path_is_file(path); - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - if (header) fputs("ticks_ns,width,height,visible_pixels,alpha_pixels," - "scale_percent,opacity_percent,window_opacity,model_mode," - "model_state_consistent,selection_serial,window_config_visible," - "window_os_visible\n", file); - FrameStats stats = frame_stats(pixels, width, height, pitch); - bool model_consistent = bongo_cat_live2d_ready(app->live2d) && - app->loaded_model[0] && - strcmp(app->loaded_model, app->config.current_model) == 0; - bool os_visible = (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) == 0; - fprintf(file, "%llu,%d,%d,%u,%u,%.3f,%.3f,%.5f,%s,%d,%u,%d,%d\n", - (unsigned long long)SDL_GetTicksNS(), width, height, - stats.visible, stats.alpha, - app->config.window.scale_percent, app->config.window.opacity_percent, - bongo_cat_platform_get_opacity(&app->platform), - bongo_cat_mode_name(app->config.current_mode), model_consistent, - app->model_selection_serial, app->config.window.visible, os_visible); - fclose(file); -} - -void bongo_cat_frame_audit(BongoCatApp *app, int width, int height) { - if (!app || !app->smoke || width < 2 || height < 2) return; - char path[BONGO_CAT_PATH_CAP]; - if (!app->smoke_frame_audited) { - app->smoke_frame_audited = true; - const int points[][2] = {{0, 0}, {width - 1, 0}, {0, height - 1}, - {width - 1, height - 1}, {width / 2, height / 2}}; - unsigned transparent = 0, opaque = 0; - for (size_t i = 0; i < sizeof(points) / sizeof(points[0]); ++i) { - unsigned char pixel[4] = {0}; - glReadPixels(points[i][0], points[i][1], 1, 1, GL_RGBA, - GL_UNSIGNED_BYTE, pixel); - if (pixel[3] < 16) transparent++; - if (pixel[3] > 239) opaque++; - } - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame-alpha.txt"); - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { - int sample_buffers = 0, sample_count = 0; - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); - fprintf(file, "samples=5 transparent=%u opaque=%u sample_buffers=%d " - "sample_count=%d gl_error=%u\n", transparent, opaque, - sample_buffers, sample_count, (unsigned)glGetError()); - fclose(file); - } - } - size_t pitch = (size_t)width * 4, bytes = pitch * (size_t)height; - unsigned char *pixels = malloc(bytes); - if (!pixels) return; - glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); - record_frame(app, pixels, width, height, pitch); - uint64_t now = SDL_GetTicksNS(); - if (app->frame_audit_bmp_ns && now >= app->frame_audit_bmp_ns && - now - app->frame_audit_bmp_ns < 50000000ull) { - free(pixels); return; - } - app->frame_audit_bmp_ns = now; - unsigned char *row = malloc(pitch); - if (!row) { free(pixels); return; } - for (int y = 0; y < height / 2; ++y) { - unsigned char *top = pixels + (size_t)y * pitch; - unsigned char *bottom = pixels + (size_t)(height - 1 - y) * pitch; - memcpy(row, top, pitch); memcpy(top, bottom, pitch); memcpy(bottom, row, pitch); - } - SDL_Surface *surface = SDL_CreateSurfaceFrom(width, height, - SDL_PIXELFORMAT_RGBA32, pixels, (int)pitch); - bongo_cat_path_join(path, sizeof(path), app->data_root, "frame.bmp"); - if (surface) { SDL_SaveBMP(surface, path); SDL_DestroySurface(surface); } - free(row); free(pixels); -} diff --git a/src/runtime/gamepad.c b/src/runtime/input/gamepad.c similarity index 96% rename from src/runtime/gamepad.c rename to src/runtime/input/gamepad.c index 1791f40b..af58f01b 100644 --- a/src/runtime/gamepad.c +++ b/src/runtime/input/gamepad.c @@ -114,7 +114,7 @@ void bongo_cat_gamepads_set_enabled(BongoCatApp *app, bool enabled) { void bongo_cat_gamepad_event(BongoCatApp *app, const void *raw) { const SDL_Event *event = raw; if (event->type == SDL_EVENT_GAMEPAD_ADDED) { - if (app->config.current_mode == BONGO_CAT_MODE_GAMEPAD && + if (app->loaded_mode == BONGO_CAT_MODE_GAMEPAD && !app->active_gamepad) { SDL_Gamepad *gamepad = SDL_GetGamepadFromID(event->gdevice.which); if (!gamepad) gamepad = SDL_OpenGamepad(event->gdevice.which); @@ -128,12 +128,12 @@ void bongo_cat_gamepad_event(BongoCatApp *app, const void *raw) { if (event->gdevice.which == app->active_gamepad) { app->active_gamepad = 0; bongo_cat_app_reset_gamepad(app); - if (app->config.current_mode == BONGO_CAT_MODE_GAMEPAD) + if (app->loaded_mode == BONGO_CAT_MODE_GAMEPAD) select_first_gamepad(app); } return; } - if (app->config.current_mode != BONGO_CAT_MODE_GAMEPAD) return; + if (app->loaded_mode != BONGO_CAT_MODE_GAMEPAD) return; BongoCatInputEvent input = {0}; SDL_JoystickID source; if (event->type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { diff --git a/src/runtime/input/hover_fade.h b/src/runtime/input/hover_fade.h new file mode 100644 index 00000000..ecb48db9 --- /dev/null +++ b/src/runtime/input/hover_fade.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_HOVER_FADE_H +#define BONGO_CAT_HOVER_FADE_H + +#include + +static inline float bongo_cat_hover_fade_curve(float phase) { + return phase * phase * (3.0f - 2.0f * phase); +} + +/* Recover the curve position from the last successfully displayed opacity. */ +static inline float bongo_cat_hover_fade_phase(float opacity) { + if (opacity <= 0.0f) return 0.0f; + if (opacity >= 1.0f) return 1.0f; + float low = 0.0f, high = 1.0f; + for (int i = 0; i < 20; ++i) { + float mid = (low + high) * 0.5f; + if (bongo_cat_hover_fade_curve(mid) < opacity) low = mid; + else high = mid; + } + return (low + high) * 0.5f; +} + +static inline float bongo_cat_hover_fade_step(float phase, bool hidden, + float elapsed, float duration) { + if (duration <= 0.0f) return hidden ? 0.0f : 1.0f; + if (elapsed <= 0.0f) return phase; + phase += (hidden ? -elapsed : elapsed) / duration; + return phase < 0.0f ? 0.0f : phase > 1.0f ? 1.0f : phase; +} + +#endif diff --git a/src/runtime/input/mouse.c b/src/runtime/input/mouse.c new file mode 100644 index 00000000..335a63ba --- /dev/null +++ b/src/runtime/input/mouse.c @@ -0,0 +1,106 @@ +#include "runtime.h" +#include "hover_fade.h" + +#include + +/* Window-facing pointer state: hover hiding and hit-test scheduling. The + sampling pipeline, display bounds and model output have their own modules. */ +void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { + app->pointer_known = true; + app->pointer_x = x; + app->pointer_y = y; + bongo_cat_window_schedule_pointer_hit(app); + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); +} + +static bool hover_opacity(BongoCatApp *app, float opacity) { + bongo_cat_window_snapshot_end(app); + if (bongo_cat_platform_set_opacity(&app->platform, opacity)) return true; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Hover hiding disabled after opacity update failed: %s", SDL_GetError()); + app->platform.hover_hide_unavailable = true; + app->hover_fade_active = false; + app->hover_hidden = app->hover_inside = false; + /* Best effort: retain the successful cached opacity if restoration fails. */ + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + bongo_cat_window_sync_click_through(app); + return false; +} + +/* Direction changes retrace the same curve, without restarting its easing. + Integrating elapsed time also makes duration edits continuous. */ +void bongo_cat_app_update_hover_fade(BongoCatApp *app, uint64_t now) { + if (!app || !app->hover_fade_active) return; + float duration = app->settings.window.hide_fade_seconds; + if (duration > 0.0f && now < app->hover_fade_next_ns) return; + float seconds = now > app->hover_fade_tick_ns ? + (float)((double)(now - app->hover_fade_tick_ns) / 1000000000.0) : 0.0f; + app->hover_fade_tick_ns = now; + app->hover_fade_next_ns = now + 8000000ull; + bool decreasing = app->hover_fade_target < app->hover_fade_phase; + float next = bongo_cat_hover_fade_step(app->hover_fade_phase, + decreasing, seconds, duration); + app->hover_fade_phase = decreasing ? SDL_max(next, app->hover_fade_target) : + SDL_min(next, app->hover_fade_target); + float phase = app->hover_fade_phase; + float opacity = phase == app->hover_fade_target ? + (app->hover_hidden ? 0.0f : app->session.window.opacity_percent / 100.0f) : + app->hover_fade_opacity * bongo_cat_hover_fade_curve(phase); + if (hover_opacity(app, opacity)) + app->hover_fade_active = phase != app->hover_fade_target; +} + +void bongo_cat_app_cancel_hover_fade(BongoCatApp *app) { + if (app) { + app->hover_fade_active = false; + app->hover_fade_next_ns = 0; + } +} + +static void hover_start_fade(BongoCatApp *app, bool hidden, uint64_t now) { + float target = hidden ? 0.0f : + app->session.window.opacity_percent / 100.0f; + app->hover_hidden = hidden; + if (app->settings.window.hide_fade_seconds <= 0.0f) { + app->hover_fade_active = false; + hover_opacity(app, target); + return; + } + float current = bongo_cat_platform_get_opacity(&app->platform); + app->hover_fade_opacity = SDL_max(current, + app->session.window.opacity_percent / 100.0f); + app->hover_fade_phase = bongo_cat_hover_fade_phase( + app->hover_fade_opacity > 0.0f ? current / app->hover_fade_opacity : 0.0f); + app->hover_fade_target = bongo_cat_hover_fade_phase( + app->hover_fade_opacity > 0.0f ? target / app->hover_fade_opacity : 0.0f); + app->hover_fade_tick_ns = now; + app->hover_fade_next_ns = now + 8000000ull; + app->hover_fade_active = current != target; +} + +void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { + if (!app || !app->window) return; + bool enabled = !app->platform.hover_hide_unavailable && + app->settings.window.hide_on_hover && + app->settings.window.pass_through && app->settings.window.always_on_top && + app->session.window.visible && !app->window_minimized && + !app->startup_visibility_pending; + float local_x = 0.0f, local_y = 0.0f; + /* Window opacity does not change the frame alpha, so hidden pets can + still detect the pointer leaving their visible model pixels. */ + bool inside = enabled && app->pointer_known && + bongo_cat_platform_pointer_local(&app->platform, + app->pointer_x, app->pointer_y, &local_x, &local_y); + if (inside) { + /* Bound GPU readbacks on backends without a CPU alpha cache, even + when high-rate input wakes the event loop faster than rendering. */ + if (now < app->hover_deadline_ns) return; + app->hover_deadline_ns = now + 8000000ull; + inside = bongo_cat_window_visible_at_pointer(app, local_x, local_y); + } else if (!enabled) app->hover_deadline_ns = 0; + app->hover_inside = inside; + if (app->hover_hidden == inside) return; + hover_start_fade(app, inside, now); + bongo_cat_window_sync_click_through(app); +} diff --git a/src/runtime/input/mouse_bounds.c b/src/runtime/input/mouse_bounds.c new file mode 100644 index 00000000..b8cbb226 --- /dev/null +++ b/src/runtime/input/mouse_bounds.c @@ -0,0 +1,112 @@ +#include "runtime.h" +#include "mouse_internal.h" + +#include +#include + +#ifdef _WIN32 +#include +#endif + +static bool mver_pointer_bounds(const BongoCatApp *app, SDL_Rect *bounds) { + const BongoCatLive2DRenderOptions *options = &app->model_render_options; + if (!options->mver_projection || !bounds) return false; + if (options->custom_pointer_bounds) { + *bounds = (SDL_Rect){options->pointer_left, options->pointer_top, + options->pointer_right - options->pointer_left, + options->pointer_bottom - options->pointer_top}; + return bounds->w > 0 && bounds->h > 0; + } +#ifdef _WIN32 + typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextFn)( + DPI_AWARENESS_CONTEXT); + FARPROC set_thread_dpi_proc = GetProcAddress( + GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"); + SetThreadDpiAwarenessContextFn set_thread_dpi = NULL; + memcpy(&set_thread_dpi, &set_thread_dpi_proc, sizeof(set_thread_dpi)); + DPI_AWARENESS_CONTEXT previous = set_thread_dpi + ? set_thread_dpi(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) : NULL; + RECT desktop = {0}; + bool available = GetWindowRect(GetDesktopWindow(), &desktop) != FALSE; + if (previous && set_thread_dpi) set_thread_dpi(previous); + if (available && desktop.right > 0 && desktop.bottom > 0) { + /* Mver 0.1.6 uses a zero origin and stores right/bottom as dimensions. */ + *bounds = (SDL_Rect){0, 0, desktop.right, desktop.bottom}; + return true; + } +#endif + SDL_DisplayID primary = SDL_GetPrimaryDisplay(); + SDL_Rect display; + if (!primary || !SDL_GetDisplayBounds(primary, &display)) return false; + *bounds = (SDL_Rect){0, 0, display.w, display.h}; + return bounds->w > 0 && bounds->h > 0; +} + +static float clamp_ratio(float value) { + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +static bool model_pointer_center(BongoCatApp *app, double *x, double *y) { + int window_x, window_y, width, height; + if (!app || !app->window || !x || !y || + !SDL_GetWindowPosition(app->window, &window_x, &window_y) || + !SDL_GetWindowSize(app->window, &width, &height) || + width <= 0 || height <= 0) return false; + if (!app->model_pointer_anchor_ready) { + BongoCatLive2DVisualState state = {0}; + if (bongo_cat_live2d_visual_state(app->live2d, &state) && state.visible) { + float center_x = (state.visible_min_x + state.visible_max_x) * 0.5f; + float center_y = (state.visible_min_y + state.visible_max_y) * 0.5f; + if (isfinite(center_x) && isfinite(center_y)) { + app->model_pointer_anchor_x = clamp_ratio(center_x * 0.5f + 0.5f); + app->model_pointer_anchor_y = clamp_ratio(0.5f - center_y * 0.5f); + app->model_pointer_anchor_ready = true; + } + } + } + float anchor_x = app->model_pointer_anchor_ready + ? app->model_pointer_anchor_x : 0.5f; + float anchor_y = app->model_pointer_anchor_ready + ? app->model_pointer_anchor_y : 0.5f; + *x = window_x + (double)width * anchor_x; + *y = window_y + (double)height * anchor_y; + return true; +} + +static bool model_pointer_bounds(BongoCatApp *app, SDL_Rect *bounds, + double *center_x, double *center_y) { + double visible_center_x, visible_center_y; + if (!bounds || !model_pointer_center(app, &visible_center_x, + &visible_center_y)) return false; + if (center_x) *center_x = visible_center_x; + if (center_y) *center_y = visible_center_y; + SDL_Point point = {(int)visible_center_x, (int)visible_center_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + return display && SDL_GetDisplayBounds(display, bounds) && + bounds->w > 0 && bounds->h > 0; +} + +bool bongo_cat_app_mouse_projection(BongoCatApp *app, double screen_x, + double screen_y, BongoCatMouseProjection *projection) { + if (!app || !projection || !isfinite(screen_x) || !isfinite(screen_y)) + return false; + *projection = (BongoCatMouseProjection){0}; + SDL_Rect bounds = {0}; + bool centered = app->settings.model.mouse_centered; + projection->centered = centered && model_pointer_bounds(app, &bounds, + &projection->center_x, &projection->center_y); + bool mapped = projection->centered || (!centered && + mver_pointer_bounds(app, &bounds)); + if (!mapped) { + SDL_Point point = {(int)screen_x, (int)screen_y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&point); + if (!display || !SDL_GetDisplayBounds(display, &bounds)) return false; + } + if (bounds.w <= 0 || bounds.h <= 0) return false; + projection->bounds = (BongoCatMverPointerBounds){ + bounds.x, bounds.y, bounds.w, bounds.h + }; + return true; +} diff --git a/src/runtime/input/mouse_internal.h b/src/runtime/input/mouse_internal.h new file mode 100644 index 00000000..5e6499f8 --- /dev/null +++ b/src/runtime/input/mouse_internal.h @@ -0,0 +1,23 @@ +#ifndef BONGO_CAT_MOUSE_INTERNAL_H +#define BONGO_CAT_MOUSE_INTERNAL_H + +#include "bongo_cat/app.h" + +/* One update uses the same coordinate space for motion limits and model output. + Resolve it from current display geometry; do not cache across window moves. */ +typedef struct BongoCatMouseProjection { + BongoCatMverPointerBounds bounds; + double center_x, center_y; + bool centered; +} BongoCatMouseProjection; + +bool bongo_cat_app_mouse_projection(BongoCatApp *app, double screen_x, + double screen_y, BongoCatMouseProjection *projection); +bool bongo_cat_app_map_pointer(BongoCatApp *app, + const BongoCatMouseProjection *projection, bool relative_requested, + double absolute_x, double absolute_y, double *x, double *y, bool *changed); +void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, + const BongoCatMouseProjection *projection, double hand_x, double hand_y, + double gaze_x, double gaze_y); + +#endif diff --git a/src/runtime/input/mouse_mapping.c b/src/runtime/input/mouse_mapping.c new file mode 100644 index 00000000..bfad4db9 --- /dev/null +++ b/src/runtime/input/mouse_mapping.c @@ -0,0 +1,105 @@ +#include "runtime.h" +#include "mouse_internal.h" +#include "bongo_cat/overlay.h" + +#include + +bool bongo_cat_app_map_pointer(BongoCatApp *app, + const BongoCatMouseProjection *projection, bool relative_requested, + double absolute_x, double absolute_y, double *x, double *y, bool *changed) { + if (!app || !projection || !x || !y || !changed) return false; + const BongoCatMverPointerBounds *bounds = &projection->bounds; + double relative_x = 0.0, relative_y = 0.0; + bool use_relative = relative_requested && + bongo_cat_platform_relative_pointer(&app->platform, + &relative_x, &relative_y); + bool initialized = app->mver_pointer.initialized; + double previous_x = app->mver_pointer.x, previous_y = app->mver_pointer.y; + /* Preserve the virtual position while relative samples are unavailable. */ + if (relative_requested && initialized && !use_relative) { + absolute_x = previous_x; + absolute_y = previous_y; + } + if (!bongo_cat_mver_pointer_update(&app->mver_pointer, + absolute_x, absolute_y, relative_x, relative_y, use_relative, + bounds, x, y)) return false; + *changed = !initialized || previous_x != *x || previous_y != *y; + return true; +} + +static void set_parameter(BongoCatApp *app, const char *id, + float x_ratio, float y_ratio, bool horizontal_mirror) { + BongoCatParameterRange range; + if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return; + size_t length = strlen(id); + char axis = length ? id[length - 1] : 'X'; + float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, + x_ratio, y_ratio, axis, horizontal_mirror); + bongo_cat_live2d_set_parameter(app->live2d, id, value); +} + +void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, + const BongoCatMouseProjection *projection, double hand_x, double hand_y, + double gaze_x, double gaze_y) { + if (!app || !projection) return; + const BongoCatMverPointerBounds *bounds = &projection->bounds; + float hand_x_ratio, hand_y_ratio; + if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, bounds, + &hand_x_ratio, &hand_y_ratio)) return; + float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; + if (projection->centered) { + gaze_x_ratio = bongo_cat_mouse_centered_ratio(gaze_x, projection->center_x, + bounds->left, bounds->left + bounds->width); + gaze_y_ratio = bongo_cat_mouse_centered_ratio(gaze_y, projection->center_y, + bounds->top, bounds->top + bounds->height); + } + bool exact_pointer = bongo_cat_overlay_mver_pointer_enabled(app->overlay); + bool overlay_left_handed = exact_pointer && + bongo_cat_overlay_mver_pointer_left_handed(app->overlay); + bool left_handed = app->model_render_options.pointer_left_handed || + overlay_left_handed; + bool horizontal_mirror = left_handed != app->settings.model.mouse_mirror; + float drag_x = 0.0f, drag_y = 0.0f; + bongo_cat_mouse_drag_coordinates(gaze_x_ratio, gaze_y_ratio, + horizontal_mirror, &drag_x, &drag_y); + bool overlay_mirror = horizontal_mirror != overlay_left_handed; + float overlay_x_ratio = overlay_mirror + ? 1.0f - hand_x_ratio : hand_x_ratio; + bongo_cat_overlay_set_mver_pointer(app->overlay, overlay_x_ratio, hand_y_ratio, + app->left_mouse_down, app->right_mouse_down, app->side_mouse_down); + if (!exact_pointer) { + set_parameter(app, "ParamMouseX", hand_x_ratio, hand_y_ratio, + horizontal_mirror); + set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio, + horizontal_mirror); + } + /* Pointer and window messages are not atomic during a native window move. */ + if (!app->settings.model.mouse_centered || !app->window_drag_active) { + if (app->settings.model.mouse_centered) + bongo_cat_live2d_set_centered_dragging(app->live2d, drag_x, drag_y); + else bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); + } + app->dirty = true; +} + +void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { + if (!app) return; + bongo_cat_platform_relative_pointer_release(&app->platform); + app->mver_pointer = (BongoCatMverPointerState){0}; + app->model_pointer_anchor_ready = false; + app->pointer_known = false; + app->mouse_last_ns = 0; + app->pointer_relative_active = false; + app->pointer_cursor_locked = false; + app->mouse_button_event_pending = false; + app->dirty = true; +} + +void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, + float elapsed_seconds) { + if (!app || app->settings.model.ignore_mouse) return; + (void)elapsed_seconds; + BongoCatMouseProjection projection; + if (bongo_cat_app_mouse_projection(app, x, y, &projection)) + bongo_cat_app_apply_mouse_coordinates(app, &projection, x, y, x, y); +} diff --git a/src/runtime/input/mouse_pipeline.c b/src/runtime/input/mouse_pipeline.c new file mode 100644 index 00000000..695b2bda --- /dev/null +++ b/src/runtime/input/mouse_pipeline.c @@ -0,0 +1,121 @@ +#include "runtime.h" +#include "mouse_internal.h" + +#include + +/* Select screen input, then map it independently for the model. */ +#ifndef _WIN32 +static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, + const char *parameter) { + if (*current == pressed) return false; + *current = pressed; + bongo_cat_live2d_set_parameter(app->live2d, parameter, + pressed ? 1.0f : 0.0f); + if (!pressed) bongo_cat_window_mark_hit_dirty(app); + app->dirty = true; + return true; +} +#endif + +static bool update_pointer_mode(BongoCatApp *app, bool cursor_locked) { + bool relative = !app->settings.model.ignore_mouse && cursor_locked; + bool changed = relative != app->pointer_relative_active || + (relative && cursor_locked != app->pointer_cursor_locked); + if (changed) { + if (relative != app->pointer_relative_active) + app->mver_pointer = (BongoCatMverPointerState){0}; + if (relative) bongo_cat_platform_relative_pointer_reset(&app->platform); + else bongo_cat_platform_relative_pointer_release(&app->platform); + app->pointer_relative_active = relative; + } + app->pointer_cursor_locked = cursor_locked; + return changed; +} + +void bongo_cat_app_apply_mouse(BongoCatApp *app) { + if (!app) return; + double target_x, target_y; + bool received = bongo_cat_input_take_mouse(&app->input, &target_x, &target_y); + uint64_t now = SDL_GetTicksNS(); + if (received) { + app->mouse_last_ns = now ? now : 1; + } + float global_x = 0.0f, global_y = 0.0f; + SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); + bool button_event_pending = app->mouse_button_event_pending; + bool cursor_locked = bongo_cat_platform_pointer_locked(&app->platform); + bool cursor_lock_changed = cursor_locked != app->pointer_cursor_locked; + bool button_changed = false; +#ifdef _WIN32 + /* Model buttons belong to Raw Input; cursor polling is position-only. */ + (void)buttons; +#else + if (!button_event_pending && !cursor_locked) { + button_changed = reconcile_button(app, &app->left_mouse_down, + (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); + button_changed = reconcile_button(app, &app->right_mouse_down, + (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || + button_changed; + app->back_mouse_down = (buttons & SDL_BUTTON_X1MASK) != 0; + app->forward_mouse_down = (buttons & SDL_BUTTON_X2MASK) != 0; + bool side_down = app->back_mouse_down || app->forward_mouse_down; + if (app->side_mouse_down != side_down) { + app->side_mouse_down = side_down; + button_changed = true; + app->dirty = true; + } + } +#endif + bool native_selected = received || (app->pointer_known && + bongo_cat_mouse_sample_fresh(now, app->mouse_last_ns)); + /* Native screen updates are optional; SDL supplies the current position. */ + if (!native_selected) { + target_x = global_x; + target_y = global_y; + } else if (!received) { + target_x = app->pointer_x; + target_y = app->pointer_y; + } + /* Seed relative motion from the observed lock point. */ + if (cursor_locked && cursor_lock_changed) { + target_x = global_x; + target_y = global_y; + } + bool moved = !app->pointer_known || app->pointer_x != target_x || + app->pointer_y != target_y; + if (moved) { + bongo_cat_app_track_hover(app, target_x, target_y); + /* Refresh the hit pixel before the next button press. Waiting for the + scheduled frame can leave a stale transparent state for fast clicks. */ + if (app->click_through_applied && !app->left_mouse_down && + !app->right_mouse_down) + bongo_cat_window_capture_pointer_hit(app); + } + bongo_cat_window_sync_click_through(app); + bool pointer_mode_changed = update_pointer_mode(app, cursor_locked); + app->mouse_button_event_pending = false; + if (app->settings.model.ignore_mouse) return; + bool relative_requested = app->pointer_relative_active; + if (!relative_requested && !moved && !button_changed && + !pointer_mode_changed && !button_event_pending) return; + + /* Resolve once: tracking limits and model output share the same display, + custom workarea and centered anchor for this update. */ + BongoCatMouseProjection projection; + if (!bongo_cat_app_mouse_projection(app, target_x, target_y, &projection)) + return; + double model_x = target_x, model_y = target_y; + bool model_moved = moved; + bool profile_relative = app->model_render_options.mver_projection && + app->model_render_options.mouse_force_move; + bool map_pointer = cursor_locked || (app->model_render_options.mver_projection && + (!app->settings.model.mouse_centered || profile_relative)); + if (map_pointer && !bongo_cat_app_map_pointer(app, &projection, + relative_requested, target_x, target_y, &model_x, &model_y, + &model_moved)) { + model_x = target_x; model_y = target_y; model_moved = moved; + } + if (model_moved || button_changed || pointer_mode_changed || button_event_pending) + bongo_cat_app_apply_mouse_coordinates(app, &projection, model_x, model_y, + cursor_locked ? model_x : target_x, cursor_locked ? model_y : target_y); +} diff --git a/src/runtime/shortcuts.c b/src/runtime/input/shortcuts.c similarity index 50% rename from src/runtime/shortcuts.c rename to src/runtime/input/shortcuts.c index f9dae451..6582a217 100644 --- a/src/runtime/shortcuts.c +++ b/src/runtime/input/shortcuts.c @@ -1,5 +1,4 @@ #include "runtime.h" -#include "bongo_cat/audio.h" #include "bongo_cat/overlay.h" #include "bongo_cat/preferences.h" #include "bongo_cat/shortcut.h" @@ -7,58 +6,58 @@ #include #include -static void visible(BongoCatApp *app) { - bongo_cat_window_set_visible(app, !app->config.window.visible); +static void toggle_pet_visibility(BongoCatApp *app) { + bongo_cat_window_set_visible(app, !app->session.window.visible); } -bool bongo_cat_app_run_behavior(BongoCatApp *app, - const BongoCatBehaviorEntry *behavior) { - if (!app || !behavior) return false; - if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) { - if (!bongo_cat_overlay_effect(app->overlay, behavior->effect)) return false; - } else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { - if (!behavior->sound[0]) { - bongo_cat_audio_stop(app->audio); - return true; +static bool hidden_toggle_has_visible_binding(BongoCatApp *app, + const BongoCatBehaviorEntry *behavior, const char *shortcut) { + if (!app || !behavior || !shortcut || !shortcut[0] || + behavior->kind != BONGO_CAT_BEHAVIOR_MOTION || + bongo_cat_live2d_motion_visible(app->live2d, + behavior->group, behavior->index)) return false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *candidate = &app->behaviors.entries[i]; + if (candidate == behavior || + candidate->kind != BONGO_CAT_BEHAVIOR_MOTION || + !bongo_cat_live2d_motion_visible(app->live2d, + candidate->group, candidate->index) || + !bongo_cat_live2d_motion_same_toggle(app->live2d, + behavior->group, behavior->index, + candidate->group, candidate->index)) continue; + for (size_t j = 0; j < app->settings.behavior_shortcut_count; ++j) { + const BongoCatBehaviorShortcut *binding = + &app->settings.behavior_shortcuts[j]; + if (strcmp(binding->id, candidate->id) == 0 && + strcmp(binding->shortcut, shortcut) == 0) return true; } - BongoCatError error = {0}; - bongo_cat_audio_play(app->audio, behavior->sound, &error); - } else if (behavior->kind == BONGO_CAT_BEHAVIOR_MOTION) { - bool started = bongo_cat_live2d_start_motion(app->live2d, - behavior->group, behavior->index); - if (!started) return false; - if (behavior->sound[0]) { - BongoCatError error = {0}; - bongo_cat_audio_play(app->audio, behavior->sound, &error); - } - } else if (!bongo_cat_live2d_set_expression(app->live2d, behavior->index)) return false; - app->dirty = true; - return true; + } + return false; } -static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) { - bool handled = false; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *shortcut = &app->config.behavior_shortcuts[i]; +static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event, + bool handled, bool sound_edge) { + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *shortcut = &app->settings.behavior_shortcuts[i]; for (size_t j = 0; j < app->behaviors.count; ++j) { BongoCatBehaviorEntry *behavior = &app->behaviors.entries[j]; + if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) continue; if (strcmp(shortcut->id, behavior->id) != 0) continue; if (behavior->momentary && bongo_cat_shortcut_release_matches(event, shortcut->shortcut)) { if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) handled = bongo_cat_overlay_effect(app->overlay, NULL) || handled; - else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { - bongo_cat_audio_stop(app->audio); - handled = true; - } } else if (bongo_cat_shortcut_matches(&app->shortcut_state, - event, shortcut->shortcut)) handled = - bongo_cat_app_run_behavior(app, behavior) || handled; + event, shortcut->shortcut) && + !hidden_toggle_has_visible_binding(app, behavior, + shortcut->shortcut)) handled = + bongo_cat_app_run_behavior(app, behavior) || handled; } } if (handled) return true; size_t limit = app->behaviors.count < 10 ? app->behaviors.count : 10; for (size_t i = 0; i < limit; ++i) { + if (app->behaviors.entries[i].kind == BONGO_CAT_BEHAVIOR_SOUND && !sound_edge) continue; char alias[8]; snprintf(alias, sizeof(alias), "Alt+%c", i == 9 ? '0' : (char)('1' + i)); if (bongo_cat_shortcut_matches(&app->shortcut_state, event, alias)) @@ -68,41 +67,44 @@ static bool behavior_shortcut(BongoCatApp *app, const BongoCatInputEvent *event) } void bongo_cat_app_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event) { - if (!app) return; + if (!app || !event) return; + bool sound_edge = false; + bool sound_handled = bongo_cat_app_sound_shortcuts(app, event, &sound_edge); if (event->kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON) { - behavior_shortcut(app, event); + behavior_shortcut(app, event, sound_handled, sound_edge); return; } bool primary = bongo_cat_shortcut_update(&app->shortcut_state, event); if (!primary) { - behavior_shortcut(app, event); + behavior_shortcut(app, event, sound_handled, sound_edge); return; } - BongoCatShortcutOptions *shortcuts = &app->config.shortcuts; - if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_cat)) { - visible(app); + BongoCatShortcutPreferences *shortcuts = &app->settings.shortcuts; + if (bongo_cat_shortcut_matches(&app->shortcut_state, event, + shortcuts->toggle_pet_visibility)) { + toggle_pet_visibility(app); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->visible_preferences)) { bongo_cat_preferences_visible(app->preferences) ? bongo_cat_preferences_close(app->preferences) : bongo_cat_preferences_show(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->mirror)) { - app->config.model.mirror = !app->config.model.mirror; + app->settings.model.mirror = !app->settings.model.mirror; app->model_pointer_anchor_ready = false; app->dirty = true; bongo_cat_preferences_invalidate(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->pass_through)) { - app->config.window.pass_through = !app->config.window.pass_through; + app->settings.window.pass_through = !app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); } else if (bongo_cat_shortcut_matches(&app->shortcut_state, event, shortcuts->always_on_top)) { - app->config.window.always_on_top = !app->config.window.always_on_top; - bongo_cat_platform_set_always_on_top(&app->platform, app->config.window.always_on_top); + app->settings.window.always_on_top = !app->settings.window.always_on_top; + bongo_cat_platform_set_always_on_top(&app->platform, app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(app); bongo_cat_window_sync_click_through(app); bongo_cat_preferences_invalidate(app->preferences); - } else behavior_shortcut(app, event); + } else behavior_shortcut(app, event, sound_handled, sound_edge); } static void test_key(BongoCatApp *app, BongoCatInputKind kind, const char *name) { @@ -118,17 +120,18 @@ static void test_press(BongoCatApp *app, const char *name) { bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { if (!app || !app->preferences) return false; - BongoCatShortcutOptions *keys = &app->config.shortcuts; - snprintf(keys->visible_cat, sizeof(keys->visible_cat), "Control+B"); + BongoCatShortcutPreferences *keys = &app->settings.shortcuts; + snprintf(keys->toggle_pet_visibility, + sizeof(keys->toggle_pet_visibility), "Control+B"); snprintf(keys->visible_preferences, sizeof(keys->visible_preferences), "Control+Comma"); snprintf(keys->mirror, sizeof(keys->mirror), "Control+M"); snprintf(keys->pass_through, sizeof(keys->pass_through), "Control+P"); snprintf(keys->always_on_top, sizeof(keys->always_on_top), "Control+T"); - app->config.window.visible = true; - app->config.model.mirror = false; + app->session.window.visible = true; + app->settings.model.mirror = false; app->model_pointer_anchor_ready = true; - app->config.window.pass_through = false; - app->config.window.always_on_top = false; + app->settings.window.pass_through = false; + app->settings.window.always_on_top = false; test_key(app, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); test_press(app, "KeyB"); test_press(app, "KeyM"); @@ -136,10 +139,22 @@ bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app) { test_press(app, "KeyT"); test_press(app, "Comma"); test_key(app, BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); - bool result = !app->config.window.visible && app->config.model.mirror && + bool result = !app->session.window.visible && app->settings.model.mirror && !app->model_pointer_anchor_ready && - app->config.window.pass_through && app->config.window.always_on_top && + app->settings.window.pass_through && app->settings.window.always_on_top && bongo_cat_preferences_visible(app->preferences); bongo_cat_preferences_close(app->preferences); + bool ignored = app->smoke_ignore_global_input; + app->smoke_ignore_global_input = false; + test_key(app, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + BongoCatInputEvent release = {.kind = BONGO_CAT_INPUT_KEY_UP}; + snprintf(release.name, sizeof(release.name), "ControlLeft"); + bongo_cat_input_push(&app->input, &release); + bongo_cat_app_drain_input(app, false); + bool mirror = app->settings.model.mirror; + test_press(app, "KeyM"); + result = result && !app->shortcut_state.control && + app->settings.model.mirror == mirror; + app->smoke_ignore_global_input = ignored; return result; } diff --git a/src/runtime/input/sound_shortcuts.c b/src/runtime/input/sound_shortcuts.c new file mode 100644 index 00000000..67a524ec --- /dev/null +++ b/src/runtime/input/sound_shortcuts.c @@ -0,0 +1,26 @@ +#include "runtime.h" + +#include + +bool bongo_cat_app_sound_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event, + bool *changed) { + *changed = bongo_cat_sound_shortcut_update(&app->sound_shortcut_state, event); + if (!*changed) return false; + bool handled = false; + const BongoCatBehaviorEntry *pending[BONGO_CAT_BEHAVIOR_BINDING_CAP]; + size_t pending_count = 0; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; + for (size_t j = 0; j < app->behaviors.count; ++j) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[j]; + if (entry->kind != BONGO_CAT_BEHAVIOR_SOUND || strcmp(binding->id, entry->id)) continue; + bool down = bongo_cat_sound_shortcut_down(&app->sound_shortcut_state, binding->shortcut); + handled = handled || down; + if (down && !app->sound_shortcut_active[i] && + pending_count < BONGO_CAT_BEHAVIOR_BINDING_CAP) pending[pending_count++] = entry; + app->sound_shortcut_active[i] = down; + } + } + for (size_t i = 0; i < pending_count; ++i) bongo_cat_app_run_behavior(app, pending[i]); + return handled; +} diff --git a/src/runtime/lifecycle/app.c b/src/runtime/lifecycle/app.c new file mode 100644 index 00000000..e7dda71f --- /dev/null +++ b/src/runtime/lifecycle/app.c @@ -0,0 +1,175 @@ +#include "runtime.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/file.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" +#include +#include +#include +#include +static void scan_models(BongoCatApp *app) { + bongo_cat_app_rescan_models(app); +} +static bool load_selected_model(BongoCatApp *app, BongoCatError *error) { + const char *candidates[] = {app->session.active_model_id, "standard", "keyboard", "gamepad"}; + for (size_t i = 0; i < 4; ++i) { + bool duplicate = false; + for (size_t j = 0; j < i; ++j) + if (strcmp(candidates[i], candidates[j]) == 0) duplicate = true; + if (!duplicate && bongo_cat_models_find(&app->models, candidates[i]) && + bongo_cat_app_select_model(app, candidates[i])) { + if (i) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Selected model was unavailable; loaded fallback model %s", candidates[i]); + return true; + } + } + for (size_t i = 0; i < app->models.count; ++i) + if (bongo_cat_app_select_model(app, app->models.entries[i].id)) return true; + /* Stored built-ins may themselves be damaged. Retry the packaged assets + before treating a model failure as a startup failure. */ + if (bongo_cat_model_catalog_add_bundled(app, true)) { + for (size_t i = 1; i < 4; ++i) + if (bongo_cat_app_select_model(app, candidates[i])) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Recovered startup using bundled model %s", candidates[i]); + return true; + } + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "No usable Live2D model could be loaded"); + return false; +} +bool bongo_cat_app_initialize(BongoCatApp *app, int argc, char **argv, + BongoCatError *error) { + memset(app, 0, sizeof(*app)); + if (argc > 0 && argv && argv[0]) + snprintf(app->executable_path, sizeof(app->executable_path), + "%s", argv[0]); + app->smoke_language = -1; + app->smoke_theme = -1; + app->smoke_preference_page = -1; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + bongo_cat_input_init(&app->input); + bongo_cat_shortcut_init(&app->shortcut_state); + bongo_cat_models_init(&app->models); + if (!bongo_cat_startup_prepare(app, argc, argv, error)) return false; + bongo_cat_config_store_load(app); + if (app->secondary_pet) { + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", + app->secondary_model_id); + bongo_cat_session_clear_additional_models(&app->session); + if (!app->session_store_valid && app->secondary_origin_known) { + app->session.window.position_known = true; + app->session.window.x = app->secondary_origin_x; + app->session.window.y = app->secondary_origin_y; + } + /* The control file reveals the child on its first update. Starting + hidden prevents a stale child session from flashing on screen. */ + app->session.window.visible = false; + } else if (!app->settings.model.multiple_pets) + bongo_cat_session_clear_additional_models(&app->session); + if (app->smoke_language >= 0) + app->settings.app.language = (BongoCatLanguage)app->smoke_language; + if (app->smoke_theme >= 0) app->settings.app.theme = (BongoCatTheme)app->smoke_theme; + if (app->smoke_pass_through) app->settings.window.pass_through = true; + if (app->smoke_model[0]) + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), + "%s", app->smoke_model); + if (!app->secondary_pet && !app->autostart_launch) + app->session.window.visible = true; + /* Keep a normally visible session hidden until the renderer has produced + its first complete frame. The native window is still configured and + sized below, but no platform gets a chance to expose an empty back + buffer during model/driver startup. */ + app->startup_visibility_pending = app->session.window.visible; + bongo_cat_startup_stage(app, "configuration-ready"); + if (bongo_cat_app_locate_assets(app, error) != BONGO_CAT_OK) return false; + bongo_cat_startup_stage(app, "assets-ready"); + BongoCatError optional = {0}; + app->i18n = bongo_cat_i18n_create(app->locale_root, app->settings.app.language, &optional); + if (!app->i18n) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + if (bongo_cat_window_create(app, error) != BONGO_CAT_OK) return false; + bongo_cat_startup_stage(app, "window-ready"); + if (bongo_cat_platform_init(&app->platform, app->window, &app->input, error) != BONGO_CAT_OK) return false; + bongo_cat_window_apply(app); + bongo_cat_startup_stage(app, "platform-ready"); + app->live2d = bongo_cat_live2d_create(app->asset_root, error); + if (!app->live2d) return false; + optional = (BongoCatError){0}; app->overlay = bongo_cat_overlay_create(&optional); + if (!app->overlay) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Overlay disabled: %s", optional.message); + optional = (BongoCatError){0}; app->audio = bongo_cat_audio_create(&optional); + if (!app->audio) SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "%s", optional.message); + else bongo_cat_audio_set_enabled(app->audio, true); + scan_models(app); + if (!load_selected_model(app, error)) return false; + bongo_cat_startup_stage(app, "model-ready"); + if (app->smoke_import_path[0]) { + BongoCatError import_error = {0}; + if (bongo_cat_app_import_model(app, app->smoke_import_path, &import_error) != BONGO_CAT_OK) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", import_error.message); + bongo_cat_startup_ci_failure(app, &import_error); + } else if (app->smoke_remove_imported) { + char imported[BONGO_CAT_PATH_CAP]; + snprintf(imported, sizeof(imported), "%s", app->session.active_model_id); + if (bongo_cat_app_remove_model(app, imported, &import_error) != BONGO_CAT_OK) + bongo_cat_startup_ci_failure(app, &import_error); + } + } + app->running = true; + optional = (BongoCatError){0}; app->tray = app->secondary_pet ? NULL : + bongo_cat_tray_create(app, &optional); + if (!app->secondary_pet && !app->tray && app->settings.app.tray_visible) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + app->preferences = app->secondary_pet ? NULL : + bongo_cat_preferences_create(app); + if (!app->secondary_pet && !app->preferences) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Preferences are unavailable because their state could not be allocated"); + app->update = app->secondary_pet ? NULL : bongo_cat_update_create(app); + if (!app->secondary_pet && !app->update) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "The update checker could not be initialized"); + bongo_cat_update_start_automatic(app->update); + if (!app->secondary_pet && !app->tray && !app->session.window.visible) { + bongo_cat_window_set_visible(app, true); + } + bongo_cat_live2d_audit_run(app); + if (app->smoke_context_menu) bongo_cat_window_show_context_menu(app); + if (app->smoke_shortcuts && !bongo_cat_app_shortcuts_self_test(app)) { + BongoCatError shortcut_error = {0}; + bongo_cat_error_set(&shortcut_error, BONGO_CAT_ERROR_PLATFORM, "Shortcut action self-test failed"); + bongo_cat_startup_ci_failure(app, &shortcut_error); + } + if (app->smoke_menu) { + bool menu = bongo_cat_window_menu_self_test(app); + bool geometry = bongo_cat_window_geometry_self_test(app); + bool display = bongo_cat_window_display_self_test(app); + bool wheel = bongo_cat_window_wheel_self_test(app); + bool tray = bongo_cat_tray_self_test(app->tray); + bool wait = bongo_cat_window_wait_timeout_self_test(); + SDL_Log("Menu self-test: menu=%d geometry=%d display=%d wheel=%d tray=%d wait=%d", + menu, geometry, display, wheel, tray, wait); + if (!menu || !geometry || !display || !wheel || !tray || !wait) { + BongoCatError menu_error = {0}; + bongo_cat_error_set(&menu_error, BONGO_CAT_ERROR_PLATFORM, + "Context menu action self-test failed (menu=%d geometry=%d display=%d wheel=%d tray=%d wait=%d)", + menu, geometry, display, wheel, tray, wait); + bongo_cat_startup_ci_failure(app, &menu_error); + } + } + if (app->smoke_preferences) bongo_cat_preferences_show(app->preferences); + app->last_frame_ns = SDL_GetTicksNS(); + app->dirty = true; + app->startup_raise_due_ns = !app->secondary_pet && !app->smoke && + !app->autostart_launch && app->session.window.visible ? + app->last_frame_ns + 250000000ull : 0; + if (app->smoke_deadline_ns) app->smoke_deadline_ns += app->last_frame_ns; + if (!app->session.window.visible) bongo_cat_startup_ready(app); + return true; +} diff --git a/src/runtime/lifecycle/app_entry.c b/src/runtime/lifecycle/app_entry.c new file mode 100644 index 00000000..5cb36ea5 --- /dev/null +++ b/src/runtime/lifecycle/app_entry.c @@ -0,0 +1,33 @@ +#include "runtime.h" + +#include + +int bongo_cat_app_run(int argc, char **argv) { + if (bongo_cat_platform_update_shutdown_argument(argc, argv)) return 0; + bool secondary = bongo_cat_multi_pet_secondary_argument(argc, argv); + if (!secondary && !bongo_cat_platform_single_instance_begin()) return 0; + BongoCatApp *app = calloc(1, sizeof(*app)); + if (!app) { + BongoCatError memory = {0}; + bongo_cat_error_set(&memory, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate application state"); + bongo_cat_startup_failure(NULL, &memory); + if (!secondary) bongo_cat_platform_single_instance_end(); + return 1; + } + BongoCatError error = {0}; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + bongo_cat_startup_failure(app, &error); + if (app->smoke) bongo_cat_startup_ci_failure(app, &error); + bongo_cat_app_shutdown(app, "shutdown:startup-failure", 1); + free(app); + if (!secondary) bongo_cat_platform_single_instance_end(); + return 1; + } + bongo_cat_app_loop(app); + int exit_code = app->exit_code; + bongo_cat_app_shutdown(app, "shutdown:normal", exit_code); + free(app); + if (!secondary) bongo_cat_platform_single_instance_end(); + return exit_code; +} diff --git a/src/runtime/lifecycle/app_input.c b/src/runtime/lifecycle/app_input.c new file mode 100644 index 00000000..c57e7f47 --- /dev/null +++ b/src/runtime/lifecycle/app_input.c @@ -0,0 +1,77 @@ +#include "runtime.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/shortcut.h" +#include "bongo_cat/log.h" + +#include + +static void log_input(BongoCatApp *app, uint64_t now) { + if (now - app->input_diagnostics.log_ms < 10000) return; + app->input_diagnostics.log_ms = now; + int x = 0, y = 0; + bool position_known = app->window && SDL_GetWindowPosition(app->window, &x, &y); + SDL_DisplayID display = app->window ? SDL_GetDisplayForWindow(app->window) : 0; + SDL_Rect bounds = {0}; + bool display_known = display && SDL_GetDisplayBounds(display, &bounds); + SDL_LogInfo(BONGO_CAT_LOG_INPUT, + "[input] consumer ticks_ms=%llu drained_keys=%llu ignored_keys=%llu " + "mapped_keys=%llu unmapped_keys=%llu no_model_keys=%llu frames=%llu " + "model_ready=%d visible=%d minimized=%d dirty=%d " + "position_known=%d pet_position=%d,%d display_known=%d " + "pet_display=%u pet_primary=%d pet_bounds=%d,%d,%d,%d", + (unsigned long long)now, + (unsigned long long)app->input_diagnostics.drained_keys, + (unsigned long long)app->input_diagnostics.ignored_keys, + (unsigned long long)app->input_diagnostics.mapped_keys, + (unsigned long long)app->input_diagnostics.unmapped_keys, + (unsigned long long)app->input_diagnostics.no_model_keys, + (unsigned long long)app->input_diagnostics.presented_frames, + app->live2d != NULL, app->session.window.visible, app->window_minimized, + app->dirty, position_known, x, y, display_known, (unsigned)display, + display && display == SDL_GetPrimaryDisplay(), + bounds.x, bounds.y, bounds.w, bounds.h); +} + +void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts) { + if (app && app->secondary_pet) allow_shortcuts = false; + BongoCatInputEvent event; + while (bongo_cat_input_pop(&app->input, &event)) { + bool keyboard = event.kind == BONGO_CAT_INPUT_KEY_DOWN || + event.kind == BONGO_CAT_INPUT_KEY_UP; + if (keyboard) app->input_diagnostics.drained_keys++; + if (app->smoke_ignore_global_input) { + if (keyboard) app->input_diagnostics.ignored_keys++; + continue; + } + if (strcmp(event.name, "CapsLock") == 0) + bongo_cat_input_schedule_release(&app->input, &event, 100); + if (keyboard || event.kind == BONGO_CAT_INPUT_GAMEPAD_BUTTON || + event.kind == BONGO_CAT_INPUT_GAMEPAD_AXIS || + (!app->window_drag_active && (event.kind == BONGO_CAT_INPUT_MOUSE_DOWN || + event.kind == BONGO_CAT_INPUT_MOUSE_UP))) + bongo_cat_window_snapshot_end(app); + if (allow_shortcuts && + !bongo_cat_preferences_shortcuts_blocked(app->preferences)) + bongo_cat_app_shortcuts(app, &event); + else { + /* Suppress actions, not key transitions: releases may arrive + while a shortcut is being recorded or a modal menu is open. */ + bongo_cat_shortcut_update(&app->shortcut_state, &event); + if (app->sound_shortcut_state.count) { + app->sound_shortcut_state.count = 0; + memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); + } + } + bongo_cat_app_apply_input(app, &event); + } + uint64_t now = SDL_GetTicks(); + while (bongo_cat_input_take_scheduled_release(&app->input, now, &event)) { + if (allow_shortcuts && + !bongo_cat_preferences_shortcuts_blocked(app->preferences)) + bongo_cat_app_shortcuts(app, &event); + else bongo_cat_shortcut_update(&app->shortcut_state, &event); + bongo_cat_app_apply_input(app, &event); + } + if (!app->smoke_ignore_global_input) bongo_cat_app_apply_mouse(app); + log_input(app, now); +} diff --git a/src/runtime/lifecycle/app_loop.c b/src/runtime/lifecycle/app_loop.c new file mode 100644 index 00000000..42c3dbb3 --- /dev/null +++ b/src/runtime/lifecycle/app_loop.c @@ -0,0 +1,231 @@ +#include "runtime.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/log.h" +#include "model_cover.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" + +#include + +static void handle_event(BongoCatApp *app, const SDL_Event *event) { + if (bongo_cat_update_event(app->update, event)) return; + if (bongo_cat_model_refresh_event(app, event)) return; + if (bongo_cat_preferences_event(app->preferences, event)) return; + if (!bongo_cat_window_event(app, event)) app->running = false; + if (event->type >= SDL_EVENT_GAMEPAD_AXIS_MOTION && + event->type <= SDL_EVENT_GAMEPAD_TOUCHPAD_UP) { + bongo_cat_window_snapshot_end(app); + bongo_cat_gamepad_event(app, event); + } +} + +static void update_model(BongoCatApp *app, uint64_t now) { + float elapsed = (float)((now - app->last_frame_ns) / 1000000000.0); + if (elapsed > 0.25f) elapsed = 0.25f; + app->last_frame_ns = now; + if (!app->smoke_freeze_model) bongo_cat_app_step_live2d(app, elapsed); +} + +static bool render(BongoCatApp *app, bool present) { + if (present && app->window_snapshot) { + bongo_cat_window_snapshot_present(app); + return app->window_snapshot != NULL; + } + uint64_t now = SDL_GetTicksNS(); + if (app->render_retry_ns > now) return false; + if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) { + app->dirty = true; + app->render_retry_ns = now + 1000000000ull; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Main GL context could not be activated: %s", SDL_GetError()); + return false; + } + app->render_retry_ns = 0; + bongo_cat_window_apply_pending_resize(app); + int width, height; + SDL_GetWindowSizeInPixels(app->window, &width, &height); + glViewport(0, 0, width, height); + glEnable(GL_MULTISAMPLE); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + bongo_cat_window_clear_background(app); + int content_x = 0, content_y = 0, content_width = width, + content_height = height; + bool content_viewport = bongo_cat_live2d_viewport(app->live2d, + &content_x, &content_y, &content_width, &content_height) && + content_width > 0 && content_height > 0; + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); + bongo_cat_overlay_draw_background(app->overlay, + app->settings.model.mirror); + bool cover_requested = !present && bongo_cat_model_cover_pending(app); + bool cover_ready = !cover_requested || + bongo_cat_live2d_prepare_cover_capture(app->live2d); + glViewport(0, 0, width, height); + bongo_cat_live2d_set_mirror(app->live2d, app->settings.model.mirror); + bongo_cat_live2d_draw(app->live2d); + if (content_viewport) + glViewport(content_x, content_y, content_width, content_height); + bongo_cat_overlay_draw_pointer_before_keys(app->overlay); + if (app->settings.model.mouse_centered && app->pointer_known && + !app->model_pointer_anchor_ready) + bongo_cat_app_apply_mouse_position(app, app->pointer_x, + app->pointer_y, 0.0f); + bongo_cat_overlay_draw_keys(app->overlay, app->settings.model.mirror); + bongo_cat_overlay_draw_effect(app->overlay, app->settings.model.mirror); + bongo_cat_overlay_draw_pointer_after_keys(app->overlay); + glViewport(0, 0, width, height); + bool cover_handled = true; + if (cover_requested) { + if (!cover_ready) { + bongo_cat_model_cover_defer(app, "model-state-preparation"); + cover_handled = false; + } else if (!bongo_cat_model_cover_capture(app, width, height)) { + bongo_cat_model_cover_defer(app, "framebuffer-capture"); + cover_handled = false; + } + } + if (cover_requested) app->dirty = true; + if (!present) { + app->dirty = true; + return cover_handled; + } + bool reveal_startup = app->startup_visibility_pending && + app->session.window.visible; + bongo_cat_window_mask_corners(app, width, height); + bongo_cat_frame_audit(app, width, height); + bongo_cat_window_capture_pointer_hit(app); + /* Keep the native window hidden while diagnostics/readback finish. The + reveal is intentionally adjacent to the swap so an uninitialised front + buffer cannot be displayed as a black startup frame. */ + bool pre_presented = reveal_startup && + bongo_cat_platform_present(&app->platform, width, height); + if (reveal_startup) + bongo_cat_platform_set_visible(&app->platform, true); + bool presented = pre_presented || + bongo_cat_platform_present(&app->platform, width, height); + if (!presented) { + if (reveal_startup) bongo_cat_platform_set_visible(&app->platform, false); + app->dirty = true; + app->render_retry_ns = now + 1000000000ull; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Main frame presentation failed: %s", SDL_GetError()); + return false; + } + if (reveal_startup) app->startup_visibility_pending = false; + bongo_cat_frame_presented_audit(app); + app->input_diagnostics.presented_frames++; + bongo_cat_startup_ready(app); + bongo_cat_memory_policy_frame_presented(); + app->dirty = false; + bongo_cat_window_sync_click_through(app); + bongo_cat_window_schedule_hit_check(app); + return true; +} + +void bongo_cat_app_render_now(BongoCatApp *app) { + if (app && app->window && app->session.window.visible && + !app->window_minimized) + render(app, true); +} + +bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app) { + if (!app || !app->window || !bongo_cat_model_cover_pending(app)) return false; + if (app->window_snapshot) return false; + uint64_t now = SDL_GetTicksNS(); + if (!bongo_cat_model_cover_capture_due(app, now)) return false; + if (app->window_minimized) { + SDL_Log("Pending model cover deferred while window is minimized: %s", + bongo_cat_model_cover_pending_path(app)); + bongo_cat_model_cover_defer(app, "window-minimized"); + return false; + } + SDL_Log("[runtime] Capturing pending model cover: model=%s loading=%s " + "visible=%d dirty=%d motions=%zu expression=%d path=%s", + app->loaded_model, + app->loading_model[0] ? app->loading_model : "none", + app->session.window.visible, app->dirty, + bongo_cat_app_selected_motion_count(app), + bongo_cat_live2d_expression(app->live2d), + bongo_cat_model_cover_pending_path(app)); + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bool restore_context = previous_window && previous_context && + (previous_window != app->window || previous_context != app->gl_context); + bool captured = render(app, false); + if (restore_context && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore the OpenGL context after model cover capture: %s", + SDL_GetError()); + return captured; +} + +static void take_instance_wake(BongoCatApp *app) { + if (!bongo_cat_platform_single_instance_take_wake()) return; + bongo_cat_window_set_visible(app, true); + if (!app->startup_visibility_pending) + bongo_cat_platform_raise_window(app->window); + SDL_Log("Existing instance requested window reveal"); +} + +static bool take_update_shutdown(BongoCatApp *app) { + if (!bongo_cat_platform_single_instance_take_update_shutdown()) + return false; + app->running = false; + SDL_LogInfo(BONGO_CAT_LOG_UPDATE, + "Installed update requested application shutdown"); + return true; +} + +void bongo_cat_app_loop(BongoCatApp *app) { + while (app->running) { + int wait_ms = bongo_cat_window_wait_timeout(app, SDL_GetTicksNS()); + if (app->secondary_pet && wait_ms > 100) wait_ms = 100; + bongo_cat_preferences_input_begin(app->preferences); + SDL_Event event; + if (bongo_cat_wait_event(&event, wait_ms)) { + handle_event(app, &event); + unsigned queued = 0; + while (queued++ < 256 && SDL_PollEvent(&event)) + handle_event(app, &event); + } + bongo_cat_preferences_input_end(app->preferences); + uint64_t now = SDL_GetTicksNS(); + bongo_cat_preferences_model_watch(app->preferences, now); + bongo_cat_model_refresh_update(app); + take_instance_wake(app); + if (take_update_shutdown(app)) continue; + now = SDL_GetTicksNS(); + bongo_cat_window_update_wheel_animation(app, now); + bongo_cat_window_snapshot_update(app, now); + bongo_cat_multi_pet_update(app, now); + bongo_cat_random_expression_update(app, now); + bongo_cat_audio_update(app->audio); + bongo_cat_window_update_display_recovery(app, now); + bongo_cat_runtime_flow_update(app, now); + bongo_cat_window_apply_pending_resize(app); + bongo_cat_app_drain_input(app, true); + now = SDL_GetTicksNS(); + bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); + if (bongo_cat_model_frame_due(app, now)) update_model(app, now); + else if (!app->session.window.visible || app->window_minimized) { + app->last_frame_ns = now; + bongo_cat_memory_policy_idle(); + } + bongo_cat_app_capture_pending_model_cover(app); + if (app->session.window.visible && !app->window_minimized && app->dirty) + render(app, true); + bongo_cat_preferences_render(app->preferences); + bongo_cat_preferences_process_model_selection(app->preferences); + if (app->session.window.visible && !app->window_minimized && app->dirty) + render(app, true); + bongo_cat_tray_sync(app->tray); + bongo_cat_window_raise_when_due(app, now); + bongo_cat_config_store_update(app, now); + if (app->smoke_deadline_ns && now >= app->smoke_deadline_ns) + app->running = false; + } +} diff --git a/src/runtime/assets.c b/src/runtime/lifecycle/assets.c similarity index 85% rename from src/runtime/assets.c rename to src/runtime/lifecycle/assets.c index b13829e2..757bdb8d 100644 --- a/src/runtime/assets.c +++ b/src/runtime/lifecycle/assets.c @@ -6,13 +6,17 @@ static bool required_assets(const char *root) { if (!root || !root[0]) return false; - const char *files[] = {"locales/en-US.json", "models/standard/cat.model3.json", + const char *files[] = { + "bongocat.png", "ui-symbols.png", "ui-symbols@4x.png", + "catime.png", "vlaina.jpg", "locales/en-US.json", + "models/standard/cat.model3.json", "models/standard/demomodel.moc3", "models/standard/demomodel.1024/texture_00.png"}; for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) { char path[BONGO_CAT_PATH_CAP]; + uint64_t size = 0; if (!bongo_cat_path_join(path, sizeof(path), root, files[i]) || - !bongo_cat_path_is_file(path)) return false; + !bongo_cat_path_file_size(path, &size) || !size) return false; } return true; } @@ -29,7 +33,7 @@ BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, if (!asset_directory(app->asset_root, sizeof(app->asset_root), base ? base : "")) { char cache[BONGO_CAT_PATH_CAP], name[64]; snprintf(name, sizeof(name), "embedded-assets-%s", BONGO_CAT_VERSION); - if (bongo_cat_path_join(cache, sizeof(cache), app->data_root, name)) { + if (bongo_cat_path_join(cache, sizeof(cache), app->cache_root, name)) { BongoCatError embedded = {0}; if (bongo_cat_platform_embedded_assets(cache, &embedded) == BONGO_CAT_OK) { asset_directory(app->asset_root, sizeof(app->asset_root), cache); diff --git a/src/runtime/lifecycle/config_store.c b/src/runtime/lifecycle/config_store.c new file mode 100644 index 00000000..0f9eb2f2 --- /dev/null +++ b/src/runtime/lifecycle/config_store.c @@ -0,0 +1,232 @@ +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include + +#define SAVE_DELAY_NS 300000000ull +#define RETRY_DELAY_NS 1000000000ull + +static uint64_t hash_bytes(uint64_t hash, const void *data, size_t size) { + const unsigned char *bytes = data; + for (size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= 1099511628211ull; + } + return hash; +} + +static uint64_t settings_hash(const BongoCatSettings *settings) { + uint64_t hash = 1469598103934665603ull; +#define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) + HASH_FIELD(settings->model); + HASH_FIELD(settings->window); + HASH_FIELD(settings->app); + HASH_FIELD(settings->shortcuts); + HASH_FIELD(settings->behavior_shortcut_count); + size_t behavior_count = settings->behavior_shortcut_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + hash = hash_bytes(hash, settings->behavior_shortcuts, + behavior_count * sizeof(settings->behavior_shortcuts[0])); + HASH_FIELD(settings->model_label_count); + size_t model_count = settings->model_label_count; + if (model_count > BONGO_CAT_MODEL_CAP) model_count = BONGO_CAT_MODEL_CAP; + hash = hash_bytes(hash, settings->model_labels, + model_count * sizeof(settings->model_labels[0])); + HASH_FIELD(settings->removed_model_count); + size_t removed_count = settings->removed_model_count; + if (removed_count > BONGO_CAT_MODEL_CAP) + removed_count = BONGO_CAT_MODEL_CAP; + hash = hash_bytes(hash, settings->removed_models, + removed_count * sizeof(settings->removed_models[0])); + HASH_FIELD(settings->extensions_json); +#undef HASH_FIELD + return hash; +} + +static uint64_t session_hash(const BongoCatSessionState *session) { + uint64_t hash = 1469598103934665603ull; +#define HASH_FIELD(field) hash = hash_bytes(hash, &(field), sizeof(field)) + HASH_FIELD(session->window.visible); + HASH_FIELD(session->window.position_known); + HASH_FIELD(session->window.scale_percent); + HASH_FIELD(session->window.opacity_percent); + HASH_FIELD(session->window.x); + HASH_FIELD(session->window.y); + HASH_FIELD(session->window.width); + HASH_FIELD(session->window.height); + HASH_FIELD(session->window.content_width); + HASH_FIELD(session->window.content_height); + HASH_FIELD(session->active_model_id); + HASH_FIELD(session->last_update_check_day); + HASH_FIELD(session->last_update_check_version); + HASH_FIELD(session->available_update_version); + HASH_FIELD(session->additional_model_count); + size_t model_count = session->additional_model_count; + if (model_count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + model_count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + hash = hash_bytes(hash, session->additional_model_ids, + model_count * sizeof(session->additional_model_ids[0])); + HASH_FIELD(session->active_behavior_count); + size_t behavior_count = session->active_behavior_count; + if (behavior_count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + behavior_count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + hash = hash_bytes(hash, session->active_behaviors, + behavior_count * sizeof(session->active_behaviors[0])); +#undef HASH_FIELD + return hash; +} + +static bool reject_configuration(const char *path, char *rejected, + size_t capacity) { + for (unsigned index = 0; index < 1000; ++index) { + int length = index ? snprintf(rejected, capacity, "%s.rejected.%u", + path, index) : snprintf(rejected, capacity, "%s.rejected", path); + if (length < 0 || (size_t)length >= capacity) return false; + if (bongo_cat_path_is_file(rejected) || + bongo_cat_path_is_dir(rejected)) continue; + return bongo_cat_path_rename(path, rejected); + } + return false; +} + +static void handle_rejected_configuration(const char *description, + const char *path, BongoCatResult result, bool *blocked) { + if (result != BONGO_CAT_ERROR_FORMAT || !bongo_cat_path_is_file(path)) { + *blocked = bongo_cat_path_is_file(path); + return; + } + char rejected[BONGO_CAT_PATH_CAP]; + if (reject_configuration(path, rejected, sizeof(rejected))) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "%s preserved as rejected file: %s", description, rejected); + return; + } + *blocked = true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "%s will not be overwritten because it could not be preserved: %s", + description, path); +} + +void bongo_cat_config_store_load(BongoCatApp *app) { + app->settings_store_valid = false; + app->session_store_valid = false; + app->settings_store_blocked = false; + app->session_store_blocked = false; + BongoCatError error = {0}; + BongoCatResult loaded = bongo_cat_settings_load( + app->settings_path, &app->settings, &error); + app->settings_store_valid = loaded == BONGO_CAT_OK && + bongo_cat_path_is_file(app->settings_path); + bool language_detected = false; + if (!app->settings_store_valid) { + /* System locale is only a first-run/recovery fallback; a valid + settings file always wins so an explicit user choice persists. */ + BongoCatLanguage detected_language = app->settings.app.language; + language_detected = bongo_cat_system_language(&detected_language); + if (language_detected) app->settings.app.language = detected_language; + } + if (language_detected) + SDL_Log("[runtime] Initial language selected from system locale: %s", + bongo_cat_language_name(app->settings.app.language)); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Settings ignored: %s", + error.message); + handle_rejected_configuration("Settings", app->settings_path, + loaded, &app->settings_store_blocked); + } + error = (BongoCatError){0}; + loaded = bongo_cat_session_load( + app->session_path, &app->session, &error); + app->session_store_valid = loaded == BONGO_CAT_OK && + bongo_cat_path_is_file(app->session_path); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Session ignored: %s", + error.message); + handle_rejected_configuration("Session", app->session_path, + loaded, &app->session_store_blocked); + } + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + app->settings_observed_hash = settings; + app->session_observed_hash = session; + app->settings_saved_hash = app->settings_store_valid || + app->settings_store_blocked ? settings : ~settings; + app->session_saved_hash = app->session_store_valid || + app->session_store_blocked ? session : ~session; +} + +static bool save_settings(BongoCatApp *app) { + bongo_cat_settings_validate(&app->settings); + uint64_t hash = settings_hash(&app->settings); + BongoCatError error = {0}; + if (bongo_cat_settings_save(app->settings_path, + &app->settings, &error) != BONGO_CAT_OK) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return false; + } + app->settings_saved_hash = hash; + app->settings_observed_hash = hash; + app->settings_store_valid = true; + return true; +} + +static bool save_session(BongoCatApp *app) { + bongo_cat_session_validate(&app->session); + uint64_t hash = session_hash(&app->session); + BongoCatError error = {0}; + if (bongo_cat_session_save(app->session_path, + &app->session, &error) != BONGO_CAT_OK) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return false; + } + app->session_saved_hash = hash; + app->session_observed_hash = hash; + app->session_store_valid = true; + return true; +} + +void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now) { + if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + if (!app->secondary_pet && !app->settings_store_blocked) { + if (settings != app->settings_observed_hash) { + app->settings_observed_hash = settings; + app->settings_save_due_ns = now + SAVE_DELAY_NS; + } else if (settings != app->settings_saved_hash && + !app->settings_save_due_ns) + app->settings_save_due_ns = now + SAVE_DELAY_NS; + } + if (!app->session_store_blocked) { + if (session != app->session_observed_hash) { + app->session_observed_hash = session; + app->session_save_due_ns = now + SAVE_DELAY_NS; + } else if (session != app->session_saved_hash && + !app->session_save_due_ns) + app->session_save_due_ns = now + SAVE_DELAY_NS; + } + if (!app->secondary_pet && !app->settings_store_blocked && + app->settings_save_due_ns && + now >= app->settings_save_due_ns) + app->settings_save_due_ns = save_settings(app) + ? 0 : now + RETRY_DELAY_NS; + if (!app->session_store_blocked && app->session_save_due_ns && + now >= app->session_save_due_ns) + app->session_save_due_ns = save_session(app) + ? 0 : now + RETRY_DELAY_NS; +} + +void bongo_cat_config_store_flush(BongoCatApp *app) { + if (!app || app->smoke || !app->settings_path[0] || !app->session_path[0]) return; + uint64_t settings = settings_hash(&app->settings); + uint64_t session = session_hash(&app->session); + if (!app->secondary_pet && !app->settings_store_blocked && + settings != app->settings_saved_hash) save_settings(app); + if (!app->session_store_blocked && + session != app->session_saved_hash) save_session(app); + app->settings_observed_hash = settings_hash(&app->settings); + app->session_observed_hash = session_hash(&app->session); + app->settings_save_due_ns = app->session_save_due_ns = 0; +} diff --git a/src/runtime/frame_clock.c b/src/runtime/lifecycle/frame_clock.c similarity index 62% rename from src/runtime/frame_clock.c rename to src/runtime/lifecycle/frame_clock.c index f79545d5..d2efd6bf 100644 --- a/src/runtime/frame_clock.c +++ b/src/runtime/lifecycle/frame_clock.c @@ -1,11 +1,12 @@ #include "runtime.h" +#include "window_wheel_internal.h" #include "bongo_cat/preferences.h" #include static uint64_t frame_interval_ns(const BongoCatApp *app) { #ifdef BONGO_CAT_HAS_CUBISM - int fps = app && app->config.model.max_fps > 0 ? - app->config.model.max_fps : 60; + int fps = app && app->settings.model.max_fps > 0 ? + app->settings.model.max_fps : 60; return 1000000000ull / (uint64_t)fps; #else (void)app; @@ -21,7 +22,7 @@ static int remaining_ms(uint64_t deadline, uint64_t now) { } bool bongo_cat_model_frame_due(const BongoCatApp *app, uint64_t now) { - return app && app->config.window.visible && !app->window_minimized && + return app && app->session.window.visible && !app->window_minimized && (!app->last_frame_ns || now >= app->last_frame_ns + frame_interval_ns(app)); } @@ -29,16 +30,24 @@ bool bongo_cat_model_frame_due(const BongoCatApp *app, uint64_t now) { int bongo_cat_window_wait_timeout(const BongoCatApp *app, uint64_t now) { if (!app) return 250; uint64_t frame_deadline = app->last_frame_ns + frame_interval_ns(app); - int wait_ms = app->config.window.visible && !app->window_minimized ? remaining_ms( + int wait_ms = app->session.window.visible && !app->window_minimized ? remaining_ms( frame_deadline, now) : 250; if (bongo_cat_preferences_needs_frame(app->preferences) && wait_ms > 4) wait_ms = 4; - if (app->wheel_animation_active && wait_ms > 8) wait_ms = 8; - if (app->config.window.visible && !app->window_minimized && - app->click_through_applied && wait_ms > 16) wait_ms = 16; - bool pending_hit = app->config.window.visible && !app->window_minimized && + if (app->wheel_animation_active) { + int wheel_wait = remaining_ms(app->wheel_animation_ns + + BONGO_CAT_WHEEL_FRAME_INTERVAL_NS, now); + if (wait_ms > wheel_wait) wait_ms = wheel_wait; + } + if (app->hover_fade_active && wait_ms > 8) wait_ms = 8; + if (app->window_snapshot && wait_ms > 16) wait_ms = 16; + if (app->session.window.visible && !app->window_minimized && + (app->click_through_applied || (app->settings.window.pass_through && + app->settings.window.always_on_top && app->settings.window.hide_on_hover)) && + wait_ms > 16) wait_ms = 16; + bool pending_hit = app->session.window.visible && !app->window_minimized && app->pointer_hit_dirty && - app->pointer_hit_deadline_ns && !app->config.window.pass_through && + app->pointer_hit_deadline_ns && !app->settings.window.pass_through && !app->hover_hidden && !app->left_mouse_down && !app->right_mouse_down; if (!pending_hit) return wait_ms; int hit_wait = remaining_ms(app->pointer_hit_deadline_ns, now); @@ -64,20 +73,20 @@ bool bongo_cat_window_wait_timeout_self_test(void) { BongoCatApp *app = calloc(1, sizeof(*app)); if (!app) return false; bool passed = false; - app->config.window.visible = true; - app->config.model.max_fps = 60; + app->session.window.visible = true; + app->settings.model.max_fps = 60; app->last_frame_ns = now; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 30; + app->settings.model.max_fps = 30; uint64_t interval_30 = frame_interval_ns(app); - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; uint64_t interval_60 = frame_interval_ns(app); - app->config.model.max_fps = 120; + app->settings.model.max_fps = 120; uint64_t interval_120 = frame_interval_ns(app); if (!(interval_30 > interval_60 && interval_60 > interval_120) || bongo_cat_model_frame_due(app, now + interval_120 - 1) || !bongo_cat_model_frame_due(app, now + interval_120)) goto done; - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; #endif int frame_wait = remaining_ms(now + frame_interval_ns(app), now); if (bongo_cat_window_wait_timeout(app, now) != frame_wait || @@ -89,13 +98,22 @@ bool bongo_cat_window_wait_timeout_self_test(void) { if (bongo_cat_window_wait_timeout(app, now) != 0) goto done; app->pointer_hit_dirty = false; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 1; + app->settings.model.max_fps = 1; #endif app->click_through_applied = true; if (bongo_cat_window_wait_timeout(app, now) != 16) goto done; app->click_through_applied = false; + app->settings.window.pass_through = true; + app->settings.window.always_on_top = true; + app->settings.window.hide_on_hover = true; + if (bongo_cat_window_wait_timeout(app, now) != 16) goto done; + app->settings.window.always_on_top = false; + if (bongo_cat_window_wait_timeout(app, now) != + remaining_ms(now + frame_interval_ns(app), now)) goto done; + app->settings.window.pass_through = false; + app->settings.window.hide_on_hover = false; #ifdef BONGO_CAT_HAS_CUBISM - app->config.model.max_fps = 60; + app->settings.model.max_fps = 60; #endif if (!bongo_cat_model_frame_due(app, now + frame_interval_ns(app))) goto done; @@ -103,10 +121,14 @@ bool bongo_cat_window_wait_timeout_self_test(void) { if (bongo_cat_model_frame_due(app, now + frame_interval_ns(app)) || bongo_cat_window_wait_timeout(app, now) != 250) goto done; app->window_minimized = false; - app->config.window.visible = false; + app->session.window.visible = false; if (bongo_cat_window_wait_timeout(app, now) != 250) goto done; app->wheel_animation_active = true; - passed = bongo_cat_window_wait_timeout(app, now) == 8; + app->wheel_animation_ns = now; + if (bongo_cat_window_wait_timeout(app, now) != 8 || + bongo_cat_window_wait_timeout(app, now + 3000000ull) != 5 || + bongo_cat_window_wait_timeout(app, now + 8000000ull) != 0) goto done; + passed = true; done: free(app); return passed; diff --git a/src/runtime/memory_policy.c b/src/runtime/lifecycle/memory_policy.c similarity index 100% rename from src/runtime/memory_policy.c rename to src/runtime/lifecycle/memory_policy.c diff --git a/src/runtime/runtime_flow.c b/src/runtime/lifecycle/runtime_flow.c similarity index 76% rename from src/runtime/runtime_flow.c rename to src/runtime/lifecycle/runtime_flow.c index b7092814..5e52df45 100644 --- a/src/runtime/runtime_flow.c +++ b/src/runtime/lifecycle/runtime_flow.c @@ -8,7 +8,7 @@ static void mark(BongoCatApp *app, const char *name) { char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->data_root, "runtime-flow-stage.txt"); + bongo_cat_path_join(path, sizeof(path), app->state_root, "runtime-flow-stage.txt"); FILE *file = bongo_cat_file_open(path, "wb"); if (file) { fputs(name, file); fclose(file); } } @@ -19,7 +19,8 @@ static void scale(BongoCatApp *app, float value) { } static void opacity(BongoCatApp *app, float value) { - app->config.window.opacity_percent = value; + app->session.window.opacity_percent = value; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_platform_set_opacity(&app->platform, value / 100.0f); } @@ -37,7 +38,7 @@ void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now) { if (!app->smoke_runtime_flow_ns) { app->smoke_runtime_flow_ns = now; snprintf(app->smoke_runtime_model, sizeof(app->smoke_runtime_model), "%.*s", - (int)sizeof(app->smoke_runtime_model) - 1, app->config.current_model); + (int)sizeof(app->smoke_runtime_model) - 1, app->session.active_model_id); mark(app, "startup"); return; } uint64_t elapsed = now - app->smoke_runtime_flow_ns; @@ -53,10 +54,19 @@ void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now) { case 6: model_mode(app, BONGO_CAT_MODE_KEYBOARD); mark(app, "model-keyboard"); break; case 7: model_mode(app, BONGO_CAT_MODE_STANDARD); mark(app, "model-standard"); break; case 8: model_mode(app, BONGO_CAT_MODE_GAMEPAD); mark(app, "model-gamepad"); break; - case 9: bongo_cat_preferences_show(app->preferences); mark(app, "settings-first"); break; + case 9: + app->smoke_preference_model_select = true; + bongo_cat_preferences_show(app->preferences); + mark(app, "settings-first-model-select"); break; case 10: mark(app, "settings-idle"); break; - case 11: bongo_cat_preferences_close(app->preferences); mark(app, "settings-closed"); break; - case 12: bongo_cat_preferences_show(app->preferences); mark(app, "settings-reopen"); break; + case 11: + bongo_cat_preferences_close(app->preferences); + model_mode(app, BONGO_CAT_MODE_STANDARD); + mark(app, "settings-closed-model-restored"); break; + case 12: + app->smoke_preference_model_select = true; + bongo_cat_preferences_show(app->preferences); + mark(app, "settings-reopen-model-select"); break; case 13: bongo_cat_preferences_close(app->preferences); bongo_cat_app_select_model(app, app->smoke_runtime_model); diff --git a/src/runtime/lifecycle/runtime_state.c b/src/runtime/lifecycle/runtime_state.c new file mode 100644 index 00000000..16bbac2c --- /dev/null +++ b/src/runtime/lifecycle/runtime_state.c @@ -0,0 +1,94 @@ +#include "runtime.h" +#include "runtime_state.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool state_path(BongoCatApp *app, char *path, size_t capacity) { + return app && app->state_root[0] && + bongo_cat_path_join(path, capacity, app->state_root, + "runtime-state.txt"); +} + +static bool temporary_path(BongoCatApp *app, char *path, size_t capacity) { + return app && app->state_root[0] && + bongo_cat_path_join(path, capacity, app->state_root, + "runtime-state.tmp"); +} + +void bongo_cat_runtime_timestamp(char *target, size_t capacity) { + if (!target || !capacity) return; + SDL_Time ticks = 0; + SDL_DateTime date = {0}; + if (SDL_GetCurrentTime(&ticks) && + SDL_TimeToDateTime(ticks, &date, true)) { + int offset_minutes = date.utc_offset >= 0 + ? (date.utc_offset + 30) / 60 + : -((-date.utc_offset + 30) / 60); + char sign = offset_minutes < 0 ? '-' : '+'; + if (offset_minutes < 0) offset_minutes = -offset_minutes; + snprintf(target, capacity, + "%04d-%02d-%02d %02d:%02d:%02d.%03d %c%02d:%02d", + date.year, date.month, date.day, date.hour, date.minute, + date.second, date.nanosecond / 1000000, sign, + offset_minutes / 60, offset_minutes % 60); + } else { + snprintf(target, capacity, "%lld", (long long)time(NULL)); + } + target[capacity - 1] = '\0'; +} + +void bongo_cat_runtime_state_previous(BongoCatApp *app, char *state, + size_t capacity) { + if (!state || !capacity) return; + state[0] = '\0'; + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (!state_path(app, path, sizeof(path)) || + !temporary_path(app, temporary, sizeof(temporary))) return; + if (!bongo_cat_path_is_file(path) && + bongo_cat_path_is_file(temporary)) + bongo_cat_file_replace(temporary, path, true); + else bongo_cat_file_remove(temporary); + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return; + size_t length = fread(state, 1, capacity - 1, file); + state[ferror(file) ? 0 : length] = '\0'; + fclose(file); + for (size_t i = 0; state[i]; ++i) + if (state[i] == '\r' || state[i] == '\n') state[i] = ' '; +} + +static bool write_state(BongoCatApp *app, const char *stage, + const char *timestamp) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (!stage || !timestamp || !state_path(app, path, sizeof(path)) || + !temporary_path(app, temporary, sizeof(temporary))) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + if (!file) return false; + bool written = fprintf(file, "time=%s stage=", timestamp) >= 0; + for (const char *cursor = stage; *cursor; ++cursor) + if (fputc(*cursor == '\r' || *cursor == '\n' ? ' ' : *cursor, + file) == EOF) written = false; + if (fputc('\n', file) == EOF) written = false; + if (fclose(file) != 0) written = false; + if (written && bongo_cat_file_replace(temporary, path, true)) return true; + bongo_cat_file_remove(temporary); + return false; +} + +void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage) { + char timestamp[64]; + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); + write_state(app, stage, timestamp); +} + +void bongo_cat_runtime_state_clean(BongoCatApp *app, const char *timestamp) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (temporary_path(app, temporary, sizeof(temporary))) + bongo_cat_file_remove(temporary); + if (state_path(app, path, sizeof(path)) && + !bongo_cat_file_remove(path) && bongo_cat_path_is_file(path)) + write_state(app, "clean-shutdown", timestamp); +} diff --git a/src/runtime/lifecycle/runtime_state.h b/src/runtime/lifecycle/runtime_state.h new file mode 100644 index 00000000..a7bee28e --- /dev/null +++ b/src/runtime/lifecycle/runtime_state.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_RUNTIME_STATE_H +#define BONGO_CAT_RUNTIME_STATE_H + +#include "bongo_cat/app.h" +#include + +void bongo_cat_runtime_timestamp(char *target, size_t capacity); +void bongo_cat_runtime_state_previous(BongoCatApp *app, char *state, + size_t capacity); +void bongo_cat_runtime_state_clean(BongoCatApp *app, const char *timestamp); + +#endif diff --git a/src/runtime/lifecycle/shutdown.c b/src/runtime/lifecycle/shutdown.c new file mode 100644 index 00000000..080c60c4 --- /dev/null +++ b/src/runtime/lifecycle/shutdown.c @@ -0,0 +1,39 @@ +#include "runtime.h" +#include "bongo_cat/log.h" +#include "model_import_lock.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" + +#include + +void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, + int exit_code) { + bongo_cat_runtime_stage(app, stage); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] Shutdown started: stage=%s exit_code=%d", + stage, exit_code); + bongo_cat_app_capture_behavior_state(app); + bongo_cat_window_snapshot_discard(app); + bongo_cat_config_store_flush(app); + bongo_cat_multi_pet_shutdown(app); + bongo_cat_model_refresh_shutdown(app); + bongo_cat_update_destroy(app->update); + app->update = NULL; + bongo_cat_preferences_destroy(app->preferences); + bongo_cat_import_storage_lock_shutdown(); + bongo_cat_i18n_destroy(app->i18n); + bongo_cat_tray_destroy(app->tray); + bongo_cat_gamepads_set_enabled(app, false); + bongo_cat_audio_destroy(app->audio); + bongo_cat_overlay_destroy(app->overlay); + bongo_cat_live2d_destroy(app->live2d); + free(app->behavior_cache); + app->behavior_cache = NULL; + bongo_cat_platform_shutdown(&app->platform); + bongo_cat_runtime_log_stop(); + bongo_cat_window_destroy(app); + bongo_cat_runtime_clean_shutdown(app, exit_code); +} diff --git a/src/runtime/lifecycle/startup.c b/src/runtime/lifecycle/startup.c new file mode 100644 index 00000000..b5cee757 --- /dev/null +++ b/src/runtime/lifecycle/startup.c @@ -0,0 +1,203 @@ +#include "runtime.h" +#include "runtime_state.h" +#include "bongo_cat/file.h" +#include "bongo_cat/log.h" +#include "bongo_cat/path.h" +#include "storage_paths.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include "windows_package.h" +#endif + +static char runtime_log_path[BONGO_CAT_PATH_CAP]; +static char log_source[BONGO_CAT_ID_CAP + 8] = "unknown"; +static bool startup_is_ready; +static SDL_Mutex *log_mutex; + +static const char *priority_name(SDL_LogPriority priority) { + switch (priority) { + case SDL_LOG_PRIORITY_TRACE: return "TRACE"; + case SDL_LOG_PRIORITY_VERBOSE: return "VERBOSE"; + case SDL_LOG_PRIORITY_DEBUG: return "DEBUG"; + case SDL_LOG_PRIORITY_INFO: return "INFO"; + case SDL_LOG_PRIORITY_WARN: return "WARN"; + case SDL_LOG_PRIORITY_ERROR: return "ERROR"; + case SDL_LOG_PRIORITY_CRITICAL: return "CRITICAL"; + default: return "UNKNOWN"; + } +} + +static void append_log(const char *path, const char *timestamp, + int category, SDL_LogPriority priority, const char *message) { + if (!path || !path[0]) return; + const char *text = message ? message : ""; + int prefix = snprintf(NULL, 0, "%s [%s] [%s:%d] ", + timestamp ? timestamp : "unknown-time", log_source, + priority_name(priority), category); + size_t text_length = strlen(text); + char *line = prefix >= 0 && text_length < SIZE_MAX - (size_t)prefix - 2 + ? malloc((size_t)prefix + text_length + 2) : NULL; + if (!line) return; + snprintf(line, (size_t)prefix + 1, "%s [%s] [%s:%d] ", + timestamp ? timestamp : "unknown-time", log_source, + priority_name(priority), category); + for (size_t i = 0; i < text_length; ++i) + line[(size_t)prefix + i] = text[i] == '\r' || text[i] == '\n' + ? ' ' : text[i]; + size_t length = (size_t)prefix + text_length; + line[length++] = '\n'; + bongo_cat_file_append(path, line, length); + free(line); +} + +static void SDLCALL log_output(void *userdata, int category, + SDL_LogPriority priority, const char *message) { + (void)userdata; + if (!bongo_cat_log_enabled(category, priority)) return; + if (log_mutex) SDL_LockMutex(log_mutex); + char timestamp[64]; + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); + append_log(runtime_log_path, timestamp, category, priority, message); + fprintf(stderr, "%s [%s] [%s:%d] %s\n", timestamp, log_source, + priority_name(priority), category, message ? message : ""); + if (log_mutex) SDL_UnlockMutex(log_mutex); +} + +void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code) { + char timestamp[64], message[80]; + bongo_cat_runtime_timestamp(timestamp, sizeof(timestamp)); + snprintf(message, sizeof(message), + "[runtime] Shutdown complete: exit_code=%d", exit_code); + append_log(runtime_log_path, timestamp, BONGO_CAT_LOG_LIFECYCLE, + SDL_LOG_PRIORITY_INFO, message); + fprintf(stderr, "%s [%s] [%s:%d] %s\n", timestamp, log_source, + priority_name(SDL_LOG_PRIORITY_INFO), + BONGO_CAT_LOG_LIFECYCLE, message); + + bongo_cat_runtime_state_clean(app, timestamp); +} + +void bongo_cat_runtime_log_stop(void) { + if (!log_mutex) return; + SDL_Mutex *mutex = log_mutex; + log_mutex = NULL; + SDL_DestroyMutex(mutex); +} + +static void set_log_source(const BongoCatApp *app) { + if (app && app->secondary_pet) + snprintf(log_source, sizeof(log_source), "pet:%s", + app->secondary_model_id); + else snprintf(log_source, sizeof(log_source), "primary"); + for (size_t i = 0; log_source[i]; ++i) + if (log_source[i] == '\r' || log_source[i] == '\n') + log_source[i] = ' '; +} + +static void begin_log(BongoCatApp *app) { + char stage_path[BONGO_CAT_PATH_CAP]; + char stage_temporary[BONGO_CAT_PATH_CAP]; + char interrupted[128] = {0}; + char unexpected[256] = {0}; + bongo_cat_runtime_state_previous(app, unexpected, sizeof(unexpected)); + runtime_log_path[0] = '\0'; + bongo_cat_path_join(runtime_log_path, sizeof(runtime_log_path), + app->log_root, "BongoCat.log"); + /* The primary process owns the session log. Secondary pets append to it + instead of erasing diagnostics already written by the primary. */ + if (runtime_log_path[0] && !app->secondary_pet) { + FILE *fresh_log = bongo_cat_file_open(runtime_log_path, "wb"); + if (fresh_log) fclose(fresh_log); + } + set_log_source(app); + bool stage_paths_ready = bongo_cat_path_join(stage_path, + sizeof(stage_path), app->state_root, "startup-stage.txt") && + bongo_cat_path_join(stage_temporary, sizeof(stage_temporary), + app->state_root, "startup-stage.tmp"); + if (stage_paths_ready && !bongo_cat_path_is_file(stage_path) && + bongo_cat_path_is_file(stage_temporary)) + bongo_cat_file_replace(stage_temporary, stage_path, true); + else if (stage_paths_ready) bongo_cat_file_remove(stage_temporary); + FILE *stage = stage_paths_ready ? bongo_cat_file_open(stage_path, "rb") + : NULL; + if (stage) { + size_t length = fread(interrupted, 1, sizeof(interrupted) - 1, stage); + interrupted[ferror(stage) ? 0 : length] = '\0'; + fclose(stage); + } + if (!log_mutex) log_mutex = SDL_CreateMutex(); + SDL_SetLogOutputFunction(log_output, NULL); + SDL_SetLogPriorities(SDL_LOG_PRIORITY_WARN); + SDL_SetLogPriority(BONGO_CAT_LOG_LIFECYCLE, SDL_LOG_PRIORITY_INFO); + SDL_SetLogPriority(BONGO_CAT_LOG_UPDATE, SDL_LOG_PRIORITY_INFO); + SDL_SetLogPriority(BONGO_CAT_LOG_INPUT, SDL_LOG_PRIORITY_INFO); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "[runtime] Process started: version=%s platform=%s", + BONGO_CAT_VERSION, SDL_GetPlatform()); +#ifdef _WIN32 + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Package identity: %s", + bongo_cat_windows_is_packaged() ? "MSIX" : "unpackaged Win32"); +#endif + if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Previous startup ended before readiness at stage: %s", interrupted); + if (unexpected[0] && !strstr(unexpected, "stage=clean-shutdown")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Previous run ended unexpectedly (crash, forced close, or hang): %s", + unexpected); +} + +bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, + BongoCatError *error) { + if (!bongo_cat_startup_arguments(app, argc, argv, error) || + !bongo_cat_storage_paths_prepare(app, error)) return false; + begin_log(app); bongo_cat_startup_stage(app, "paths-ready"); return true; +} + +void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { + if (!app || !app->state_root[0] || !stage) return; + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.txt") && bongo_cat_path_join(temporary, + sizeof(temporary), app->state_root, "startup-stage.tmp")) { + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fputs(stage, file) >= 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) + bongo_cat_file_remove(temporary); + } + char runtime_stage[160]; + snprintf(runtime_stage, sizeof(runtime_stage), "startup:%s", stage); + bongo_cat_runtime_stage(app, runtime_stage); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Startup stage: %s", stage); +} + +void bongo_cat_startup_ready(BongoCatApp *app) { + if (!app || startup_is_ready) return; + startup_is_ready = true; + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.txt")) bongo_cat_file_remove(path); + if (bongo_cat_path_join(path, sizeof(path), app->state_root, + "startup-stage.tmp")) bongo_cat_file_remove(path); + bongo_cat_runtime_stage(app, "running"); + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, "[runtime] Startup ready"); +} + +void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { + const char *message = error && error->message[0] ? error->message : "Initialization failed"; + if (app) bongo_cat_startup_stage(app, "failed"); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); +} + +void bongo_cat_startup_ci_failure(BongoCatApp *app, + const BongoCatError *error) { + if (!app) return; + app->exit_code = 1; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "[runtime] CI failure: %s", + error && error->message[0] ? error->message : "CI operation failed"); +} diff --git a/src/runtime/lifecycle/startup_arguments.c b/src/runtime/lifecycle/startup_arguments.c new file mode 100644 index 00000000..1ad562fc --- /dev/null +++ b/src/runtime/lifecycle/startup_arguments.c @@ -0,0 +1,126 @@ +#include "runtime.h" +#include "bongo_cat/i18n.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include "windows_package.h" +#endif + +static bool store_argument(char *target, size_t capacity, const char *value, + const char *name, BongoCatError *error) { + int length = snprintf(target, capacity, "%s", value ? value : ""); + if (length >= 0 && (size_t)length < capacity) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "%s path is too long", name); + return false; +} + +static bool prepare_nearby_root(BongoCatApp *app, BongoCatError *error) { +#ifdef _WIN32 + /* A packaged full-trust app is commonly launched with System32 as its + current directory. That is not a user model directory, and scanning + it can walk protected Windows management folders (for example + appmgmt\MACHINE), causing startup to fail or consume the scan budget. + Nearby model discovery remains available through an explicit + --nearby-root argument and for unpackaged/portable launches. */ + char *current = NULL; + const char *root = NULL; + if (!bongo_cat_windows_is_packaged()) { + current = SDL_GetCurrentDirectory(); + root = current && current[0] ? current : SDL_GetBasePath(); + } +#else + const char *root = SDL_GetBasePath(); +#endif + bool stored = !root || store_argument(app->nearby_root, + sizeof(app->nearby_root), root, "Nearby model", error); +#ifdef _WIN32 + SDL_free(current); +#endif + return stored; +} + +bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, + BongoCatError *error) { + if (!prepare_nearby_root(app, error)) return false; + for (int i = 1; i < argc; ++i) { + const char *arg = argv[i]; + if (strcmp(arg, "--autostart") == 0) app->autostart_launch = true; + else if (strcmp(arg, "--ci-smoke") == 0) app->smoke = true; + else if (strcmp(arg, "--ci-preferences") == 0) app->smoke_preferences = true; + else if (strcmp(arg, "--ci-preference-shortcut") == 0) + app->smoke_preference_shortcut = true; + else if (strcmp(arg, "--ci-preference-model-select") == 0) + app->smoke_preference_model_select = true; + else if (strcmp(arg, "--ci-remove-imported") == 0) app->smoke_remove_imported = true; + else if (strcmp(arg, "--ci-shortcuts") == 0) app->smoke_shortcuts = true; + else if (strcmp(arg, "--ci-menu") == 0) app->smoke_menu = true; + else if (strcmp(arg, "--ci-ignore-global-input") == 0) app->smoke_ignore_global_input = true; + else if (strcmp(arg, "--ci-pass-through") == 0) app->smoke_pass_through = true; + else if (strcmp(arg, "--ci-context-menu") == 0) app->smoke_context_menu = true; + else if (strcmp(arg, "--ci-frame-series") == 0) app->smoke_frame_series = true; + else if (strcmp(arg, "--ci-runtime-flow") == 0) app->smoke_runtime_flow = true; + else if (strcmp(arg, "--ci-freeze-model") == 0) app->smoke_freeze_model = true; + else if (strncmp(arg, "--ci-preference-page=", 21) == 0) { + int page = atoi(arg + 21); + if (page >= 0 && page < 4) app->smoke_preference_page = page; + } else if (strncmp(arg, "--ci-language=", 14) == 0) { + BongoCatLanguage language; + if (bongo_cat_language_parse(arg + 14, &language)) + app->smoke_language = language; + } else if (strncmp(arg, "--ci-theme=", 11) == 0) { + const char *name = arg + 11; + for (int value = 0; value <= BONGO_CAT_THEME_DARK; ++value) + if (strcmp(name, bongo_cat_theme_name((BongoCatTheme)value)) == 0) + app->smoke_theme = value; + } else if (strncmp(arg, "--ci-exit-ms=", 13) == 0) { + uint64_t delay = strtoull(arg + 13, NULL, 10); + app->smoke_deadline_ns = delay > UINT64_MAX / 1000000ull + ? UINT64_MAX : delay * 1000000ull; + } else if (strncmp(arg, "--storage-root=", 15) == 0) { + if (!store_argument(app->storage_root, sizeof(app->storage_root), + arg + 15, "Storage", error)) return false; + } else if (strncmp(arg, "--nearby-root=", 14) == 0) { + if (!store_argument(app->nearby_root, sizeof(app->nearby_root), + arg + 14, "Nearby model", error)) return false; + } else if (strncmp(arg, "--secondary-pet=", 16) == 0) { + if (!store_argument(app->secondary_model_id, + sizeof(app->secondary_model_id), arg + 16, + "Secondary model", error)) return false; + app->secondary_pet = app->secondary_model_id[0] != '\0'; + } else if (strncmp(arg, "--secondary-position=", 21) == 0) { + char *separator = NULL, *end = NULL; + long x = strtol(arg + 21, &separator, 10); + long y = separator && *separator == ',' ? + strtol(separator + 1, &end, 10) : 0; + app->secondary_origin_known = separator != arg + 21 && end && + end != separator + 1 && !*end && x >= INT_MIN && x <= INT_MAX && + y >= INT_MIN && y <= INT_MAX; + if (app->secondary_origin_known) { + app->secondary_origin_x = (int)x; + app->secondary_origin_y = (int)y; + } + } else if (strncmp(arg, "--ci-import=", 12) == 0) { + if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), + arg + 12, "Import", error)) return false; + } else if (strncmp(arg, "--ci-model=", 11) == 0) { + if (!store_argument(app->smoke_model, sizeof(app->smoke_model), + arg + 11, "Model", error)) return false; + } else if (strncmp(arg, "--ci-viewer-trace=", 18) == 0) { + if (!store_argument(app->smoke_viewer_trace, + sizeof(app->smoke_viewer_trace), arg + 18, + "Viewer trace", error)) return false; + } else if (strncmp(arg, "--ci-live2d-scenario=", 21) == 0 && + !store_argument(app->smoke_live2d_scenario, + sizeof(app->smoke_live2d_scenario), arg + 21, + "Scenario", error)) return false; + } + if (app->smoke && !app->smoke_deadline_ns) + app->smoke_deadline_ns = 1500000000ull; + return true; +} diff --git a/src/runtime/lifecycle/storage_paths.c b/src/runtime/lifecycle/storage_paths.c new file mode 100644 index 00000000..87a1e983 --- /dev/null +++ b/src/runtime/lifecycle/storage_paths.c @@ -0,0 +1,136 @@ +#include "storage_paths.h" +#include "runtime.h" + +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +#ifdef _WIN32 +#include "windows_package.h" +#endif + +static bool child_path(char *target, size_t capacity, const char *root, + const char *child) { + return root && root[0] && + bongo_cat_path_join(target, capacity, root, child); +} + +static bool writable_directory(const char *root) { + char probe[BONGO_CAT_PATH_CAP]; + if (!root || !root[0] || !bongo_cat_path_create_directory(root) || + !bongo_cat_path_join(probe, sizeof(probe), root, + ".bongocat-write-probe")) return false; + FILE *file = bongo_cat_file_open(probe, "wb"); + bool written = file && fputc('1', file) != EOF; + if (file && fclose(file) != 0) written = false; + bongo_cat_file_remove(probe); + return written; +} + +static bool roots_under(BongoCatApp *app, const char *root) { + return child_path(app->config_root, sizeof(app->config_root), + root, "config") && + child_path(app->data_root, sizeof(app->data_root), + root, "data") && + child_path(app->models_root, sizeof(app->models_root), + root, "models") && + child_path(app->cache_root, sizeof(app->cache_root), + root, "cache") && + child_path(app->state_root, sizeof(app->state_root), + root, "state") && + child_path(app->log_root, sizeof(app->log_root), + root, "logs"); +} + +static bool isolated_roots(BongoCatApp *app) { + return roots_under(app, app->storage_root); +} + +#ifdef _WIN32 +static bool platform_roots(BongoCatApp *app) { + char local_app[BONGO_CAT_PATH_CAP]; + if (bongo_cat_windows_is_packaged()) + return bongo_cat_windows_package_storage_root(local_app, + sizeof(local_app)) && roots_under(app, local_app); + const char *local = SDL_getenv("LOCALAPPDATA"); + return child_path(local_app, sizeof(local_app), local, BONGO_CAT_NAME) && + roots_under(app, local_app); +} +#elif defined(__APPLE__) +static bool platform_roots(BongoCatApp *app) { + const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); + char library[BONGO_CAT_PATH_CAP], support[BONGO_CAT_PATH_CAP]; + char app_support[BONGO_CAT_PATH_CAP]; + return child_path(library, sizeof(library), home, "Library") && + child_path(support, sizeof(support), library, "Application Support") && + child_path(app_support, sizeof(app_support), support, BONGO_CAT_NAME) && + child_path(app->config_root, sizeof(app->config_root), + app_support, "config") && + child_path(app->data_root, sizeof(app->data_root), app_support, "data") && + child_path(app->models_root, sizeof(app->models_root), + app_support, "models") && + child_path(app->state_root, sizeof(app->state_root), app_support, "state") && + child_path(app->cache_root, sizeof(app->cache_root), + library, "Caches/BongoCat") && + child_path(app->log_root, sizeof(app->log_root), + library, "Logs/BongoCat"); +} +#else +static bool xdg_root(char *target, size_t capacity, const char *variable, + const char *home_suffix) { + const char *configured = SDL_getenv(variable); + if (configured && configured[0]) + return child_path(target, capacity, configured, "bongocat"); + const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); + char fallback[BONGO_CAT_PATH_CAP]; + return child_path(fallback, sizeof(fallback), home, home_suffix) && + child_path(target, capacity, fallback, "bongocat"); +} + +static bool platform_roots(BongoCatApp *app) { + if (!xdg_root(app->config_root, sizeof(app->config_root), + "XDG_CONFIG_HOME", ".config") || + !xdg_root(app->data_root, sizeof(app->data_root), + "XDG_DATA_HOME", ".local/share") || + !xdg_root(app->state_root, sizeof(app->state_root), + "XDG_STATE_HOME", ".local/state") || + !xdg_root(app->cache_root, sizeof(app->cache_root), + "XDG_CACHE_HOME", ".cache")) return false; + if (!child_path(app->models_root, sizeof(app->models_root), + app->data_root, "models")) return false; + return child_path(app->log_root, sizeof(app->log_root), + app->state_root, "logs"); +} +#endif + +bool bongo_cat_storage_paths_prepare(BongoCatApp *app, + BongoCatError *error) { + if (!app) return false; + bool resolved = app->storage_root[0] + ? isolated_roots(app) : platform_roots(app); + if (resolved) { + snprintf(app->primary_state_root, sizeof(app->primary_state_root), + "%s", app->state_root); + } + if (resolved && app->secondary_pet) { + resolved = bongo_cat_multi_pet_state_directory(app->state_root, + sizeof(app->state_root), app->primary_state_root, + app->secondary_model_id); + } + const char *roots[] = {app->config_root, app->data_root, app->models_root, + app->cache_root, app->state_root, app->log_root}; + for (size_t i = 0; resolved && i < sizeof(roots) / sizeof(roots[0]); ++i) + resolved = writable_directory(roots[i]); + resolved = resolved && child_path(app->settings_path, + sizeof(app->settings_path), app->config_root, "settings.json") && + child_path(app->session_path, sizeof(app->session_path), + app->state_root, "session.json"); + if (resolved) return true; + app->settings_path[0] = app->session_path[0] = '\0'; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Application storage directories are unavailable or not writable"); + return false; +} diff --git a/src/runtime/lifecycle/storage_paths.h b/src/runtime/lifecycle/storage_paths.h new file mode 100644 index 00000000..6fc5677a --- /dev/null +++ b/src/runtime/lifecycle/storage_paths.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_STORAGE_PATHS_H +#define BONGO_CAT_STORAGE_PATHS_H + +#include "bongo_cat/app.h" + +bool bongo_cat_storage_paths_prepare(BongoCatApp *app, + BongoCatError *error); + +#endif diff --git a/src/runtime/lifecycle/system_language.c b/src/runtime/lifecycle/system_language.c new file mode 100644 index 00000000..dc5c7d12 --- /dev/null +++ b/src/runtime/lifecycle/system_language.c @@ -0,0 +1,20 @@ +#include "runtime.h" + +#include + +bool bongo_cat_system_language(BongoCatLanguage *language) { + if (!language) return false; + int count = 0; + SDL_Locale **locales = SDL_GetPreferredLocales(&count); + bool matched = false; + for (int i = 0; locales && i < count; ++i) { + const SDL_Locale *locale = locales[i]; + if (locale && bongo_cat_language_from_locale(locale->language, + locale->country, language)) { + matched = true; + break; + } + } + if (locales) SDL_free(locales); + return matched; +} diff --git a/src/runtime/model/import/model_import.h b/src/runtime/model/import/model_import.h new file mode 100644 index 00000000..d95032ba --- /dev/null +++ b/src/runtime/model/import/model_import.h @@ -0,0 +1,165 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_H +#define BONGO_CAT_MODEL_IMPORT_H + +#include "bongo_cat/common.h" +#include "bongo_cat/model.h" + +#define BONGO_CAT_IMPORT_CANDIDATE_CAP 16 +#define BONGO_CAT_IMPORT_FAILURE_NAME_CAP 3 +#define BONGO_CAT_SKIN_CONFIG_FILE "bongocat.skin.json" + +typedef enum BongoCatImportFormat { + BONGO_CAT_IMPORT_TAURI, + BONGO_CAT_IMPORT_MVER, + BONGO_CAT_IMPORT_MVER_PATCH +} BongoCatImportFormat; + +typedef struct BongoCatImportCandidate { + char directory[BONGO_CAT_PATH_CAP]; + char setting[BONGO_CAT_PATH_CAP]; + char assets[BONGO_CAT_PATH_CAP]; + char package_root[BONGO_CAT_PATH_CAP]; + char patch_root[BONGO_CAT_PATH_CAP]; + char config[BONGO_CAT_PATH_CAP]; + char overrides[BONGO_CAT_PATH_CAP]; + BongoCatModelMode mode; + BongoCatImportFormat format; + bool gamepad_buttons; +} BongoCatImportCandidate; + +typedef struct BongoCatImportDiscovery { + BongoCatImportCandidate candidates[BONGO_CAT_IMPORT_CANDIDATE_CAP]; + /* Basename selected by the user. This becomes the visible model id. */ + char source_name[BONGO_CAT_ID_CAP]; + size_t count; + int depth; + bool ambiguous; +} BongoCatImportDiscovery; + +typedef struct BongoCatImportReceipt { + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + bool installed[BONGO_CAT_IMPORT_CANDIDATE_CAP]; + size_t count; + size_t installed_count; +} BongoCatImportReceipt; + +typedef void (*BongoCatImportReceiptCallback)(void *userdata, + const BongoCatImportReceipt *receipt); + +typedef struct BongoCatImportBatchStats { + size_t succeeded_count; + size_t failed_count; + size_t failure_name_count; + char failure_names[BONGO_CAT_IMPORT_FAILURE_NAME_CAP][BONGO_CAT_ID_CAP]; +} BongoCatImportBatchStats; + +typedef struct BongoCatPackageMetadata { + char package_id[BONGO_CAT_ID_CAP]; + char content_digest[65]; + char family_id[BONGO_CAT_ID_CAP]; + char display_name[BONGO_CAT_ID_CAP]; + char source_name[BONGO_CAT_ID_CAP]; + uint32_t capabilities; +} BongoCatPackageMetadata; + +typedef struct BongoCatImportDigestCache BongoCatImportDigestCache; +typedef struct BongoCatImportSession BongoCatImportSession; + +typedef BongoCatResult (*BongoCatImportVisitor)(void *userdata, + const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); + +typedef struct BongoCatApp BongoCatApp; + +/* Shared discovery and package preparation. */ +bool bongo_cat_import_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error); +bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error); +bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error); +bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, + const char *target, BongoCatImportCandidate *installed, BongoCatError *error); +bool bongo_cat_import_prepare_storage( + const BongoCatImportDiscovery *discovery, const char *target, + BongoCatError *error); +bool bongo_cat_import_authored_package(const char *directory); + +/* Content identity and package metadata. */ +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error); +bool bongo_cat_import_candidate_inspect_cached( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatImportDigestCache *cache, + BongoCatError *error); +BongoCatImportDigestCache *bongo_cat_import_digest_cache_create(void); +void bongo_cat_import_digest_cache_destroy(BongoCatImportDigestCache *cache); +bool bongo_cat_import_digest_file_cached(BongoCatImportDigestCache *cache, + const char *path, uint64_t size, uint64_t modified, char output[65]); +bool bongo_cat_import_prepare_package_metadata( + BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatError *error); +bool bongo_cat_import_prepare_package_metadata_cached( + BongoCatImportDiscovery *discovery, BongoCatPackageMetadata *metadata, + BongoCatImportDigestCache *cache, BongoCatError *error); +bool bongo_cat_import_package_id(char *output, size_t capacity, + const char *name); +bool bongo_cat_import_variant_id(char *output, size_t capacity, + const char *package_id, size_t variant_index); +bool bongo_cat_import_package_base_id(char *output, size_t capacity, + const char *model_id); +uint32_t bongo_cat_import_candidate_capabilities( + const BongoCatImportCandidate *candidate); + +/* Runtime adapter and catalog integration. */ +void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *source_hash, const char *source, + const char *adapter); +void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, + const char *directory); +bool bongo_cat_import_render_options(const char *directory, + BongoCatLive2DRenderOptions *options); + +/* Recursive source scanning. */ +BongoCatResult bongo_cat_import_scan(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); +BongoCatResult bongo_cat_import_scan_diagnostic(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error); +BongoCatResult bongo_cat_import_scan_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + BongoCatError *error); +BongoCatResult bongo_cat_import_source_directory(const char *source, + char *directory, size_t capacity, BongoCatError *error); + +/* Nearby and installed catalog discovery. */ +BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, + const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, + const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, + const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, + const char *root, BongoCatError *error); +BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, + const char *root, const char *package_id, BongoCatError *error); + +/* Installation sessions and progressive batch import. */ +BongoCatResult bongo_cat_import_install(const char *source, + const char *models_root, BongoCatImportReceipt *receipt, + BongoCatError *error); +BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, + BongoCatError *error); +void bongo_cat_import_session_destroy(BongoCatImportSession *session); +BongoCatResult bongo_cat_import_session_install( + BongoCatImportSession *session, const char *source, + BongoCatImportReceipt *receipt, BongoCatError *error); +BongoCatResult bongo_cat_import_session_install_progressive( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatImportBatchStats *stats, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_adapter.c b/src/runtime/model/import/model_import_adapter.c new file mode 100644 index 00000000..d617defb --- /dev/null +++ b/src/runtime/model/import/model_import_adapter.c @@ -0,0 +1,104 @@ +#include "model_import.h" +#include "mver/model_import_mver.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool copy_optional(const char *source_dir, const char *source_name, + const char *target_dir, const char *target_name) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(source, sizeof(source), source_dir, source_name) && + bongo_cat_path_join(target, sizeof(target), target_dir, target_name) && + bongo_cat_path_copy_file(source, target); +} + +static bool copy_first(const char *source_dir, const char *const *names, + size_t count, const char *target_dir, const char *target_name) { + for (size_t i = 0; i < count; ++i) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), source_dir, names[i]) || + !bongo_cat_path_is_file(source)) continue; + return copy_optional(source_dir, names[i], target_dir, target_name); + } + return true; +} + +static bool copy_preview_file(const char *source_resources, + const char *source_root, const char *const *names, size_t count, + const char *target_resources, const char *target_name) { + for (size_t i = 0; i < count; ++i) { + char source[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(source, sizeof(source), source_resources, + names[i]) && bongo_cat_path_is_file(source)) + return copy_optional(source_resources, names[i], target_resources, + target_name); + } + return copy_first(source_root, names, count, target_resources, target_name); +} + +static bool preview_file_exists(const char *directory, const char *name) { + char path[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(path, sizeof(path), directory, name) && + bongo_cat_path_is_file(path); +} + +static bool copy_preview(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source_resources, sizeof(source_resources), + candidate->assets, "resources") || + !bongo_cat_path_join(target_resources, sizeof(target_resources), target, + "resources")) return false; + bool target_exists = bongo_cat_path_is_dir(target_resources); + if (bongo_cat_path_is_dir(source_resources) && !target_exists) { + if (bongo_cat_model_copy_directory(source_resources, target_resources, + error) != BONGO_CAT_OK) return false; + target_exists = true; + } + if (!target_exists && !bongo_cat_path_create_directory(target_resources)) + return false; + const char *covers[] = { + "cover.png", "cat.png", "bg.png", "mousebg.png", "tabletbg.png"}; + const char *backgrounds[] = { + "background.png", "bg.png", "mousebg.png", "tabletbg.png"}; + bool ok = (preview_file_exists(target_resources, "cover.png") || + copy_preview_file(source_resources, candidate->assets, covers, 5, + target_resources, "cover.png")) && + (preview_file_exists(target_resources, "background.png") || + copy_preview_file(source_resources, candidate->assets, backgrounds, 4, + target_resources, "background.png")); + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy model preview assets: %s", SDL_GetError()); + return ok; +} + +static bool write_mode(const char *target, BongoCatModelMode mode, + BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, ".bongo-cat-mode")) + return false; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + const char *name = mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : + mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; + bool ok = fputs(name, file) >= 0; + if (fclose(file) != 0) ok = false; + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write imported model metadata"); + return ok; +} + +bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + if (!candidate || !target || (!bongo_cat_path_is_dir(target) && + !bongo_cat_path_create_directory(target))) return false; + return copy_preview(candidate, target, error) && + bongo_cat_import_mver_assets(candidate, target, error) && + bongo_cat_import_adapter_metadata(candidate, target, error) && + bongo_cat_import_write_report(candidate, target, error) && + write_mode(target, candidate->mode, error); +} diff --git a/src/runtime/model/import/model_import_app.c b/src/runtime/model/import/model_import_app.c new file mode 100644 index 00000000..507b29d6 --- /dev/null +++ b/src/runtime/model/import/model_import_app.c @@ -0,0 +1,66 @@ +#include "model_import.h" +#include "model_import_lock.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include + +#ifdef BONGO_CAT_HAS_CUBISM +static void remove_receipt(const char *models_root, + const BongoCatImportReceipt *receipt) { + char path[BONGO_CAT_PATH_CAP]; + if (!receipt || !models_root) return; + bongo_cat_import_storage_lock(); + for (size_t i = 0; i < receipt->count; ++i) + if (receipt->installed[i] && + bongo_cat_path_join(path, sizeof(path), models_root, receipt->ids[i])) + bongo_cat_model_remove_tree(path, NULL); + bongo_cat_import_storage_unlock(); +} +#endif + +BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, + BongoCatError *error) { + if (!app || !source) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatImportReceipt receipt; + BongoCatResult result = bongo_cat_import_install(source, app->models_root, + &receipt, error); + if (result != BONGO_CAT_OK) { + if (error && !error->message[0]) bongo_cat_error_set(error, result, + "Model import failed while installing: %s", source); + return result; + } + char previous[BONGO_CAT_PATH_CAP]; + snprintf(previous, sizeof(previous), "%s", app->session.active_model_id); + size_t preferred = 0; + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) { preferred = i; break; } + const char *imported_id = receipt.count ? receipt.ids[preferred] : NULL; + if (receipt.count) + bongo_cat_settings_restore_model_package(&app->settings, + receipt.ids[0]); + bongo_cat_app_refresh_installed_models(app); + for (size_t i = 0; i < receipt.count; ++i) + if (receipt.installed[i]) + bongo_cat_app_forget_behavior_state(app, receipt.ids[i]); + if (imported_id && bongo_cat_app_select_model(app, imported_id)) + return BONGO_CAT_OK; +#ifndef BONGO_CAT_HAS_CUBISM + const BongoCatModelEntry *entry = imported_id ? + bongo_cat_models_find(&app->models, imported_id) : NULL; + if (entry) { + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), + "%s", imported_id); + app->loaded_mode = entry->mode; + } + return BONGO_CAT_OK; +#else + remove_receipt(app->models_root, &receipt); + bongo_cat_app_refresh_installed_models(app); + if (previous[0]) bongo_cat_app_select_model(app, previous); + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Model import was rolled back because the Live2D model could not be loaded"); + return BONGO_CAT_ERROR_CUBISM; +#endif +} diff --git a/src/runtime/model/import/model_import_archive.c b/src/runtime/model/import/model_import_archive.c new file mode 100644 index 00000000..71f32954 --- /dev/null +++ b/src/runtime/model/import/model_import_archive.c @@ -0,0 +1,168 @@ +#include "model_import_archive.h" +#include "model_import_path.h" +#include "model_storage.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include + +#define ARCHIVE_BYTES_LIMIT (UINT64_C(2) * 1024 * 1024 * 1024) +#define ARCHIVE_ENTRY_LIMIT 20000 + +bool bongo_cat_import_is_archive(const char *source) { + return source && bongo_cat_import_has_suffix_ci(source, ".zip") && + !bongo_cat_path_is_dir(source); +} + +static size_t read_archive(void *userdata, mz_uint64 offset, void *buffer, + size_t size) { + SDL_IOStream *stream = userdata; + if (offset > INT64_MAX || SDL_SeekIO(stream, (Sint64)offset, + SDL_IO_SEEK_SET) < 0) return 0; + return SDL_ReadIO(stream, buffer, size); +} + +typedef struct ArchiveOutput { + SDL_IOStream *stream; + uint64_t remaining; + bool failed; +} ArchiveOutput; + +static size_t write_archive(void *userdata, mz_uint64 offset, + const void *buffer, size_t size) { + (void)offset; + ArchiveOutput *output = userdata; + if (size > output->remaining) return 0; + size_t written = SDL_WriteIO(output->stream, buffer, size); + output->remaining -= written; + if (written != size) output->failed = true; + return written; +} + +/* Reject aliases and special Windows paths on every platform, so an archive + has the same meaning wherever it is imported. Never materialize links. */ +static bool safe_path(char *path) { + for (char *p = path; *p; ++p) if (*p == '\\') *p = '/'; + if (!path[0] || path[0] == '/') return false; + unsigned depth = 0; + for (char *part = path; *part;) { + char *end = strchr(part, '/'); + size_t length = end ? (size_t)(end - part) : strlen(part); + if (!length || ++depth > 24 || part[length - 1] == '.' || + part[length - 1] == ' ') return false; + for (size_t i = 0; i < length; ++i) + if ((unsigned char)part[i] < 32 || strchr(":*?\"<>|", part[i])) + return false; + size_t stem = 0; + while (stem < length && part[stem] != '.') ++stem; + if ((stem == 3 && (!SDL_strncasecmp(part, "CON", 3) || + !SDL_strncasecmp(part, "PRN", 3) || + !SDL_strncasecmp(part, "AUX", 3) || + !SDL_strncasecmp(part, "NUL", 3))) || + (stem == 4 && (!SDL_strncasecmp(part, "COM", 3) || + !SDL_strncasecmp(part, "LPT", 3)) && + part[3] >= '0' && part[3] <= '9')) return false; + part = end ? end + 1 : part + length; + } + return true; +} + +static BongoCatResult extract_entries(mz_zip_archive *zip, const char *root) { + mz_uint count = mz_zip_reader_get_num_files(zip); + if (!count || count > ARCHIVE_ENTRY_LIMIT) return BONGO_CAT_ERROR_FORMAT; + uint64_t total = 0; + for (mz_uint i = 0; i < count; ++i) { + mz_zip_archive_file_stat stat; + char name[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!mz_zip_reader_file_stat(zip, i, &stat)) + return BONGO_CAT_ERROR_FORMAT; + mz_uint length = mz_zip_reader_get_filename(zip, i, NULL, 0); + if (!length || length > sizeof(name) || + mz_zip_reader_get_filename(zip, i, name, sizeof(name)) != length) + return BONGO_CAT_ERROR_FORMAT; + unsigned kind = (stat.m_external_attr >> 16) & 0170000; + if (!length || length > sizeof(name) || strlen(name) + 1 != length || + !safe_path(name) || stat.m_is_encrypted || !stat.m_is_supported || + (kind && kind != 0100000 && kind != 0040000) || + stat.m_uncomp_size > ARCHIVE_BYTES_LIMIT - total || + !bongo_cat_path_join(target, sizeof(target), root, name)) + return BONGO_CAT_ERROR_FORMAT; + total += stat.m_uncomp_size; + if (stat.m_is_directory) { + if (!bongo_cat_path_create_directory(target)) return BONGO_CAT_ERROR_IO; + continue; + } + char parent[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_is_file(target) || bongo_cat_path_is_dir(target)) + return BONGO_CAT_ERROR_FORMAT; + if (!bongo_cat_import_parent_path(target, parent, sizeof(parent)) || + !bongo_cat_path_create_directory(parent)) return BONGO_CAT_ERROR_IO; + SDL_IOStream *output = SDL_IOFromFile(target, "wb"); + if (!output) return BONGO_CAT_ERROR_IO; + ArchiveOutput destination = {output, stat.m_uncomp_size, false}; + bool extracted = mz_zip_reader_extract_to_callback(zip, i, + write_archive, &destination, 0) != 0; + bool closed = SDL_CloseIO(output); + if (!closed || destination.failed) return BONGO_CAT_ERROR_IO; + if (!extracted || destination.remaining) return BONGO_CAT_ERROR_FORMAT; + } + return BONGO_CAT_OK; +} + +void bongo_cat_import_archive_cleanup(const char *temporary) { + if (temporary && temporary[0] && + !bongo_cat_model_remove_tree(temporary, NULL)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot remove model archive temporary directory: %s", temporary); +} + +BongoCatResult bongo_cat_import_archive_extract(const char *source, + char directory[BONGO_CAT_PATH_CAP], char temporary[BONGO_CAT_PATH_CAP], + BongoCatError *error) { + directory[0] = temporary[0] = '\0'; + SDL_IOStream *input = SDL_IOFromFile(source, "rb"); + if (!input) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot open ZIP: %s", source); + return BONGO_CAT_ERROR_IO; + } + mz_zip_archive zip = {0}; + zip.m_pRead = read_archive; + zip.m_pIO_opaque = input; + Sint64 size = SDL_GetIOSize(input); + BongoCatResult result = BONGO_CAT_ERROR_FORMAT; + if (size > 0 && mz_zip_reader_init(&zip, (mz_uint64)size, 0)) { + char *base = SDL_GetPrefPath("BongoCat", "archive-import"); + char token[80], name[BONGO_CAT_PATH_CAP]; + snprintf(token, sizeof(token), "%016llx-%016llx", + (unsigned long long)SDL_GetPerformanceCounter(), + (unsigned long long)SDL_GetTicksNS()); + int written = snprintf(name, sizeof(name), "%s", bongo_cat_path_name(source)); + result = BONGO_CAT_ERROR_IO; + if (base && written >= 4 && (size_t)written < sizeof(name)) { + name[written - 4] = '\0'; + if (!safe_path(name)) snprintf(name, sizeof(name), "model"); + char candidate[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(candidate, sizeof(candidate), base, token) && + !bongo_cat_path_is_dir(candidate) && + !bongo_cat_path_is_file(candidate) && + bongo_cat_path_create_directory(candidate)) { + snprintf(temporary, BONGO_CAT_PATH_CAP, "%s", candidate); + if (bongo_cat_path_join(directory, BONGO_CAT_PATH_CAP, temporary, name) && + bongo_cat_path_create_directory(directory)) + result = extract_entries(&zip, directory); + } + } + SDL_free(base); + mz_zip_reader_end(&zip); + } + SDL_CloseIO(input); + if (result != BONGO_CAT_OK) { + bongo_cat_import_archive_cleanup(temporary); + directory[0] = temporary[0] = '\0'; + bongo_cat_error_set(error, result, + "Cannot extract ZIP (damaged, encrypted, unsafe, too large, or inaccessible): %s", + source); + } + return result; +} diff --git a/src/runtime/model/import/model_import_archive.h b/src/runtime/model/import/model_import_archive.h new file mode 100644 index 00000000..80108b62 --- /dev/null +++ b/src/runtime/model/import/model_import_archive.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_ARCHIVE_H +#define BONGO_CAT_MODEL_IMPORT_ARCHIVE_H + +#include "model_import.h" + +bool bongo_cat_import_is_archive(const char *source); +BongoCatResult bongo_cat_import_archive_extract(const char *source, + char directory[BONGO_CAT_PATH_CAP], char temporary[BONGO_CAT_PATH_CAP], + BongoCatError *error); +void bongo_cat_import_archive_cleanup(const char *temporary); + +#endif diff --git a/src/runtime/model/import/model_import_digest.c b/src/runtime/model/import/model_import_digest.c new file mode 100644 index 00000000..f725b14f --- /dev/null +++ b/src/runtime/model/import/model_import_digest.c @@ -0,0 +1,249 @@ +#include "model_import.h" +#include "model_import_digest_internal.h" +#include "model_import_mver_policy.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define DIGEST_ROOT_CAP 12 +#define path_length bongo_cat_import_digest_path_length +#define path_contains bongo_cat_import_digest_path_contains +#define hash_text bongo_cat_import_digest_hash_text +#define mix bongo_cat_import_digest_mix + +typedef struct DigestStamp { uint64_t sum, exclusive, bytes, files, entries; } DigestStamp; + +typedef struct DigestWalk { + const char *root; + const char *group; + DigestStamp *stamp; + const char *content_root; + DigestStamp *content; + const char *override_root; + DigestStamp *overrides; + BongoCatImportDigestCache *cache; + int depth; +} DigestWalk; + +typedef struct DigestRoot { char path[BONGO_CAT_PATH_CAP]; const char *group; } DigestRoot; + +static bool stamp_path(const char *path, DigestWalk *walk); + +static BongoCatPathVisit stamp_item(void *userdata, + const char *dirname, const char *name) { + DigestWalk *walk = userdata; + char path[BONGO_CAT_PATH_CAP]; + return ++walk->stamp->entries <= 16384 && + bongo_cat_path_join(path, sizeof(path), dirname, name) && + stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : + BONGO_CAT_PATH_FAILURE; +} + +static const char *relative_path(const char *root, const char *path) { + size_t root_length = path_length(root); + if (!path_contains(root, path)) return path; + const char *relative = path + root_length; + while (*relative == '/' || *relative == '\\') relative++; + return relative; +} + +static bool stamp_path(const char *path, DigestWalk *walk) { + if (bongo_cat_path_is_dir(path)) { + if (walk->depth >= 24) return false; + walk->depth++; + bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); + walk->depth--; + return ok; + } + uint64_t size = 0, modified = 0; + if (!bongo_cat_path_file_info(path, &size, &modified) || + walk->stamp->files >= 8192 || + size > 1073741824ull - walk->stamp->bytes) return false; + char file_hash[65]; + if (!bongo_cat_import_digest_file_cached(walk->cache, path, size, + modified, file_hash)) + return false; + uint64_t value = mix(hash_text(walk->group) ^ + hash_text(relative_path(walk->root, path)) ^ hash_text(file_hash) ^ + mix(size)); + walk->stamp->sum += value; + walk->stamp->exclusive ^= value; + walk->stamp->bytes += size; + walk->stamp->files++; + if (walk->content && path_contains(walk->content_root, path)) { + value = mix(hash_text("content") ^ + hash_text(relative_path(walk->content_root, path)) ^ + hash_text(file_hash) ^ mix(size)); + walk->content->sum += value; + walk->content->exclusive ^= value; + walk->content->bytes += size; + walk->content->files++; + } + if (walk->overrides && path_contains(walk->override_root, path)) { + value = mix(hash_text("overrides") ^ + hash_text(relative_path(walk->override_root, path)) ^ + hash_text(file_hash) ^ mix(size)); + walk->overrides->sum += value; + walk->overrides->exclusive ^= value; + walk->overrides->bytes += size; + walk->overrides->files++; + } + return true; +} + +static bool stamp_root(const char *root, const char *group, + DigestStamp *stamp, const BongoCatImportCandidate *candidate, + DigestStamp *content, DigestStamp *overrides, + BongoCatImportDigestCache *cache) { + if (!root || !root[0]) return true; + DigestWalk walk = {root, group, stamp, + candidate->assets, content, candidate->overrides, overrides, cache, 0}; + return stamp_path(root, &walk); +} + +static void add_root(DigestRoot roots[DIGEST_ROOT_CAP], size_t *count, + const char *path, const char *group) { + if (!path || !path[0]) return; + for (size_t i = 0; i < *count; ++i) + if (path_contains(roots[i].path, path)) return; + bool merged = false; + for (size_t i = 0; i < *count;) { + if (!path_contains(path, roots[i].path)) { i++; continue; } + roots[i] = roots[--*count]; + merged = true; + } + if (merged) group = "content"; + if (*count < DIGEST_ROOT_CAP) { + DigestRoot *root = &roots[(*count)++]; + snprintf(root->path, sizeof(root->path), "%s", path); + root->group = group; + } +} + +static bool roots_contain(const DigestRoot *roots, size_t count, + const char *path) { + for (size_t i = 0; i < count; ++i) + if (path_contains(roots[i].path, path)) return true; + return false; +} + +static bool first_file(const char *directory, const char *const *names, + size_t name_count, char *path, size_t capacity, const char **name) { + for (size_t i = 0; i < name_count; ++i) + if (bongo_cat_path_join(path, capacity, directory, names[i]) && + bongo_cat_path_is_file(path)) { + if (name) *name = names[i]; + return true; + } + return false; +} + +static void add_tauri_preview_roots(const BongoCatImportCandidate *candidate, + DigestRoot roots[DIGEST_ROOT_CAP], size_t *count) { + static const char *const covers[] = { + "cover.png", "cat.png", "bg.png", "mousebg.png", "tabletbg.png" + }; + static const char *const backgrounds[] = { + "background.png", "bg.png", "mousebg.png", "tabletbg.png" + }; + char resources[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + bool has_resources = bongo_cat_path_join(resources, sizeof(resources), + candidate->assets, "resources") && bongo_cat_path_is_dir(resources); + if (has_resources) + add_root(roots, count, resources, "preview-resources"); + const char *name = NULL; + bool resource_cover = has_resources && first_file(resources, covers, + sizeof(covers) / sizeof(covers[0]), path, sizeof(path), NULL); + if (!resource_cover && first_file(candidate->assets, covers, + sizeof(covers) / sizeof(covers[0]), path, sizeof(path), &name)) + add_root(roots, count, path, name); + bool resource_background = has_resources && first_file(resources, + backgrounds, sizeof(backgrounds) / sizeof(backgrounds[0]), path, + sizeof(path), NULL); + if (!resource_background && first_file(candidate->assets, backgrounds, + sizeof(backgrounds) / sizeof(backgrounds[0]), path, + sizeof(path), &name)) + add_root(roots, count, path, name); + if (bongo_cat_path_join(path, sizeof(path), candidate->assets, + "cover.png") && bongo_cat_path_is_file(path)) + add_root(roots, count, path, "authored-cover"); +} + +static bool stamp_equal(const DigestStamp *value, uint64_t sum, uint64_t exclusive, + uint64_t bytes, uint64_t files) { + return value->sum == sum && value->exclusive == exclusive && + value->bytes == bytes && value->files == files; +} + +static bool candidate_placeholder(const BongoCatImportCandidate *candidate, + const DigestStamp *overrides, + BongoCatImportDigestCache *cache) { + /* Identify bundled runtime templates by their rendered Live2D core. Do + not use aggregate asset stamps here: older releases shipped a popular + authored skin with the same file counts and byte totals. */ + if (candidate->format == BONGO_CAT_IMPORT_TAURI) return false; + if (bongo_cat_import_mver_stock_model(candidate, cache)) return true; + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { + if (!overrides->files) return true; + if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return false; + return stamp_equal(overrides, 0x3830b3ef1cf413b7ull, + 0xf36e3216ab32c519ull, 828360ull, 12ull) || + stamp_equal(overrides, 0xe35c6e634128d62bull, + 0x17e2f6d0ad5746bfull, 1297880ull, 12ull); + } + return false; +} + +bool bongo_cat_import_candidate_inspect_cached( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatImportDigestCache *cache, + BongoCatError *error) { + if (!candidate || !output) return false; + char setting[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(setting, sizeof(setting), + candidate->directory, candidate->setting) || + !bongo_cat_path_is_file(setting)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Imported model manifest is missing: %s", candidate->setting); + return false; + } + DigestRoot roots[DIGEST_ROOT_CAP] = {0}; + size_t root_count = 0; + add_root(roots, &root_count, candidate->directory, "model"); + if (candidate->format == BONGO_CAT_IMPORT_TAURI) + add_tauri_preview_roots(candidate, roots, &root_count); + else { + add_root(roots, &root_count, candidate->assets, "assets"); + add_root(roots, &root_count, candidate->overrides, "overrides"); + } + if (candidate->config[0] && + !roots_contain(roots, root_count, candidate->config)) + add_root(roots, &root_count, candidate->config, "configuration"); + DigestStamp stamp = {0}, content = {0}, overrides = {0}; + for (size_t i = 0; i < root_count; ++i) { + if (stamp_root(roots[i].path, roots[i].group, &stamp, candidate, + &content, &overrides, cache)) continue; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot fingerprint imported model assets: %s", + candidate->package_root); + return false; + } + char summary[512]; + int length = snprintf(summary, sizeof(summary), + "%016llx|%s|%d|%d|%016llx|%016llx|%llu|%llu", + (unsigned long long)hash_text(candidate->setting), + bongo_cat_mode_name(candidate->mode), (int)candidate->format, + candidate->gamepad_buttons ? 1 : 0, + (unsigned long long)stamp.sum, + (unsigned long long)stamp.exclusive, + (unsigned long long)stamp.bytes, + (unsigned long long)stamp.files); + if (length < 0 || (size_t)length >= sizeof(summary)) return false; + bongo_cat_sha256_bytes(summary, (size_t)length, output); + if (placeholder) + *placeholder = candidate_placeholder(candidate, &overrides, cache); + return true; +} diff --git a/src/runtime/model/import/model_import_digest_api.c b/src/runtime/model/import/model_import_digest_api.c new file mode 100644 index 00000000..e921080b --- /dev/null +++ b/src/runtime/model/import/model_import_digest_api.c @@ -0,0 +1,12 @@ +#include "model_import.h" + +bool bongo_cat_import_candidate_inspect(const BongoCatImportCandidate *candidate, + char output[65], bool *placeholder, BongoCatError *error) { + return bongo_cat_import_candidate_inspect_cached(candidate, output, + placeholder, NULL, error); +} + +bool bongo_cat_import_candidate_digest(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + return bongo_cat_import_candidate_inspect(candidate, output, NULL, error); +} diff --git a/src/runtime/model/import/model_import_digest_cache.c b/src/runtime/model/import/model_import_digest_cache.c new file mode 100644 index 00000000..d4bc8caa --- /dev/null +++ b/src/runtime/model/import/model_import_digest_cache.c @@ -0,0 +1,119 @@ +#include "model_import.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define DIGEST_CACHE_BUCKET_CAP 4093 +#define DIGEST_CACHE_ENTRY_CAP 16384 + +typedef struct DigestCacheEntry { + struct DigestCacheEntry *next; + char *path; + char digest[65]; + uint64_t size; + uint64_t modified; +} DigestCacheEntry; + +struct BongoCatImportDigestCache { + DigestCacheEntry **buckets; + size_t count; +}; + +static uint64_t hash_path(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; +#ifdef _WIN32 + if (byte >= 'A' && byte <= 'Z') byte = (unsigned char)(byte + 32); +#endif + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +static bool path_equal(const char *left, const char *right) { +#ifdef _WIN32 + return SDL_strcasecmp(left, right) == 0; +#else + return strcmp(left, right) == 0; +#endif +} + +static DigestCacheEntry *cache_entry(BongoCatImportDigestCache *cache, + const char *path, size_t *bucket) { + if (!cache || !cache->buckets || !path) return NULL; + size_t index = (size_t)(hash_path(path) % DIGEST_CACHE_BUCKET_CAP); + if (bucket) *bucket = index; + for (DigestCacheEntry *entry = cache->buckets[index]; entry; + entry = entry->next) + if (path_equal(entry->path, path)) return entry; + return NULL; +} + +static void cache_store(BongoCatImportDigestCache *cache, const char *path, + uint64_t size, uint64_t modified, const char digest[65]) { + if (!cache || !cache->buckets || cache->count >= DIGEST_CACHE_ENTRY_CAP) + return; + size_t bucket = 0; + DigestCacheEntry *entry = cache_entry(cache, path, &bucket); + if (!entry) { + entry = calloc(1, sizeof(*entry)); + size_t length = strlen(path) + 1; + if (entry) entry->path = malloc(length); + if (!entry || !entry->path) { + if (entry) free(entry->path); + free(entry); + return; + } + memcpy(entry->path, path, length); + entry->next = cache->buckets[bucket]; + cache->buckets[bucket] = entry; + cache->count++; + } + entry->size = size; + entry->modified = modified; + memcpy(entry->digest, digest, 65); +} + +BongoCatImportDigestCache *bongo_cat_import_digest_cache_create(void) { + BongoCatImportDigestCache *cache = calloc(1, sizeof(*cache)); + if (cache) cache->buckets = calloc(DIGEST_CACHE_BUCKET_CAP, + sizeof(*cache->buckets)); + if (cache && cache->buckets) return cache; + if (cache) free(cache->buckets); + free(cache); + return NULL; +} + +void bongo_cat_import_digest_cache_destroy(BongoCatImportDigestCache *cache) { + if (!cache) return; + if (cache->buckets) for (size_t i = 0; + i < DIGEST_CACHE_BUCKET_CAP; ++i) { + DigestCacheEntry *entry = cache->buckets[i]; + while (entry) { + DigestCacheEntry *next = entry->next; + free(entry->path); + free(entry); + entry = next; + } + } + free(cache->buckets); + free(cache); +} + +bool bongo_cat_import_digest_file_cached(BongoCatImportDigestCache *cache, + const char *path, uint64_t size, uint64_t modified, char output[65]) { + DigestCacheEntry *entry = cache_entry(cache, path, NULL); + if (entry && entry->size == size && entry->modified == modified) { + memcpy(output, entry->digest, 65); + return true; + } + if (bongo_cat_sha256_file(path, output, NULL) != BONGO_CAT_OK) + return false; + cache_store(cache, path, size, modified, output); + return true; +} diff --git a/src/runtime/model/import/model_import_digest_internal.h b/src/runtime/model/import/model_import_digest_internal.h new file mode 100644 index 00000000..3ed46cda --- /dev/null +++ b/src/runtime/model/import/model_import_digest_internal.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_DIGEST_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_DIGEST_INTERNAL_H + +#include +#include +#include + +size_t bongo_cat_import_digest_path_length(const char *path); +bool bongo_cat_import_digest_path_contains(const char *root, + const char *path); +uint64_t bongo_cat_import_digest_hash_text(const char *value); +uint64_t bongo_cat_import_digest_mix(uint64_t value); + +#endif diff --git a/src/runtime/model/import/model_import_digest_path.c b/src/runtime/model/import/model_import_digest_path.c new file mode 100644 index 00000000..efc023bf --- /dev/null +++ b/src/runtime/model/import/model_import_digest_path.c @@ -0,0 +1,51 @@ +#include "model_import_digest_internal.h" + +#include + +static bool separator(char value) { + return value == '/' || value == '\\'; +} + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +size_t bongo_cat_import_digest_path_length(const char *path) { + size_t length = path ? strlen(path) : 0; + while (length > 1 && separator(path[length - 1])) length--; + return length; +} + +bool bongo_cat_import_digest_path_contains(const char *root, + const char *path) { + size_t root_length = bongo_cat_import_digest_path_length(root); + size_t item_length = bongo_cat_import_digest_path_length(path); + if (!root_length || item_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + return item_length == root_length || separator(path[root_length]); +} + +uint64_t bongo_cat_import_digest_hash_text(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +uint64_t bongo_cat_import_digest_mix(uint64_t value) { + value ^= value >> 30; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; + value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} diff --git a/src/runtime/model/import/model_import_discover.c b/src/runtime/model/import/model_import_discover.c new file mode 100644 index 00000000..21845a6d --- /dev/null +++ b/src/runtime/model/import/model_import_discover.c @@ -0,0 +1,112 @@ +#include "model_import.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static void seed_source_name(const char *source, + BongoCatImportDiscovery *discovery) { + char path[BONGO_CAT_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s", source ? source : ""); + if (written < 0 || (size_t)written >= sizeof(path)) return; + size_t length = strlen(path); + while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) + path[--length] = '\0'; + const char *name = bongo_cat_path_name(path); + if (name && name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", name); +} + +static int rank(const BongoCatImportCandidate *candidate) { + return candidate->mode == BONGO_CAT_MODE_STANDARD ? 0 : + candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; +} + +static int format_rank(const BongoCatImportCandidate *candidate) { + return candidate->format == BONGO_CAT_IMPORT_MVER ? 0 : + candidate->format == BONGO_CAT_IMPORT_TAURI ? 1 : 2; +} + +static int compare_candidates(const void *left, const void *right) { + const BongoCatImportCandidate *a = left, *b = right; + int difference = rank(a) - rank(b); + if (difference) return difference; + difference = format_rank(a) - format_rank(b); + if (difference) return difference; + difference = strcmp(a->directory, b->directory); + return difference ? difference : strcmp(a->patch_root, b->patch_root); +} + +typedef struct ContainerDiscovery { + BongoCatImportDiscovery *output; +} ContainerDiscovery; + +static BongoCatResult collect_container(void *userdata, const char *source, + BongoCatImportDiscovery *found, BongoCatError *error) { + (void)source; + ContainerDiscovery *container = userdata; + for (size_t i = 0; i < found->count; ++i) { + if (container->output->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + container->output->candidates[container->output->count++] = + found->candidates[i]; + } + return BONGO_CAT_OK; +} + +bool bongo_cat_import_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + /* Broad package discovery intentionally stays separate from exact probe + dispatch: Mver may be selected from one of its authored subfolders, + while Tauri conversion starts only from an owning package root. */ + int mver = bongo_cat_import_mver_discover(source, discovery, error); + if (mver > 0) return true; + if (mver < 0) return false; + BongoCatError patch_error = {0}; + int patch = bongo_cat_import_mver_patch_discover(source, discovery, + &patch_error); + if (patch > 0) return true; + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + int tauri = bongo_cat_import_tauri_discover_exact(source, discovery, error); + if (tauri > 0) return true; + if (tauri < 0) return false; + memset(discovery, 0, sizeof(*discovery)); + seed_source_name(source, discovery); + ContainerDiscovery container = {discovery}; + BongoCatResult result = bongo_cat_import_scan(source, collect_container, + &container, error); + if (result != BONGO_CAT_OK) return false; + if (discovery->count) { + /* Recursive discovery returns the owning package root on each + candidate. Re-rooting Mver candidates at the selected container + would copy unrelated sibling folders and break normalization. */ + qsort(discovery->candidates, discovery->count, + sizeof(discovery->candidates[0]), compare_candidates); + return true; + } + if (patch < 0) { + if (error) *error = patch_error; + return false; + } + if (error) *error = (BongoCatError){0}; + int recursive = bongo_cat_import_tauri_discover_recursive(source, + discovery, error); + if (recursive < 0) return false; + if (!recursive) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected directory contains no valid Live2D model3 JSON"); + return false; + } + qsort(discovery->candidates, discovery->count, + sizeof(discovery->candidates[0]), compare_candidates); + return true; +} diff --git a/src/runtime/model/import/model_import_identity.c b/src/runtime/model/import/model_import_identity.c new file mode 100644 index 00000000..34593b28 --- /dev/null +++ b/src/runtime/model/import/model_import_identity.c @@ -0,0 +1,277 @@ +#include "model_import.h" +#include "model_import_path.h" +#include "model_import_mver_policy.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" +#include "bongo_cat/utf8.h" + +#include +#include + +#define IMPORT_COLLISION_SUFFIX_RESERVE 5 + +uint32_t bongo_cat_import_candidate_capabilities( + const BongoCatImportCandidate *candidate) { + uint32_t result = BONGO_CAT_MODEL_CAPABILITY_LIVE2D | + BONGO_CAT_MODEL_CAPABILITY_PREVIEW | + BONGO_CAT_MODEL_CAPABILITY_RUNTIME_ADAPTER | + BONGO_CAT_MODEL_CAPABILITY_BEHAVIORS; + if (candidate->format == BONGO_CAT_IMPORT_TAURI) return result; + result |= BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES | + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT | + BONGO_CAT_MODEL_CAPABILITY_AUDIO | + BONGO_CAT_MODEL_CAPABILITY_EFFECTS | + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) + result |= BONGO_CAT_MODEL_CAPABILITY_POINTER_OVERLAY; + if (candidate->mode == BONGO_CAT_MODE_GAMEPAD && candidate->gamepad_buttons) + result |= BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT; + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) + result |= BONGO_CAT_MODEL_CAPABILITY_IMAGE_PATCH; + return result; +} + +static const char *source_root(const BongoCatImportCandidate *candidate) { + return candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->patch_root[0] ? candidate->patch_root : candidate->package_root; +} + +static const char *display_source(const BongoCatImportCandidate *candidate, + char parent[BONGO_CAT_PATH_CAP]) { + const char *source = source_root(candidate); + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + bongo_cat_import_parent_path(source, parent, BONGO_CAT_PATH_CAP) && + !strcmp(bongo_cat_path_name(source), + bongo_cat_path_name(candidate->package_root))) return parent; + return source; +} + +static void truncate_utf8(char *value, size_t maximum) { + size_t length = value ? strlen(value) : 0; + if (length > maximum) value[maximum] = '\0'; + length = value ? strlen(value) : 0; + while (length && !bongo_cat_utf8_valid(value)) value[--length] = '\0'; +} + +static void trim_id_end(char *value) { + size_t length = value ? strlen(value) : 0; + while (length && (value[length - 1] == ' ' || value[length - 1] == '.')) + value[--length] = '\0'; +} + +static bool ascii_equal(const char *value, size_t length, const char *expected) { + if (strlen(expected) != length) return false; + for (size_t i = 0; i < length; ++i) { + unsigned char left = (unsigned char)value[i]; + unsigned char right = (unsigned char)expected[i]; + if (left >= 'a' && left <= 'z') left = (unsigned char)(left - 'a' + 'A'); + if (right >= 'a' && right <= 'z') right = (unsigned char)(right - 'a' + 'A'); + if (left != right) return false; + } + return true; +} + +static bool reserved_device_name(const char *value) { + size_t length = value ? strcspn(value, ".") : 0; + while (length && value[length - 1] == ' ') length--; + if (ascii_equal(value, length, "CON") || ascii_equal(value, length, "PRN") || + ascii_equal(value, length, "AUX") || ascii_equal(value, length, "NUL")) + return true; + return length == 4 && value[3] >= '1' && value[3] <= '9' && + (ascii_equal(value, 3, "COM") || ascii_equal(value, 3, "LPT")); +} + +static void copy_display_text(char *output, size_t capacity, const char *value) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!value) return; + snprintf(output, capacity, "%s", value); + output[capacity - 1] = '\0'; + truncate_utf8(output, capacity - 1); + for (unsigned char *cursor = (unsigned char *)output; *cursor; ++cursor) + if (*cursor < 0x20 || *cursor == 0x7f) *cursor = ' '; +} + +static bool forbidden_id_byte(unsigned char value) { + return value < 0x20 || value == 0x7f || value == '/' || value == '\\' || + value == ':' || value == '*' || value == '?' || value == '"' || + value == '<' || value == '>' || value == '|' || value == '~'; +} + +static void normalize_id_base(char *output, size_t capacity, + const char *value) { + if (!output || !capacity) return; + output[0] = '\0'; + if (!value) return; + size_t used = 0; + for (const unsigned char *cursor = (const unsigned char *)value; + *cursor && used + 1 < capacity; ++cursor) { + unsigned char byte = *cursor; + if (byte < 0x80 && (forbidden_id_byte(byte) || (!used && byte == '.'))) + byte = '-'; + output[used++] = (char)byte; + } + output[used] = '\0'; + truncate_utf8(output, capacity - 1); + trim_id_end(output); + if (reserved_device_name(output)) { + truncate_utf8(output, capacity > 1 ? capacity - 2 : 0); + trim_id_end(output); + size_t length = strlen(output); + if (length + 1 < capacity) { + memmove(output + 1, output, length + 1); + output[0] = '_'; + } + } +} + +bool bongo_cat_import_package_id(char *output, size_t capacity, + const char *name) { + if (!output || capacity < 2) return false; + char normalized[BONGO_CAT_ID_CAP]; + normalize_id_base(normalized, sizeof(normalized), name); + if (!normalized[0]) snprintf(normalized, sizeof(normalized), "Imported model"); + size_t maximum = capacity > IMPORT_COLLISION_SUFFIX_RESERVE + 1 + ? capacity - IMPORT_COLLISION_SUFFIX_RESERVE - 1 : 0; + truncate_utf8(normalized, maximum); + trim_id_end(normalized); + if (!normalized[0]) snprintf(normalized, sizeof(normalized), "model"); + int written = snprintf(output, capacity, "%s", normalized); + return written >= 0 && (size_t)written < capacity; +} + +bool bongo_cat_import_variant_id(char *output, size_t capacity, + const char *package_id, size_t variant_index) { + if (!output || !capacity || !package_id || !package_id[0]) return false; + if (!variant_index) { + int written = snprintf(output, capacity, "%s", package_id); + return written >= 0 && (size_t)written < capacity; + } + char suffix[32]; + int suffix_length = snprintf(suffix, sizeof(suffix), "~%zu", + variant_index + 1); + if (suffix_length < 0 || (size_t)suffix_length >= sizeof(suffix) || + capacity <= (size_t)suffix_length + 1) return false; + char base[BONGO_CAT_ID_CAP]; + snprintf(base, sizeof(base), "%s", package_id); + truncate_utf8(base, capacity - (size_t)suffix_length - 1); + trim_id_end(base); + int written = snprintf(output, capacity, "%s%s", base, suffix); + return written >= 0 && (size_t)written < capacity; +} + +bool bongo_cat_import_package_base_id(char *output, size_t capacity, + const char *model_id) { + if (!output || capacity < 2 || !model_id || !model_id[0]) return false; + int written = snprintf(output, capacity, "%s", model_id); + if (written < 0 || (size_t)written >= capacity) return false; + char *separator = strrchr(output, '~'); + if (!separator || !separator[1]) return true; + for (char *cursor = separator + 1; *cursor; ++cursor) + if (*cursor < '0' || *cursor > '9') return true; + *separator = '\0'; + return output[0] != '\0'; +} + +bool bongo_cat_import_prepare_package_metadata_cached( + BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatImportDigestCache *cache, + BongoCatError *error) { + char family_material[BONGO_CAT_IMPORT_CANDIDATE_CAP * 66 + 32]; + size_t used = 0, output = 0; + for (size_t i = 0; i < discovery->count; ++i) { + BongoCatImportCandidate candidate = discovery->candidates[i]; + if (bongo_cat_import_patch_has_full_base(discovery, i)) continue; + BongoCatPackageMetadata *item = &metadata[output]; + bool placeholder = false; + if (!bongo_cat_import_candidate_inspect_cached(&candidate, + item->content_digest, &placeholder, cache, error)) return false; + if (placeholder) continue; + bool duplicate = false; + for (size_t j = 0; j < output; ++j) + if (discovery->candidates[j].mode == candidate.mode && + strcmp(metadata[j].content_digest, item->content_digest) == 0) { + duplicate = true; + break; + } + if (duplicate) continue; + discovery->candidates[output] = candidate; + int written = snprintf(family_material + used, + sizeof(family_material) - used, "%s|", item->content_digest); + if (written < 0 || (size_t)written >= sizeof(family_material) - used) + return false; + used += (size_t)written; + char parent[BONGO_CAT_PATH_CAP]; + const char *name = discovery->source_name[0] ? discovery->source_name : + bongo_cat_path_name(display_source(&candidate, parent)); + copy_display_text(item->source_name, sizeof(item->source_name), + name && name[0] ? name : "Imported model"); + if (!item->source_name[0]) copy_display_text(item->source_name, + sizeof(item->source_name), "Imported model"); + copy_display_text(item->display_name, sizeof(item->display_name), + item->source_name); + item->capabilities = bongo_cat_import_candidate_capabilities(&candidate); + /* Tauri candidates are normalized to Mver before they are stored. */ + if (candidate.format == BONGO_CAT_IMPORT_TAURI) + item->capabilities |= BONGO_CAT_MODEL_CAPABILITY_INPUT_IMAGES | + BONGO_CAT_MODEL_CAPABILITY_KEYBOARD_INPUT | + BONGO_CAT_MODEL_CAPABILITY_AUDIO | + BONGO_CAT_MODEL_CAPABILITY_EFFECTS | + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION | + (candidate.mode == BONGO_CAT_MODE_GAMEPAD + ? BONGO_CAT_MODEL_CAPABILITY_GAMEPAD_INPUT : 0); + output++; + } + discovery->count = output; + if (!output) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected model package contains only bundled placeholder modes"); + return false; + } + for (size_t i = 0; i < output; ++i) { + char package_id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(package_id, sizeof(package_id), + metadata[i].source_name) || + !bongo_cat_import_variant_id(metadata[i].package_id, + sizeof(metadata[i].package_id), package_id, i)) return false; + } + if (output > 1) { + char digest[65]; + bongo_cat_sha256_bytes(family_material, used, digest); + for (size_t i = 0; i < output; ++i) + snprintf(metadata[i].family_id, sizeof(metadata[i].family_id), + "family-%s", digest); + } + return true; +} + +bool bongo_cat_import_prepare_package_metadata( + BongoCatImportDiscovery *discovery, + BongoCatPackageMetadata *metadata, BongoCatError *error) { + return bongo_cat_import_prepare_package_metadata_cached(discovery, + metadata, NULL, error); +} + +void bongo_cat_import_describe_nearby_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *source_hash, const char *source, + const char *adapter) { + snprintf(entry->id, sizeof(entry->id), "%s", id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", id); + snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", identity); + snprintf(entry->family_id, sizeof(entry->family_id), "family-nearby-%.16s", + source_hash); + snprintf(entry->directory, sizeof(entry->directory), "%s", candidate->directory); + snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", adapter); + snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", source); + snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", candidate->setting); + entry->mode = candidate->mode; + entry->source_format = candidate->format == BONGO_CAT_IMPORT_TAURI + ? BONGO_CAT_MODEL_SOURCE_TAURI : + candidate->format == BONGO_CAT_IMPORT_MVER_PATCH + ? BONGO_CAT_MODEL_SOURCE_MVER_PATCH : BONGO_CAT_MODEL_SOURCE_MVER; + entry->capabilities = bongo_cat_import_candidate_capabilities(candidate); + entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; + entry->adapter_generator = BONGO_CAT_MODEL_ADAPTER_GENERATOR; + entry->managed = true; +} diff --git a/src/runtime/model/import/model_import_install.c b/src/runtime/model/import/model_import_install.c new file mode 100644 index 00000000..bed99e33 --- /dev/null +++ b/src/runtime/model/import/model_import_install.c @@ -0,0 +1,207 @@ +#include "model_import_session_internal.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct ImportInstall { + char id[BONGO_CAT_ID_CAP]; + char temporary[BONGO_CAT_PATH_CAP]; + char target[BONGO_CAT_PATH_CAP]; + bool committed; +} ImportInstall; + +static void cleanup(ImportInstall *install) { + if (!install) return; + bongo_cat_model_remove_tree(install->committed + ? install->target : install->temporary, NULL); +} + +static bool existing_package(BongoCatImportSession *session, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count) { + return bongo_cat_import_package_index_find(session->packages, + discovery, metadata, ids, count); +} + +static bool canonical_mver_package( + const BongoCatImportDiscovery *discovery) { + if (!discovery || !discovery->count) return false; + for (size_t i = 0; i < discovery->count; ++i) + if (discovery->candidates[i].format != BONGO_CAT_IMPORT_MVER) + return false; + return true; +} + +static bool resolve_install_id(const char *base, const char *root, + char output[BONGO_CAT_ID_CAP], BongoCatError *error) { + for (size_t suffix = 1; suffix < 10000; ++suffix) { + char id[BONGO_CAT_ID_CAP]; + int written = suffix == 1 + ? snprintf(id, sizeof(id), "%s", base) + : snprintf(id, sizeof(id), "%s-%zu", base, suffix); + if (written < 0 || (size_t)written >= sizeof(id)) continue; + char path[BONGO_CAT_PATH_CAP]; + bool exists = bongo_cat_path_join(path, sizeof(path), root, id) && + (bongo_cat_path_is_dir(path) || bongo_cat_path_is_file(path)); + if (!exists) { + snprintf(output, BONGO_CAT_ID_CAP, "%s", id); + return true; + } + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot allocate a unique model directory for %s", base); + return false; +} + +static void fill_receipt(BongoCatImportReceipt *receipt, const char *id, + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + bongo_cat_import_variant_id(receipt->ids[i], + sizeof(receipt->ids[i]), id, i); + receipt->installed[i] = installed; + } +} + +static void fill_receipt_ids(BongoCatImportReceipt *receipt, + const char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t count, bool installed) { + if (!receipt) return; + receipt->count = count; + receipt->installed_count = installed ? count : 0; + for (size_t i = 0; i < count; ++i) { + snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", ids[i]); + receipt->installed[i] = installed; + } +} + +BongoCatResult bongo_cat_import_install_discovery( + BongoCatImportSession *session, + BongoCatImportDiscovery *source_discovery, + BongoCatPackageMetadata *source_metadata, + BongoCatImportReceipt *receipt, BongoCatError *error) { + uint64_t started = SDL_GetTicksNS(); + if (canonical_mver_package(source_discovery)) { + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, source_discovery, source_metadata, + existing, &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: " + "stage=source-duplicate variants=%llu elapsed_ms=%.1f", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0); + fill_receipt_ids(receipt, existing, existing_count, false); + return BONGO_CAT_OK; + } + } + + ImportInstall install = {0}; + char temporary_name[BONGO_CAT_ID_CAP + 24]; + snprintf(temporary_name, sizeof(temporary_name), ".import-%.16s-1.tmp", + source_metadata[0].content_digest); + if (!bongo_cat_path_join(install.temporary, sizeof(install.temporary), + session->root, temporary_name)) return BONGO_CAT_ERROR_IO; + SDL_Log("[runtime] Model import storage transaction: stage=prepare " + "candidates=%llu temporary=%s", + (unsigned long long)source_discovery->count, install.temporary); + bongo_cat_model_remove_tree(install.temporary, NULL); + if (!bongo_cat_import_prepare_storage(source_discovery, + install.temporary, error)) { + cleanup(&install); + return error && error->code == BONGO_CAT_ERROR_FORMAT + ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; + } + SDL_Log("[runtime] Model import storage transaction: stage=prepared " + "elapsed_ms=%.1f temporary=%s", + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); + + BongoCatImportDiscovery *normalized = calloc(1, sizeof(*normalized)); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + if (!normalized || !metadata) { + free(normalized); + free(metadata); + cleanup(&install); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot validate normalized model package"); + return BONGO_CAT_ERROR_MEMORY; + } + SDL_Log("[runtime] Model import storage transaction: " + "stage=validate-normalized temporary=%s", install.temporary); + bool discovered = bongo_cat_import_discover(install.temporary, normalized, + error); + SDL_Log("[runtime] Model import storage transaction: " + "stage=normalized-discovered result=%d candidates=%llu temporary=%s", + discovered, (unsigned long long)normalized->count, install.temporary); + bool valid = discovered && bongo_cat_import_prepare_package_metadata_cached( + normalized, metadata, session->digests, error); + if (!valid) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + + char existing[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; + size_t existing_count = 0; + if (existing_package(session, normalized, metadata, existing, + &existing_count)) { + SDL_Log("[runtime] Model import storage transaction: stage=duplicate " + "variants=%llu elapsed_ms=%.1f temporary=%s", + (unsigned long long)existing_count, + (SDL_GetTicksNS() - started) / 1000000.0, install.temporary); + fill_receipt_ids(receipt, existing, existing_count, false); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_OK; + } + if (!bongo_cat_import_package_index_has_capacity(session->packages, + normalized->count)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_FORMAT; + } + if (!resolve_install_id(source_metadata[0].package_id, session->root, + install.id, error) || + !bongo_cat_path_join(install.target, sizeof(install.target), + session->root, install.id)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + if (!bongo_cat_path_rename(install.temporary, install.target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot finish model import: %s", SDL_GetError()); + free(normalized); + free(metadata); + cleanup(&install); + return BONGO_CAT_ERROR_IO; + } + install.committed = true; + SDL_Log("[runtime] Model import storage transaction: stage=committed " + "id=%s elapsed_ms=%.1f target=%s", install.id, + (SDL_GetTicksNS() - started) / 1000000.0, install.target); + if (!bongo_cat_import_package_index_add(session->packages, install.id, + normalized, metadata, error)) { + free(normalized); + free(metadata); + cleanup(&install); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + fill_receipt(receipt, install.id, normalized->count, true); + free(normalized); + free(metadata); + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/import/model_import_installed.c b/src/runtime/model/import/model_import_installed.c new file mode 100644 index 00000000..d6381e57 --- /dev/null +++ b/src/runtime/model/import/model_import_installed.c @@ -0,0 +1,282 @@ +#include "model_import.h" +#include "model_import_lock.h" +#include "model_import_mver_policy.h" +#include "model_import_nearby_internal.h" +#include "runtime.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include +#include + +typedef struct InstalledModelScan { + BongoCatApp *app; + const char *cache_root; + BongoCatError *error; + BongoCatResult result; +} InstalledModelScan; + +static bool existing_identity(const BongoCatModelCatalog *models, + const char *identity, BongoCatModelMode mode) { + for (size_t i = 0; models && i < models->count; ++i) { + const BongoCatModelEntry *entry = &models->entries[i]; + if (entry->mode == mode && entry->content_digest[0] && + strcmp(entry->content_digest, identity) == 0) return true; + } + return false; +} + +static bool package_identity_seen( + const char identities[BONGO_CAT_IMPORT_CANDIDATE_CAP][65], + const BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP], + size_t count, const char *identity, BongoCatModelMode mode) { + for (size_t i = 0; i < count; ++i) + if (modes[i] == mode && strcmp(identities[i], identity) == 0) + return true; + return false; +} + +static BongoCatModelSourceFormat source_format( + const BongoCatImportCandidate *candidate) { + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) + return BONGO_CAT_MODEL_SOURCE_MVER_PATCH; + return candidate->format == BONGO_CAT_IMPORT_TAURI + ? BONGO_CAT_MODEL_SOURCE_TAURI : BONGO_CAT_MODEL_SOURCE_MVER; +} + +static const char *variant_name(const BongoCatImportCandidate *candidate) { + if (candidate->patch_root[0]) { + const char *name = bongo_cat_path_name(candidate->patch_root); + if (name && name[0]) return name; + } + return bongo_cat_mode_name(candidate->mode); +} + +static void describe_entry(BongoCatModelEntry *entry, + const BongoCatImportCandidate *candidate, const char *id, + const char *identity, const char *family, const char *storage, + const char *adapter, const char *display) { + memset(entry, 0, sizeof(*entry)); + snprintf(entry->id, sizeof(entry->id), "%s", id); + snprintf(entry->package_id, sizeof(entry->package_id), "%s", id); + snprintf(entry->content_digest, sizeof(entry->content_digest), "%s", + identity); + snprintf(entry->family_id, sizeof(entry->family_id), "%s", family); + snprintf(entry->display_name, sizeof(entry->display_name), "%s", display); + snprintf(entry->directory, sizeof(entry->directory), "%s", + candidate->directory); + snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", + adapter); + snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", + storage); + snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", + candidate->setting); + entry->mode = candidate->mode; + entry->source_format = source_format(candidate); + entry->capabilities = bongo_cat_import_candidate_capabilities(candidate); + entry->adapter_schema = BONGO_CAT_MODEL_ADAPTER_SCHEMA; + entry->adapter_generator = BONGO_CAT_MODEL_ADAPTER_GENERATOR; +} + +static bool add_package(InstalledModelScan *scan, const char *directory, + BongoCatImportDiscovery *discovery) { + const char *folder = bongo_cat_path_name(directory); + char package_id[BONGO_CAT_ID_CAP], path_hash[65], family[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(package_id, sizeof(package_id), folder) || + !package_id[0]) return false; + bongo_cat_sha256_bytes(directory, strlen(directory), path_hash); + snprintf(family, sizeof(family), "family-installed-%.16s", path_hash); + size_t emitted = 0; + char identities[BONGO_CAT_IMPORT_CANDIDATE_CAP][65] = {{0}}; + BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + size_t identity_count = 0; + for (size_t i = 0; i < discovery->count; ++i) { + if (bongo_cat_import_patch_has_full_base(discovery, i)) continue; + BongoCatImportCandidate *candidate = &discovery->candidates[i]; + char cache_id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; + char signature[65], identity[65]; + snprintf(cache_id, sizeof(cache_id), "installed-%.16s-%zu", + path_hash, i + 1); + if (!bongo_cat_path_join(adapter, sizeof(adapter), scan->cache_root, + cache_id) || + !bongo_cat_nearby_signature(candidate, signature, scan->error)) + return false; + bool placeholder = false; + bool cached = bongo_cat_nearby_cached_inspection(adapter, directory, + signature, identity, &placeholder); + if (!cached && !bongo_cat_import_candidate_inspect(candidate, identity, + &placeholder, scan->error)) return false; + if (placeholder) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, true, candidate->mode); + continue; + } + bool duplicate = existing_identity(&scan->app->models, identity, + candidate->mode) || package_identity_seen(identities, modes, + identity_count, identity, candidate->mode); + if (identity_count < BONGO_CAT_IMPORT_CANDIDATE_CAP) { + snprintf(identities[identity_count], 65, "%s", identity); + modes[identity_count++] = candidate->mode; + } + char id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_variant_id(id, sizeof(id), package_id, emitted)) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Installed model id is invalid: %s", package_id); + return false; + } + emitted++; + if (bongo_cat_settings_model_removed(&scan->app->settings, id)) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, false, candidate->mode); + continue; + } + if (duplicate) { + if (!cached) bongo_cat_nearby_remember_inspection(adapter, + directory, signature, identity, false, candidate->mode); + continue; + } + if (scan->app->models.count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + return false; + } + bool created = false; + if (!bongo_cat_nearby_refresh_cache(candidate, scan->cache_root, + cache_id, directory, signature, identity, false, adapter, + &created, scan->error)) return false; + (void)created; + if (bongo_cat_models_find(&scan->app->models, id)) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_FORMAT, + "Installed model id is not unique: %s", package_id); + return false; + } + char display[BONGO_CAT_ID_CAP]; + if (discovery->count > 1) + snprintf(display, sizeof(display), "%s - %s", package_id, + variant_name(candidate)); + else snprintf(display, sizeof(display), "%s", package_id); + BongoCatModelEntry *entry = + &scan->app->models.entries[scan->app->models.count++]; + describe_entry(entry, candidate, id, identity, family, directory, + adapter, display); + } + return true; +} + +static BongoCatPathVisit scan_package(void *userdata, + const char *dirname, const char *name) { + InstalledModelScan *scan = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name)) { + scan->result = BONGO_CAT_ERROR_FORMAT; + return BONGO_CAT_PATH_FAILURE; + } + if (!bongo_cat_import_authored_package(directory)) + return BONGO_CAT_PATH_CONTINUE; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) { + scan->result = BONGO_CAT_ERROR_MEMORY; + return BONGO_CAT_PATH_FAILURE; + } + BongoCatError local = {0}; + bool found = bongo_cat_import_discover(directory, discovery, &local); + if (!found) { + if (local.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping installed model package: path=%s error=%s", + directory, local.message); + free(discovery); + return BONGO_CAT_PATH_CONTINUE; + } + size_t before = scan->app->models.count; + if (scan->error) *scan->error = (BongoCatError){0}; + bool added = add_package(scan, directory, discovery); + free(discovery); + if (added) return BONGO_CAT_PATH_CONTINUE; + /* A package is atomic, but independent packages remain discoverable. */ + memset(&scan->app->models.entries[before], 0, + (scan->app->models.count - before) * sizeof(scan->app->models.entries[0])); + scan->app->models.count = before; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping installed model package: path=%s error=%s", directory, + scan->error && scan->error->message[0] ? scan->error->message : + "model adaptation failed"); + if (scan->error) *scan->error = (BongoCatError){0}; + return BONGO_CAT_PATH_CONTINUE; +} + +static bool installed_cache_root(const BongoCatApp *app, + char cache_root[BONGO_CAT_PATH_CAP], BongoCatError *error) { + if (bongo_cat_path_join(cache_root, BONGO_CAT_PATH_CAP, app->cache_root, + BONGO_CAT_ADAPTER_CACHE_DIRECTORY) && + bongo_cat_path_create_directory(cache_root)) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create installed model adapter cache"); + return false; +} + +static void remove_storage_models(BongoCatModelCatalog *models, + const char *directory) { + size_t output = 0; + for (size_t i = 0; i < models->count; ++i) { + BongoCatModelEntry entry = models->entries[i]; + if (!strcmp(entry.storage_directory, directory)) continue; + models->entries[output++] = entry; + } + if (output < BONGO_CAT_MODEL_CAP) + memset(&models->entries[output], 0, + (BONGO_CAT_MODEL_CAP - output) * sizeof(models->entries[0])); + models->count = output; +} + +BongoCatResult bongo_cat_import_installed_package(BongoCatApp *app, + const char *root, const char *package_id, BongoCatError *error) { + if (!app || !root || !package_id || !package_id[0] || + !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; + char normalized_id[BONGO_CAT_ID_CAP]; + if (!bongo_cat_import_package_id(normalized_id, sizeof(normalized_id), + package_id) || strcmp(normalized_id, package_id)) + return BONGO_CAT_ERROR_ARGUMENT; + char directory[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), root, package_id) || + !bongo_cat_import_authored_package(directory) || + !installed_cache_root(app, cache_root, error)) { + if (error && !error->code) + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Installed model package is unavailable: %s", package_id); + return error && error->code ? error->code : BONGO_CAT_ERROR_FORMAT; + } + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return BONGO_CAT_ERROR_MEMORY; + bongo_cat_import_storage_lock(); + bool found = bongo_cat_import_discover(directory, discovery, error); + InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; + if (found) { + remove_storage_models(&app->models, directory); + if (!add_package(&scan, directory, discovery)) + scan.result = error && error->code ? error->code : + BONGO_CAT_ERROR_IO; + } + bongo_cat_import_storage_unlock(); + free(discovery); + if (!found && scan.result == BONGO_CAT_OK) + scan.result = error && error->code ? error->code : + BONGO_CAT_ERROR_FORMAT; + return scan.result; +} + +BongoCatResult bongo_cat_import_installed_models(BongoCatApp *app, + const char *root, BongoCatError *error) { + if (!app || !root || !app->cache_root[0]) return BONGO_CAT_ERROR_ARGUMENT; + if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; + char cache_root[BONGO_CAT_PATH_CAP]; + if (!installed_cache_root(app, cache_root, error)) return BONGO_CAT_ERROR_IO; + InstalledModelScan scan = {app, cache_root, error, BONGO_CAT_OK}; + bongo_cat_import_storage_lock(); + bool ok = bongo_cat_path_enumerate(root, scan_package, &scan); + bongo_cat_import_storage_unlock(); + if (!ok && scan.result == BONGO_CAT_OK) scan.result = BONGO_CAT_ERROR_IO; + return scan.result; +} diff --git a/src/runtime/model/import/model_import_lock.c b/src/runtime/model/import/model_import_lock.c new file mode 100644 index 00000000..d56efb30 --- /dev/null +++ b/src/runtime/model/import/model_import_lock.c @@ -0,0 +1,32 @@ +#include "model_import_lock.h" + +#include + +static SDL_InitState storage_lock_init; +static SDL_Mutex *storage_lock; + +static SDL_Mutex *storage_mutex(void) { + if (SDL_ShouldInit(&storage_lock_init)) { + storage_lock = SDL_CreateMutex(); + SDL_SetInitialized(&storage_lock_init, storage_lock != NULL); + } + return storage_lock; +} + +void bongo_cat_import_storage_lock(void) { + SDL_Mutex *mutex = storage_mutex(); + if (mutex) SDL_LockMutex(mutex); +} + +void bongo_cat_import_storage_unlock(void) { + SDL_Mutex *mutex = storage_lock; + if (mutex) SDL_UnlockMutex(mutex); +} + +void bongo_cat_import_storage_lock_shutdown(void) { + if (!SDL_ShouldQuit(&storage_lock_init)) return; + SDL_Mutex *mutex = storage_lock; + storage_lock = NULL; + SDL_SetInitialized(&storage_lock_init, false); + SDL_DestroyMutex(mutex); +} diff --git a/src/runtime/model/import/model_import_lock.h b/src/runtime/model/import/model_import_lock.h new file mode 100644 index 00000000..0981e7f4 --- /dev/null +++ b/src/runtime/model/import/model_import_lock.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_LOCK_H +#define BONGO_CAT_MODEL_IMPORT_LOCK_H + +/* Serializes model-directory scans and mutations across import, refresh, + deletion, rollback, and nearby adapter generation. */ +void bongo_cat_import_storage_lock(void); +void bongo_cat_import_storage_unlock(void); +void bongo_cat_import_storage_lock_shutdown(void); + +#endif diff --git a/src/runtime/model/import/model_import_manifest.c b/src/runtime/model/import/model_import_manifest.c new file mode 100644 index 00000000..4eec4df8 --- /dev/null +++ b/src/runtime/model/import/model_import_manifest.c @@ -0,0 +1,107 @@ +#include "model_import_manifest.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool safe_reference(const char *value) { + if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || + strchr(value, ':')) return false; + const char *part = value; + while (*part) { + while (*part == '/' || *part == '\\') part++; + if (part[0] == '.' && part[1] == '.' && + (!part[2] || part[2] == '/' || part[2] == '\\')) return false; + part = strpbrk(part, "/\\"); + if (!part) break; + } + return true; +} +static bool referenced_file(const char *root, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + return safe_reference(relative) && + bongo_cat_path_join(path, sizeof(path), root, relative) && + bongo_cat_path_is_file(path); +} + +static bool referenced_texture(const char *root, const char *relative) { + char path[BONGO_CAT_PATH_CAP]; + if (!safe_reference(relative) || + !bongo_cat_path_join(path, sizeof(path), root, relative)) return false; + return bongo_cat_image_info(path, NULL, NULL); +} + +static bool optional_reference(const char *root, yyjson_val *refs, + const char *name, bool allow_missing) { + yyjson_val *value = yyjson_obj_get(refs, name); + if (!value) return true; + const char *relative = yyjson_get_str(value); + return relative && (allow_missing ? (!relative[0] || safe_reference(relative)) + : referenced_file(root, relative)); +} + +static bool behavior_references(const char *root, yyjson_val *refs, + bool allow_missing) { + yyjson_val *expressions = yyjson_obj_get(refs, "Expressions"); + if (expressions && !yyjson_is_arr(expressions)) return false; + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(expressions, index, count, item) { + const char *file = yyjson_get_str(yyjson_obj_get(item, "File")); + if (!(allow_missing ? safe_reference(file) : referenced_file(root, file))) + return false; + } + yyjson_val *motions = yyjson_obj_get(refs, "Motions"); + if (motions && !yyjson_is_obj(motions)) return false; + size_t group_index, group_count; yyjson_val *key, *group; + yyjson_obj_foreach(motions, group_index, group_count, key, group) { + if (!yyjson_is_arr(group)) return false; + yyjson_arr_foreach(group, index, count, item) { + const char *file = yyjson_get_str(yyjson_obj_get(item, "File")); + if (!(allow_missing ? safe_reference(file) : referenced_file(root, file))) + return false; + const char *sound = yyjson_get_str(yyjson_obj_get(item, "Sound")); + if (sound && !(allow_missing && !sound[0]) && !safe_reference(sound)) + return false; + } + } + return true; +} + +bool bongo_cat_import_manifest_document_valid(const char *root, + yyjson_doc *document, bool allow_missing_optional) { + yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *refs = yyjson_is_obj(manifest) + ? yyjson_obj_get(manifest, "FileReferences") : NULL; + const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); + yyjson_val *textures = yyjson_obj_get(refs, "Textures"); + bool valid = yyjson_get_int(yyjson_obj_get(manifest, "Version")) == 3 && + yyjson_is_obj(refs) && referenced_file(root, moc) && yyjson_is_arr(textures) && + yyjson_arr_size(textures) > 0; + size_t index, maximum; yyjson_val *texture; + yyjson_arr_foreach(textures, index, maximum, texture) + valid = valid && referenced_texture(root, yyjson_get_str(texture)); + valid = valid && optional_reference(root, refs, "Physics", allow_missing_optional) && + optional_reference(root, refs, "Pose", allow_missing_optional) && + optional_reference(root, refs, "DisplayInfo", allow_missing_optional) && + (!allow_missing_optional || optional_reference(root, refs, "UserData", true)) && + behavior_references(root, refs, allow_missing_optional); + return valid; +} + +bool bongo_cat_import_manifest_valid(const char *root, const char *setting, + BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; + if (file) fclose(file); + bool valid = bongo_cat_import_manifest_document_valid(root, document, false); + yyjson_doc_free(document); + if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model manifest or referenced assets are invalid: %s", path); + return valid; +} diff --git a/src/runtime/model/import/model_import_manifest.h b/src/runtime/model/import/model_import_manifest.h new file mode 100644 index 00000000..dd055f46 --- /dev/null +++ b/src/runtime/model/import/model_import_manifest.h @@ -0,0 +1,14 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MANIFEST_H +#define BONGO_CAT_MODEL_IMPORT_MANIFEST_H + +#include "bongo_cat/common.h" +#include + +bool bongo_cat_import_manifest_document_valid(const char *root, + yyjson_doc *document, bool allow_missing_optional); + +/* Validates a Live2D manifest and every referenced local asset. */ +bool bongo_cat_import_manifest_valid(const char *root, const char *setting, + BongoCatError *error); + +#endif diff --git a/src/runtime/model_import_metadata.c b/src/runtime/model/import/model_import_metadata.c similarity index 84% rename from src/runtime/model_import_metadata.c rename to src/runtime/model/import/model_import_metadata.c index 5b4b0c9b..5deb4f8c 100644 --- a/src/runtime/model_import_metadata.c +++ b/src/runtime/model/import/model_import_metadata.c @@ -7,7 +7,14 @@ #include #include -#define MVER_METADATA ".bongo-cat-mver.json" +static bool metadata_version(yyjson_val *root) { + if (!yyjson_is_obj(root)) return false; + yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + return yyjson_is_int(schema) && + yyjson_get_int(schema) == BONGO_CAT_MODEL_ADAPTER_SCHEMA && kind && + strcmp(kind, "bongo-cat-runtime-adapter") == 0; +} static bool same_family(const char *left, const char *right) { bool left_effect = left && (strcmp(left, "effect") == 0 || @@ -32,9 +39,9 @@ static int sequential_index(yyjson_val *bindings, size_t index, const char *kind } static BongoCatBehaviorShortcut *shortcut_for(BongoCatApp *app, const char *id) { - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) - if (strcmp(app->config.behavior_shortcuts[i].id, id) == 0) - return &app->config.behavior_shortcuts[i]; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) + if (strcmp(app->settings.behavior_shortcuts[i].id, id) == 0) + return &app->settings.behavior_shortcuts[i]; return NULL; } @@ -62,12 +69,12 @@ static bool behavior_id(char *id, size_t capacity, const char *model_id, void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, const char *directory) { char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), directory, MVER_METADATA)) return; + if (!bongo_cat_model_adapter_metadata_path(directory, path, + sizeof(path))) return; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *bindings = yyjson_obj_get(root, "bindings"); - if (!yyjson_is_obj(root) || yyjson_get_int(yyjson_obj_get(root, "version")) != 1 || - !yyjson_is_arr(bindings)) { + if (!metadata_version(root) || !yyjson_is_arr(bindings)) { yyjson_doc_free(document); return; } @@ -84,9 +91,9 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, snprintf(existing->label, sizeof(existing->label), "%s", label); continue; } - if (app->config.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) break; + if (app->settings.behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) break; BongoCatBehaviorShortcut *value = - &app->config.behavior_shortcuts[app->config.behavior_shortcut_count++]; + &app->settings.behavior_shortcuts[app->settings.behavior_shortcut_count++]; snprintf(value->id, sizeof(value->id), "%s", id); snprintf(value->shortcut, sizeof(value->shortcut), "%s", shortcut); if (label) snprintf(value->label, sizeof(value->label), "%s", label); @@ -94,13 +101,13 @@ void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, yyjson_doc_free(document); } -bool bongo_cat_import_mver_render_options(const char *directory, +bool bongo_cat_import_render_options(const char *directory, BongoCatLive2DRenderOptions *options) { if (!options) return false; *options = (BongoCatLive2DRenderOptions){0}; char path[BONGO_CAT_PATH_CAP]; - if (!directory || !bongo_cat_path_join(path, sizeof(path), directory, - MVER_METADATA)) return false; + if (!bongo_cat_model_adapter_metadata_path(directory, path, + sizeof(path))) return false; yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; yyjson_val *render = yyjson_obj_get(root, "render"); @@ -108,13 +115,13 @@ bool bongo_cat_import_mver_render_options(const char *directory, yyjson_val *scale = yyjson_obj_get(render, "projectionScale"); int width = (int)yyjson_get_sint(yyjson_obj_get(render, "referenceWidth")); int height = (int)yyjson_get_sint(yyjson_obj_get(render, "referenceHeight")); - bool valid = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "version")) == 1 && + bool valid = metadata_version(root) && yyjson_is_obj(render) && profile && strcmp(profile, "mver-0.1.6") == 0 && yyjson_is_num(scale) && yyjson_get_num(scale) > 0.0 && yyjson_get_num(scale) <= 100.0 && width > 0 && height > 0; if (valid) { options->mver_projection = true; + options->auto_frame = yyjson_get_bool(yyjson_obj_get(render, "autoFrame")); options->projection_scale = (float)yyjson_get_num(scale); options->offset_x = (float)yyjson_get_num(yyjson_obj_get(render, "offsetX")); options->offset_y = (float)yyjson_get_num(yyjson_obj_get(render, "offsetY")); diff --git a/src/runtime/model/import/model_import_package.c b/src/runtime/model/import/model_import_package.c new file mode 100644 index 00000000..764fdbda --- /dev/null +++ b/src/runtime/model/import/model_import_package.c @@ -0,0 +1,139 @@ +#include "model_import.h" +#include "model_import_path.h" +#include "model_import_mver_copy.h" +#include "tauri/model_import_tauri.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool copy_package_files(const BongoCatImportCandidate *candidate, + const char *target, BongoCatImportCandidate *installed, + BongoCatError *error) { + if (candidate->format == BONGO_CAT_IMPORT_TAURI) + return bongo_cat_import_tauri_convert_to_mver(candidate, target, + installed, error); + return bongo_cat_import_mver_copy_package(candidate, target, installed, + error); +} + +bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, + const char *target, BongoCatImportCandidate *installed, + BongoCatError *error) { + if (!candidate || !target || !installed || + !bongo_cat_path_create_directory(target)) return false; + *installed = *candidate; + if (copy_package_files(candidate, target, installed, error)) return true; + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot preserve imported model files"); + return false; +} + +static bool tauri_variant_directory(char *output, size_t capacity, + const char *target, const BongoCatImportDiscovery *discovery, + size_t index) { + const char *mode = bongo_cat_mode_name(discovery->candidates[index].mode); + size_t occurrence = 1; + for (size_t i = 0; i < index; ++i) + if (discovery->candidates[i].mode == discovery->candidates[index].mode) + occurrence++; + char name[48]; + int written = occurrence == 1 + ? snprintf(name, sizeof(name), "%s", mode) + : snprintf(name, sizeof(name), "%s-%zu", mode, occurrence); + return written >= 0 && (size_t)written < sizeof(name) && + bongo_cat_path_join(output, capacity, target, name); +} + +static bool common_package_root(const BongoCatImportDiscovery *discovery, + char root[BONGO_CAT_PATH_CAP]) { + if (!bongo_cat_import_parent_path(discovery->candidates[0].package_root, + root, BONGO_CAT_PATH_CAP)) return false; + for (size_t i = 1; i < discovery->count; ++i) { + char relative[BONGO_CAT_PATH_CAP]; + while (!bongo_cat_import_relative_path(root, + discovery->candidates[i].package_root, relative, + sizeof(relative))) { + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(root, parent, sizeof(parent))) + return false; + snprintf(root, BONGO_CAT_PATH_CAP, "%s", parent); + } + } + return true; +} + +static bool prepare_container_storage( + const BongoCatImportDiscovery *discovery, const char *target, + BongoCatError *error) { + char root[BONGO_CAT_PATH_CAP]; + if (!common_package_root(discovery, root)) return false; + for (size_t i = 0; i < discovery->count; ++i) { + bool already_copied = false; + for (size_t j = 0; j < i; ++j) + if (strcmp(discovery->candidates[j].package_root, + discovery->candidates[i].package_root) == 0) { + already_copied = true; + break; + } + if (already_copied) continue; + char relative[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_relative_path(root, + discovery->candidates[i].package_root, relative, + sizeof(relative)) || !relative[0] || + !bongo_cat_path_join(package, sizeof(package), target, relative)) + return false; + BongoCatImportCandidate installed; + if (!bongo_cat_import_prepare_package(&discovery->candidates[i], + package, &installed, error)) return false; + } + return true; +} + +static bool single_package_root(const BongoCatImportDiscovery *discovery) { + for (size_t i = 1; i < discovery->count; ++i) + if (strcmp(discovery->candidates[0].package_root, + discovery->candidates[i].package_root) != 0) return false; + return true; +} + +bool bongo_cat_import_prepare_storage( + const BongoCatImportDiscovery *discovery, const char *target, + BongoCatError *error) { + if (!discovery || !discovery->count || !target) return false; + bool tauri = true; + for (size_t i = 0; i < discovery->count; ++i) + tauri = tauri && discovery->candidates[i].format == + BONGO_CAT_IMPORT_TAURI; + if (!tauri && single_package_root(discovery)) { + BongoCatImportCandidate installed; + return bongo_cat_import_prepare_package(&discovery->candidates[0], + target, &installed, error); + } + if (!tauri) return prepare_container_storage(discovery, target, error); + for (size_t i = 0; i < discovery->count; ++i) { + char package[BONGO_CAT_PATH_CAP]; + if (discovery->count == 1) + snprintf(package, sizeof(package), "%s", target); + else if (!tauri_variant_directory(package, sizeof(package), target, + discovery, i)) return false; + BongoCatImportCandidate installed; + if (!bongo_cat_import_prepare_package(&discovery->candidates[i], + package, &installed, error)) return false; + } + return true; +} + +bool bongo_cat_import_authored_package(const char *directory) { + char path[BONGO_CAT_PATH_CAP]; + if (!directory || !bongo_cat_path_is_dir(directory)) return false; + static const char *const internal[] = { + ".bongo-cat-package.json", ".bongo-cat-adapter", + BONGO_CAT_MODEL_BUILTIN_MARKER + }; + for (size_t i = 0; i < sizeof(internal) / sizeof(internal[0]); ++i) + if (bongo_cat_path_join(path, sizeof(path), directory, internal[i]) && + (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path))) + return false; + return true; +} diff --git a/src/runtime/model/import/model_import_package_index.c b/src/runtime/model/import/model_import_package_index.c new file mode 100644 index 00000000..f831d1d9 --- /dev/null +++ b/src/runtime/model/import/model_import_package_index.c @@ -0,0 +1,165 @@ +#include "model_import_session_internal.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct IndexedPackage { + char id[BONGO_CAT_ID_CAP]; + char digests[BONGO_CAT_IMPORT_CANDIDATE_CAP][65]; + BongoCatModelMode modes[BONGO_CAT_IMPORT_CANDIDATE_CAP]; + size_t count; +} IndexedPackage; + +struct BongoCatImportPackageIndex { + IndexedPackage entries[BONGO_CAT_MODEL_CAP]; + size_t count; + size_t model_count; +}; + +/* Build once for a batch; successful atomic installs extend this snapshot. */ + +static bool match_package(const IndexedPackage *known, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + size_t matches[BONGO_CAT_IMPORT_CANDIDATE_CAP]) { + if (!known || !discovery || !metadata || discovery->count > known->count) + return false; + bool used[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + for (size_t i = 0; i < discovery->count; ++i) { + bool found = false; + for (size_t j = 0; j < known->count; ++j) { + if (used[j] || known->modes[j] != discovery->candidates[i].mode || + strcmp(known->digests[j], metadata[i].content_digest) != 0) + continue; + used[j] = true; + matches[i] = j; + found = true; + break; + } + if (!found) return false; + } + return true; +} + +bool bongo_cat_import_package_index_add(BongoCatImportPackageIndex *index, + const char *id, const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, BongoCatError *error) { + if (!index || !id || !id[0] || !discovery || !metadata || + discovery->count > BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; + if (index->count >= BONGO_CAT_MODEL_CAP || + discovery->count > BONGO_CAT_MODEL_CAP - index->model_count) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many installed model variants"); + return false; + } + IndexedPackage *entry = &index->entries[index->count++]; + entry->count = discovery->count; + index->model_count += discovery->count; + snprintf(entry->id, sizeof(entry->id), "%s", id); + for (size_t i = 0; i < entry->count; ++i) { + entry->modes[i] = discovery->candidates[i].mode; + snprintf(entry->digests[i], sizeof(entry->digests[i]), "%s", + metadata[i].content_digest); + } + return true; +} + +bool bongo_cat_import_package_index_find( + const BongoCatImportPackageIndex *index, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count) { + if (!index || !discovery || !metadata || !ids || !count || + discovery->count > BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; + *count = 0; + for (size_t i = 0; i < index->count; ++i) { + size_t matches[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + if (!match_package(&index->entries[i], discovery, metadata, matches)) + continue; + for (size_t j = 0; j < discovery->count; ++j) + if (!bongo_cat_import_variant_id(ids[j], BONGO_CAT_ID_CAP, + index->entries[i].id, matches[j])) { + *count = 0; + return false; + } + *count = discovery->count; + return true; + } + return false; +} + +bool bongo_cat_import_package_index_has_capacity( + const BongoCatImportPackageIndex *index, size_t model_count) { + return index && index->count < BONGO_CAT_MODEL_CAP && + model_count <= BONGO_CAT_MODEL_CAP - index->model_count; +} + +typedef struct IndexBuild { + BongoCatImportPackageIndex *index; + BongoCatImportDigestCache *digests; + BongoCatError *error; + bool failed; +} IndexBuild; + +static BongoCatPathVisit index_package(void *userdata, + const char *dirname, const char *name) { + IndexBuild *build = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(directory, sizeof(directory), dirname, name) || + !bongo_cat_import_authored_package(directory)) + return BONGO_CAT_PATH_CONTINUE; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + BongoCatPackageMetadata *metadata = calloc( + BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*metadata)); + if (!discovery || !metadata) { + free(discovery); + free(metadata); + bongo_cat_error_set(build->error, BONGO_CAT_ERROR_MEMORY, + "Cannot index installed model packages"); + build->failed = true; + return BONGO_CAT_PATH_FAILURE; + } + BongoCatError ignored = {0}; + bool valid = bongo_cat_import_discover(directory, discovery, &ignored) && + bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + build->digests, &ignored); + char id[BONGO_CAT_ID_CAP]; + bool added = !valid || + (bongo_cat_import_package_id(id, sizeof(id), name) && + bongo_cat_import_package_index_add(build->index, id, discovery, + metadata, build->error)); + free(discovery); + free(metadata); + if (added) return BONGO_CAT_PATH_CONTINUE; + build->failed = true; + return BONGO_CAT_PATH_FAILURE; +} + +BongoCatImportPackageIndex *bongo_cat_import_package_index_create( + const char *root, BongoCatImportDigestCache *digests, + BongoCatError *error) { + if (!root || !digests) return NULL; + BongoCatImportPackageIndex *index = calloc(1, sizeof(*index)); + if (!index) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate installed model index"); + return NULL; + } + IndexBuild build = {index, digests, error, false}; + bool scanned = bongo_cat_path_enumerate(root, index_package, &build); + if (scanned && !build.failed) return index; + if (!build.failed) + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot index installed model packages"); + free(index); + return NULL; +} + +void bongo_cat_import_package_index_destroy( + BongoCatImportPackageIndex *index) { + free(index); +} diff --git a/src/runtime/model/import/model_import_path.c b/src/runtime/model/import/model_import_path.c new file mode 100644 index 00000000..df26cd4c --- /dev/null +++ b/src/runtime/model/import/model_import_path.c @@ -0,0 +1,60 @@ +#include "model_import_path.h" + +#include +#include +#include + +static bool separator(char value) { + return value == '/' || value == '\\'; +} + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +bool bongo_cat_import_parent_path(const char *path, char *parent, + size_t capacity) { + size_t length = path ? strlen(path) : 0; + while (length && separator(path[length - 1])) length--; + while (length && !separator(path[length - 1])) length--; + while (length > 1 && separator(path[length - 1])) length--; + if (!length || length >= capacity) return false; + memcpy(parent, path, length); + parent[length] = '\0'; + return true; +} + +bool bongo_cat_import_relative_path(const char *root, const char *path, + char *relative, size_t capacity) { + size_t root_length = root ? strlen(root) : 0; + while (root_length > 1 && separator(root[root_length - 1])) root_length--; + size_t path_length = path ? strlen(path) : 0; + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + if (path_length > root_length && !separator(path[root_length])) + return false; + const char *value = path + root_length; + while (separator(*value)) value++; + int written = snprintf(relative, capacity, "%s", value); + return written >= 0 && (size_t)written < capacity; +} + +bool bongo_cat_import_has_suffix(const char *value, const char *suffix) { + size_t value_length = value ? strlen(value) : 0; + size_t suffix_length = suffix ? strlen(suffix) : 0; + return value_length >= suffix_length && suffix_length > 0 && + memcmp(value + value_length - suffix_length, suffix, suffix_length) == 0; +} + +bool bongo_cat_import_has_suffix_ci(const char *value, const char *suffix) { + size_t value_length = value ? strlen(value) : 0; + size_t suffix_length = suffix ? strlen(suffix) : 0; + return value_length >= suffix_length && suffix_length > 0 && + SDL_strcasecmp(value + value_length - suffix_length, suffix) == 0; +} diff --git a/src/runtime/model/import/model_import_path.h b/src/runtime/model/import/model_import_path.h new file mode 100644 index 00000000..55526475 --- /dev/null +++ b/src/runtime/model/import/model_import_path.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_PATH_H +#define BONGO_CAT_MODEL_IMPORT_PATH_H + +#include +#include + +/* Small path operations shared by source-format modules. */ +bool bongo_cat_import_parent_path(const char *path, char *parent, + size_t capacity); +bool bongo_cat_import_relative_path(const char *root, const char *path, + char *relative, size_t capacity); +bool bongo_cat_import_has_suffix(const char *value, const char *suffix); +bool bongo_cat_import_has_suffix_ci(const char *value, const char *suffix); + +#endif diff --git a/src/runtime/model/import/model_import_probe.c b/src/runtime/model/import/model_import_probe.c new file mode 100644 index 00000000..68afa276 --- /dev/null +++ b/src/runtime/model/import/model_import_probe.c @@ -0,0 +1,122 @@ +#include "model_import_probe.h" +#include "model_import_path.h" +#include "mver/model_import_mver.h" +#include "tauri/model_import_tauri.h" + +#include +#include +#include +#include + +typedef int (*ExactProbe)(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); + +static int probe_live2d_owner_at(const char *directory, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + memset(discovery, 0, sizeof(*discovery)); + /* Mver's adapter owns parent lookup and protects bundled Live2D models + from being converted as standalone Tauri sources. */ + int found = bongo_cat_import_mver_discover(directory, discovery, error); + return found ? found : bongo_cat_import_tauri_discover_exact(directory, + discovery, error); +} + +BongoCatResult bongo_cat_import_probe_live2d_owner(const char *source, + char *directory, size_t capacity, BongoCatError *error) { + char current[BONGO_CAT_PATH_CAP]; + if (!directory || !capacity || + !bongo_cat_import_parent_path(source, current, sizeof(current))) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Cannot determine the selected model file directory"); + return BONGO_CAT_ERROR_ARGUMENT; + } + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model source discovery workspace"); + return BONGO_CAT_ERROR_MEMORY; + } + BongoCatResult result = BONGO_CAT_ERROR_FORMAT; + int found = 0; + for (int depth = 0; depth < 12; ++depth) { + BongoCatError probe_error = {0}; + found = probe_live2d_owner_at(current, discovery, &probe_error); + if (found < 0) { + if (error) *error = probe_error; + result = probe_error.code ? probe_error.code : BONGO_CAT_ERROR_FORMAT; + break; + } + if (found > 0) { + int length = snprintf(directory, capacity, "%s", + discovery->candidates[0].package_root); + result = length >= 0 && (size_t)length < capacity + ? BONGO_CAT_OK : BONGO_CAT_ERROR_ARGUMENT; + if (result != BONGO_CAT_OK) bongo_cat_error_set(error, result, + "Model import path is too long"); + break; + } + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent)) || + strcmp(current, parent) == 0) break; + snprintf(current, sizeof(current), "%s", parent); + } + free(discovery); + if (!found) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "No supported model package found above Live2D .moc3 file: %s", source); + return result; +} + +const char *bongo_cat_import_format_name(BongoCatImportFormat format) { + switch (format) { + case BONGO_CAT_IMPORT_MVER: return "mver"; + case BONGO_CAT_IMPORT_MVER_PATCH: return "mver-patch"; + case BONGO_CAT_IMPORT_TAURI: return "tauri"; + default: return "unknown"; + } +} + +static int run_probe(const char *source, BongoCatImportDiscovery *discovery, + BongoCatImportFormat format, ExactProbe exact, bool diagnostic, + uint64_t started, BongoCatError *error) { + int found = exact(source, discovery, error); + if (found && diagnostic) SDL_Log( + "[runtime] Model import exact probe completed: format=%s result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", + bongo_cat_import_format_name(format), found, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + return found; +} + +int bongo_cat_import_probe_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatImportFormat *format, + BongoCatImportProbePolicy policy, bool diagnostic, + BongoCatError *error) { + if (!source || !discovery) return 0; + uint64_t started = SDL_GetTicksNS(); + static const struct { + BongoCatImportFormat format; + ExactProbe probe; + } probes[] = { + {BONGO_CAT_IMPORT_MVER, bongo_cat_import_mver_discover_exact}, + {BONGO_CAT_IMPORT_MVER_PATCH, + bongo_cat_import_mver_patch_discover_exact}, + {BONGO_CAT_IMPORT_TAURI, bongo_cat_import_tauri_discover_exact} + }; + for (size_t i = 0; i < sizeof(probes) / sizeof(probes[0]); ++i) { + memset(discovery, 0, sizeof(*discovery)); + if (error) *error = (BongoCatError){0}; + int found = run_probe(source, discovery, probes[i].format, + probes[i].probe, diagnostic, started, error); + if (found == 0) continue; + if (found < 0 && policy == BONGO_CAT_IMPORT_PROBE_FALLBACK && + i + 1 < sizeof(probes) / sizeof(probes[0])) continue; + if (format) *format = probes[i].format; + return found; + } + if (diagnostic) SDL_Log( + "[runtime] Model import exact probe completed: format=none result=0 " + "candidates=0 elapsed_ms=%.1f path=%s", + (SDL_GetTicksNS() - started) / 1000000.0, source); + return 0; +} diff --git a/src/runtime/model/import/model_import_probe.h b/src/runtime/model/import/model_import_probe.h new file mode 100644 index 00000000..f762a71c --- /dev/null +++ b/src/runtime/model/import/model_import_probe.h @@ -0,0 +1,26 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_PROBE_H +#define BONGO_CAT_MODEL_IMPORT_PROBE_H + +#include "model_import.h" + +typedef enum BongoCatImportProbePolicy { + /* A malformed matching package is a user-visible import error. */ + BONGO_CAT_IMPORT_PROBE_STRICT, + /* Container scans may continue trying later source adapters. */ + BONGO_CAT_IMPORT_PROBE_FALLBACK +} BongoCatImportProbePolicy; + +/* Exact package-root dispatch shared by progressive, bounded, and queued + scans. Broad Mver parent lookup remains in the Mver adapter. */ +const char *bongo_cat_import_format_name(BongoCatImportFormat format); +int bongo_cat_import_probe_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatImportFormat *format, + BongoCatImportProbePolicy policy, bool diagnostic, BongoCatError *error); + +/* Resolve an existing Moc file to its owning package, checking ancestors + only. Mver ownership takes precedence over standalone Tauri conversion; + malformed matching packages stop lookup. Source validates the input file. */ +BongoCatResult bongo_cat_import_probe_live2d_owner(const char *source, + char *directory, size_t capacity, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_progressive.c b/src/runtime/model/import/model_import_progressive.c new file mode 100644 index 00000000..b7b89fe3 --- /dev/null +++ b/src/runtime/model/import/model_import_progressive.c @@ -0,0 +1,223 @@ +#include "model_import.h" +#include "model_import_archive.h" +#include "model_import_probe.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +typedef struct ProgressiveBase { + char directory[BONGO_CAT_PATH_CAP]; + char setting[BONGO_CAT_PATH_CAP]; + BongoCatModelMode mode; +} ProgressiveBase; + +typedef struct ProgressiveUnit { + char source[BONGO_CAT_PATH_CAP]; + bool patch_only; +} ProgressiveUnit; + +typedef struct ProgressiveScan { + ProgressiveUnit units[BONGO_CAT_MODEL_CAP]; + ProgressiveBase bases[BONGO_CAT_MODEL_CAP]; + size_t unit_count; + size_t base_count; + size_t model_count; +} ProgressiveScan; + +static BongoCatResult collect_unit(void *userdata, const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + ProgressiveScan *scan = userdata; + if (scan->unit_count >= BONGO_CAT_MODEL_CAP || + discovery->count > BONGO_CAT_MODEL_CAP - scan->model_count) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + ProgressiveUnit *unit = &scan->units[scan->unit_count++]; + snprintf(unit->source, sizeof(unit->source), "%s", source); + unit->patch_only = discovery->count > 0; + scan->model_count += discovery->count; + for (size_t i = 0; i < discovery->count; ++i) { + const BongoCatImportCandidate *candidate = &discovery->candidates[i]; + if (candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) + unit->patch_only = false; + if (candidate->format != BONGO_CAT_IMPORT_MVER) continue; + if (scan->base_count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Selected folder contains too many model variants"); + return BONGO_CAT_ERROR_FORMAT; + } + ProgressiveBase *base = &scan->bases[scan->base_count++]; + snprintf(base->directory, sizeof(base->directory), "%s", + candidate->directory); + snprintf(base->setting, sizeof(base->setting), "%s", + candidate->setting); + base->mode = candidate->mode; + } + return BONGO_CAT_OK; +} + +static bool has_base(const ProgressiveScan *scan, + const BongoCatImportCandidate *patch) { + for (size_t i = 0; i < scan->base_count; ++i) { + const ProgressiveBase *base = &scan->bases[i]; + if (base->mode == patch->mode && + strcmp(base->directory, patch->directory) == 0 && + strcmp(base->setting, patch->setting) == 0) return true; + } + return false; +} + +static bool redundant_patch(const ProgressiveScan *scan, + const ProgressiveUnit *unit) { + if (!unit->patch_only) return false; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return false; + BongoCatError ignored = {0}; + bool redundant = bongo_cat_import_probe_exact(unit->source, discovery, + NULL, BONGO_CAT_IMPORT_PROBE_STRICT, false, &ignored) > 0 && + discovery->count > 0; + for (size_t i = 0; redundant && i < discovery->count; ++i) + if (!has_base(scan, &discovery->candidates[i])) redundant = false; + free(discovery); + return redundant; +} + +static void record_failure_name(BongoCatImportBatchStats *stats, + const char *source) { + if (!stats || stats->failure_name_count >= + BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; + const char *name = bongo_cat_path_name(source); + if (!name || !name[0]) return; + SDL_utf8strlcpy(stats->failure_names[stats->failure_name_count++], name, + BONGO_CAT_ID_CAP); +} + +static BongoCatResult install_one(BongoCatImportSession *session, + const char *source, BongoCatImportReceiptCallback callback, + void *userdata, BongoCatImportBatchStats *stats, BongoCatError *error) { + BongoCatImportReceipt receipt = {0}; + uint64_t started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import unit started: path=%s", source); + BongoCatResult result = bongo_cat_import_session_install(session, source, + &receipt, error); + if (result == BONGO_CAT_OK) stats->succeeded_count++; + else { + stats->failed_count++; + record_failure_name(stats, source); + } + if (result == BONGO_CAT_OK && callback) + callback(userdata, &receipt); + SDL_Log("[runtime] Model import unit completed: result=%d variants=%llu " + "installed=%llu elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)receipt.count, + (unsigned long long)receipt.installed_count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + return result; +} + +static BongoCatResult fail_source(BongoCatImportBatchStats *stats, + const char *source, BongoCatResult result) { + stats->failed_count++; + record_failure_name(stats, source); + return result; +} + +static BongoCatResult install_progressive_directory( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatImportBatchStats *stats, BongoCatError *error) { + if (!stats) return BONGO_CAT_ERROR_ARGUMENT; + *stats = (BongoCatImportBatchStats){0}; + if (!session || !source) + return fail_source(stats, source, BONGO_CAT_ERROR_ARGUMENT); + char directory[BONGO_CAT_PATH_CAP]; + BongoCatResult normalized = bongo_cat_import_source_directory(source, + directory, sizeof(directory), error); + if (normalized != BONGO_CAT_OK) + return fail_source(stats, source, normalized); + + SDL_Log("[runtime] Model import progressive discovery started: path=%s", + directory); + BongoCatImportDiscovery *exact = calloc(1, sizeof(*exact)); + if (!exact) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate exact model discovery workspace"); + return fail_source(stats, directory, BONGO_CAT_ERROR_MEMORY); + } + BongoCatError exact_error = {0}; + int exact_result = bongo_cat_import_probe_exact(directory, exact, NULL, + BONGO_CAT_IMPORT_PROBE_STRICT, true, &exact_error); + free(exact); + if (exact_result > 0) { + SDL_Log("[runtime] Model import progressive discovery selected exact " + "package: path=%s", directory); + return install_one(session, directory, callback, userdata, stats, + error); + } + if (exact_result < 0) { + if (error) *error = exact_error; + return fail_source(stats, directory, exact_error.code + ? exact_error.code : BONGO_CAT_ERROR_FORMAT); + } + + ProgressiveScan *scan = calloc(1, sizeof(*scan)); + if (!scan) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate progressive model import scan"); + return fail_source(stats, directory, BONGO_CAT_ERROR_MEMORY); + } + uint64_t scan_started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import container scan started: path=%s", + directory); + BongoCatResult scanned = bongo_cat_import_scan_diagnostic(directory, + collect_unit, scan, error); + SDL_Log("[runtime] Model import container scan completed: result=%d " + "units=%llu variants=%llu elapsed_ms=%.1f path=%s", (int)scanned, + (unsigned long long)scan->unit_count, + (unsigned long long)scan->model_count, + (SDL_GetTicksNS() - scan_started) / 1000000.0, directory); + if (scanned != BONGO_CAT_OK || !scan->unit_count) { + free(scan); + return scanned != BONGO_CAT_OK ? fail_source(stats, directory, scanned) : + install_one(session, directory, callback, userdata, stats, error); + } + + BongoCatResult first_failure = BONGO_CAT_OK; + BongoCatError first_error = {0}; + for (size_t i = 0; i < scan->unit_count; ++i) { + if (redundant_patch(scan, &scan->units[i])) continue; + BongoCatError local = {0}; + BongoCatResult result = install_one(session, scan->units[i].source, + callback, userdata, stats, &local); + if (result != BONGO_CAT_OK && first_failure == BONGO_CAT_OK) { + first_failure = result; + first_error = local; + } + } + free(scan); + if (first_failure != BONGO_CAT_OK && error) *error = first_error; + return first_failure; +} + +BongoCatResult bongo_cat_import_session_install_progressive( + BongoCatImportSession *session, const char *source, + BongoCatImportReceiptCallback callback, void *userdata, + BongoCatImportBatchStats *stats, BongoCatError *error) { + if (!stats) return BONGO_CAT_ERROR_ARGUMENT; + if (!session || !source || !bongo_cat_import_is_archive(source)) + return install_progressive_directory(session, source, callback, + userdata, stats, error); + *stats = (BongoCatImportBatchStats){0}; + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + BongoCatResult result = bongo_cat_import_archive_extract(source, + directory, temporary, error); + if (result != BONGO_CAT_OK) return fail_source(stats, source, result); + result = install_progressive_directory(session, directory, callback, + userdata, stats, error); + bongo_cat_import_archive_cleanup(temporary); + return result; +} diff --git a/src/runtime/model_import_report.c b/src/runtime/model/import/model_import_report.c similarity index 95% rename from src/runtime/model_import_report.c rename to src/runtime/model/import/model_import_report.c index 79244f58..2a090a81 100644 --- a/src/runtime/model_import_report.c +++ b/src/runtime/model/import/model_import_report.c @@ -1,4 +1,5 @@ #include "model_import.h" +#include "mver/model_import_mver_manifest.h" #include "bongo_cat/file.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -39,7 +40,7 @@ static bool add_stat(yyjson_mut_doc *output, yyjson_mut_val *stats, yyjson_val *mode, const BongoCatImportCandidate *candidate, const char *key, const char *directory, bool sound) { yyjson_val *rows = yyjson_obj_get(mode, key); - if (!rows) return true; + if (!rows || yyjson_is_null(rows)) return true; if (!yyjson_is_arr(rows)) return false; size_t declared = yyjson_arr_size(rows), available = 0; for (size_t i = 0; i < declared; ++i) @@ -85,7 +86,9 @@ static size_t missing_motion_sounds(const BongoCatImportCandidate *candidate) { char manifest_path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(manifest_path, sizeof(manifest_path), candidate->directory, candidate->setting)) return 0; - yyjson_doc *document = bongo_cat_json_read_file(manifest_path, 0, NULL); + yyjson_doc *document = candidate->format == BONGO_CAT_IMPORT_TAURI + ? bongo_cat_json_read_file(manifest_path, 0, NULL) + : bongo_cat_import_mver_manifest_read(manifest_path, NULL); yyjson_val *refs = document ? yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences") : NULL; yyjson_val *motions = yyjson_obj_get(refs, "Motions"); @@ -127,7 +130,8 @@ bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, bool ok = root && stats && capabilities && degraded && yyjson_mut_obj_add_int(output, root, "schemaVersion", 1) && yyjson_mut_obj_add_strcpy(output, root, "format", format_name(candidate->format)) && - yyjson_mut_obj_add_str(output, root, "runtimeProfile", "mver-0.1.6") && + yyjson_mut_obj_add_str(output, root, "runtimeProfile", + mver ? "mver-0.1.6" : "native") && yyjson_mut_obj_add_strcpy(output, root, "mode", bongo_cat_mode_name(candidate->mode)) && yyjson_mut_obj_add_bool(output, capabilities, "live2dModel", true) && @@ -141,7 +145,7 @@ bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, yyjson_mut_obj_add_bool(output, capabilities, "expressionsAndMotions", true) && yyjson_mut_obj_add_bool(output, capabilities, "shortcutAudio", mver) && yyjson_mut_obj_add_bool(output, capabilities, "imageEffects", mver) && - yyjson_mut_obj_add_bool(output, capabilities, "mverRuntime", true) && + yyjson_mut_obj_add_bool(output, capabilities, "mverRuntime", mver) && yyjson_mut_obj_add_bool(output, capabilities, "tauriAdapter", !mver) && yyjson_mut_obj_add_bool(output, capabilities, "mverProjection", mver) && yyjson_mut_obj_add_bool(output, capabilities, "mverPointerDomain", mver) && diff --git a/src/runtime/model/import/model_import_scan.c b/src/runtime/model/import/model_import_scan.c new file mode 100644 index 00000000..81877059 --- /dev/null +++ b/src/runtime/model/import/model_import_scan.c @@ -0,0 +1,211 @@ +#include "model_import.h" +#include "model_import_probe.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define IMPORT_CHILD_CAP 64 +#define IMPORT_ENTRY_CAP 256 +#define IMPORT_NODE_CAP 256 +#define IMPORT_SCAN_DEPTH 3 +#define IMPORT_SCAN_BUDGET_NS 2000000000ull + +typedef struct ChildList { + char paths[IMPORT_CHILD_CAP][BONGO_CAT_PATH_CAP]; + size_t count, entries; + bool limited; +} ChildList; + +typedef struct ImportWorkspace { + BongoCatImportDiscovery discovery; + ChildList children; +} ImportWorkspace; + +typedef struct ImportScan { + BongoCatImportVisitor visitor; + void *userdata; + BongoCatError *error; + size_t nodes; + uint64_t deadline; + bool limited; + bool diagnostic; +} ImportScan; + +typedef struct ImportQueue { + char paths[IMPORT_NODE_CAP][BONGO_CAT_PATH_CAP]; + unsigned char depths[IMPORT_NODE_CAP]; + size_t head, count; +} ImportQueue; + +static BongoCatPathVisit collect_child(void *userdata, + const char *dirname, const char *name) { + ChildList *list = userdata; + if (++list->entries > IMPORT_ENTRY_CAP || list->count >= IMPORT_CHILD_CAP) { + list->limited = true; return BONGO_CAT_PATH_SUCCESS; + } + if (name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), dirname, name) && + bongo_cat_path_is_dir(path)) + snprintf(list->paths[list->count++], BONGO_CAT_PATH_CAP, "%s", path); + return BONGO_CAT_PATH_CONTINUE; +} + +static int compare_path(const void *left, const void *right) { +#ifdef _WIN32 + return SDL_strcasecmp(left, right); +#else + return strcmp(left, right); +#endif +} + +static bool within_budget(ImportScan *scan) { + if (scan->nodes >= IMPORT_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { + scan->limited = true; return false; + } + scan->nodes++; return true; +} + +static void enqueue_children(ImportScan *scan, ImportQueue *queue, + const ChildList *children, int depth) { + for (size_t i = 0; i < children->count; ++i) { + if (queue->count >= IMPORT_NODE_CAP) { + scan->limited = true; return; + } + snprintf(queue->paths[queue->count], BONGO_CAT_PATH_CAP, "%s", + children->paths[i]); + queue->depths[queue->count++] = (unsigned char)depth; + } +} + +static BongoCatResult scan_node(ImportScan *scan, ImportQueue *queue, + const char *source, int depth) { + if (!within_budget(scan)) return BONGO_CAT_OK; + uint64_t started = SDL_GetTicksNS(); + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node started: node=%llu depth=%d " + "path=%s", (unsigned long long)scan->nodes, depth, source); + ImportWorkspace *work = calloc(1, sizeof(*work)); + if (!work) { + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import scan workspace"); + return BONGO_CAT_ERROR_MEMORY; + } + BongoCatError local = {0}; + BongoCatImportFormat format = BONGO_CAT_IMPORT_MVER; + int found = bongo_cat_import_probe_exact(source, &work->discovery, + &format, BONGO_CAT_IMPORT_PROBE_FALLBACK, scan->diagnostic, &local); + if (found > 0) { + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node discovered: format=%s " + "candidates=%llu elapsed_ms=%.1f path=%s", + bongo_cat_import_format_name(format), + (unsigned long long)work->discovery.count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + uint64_t visitor_started = SDL_GetTicksNS(); + BongoCatResult result = scan->visitor(scan->userdata, source, + &work->discovery, scan->error); + uint64_t finished = SDL_GetTicksNS(); + uint64_t elapsed = finished - visitor_started; + if (scan->deadline <= UINT64_MAX - elapsed) scan->deadline += elapsed; + else scan->deadline = UINT64_MAX; + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node collected: result=%d " + "elapsed_ms=%.1f path=%s", (int)result, + (finished - started) / 1000000.0, source); + free(work); return result; + } + if (depth < IMPORT_SCAN_DEPTH) { + bool enumerated = bongo_cat_path_enumerate(source, collect_child, + &work->children); + if (!enumerated) { + if (!scan->error || scan->error->code == BONGO_CAT_OK) + bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_IO, + "Cannot enumerate model directory: %s", source); + BongoCatResult result = scan->error && scan->error->code + ? scan->error->code : BONGO_CAT_ERROR_IO; + free(work); + return result; + } + if (work->children.limited) scan->limited = true; + qsort(work->children.paths, work->children.count, + sizeof(work->children.paths[0]), compare_path); + enqueue_children(scan, queue, &work->children, depth + 1); + } + if (scan->diagnostic) SDL_Log( + "[runtime] Model import scan node completed: children=%llu " + "elapsed_ms=%.1f path=%s", + (unsigned long long)work->children.count, + (SDL_GetTicksNS() - started) / 1000000.0, source); + free(work); return BONGO_CAT_OK; +} + +static BongoCatResult scan_with_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + bool diagnostic, BongoCatError *error) { + if (!root || !visitor) return BONGO_CAT_ERROR_ARGUMENT; + ImportWorkspace *work = calloc(1, sizeof(*work)); + ImportQueue *queue = calloc(1, sizeof(*queue)); + if (!work || !queue) { + free(work); + free(queue); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import directory scan"); + return BONGO_CAT_ERROR_MEMORY; + } + uint64_t now = SDL_GetTicksNS(); + uint64_t deadline = budget_ns <= UINT64_MAX - now + ? now + budget_ns : UINT64_MAX; + ImportScan scan = { + visitor, userdata, error, 0, deadline, false, diagnostic + }; + BongoCatResult result = BONGO_CAT_OK; + if (diagnostic) SDL_Log( + "[runtime] Model import scan started: budget_ms=%.1f path=%s", + budget_ns / 1000000.0, root); + if (bongo_cat_path_enumerate(root, collect_child, &work->children)) { + if (work->children.limited) scan.limited = true; + qsort(work->children.paths, work->children.count, + sizeof(work->children.paths[0]), compare_path); + enqueue_children(&scan, queue, &work->children, 1); + while (queue->head < queue->count) { + size_t current = queue->head++; + BongoCatResult item = scan_node(&scan, queue, + queue->paths[current], queue->depths[current]); + if (item != BONGO_CAT_OK && result == BONGO_CAT_OK) result = item; + } + } else { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot enumerate model directory: %s", root); + result = error && error->code ? error->code : BONGO_CAT_ERROR_IO; + } + if (scan.limited) SDL_Log( + "Model folder scan reached its directory budget"); + if (diagnostic) SDL_Log( + "[runtime] Model import scan completed: result=%d nodes=%llu " + "queued=%llu limited=%d elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)scan.nodes, (unsigned long long)queue->count, + scan.limited, (SDL_GetTicksNS() - now) / 1000000.0, root); + free(queue); free(work); return result; +} + +BongoCatResult bongo_cat_import_scan_budget(const char *root, + BongoCatImportVisitor visitor, void *userdata, uint64_t budget_ns, + BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, budget_ns, false, error); +} + +BongoCatResult bongo_cat_import_scan(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, + IMPORT_SCAN_BUDGET_NS, false, error); +} + +BongoCatResult bongo_cat_import_scan_diagnostic(const char *root, + BongoCatImportVisitor visitor, void *userdata, BongoCatError *error) { + return scan_with_budget(root, visitor, userdata, + IMPORT_SCAN_BUDGET_NS, true, error); +} diff --git a/src/runtime/model/import/model_import_session.c b/src/runtime/model/import/model_import_session.c new file mode 100644 index 00000000..1441cb1c --- /dev/null +++ b/src/runtime/model/import/model_import_session.c @@ -0,0 +1,163 @@ +#include "model_import.h" +#include "model_import_archive.h" +#include "model_import_lock.h" +#include "model_import_session_internal.h" +#include "model_storage.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include + +static BongoCatImportSession *session_create_unlocked(const char *models_root, + BongoCatError *error) { + uint64_t started = SDL_GetTicksNS(); + SDL_Log("[runtime] Model import session creation started: root=%s", + models_root); + BongoCatImportSession *session = calloc(1, sizeof(*session)); + if (!session) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import session"); + return NULL; + } + session->digests = bongo_cat_import_digest_cache_create(); + int root_length = snprintf(session->root, sizeof(session->root), "%s", + models_root); + if (!session->digests || root_length < 0 || + (size_t)root_length >= sizeof(session->root) || + !bongo_cat_path_create_directory(session->root)) { + bongo_cat_import_session_destroy(session); + return NULL; + } + SDL_Log("[runtime] Model import session creation: stage=cleanup root=%s", + session->root); + if (!bongo_cat_model_cleanup_imports(session->root, error)) { + bongo_cat_import_session_destroy(session); + return NULL; + } + SDL_Log("[runtime] Model import session creation: stage=index root=%s", + session->root); + session->packages = bongo_cat_import_package_index_create(session->root, + session->digests, error); + if (!session->packages) { + bongo_cat_import_session_destroy(session); + return NULL; + } + SDL_Log("[runtime] Model import session creation completed: " + "elapsed_ms=%.1f root=%s", + (SDL_GetTicksNS() - started) / 1000000.0, session->root); + return session; +} + +BongoCatImportSession *bongo_cat_import_session_create(const char *models_root, + BongoCatError *error) { + if (!models_root) return NULL; + SDL_Log("[runtime] Model import session lock: stage=waiting"); + bongo_cat_import_storage_lock(); + SDL_Log("[runtime] Model import session lock: stage=acquired"); + BongoCatImportSession *session = session_create_unlocked(models_root, + error); + bongo_cat_import_storage_unlock(); + SDL_Log("[runtime] Model import session lock: stage=released result=%d", + session != NULL); + return session; +} + +void bongo_cat_import_session_destroy(BongoCatImportSession *session) { + if (!session) return; + bongo_cat_import_package_index_destroy(session->packages); + bongo_cat_import_digest_cache_destroy(session->digests); + free(session); +} + +static BongoCatResult session_install_unlocked( + BongoCatImportSession *session, const char *source, + BongoCatImportReceipt *receipt, BongoCatError *error) { + char source_directory[BONGO_CAT_PATH_CAP]; + uint64_t started = SDL_GetTicksNS(); + BongoCatResult source_result = bongo_cat_import_source_directory(source, + source_directory, sizeof(source_directory), error); + if (source_result != BONGO_CAT_OK) return source_result; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + BongoCatPackageMetadata *metadata = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, + sizeof(*metadata)); + if (!discovery || !metadata) { + free(discovery); + free(metadata); + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model import workspace"); + return BONGO_CAT_ERROR_MEMORY; + } + SDL_Log("[runtime] Model import package discovery started: path=%s", + source_directory); + bool discovered = bongo_cat_import_discover(source_directory, discovery, + error); + SDL_Log("[runtime] Model import package discovery completed: result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", discovered, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); + bool metadata_ready = discovered && + bongo_cat_import_prepare_package_metadata_cached(discovery, metadata, + session->digests, error); + SDL_Log("[runtime] Model import package fingerprint completed: result=%d " + "candidates=%llu elapsed_ms=%.1f path=%s", metadata_ready, + (unsigned long long)discovery->count, + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); + if (!metadata_ready) { + BongoCatResult result = error && error->code ? error->code : + BONGO_CAT_ERROR_FORMAT; + free(discovery); + free(metadata); + return result; + } + BongoCatResult result = bongo_cat_import_install_discovery(session, + discovery, metadata, receipt, error); + SDL_Log("[runtime] Model import package completed: result=%d variants=%llu " + "installed=%llu elapsed_ms=%.1f path=%s", (int)result, + (unsigned long long)(receipt ? receipt->count : 0), + (unsigned long long)(receipt ? receipt->installed_count : 0), + (SDL_GetTicksNS() - started) / 1000000.0, source_directory); + free(discovery); + free(metadata); + return result; +} + +BongoCatResult bongo_cat_import_session_install( + BongoCatImportSession *session, const char *source, + BongoCatImportReceipt *receipt, BongoCatError *error) { + if (receipt) memset(receipt, 0, sizeof(*receipt)); + if (!session || !source) return BONGO_CAT_ERROR_ARGUMENT; + SDL_Log("[runtime] Model import storage lock: stage=waiting path=%s", + source); + bongo_cat_import_storage_lock(); + SDL_Log("[runtime] Model import storage lock: stage=acquired path=%s", + source); + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP] = ""; + BongoCatResult result = BONGO_CAT_OK; + bool archive = bongo_cat_import_is_archive(source); + if (archive) result = bongo_cat_import_archive_extract(source, directory, + temporary, error); + if (result == BONGO_CAT_OK) + result = session_install_unlocked(session, archive ? directory : source, + receipt, error); + bongo_cat_import_archive_cleanup(temporary); + bongo_cat_import_storage_unlock(); + SDL_Log("[runtime] Model import storage lock: stage=released result=%d " + "path=%s", (int)result, source); + return result; +} + +BongoCatResult bongo_cat_import_install(const char *source, + const char *models_root, BongoCatImportReceipt *receipt, + BongoCatError *error) { + if (receipt) memset(receipt, 0, sizeof(*receipt)); + if (!source || !models_root) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatImportSession *session = bongo_cat_import_session_create( + models_root, error); + if (!session) return error && error->code ? error->code : + BONGO_CAT_ERROR_IO; + BongoCatResult result = bongo_cat_import_session_install(session, + source, receipt, error); + bongo_cat_import_session_destroy(session); + return result; +} diff --git a/src/runtime/model/import/model_import_session_internal.h b/src/runtime/model/import/model_import_session_internal.h new file mode 100644 index 00000000..c9438bef --- /dev/null +++ b/src/runtime/model/import/model_import_session_internal.h @@ -0,0 +1,36 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_SESSION_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_SESSION_INTERNAL_H + +#include "model_import.h" + +typedef struct BongoCatImportPackageIndex BongoCatImportPackageIndex; + +struct BongoCatImportSession { + char root[BONGO_CAT_PATH_CAP]; + BongoCatImportDigestCache *digests; + BongoCatImportPackageIndex *packages; +}; + +BongoCatImportPackageIndex *bongo_cat_import_package_index_create( + const char *root, BongoCatImportDigestCache *digests, + BongoCatError *error); +void bongo_cat_import_package_index_destroy( + BongoCatImportPackageIndex *index); +bool bongo_cat_import_package_index_find( + const BongoCatImportPackageIndex *index, + const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, + char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP], + size_t *count); +bool bongo_cat_import_package_index_has_capacity( + const BongoCatImportPackageIndex *index, size_t model_count); +bool bongo_cat_import_package_index_add(BongoCatImportPackageIndex *index, + const char *id, const BongoCatImportDiscovery *discovery, + const BongoCatPackageMetadata *metadata, BongoCatError *error); +BongoCatResult bongo_cat_import_install_discovery( + BongoCatImportSession *session, + BongoCatImportDiscovery *source_discovery, + BongoCatPackageMetadata *source_metadata, + BongoCatImportReceipt *receipt, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/model_import_source.c b/src/runtime/model/import/model_import_source.c new file mode 100644 index 00000000..3bbcd1d0 --- /dev/null +++ b/src/runtime/model/import/model_import_source.c @@ -0,0 +1,64 @@ +#include "model_import.h" +#include "model_import_path.h" +#include "model_import_probe.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool image_package_root(const char *source, char *directory, + size_t capacity) { + char current[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(source, current, sizeof(current))) + return false; + for (int depth = 0; depth < 12; ++depth) { + if (SDL_strcasecmp(bongo_cat_path_name(current), "img") == 0) + return bongo_cat_import_parent_path(current, directory, capacity); + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + return false; + snprintf(current, sizeof(current), "%s", parent); + } + return false; +} + +BongoCatResult bongo_cat_import_source_directory(const char *source, + char *directory, size_t capacity, BongoCatError *error) { + if (!source || !source[0] || !directory || !capacity) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model import source"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (bongo_cat_path_is_dir(source)) { + int length = snprintf(directory, capacity, "%s", source); + if (length >= 0 && (size_t)length < capacity) return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model import path is too long"); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (!bongo_cat_path_is_file(source)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Selected model source does not exist: %s", source); + return BONGO_CAT_ERROR_ARGUMENT; + } + const char *name = bongo_cat_path_name(source); + if (name && bongo_cat_import_has_suffix_ci(name, ".moc3")) + return bongo_cat_import_probe_live2d_owner(source, directory, + capacity, error); + if (name && bongo_cat_import_has_suffix_ci(name, ".png") && + image_package_root(source, directory, capacity)) return BONGO_CAT_OK; + if (!name || (SDL_strcasecmp(name, "config.json") != 0 && + SDL_strcasecmp(name, BONGO_CAT_SKIN_CONFIG_FILE) != 0 && + !bongo_cat_import_has_suffix_ci(name, ".model3.json"))) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Select a BongoCat skin file, Mver config.json, image-patch PNG, " + "or Live2D .model3.json/.moc3 file"); + return BONGO_CAT_ERROR_FORMAT; + } + if (bongo_cat_import_parent_path(source, directory, capacity)) + return BONGO_CAT_OK; + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Cannot determine the selected model file directory: %s", source); + return BONGO_CAT_ERROR_ARGUMENT; +} diff --git a/src/runtime/model/import/mver/model_import_mver.h b/src/runtime/model/import/mver/model_import_mver.h new file mode 100644 index 00000000..58ed4157 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver.h @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_H +#define BONGO_CAT_MODEL_IMPORT_MVER_H + +#include "../model_import.h" + +/* Canonical package discovery and runtime-adapter generation. */ +bool bongo_cat_import_mver_config_path(const char *root, + char *path, size_t capacity); +int bongo_cat_import_mver_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_patch_discover(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_mver_patch_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error); + +#endif diff --git a/src/runtime/model_import_mver.c b/src/runtime/model/import/mver/model_import_mver_assets.c similarity index 92% rename from src/runtime/model_import_mver.c rename to src/runtime/model/import/mver/model_import_mver_assets.c index 47f3b11a..92f6a164 100644 --- a/src/runtime/model_import_mver.c +++ b/src/runtime/model/import/mver/model_import_mver_assets.c @@ -1,7 +1,7 @@ -#include "model_import.h" #include "model_import_mver_internal.h" #include "runtime.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/path.h" #include @@ -11,11 +11,6 @@ typedef BongoCatMverKeyNames KeyNames; -static bool child_is_dir(const char *root, const char *name) { - char path[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(path, sizeof(path), root, name) && bongo_cat_path_is_dir(path); -} - static bool asset_file(const BongoCatImportCandidate *candidate, const char *group, const char *name, char *output, size_t capacity) { char directory[BONGO_CAT_PATH_CAP]; @@ -50,6 +45,9 @@ static bool copy_standard_pointer_assets(const BongoCatImportCandidate *candidat for (size_t index = 0; index < sizeof(names) / sizeof(names[0]); ++index) { char source[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; if (!root_asset_file(candidate, names[index], source, sizeof(source))) continue; + int width = 0, height = 0; + if (!bongo_cat_image_info(source, &width, &height) || + (width <= 1 && height <= 1)) continue; if (!bongo_cat_path_join(destination, sizeof(destination), output, names[index]) || !bongo_cat_path_copy_file(source, destination)) return false; } @@ -112,6 +110,10 @@ KeyNames bongo_cat_mver_device_names(int code, size_t occurrence, size_t total) }; for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) if (map[i].code == code) { names.items[0] = map[i].name; names.count = 1; break; } + if (!names.count && code > 0 && code <= 255) { + snprintf(names.generated, sizeof(names.generated), "%d", code); + names.count = 1; + } return names; } names.count = 1; @@ -149,7 +151,9 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val yyjson_val *before, yyjson_val *after, yyjson_val *keyboard_matrix, const char *hand_name, const char *key_group, const char *target, BongoCatError *error) { + if (!matrix || yyjson_is_null(matrix)) return true; if (!yyjson_is_arr(matrix)) return false; + if (!yyjson_arr_size(matrix)) return true; char resources[BONGO_CAT_PATH_CAP], output_dir[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(resources, sizeof(resources), target, "resources") || !bongo_cat_path_join(output_dir, sizeof(output_dir), resources, key_group) || @@ -163,7 +167,8 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val yyjson_arr_foreach(matrix, index, count, keys) { char hand[BONGO_CAT_PATH_CAP], filename[32]; snprintf(filename, sizeof(filename), "%zu.png", index); - if (!asset_file(candidate, hand_name, filename, hand, sizeof(hand))) { + if (!asset_file(candidate, hand_name, filename, hand, sizeof(hand)) || + !bongo_cat_image_info(hand, NULL, NULL)) { size_t missing_index, missing_count; yyjson_val *missing; yyjson_arr_foreach(keys, missing_index, missing_count, missing) { int modifier = (yyjson_is_int(missing) || yyjson_is_uint(missing)) @@ -186,7 +191,8 @@ static bool process_matrix(const BongoCatImportCandidate *candidate, yyjson_val if (keyboard_index(keyboard_matrix, code, &keyboard_row)) { snprintf(filename, sizeof(filename), "%zu.png", keyboard_row); if (asset_file(candidate, "keyboard", filename, keyboard, - sizeof(keyboard))) keyboard_path = keyboard; + sizeof(keyboard)) && bongo_cat_image_info(keyboard, NULL, NULL)) + keyboard_path = keyboard; } int modifier = bongo_cat_mver_modifier_index(code); size_t occurrence = modifier >= 0 ? modifier_seen[modifier]++ : 0; @@ -208,8 +214,6 @@ bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { if (candidate->format != BONGO_CAT_IMPORT_MVER && candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) return true; - const char *left = candidate->mode == BONGO_CAT_MODE_STANDARD ? "hand" : "lefthand"; - if (!child_is_dir(candidate->assets, left)) return false; FILE *file = bongo_cat_file_open(candidate->config, "rb"); yyjson_doc *document = file ? yyjson_read_fp(file, YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL, NULL) : NULL; @@ -227,15 +231,14 @@ bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, yyjson_val *keyboard = yyjson_obj_get(mode, "keyboard"); if (candidate->mode == BONGO_CAT_MODE_STANDARD) { yyjson_val *hand = yyjson_obj_get(mode, "hand"); - if (yyjson_is_arr(hand)) ok = process_matrix(candidate, hand, NULL, NULL, + ok = process_matrix(candidate, hand, NULL, NULL, keyboard, "hand", "left-keys", target, error); if (ok) ok = copy_standard_pointer_assets(candidate, target); } else { yyjson_val *left_keys = yyjson_obj_get(mode, "lefthand"); yyjson_val *right_keys = yyjson_obj_get(mode, "righthand"); - if (yyjson_is_arr(left_keys) && yyjson_is_arr(right_keys)) - ok = process_matrix(candidate, left_keys, NULL, right_keys, keyboard, - "lefthand", "left-keys", target, error) && + ok = process_matrix(candidate, left_keys, NULL, right_keys, keyboard, + "lefthand", "left-keys", target, error) && process_matrix(candidate, right_keys, left_keys, NULL, keyboard, "righthand", "right-keys", target, error); } diff --git a/src/runtime/model/import/mver/model_import_mver_audio.c b/src/runtime/model/import/mver/model_import_mver_audio.c new file mode 100644 index 00000000..1049c751 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_audio.c @@ -0,0 +1,71 @@ +#include "model_import_mver_internal.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool sound_source(const BongoCatImportCandidate *candidate, size_t index, + char *source, size_t capacity, char *relative, size_t relative_capacity) { + static const char *extensions[] = {"wav", "ogg", "flac"}; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) { + char name[40], sounds[BONGO_CAT_PATH_CAP]; + snprintf(name, sizeof(name), "%zu.%s", index, extensions[i]); + if (!bongo_cat_path_join(sounds, sizeof(sounds), candidate->assets, "sounds") || + !bongo_cat_path_join(source, capacity, sounds, name) || + !bongo_cat_path_is_file(source)) continue; + snprintf(relative, relative_capacity, "resources/sounds/%s", name); + return true; + } + return false; +} + +static bool add_sound_clear(yyjson_mut_doc *output, yyjson_mut_val *items, + yyjson_val *config) { + yyjson_val *decoration = yyjson_obj_get(config, "decoration"); + yyjson_val *row = yyjson_obj_get(decoration, "soundClear"); + if (!row || (yyjson_is_arr(row) && yyjson_arr_size(row) == 0)) return true; + yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); + /* Stopping all audio is an explicit command and has no default shortcut. */ + return item && yyjson_mut_obj_add_str(output, item, "kind", "sound-clear"); +} + +bool bongo_cat_mver_add_audio(void *raw_output, void *raw_items, + void *raw_config, void *raw_rows, + const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, + const char *target) { + yyjson_mut_doc *output = raw_output; + yyjson_mut_val *items = raw_items; + yyjson_val *config = raw_config, *rows = raw_rows; + if (!rows || yyjson_is_null(rows)) return true; + if (!yyjson_is_arr(rows)) return false; + char target_resources[BONGO_CAT_PATH_CAP], target_sounds[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources") || + !bongo_cat_path_join(target_sounds, sizeof(target_sounds), target_resources, "sounds") || + !bongo_cat_path_create_directory(target_sounds)) return false; + yyjson_val *decoration = yyjson_obj_get(config, "decoration"); + yyjson_val *keep_value = yyjson_obj_get(decoration, "soundKeep"); + bool keep = !keep_value || yyjson_get_bool(keep_value); + size_t emitted = 0, index, count; yyjson_val *row; + yyjson_arr_foreach(rows, index, count, row) { + char shortcut[BONGO_CAT_SHORTCUT_CAP], source[BONGO_CAT_PATH_CAP]; + char relative[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; + if (!sound_source(candidate, index, source, sizeof(source), relative, + sizeof(relative))) continue; + if (!bongo_cat_mver_sound_chord(row, shortcut, sizeof(shortcut))) return false; + const char *name = bongo_cat_path_name(source); + if (!bongo_cat_path_join(destination, sizeof(destination), target_sounds, name) || + !bongo_cat_path_copy_file(source, destination)) return false; + yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); + const char *label = bongo_cat_mver_label(labels, "sounds", index); + if (!item || !yyjson_mut_obj_add_str(output, item, "kind", "sound") || + !yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut) || + !yyjson_mut_obj_add_strcpy(output, item, "sound", relative) || + (label && !yyjson_mut_obj_add_strcpy(output, item, "label", label)) || + !yyjson_mut_obj_add_bool(output, item, "overlap", keep)) return false; + emitted++; + } + return !emitted || add_sound_clear(output, items, config); +} + diff --git a/src/runtime/model/import/mver/model_import_mver_copy.c b/src/runtime/model/import/mver/model_import_mver_copy.c new file mode 100644 index 00000000..a946c187 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_copy.c @@ -0,0 +1,154 @@ +#include "model_import_mver_copy.h" +#include "model_import_path.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct MverDirectoryCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} MverDirectoryCopy; + +static bool rebase(const char *source_root, const char *installed_root, + const char *source, char *target, size_t capacity) { + char relative[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative))) + return false; + if (!relative[0]) { + int written = snprintf(target, capacity, "%s", installed_root); + return written >= 0 && (size_t)written < capacity; + } + return bongo_cat_path_join(target, capacity, installed_root, relative); +} + +static bool copy_relative_file(const char *source_root, const char *source, + const char *target_root, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative)) || + !relative[0] || !bongo_cat_path_join(target, sizeof(target), + target_root, relative)) return false; + if (bongo_cat_path_is_file(target)) return true; + if (bongo_cat_path_copy_file(source, target)) return true; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy imported model file: %s", source); + return false; +} + +static bool distribution_file(const char *name) { + if (!name || name[0] == '.') return true; + size_t length = strlen(name); + if (length >= 4 && SDL_strcasecmp(name + length - 4, "_bak") == 0) + return true; + const char *dot = strrchr(name, '.'); + if (!dot) return false; + static const char *const extensions[] = { + ".exe", ".dll", ".pdf", ".bak", ".lock", ".pdb", ".zip" + }; + for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) + if (SDL_strcasecmp(dot, extensions[i]) == 0) return true; + return false; +} + +static bool runtime_resources(const char *directory, const char *name) { + if (!name || SDL_strcasecmp(name, "Resources") != 0 || + !bongo_cat_path_is_dir(directory)) return false; + char font[BONGO_CAT_PATH_CAP], logo[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_join(font, sizeof(font), directory, "cat.ttf") && + bongo_cat_path_join(logo, sizeof(logo), directory, "l2dlogo.png") && + bongo_cat_path_is_file(font) && bongo_cat_path_is_file(logo); +} + +static bool copy_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static BongoCatPathVisit copy_child(void *userdata, const char *dirname, + const char *name) { + MverDirectoryCopy *context = userdata; + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (runtime_resources(source, name) || distribution_file(name)) + return BONGO_CAT_PATH_CONTINUE; + char target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_dir(source)) { + if (context->depth == 0) return BONGO_CAT_PATH_CONTINUE; + return bongo_cat_path_copy_file(source, target) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + } + return copy_tree(source, target, context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > 32 || !bongo_cat_path_create_directory(target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create imported model directory: %s", target); + return false; + } + MverDirectoryCopy context = {target, error, depth}; + return bongo_cat_path_enumerate(source, copy_child, &context); +} + +static bool copy_relative_directory(const char *source_root, + const char *source, const char *target_root, BongoCatError *error) { + char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_relative_path(source_root, source, relative, + sizeof(relative)) || + !relative[0] || !bongo_cat_path_join(target, sizeof(target), + target_root, relative)) return false; + return copy_tree(source, target, 1, error); +} + +static bool copy_package(const BongoCatImportCandidate *candidate, + const char *target, BongoCatError *error) { + if (!bongo_cat_path_create_directory(target) || + !copy_tree(candidate->package_root, target, 0, error)) return false; + return copy_relative_file(candidate->package_root, candidate->config, + target, error); +} + +bool bongo_cat_import_mver_copy_package( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error) { + if (!candidate || !target || !installed) return false; + char base[BONGO_CAT_PATH_CAP]; + bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; + if (patch) { + char patch_relative[BONGO_CAT_PATH_CAP]; + bool patch_inside_package = bongo_cat_import_relative_path( + candidate->package_root, candidate->patch_root, patch_relative, + sizeof(patch_relative)); + const char *patch_source_root = patch_inside_package + ? candidate->package_root : candidate->patch_root; + snprintf(base, sizeof(base), "%s", target); + if (!copy_package(candidate, base, error) || + (!patch_inside_package && candidate->overrides[0] && + !copy_relative_directory(patch_source_root, + candidate->overrides, target, error)) || + (candidate->overrides[0] && !rebase(patch_source_root, target, + candidate->overrides, installed->overrides, + sizeof(installed->overrides))) || + !rebase(patch_source_root, target, candidate->patch_root, + installed->patch_root, sizeof(installed->patch_root))) + return false; + } else if (candidate->format == BONGO_CAT_IMPORT_MVER) { + snprintf(base, sizeof(base), "%s", target); + if (!copy_package(candidate, base, error)) return false; + } else return false; + if (!rebase(candidate->package_root, base, candidate->directory, + installed->directory, sizeof(installed->directory)) || + !rebase(candidate->package_root, base, candidate->assets, + installed->assets, sizeof(installed->assets)) || + !rebase(candidate->package_root, base, candidate->config, + installed->config, sizeof(installed->config))) return false; + installed->format = candidate->format; + return true; +} diff --git a/src/runtime/model/import/mver/model_import_mver_copy.h b/src/runtime/model/import/mver/model_import_mver_copy.h new file mode 100644 index 00000000..f8a50ef0 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_copy.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_COPY_H +#define BONGO_CAT_MODEL_IMPORT_MVER_COPY_H + +#include "model_import_mver.h" + +bool bongo_cat_import_mver_copy_package( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error); + +#endif diff --git a/src/runtime/model_import_format.c b/src/runtime/model/import/mver/model_import_mver_discovery.c similarity index 62% rename from src/runtime/model_import_format.c rename to src/runtime/model/import/mver/model_import_mver_discovery.c index a2d5dbeb..74c62300 100644 --- a/src/runtime/model_import_format.c +++ b/src/runtime/model/import/mver/model_import_mver_discovery.c @@ -1,4 +1,7 @@ #include "model_import.h" +#include "model_import_mver_manifest.h" +#include "model_import_mver.h" +#include "model_import_path.h" #include "runtime.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -21,32 +24,39 @@ static bool numbered_asset(const char *directory, const char *group) { } static bool mver_shape(const char *image_root) { - char mode[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(mode, sizeof(mode), image_root, "standard") && - numbered_asset(mode, "hand")) return true; - const char *names[] = {"keyboard", "gamepad"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) - if (bongo_cat_path_join(mode, sizeof(mode), image_root, names[i]) && - numbered_asset(mode, "lefthand") && numbered_asset(mode, "righthand")) return true; + char mode[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char setting[BONGO_CAT_PATH_CAP]; + const char *names[] = {"standard", "keyboard", "gamepad"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (!bongo_cat_path_join(mode, sizeof(mode), image_root, names[i])) + continue; + if (i == 0 ? numbered_asset(mode, "hand") : + numbered_asset(mode, "lefthand") && numbered_asset(mode, "righthand")) + return true; + /* Live2D packages may omit all optional hand sprites. */ + if (bongo_cat_path_find_suffix(mode, ".model3.json", setting, + sizeof(setting)) || + (bongo_cat_path_join(model, sizeof(model), mode, "cat_model") && + bongo_cat_path_find_suffix(model, ".model3.json", setting, + sizeof(setting)))) return true; + } return false; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); parent[length] = '\0'; - return true; +bool bongo_cat_import_mver_config_path(const char *root, + char *path, size_t capacity) { + static const char *names[] = {BONGO_CAT_SKIN_CONFIG_FILE, "config.json"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if (bongo_cat_path_join(path, capacity, root, names[i]) && + bongo_cat_path_is_file(path)) return true; + return false; } static bool package_at(const char *source, char *config, char *image_root) { - return bongo_cat_path_join(config, BONGO_CAT_PATH_CAP, source, - "config.json") && + return bongo_cat_import_mver_config_path(source, config, + BONGO_CAT_PATH_CAP) && bongo_cat_path_join(image_root, BONGO_CAT_PATH_CAP, source, "img") && - bongo_cat_path_is_file(config) && bongo_cat_path_is_dir(image_root) && - mver_shape(image_root); + bongo_cat_path_is_dir(image_root) && mver_shape(image_root); } static bool find_package(const char *source, char *package, size_t capacity, @@ -55,40 +65,52 @@ static bool find_package(const char *source, char *package, size_t capacity, for (int depth = 0; depth < 4; ++depth) { if (package_at(package, config, image_root)) return true; char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(package, parent, sizeof(parent))) break; + if (!bongo_cat_import_parent_path(package, parent, sizeof(parent))) + break; snprintf(package, capacity, "%s", parent); } return false; } -static bool mode_config_valid(yyjson_val *mode, BongoCatModelMode value, - const char *directory) { +static bool optional_matrix(yyjson_val *mode, const char *name) { + yyjson_val *value = yyjson_obj_get(mode, name); + return !value || yyjson_is_null(value) || yyjson_is_arr(value); +} + +static bool mode_config_valid(yyjson_val *mode, BongoCatModelMode value) { if (!yyjson_is_obj(mode)) return false; if (value == BONGO_CAT_MODE_STANDARD) - return yyjson_is_arr(yyjson_obj_get(mode, "hand")) && - numbered_asset(directory, "hand"); - return yyjson_is_arr(yyjson_obj_get(mode, "lefthand")) && - yyjson_is_arr(yyjson_obj_get(mode, "righthand")) && - numbered_asset(directory, "lefthand") && numbered_asset(directory, "righthand"); + return optional_matrix(mode, "hand"); + return optional_matrix(mode, "lefthand") && optional_matrix(mode, "righthand"); +} + +static bool mode_uses_live2d(yyjson_val *mode) { + yyjson_val *enabled = yyjson_obj_get(mode, "l2d"); + return !yyjson_is_bool(enabled) || yyjson_get_bool(enabled); } static bool model_at(const char *directory, char *setting, size_t capacity) { + /* Mver explicitly loads cat.model3.json. Authored packages may also keep + the original Live2D export beside it; that is not an ambiguous entry. */ + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), directory, "cat.model3.json") && + bongo_cat_path_is_file(path)) { + int written = snprintf(setting, capacity, "cat.model3.json"); + return written >= 0 && (size_t)written < capacity && + bongo_cat_import_mver_manifest_valid(directory, setting); + } return bongo_cat_path_find_unique_suffix(directory, ".model3.json", setting, capacity) == 1 && - bongo_cat_import_manifest_valid(directory, setting, NULL); + bongo_cat_import_mver_manifest_valid(directory, setting); } -static bool find_mode_model(const char *package, const char *mode_root, char *directory, +static bool find_mode_model(const char *mode_root, char *directory, size_t directory_capacity, char *setting, size_t setting_capacity) { if (bongo_cat_path_join(directory, directory_capacity, mode_root, "cat_model") && bongo_cat_path_is_dir(directory) && model_at(directory, setting, setting_capacity)) return true; snprintf(directory, directory_capacity, "%s", mode_root); - if (model_at(directory, setting, setting_capacity)) return true; - char resources[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(resources, sizeof(resources), package, "Resources") && - bongo_cat_path_join(directory, directory_capacity, resources, "cat") && - bongo_cat_path_is_dir(directory) && model_at(directory, setting, setting_capacity); + return model_at(directory, setting, setting_capacity); } static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, @@ -99,14 +121,18 @@ static bool add_mode(BongoCatImportDiscovery *discovery, const char *source, if (!bongo_cat_path_join(mode_root, sizeof(mode_root), image_root, name)) return false; yyjson_val *mode_config = yyjson_obj_get(root, name); if (!bongo_cat_path_is_dir(mode_root) || !yyjson_is_obj(mode_config)) return true; - if (!mode_config_valid(mode_config, mode, mode_root)) { + /* Mver distributions bundle fallback Live2D files for modes that are + configured to use static sprites. They are runtime templates, not + authored model variants, so they must not become import candidates. */ + if (!mode_uses_live2d(mode_config)) return true; + if (!mode_config_valid(mode_config, mode)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Mver mode has invalid input mappings or numbered assets: %s", mode_root); + "Mver mode has invalid input mappings: %s", mode_root); return false; } if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count]; - if (!find_mode_model(source, mode_root, candidate->directory, sizeof(candidate->directory), + if (!find_mode_model(mode_root, candidate->directory, sizeof(candidate->directory), candidate->setting, sizeof(candidate->setting))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Mver mode contains no valid Live2D model: %s", mode_root); @@ -159,6 +185,8 @@ int bongo_cat_import_mver_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { char config[BONGO_CAT_PATH_CAP], image_root[BONGO_CAT_PATH_CAP]; if (!package_at(source, config, image_root)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); return discover_package(source, config, image_root, discovery, error); } @@ -167,5 +195,7 @@ int bongo_cat_import_mver_discover(const char *source, char package[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; char image_root[BONGO_CAT_PATH_CAP]; if (!find_package(source, package, sizeof(package), config, image_root)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(package)); return discover_package(package, config, image_root, discovery, error); } diff --git a/src/runtime/model_import_mver_effect.c b/src/runtime/model/import/mver/model_import_mver_effect.c similarity index 92% rename from src/runtime/model_import_mver_effect.c rename to src/runtime/model/import/mver/model_import_mver_effect.c index 344259c3..c73dbe4e 100644 --- a/src/runtime/model_import_mver_effect.c +++ b/src/runtime/model/import/mver/model_import_mver_effect.c @@ -22,7 +22,8 @@ static bool clear_binding(yyjson_mut_doc *output, yyjson_mut_val *items, yyjson_val *root, const BongoCatImportCandidate *candidate) { yyjson_val *decoration = yyjson_obj_get(root, "decoration"); yyjson_val *row = yyjson_obj_get(decoration, "emoticonClear"); - if (!row) return true; + if (!row || yyjson_is_null(row) || + (yyjson_is_arr(row) && !yyjson_arr_size(row))) return true; char shortcut[BONGO_CAT_SHORTCUT_CAP]; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); @@ -50,12 +51,12 @@ bool bongo_cat_mver_effects(void *raw_output, void *raw_items, void *raw_root, yyjson_arr_foreach(rows, index, count, row) { char shortcut[BONGO_CAT_SHORTCUT_CAP], name[32]; char source[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; snprintf(name, sizeof(name), "%zu.png", index); + if (!effect_source(candidate, name, source, sizeof(source)) || + !bongo_cat_image_info(source, NULL, NULL)) continue; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - if (!effect_source(candidate, name, source, sizeof(source))) continue; - BongoCatImage image; - if (bongo_cat_image_load(source, &image, NULL) != BONGO_CAT_OK) return false; - bongo_cat_image_free(&image); if (!bongo_cat_path_join(destination, sizeof(destination), target_effects, name) || !bongo_cat_path_copy_file(source, destination)) return false; yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); diff --git a/src/runtime/model_import_mver_image.c b/src/runtime/model/import/mver/model_import_mver_image.c similarity index 75% rename from src/runtime/model_import_mver_image.c rename to src/runtime/model/import/mver/model_import_mver_image.c index a358de1b..41284b93 100644 --- a/src/runtime/model_import_mver_image.c +++ b/src/runtime/model/import/mver/model_import_mver_image.c @@ -4,6 +4,8 @@ #include "bongo_cat/path.h" #include +#include +#include #include #include #include @@ -25,13 +27,20 @@ static bool compose(const char *base_path, const char *hand_path, bongo_cat_image_free(&base); return false; } int width = SDL_max(base.width, hand.width), height = SDL_max(base.height, hand.height); - size_t bytes = (size_t)width * (size_t)height * 4; - unsigned char *pixels = width > 0 && height > 0 ? malloc(bytes) : NULL; - bool allocated = pixels != NULL; - if (pixels) memset(pixels, 0, bytes); - if (pixels) for (int y = 0; y < base.height; ++y) - memcpy(pixels + (size_t)y * width * 4, - base.pixels + (size_t)y * base.width * 4, (size_t)base.width * 4); + bool dimensions_valid = width > 0 && height > 0 && width <= INT_MAX / 4 && + (size_t)width <= SIZE_MAX / (size_t)height / 4; + size_t bytes = dimensions_valid ? (size_t)width * (size_t)height * 4 : 0; + bool in_place = dimensions_valid && width == base.width && + height == base.height; + unsigned char *pixels = in_place ? base.pixels : + (dimensions_valid ? malloc(bytes) : NULL); + if (pixels && !in_place) { + memset(pixels, 0, bytes); + for (int y = 0; y < base.height; ++y) + memcpy(pixels + (size_t)y * width * 4, + base.pixels + (size_t)y * base.width * 4, + (size_t)base.width * 4); + } if (pixels) for (int y = 0; y < hand.height; ++y) for (int x = 0; x < hand.width; ++x) { unsigned char *dst = pixels + ((size_t)y * width + x) * 4; const unsigned char *src = hand.pixels + ((size_t)y * hand.width + x) * 4; @@ -47,11 +56,13 @@ static bool compose(const char *base_path, const char *hand_path, } FILE *file = pixels ? bongo_cat_file_open(target, "wb") : NULL; PngWriter writer = {file, file != NULL}; + bool buffer_ready = pixels != NULL; bool ok = file && stbi_write_png_to_func(write_png, &writer, width, height, 4, pixels, width * 4) && writer.ok; if (file && fclose(file) != 0) ok = false; - free(pixels); bongo_cat_image_free(&hand); bongo_cat_image_free(&base); - if (!ok) bongo_cat_error_set(error, allocated ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_MEMORY, + if (!in_place) free(pixels); + bongo_cat_image_free(&hand); bongo_cat_image_free(&base); + if (!ok) bongo_cat_error_set(error, buffer_ready ? BONGO_CAT_ERROR_IO : BONGO_CAT_ERROR_MEMORY, "Cannot compose Mver input image: %s", target); return ok; } @@ -66,10 +77,8 @@ bool bongo_cat_mver_emit_pair(const char *hand, const char *keyboard, bool ok; if (keyboard) ok = compose(keyboard, hand, first, error); else { - BongoCatImage image; - ok = bongo_cat_image_load(hand, &image, error) == BONGO_CAT_OK; - if (ok) { bongo_cat_image_free(&image); - ok = bongo_cat_path_copy_file(hand, first); } + ok = bongo_cat_image_info(hand, NULL, NULL) && + bongo_cat_path_copy_file(hand, first); } if (!ok && error && !error->message[0]) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, diff --git a/src/runtime/model_import_mver_internal.h b/src/runtime/model/import/mver/model_import_mver_internal.h similarity index 83% rename from src/runtime/model_import_mver_internal.h rename to src/runtime/model/import/mver/model_import_mver_internal.h index 83cbb377..8c3b7597 100644 --- a/src/runtime/model_import_mver_internal.h +++ b/src/runtime/model/import/mver/model_import_mver_internal.h @@ -1,7 +1,7 @@ #ifndef BONGO_CAT_MODEL_IMPORT_MVER_INTERNAL_H #define BONGO_CAT_MODEL_IMPORT_MVER_INTERNAL_H -#include "model_import.h" +#include "model_import_mver.h" typedef struct BongoCatMverKeyNames { const char *items[2]; @@ -28,6 +28,10 @@ BongoCatMverKeyNames bongo_cat_mver_gamepad_names(int code); int bongo_cat_mver_modifier_index(int code); bool bongo_cat_mver_chord(const BongoCatImportCandidate *candidate, void *row, char *output, size_t capacity); +bool bongo_cat_mver_sound_chord(void *row, char *output, size_t capacity); +bool bongo_cat_mver_add_audio(void *output, void *items, void *config, void *rows, + const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, + const char *target); bool bongo_cat_mver_effects(void *output, void *items, void *root, void *mode, const BongoCatImportCandidate *candidate, const char *target); bool bongo_cat_mver_add_behaviors(void *output, void *items, void *mode, @@ -37,5 +41,4 @@ bool bongo_cat_mver_labels_load(const char *path, const char *mode, BongoCatMverLabels *labels); const char *bongo_cat_mver_label(const BongoCatMverLabels *labels, const char *field, size_t index); - #endif diff --git a/src/runtime/model_import_mver_labels.c b/src/runtime/model/import/mver/model_import_mver_labels.c similarity index 99% rename from src/runtime/model_import_mver_labels.c rename to src/runtime/model/import/mver/model_import_mver_labels.c index afca5a36..eac88ce3 100644 --- a/src/runtime/model_import_mver_labels.c +++ b/src/runtime/model/import/mver/model_import_mver_labels.c @@ -142,7 +142,7 @@ static void collect_field(BongoCatMverLabels *labels, TextSpan mode, TextSpan row = {cursor, after}; char label[BONGO_CAT_ID_CAP], normalized[BONGO_CAT_ID_CAP]; if (line_label(row, label, sizeof(label)) && - bongo_cat_utf8_normalize_legacy(label, normalized, + bongo_cat_utf8_normalize_mver(label, normalized, sizeof(normalized)) && labels->count < BONGO_CAT_BEHAVIOR_CAP) { BongoCatMverLabelEntry *entry = &labels->entries[labels->count++]; diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.c b/src/runtime/model/import/mver/model_import_mver_manifest.c new file mode 100644 index 00000000..f7561a31 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_manifest.c @@ -0,0 +1,19 @@ +#include "model_import_mver_manifest.h" +#include "../model_import_manifest.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, + bool *repaired) { + return bongo_cat_model_json_read(path, repaired); +} + +bool bongo_cat_import_mver_manifest_valid(const char *root, + const char *setting) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, NULL); + bool valid = bongo_cat_import_manifest_document_valid(root, document, true); + yyjson_doc_free(document); + return valid; +} diff --git a/src/runtime/model/import/mver/model_import_mver_manifest.h b/src/runtime/model/import/mver/model_import_mver_manifest.h new file mode 100644 index 00000000..18dfb1f7 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_manifest.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_MANIFEST_H +#define BONGO_CAT_MODEL_IMPORT_MVER_MANIFEST_H + +#include "bongo_cat/common.h" +#include + +/* Compatibility is applied when reading, without modifying model files. */ +yyjson_doc *bongo_cat_import_mver_manifest_read(const char *path, + bool *repaired); +bool bongo_cat_import_mver_manifest_valid(const char *root, + const char *setting); + +#endif diff --git a/src/runtime/model_import_mver_metadata.c b/src/runtime/model/import/mver/model_import_mver_metadata.c similarity index 64% rename from src/runtime/model_import_mver_metadata.c rename to src/runtime/model/import/mver/model_import_mver_metadata.c index 94c4e1b1..88cc74a5 100644 --- a/src/runtime/model_import_mver_metadata.c +++ b/src/runtime/model/import/mver/model_import_mver_metadata.c @@ -2,6 +2,7 @@ #include "model_import_mver_internal.h" #include "runtime.h" #include "bongo_cat/file.h" +#include "bongo_cat/image.h" #include "bongo_cat/json.h" #include "bongo_cat/path.h" @@ -9,12 +10,33 @@ #include #include -#define MVER_METADATA ".bongo-cat-mver.json" +static const char *format_name(BongoCatImportFormat format) { + if (format == BONGO_CAT_IMPORT_MVER) return "bongo-cat-mver"; + if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "bongo-cat-mver-patch"; + return "tauri-live2d"; +} static double number_or(yyjson_val *value, double fallback) { return yyjson_is_num(value) ? yyjson_get_num(value) : fallback; } +static bool usable_pointer_image(const char *path) { + int width = 0, height = 0; + return bongo_cat_path_is_file(path) && + bongo_cat_image_info(path, &width, &height) && + (width > 1 || height > 1); +} + +static bool pointer_asset(const BongoCatImportCandidate *candidate, + const char *name) { + char path[BONGO_CAT_PATH_CAP]; + if (candidate->overrides[0] && bongo_cat_path_join(path, sizeof(path), + candidate->overrides, name) && bongo_cat_path_is_file(path)) + return usable_pointer_image(path); + return bongo_cat_path_join(path, sizeof(path), candidate->assets, name) && + usable_pointer_image(path); +} + static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, yyjson_val *config, const BongoCatImportCandidate *candidate) { if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; @@ -39,12 +61,15 @@ static bool add_standard_pointer(yyjson_mut_doc *output, yyjson_mut_val *root, const char *right = mouse ? "resources/mver-pointer/mouse_right.png" : "resources/mver-pointer/tablet_right.png"; const char *side = mouse ? "resources/mver-pointer/mouse_side.png" : ""; + /* Mver 0.1.6's l2d switch replaces the sprite renderer. The older + standalone mode 98 instead draws a sprite pointer over Live2D. */ + bool sprite_pointer = !yyjson_is_true(live2d_value) || + yyjson_get_int(yyjson_obj_get(config, "mode")) == 98; + bool enabled = sprite_pointer && pointer_asset(candidate, "arm.png") && + pointer_asset(candidate, mouse ? "mouse.png" : "tablet.png"); yyjson_mut_val *pointer = yyjson_mut_obj_add_obj(output, root, "standardPointer"); return pointer && - /* Mver draws the pointer layer after the Live2D model in both standard - and Live2D-standard modes. Keep it enabled so the authored hand, - device, and button overlays continue to follow the pointer. */ - yyjson_mut_obj_add_bool(output, pointer, "enabled", true) && + yyjson_mut_obj_add_bool(output, pointer, "enabled", enabled) && yyjson_mut_obj_add_bool(output, pointer, "mouse", mouse) && yyjson_mut_obj_add_bool(output, pointer, "leftHanded", yyjson_is_bool(left_handed) && yyjson_get_bool(left_handed)) && @@ -104,6 +129,8 @@ static bool add_render_profile(yyjson_mut_doc *output, yyjson_mut_val *root, yyjson_mut_val *render = yyjson_mut_obj_add_obj(output, root, "render"); return render && yyjson_mut_obj_add_str(output, render, "profile", "mver-0.1.6") && + yyjson_mut_obj_add_bool(output, render, "autoFrame", + yyjson_get_bool(yyjson_obj_get(decoration, "l2d_auto_frame"))) && yyjson_mut_obj_add_real(output, render, "projectionScale", scale) && yyjson_mut_obj_add_real(output, render, "offsetX", offset_x) && yyjson_mut_obj_add_real(output, render, "offsetY", offset_y) && @@ -121,97 +148,46 @@ static bool add_render_profile(yyjson_mut_doc *output, yyjson_mut_val *root, add_standard_pointer(output, root, config, candidate); } -static bool sound_source(const BongoCatImportCandidate *candidate, size_t index, - char *source, size_t capacity, char *relative, size_t relative_capacity) { - static const char *extensions[] = {"wav", "ogg", "flac"}; - for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); ++i) { - char name[40], sounds[BONGO_CAT_PATH_CAP]; - snprintf(name, sizeof(name), "%zu.%s", index, extensions[i]); - if (!bongo_cat_path_join(sounds, sizeof(sounds), candidate->assets, "sounds") || - !bongo_cat_path_join(source, capacity, sounds, name) || - !bongo_cat_path_is_file(source)) continue; - snprintf(relative, relative_capacity, "resources/sounds/%s", name); - return true; - } - return false; -} - -static bool add_sound_clear(yyjson_mut_doc *output, yyjson_mut_val *items, - yyjson_val *config, const BongoCatImportCandidate *candidate) { - yyjson_val *decoration = yyjson_obj_get(config, "decoration"); - yyjson_val *row = yyjson_obj_get(decoration, "soundClear"); - if (!row) return true; - char shortcut[BONGO_CAT_SHORTCUT_CAP]; - if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); - return item && yyjson_mut_obj_add_str(output, item, "kind", "sound-clear") && - yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut); -} - -static bool add_sounds(yyjson_mut_doc *output, yyjson_mut_val *items, - yyjson_val *config, yyjson_val *rows, - const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, - const char *target) { - if (!rows) return true; - if (!yyjson_is_arr(rows)) return false; - char target_resources[BONGO_CAT_PATH_CAP], target_sounds[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources") || - !bongo_cat_path_join(target_sounds, sizeof(target_sounds), target_resources, "sounds") || - !bongo_cat_path_create_directory(target_sounds)) return false; - yyjson_val *decoration = yyjson_obj_get(config, "decoration"); - yyjson_val *keep_value = yyjson_obj_get(decoration, "soundKeep"); - bool keep = !keep_value || yyjson_get_bool(keep_value); - size_t emitted = 0, index, count; yyjson_val *row; - yyjson_arr_foreach(rows, index, count, row) { - char shortcut[BONGO_CAT_SHORTCUT_CAP], source[BONGO_CAT_PATH_CAP]; - char relative[BONGO_CAT_PATH_CAP], destination[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) return false; - if (!sound_source(candidate, index, source, sizeof(source), relative, - sizeof(relative))) continue; - const char *name = bongo_cat_path_name(source); - if (!bongo_cat_path_join(destination, sizeof(destination), target_sounds, name) || - !bongo_cat_path_copy_file(source, destination)) return false; - yyjson_mut_val *item = yyjson_mut_arr_add_obj(output, items); - const char *label = bongo_cat_mver_label(labels, "sounds", index); - if (!item || !yyjson_mut_obj_add_str(output, item, "kind", "sound") || - !yyjson_mut_obj_add_strcpy(output, item, "shortcut", shortcut) || - !yyjson_mut_obj_add_strcpy(output, item, "sound", relative) || - (label && !yyjson_mut_obj_add_strcpy(output, item, "label", label)) || - (!keep && !yyjson_mut_obj_add_bool(output, item, "momentary", true))) return false; - emitted++; - } - return !emitted || !keep || add_sound_clear(output, items, config, candidate); +static bool add_native_render(yyjson_mut_doc *output, yyjson_mut_val *root) { + yyjson_mut_val *render = yyjson_mut_obj_add_obj(output, root, "render"); + return render && yyjson_mut_obj_add_str(output, render, "profile", "native"); } -bool bongo_cat_import_mver_metadata(const BongoCatImportCandidate *candidate, +bool bongo_cat_import_adapter_metadata(const BongoCatImportCandidate *candidate, const char *target, BongoCatError *error) { - if (candidate->format != BONGO_CAT_IMPORT_MVER && - candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) return true; - yyjson_doc *source = bongo_cat_json_read_file(candidate->config, - YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL); + bool mver = candidate->format != BONGO_CAT_IMPORT_TAURI; + yyjson_doc *source = mver ? bongo_cat_json_read_file(candidate->config, + YYJSON_READ_JSON5 | YYJSON_READ_ALLOW_INVALID_UNICODE, NULL) : NULL; yyjson_val *config = source ? yyjson_doc_get_root(source) : NULL; yyjson_val *mode = yyjson_obj_get(config, bongo_cat_mode_name(candidate->mode)); BongoCatMverLabels labels = {0}; - bongo_cat_mver_labels_load(candidate->config, + if (mver) bongo_cat_mver_labels_load(candidate->config, bongo_cat_mode_name(candidate->mode), &labels); yyjson_mut_doc *output = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = output ? yyjson_mut_obj(output) : NULL; yyjson_mut_val *items = root ? yyjson_mut_obj_add_arr(output, root, "bindings") : NULL; if (output) yyjson_mut_doc_set_root(output, root); - bool ok = yyjson_is_obj(mode) && items && - yyjson_mut_obj_add_int(output, root, "version", 1) && + bool ok = items && + yyjson_mut_obj_add_int(output, root, "schemaVersion", + BONGO_CAT_MODEL_ADAPTER_SCHEMA) && + yyjson_mut_obj_add_str(output, root, "kind", "bongo-cat-runtime-adapter") && + yyjson_mut_obj_add_strcpy(output, root, "sourceFormat", + format_name(candidate->format)); + if (ok && mver) ok = yyjson_is_obj(mode) && add_render_profile(output, root, config, candidate) && bongo_cat_mver_add_behaviors(output, items, mode, candidate, &labels, error) && - add_sounds(output, items, config, yyjson_obj_get(mode, "sounds"), + bongo_cat_mver_add_audio(output, items, config, yyjson_obj_get(mode, "sounds"), candidate, &labels, target) && bongo_cat_mver_effects(output, items, config, mode, candidate, target); + else if (ok) ok = add_native_render(output, root); char path[BONGO_CAT_PATH_CAP]; - if (ok) ok = bongo_cat_path_join(path, sizeof(path), target, MVER_METADATA) && + if (ok) ok = bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_MODEL_ADAPTER_FILE) && bongo_cat_json_write_file(path, output, YYJSON_WRITE_PRETTY, NULL); yyjson_mut_doc_free(output); yyjson_doc_free(source); if (!ok && error && !error->message[0]) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Cannot convert Mver behavior metadata: %s", candidate->config); + "Cannot create runtime adapter metadata: %s", candidate->config); return ok; } diff --git a/src/runtime/model_import_mver_motion.c b/src/runtime/model/import/mver/model_import_mver_motion.c similarity index 80% rename from src/runtime/model_import_mver_motion.c rename to src/runtime/model/import/mver/model_import_mver_motion.c index 1f705094..4b86a24b 100644 --- a/src/runtime/model_import_mver_motion.c +++ b/src/runtime/model/import/mver/model_import_mver_motion.c @@ -1,4 +1,5 @@ #include "model_import_mver_internal.h" +#include "model_import_mver_manifest.h" #include "bongo_cat/file.h" #include "bongo_cat/path.h" @@ -7,20 +8,27 @@ static bool add_rows(yyjson_mut_doc *output, yyjson_mut_val *items, yyjson_val *rows, const BongoCatImportCandidate *candidate, - const char *kind, const char *field, const char *group, size_t available, + const char *kind, const char *field, const char *group, yyjson_val *available, const BongoCatMverLabels *labels, BongoCatError *error) { - if (!rows) return true; + if (!rows || yyjson_is_null(rows)) return true; if (!yyjson_is_arr(rows)) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, "Mver %s bindings exceed the matching Live2D manifest entries", kind); return false; } size_t limit = yyjson_arr_size(rows); - if (limit > available) limit = available; + if (limit > yyjson_arr_size(available)) limit = yyjson_arr_size(available); size_t index, count; yyjson_val *row; yyjson_arr_foreach(rows, index, count, row) { if (index >= limit) break; + if (yyjson_is_null(row) || (yyjson_is_arr(row) && !yyjson_arr_size(row))) + continue; + const char *file = yyjson_get_str(yyjson_obj_get( + yyjson_arr_get(available, index), "File")); + char path[BONGO_CAT_PATH_CAP]; + if (!file || !bongo_cat_path_join(path, sizeof(path), candidate->directory, + file) || !bongo_cat_path_is_file(path)) continue; char shortcut[BONGO_CAT_SHORTCUT_CAP]; if (!bongo_cat_mver_chord(candidate, row, shortcut, sizeof(shortcut))) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, @@ -43,9 +51,7 @@ static yyjson_doc *manifest(const BongoCatImportCandidate *candidate, char path[BONGO_CAT_PATH_CAP]; if (!bongo_cat_path_join(path, sizeof(path), candidate->directory, candidate->setting)) return NULL; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, NULL); *references = document ? yyjson_obj_get(yyjson_doc_get_root(document), "FileReferences") : NULL; return document; @@ -56,15 +62,9 @@ static bool add_motion_group(yyjson_mut_doc *output, yyjson_mut_val *items, const char *group, const BongoCatImportCandidate *candidate, const BongoCatMverLabels *labels, BongoCatError *error) { yyjson_val *available = yyjson_obj_get(motions, group); - if (yyjson_is_arr(configured) && yyjson_arr_size(configured) > 0 && - !yyjson_is_arr(available)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Mver motion bindings reference a missing Live2D motion group: %s", group); - return false; - } - size_t count = yyjson_is_arr(available) ? yyjson_arr_size(available) : 0; + if (yyjson_is_arr(configured) && !yyjson_is_arr(available)) return true; return add_rows(output, items, configured, candidate, "motion", field, - group, count, labels, error); + group, available, labels, error); } bool bongo_cat_mver_add_behaviors(void *raw_output, void *raw_items, @@ -79,8 +79,7 @@ bool bongo_cat_mver_add_behaviors(void *raw_output, void *raw_items, bool ok = document && add_rows(output, items, yyjson_obj_get(mode, "l2d_expression"), candidate, "expression", "l2d_expression", NULL, - yyjson_is_arr(expressions) ? yyjson_arr_size(expressions) : 0, - labels, error) && + expressions, labels, error) && add_motion_group(output, items, yyjson_obj_get(mode, "l2d_motion"), motions, "l2d_motion", "CAT_motion", candidate, labels, error) && add_motion_group(output, items, diff --git a/src/runtime/model_import_mver_patch.c b/src/runtime/model/import/mver/model_import_mver_patch.c similarity index 85% rename from src/runtime/model_import_mver_patch.c rename to src/runtime/model/import/mver/model_import_mver_patch.c index de11eb2f..9e1c54cd 100644 --- a/src/runtime/model_import_mver_patch.c +++ b/src/runtime/model/import/mver/model_import_mver_patch.c @@ -1,4 +1,6 @@ #include "model_import.h" +#include "model_import_mver.h" +#include "../model_import_path.h" #include "runtime.h" #include "bongo_cat/path.h" @@ -48,21 +50,11 @@ static BongoCatPathVisit find_image_root(void *userdata, return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; } -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); parent[length] = '\0'; - return true; -} - static bool full_package(const char *path) { char config[BONGO_CAT_PATH_CAP], image[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(config, sizeof(config), path, "config.json") && + return bongo_cat_import_mver_config_path(path, config, sizeof(config)) && bongo_cat_path_join(image, sizeof(image), path, "img") && - bongo_cat_path_is_file(config) && bongo_cat_path_is_dir(image) && + bongo_cat_path_is_dir(image) && patch_shape(image); } @@ -73,7 +65,8 @@ static int find_base(const char *source, const char *model_name, snprintf(current, sizeof(current), "%s", source); for (int depth = 0; depth < 6; ++depth) { char parent[BONGO_CAT_PATH_CAP], candidate[BONGO_CAT_PATH_CAP]; - if (!parent_path(current, parent, sizeof(parent))) break; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + break; if (bongo_cat_path_join(candidate, sizeof(candidate), parent, model_name) && strcmp(candidate, source) != 0 && full_package(candidate)) { if (!matches) snprintf(base, capacity, "%s", candidate); @@ -87,7 +80,8 @@ static int find_base(const char *source, const char *model_name, static int finish_patch(const char *source, const char *image_root, BongoCatImportDiscovery *discovery, BongoCatError *error) { char model_root[BONGO_CAT_PATH_CAP], base[BONGO_CAT_PATH_CAP]; - if (!parent_path(image_root, model_root, sizeof(model_root))) return -1; + if (!bongo_cat_import_parent_path(image_root, model_root, + sizeof(model_root))) return -1; int bases = find_base(source, bongo_cat_path_name(model_root), base, sizeof(base)); if (bases != 1) { bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, @@ -97,13 +91,17 @@ static int finish_patch(const char *source, const char *image_root, } int result = bongo_cat_import_mver_discover(base, discovery, error); if (result <= 0) return result < 0 ? -1 : 0; + const char *source_name = bongo_cat_path_name(source); + if (source_name && source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", source_name); for (size_t i = 0; i < discovery->count; ++i) { BongoCatImportCandidate *candidate = &discovery->candidates[i]; char mode[BONGO_CAT_PATH_CAP]; if (bongo_cat_path_join(mode, sizeof(mode), image_root, bongo_cat_mode_name(candidate->mode)) && bongo_cat_path_is_dir(mode)) snprintf(candidate->overrides, sizeof(candidate->overrides), "%s", mode); - snprintf(candidate->patch_root, sizeof(candidate->patch_root), "%s", source); + snprintf(candidate->patch_root, sizeof(candidate->patch_root), "%s", + source); candidate->format = BONGO_CAT_IMPORT_MVER_PATCH; } return 1; @@ -113,7 +111,8 @@ int bongo_cat_import_mver_patch_discover_exact(const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error) { char root[BONGO_CAT_PATH_CAP], image[BONGO_CAT_PATH_CAP]; if (strcmp(bongo_cat_path_name(source), "img") == 0 && patch_shape(source)) { - if (!parent_path(source, root, sizeof(root))) return 0; + if (!bongo_cat_import_parent_path(source, root, sizeof(root))) + return 0; return finish_patch(root, source, discovery, error); } if (!bongo_cat_path_join(image, sizeof(image), source, "img") || diff --git a/src/runtime/model/import/mver/model_import_mver_policy.c b/src/runtime/model/import/mver/model_import_mver_policy.c new file mode 100644 index 00000000..5401454d --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_policy.c @@ -0,0 +1,138 @@ +#include "model_import_mver_policy.h" +#include "model_import_mver_manifest.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +typedef struct StockLive2DProfile { + BongoCatModelMode mode; + const char *moc; + const char *textures[3]; + size_t texture_count; +} StockLive2DProfile; + +bool bongo_cat_import_patch_base_inspect( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatError *error) { + if (!candidate || candidate->format != BONGO_CAT_IMPORT_MVER_PATCH) + return false; + BongoCatImportCandidate base = *candidate; + base.format = BONGO_CAT_IMPORT_MVER; + base.patch_root[0] = '\0'; + base.overrides[0] = '\0'; + return bongo_cat_import_candidate_inspect(&base, output, placeholder, error); +} + +bool bongo_cat_import_patch_has_full_base( + const BongoCatImportDiscovery *discovery, size_t candidate_index) { + if (!discovery || candidate_index >= discovery->count) return false; + const BongoCatImportCandidate *patch = + &discovery->candidates[candidate_index]; + if (patch->format != BONGO_CAT_IMPORT_MVER_PATCH) return false; + for (size_t i = 0; i < discovery->count; ++i) { + const BongoCatImportCandidate *base = &discovery->candidates[i]; + if (base->format == BONGO_CAT_IMPORT_MVER && + base->mode == patch->mode && + strcmp(base->directory, patch->directory) == 0 && + strcmp(base->setting, patch->setting) == 0) return true; + } + return false; +} + +static bool safe_model_reference(const char *value) { + if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || + strchr(value, ':')) return false; + const char *part = value; + while (*part) { + while (*part == '/' || *part == '\\') part++; + if (part[0] == '.' && part[1] == '.' && + (!part[2] || part[2] == '/' || part[2] == '\\')) return false; + part = strpbrk(part, "/\\"); + if (!part) break; + } + return true; +} + +static bool reference_digest(const char *root, const char *relative, + BongoCatImportDigestCache *cache, char digest[65]) { + char path[BONGO_CAT_PATH_CAP]; + uint64_t size = 0, modified = 0; + return safe_model_reference(relative) && + bongo_cat_path_join(path, sizeof(path), root, relative) && + bongo_cat_path_file_info(path, &size, &modified) && + bongo_cat_import_digest_file_cached(cache, path, size, modified, digest); +} + +static bool profile_matches(const StockLive2DProfile *profile, + const BongoCatImportCandidate *candidate, yyjson_val *refs, + BongoCatImportDigestCache *cache) { + if (profile->mode != candidate->mode) return false; + const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); + yyjson_val *textures = yyjson_obj_get(refs, "Textures"); + if (!yyjson_is_arr(textures) || + yyjson_arr_size(textures) != profile->texture_count) return false; + char digest[65]; + if (!reference_digest(candidate->directory, moc, cache, digest) || + strcmp(digest, profile->moc) != 0) return false; + for (size_t i = 0; i < profile->texture_count; ++i) { + const char *texture = yyjson_get_str(yyjson_arr_get(textures, i)); + if (!reference_digest(candidate->directory, texture, cache, digest) || + strcmp(digest, profile->textures[i]) != 0) return false; + } + return true; +} + +bool bongo_cat_import_mver_stock_model( + const BongoCatImportCandidate *candidate, + BongoCatImportDigestCache *cache) { + if (!candidate) return false; + /* Mver skins commonly retain these runtime models while replacing only + mode input images. Match the model's rendered core independently from + those surrounding images so the bundled model cannot become a variant. */ + static const StockLive2DProfile profiles[] = { + {BONGO_CAT_MODE_STANDARD, + "7bbcdb3df4fe085b0cbd9dc3a1cf32d351bd56787d0ddd1c238e50a5dcb6729a", + {"f20955f2779f52dddefae131e6c7c5506ce4261d60cf020d594e095e5a2193b2", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3}, + {BONGO_CAT_MODE_KEYBOARD, + "03ed67f3ee2ea612aba4da0d42874f8879853d69043c9aae98af440d1f66965e", + {"3153891ec9c6b89ce9b33a610fb081d276ff356c5e19407d5ff0690d7ac7d97a", + "3332efb5349cdf54ea70f1ba491791d3690d48876d8abea19cb7c3f3ef383d93", + "681fcc7634d723cb10cfe4efa4eccb16c2b90d51647fff2f9904aaa298f030c5"}, 3}, + {BONGO_CAT_MODE_GAMEPAD, + "e7f11d627011bb2c65d8b0882ce4545115d2256672dca256b674a713e3e5f3d6", + {"9e593239d867b90966b0f3df81ac98aad7e74eb35cba81f3cf961dfb9bf495ea", + "3332efb5349cdf54ea70f1ba491791d3690d48876d8abea19cb7c3f3ef383d93", + "681fcc7634d723cb10cfe4efa4eccb16c2b90d51647fff2f9904aaa298f030c5"}, 3}, + {BONGO_CAT_MODE_KEYBOARD, + "03ed67f3ee2ea612aba4da0d42874f8879853d69043c9aae98af440d1f66965e", + {"0a17e9c74b0269e0d8c4291da03825fe81d20c09da520537127140fbd0900400", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3}, + {BONGO_CAT_MODE_GAMEPAD, + "e7f11d627011bb2c65d8b0882ce4545115d2256672dca256b674a713e3e5f3d6", + {"8238890702166583db122e9d4f0a3c3d1717e3188ab8bfd4a264a2a4e13a053d", + "bf32b89f328160ac8bd7cd4e527646eedfae98294506c597f381001ae6a83eb4", + "93c3213355e4743b38e8dd2e14a24693f2d24f10dc8b51c4ca986a749ee24682"}, 3} + }; + char manifest[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(manifest, sizeof(manifest), candidate->directory, + candidate->setting)) return false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(manifest, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *refs = yyjson_is_obj(root) + ? yyjson_obj_get(root, "FileReferences") : NULL; + bool matched = false; + for (size_t i = 0; yyjson_is_obj(refs) && + i < sizeof(profiles) / sizeof(profiles[0]); ++i) + if (profile_matches(&profiles[i], candidate, refs, cache)) { + matched = true; + break; + } + yyjson_doc_free(document); + return matched; +} diff --git a/src/runtime/model/import/mver/model_import_mver_policy.h b/src/runtime/model/import/mver/model_import_mver_policy.h new file mode 100644 index 00000000..8a0e5de4 --- /dev/null +++ b/src/runtime/model/import/mver/model_import_mver_policy.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H +#define BONGO_CAT_MODEL_IMPORT_MVER_POLICY_H + +#include "model_import_mver.h" + +bool bongo_cat_import_mver_stock_model( + const BongoCatImportCandidate *candidate, + BongoCatImportDigestCache *cache); +bool bongo_cat_import_patch_base_inspect( + const BongoCatImportCandidate *candidate, char output[65], + bool *placeholder, BongoCatError *error); +bool bongo_cat_import_patch_has_full_base( + const BongoCatImportDiscovery *discovery, size_t candidate_index); + +#endif diff --git a/src/runtime/model_import_mver_shortcut.c b/src/runtime/model/import/mver/model_import_mver_shortcut.c similarity index 64% rename from src/runtime/model_import_mver_shortcut.c rename to src/runtime/model/import/mver/model_import_mver_shortcut.c index bb6dcb3e..652ed1d6 100644 --- a/src/runtime/model_import_mver_shortcut.c +++ b/src/runtime/model/import/mver/model_import_mver_shortcut.c @@ -13,7 +13,11 @@ static bool append(char *output, size_t capacity, const char *value) { static const char *shortcut_key(int code, char generated[16]) { BongoCatMverKeyNames names = bongo_cat_mver_device_names(code, 0, 1); - const char *name = names.items[0] ? names.items[0] : names.generated; + const char *name = names.items[0]; + if (!name && names.generated[0]) { + snprintf(generated, 16, "%s", names.generated); + name = generated; + } if (!name || !*name || bongo_cat_mver_modifier_index(code) >= 0) return NULL; if (strncmp(name, "Key", 3) == 0 || strncmp(name, "Num", 3) == 0) { generated[0] = name[3]; generated[1] = '\0'; return generated; @@ -69,3 +73,38 @@ bool bongo_cat_mver_chord(const BongoCatImportCandidate *candidate, return candidate && candidate->gamepad_buttons && code >= 0 && code <= 15 ? gamepad_chord(row, output, capacity) : keyboard_chord(row, output, capacity); } + +/* Mver's audio uses Windows keys even when its visual mode uses a gamepad. + Preserve arbitrary chords, modifier-only keys, and mouse buttons. */ +bool bongo_cat_mver_sound_chord(void *raw, char *output, size_t capacity) { + yyjson_val *row = raw; + if (!output || !capacity || !yyjson_is_arr(row) || !yyjson_arr_size(row)) return false; + output[0] = '\0'; + size_t index, count; yyjson_val *key; + yyjson_arr_foreach(row, index, count, key) { + if (!yyjson_is_int(key) && !yyjson_is_uint(key)) return false; + int64_t raw_code = yyjson_get_sint(key); + if (raw_code <= 0 || raw_code > 255) return false; + int code = (int)raw_code; + const char *name = NULL; + if (code == 1) name = "Left"; + else if (code == 2) name = "Right"; + else if (code == 4) name = "Middle"; + else if (code == 5) name = "Back"; + else if (code == 6) name = "Forward"; + else if (code == 16) name = "Shift"; + else if (code == 17) name = "Control"; + else if (code == 18) name = "Alt"; + else if (code == 160) name = "ShiftLeft"; + else if (code == 161) name = "ShiftRight"; + else if (code == 162) name = "ControlLeft"; + else if (code == 163) name = "ControlRight"; + else if (code == 164) name = "Alt"; + else if (code == 165) name = "AltGr"; + char generated[16]; + if (!name) name = shortcut_key(code, generated); + if (!name || (index && !append(output, capacity, "+")) || + !append(output, capacity, name)) return false; + } + return true; +} diff --git a/src/runtime/model/import/nearby/model_import_nearby.c b/src/runtime/model/import/nearby/model_import_nearby.c new file mode 100644 index 00000000..1af9b318 --- /dev/null +++ b/src/runtime/model/import/nearby/model_import_nearby.c @@ -0,0 +1,232 @@ +#include "model_import.h" +#include "model_import_lock.h" +#include "model_import_probe.h" +#include "model_import_mver_policy.h" +#include "model_import_mver.h" +#include "model_import_nearby_internal.h" +#include "../model_import_path.h" +#include "../tauri/model_import_tauri.h" +#include "runtime.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define NEARBY_SCAN_BUDGET_NS 500000000ull + +static bool existing_identity(const BongoCatModelCatalog *models, + const char *identity, BongoCatModelMode mode, + const BongoCatModelEntry *ignored) { + for (size_t i = 0; models && i < models->count; ++i) { + const BongoCatModelEntry *entry = &models->entries[i]; + if (entry != ignored && entry->mode == mode && + entry->content_digest[0] && + strcmp(entry->content_digest, identity) == 0) return true; + } + return false; +} + +static BongoCatModelEntry *model_with_id(BongoCatApp *app, const char *id) { + for (size_t i = 0; app && i < app->models.count; ++i) + if (strcmp(app->models.entries[i].id, id) == 0) + return &app->models.entries[i]; + return NULL; +} + +static const char *candidate_source(const BongoCatImportCandidate *candidate, + const char *fallback) { + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->patch_root[0]) return candidate->patch_root; + return candidate->package_root[0] ? candidate->package_root : fallback; +} + +static void candidate_name(const char *source, + const BongoCatImportCandidate *candidate, char *name, size_t capacity) { + const char *value = bongo_cat_path_name(source); + char parent[BONGO_CAT_PATH_CAP]; + if (value && candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && + candidate->package_root[0] && + !strcmp(value, bongo_cat_path_name(candidate->package_root)) && + bongo_cat_import_parent_path(source, parent, sizeof(parent))) + value = bongo_cat_path_name(parent); + snprintf(name, capacity, "%s", value && value[0] ? value : "Nearby model"); +} + +static bool add_candidate(BongoCatApp *app, const char *cache_root, + const char *source, const BongoCatImportCandidate *candidate, + char first_created[BONGO_CAT_ID_CAP], BongoCatError *error) { + char source_hash[65], signature[65], identity[65]; + char id[BONGO_CAT_ID_CAP], adapter[BONGO_CAT_PATH_CAP]; + bongo_cat_sha256_bytes(source, strlen(source), source_hash); + snprintf(id, sizeof(id), "nearby-%.16s-%s", source_hash, + bongo_cat_mode_name(candidate->mode)); + BongoCatModelEntry *same_id = model_with_id(app, id); + if (same_id && !same_id->managed) return true; + if (!bongo_cat_nearby_signature(candidate, signature, error) || + !bongo_cat_path_join(adapter, sizeof(adapter), cache_root, id)) + return false; + bool placeholder = false; + bool identity_cached = bongo_cat_nearby_cached_inspection(adapter, source, + signature, identity, &placeholder); + if (!identity_cached && + !bongo_cat_import_candidate_inspect(candidate, identity, + &placeholder, error)) + return false; + if (placeholder) { + if (!identity_cached) bongo_cat_nearby_remember_inspection(adapter, + source, signature, identity, true, candidate->mode); + return true; + } + if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH) { + char base_identity[65]; + bool base_placeholder = false; + if (!bongo_cat_import_patch_base_inspect(candidate, base_identity, + &base_placeholder, error)) return false; + if (base_placeholder || existing_identity(&app->models, base_identity, + candidate->mode, same_id)) return true; + } + if (existing_identity(&app->models, identity, candidate->mode, same_id)) { + if (!identity_cached) bongo_cat_nearby_remember_inspection(adapter, + source, signature, identity, false, candidate->mode); + return true; + } + if (!same_id && app->models.count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many models while adding nearby package"); + return false; + } + bool created = false; + if (!bongo_cat_nearby_refresh_cache(candidate, cache_root, id, source, + signature, identity, false, adapter, &created, error)) return false; + if (!same_id && existing_identity(&app->models, identity, + candidate->mode, NULL)) return true; + BongoCatModelEntry *entry = same_id ? same_id : + &app->models.entries[app->models.count++]; + memset(entry, 0, sizeof(*entry)); + candidate_name(source, candidate, entry->display_name, + sizeof(entry->display_name)); + bongo_cat_import_describe_nearby_entry(entry, candidate, id, identity, + source_hash, source, adapter); + if (created && !first_created[0]) + snprintf(first_created, BONGO_CAT_ID_CAP, "%s", id); + return true; +} + +typedef struct NearbyAdd { + BongoCatApp *app; + const char *cache_root; + char *first_created; + bool best_effort; +} NearbyAdd; + +static BongoCatResult add_discovery(NearbyAdd *add, const char *fallback, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + for (size_t i = 0; i < discovery->count; ++i) { + BongoCatImportCandidate *candidate = &discovery->candidates[i]; + const char *source = candidate_source(candidate, fallback); + if (!add_candidate(add->app, add->cache_root, source, candidate, + add->first_created, error)) { + if (!add->best_effort) return error && error->code + ? error->code : BONGO_CAT_ERROR_IO; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Skipping nearby model: path=%s model=%s error=%s", source, + candidate->directory, error && error->message[0] + ? error->message : "model adaptation failed"); + if (error) *error = (BongoCatError){0}; + } + } + return BONGO_CAT_OK; +} + +static BongoCatResult add_scanned(void *userdata, const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + return add_discovery(userdata, source, discovery, error); +} + +static int discover_direct(const char *root, bool bounded, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (bounded) return bongo_cat_import_probe_exact(root, discovery, + NULL, BONGO_CAT_IMPORT_PROBE_FALLBACK, false, error); + /* An explicit nearby root uses broad Mver lookup so selecting an inner + authored folder still resolves to its canonical package root. */ + int found = bongo_cat_import_mver_discover(root, discovery, error); + if (found <= 0) { + memset(discovery, 0, sizeof(*discovery)); + if (error) *error = (BongoCatError){0}; + found = bongo_cat_import_mver_patch_discover(root, discovery, error); + } + if (found <= 0) { + memset(discovery, 0, sizeof(*discovery)); + if (error) *error = (BongoCatError){0}; + found = bongo_cat_import_tauri_discover_exact(root, + discovery, error); + } + return found; +} + +static BongoCatResult import_root_unlocked(BongoCatApp *app, const char *root, + bool bounded, BongoCatError *error) { + if (!app || !root || !app->cache_root[0]) + return BONGO_CAT_ERROR_ARGUMENT; + if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; + char cache_root[BONGO_CAT_PATH_CAP]; + char first_created[BONGO_CAT_ID_CAP] = {0}; + if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->cache_root, + BONGO_CAT_ADAPTER_CACHE_DIRECTORY) || + !bongo_cat_path_create_directory(cache_root)) + return BONGO_CAT_ERROR_IO; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + if (!discovery) return BONGO_CAT_ERROR_MEMORY; + int direct = discover_direct(root, bounded, discovery, error); + NearbyAdd add = {app, cache_root, first_created, bounded}; + BongoCatResult result = direct > 0 + ? add_discovery(&add, root, discovery, error) : BONGO_CAT_OK; + if (direct <= 0) { + if (error) *error = (BongoCatError){0}; + result = bounded + ? bongo_cat_import_scan_budget(root, add_scanned, &add, + NEARBY_SCAN_BUDGET_NS, error) + : bongo_cat_import_scan(root, add_scanned, &add, error); + } + const char *selected = app->session.active_model_id; + if (first_created[0] && (!selected[0] || !strcmp(selected, "standard") || + !strcmp(selected, "keyboard") || !strcmp(selected, "gamepad"))) + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", first_created); + free(discovery); + return result; +} + +static BongoCatResult import_root(BongoCatApp *app, const char *root, + bool bounded, BongoCatError *error) { + bongo_cat_import_storage_lock(); + BongoCatResult result = import_root_unlocked(app, root, bounded, error); + bongo_cat_import_storage_unlock(); + return result; +} + +BongoCatResult bongo_cat_import_nearby_root(BongoCatApp *app, + const char *root, BongoCatError *error) { + return import_root(app, root, false, error); +} + +BongoCatResult bongo_cat_import_nearby_scan(BongoCatApp *app, + const char *root, BongoCatError *error) { + return import_root(app, root, true, error); +} + +BongoCatResult bongo_cat_import_nearby(BongoCatApp *app, + const char *root, BongoCatError *error) { + if (!app || !root) return BONGO_CAT_ERROR_ARGUMENT; + size_t before = app->models.count; + BongoCatResult result = bongo_cat_import_nearby_root(app, root, error); + if (result != BONGO_CAT_OK || app->models.count != before) return result; + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(root, parent, sizeof(parent)) || + !strcmp(parent, root)) + return BONGO_CAT_OK; + if (error) *error = (BongoCatError){0}; + return bongo_cat_import_nearby_root(app, parent, error); +} diff --git a/src/runtime/model/import/nearby/model_import_nearby_cache.c b/src/runtime/model/import/nearby/model_import_nearby_cache.c new file mode 100644 index 00000000..b1f1d13d --- /dev/null +++ b/src/runtime/model/import/nearby/model_import_nearby_cache.c @@ -0,0 +1,184 @@ +#include "model_import_nearby_internal.h" +#include "model_storage.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define NEARBY_CACHE_KIND "bongocat/nearby-model-cache" + +typedef struct NearbyMarker { + char source[BONGO_CAT_PATH_CAP]; + char signature[65]; + char identity[65]; + bool adapter_ready; + bool placeholder; + bool valid; +} NearbyMarker; + +static bool digest_valid(const char *value) { + if (!value || strlen(value) != 64) return false; + for (size_t i = 0; i < 64; ++i) + if (!((value[i] >= '0' && value[i] <= '9') || + (value[i] >= 'a' && value[i] <= 'f'))) return false; + return true; +} + +static NearbyMarker read_marker(const char *target) { + NearbyMarker marker = {0}; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_NEARBY_CACHE_MARKER)) return marker; + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *source = yyjson_get_str(yyjson_obj_get(root, "source")); + const char *signature = yyjson_get_str(yyjson_obj_get(root, "signature")); + const char *identity = yyjson_get_str(yyjson_obj_get(root, "identity")); + yyjson_val *adapter_ready = yyjson_obj_get(root, "adapterReady"); + yyjson_val *placeholder = yyjson_obj_get(root, "placeholder"); + marker.valid = yyjson_is_obj(root) && kind && source && signature && + digest_valid(signature) && digest_valid(identity) && + yyjson_is_bool(adapter_ready) && yyjson_is_bool(placeholder) && + yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == + BONGO_CAT_NEARBY_CACHE_SCHEMA && + strcmp(kind, NEARBY_CACHE_KIND) == 0; + if (marker.valid) { + snprintf(marker.source, sizeof(marker.source), "%s", source); + snprintf(marker.signature, sizeof(marker.signature), "%s", signature); + snprintf(marker.identity, sizeof(marker.identity), "%s", identity); + marker.adapter_ready = yyjson_get_bool(adapter_ready); + marker.placeholder = yyjson_get_bool(placeholder); + } + yyjson_doc_free(document); + return marker; +} + +static bool adapter_usable(const char *target, const char *source, + const NearbyMarker *marker) { + char mode[BONGO_CAT_PATH_CAP], metadata[BONGO_CAT_PATH_CAP]; + char resources[BONGO_CAT_PATH_CAP]; + return marker && marker->valid && marker->adapter_ready && + !marker->placeholder && + strcmp(marker->source, source) == 0 && + bongo_cat_path_is_dir(target) && + bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") && + bongo_cat_path_join(metadata, sizeof(metadata), target, + BONGO_CAT_MODEL_ADAPTER_FILE) && + bongo_cat_path_join(resources, sizeof(resources), target, "resources") && + bongo_cat_path_is_file(mode) && bongo_cat_path_is_file(metadata) && + bongo_cat_path_is_dir(resources); +} + +bool bongo_cat_nearby_cached_inspection(const char *target, + const char *source, const char *signature, char identity[65], + bool *placeholder) { + NearbyMarker marker = read_marker(target); + if (!marker.valid || strcmp(marker.source, source) != 0 || + strcmp(marker.signature, signature) != 0) return false; + snprintf(identity, 65, "%s", marker.identity); + if (placeholder) *placeholder = marker.placeholder; + return true; +} + +static bool write_marker(const char *target, const char *source, + const char *signature, const char *identity, BongoCatModelMode mode, + bool placeholder, bool adapter_ready) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (document) yyjson_mut_doc_set_root(document, root); + char path[BONGO_CAT_PATH_CAP]; + bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", + BONGO_CAT_NEARBY_CACHE_SCHEMA) && + yyjson_mut_obj_add_str(document, root, "kind", NEARBY_CACHE_KIND) && + yyjson_mut_obj_add_strcpy(document, root, "source", source) && + yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && + yyjson_mut_obj_add_strcpy(document, root, "identity", identity) && + yyjson_mut_obj_add_bool(document, root, "placeholder", placeholder) && + yyjson_mut_obj_add_bool(document, root, "adapterReady", + adapter_ready) && + yyjson_mut_obj_add_strcpy(document, root, "mode", + bongo_cat_mode_name(mode)) && + bongo_cat_path_join(path, sizeof(path), target, + BONGO_CAT_NEARBY_CACHE_MARKER) && + bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(document); + return ok; +} + +void bongo_cat_nearby_remember_inspection(const char *target, + const char *source, const char *signature, const char *identity, + bool placeholder, BongoCatModelMode mode) { + if ((bongo_cat_path_is_dir(target) || + bongo_cat_path_create_directory(target)) && + write_marker(target, source, signature, identity, mode, + placeholder, false)) return; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot cache nearby model identity: %s", source); +} + +static bool use_previous_cache(const char *target, const char *source, + char identity[65], BongoCatError *error) { + NearbyMarker marker = read_marker(target); + if (!adapter_usable(target, source, &marker)) return false; + snprintf(identity, 65, "%s", marker.identity); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot refresh nearby model cache; using the previous cache: %s", + error && error->message[0] ? error->message : source); + if (error) *error = (BongoCatError){0}; + return true; +} + +bool bongo_cat_nearby_refresh_cache( + const BongoCatImportCandidate *candidate, const char *cache_root, + const char *id, const char *source, const char *signature, + char identity[65], bool placeholder, + char adapter[BONGO_CAT_PATH_CAP], bool *created, BongoCatError *error) { + char temporary[BONGO_CAT_PATH_CAP], backup[BONGO_CAT_PATH_CAP]; + char name[BONGO_CAT_ID_CAP + 8]; + snprintf(name, sizeof(name), ".%s.tmp", id); + if (!bongo_cat_path_join(adapter, BONGO_CAT_PATH_CAP, cache_root, id) || + !bongo_cat_path_join(temporary, sizeof(temporary), cache_root, name)) + return false; + snprintf(name, sizeof(name), ".%s.old", id); + if (!bongo_cat_path_join(backup, sizeof(backup), cache_root, name)) + return false; + bongo_cat_model_remove_tree(temporary, NULL); + if (!bongo_cat_path_is_dir(adapter) && bongo_cat_path_is_dir(backup)) + bongo_cat_path_rename(backup, adapter); + if (bongo_cat_path_is_dir(adapter)) + bongo_cat_model_remove_tree(backup, NULL); + *created = !bongo_cat_path_is_dir(adapter); + NearbyMarker marker = read_marker(adapter); + if (adapter_usable(adapter, source, &marker) && + strcmp(marker.signature, signature) == 0 && + strcmp(marker.identity, identity) == 0) return true; + if (!bongo_cat_path_create_directory(temporary) || + !bongo_cat_import_prepare_adapter(candidate, temporary, error) || + !write_marker(temporary, source, signature, identity, + candidate->mode, placeholder, true)) { + bongo_cat_model_remove_tree(temporary, NULL); + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot build nearby model adapter: %s", source); + return use_previous_cache(adapter, source, identity, error); + } + bool had_adapter = bongo_cat_path_is_dir(adapter); + if (had_adapter && !bongo_cat_path_rename(adapter, backup)) { + bongo_cat_model_remove_tree(temporary, NULL); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot update nearby model adapter: %s", SDL_GetError()); + return use_previous_cache(adapter, source, identity, error); + } + if (!bongo_cat_path_rename(temporary, adapter)) { + if (had_adapter) bongo_cat_path_rename(backup, adapter); + bongo_cat_model_remove_tree(temporary, NULL); + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot activate nearby model adapter: %s", SDL_GetError()); + return use_previous_cache(adapter, source, identity, error); + } + bongo_cat_model_remove_tree(backup, NULL); + return true; +} diff --git a/src/runtime/model/import/nearby/model_import_nearby_internal.h b/src/runtime/model/import/nearby/model_import_nearby_internal.h new file mode 100644 index 00000000..9b539c57 --- /dev/null +++ b/src/runtime/model/import/nearby/model_import_nearby_internal.h @@ -0,0 +1,24 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_NEARBY_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_NEARBY_INTERNAL_H + +#include "model_import.h" + +#define BONGO_CAT_NEARBY_CACHE_MARKER ".bongo-cat-cache.json" +#define BONGO_CAT_NEARBY_CACHE_SCHEMA 7 +#define BONGO_CAT_ADAPTER_CACHE_DIRECTORY "model-adapters" + +bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error); +bool bongo_cat_nearby_cached_inspection(const char *target, + const char *source, const char *signature, char identity[65], + bool *placeholder); +void bongo_cat_nearby_remember_inspection(const char *target, + const char *source, const char *signature, const char *identity, + bool placeholder, BongoCatModelMode mode); +bool bongo_cat_nearby_refresh_cache( + const BongoCatImportCandidate *candidate, const char *cache_root, + const char *id, const char *source, const char *signature, + char identity[65], bool placeholder, + char adapter[BONGO_CAT_PATH_CAP], bool *created, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/nearby/model_import_nearby_signature.c b/src/runtime/model/import/nearby/model_import_nearby_signature.c new file mode 100644 index 00000000..0853fb15 --- /dev/null +++ b/src/runtime/model/import/nearby/model_import_nearby_signature.c @@ -0,0 +1,160 @@ +#include "model_import_nearby_internal.h" +#include "bongo_cat/path.h" +#include "bongo_cat/sha256.h" + +#include +#include +#include + +#define SIGNATURE_ROOT_CAP 3 + +typedef struct SignatureStamp { + uint64_t sum, exclusive, bytes, latest, files, entries; +} SignatureStamp; + +typedef struct SignatureRoot { + char path[BONGO_CAT_PATH_CAP]; + const char *group; +} SignatureRoot; + +typedef struct SignatureWalk { + const SignatureRoot *root; + SignatureStamp *stamp; + int depth; +} SignatureWalk; + +static bool separator(char value) { return value == '/' || value == '\\'; } + +static bool character_equal(char left, char right) { + if (separator(left) && separator(right)) return true; +#ifdef _WIN32 + if (left >= 'A' && left <= 'Z') left = (char)(left - 'A' + 'a'); + if (right >= 'A' && right <= 'Z') right = (char)(right - 'A' + 'a'); +#endif + return left == right; +} + +static size_t normalized_length(const char *path) { + size_t length = path ? strlen(path) : 0; + while (length > 1 && separator(path[length - 1])) length--; + return length; +} + +static bool contains(const char *root, const char *path) { + size_t root_length = normalized_length(root); + size_t path_length = normalized_length(path); + if (!root_length || path_length < root_length) return false; + for (size_t i = 0; i < root_length; ++i) + if (!character_equal(root[i], path[i])) return false; + return path_length == root_length || separator(path[root_length]); +} + +static void add_root(SignatureRoot roots[SIGNATURE_ROOT_CAP], size_t *count, + const char *path, const char *group) { + if (!path || !path[0]) return; + for (size_t i = 0; i < *count; ++i) + if (contains(roots[i].path, path)) return; + bool merged = false; + for (size_t i = 0; i < *count;) { + if (!contains(path, roots[i].path)) { i++; continue; } + roots[i] = roots[--*count]; + merged = true; + } + if (*count >= SIGNATURE_ROOT_CAP) return; + SignatureRoot *root = &roots[(*count)++]; + snprintf(root->path, sizeof(root->path), "%s", path); + root->group = merged ? "content" : group; +} + +static uint64_t text_hash(const char *value) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)value; + cursor && *cursor; ++cursor) { + unsigned char byte = *cursor == '\\' ? '/' : *cursor; + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +static uint64_t mix(uint64_t value) { + value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static bool stamp_path(const char *path, SignatureWalk *walk); + +static BongoCatPathVisit stamp_item(void *userdata, + const char *dirname, const char *name) { + SignatureWalk *walk = userdata; + char path[BONGO_CAT_PATH_CAP]; + return ++walk->stamp->entries <= 16384 && + bongo_cat_path_join(path, sizeof(path), dirname, name) && + stamp_path(path, walk) ? BONGO_CAT_PATH_CONTINUE : + BONGO_CAT_PATH_FAILURE; +} + +static const char *relative_path(const SignatureRoot *root, + const char *path) { + if (!contains(root->path, path)) return path; + const char *relative = path + normalized_length(root->path); + while (separator(*relative)) relative++; + return relative; +} + +static bool stamp_path(const char *path, SignatureWalk *walk) { + if (bongo_cat_path_is_dir(path)) { + if (walk->depth >= 24) return false; + walk->depth++; + bool ok = bongo_cat_path_enumerate(path, stamp_item, walk); + walk->depth--; + return ok; + } + uint64_t size = 0, modified = 0; + if (!bongo_cat_path_file_info(path, &size, &modified) || + walk->stamp->files >= 8192 || + size > 1073741824ull - walk->stamp->bytes) return false; + uint64_t value = mix(text_hash(walk->root->group) ^ + text_hash(relative_path(walk->root, path)) ^ mix(size) ^ mix(modified)); + walk->stamp->sum += value; + walk->stamp->exclusive ^= value; + walk->stamp->bytes += size; + if (modified > walk->stamp->latest) walk->stamp->latest = modified; + walk->stamp->files++; + return true; +} + +bool bongo_cat_nearby_signature(const BongoCatImportCandidate *candidate, + char output[65], BongoCatError *error) { + if (!candidate || !output) return false; + char config_hash[65] = {0}; + if (candidate->config[0] && bongo_cat_sha256_file(candidate->config, + config_hash, error) != BONGO_CAT_OK) return false; + SignatureRoot roots[SIGNATURE_ROOT_CAP] = {0}; + size_t root_count = 0; + add_root(roots, &root_count, candidate->directory, "model"); + add_root(roots, &root_count, candidate->assets, "assets"); + add_root(roots, &root_count, candidate->overrides, "overrides"); + SignatureStamp stamp = {0}; + for (size_t i = 0; i < root_count; ++i) { + SignatureWalk walk = {&roots[i], &stamp, 0}; + if (stamp_path(roots[i].path, &walk)) continue; + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot inspect nearby model assets: %s", + candidate->package_root); + return false; + } + char summary[256]; + int length = snprintf(summary, sizeof(summary), + "%s|%s|%d|%016llx|%016llx|%llu|%llu|%llu", config_hash, + bongo_cat_mode_name(candidate->mode), (int)candidate->format, + (unsigned long long)stamp.sum, + (unsigned long long)stamp.exclusive, + (unsigned long long)stamp.bytes, + (unsigned long long)stamp.latest, + (unsigned long long)stamp.files); + if (length < 0 || (size_t)length >= sizeof(summary)) return false; + bongo_cat_sha256_bytes(summary, (size_t)length, output); + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri.h b/src/runtime/model/import/tauri/model_import_tauri.h new file mode 100644 index 00000000..838adbaa --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_H +#define BONGO_CAT_MODEL_IMPORT_TAURI_H + +#include "../model_import.h" + +/* Source-format adapter. Conversion output must be a canonical Mver package. */ +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +int bongo_cat_import_tauri_discover_recursive(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error); +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error); + +#endif diff --git a/src/runtime/model/import/tauri/model_import_tauri_candidate.c b/src/runtime/model/import/tauri/model_import_tauri_candidate.c new file mode 100644 index 00000000..a6fbb1ea --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_candidate.c @@ -0,0 +1,183 @@ +#include "model_import_tauri_internal.h" + +#include +#include +#include + +static bool ascii_contains_ci(const char *value, const char *needle) { + if (!value || !needle || !needle[0]) return false; + size_t length = strlen(needle); + for (const char *cursor = value; *cursor; ++cursor) + if (SDL_strncasecmp(cursor, needle, length) == 0) return true; + return false; +} + +static bool utf8_contains(const char *value, const unsigned char *needle, + size_t length) { + if (!value || !needle || !length) return false; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; + ++cursor) { + size_t remaining = strlen((const char *)cursor); + if (remaining >= length && memcmp(cursor, needle, length) == 0) + return true; + } + return false; +} + +static BongoCatModelMode import_mode(const char *path) { + const char *cursor = path; + BongoCatModelMode mode = BONGO_CAT_MODE_STANDARD; + while (cursor && *cursor) { + while (*cursor == '/' || *cursor == '\\') cursor++; + const char *end = strpbrk(cursor, "/\\"); + size_t length = end ? (size_t)(end - cursor) : strlen(cursor); + if (length == 8 && SDL_strncasecmp(cursor, "keyboard", length) == 0) + mode = BONGO_CAT_MODE_KEYBOARD; + else if (length == 7 && SDL_strncasecmp(cursor, "gamepad", length) == 0) + mode = BONGO_CAT_MODE_GAMEPAD; + else if (length == 8 && SDL_strncasecmp(cursor, "standard", length) == 0) + mode = BONGO_CAT_MODE_STANDARD; + if (!end) break; + cursor = end + 1; + } + const char *name = bongo_cat_path_name(path); + static const unsigned char keyboard_cn[] = {0xe9,0x94,0xae,0xe7,0x9b,0x98}; + static const unsigned char gamepad_cn[] = {0xe6,0x89,0x8b,0xe6,0x9f,0x84}; + static const unsigned char standard_cn[] = {0xe6,0xa0,0x87,0xe5,0x87,0x86}; + if (name && (ascii_contains_ci(name, "keyboard") || + utf8_contains(name, keyboard_cn, sizeof(keyboard_cn)))) + return BONGO_CAT_MODE_KEYBOARD; + if (name && (ascii_contains_ci(name, "gamepad") || + utf8_contains(name, gamepad_cn, sizeof(gamepad_cn)))) + return BONGO_CAT_MODE_GAMEPAD; + if (name && (ascii_contains_ci(name, "standard") || + utf8_contains(name, standard_cn, sizeof(standard_cn)))) + return BONGO_CAT_MODE_STANDARD; + return mode; +} + +static bool has_preview_assets(const char *directory) { + const char *names[] = {"resources", "cover.png", "cat.png", "bg.png", + "mousebg.png", "tabletbg.png"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, names[i])) + continue; + if (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path)) + return true; + } + return false; +} + +static bool has_cover_asset(const char *directory) { + const char *names[] = { + "resources/cover.png", "cover.png", "cat.png", "bg.png" + }; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), directory, names[i]) && + bongo_cat_path_is_file(path)) return true; + } + return false; +} + +static bool has_background_asset(const char *directory) { + const char *names[] = {"resources/background.png", "background.png", + "bg.png", "mousebg.png", "tabletbg.png"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(path, sizeof(path), directory, names[i]) && + bongo_cat_path_is_file(path)) return true; + } + return false; +} + +typedef struct TauriRightKeys { + bool gamepad; +} TauriRightKeys; + +static bool gamepad_key_name(const char *name) { + static const char *const names[] = { + "South", "East", "West", "North", "LeftTrigger", "RightTrigger", + "LeftTrigger2", "RightTrigger2", "LeftThumb", "RightThumb", + "DPadLeft", "DPadRight", "DPadUp", "DPadDown", "Start", "Select" + }; + size_t length = name ? strlen(name) : 0; + if (length <= 4 || SDL_strcasecmp(name + length - 4, ".png") != 0) + return false; + length -= 4; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) + if (strlen(names[i]) == length && + SDL_strncasecmp(name, names[i], length) == 0) return true; + return false; +} + +static BongoCatPathVisit inspect_right_key(void *userdata, + const char *dirname, const char *name) { + (void)dirname; + TauriRightKeys *keys = userdata; + if (gamepad_key_name(name)) keys->gamepad = true; + return BONGO_CAT_PATH_CONTINUE; +} + +static bool tauri_resource_mode(const char *directory, const char *assets, + BongoCatModelMode *mode, bool *gamepad_buttons) { + const char *roots[] = {directory, assets}; + bool right_keys = false; + TauriRightKeys keys = {0}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i] || !roots[i][0] || (i && strcmp(roots[0], roots[i]) == 0)) + continue; + const char *prefixes[] = {"resources", NULL}; + for (size_t j = 0; j < sizeof(prefixes) / sizeof(prefixes[0]); ++j) { + char parent[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + const char *root = roots[i]; + if (prefixes[j]) { + if (!bongo_cat_path_join(parent, sizeof(parent), root, + prefixes[j])) continue; + root = parent; + } + if (!bongo_cat_path_join(path, sizeof(path), root, "right-keys") || + !bongo_cat_path_is_dir(path)) continue; + right_keys = true; + if (!bongo_cat_path_enumerate(path, inspect_right_key, &keys)) + return false; + } + } + if (!right_keys) return false; + *mode = keys.gamepad ? BONGO_CAT_MODE_GAMEPAD : BONGO_CAT_MODE_KEYBOARD; + *gamepad_buttons = keys.gamepad; + return true; +} + +bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, + const char *directory, const char *setting) { + if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; + for (size_t i = 0; i < discovery->count; ++i) + if (strcmp(discovery->candidates[i].directory, directory) == 0) { + if (strcmp(discovery->candidates[i].setting, setting) == 0) + return true; + discovery->ambiguous = true; + return false; + } + BongoCatImportCandidate *candidate = + &discovery->candidates[discovery->count++]; + snprintf(candidate->directory, sizeof(candidate->directory), "%s", + directory); + snprintf(candidate->setting, sizeof(candidate->setting), "%s", setting); + snprintf(candidate->assets, sizeof(candidate->assets), "%s", directory); + snprintf(candidate->package_root, sizeof(candidate->package_root), "%s", + directory); + candidate->format = BONGO_CAT_IMPORT_TAURI; + char parent[BONGO_CAT_PATH_CAP]; + if ((!has_cover_asset(directory) || !has_background_asset(directory)) && + bongo_cat_import_parent_path(directory, parent, sizeof(parent)) && + has_preview_assets(parent)) + snprintf(candidate->assets, sizeof(candidate->assets), "%s", parent); + if (!tauri_resource_mode(candidate->directory, candidate->assets, + &candidate->mode, &candidate->gamepad_buttons)) { + candidate->mode = import_mode(candidate->directory); + candidate->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; + } + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_config.c b/src/runtime/model/import/tauri/model_import_tauri_config.c new file mode 100644 index 00000000..c2f50c0a --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_config.c @@ -0,0 +1,72 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/json.h" + +#include + +static bool add_matrix(yyjson_mut_doc *document, yyjson_mut_val *mode, + const char *name, const TauriKeyFiles *files) { + yyjson_mut_val *matrix = yyjson_mut_obj_add_arr(document, mode, name); + if (!matrix) return false; + for (size_t i = 0; i < files->count; ++i) { + yyjson_mut_val *row = yyjson_mut_arr_add_arr(document, matrix); + if (!row || !yyjson_mut_arr_add_int(document, row, + files->values[i].code)) return false; + } + return true; +} + +bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, + const TauriKeyFiles *left, const TauriKeyFiles *right, + const TauriMverCalibration *calibration, BongoCatError *error) { + if (!path || !left || !right || !calibration) return false; + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (document) yyjson_mut_doc_set_root(document, root); + yyjson_mut_val *decoration = root ? yyjson_mut_obj_add_obj(document, root, + "decoration") : NULL; + yyjson_mut_val *mode_object = root ? yyjson_mut_obj_add_obj(document, root, + bongo_cat_mode_name(mode)) : NULL; + yyjson_mut_val *window_size = decoration ? yyjson_mut_obj_add_arr(document, + decoration, "window_size") : NULL; + yyjson_mut_val *offset = decoration ? yyjson_mut_obj_add_arr(document, + decoration, "l2d_offset") : NULL; + bool ok = root && decoration && mode_object && window_size && offset && + yyjson_mut_arr_add_int(document, window_size, + calibration->window_width) && + yyjson_mut_arr_add_int(document, window_size, + calibration->window_height) && + yyjson_mut_obj_add_real(document, decoration, "l2d_correct", + calibration->l2d_correct) && + yyjson_mut_arr_add_real(document, offset, calibration->l2d_offset_x) && + yyjson_mut_arr_add_real(document, offset, calibration->l2d_offset_y) && + yyjson_mut_obj_add_bool(document, decoration, "l2d_horizontal_flip", + calibration->mirror) && + yyjson_mut_obj_add_bool(document, decoration, "l2d_auto_frame", + calibration->auto_frame) && + yyjson_mut_obj_add_int(document, root, "mode", + mode == BONGO_CAT_MODE_STANDARD ? 1 : + mode == BONGO_CAT_MODE_KEYBOARD ? 2 : 3) && + yyjson_mut_obj_add_bool(document, mode_object, "l2d", true); + if (ok && mode == BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "hand", left) && + yyjson_mut_obj_add_bool(document, mode_object, "mouse", false) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode != BONGO_CAT_MODE_STANDARD) + ok = add_matrix(document, mode_object, "lefthand", left) && + add_matrix(document, mode_object, "righthand", right) && + yyjson_mut_obj_add_arr(document, mode_object, "keyboard"); + if (ok && mode == BONGO_CAT_MODE_GAMEPAD) + ok = yyjson_mut_obj_add_int(document, mode_object, "input_mode", 1); + if (ok) ok = yyjson_mut_obj_add_arr(document, mode_object, "face") && + yyjson_mut_obj_add_arr(document, mode_object, "sounds") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_expression") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion") && + yyjson_mut_obj_add_arr(document, mode_object, "l2d_motion_lockhand"); + if (ok) ok = bongo_cat_json_write_file(path, document, + YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(document); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot write normalized Mver configuration: %s", path); + return ok; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_convert.c b/src/runtime/model/import/tauri/model_import_tauri_convert.c new file mode 100644 index 00000000..2308b78e --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_convert.c @@ -0,0 +1,70 @@ +#include "model_import_tauri_internal.h" +#include "bongo_cat/path.h" + +#include +#include + +typedef struct TauriConversionWorkspace { + TauriKeyFiles left; + TauriKeyFiles right; +} TauriConversionWorkspace; + +static bool create_target_tree(const char *target, BongoCatModelMode mode, + char *mode_root, size_t mode_capacity, char *model_root, + size_t model_capacity) { + char image_root[BONGO_CAT_PATH_CAP]; + return bongo_cat_path_create_directory(target) && + bongo_cat_path_join(image_root, sizeof(image_root), target, "img") && + bongo_cat_path_join(mode_root, mode_capacity, image_root, + bongo_cat_mode_name(mode)) && + bongo_cat_path_join(model_root, model_capacity, mode_root, + "cat_model") && bongo_cat_path_create_directory(mode_root) && + bongo_cat_path_create_directory(model_root); +} + +bool bongo_cat_import_tauri_convert_to_mver( + const BongoCatImportCandidate *candidate, const char *target, + BongoCatImportCandidate *installed, BongoCatError *error) { + if (!candidate || !target || !installed || + candidate->format != BONGO_CAT_IMPORT_TAURI) return false; + char mode_root[BONGO_CAT_PATH_CAP], model_root[BONGO_CAT_PATH_CAP]; + if (!create_target_tree(target, candidate->mode, mode_root, + sizeof(mode_root), model_root, sizeof(model_root)) || + !bongo_cat_tauri_copy_model_tree(candidate->directory, model_root, 0, + error)) return false; + TauriConversionWorkspace *work = calloc(1, sizeof(*work)); + if (!work) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate Tauri conversion workspace"); + return false; + } + if (!bongo_cat_tauri_copy_input_images(candidate, + mode_root, &work->left, &work->right, error)) { + free(work); + return false; + } + TauriMverCalibration calibration; + char config[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_read_calibration(candidate, &calibration) || + !bongo_cat_path_join(config, sizeof(config), target, "config.json") || + !bongo_cat_tauri_write_config(config, candidate->mode, &work->left, + &work->right, &calibration, error) || + !bongo_cat_tauri_copy_preview(candidate, mode_root, error) || + !bongo_cat_tauri_copy_runtime_images(candidate, mode_root, error)) { + free(work); + return false; + } + free(work); + *installed = *candidate; + snprintf(installed->directory, sizeof(installed->directory), "%s", + model_root); + snprintf(installed->assets, sizeof(installed->assets), "%s", mode_root); + snprintf(installed->package_root, sizeof(installed->package_root), "%s", + target); + snprintf(installed->config, sizeof(installed->config), "%s", config); + installed->overrides[0] = '\0'; + installed->patch_root[0] = '\0'; + installed->format = BONGO_CAT_IMPORT_MVER; + installed->gamepad_buttons = candidate->mode == BONGO_CAT_MODE_GAMEPAD; + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_discovery.c b/src/runtime/model/import/tauri/model_import_tauri_discovery.c new file mode 100644 index 00000000..4413abf2 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_discovery.c @@ -0,0 +1,111 @@ +#include "model_import_tauri_internal.h" +#include "../model_import_manifest.h" + +#include +#include +#include + +static bool mver_input_assets(const char *directory) { + char path[BONGO_CAT_PATH_CAP], group[BONGO_CAT_PATH_CAP]; + static const char *const groups[] = {"hand", "lefthand", "righthand"}; + for (size_t i = 0; i < sizeof(groups) / sizeof(groups[0]); ++i) + if (bongo_cat_path_join(group, sizeof(group), directory, groups[i]) && + bongo_cat_path_join(path, sizeof(path), group, "0.png") && + bongo_cat_path_is_file(path)) return true; + return false; +} + +static bool mver_mode_directory(const char *directory) { + const char *name = bongo_cat_path_name(directory); + if (!name || (SDL_strcasecmp(name, "standard") != 0 && + SDL_strcasecmp(name, "keyboard") != 0 && + SDL_strcasecmp(name, "gamepad") != 0)) return false; + char image_root[BONGO_CAT_PATH_CAP]; + return bongo_cat_import_parent_path(directory, image_root, + sizeof(image_root)) && + SDL_strcasecmp(bongo_cat_path_name(image_root), "img") == 0 && + mver_input_assets(directory); +} + +static bool inside_mver_model(const char *directory) { + char current[BONGO_CAT_PATH_CAP]; + int written = snprintf(current, sizeof(current), "%s", + directory ? directory : ""); + if (written < 0 || (size_t)written >= sizeof(current)) return false; + for (int depth = 0; depth < 4; ++depth) { + if (mver_mode_directory(current)) return true; + char parent[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_import_parent_path(current, parent, sizeof(parent))) + break; + snprintf(current, sizeof(current), "%s", parent); + } + return false; +} + +static BongoCatPathVisit discover_model(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(path) || + !bongo_cat_import_has_suffix(name, ".model3.json")) + return BONGO_CAT_PATH_CONTINUE; + if (inside_mver_model(dirname)) return BONGO_CAT_PATH_CONTINUE; + if (bongo_cat_import_manifest_valid(dirname, name, NULL) && + !bongo_cat_import_tauri_add_candidate(discovery, dirname, name)) + return BONGO_CAT_PATH_FAILURE; + return BONGO_CAT_PATH_CONTINUE; +} + +static BongoCatPathVisit discover_recursive_item(void *userdata, + const char *dirname, const char *name) { + BongoCatImportDiscovery *discovery = userdata; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_file(path)) + return discover_model(userdata, dirname, name); + if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') + return BONGO_CAT_PATH_CONTINUE; + discovery->depth++; + bool ok = bongo_cat_path_enumerate(path, discover_recursive_item, + discovery); + discovery->depth--; + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +int bongo_cat_import_tauri_discover_exact(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); + size_t before = discovery->count; + if (!bongo_cat_path_enumerate(source, discover_model, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot inspect nearby model directory"); + return -1; + } + for (size_t i = before; i < discovery->count; ++i) + snprintf(discovery->candidates[i].package_root, + sizeof(discovery->candidates[i].package_root), "%s", source); + return discovery->count > before ? 1 : 0; +} + +int bongo_cat_import_tauri_discover_recursive(const char *source, + BongoCatImportDiscovery *discovery, BongoCatError *error) { + if (!source || !discovery || !bongo_cat_path_is_dir(source)) return 0; + if (!discovery->source_name[0]) snprintf(discovery->source_name, + sizeof(discovery->source_name), "%s", bongo_cat_path_name(source)); + size_t before = discovery->count; + if (!bongo_cat_path_enumerate(source, discover_recursive_item, discovery)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + discovery->ambiguous + ? "A model directory contains multiple model3 manifests" + : "Cannot scan model directory or it contains too many models"); + return -1; + } + return discovery->count > before ? 1 : 0; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_images.c b/src/runtime/model/import/tauri/model_import_tauri_images.c new file mode 100644 index 00000000..e9a5beaa --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_images.c @@ -0,0 +1,106 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include + +static bool write_placeholder(const char *path) { + static const unsigned char png[] = { + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d, + 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x04,0x00,0x00,0x00,0xb5,0x1c,0x0c,0x02,0x00,0x00,0x00, + 0x0b,0x49,0x44,0x41,0x54,0x78,0xda,0x63,0x60,0x60,0x00,0x00, + 0x00,0x03,0x00,0x01,0x2b,0x09,0x4d,0x84,0x00,0x00,0x00,0x00, + 0x49,0x45,0x4e,0x44,0xae,0x42,0x60,0x82 + }; + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + bool ok = fwrite(png, 1, sizeof(png), file) == sizeof(png); + if (fclose(file) != 0) ok = false; + return ok; +} + +bool bongo_cat_tauri_copy_image_or_placeholder(const char *source, + const char *target, BongoCatError *error) { + bool ok = source && bongo_cat_path_is_file(source) + ? bongo_cat_path_copy_file(source, target) : write_placeholder(target); + if (!ok && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create normalized Mver image: %s", target); + return ok; +} + +static bool copy_mver_image(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *source_name, const char *target_name, + const char *fallback, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + const char *selected = fallback; + if (source_name && bongo_cat_tauri_find_resource_file(candidate, + source_name, source, sizeof(source))) selected = source; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, target_name)) + return false; + return bongo_cat_tauri_copy_image_or_placeholder(selected, target, error); +} + +bool bongo_cat_tauri_copy_runtime_images( + const BongoCatImportCandidate *candidate, const char *mode_root, + BongoCatError *error) { + char background[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(background, sizeof(background), mode_root, + candidate->mode == BONGO_CAT_MODE_STANDARD ? "mousebg.png" : + "bg.png")) return false; + /* Tauri omits these Mver-only layers. Preserve real files when supplied; + otherwise use transparent PNGs so the normalized package still loads. */ + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + static const char *const backgrounds[] = { + "tabletbg.png", "l2dmousebg.png", "l2dtabletbg.png" + }; + static const char *const layers[] = { + "arm.png", "up.png", "mouse.png", "mouse_left.png", + "mouse_right.png", "mouse_side.png", "tablet.png", + "tablet_left.png", "tablet_right.png" + }; + for (size_t i = 0; i < sizeof(backgrounds) / sizeof(backgrounds[0]); + ++i) + if (!copy_mver_image(candidate, mode_root, backgrounds[i], + backgrounds[i], background, error)) return false; + for (size_t i = 0; i < sizeof(layers) / sizeof(layers[0]); ++i) + if (!copy_mver_image(candidate, mode_root, layers[i], layers[i], + NULL, error)) return false; + return true; + } + static const char *const idle_hands[] = { + "lefthand/leftup.png", "righthand/rightup.png" + }; + for (size_t i = 0; i < sizeof(idle_hands) / sizeof(idle_hands[0]); ++i) + if (!copy_mver_image(candidate, mode_root, idle_hands[i], + idle_hands[i], NULL, error)) return false; + if (candidate->mode != BONGO_CAT_MODE_GAMEPAD) return true; + static const char *const gamepad[] = { + "arm_L.png", "arm_R.png", "left_stick.png", "left_stick_down.png", + "right_stick.png", "right_stick_down.png" + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (!copy_mver_image(candidate, mode_root, gamepad[i], gamepad[i], + NULL, error)) return false; + return true; +} + +bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, + const char *mode_root, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP], cat[BONGO_CAT_PATH_CAP]; + char target[BONGO_CAT_PATH_CAP]; + bool have_cover = bongo_cat_tauri_find_resource_file(candidate, + "cover.png", source, sizeof(source)); + if (!bongo_cat_path_join(cat, sizeof(cat), mode_root, "cat.png") || + !bongo_cat_tauri_copy_image_or_placeholder( + have_cover ? source : NULL, cat, error)) return false; + bool have_background = bongo_cat_tauri_find_resource_file(candidate, + "background.png", source, sizeof(source)); + const char *background_name = candidate->mode == BONGO_CAT_MODE_STANDARD + ? "mousebg.png" : "bg.png"; + if (!bongo_cat_path_join(target, sizeof(target), mode_root, + background_name)) return false; + return bongo_cat_tauri_copy_image_or_placeholder( + have_background ? source : NULL, target, error); +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_input.c b/src/runtime/model/import/tauri/model_import_tauri_input.c new file mode 100644 index 00000000..4d522f19 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_input.c @@ -0,0 +1,89 @@ +#include "model_import_tauri_internal.h" + +#include +#include +#include +#include + +static BongoCatPathVisit collect_keys(void *userdata, + const char *dirname, const char *name) { + TauriKeyFiles *files = userdata; + if (files->count >= TAURI_KEY_CAP || + !bongo_cat_import_has_suffix_ci(name, ".png")) + return BONGO_CAT_PATH_CONTINUE; + int code = bongo_cat_tauri_key_code(name); + if (code < 0) return BONGO_CAT_PATH_CONTINUE; + TauriKeyFile *item = &files->values[files->count]; + if (!bongo_cat_path_join(item->path, sizeof(item->path), dirname, name)) + return BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(item->path)) return BONGO_CAT_PATH_CONTINUE; + snprintf(item->name, sizeof(item->name), "%s", name); + item->code = code; + files->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static int compare_keys(const void *left, const void *right) { + const TauriKeyFile *a = left, *b = right; + return SDL_strcasecmp(a->name, b->name); +} + +static bool copy_keys(const BongoCatImportCandidate *candidate, + const char *mode_root, const char *source_group, const char *target_group, + TauriKeyFiles *files, BongoCatError *error) { + char source[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_resource_directory(candidate, source_group, + source, sizeof(source))) return true; + if (!bongo_cat_path_enumerate(source, collect_keys, files)) return false; + qsort(files->values, files->count, sizeof(files->values[0]), compare_keys); + char destination[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(destination, sizeof(destination), mode_root, + target_group) || !bongo_cat_path_create_directory(destination)) + return false; + for (size_t i = 0; i < files->count; ++i) { + char filename[32], path[BONGO_CAT_PATH_CAP]; + snprintf(filename, sizeof(filename), "%zu.png", i); + if (!bongo_cat_path_join(path, sizeof(path), destination, filename) || + !bongo_cat_path_copy_file(files->values[i].path, path)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri input image: %s", files->values[i].path); + return false; + } + } + return true; +} + +static bool ensure_key(const char *mode_root, const char *target_group, + int fallback_code, TauriKeyFiles *files, BongoCatError *error) { + if (files->count) return true; + char group[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(group, sizeof(group), mode_root, target_group) || + !bongo_cat_path_create_directory(group) || + !bongo_cat_path_join(target, sizeof(target), group, "0.png") || + !bongo_cat_tauri_copy_image_or_placeholder(NULL, target, error)) + return false; + files->values[0].code = fallback_code; + files->count = 1; + return true; +} + +bool bongo_cat_tauri_copy_input_images( + const BongoCatImportCandidate *candidate, + const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, + BongoCatError *error) { + *left = (TauriKeyFiles){0}; + *right = (TauriKeyFiles){0}; + if (candidate->mode == BONGO_CAT_MODE_STANDARD) { + return copy_keys(candidate, mode_root, "left-keys", "hand", left, + error) && + ensure_key(mode_root, "hand", 65, left, error); + } + return copy_keys(candidate, mode_root, + "left-keys", "lefthand", left, error) && + copy_keys(candidate, mode_root, + "right-keys", "righthand", right, error) && + ensure_key(mode_root, "lefthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 0 : 65, left, error) && + ensure_key(mode_root, "righthand", + candidate->mode == BONGO_CAT_MODE_GAMEPAD ? 1 : 65, right, error); +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_internal.h b/src/runtime/model/import/tauri/model_import_tauri_internal.h new file mode 100644 index 00000000..f8095566 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_internal.h @@ -0,0 +1,72 @@ +#ifndef BONGO_CAT_MODEL_IMPORT_TAURI_INTERNAL_H +#define BONGO_CAT_MODEL_IMPORT_TAURI_INTERNAL_H + +#include "model_import_tauri.h" +#include "../model_import_path.h" +#include "bongo_cat/path.h" + +#define TAURI_KEY_CAP 256 +#define BONGO_CAT_TAURI_SOURCE_FILE "bongocat.mver.json" + +typedef struct TauriMverCalibration { + double l2d_correct; + double l2d_offset_x; + double l2d_offset_y; + int window_width; + int window_height; + bool mirror; + bool auto_frame; +} TauriMverCalibration; + +typedef struct TauriKeyFile { + char name[BONGO_CAT_ID_CAP]; + char path[BONGO_CAT_PATH_CAP]; + int code; +} TauriKeyFile; + +typedef struct TauriKeyFiles { + TauriKeyFile values[TAURI_KEY_CAP]; + size_t count; +} TauriKeyFiles; + +/* Candidate construction is private to Tauri discovery. */ +bool bongo_cat_import_tauri_add_candidate(BongoCatImportDiscovery *discovery, + const char *directory, const char *setting); + +/* Source package lookup. */ +bool bongo_cat_tauri_find_resource_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); +bool bongo_cat_tauri_find_resource_directory( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); +bool bongo_cat_tauri_find_package_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity); + +/* Mver normalization steps. */ +int bongo_cat_tauri_key_code(const char *filename); +void bongo_cat_tauri_calibration_defaults(TauriMverCalibration *calibration); +void bongo_cat_tauri_legacy_calibration( + const BongoCatImportCandidate *candidate, TauriMverCalibration *calibration); +bool bongo_cat_tauri_copy_image_or_placeholder(const char *source, + const char *target, BongoCatError *error); +bool bongo_cat_tauri_copy_preview(const BongoCatImportCandidate *candidate, + const char *mode_root, BongoCatError *error); +bool bongo_cat_tauri_copy_runtime_images( + const BongoCatImportCandidate *candidate, const char *mode_root, + BongoCatError *error); +bool bongo_cat_tauri_copy_input_images( + const BongoCatImportCandidate *candidate, + const char *mode_root, TauriKeyFiles *left, TauriKeyFiles *right, + BongoCatError *error); +bool bongo_cat_tauri_write_config(const char *path, BongoCatModelMode mode, + const TauriKeyFiles *left, const TauriKeyFiles *right, + const TauriMverCalibration *calibration, BongoCatError *error); +bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); +bool bongo_cat_tauri_read_calibration( + const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration); + +#endif diff --git a/src/runtime/model/import/tauri/model_import_tauri_keys.c b/src/runtime/model/import/tauri/model_import_tauri_keys.c new file mode 100644 index 00000000..0279c541 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_keys.c @@ -0,0 +1,60 @@ +#include "model_import_tauri_internal.h" + +#include +#include +#include +#include + +int bongo_cat_tauri_key_code(const char *filename) { + char name[BONGO_CAT_ID_CAP]; + snprintf(name, sizeof(name), "%s", filename ? filename : ""); + char *dot = strrchr(name, '.'); + if (!dot || SDL_strcasecmp(dot, ".png") != 0) return -1; + *dot = '\0'; + char *end = NULL; + long numeric = strtol(name, &end, 10); + if (name[0] && end && !end[0] && numeric >= 0 && numeric <= 255) + return (int)numeric; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Key", 3) == 0 && + name[3] >= 'A' && name[3] <= 'Z') return name[3]; + if (strlen(name) == 4 && SDL_strncasecmp(name, "Num", 3) == 0 && + name[3] >= '0' && name[3] <= '9') return name[3]; + if ((name[0] == 'F' || name[0] == 'f') && strlen(name) <= 3) { + long value = strtol(name + 1, &end, 10); + if (end != name + 1 && !*end && value >= 1 && value <= 12) + return 111 + (int)value; + } + static const struct { const char *name; int code; } map[] = { + {"Backspace", 8}, {"BackSpace", 8}, {"Tab", 9}, {"Return", 13}, + {"Pause", 19}, {"CapsLock", 20}, {"Escape", 27}, {"Space", 32}, + {"PageUp", 33}, {"PageDown", 34}, {"End", 35}, {"Home", 36}, + {"LeftArrow", 37}, {"UpArrow", 38}, {"RightArrow", 39}, + {"DownArrow", 40}, {"PrintScreen", 44}, {"Insert", 45}, + {"Delete", 46}, {"Meta", 91}, {"MetaLeft", 91}, {"MetaRight", 92}, + {"Apps", 93}, {"Kp0", 96}, {"Kp1", 97}, {"Kp2", 98}, + {"Kp3", 99}, {"Kp4", 100}, {"Kp5", 101}, {"Kp6", 102}, + {"Kp7", 103}, {"Kp8", 104}, {"Kp9", 105}, + {"KpMultiply", 106}, {"KpPlus", 107}, {"KpMinus", 109}, + {"KpDecimal", 110}, {"KpDivide", 111}, {"NumLock", 144}, + {"ScrollLock", 145}, {"Semicolon", 186}, {"SemiColon", 186}, + {"Equal", 187}, {"Comma", 188}, {"Minus", 189}, + {"Period", 190}, {"Dot", 190}, {"Slash", 191}, + {"BackQuote", 192}, {"BracketLeft", 219}, {"LeftBracket", 219}, + {"Backslash", 220}, {"BracketRight", 221}, {"RightBracket", 221}, + {"Quote", 222}, {"Shift", 16}, {"ShiftLeft", 16}, + {"ShiftRight", 16}, {"Control", 17}, {"ControlLeft", 17}, + {"ControlRight", 17}, {"Alt", 18}, {"AltGr", 18} + }; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); ++i) + if (SDL_strcasecmp(name, map[i].name) == 0) return map[i].code; + static const struct { const char *name; int code; } gamepad[] = { + {"South", 0}, {"East", 1}, {"West", 2}, {"North", 3}, + {"LeftTrigger", 4}, {"RightTrigger", 5}, {"LeftTrigger2", 6}, + {"RightTrigger2", 7}, {"LeftThumb", 8}, {"RightThumb", 9}, + {"DPadLeft", 10}, {"DPadRight", 11}, {"DPadUp", 12}, + {"DPadDown", 13}, {"Start", 14}, {"Select", 15} + }; + for (size_t i = 0; i < sizeof(gamepad) / sizeof(gamepad[0]); ++i) + if (SDL_strcasecmp(name, gamepad[i].name) == 0) return gamepad[i].code; + return -1; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_legacy.c b/src/runtime/model/import/tauri/model_import_tauri_legacy.c new file mode 100644 index 00000000..f7a0e6b9 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_legacy.c @@ -0,0 +1,99 @@ +#include "model_import_tauri_internal.h" +#include "bongo_cat/image.h" + +static bool valid_canvas_size(int width, int height) { + return width >= 64 && width <= 8192 && height >= 64 && height <= 8192; +} + +static bool legacy_converter_double_scale( + const BongoCatImportCandidate *candidate, int width, int height) { + if (!candidate || !valid_canvas_size(width, height) || + width < 256 || height < 256) return false; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_resource_directory(candidate, "left-keys", + path, sizeof(path))) return false; + if (candidate->mode != BONGO_CAT_MODE_STANDARD) return true; + return width == height && width >= 512; +} + +typedef struct InputCanvas { + int width, height; + bool mismatch; +} InputCanvas; + +static BongoCatPathVisit inspect_input_canvas(void *userdata, + const char *directory, const char *name) { + InputCanvas *canvas = userdata; + char path[BONGO_CAT_PATH_CAP]; + int width = 0, height = 0; + if (bongo_cat_tauri_key_code(name) < 0 || + !bongo_cat_path_join(path, sizeof(path), directory, name) || + !bongo_cat_image_info(path, &width, &height) || + !valid_canvas_size(width, height)) return BONGO_CAT_PATH_CONTINUE; + if (canvas->width && (canvas->width != width || canvas->height != height)) + canvas->mismatch = true; + canvas->width = width; + canvas->height = height; + return BONGO_CAT_PATH_CONTINUE; +} + +static void legacy_input_canvas(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + InputCanvas canvas = {0}; + const char *groups[] = {"left-keys", "right-keys"}; + for (size_t i = 0; i < sizeof(groups) / sizeof(groups[0]); ++i) { + char path[BONGO_CAT_PATH_CAP]; + if (bongo_cat_tauri_find_resource_directory(candidate, groups[i], + path, sizeof(path)) && + !bongo_cat_path_enumerate(path, inspect_input_canvas, &canvas)) + return; + } + if (!canvas.width || canvas.mismatch || + (canvas.width == calibration->window_width && + canvas.height == calibration->window_height)) return; + /* Legacy exporters can leave a square placeholder background while keeping + the original input canvas. Preserve vertical projection when recovering + its aspect ratio; a cropped cover is not calibration metadata. */ + double vertical_scale = calibration->l2d_correct * + calibration->window_width / calibration->window_height; + double scale = vertical_scale * canvas.height / canvas.width; + if (scale <= 0.01 || scale > 100.0) return; + calibration->window_width = canvas.width; + calibration->window_height = canvas.height; + calibration->l2d_correct = scale; +} + +void bongo_cat_tauri_legacy_calibration(const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + bongo_cat_tauri_calibration_defaults(calibration); + /* Legacy packages lost the authored projection. Keep aligned layers, but + allow visible model geometry to extend beyond the inferred canvas. */ + calibration->auto_frame = true; + char path[BONGO_CAT_PATH_CAP]; + const char *names[] = {"background.png", "cover.png"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + if (!bongo_cat_tauri_find_resource_file(candidate, names[i], path, + sizeof(path))) continue; + int width = 0, height = 0; + if (bongo_cat_image_info(path, &width, &height) && + valid_canvas_size(width, height)) { + calibration->window_width = width; + calibration->window_height = height; + break; + } + } + /* Converter packages before schemaVersion 1 omitted their Mver projection + calibration. Keyboard/gamepad packages and the converter's square + standard profile use approximately 2.0. Non-square standard packages + retain Mver's 1.1 default; cover.png is only a cropped preview and must + not be used to infer runtime projection. */ + if (legacy_converter_double_scale(candidate, calibration->window_width, + calibration->window_height)) { + calibration->l2d_correct = 2.0; + if (candidate->mode == BONGO_CAT_MODE_STANDARD && + calibration->window_width == calibration->window_height) + calibration->l2d_offset_y = -0.005; + legacy_input_canvas(candidate, calibration); + } +} + diff --git a/src/runtime/model/import/tauri/model_import_tauri_metadata.c b/src/runtime/model/import/tauri/model_import_tauri_metadata.c new file mode 100644 index 00000000..bde5d242 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_metadata.c @@ -0,0 +1,106 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/json.h" + +#include +#include +#include + +static bool finite_number(yyjson_val *value) { + return yyjson_is_num(value) && isfinite(yyjson_get_num(value)); +} + +static bool read_offset(yyjson_val *value, double *x, double *y) { + if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || + !finite_number(yyjson_arr_get(value, 0)) || + !finite_number(yyjson_arr_get(value, 1))) return false; + double next_x = yyjson_get_num(yyjson_arr_get(value, 0)); + double next_y = yyjson_get_num(yyjson_arr_get(value, 1)); + if (next_x < -100.0 || next_x > 100.0 || next_y < -100.0 || + next_y > 100.0) return false; + *x = next_x; + *y = next_y; + return true; +} + +static bool read_window(yyjson_val *value, int *width, int *height) { + if (!yyjson_is_arr(value) || yyjson_arr_size(value) < 2 || + !yyjson_is_num(yyjson_arr_get(value, 0)) || + !yyjson_is_num(yyjson_arr_get(value, 1))) return false; + double next_width = yyjson_get_num(yyjson_arr_get(value, 0)); + double next_height = yyjson_get_num(yyjson_arr_get(value, 1)); + if (!isfinite(next_width) || !isfinite(next_height) || + next_width < 64.0 || next_width > 8192.0 || + next_height < 64.0 || next_height > 8192.0) + return false; + int integer_width = (int)next_width; + int integer_height = (int)next_height; + if ((double)integer_width != next_width || + (double)integer_height != next_height) return false; + *width = integer_width; + *height = integer_height; + return true; +} + +void bongo_cat_tauri_calibration_defaults(TauriMverCalibration *calibration) { + calibration->l2d_correct = 1.1; + calibration->l2d_offset_x = 0.0; + calibration->l2d_offset_y = 0.0; + calibration->window_width = 612; + calibration->window_height = 352; + calibration->mirror = false; + calibration->auto_frame = false; +} + +bool bongo_cat_tauri_read_calibration( + const BongoCatImportCandidate *candidate, + TauriMverCalibration *calibration) { + if (!candidate || !calibration) return false; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_tauri_find_package_file(candidate, + BONGO_CAT_TAURI_SOURCE_FILE, path, sizeof(path))) { + bongo_cat_tauri_legacy_calibration(candidate, calibration); + return true; + } + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *schema = yyjson_obj_get(root, "schemaVersion"); + const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); + const char *mode = yyjson_get_str(yyjson_obj_get(root, "mode")); + yyjson_val *decoration = yyjson_obj_get(root, "decoration"); + bool valid = yyjson_is_int(schema) && yyjson_get_int(schema) == 1 && + kind && strcmp(kind, "bongo-cat-mver-source") == 0 && + (!mode || strcmp(mode, bongo_cat_mode_name(candidate->mode)) == 0) && + yyjson_is_obj(decoration); + if (valid) { + bongo_cat_tauri_calibration_defaults(calibration); + yyjson_val *scale = yyjson_obj_get(decoration, "l2d_correct"); + bool have_scale = false; + if (finite_number(scale)) { + double value = yyjson_get_num(scale); + if (value > 0.01 && value <= 100.0) { + calibration->l2d_correct = value; + have_scale = true; + } + } + bool have_window = read_window(yyjson_obj_get(decoration, "window_size"), + &calibration->window_width, &calibration->window_height); + if (!have_scale || !have_window) { + TauriMverCalibration authored = *calibration; + bongo_cat_tauri_legacy_calibration(candidate, calibration); + if (have_scale) calibration->l2d_correct = authored.l2d_correct; + if (have_window) { + calibration->window_width = authored.window_width; + calibration->window_height = authored.window_height; + } + } + read_offset(yyjson_obj_get(decoration, "l2d_offset"), + &calibration->l2d_offset_x, &calibration->l2d_offset_y); + calibration->auto_frame = !(have_scale && have_window); + yyjson_val *mirror = yyjson_obj_get(decoration, + "l2d_horizontal_flip"); + if (yyjson_is_bool(mirror)) calibration->mirror = yyjson_get_bool(mirror); + } else bongo_cat_tauri_legacy_calibration(candidate, calibration); + yyjson_doc_free(document); + return true; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_resources.c b/src/runtime/model/import/tauri/model_import_tauri_resources.c new file mode 100644 index 00000000..583ca713 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_resources.c @@ -0,0 +1,52 @@ +#include "model_import_tauri_internal.h" + +#include "bongo_cat/path.h" + + +bool bongo_cat_tauri_find_resource_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->assets, candidate->directory, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i][0]) continue; + char resources[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(resources, sizeof(resources), roots[i], + "resources") && bongo_cat_path_join(path, capacity, + resources, name) && bongo_cat_path_is_file(path)) return true; + if (bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_file(path)) return true; + } + return false; +} + +bool bongo_cat_tauri_find_resource_directory( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->assets, candidate->directory, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) { + if (!roots[i][0]) continue; + char resources[BONGO_CAT_PATH_CAP]; + if (bongo_cat_path_join(resources, sizeof(resources), roots[i], + "resources") && bongo_cat_path_join(path, capacity, + resources, name) && bongo_cat_path_is_dir(path)) return true; + if (bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_dir(path)) return true; + } + return false; +} + +bool bongo_cat_tauri_find_package_file( + const BongoCatImportCandidate *candidate, const char *name, + char *path, size_t capacity) { + if (!candidate || !name || !path || !capacity) return false; + const char *roots[] = {candidate->directory, candidate->assets, + candidate->package_root}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); ++i) + if (roots[i][0] && bongo_cat_path_join(path, capacity, roots[i], name) && + bongo_cat_path_is_file(path)) return true; + return false; +} diff --git a/src/runtime/model/import/tauri/model_import_tauri_tree.c b/src/runtime/model/import/tauri/model_import_tauri_tree.c new file mode 100644 index 00000000..88b4f142 --- /dev/null +++ b/src/runtime/model/import/tauri/model_import_tauri_tree.c @@ -0,0 +1,103 @@ +#include "model_import_tauri_internal.h" + +#include + +#define TAURI_MODEL_COPY_DEPTH_CAP 32 + +typedef struct TauriModelCopy { + const char *target_root; + BongoCatError *error; + unsigned depth; +} TauriModelCopy; + +static bool copy_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error); + +static bool interface_resource(const char *name) { + static const char *const files[] = { + "cover.png", "background.png", "cat.png", "bg.png", + "mousebg.png", "tabletbg.png", BONGO_CAT_TAURI_SOURCE_FILE + }; + if (SDL_strcasecmp(name, "left-keys") == 0 || + SDL_strcasecmp(name, "right-keys") == 0) return true; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) + if (SDL_strcasecmp(name, files[i]) == 0) return true; + return false; +} + +static BongoCatPathVisit copy_resource_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.' || interface_resource(name)) + return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return bongo_cat_tauri_copy_model_tree(source, target, + context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source)) return BONGO_CAT_PATH_CONTINUE; + if (!bongo_cat_path_create_directory(context->target_root) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot preserve Tauri Live2D resource: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +static BongoCatPathVisit copy_model_child(void *userdata, + const char *dirname, const char *name) { + TauriModelCopy *context = userdata; + if (!name || name[0] == '.' || (context->depth == 0 && + SDL_strcasecmp(name, BONGO_CAT_TAURI_SOURCE_FILE) == 0)) + return BONGO_CAT_PATH_CONTINUE; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), dirname, name) || + !bongo_cat_path_join(target, sizeof(target), context->target_root, + name)) return BONGO_CAT_PATH_FAILURE; + if (context->depth == 0 && bongo_cat_path_is_dir(source) && + SDL_strcasecmp(name, "resources") == 0) + return copy_resource_tree(source, target, context->depth + 1, + context->error) ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(source)) + return bongo_cat_tauri_copy_model_tree(source, target, + context->depth + 1, context->error) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + if (!bongo_cat_path_is_file(source) || + !bongo_cat_path_copy_file(source, target)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy Tauri Live2D asset: %s", source); + return BONGO_CAT_PATH_FAILURE; + } + return BONGO_CAT_PATH_CONTINUE; +} + +bool bongo_cat_tauri_copy_model_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_create_directory(target) || + !bongo_cat_path_enumerate(source, copy_model_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot copy Tauri Live2D directory: %s", + source); + return false; + } + return true; +} + +static bool copy_resource_tree(const char *source, const char *target, + unsigned depth, BongoCatError *error) { + if (depth > TAURI_MODEL_COPY_DEPTH_CAP || + !bongo_cat_path_enumerate(source, copy_resource_child, + &(TauriModelCopy){target, error, depth})) { + if (error && !error->message[0]) bongo_cat_error_set(error, + BONGO_CAT_ERROR_IO, "Cannot inspect Tauri resource directory: %s", + source); + return false; + } + return true; +} diff --git a/src/runtime/model/model_behavior.c b/src/runtime/model/model_behavior.c new file mode 100644 index 00000000..90b4dab7 --- /dev/null +++ b/src/runtime/model/model_behavior.c @@ -0,0 +1,40 @@ +#include "runtime.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/overlay.h" + +bool bongo_cat_app_run_behavior(BongoCatApp *app, + const BongoCatBehaviorEntry *behavior) { + if (!app || !behavior) return false; + if (behavior->kind == BONGO_CAT_BEHAVIOR_EFFECT) { + if (!bongo_cat_overlay_effect(app->overlay, behavior->effect)) return false; + } else if (behavior->kind == BONGO_CAT_BEHAVIOR_SOUND) { + if (behavior->sound_clear) { + bongo_cat_audio_stop(app->audio); + return true; + } + if (!behavior->sound[0]) return false; + BongoCatError error = {0}; + if (bongo_cat_audio_play_sound(app->audio, behavior->sound, + behavior->sound_overlap, &error) != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_AUDIO, "%s", error.message); + return false; + } + return true; + } else if (behavior->kind == BONGO_CAT_BEHAVIOR_MOTION) { + bool started = bongo_cat_live2d_start_motion(app->live2d, + behavior->group, behavior->index); + if (!started) return false; + if (behavior->sound[0]) { + BongoCatError error = {0}; + bongo_cat_audio_play(app->audio, behavior->sound, &error); + } + } else { + int expression = bongo_cat_live2d_expression(app->live2d) == + behavior->index ? -1 : behavior->index; + if (!bongo_cat_live2d_set_expression(app->live2d, expression)) return false; + } + bongo_cat_app_capture_behavior_state(app); + app->dirty = true; + return true; +} + diff --git a/src/runtime/model/model_behavior_cache.c b/src/runtime/model/model_behavior_cache.c new file mode 100644 index 00000000..6c7352fe --- /dev/null +++ b/src/runtime/model/model_behavior_cache.c @@ -0,0 +1,41 @@ +#include "model_behavior_cache.h" + +#include +#include +#include + +static bool cacheable(const BongoCatModelEntry *entry) { + /* Installed Mver trees keep a stable content digest. Nearby models are + managed by their source directory, so do not retain stale data. */ + return entry && (entry->preset || (!entry->managed && + entry->content_digest[0])); +} + +bool bongo_cat_model_behavior_cache_matches(const BongoCatApp *app, + const BongoCatModelEntry *entry) { + return app && app->behavior_cache && app->behavior_cache_valid && + cacheable(entry) && + strcmp(app->behavior_cache_model_id, entry->id) == 0 && + (entry->preset || strcmp(app->behavior_cache_digest, + entry->content_digest) == 0); +} + +void bongo_cat_model_behavior_cache_store(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !cacheable(entry)) { + if (app) app->behavior_cache_valid = false; + return; + } + if (!app->behavior_cache) + app->behavior_cache = malloc(sizeof(*app->behavior_cache)); + if (!app->behavior_cache) { + app->behavior_cache_valid = false; + return; + } + *app->behavior_cache = app->behaviors; + snprintf(app->behavior_cache_model_id, + sizeof(app->behavior_cache_model_id), "%s", entry->id); + snprintf(app->behavior_cache_digest, sizeof(app->behavior_cache_digest), + "%s", entry->content_digest); + app->behavior_cache_valid = true; +} diff --git a/src/runtime/model/model_behavior_cache.h b/src/runtime/model/model_behavior_cache.h new file mode 100644 index 00000000..fc228a88 --- /dev/null +++ b/src/runtime/model/model_behavior_cache.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_MODEL_BEHAVIOR_CACHE_H +#define BONGO_CAT_MODEL_BEHAVIOR_CACHE_H + +#include "bongo_cat/app.h" + +bool bongo_cat_model_behavior_cache_matches(const BongoCatApp *app, + const BongoCatModelEntry *entry); +void bongo_cat_model_behavior_cache_store(BongoCatApp *app, + const BongoCatModelEntry *entry); + +#endif diff --git a/src/runtime/model/model_behavior_state.c b/src/runtime/model/model_behavior_state.c new file mode 100644 index 00000000..9b4ff8de --- /dev/null +++ b/src/runtime/model/model_behavior_state.c @@ -0,0 +1,135 @@ +#include "bongo_cat/app.h" + +#include +#include + +static bool behavior_active(const BongoCatApp *app, + const BongoCatBehaviorEntry *entry, int expression) { + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION) { + if (!bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) return false; + return bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index); + } + return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && + entry->index == expression; +} + +size_t bongo_cat_app_selected_motion_count(const BongoCatApp *app) { + if (!app || !app->live2d) return 0; + size_t count = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index)) count++; + } + return count; +} + +static void remove_model_state(BongoCatSessionState *session, + const char *model_id) { + size_t count = session->active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t output = 0; + for (size_t i = 0; i < count; ++i) { + BongoCatActiveBehavior entry = session->active_behaviors[i]; + if (strcmp(entry.model_id, model_id) != 0) + session->active_behaviors[output++] = entry; + } + session->active_behavior_count = output; +} + +void bongo_cat_app_forget_behavior_state(BongoCatApp *app, + const char *model_id) { + if (!app || !model_id || !model_id[0]) return; + remove_model_state(&app->session, model_id); +} + +static void make_room(BongoCatSessionState *session, size_t required) { + size_t count = session->active_behavior_count; + if (required > BONGO_CAT_BEHAVIOR_BINDING_CAP) + required = BONGO_CAT_BEHAVIOR_BINDING_CAP; + if (count + required <= BONGO_CAT_BEHAVIOR_BINDING_CAP) return; + size_t remove = count + required - BONGO_CAT_BEHAVIOR_BINDING_CAP; + memmove(session->active_behaviors, &session->active_behaviors[remove], + (count - remove) * sizeof(session->active_behaviors[0])); + session->active_behavior_count -= remove; +} + +void bongo_cat_app_capture_behavior_state(BongoCatApp *app) { + if (!app || !app->live2d || !app->loaded_model[0]) return; + int expression = bongo_cat_live2d_expression(app->live2d); + size_t active = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) + if (behavior_active(app, &app->behaviors.entries[i], expression)) + active++; + remove_model_state(&app->session, app->loaded_model); + make_room(&app->session, active); + for (size_t i = 0; i < app->behaviors.count && + app->session.active_behavior_count < + BONGO_CAT_BEHAVIOR_BINDING_CAP; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (!behavior_active(app, entry, expression)) continue; + BongoCatActiveBehavior *saved = &app->session.active_behaviors[ + app->session.active_behavior_count++]; + memset(saved, 0, sizeof(*saved)); + snprintf(saved->model_id, sizeof(saved->model_id), "%s", + app->loaded_model); + snprintf(saved->behavior_id, sizeof(saved->behavior_id), "%s", + entry->id); + } +} + +size_t bongo_cat_app_saved_behavior_count(const BongoCatApp *app, + const char *model_id) { + if (!app || !model_id) return 0; + size_t limit = app->session.active_behavior_count; + if (limit > BONGO_CAT_BEHAVIOR_BINDING_CAP) + limit = BONGO_CAT_BEHAVIOR_BINDING_CAP; + size_t count = 0; + for (size_t i = 0; i < limit; ++i) + if (strcmp(app->session.active_behaviors[i].model_id, model_id) == 0) + count++; + return count; +} + +static const BongoCatBehaviorEntry *find_behavior(const BongoCatApp *app, + const char *id) { + for (size_t i = 0; i < app->behaviors.count; ++i) + if (strcmp(app->behaviors.entries[i].id, id) == 0) + return &app->behaviors.entries[i]; + return NULL; +} + +void bongo_cat_app_restore_behavior_state(BongoCatApp *app, + const char *model_id) { + if (!app || !app->live2d || !model_id || !model_id[0]) return; + size_t count = app->session.active_behavior_count; + if (count > BONGO_CAT_BEHAVIOR_BINDING_CAP) + count = BONGO_CAT_BEHAVIOR_BINDING_CAP; + for (size_t i = 0; i < count; ++i) { + const BongoCatActiveBehavior *saved = + &app->session.active_behaviors[i]; + if (strcmp(saved->model_id, model_id) != 0) continue; + const BongoCatBehaviorEntry *entry = find_behavior(app, + saved->behavior_id); + if (!entry) continue; + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index) && + bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index) && + !bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index)) + bongo_cat_live2d_restore_motion_state(app->live2d, + entry->group, entry->index); + else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION) + bongo_cat_live2d_set_expression(app->live2d, entry->index); + } +} diff --git a/src/runtime/model/model_catalog_builtins.c b/src/runtime/model/model_catalog_builtins.c new file mode 100644 index 00000000..e8a355a8 --- /dev/null +++ b/src/runtime/model/model_catalog_builtins.c @@ -0,0 +1,47 @@ +#include "model_storage.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool mark_builtin(const char *directory, BongoCatError *error) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, + BONGO_CAT_MODEL_BUILTIN_MARKER)) return false; + if (bongo_cat_path_is_file(path)) return true; + FILE *file = bongo_cat_file_open(path, "wb"); + bool ok = file && fputs("BongoCat built-in model\n", file) >= 0; + if (file && fclose(file) != 0) ok = false; + if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot mark built-in model: %s", directory); + return ok; +} + +BongoCatResult bongo_cat_model_install_builtins(const char *asset_root, + const char *models_root, BongoCatError *error) { + static const char *const names[] = {"standard", "keyboard", "gamepad"}; + char source_root[BONGO_CAT_PATH_CAP]; + if (!asset_root || !models_root || + !bongo_cat_path_join(source_root, sizeof(source_root), asset_root, + "models") || !bongo_cat_path_create_directory(models_root)) + return BONGO_CAT_ERROR_ARGUMENT; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), source_root, names[i]) || + !bongo_cat_path_join(target, sizeof(target), models_root, names[i]) || + !bongo_cat_path_is_dir(source)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Built-in model assets are missing: %s", names[i]); + return BONGO_CAT_ERROR_IO; + } + if (!bongo_cat_path_is_dir(target)) { + if (bongo_cat_path_is_file(target) || + bongo_cat_model_copy_directory(source, target, error) != BONGO_CAT_OK) + return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + } + if (!mark_builtin(target, error)) + return error && error->code ? error->code : BONGO_CAT_ERROR_IO; + } + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/model_catalog_commit.c b/src/runtime/model/model_catalog_commit.c new file mode 100644 index 00000000..28bfe3f5 --- /dev/null +++ b/src/runtime/model/model_catalog_commit.c @@ -0,0 +1,19 @@ +#include "runtime.h" +#include "model_import.h" +#include "bongo_cat/path.h" + +#include + +void bongo_cat_model_catalog_finish_package(BongoCatApp *app, + const char *package_id) { + if (!app || !package_id || !package_id[0]) return; + char storage[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(storage, sizeof(storage), app->models_root, + package_id)) return; + for (size_t i = 0; i < app->models.count; ++i) { + BongoCatModelEntry *entry = &app->models.entries[i]; + if (!strcmp(entry->storage_directory, storage)) + bongo_cat_import_apply_metadata(app, entry->id, + entry->adapter_directory); + } +} diff --git a/src/runtime/model/model_catalog_refresh.c b/src/runtime/model/model_catalog_refresh.c new file mode 100644 index 00000000..17085b55 --- /dev/null +++ b/src/runtime/model/model_catalog_refresh.c @@ -0,0 +1,277 @@ +#include "runtime.h" +#include "model_import.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include +#include + +typedef struct BongoCatModelRefreshJob { + struct BongoCatModelRefresh *owner; + BongoCatModelCatalog models; + char asset_root[BONGO_CAT_PATH_CAP]; + char models_root[BONGO_CAT_PATH_CAP]; + char cache_root[BONGO_CAT_PATH_CAP]; + char nearby_root[BONGO_CAT_PATH_CAP]; + char active_model_id[BONGO_CAT_ID_CAP]; + char package_id[BONGO_CAT_ID_CAP]; + BongoCatRemovedModel removed_models[BONGO_CAT_MODEL_CAP]; + size_t removed_model_count; + uint64_t started_ns; + uint64_t revision; + BongoCatError error; + bool success; +} BongoCatModelRefreshJob; + +struct BongoCatModelRefresh { + SDL_Mutex *mutex; + SDL_Thread *worker; + BongoCatModelRefreshJob *completed; + Uint32 event_type; + uint64_t revision; + bool busy; + bool active_nearby; + bool rerun_full; + bool rerun_nearby; + size_t pending_package_count; + char pending_packages[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; +}; + +static BongoCatModelRefresh *create_refresh(void) { + BongoCatModelRefresh *refresh = calloc(1, sizeof(*refresh)); + if (!refresh) return NULL; + refresh->mutex = SDL_CreateMutex(); + refresh->event_type = SDL_RegisterEvents(1); + if (!refresh->mutex || refresh->event_type == (Uint32)-1) { + if (refresh->mutex) SDL_DestroyMutex(refresh->mutex); + free(refresh); + return NULL; + } + return refresh; +} + +static int SDLCALL refresh_worker(void *userdata) { + BongoCatModelRefreshJob *job = userdata; + if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, + "Cannot lower model catalog thread priority: %s", SDL_GetError()); + SDL_ClearError(); + } + BongoCatApp *scan = calloc(1, sizeof(*scan)); + if (scan) { + snprintf(scan->asset_root, sizeof(scan->asset_root), "%s", + job->asset_root); + snprintf(scan->models_root, sizeof(scan->models_root), "%s", + job->models_root); + snprintf(scan->cache_root, sizeof(scan->cache_root), "%s", + job->cache_root); + snprintf(scan->session.active_model_id, + sizeof(scan->session.active_model_id), "%s", + job->active_model_id); + scan->settings.removed_model_count = job->removed_model_count; + memcpy(scan->settings.removed_models, job->removed_models, + job->removed_model_count * sizeof(job->removed_models[0])); + scan->models = job->models; + BongoCatResult result = BONGO_CAT_OK; + if (job->package_id[0]) + result = bongo_cat_import_installed_package(scan, + job->models_root, job->package_id, &job->error); + else result = bongo_cat_model_catalog_scan(scan, false, + job->nearby_root); + job->models = scan->models; + job->success = result == BONGO_CAT_OK; + free(scan); + } + + BongoCatModelRefresh *refresh = job->owner; + SDL_LockMutex(refresh->mutex); + refresh->completed = job; + SDL_UnlockMutex(refresh->mutex); + + SDL_Event event = {0}; + event.type = refresh->event_type; + event.user.data1 = refresh; + SDL_PushEvent(&event); + return 0; +} + +static bool start_refresh(BongoCatApp *app, bool include_nearby, + const char *package_id) { + BongoCatModelRefresh *refresh = app->model_refresh; + BongoCatModelRefreshJob *job = calloc(1, sizeof(*job)); + if (!job) return false; + job->owner = refresh; + job->started_ns = SDL_GetTicksNS(); + job->revision = refresh->revision; + if (package_id) job->models = app->models; + snprintf(job->asset_root, sizeof(job->asset_root), "%s", app->asset_root); + snprintf(job->models_root, sizeof(job->models_root), "%s", app->models_root); + snprintf(job->cache_root, sizeof(job->cache_root), "%s", app->cache_root); + if (include_nearby) + snprintf(job->nearby_root, sizeof(job->nearby_root), "%s", + app->nearby_root); + if (package_id) + snprintf(job->package_id, sizeof(job->package_id), "%s", package_id); + snprintf(job->active_model_id, sizeof(job->active_model_id), "%s", + app->session.active_model_id); + job->removed_model_count = app->settings.removed_model_count; + if (job->removed_model_count > BONGO_CAT_MODEL_CAP) + job->removed_model_count = BONGO_CAT_MODEL_CAP; + memcpy(job->removed_models, app->settings.removed_models, + job->removed_model_count * sizeof(job->removed_models[0])); + refresh->busy = true; + refresh->active_nearby = include_nearby; + refresh->worker = SDL_CreateThread(refresh_worker, + BONGO_CAT_SLUG "-model-catalog", job); + if (refresh->worker) return true; + refresh->busy = false; + free(job); + return false; +} + +static void request_refresh(BongoCatApp *app, bool include_nearby, + const char *package_id) { + if (!app) return; + if (!app->model_refresh) app->model_refresh = create_refresh(); + BongoCatModelRefresh *refresh = app->model_refresh; + if (!refresh) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Background model refresh is unavailable: %s", SDL_GetError()); + return; + } + if (refresh->busy) { + if (!package_id || include_nearby) { + refresh->rerun_full = true; + refresh->rerun_nearby = refresh->rerun_nearby || include_nearby; + refresh->pending_package_count = 0; + return; + } + if (refresh->rerun_full) return; + for (size_t i = 0; i < refresh->pending_package_count; ++i) + if (!strcmp(refresh->pending_packages[i], package_id)) return; + if (refresh->pending_package_count < BONGO_CAT_MODEL_CAP) + snprintf(refresh->pending_packages[ + refresh->pending_package_count++], BONGO_CAT_ID_CAP, + "%s", package_id); + else { + refresh->rerun_full = true; + refresh->pending_package_count = 0; + } + return; + } + if (!start_refresh(app, include_nearby, package_id)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot start background model refresh: %s", SDL_GetError()); +} + +void bongo_cat_app_request_model_refresh(BongoCatApp *app) { + request_refresh(app, false, NULL); +} + +bool bongo_cat_app_model_refresh_busy(const BongoCatApp *app) { + return app && app->model_refresh && app->model_refresh->busy; +} + +void bongo_cat_app_request_model_package_refresh(BongoCatApp *app, + const char *package_id) { + if (!package_id || !package_id[0]) return; + request_refresh(app, false, package_id); +} + +void bongo_cat_app_request_nearby_model_refresh(BongoCatApp *app) { + if (SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + request_refresh(app, true, NULL); +} + +void bongo_cat_model_refresh_invalidate(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh) return; + refresh->revision++; + if (refresh->busy) { + refresh->rerun_full = true; + refresh->rerun_nearby = refresh->rerun_nearby || + refresh->active_nearby; + refresh->pending_package_count = 0; + } +} + +void bongo_cat_model_refresh_update(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh || !refresh->busy) return; + SDL_LockMutex(refresh->mutex); + BongoCatModelRefreshJob *job = refresh->completed; + refresh->completed = NULL; + SDL_UnlockMutex(refresh->mutex); + if (!job) return; + + if (refresh->worker) SDL_WaitThread(refresh->worker, NULL); + refresh->worker = NULL; + refresh->busy = false; + if (job->success && job->revision == refresh->revision) { + bool changed = app->models.count != job->models.count || + memcmp(app->models.entries, job->models.entries, + job->models.count * sizeof(job->models.entries[0])) != 0; + if (changed) { + app->models = job->models; + if (job->package_id[0]) + bongo_cat_model_catalog_finish_package(app, job->package_id); + else bongo_cat_model_catalog_finish(app); + bongo_cat_preferences_models_changed(app->preferences); + } + SDL_Log("Background model refresh completed: models=%llu changed=%d " + "elapsed_ms=%.1f", (unsigned long long)job->models.count, + changed, (SDL_GetTicksNS() - job->started_ns) / 1000000.0); + } else if (!job->success) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Background model refresh failed: %s", + job->error.message[0] ? job->error.message : "not enough memory"); + free(job); + bool rerun_full = refresh->rerun_full; + bool include_nearby = refresh->rerun_nearby; + char package_id[BONGO_CAT_ID_CAP]; + package_id[0] = '\0'; + if (rerun_full) { + refresh->rerun_full = false; + refresh->rerun_nearby = false; + refresh->pending_package_count = 0; + } else if (refresh->pending_package_count) { + snprintf(package_id, sizeof(package_id), "%s", + refresh->pending_packages[0]); + memmove(refresh->pending_packages, refresh->pending_packages + 1, + (refresh->pending_package_count - 1) * + sizeof(refresh->pending_packages[0])); + --refresh->pending_package_count; + } + if ((rerun_full || package_id[0]) && !start_refresh(app, include_nearby, + package_id[0] ? package_id : NULL)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot restart background model refresh: %s", SDL_GetError()); + bongo_cat_preferences_invalidate(app->preferences); +} + +bool bongo_cat_model_refresh_event(BongoCatApp *app, + const SDL_Event *event) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh || !event || event->type != refresh->event_type || + event->user.data1 != refresh) return false; + bongo_cat_model_refresh_update(app); + return true; +} + +void bongo_cat_model_refresh_shutdown(BongoCatApp *app) { + BongoCatModelRefresh *refresh = app ? app->model_refresh : NULL; + if (!refresh) return; + if (refresh->worker) SDL_WaitThread(refresh->worker, NULL); + SDL_LockMutex(refresh->mutex); + BongoCatModelRefreshJob *job = refresh->completed; + refresh->completed = NULL; + SDL_UnlockMutex(refresh->mutex); + free(job); + SDL_Event event; + while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, refresh->event_type, + refresh->event_type) > 0) {} + SDL_DestroyMutex(refresh->mutex); + free(refresh); + app->model_refresh = NULL; +} diff --git a/src/runtime/model/model_catalog_remove.c b/src/runtime/model/model_catalog_remove.c new file mode 100644 index 00000000..29b0328a --- /dev/null +++ b/src/runtime/model/model_catalog_remove.c @@ -0,0 +1,152 @@ +#include "runtime.h" +#include "model_catalog_selection.h" +#include "model_import.h" +#include "model_import_lock.h" +#include "model_storage.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static size_t models_using_storage(const BongoCatModelCatalog *models, + const char *directory) { + size_t count = 0; + if (!models || !directory || !directory[0]) return 0; + for (size_t i = 0; i < models->count; ++i) + if (!strcmp(models->entries[i].storage_directory, directory)) count++; + return count; +} + +static bool storage_package_id(const char *directory, + char output[BONGO_CAT_ID_CAP]) { + const char *name = directory ? bongo_cat_path_name(directory) : NULL; + return name && name[0] && bongo_cat_import_package_id(output, + BONGO_CAT_ID_CAP, name); +} + +BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, + BongoCatError *error) { + if (!app || !id) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Missing model id"); + return BONGO_CAT_ERROR_ARGUMENT; + } + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + if (!entry) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + if (entry->preset || entry->managed) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, entry->managed + ? "Nearby models are managed by their source directory: %s" + : "Built-in models cannot be removed: %s", id); + return BONGO_CAT_ERROR_ARGUMENT; + } + bool primary = !strcmp(id, app->session.active_model_id) || + !strcmp(id, app->loaded_model); + bool additional = !primary && bongo_cat_app_model_active(app, id); + BongoCatModelSelection previous_selection = + bongo_cat_model_selection_capture(app); + char directory[BONGO_CAT_PATH_CAP]; + snprintf(directory, sizeof(directory), "%s", entry->storage_directory); + char package_id[BONGO_CAT_ID_CAP]; + if (!directory[0] || !storage_package_id(directory, package_id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model storage directory is invalid: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + bool shared_storage = models_using_storage(&app->models, directory) > 1; + bongo_cat_settings_validate(&app->settings); + bool already_removed = bongo_cat_settings_model_removed( + &app->settings, id); + if (shared_storage && !already_removed && + app->settings.removed_model_count >= BONGO_CAT_MODEL_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Too many removed model versions are recorded"); + return BONGO_CAT_ERROR_FORMAT; + } + if (additional) { + BongoCatError deactivate_error = {0}; + if (!bongo_cat_app_set_model_active(app, id, false, + &deactivate_error)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Cannot stop the active model: %s", + deactivate_error.message); + return BONGO_CAT_ERROR_PLATFORM; + } + } else if (primary) { + BongoCatError load_error = {0}; + bool replacement = false; + for (size_t i = 0; i < app->models.count; ++i) + if (strcmp(app->models.entries[i].id, id) && + bongo_cat_app_select_model_with_error(app, + app->models.entries[i].id, &load_error)) { + replacement = true; + break; + } + if (!replacement) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, + "Cannot delete the active model because no replacement could be displayed: %s", + load_error.message[0] ? load_error.message : + "no installed models"); + return BONGO_CAT_ERROR_CUBISM; + } + } + if (shared_storage && !already_removed && + !bongo_cat_settings_set_model_removed(&app->settings, id, true)) { + if (primary) { + BongoCatError restore_error = {0}; + if (!bongo_cat_app_select_model_with_error(app, id, + &restore_error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after recording deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + if (additional) + bongo_cat_model_selection_restore(app, &previous_selection); + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Cannot record removed model version: %s", id); + return BONGO_CAT_ERROR_FORMAT; + } + bool removed = true; + if (!shared_storage) { + bongo_cat_import_storage_lock(); + removed = bongo_cat_model_remove_tree(directory, error); + bongo_cat_import_storage_unlock(); + } + if (!removed) { + bongo_cat_app_rescan_models(app); + bool restore_selection = additional; + if (primary && bongo_cat_models_find(&app->models, id)) { + BongoCatError restore_error = {0}; + restore_selection = bongo_cat_app_select_model_with_error(app, id, + &restore_error); + if (!restore_selection) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore model after deletion failed: %s", + restore_error.message[0] ? restore_error.message : + "unknown error"); + } + if (restore_selection) + bongo_cat_model_selection_restore(app, &previous_selection); + return BONGO_CAT_ERROR_IO; + } + if (!shared_storage) + bongo_cat_settings_restore_model_package(&app->settings, package_id); + bongo_cat_app_forget_behavior_state(app, id); + bongo_cat_settings_set_model_label(&app->settings, id, ""); + size_t output = 0; + size_t id_length = strlen(id); + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *shortcut = &app->settings.behavior_shortcuts[i]; + if (!strncmp(shortcut->id, id, id_length) && + shortcut->id[id_length] == ':') continue; + app->settings.behavior_shortcuts[output++] = *shortcut; + } + app->settings.behavior_shortcut_count = output; + bongo_cat_app_rescan_models(app); + return BONGO_CAT_OK; +} diff --git a/src/runtime/model/model_catalog_runtime.c b/src/runtime/model/model_catalog_runtime.c new file mode 100644 index 00000000..9c2a48e9 --- /dev/null +++ b/src/runtime/model/model_catalog_runtime.c @@ -0,0 +1,216 @@ +#include "runtime.h" +#include "model_catalog_selection.h" +#include "model_import.h" +#include "model_import_lock.h" +#include "model_storage.h" +#include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include +#include + +static BongoCatModelCatalog *model_catalog_snapshot( + const BongoCatModelCatalog *models) { + if (!models) return NULL; + BongoCatModelCatalog *snapshot = malloc(sizeof(*snapshot)); + if (snapshot) *snapshot = *models; + return snapshot; +} + +static void restore_model_catalog(BongoCatApp *app, + const BongoCatModelCatalog *snapshot, const char *active_model_id) { + if (!app || !snapshot) return; + app->models = *snapshot; + if (active_model_id) + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); +} + +static BongoCatResult scan_nearby_root(BongoCatApp *app, const char *root) { + if (!root || !root[0] || !bongo_cat_path_is_dir(root)) + return BONGO_CAT_OK; + size_t before = app->models.count; + BongoCatError error = {0}; + BongoCatResult result = bongo_cat_import_nearby_scan(app, root, + &error); + size_t added = app->models.count - before; + if (added) SDL_Log("Nearby model scan added %llu models from %s", + (unsigned long long)added, root); + if (result != BONGO_CAT_OK && error.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + return result; +} + +bool bongo_cat_model_catalog_add_bundled(BongoCatApp *app, bool replace) { + char root[BONGO_CAT_PATH_CAP]; + if (!app || !bongo_cat_path_join(root, sizeof(root), app->asset_root, + "models")) return false; + BongoCatModelCatalog *bundled = calloc(1, sizeof(*bundled)); + if (!bundled) return false; + BongoCatError error = {0}; + BongoCatResult result = bongo_cat_models_scan(bundled, root, true, &error); + for (size_t i = 0; i < bundled->count; ++i) { + const BongoCatModelEntry *entry = &bundled->entries[i]; + size_t index = 0; + while (index < app->models.count && + strcmp(app->models.entries[index].id, entry->id)) index++; + if (index < app->models.count && !replace) continue; + if (index >= BONGO_CAT_MODEL_CAP) continue; + app->models.entries[index] = *entry; + if (index == app->models.count) app->models.count++; + } + bool available = bundled->count > 0 && result == BONGO_CAT_OK; + if (!available) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Bundled model scan failed: path=%s error=%s", root, + error.message[0] ? error.message : "no bundled models found"); + free(bundled); + return available; +} + +static BongoCatResult scan_owned_models(BongoCatApp *app, bool cleanup) { + BongoCatError error = {0}; + bongo_cat_models_init(&app->models); + bongo_cat_import_storage_lock(); + BongoCatResult result = bongo_cat_model_install_builtins(app->asset_root, + app->models_root, &error); + if (result != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Built-in model installation failed; using available models: " + "path=%s error=%s", app->models_root, error.message); + error = (BongoCatError){0}; + if (cleanup && !bongo_cat_model_cleanup_imports(app->models_root, &error)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model cleanup failed; continuing scan: path=%s error=%s", + app->models_root, error.message); + error = (BongoCatError){0}; + result = bongo_cat_models_scan(&app->models, app->models_root, false, + &error); + bongo_cat_import_storage_unlock(); + if (result != BONGO_CAT_OK) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Stored built-in model scan incomplete: path=%s error=%s", + app->models_root, error.message); + /* Establish fallback entries before optional discovery can fail or fill + the catalog. Bundled assets do not depend on writable model storage. */ + bongo_cat_model_catalog_add_bundled(app, false); + error = (BongoCatError){0}; + result = bongo_cat_import_installed_models(app, app->models_root, &error); + if (result != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model scan incomplete: path=%s result=%d error=%s", + app->models_root, (int)result, + error.message[0] ? error.message : "scan unavailable"); + return app->models.count ? BONGO_CAT_OK : + (result != BONGO_CAT_OK ? result : BONGO_CAT_ERROR_IO); +} + +void bongo_cat_model_catalog_finish(BongoCatApp *app) { + if (!app) return; + /* Absence from a partial scan does not mean the user deleted a model. */ + bool selection_changed = bongo_cat_model_catalog_reconcile(app); + for (size_t i = 0; i < app->models.count; ++i) + bongo_cat_import_apply_metadata(app, app->models.entries[i].id, + app->models.entries[i].adapter_directory); + if (selection_changed && app->preferences) bongo_cat_preferences_models_changed(app->preferences); +} + +BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, + const char *nearby_root) { + if (!app) return BONGO_CAT_ERROR_ARGUMENT; + BongoCatResult result = scan_owned_models(app, cleanup); + if (nearby_root && !SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) { + /* Nearby folders are an optional convenience (the default root is + often the folder from which the portable executable was launched). + A malformed third-party model must not make the built-in catalog + disappear: the scan can add valid entries before it reports the + malformed one. Keep those entries and let startup continue. */ + BongoCatResult nearby = scan_nearby_root(app, nearby_root); + if (nearby != BONGO_CAT_OK) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model scan was incomplete; continuing with %llu " + "usable model(s)", + (unsigned long long)app->models.count); + } + return app->models.count ? BONGO_CAT_OK : result; +} + +void bongo_cat_app_rescan_models(BongoCatApp *app) { + if (!app) return; + bongo_cat_model_refresh_invalidate(app); + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); + if (!previous) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model catalog rescan skipped: cannot allocate catalog snapshot"); + return; + } + char active_model_id[BONGO_CAT_ID_CAP]; + snprintf(active_model_id, sizeof(active_model_id), "%s", + app->session.active_model_id); + BongoCatResult result = bongo_cat_model_catalog_scan(app, true, + app->nearby_root); + if (result != BONGO_CAT_OK) { + restore_model_catalog(app, previous, active_model_id); + free(previous); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Model catalog rescan failed; keeping previous catalog"); + return; + } + free(previous); + bongo_cat_model_catalog_finish(app); +} + +void bongo_cat_app_refresh_installed_models(BongoCatApp *app) { + if (!app) return; + bongo_cat_model_refresh_invalidate(app); + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); + if (!previous) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model refresh skipped: cannot allocate catalog snapshot"); + return; + } + BongoCatResult result = scan_owned_models(app, false); + if (result != BONGO_CAT_OK) { + restore_model_catalog(app, previous, NULL); + free(previous); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Installed model refresh failed; keeping previous catalog"); + return; + } + for (size_t i = 0; i < previous->count && + app->models.count < BONGO_CAT_MODEL_CAP; ++i) { + const BongoCatModelEntry *entry = &previous->entries[i]; + if (!entry->managed || bongo_cat_models_find(&app->models, entry->id)) + continue; + app->models.entries[app->models.count++] = *entry; + } + free(previous); + bongo_cat_model_catalog_finish(app); +} + +void bongo_cat_app_refresh_nearby_models(BongoCatApp *app) { + if (!app || SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) return; + bongo_cat_model_refresh_invalidate(app); + BongoCatModelCatalog *previous = model_catalog_snapshot(&app->models); + if (!previous) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model refresh skipped: cannot allocate catalog snapshot"); + return; + } + char active_model_id[BONGO_CAT_ID_CAP]; + snprintf(active_model_id, sizeof(active_model_id), "%s", + app->session.active_model_id); + BongoCatResult result = bongo_cat_model_catalog_scan(app, false, + app->nearby_root); + if (result != BONGO_CAT_OK) { + restore_model_catalog(app, previous, active_model_id); + free(previous); + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Nearby model refresh failed; keeping previous catalog"); + return; + } + free(previous); + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active_model_id); + bongo_cat_model_catalog_finish(app); +} diff --git a/src/runtime/model/model_catalog_selection.c b/src/runtime/model/model_catalog_selection.c new file mode 100644 index 00000000..ee6e49b2 --- /dev/null +++ b/src/runtime/model/model_catalog_selection.c @@ -0,0 +1,106 @@ +#include "model_catalog_selection.h" + +#include +#include +#include + +BongoCatModelSelection bongo_cat_model_selection_capture( + const BongoCatApp *app) { + BongoCatModelSelection selection = {0}; + if (!app) return selection; + selection.count = app->session.additional_model_count; + if (selection.count > BONGO_CAT_ADDITIONAL_MODEL_CAP) + selection.count = BONGO_CAT_ADDITIONAL_MODEL_CAP; + memcpy(selection.additional, app->session.additional_model_ids, + selection.count * sizeof(selection.additional[0])); + return selection; +} + +void bongo_cat_model_selection_restore(BongoCatApp *app, + const BongoCatModelSelection *selection) { + if (!app || !selection) return; + bongo_cat_session_clear_additional_models(&app->session); + for (size_t i = 0; i < selection->count; ++i) + if (bongo_cat_models_find(&app->models, selection->additional[i])) + bongo_cat_session_add_model(&app->session, + selection->additional[i]); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} + +static const BongoCatModelEntry *fallback_model( + const BongoCatModelCatalog *models) { + static const char *const ids[] = {"standard", "keyboard", "gamepad"}; + for (size_t i = 0; i < sizeof(ids) / sizeof(ids[0]); ++i) { + const BongoCatModelEntry *entry = bongo_cat_models_find(models, ids[i]); + if (entry) return entry; + } + return models && models->count ? &models->entries[0] : NULL; +} + +static bool selection_matches(const BongoCatApp *app, + const BongoCatModelSelection *selection) { + if (!app || !selection || app->session.additional_model_count != + selection->count) return false; + return !memcmp(app->session.additional_model_ids, selection->additional, + selection->count * sizeof(selection->additional[0])); +} + +static void clear_loaded_model(BongoCatApp *app) { + if (!app) return; + app->loaded_model[0] = '\0'; + app->loading_model[0] = '\0'; + app->loaded_mode = BONGO_CAT_MODE_STANDARD; + bongo_cat_gamepads_set_enabled(app, false); +} + +bool bongo_cat_model_catalog_reconcile(BongoCatApp *app) { + if (!app) return false; + BongoCatModelSelection selection = + bongo_cat_model_selection_capture(app); + const BongoCatModelEntry *active = bongo_cat_models_find(&app->models, + app->session.active_model_id); + const BongoCatModelEntry *loaded = bongo_cat_models_find(&app->models, + app->loaded_model); + bool changed = false; + if (!active) { + active = loaded ? loaded : fallback_model(&app->models); + if (active) { + if (strcmp(app->session.active_model_id, active->id) != 0) { + snprintf(app->session.active_model_id, + sizeof(app->session.active_model_id), "%s", active->id); + changed = true; + } + } else if (app->session.active_model_id[0]) { + app->session.active_model_id[0] = '\0'; + changed = true; + } + } + if (app->settings.model.multiple_pets) { + bongo_cat_model_selection_restore(app, &selection); + if (!selection_matches(app, &selection)) changed = true; + } else if (app->session.additional_model_count) { + bongo_cat_session_clear_additional_models(&app->session); + changed = true; + } + if (app->loaded_model[0] && !loaded) { + if (active && app->live2d) { + BongoCatError error = {0}; + if (!bongo_cat_app_select_model_with_error(app, active->id, + &error)) { + if (error.message[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Unable to restore an available model after refresh: %s", + error.message); + clear_loaded_model(app); + changed = true; + } + } else { + clear_loaded_model(app); + changed = true; + } + } + if (!active && app->session.additional_model_count) { + bongo_cat_session_clear_additional_models(&app->session); + changed = true; + } + return changed; +} diff --git a/src/runtime/model/model_catalog_selection.h b/src/runtime/model/model_catalog_selection.h new file mode 100644 index 00000000..5d1edf00 --- /dev/null +++ b/src/runtime/model/model_catalog_selection.h @@ -0,0 +1,17 @@ +#ifndef BONGO_CAT_MODEL_CATALOG_SELECTION_H +#define BONGO_CAT_MODEL_CATALOG_SELECTION_H + +#include "runtime.h" + +typedef struct BongoCatModelSelection { + char additional[BONGO_CAT_ADDITIONAL_MODEL_CAP][BONGO_CAT_ID_CAP]; + size_t count; +} BongoCatModelSelection; + +BongoCatModelSelection bongo_cat_model_selection_capture( + const BongoCatApp *app); +void bongo_cat_model_selection_restore(BongoCatApp *app, + const BongoCatModelSelection *selection); +bool bongo_cat_model_catalog_reconcile(BongoCatApp *app); + +#endif diff --git a/src/runtime/model/model_cover.c b/src/runtime/model/model_cover.c new file mode 100644 index 00000000..eb08103c --- /dev/null +++ b/src/runtime/model/model_cover.c @@ -0,0 +1,137 @@ +#include "model_cover.h" + +#include "preferences_model_cover.h" +#include "runtime.h" +#include "bongo_cat/file.h" + +#include +#include +#include +#include +#include + +typedef struct CoverWriter { + FILE *file; + bool ok; +} CoverWriter; + +void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app) { + if (!app || !app->loaded_model[0]) return; + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, + app->loaded_model); + if (!entry) return; + bongo_cat_model_cover_schedule(app, entry); + if (!bongo_cat_model_cover_pending(app)) return; + if (!bongo_cat_app_capture_pending_model_cover(app)) + SDL_Log("Model cover refresh before switch deferred: id=%s", + entry->id); +} + +static void cover_write(void *context, void *data, int size) { + CoverWriter *writer = context; + if (writer->ok && fwrite(data, 1, (size_t)size, writer->file) != + (size_t)size) + writer->ok = false; +} + +static void flip_cover_pixels(unsigned char *pixels, int height, size_t pitch) { + for (int y = 0; y < height / 2; ++y) { + unsigned char *top = pixels + (size_t)y * pitch; + unsigned char *bottom = pixels + (size_t)(height - y - 1) * pitch; + for (size_t i = 0; i < pitch; ++i) { + unsigned char value = top[i]; + top[i] = bottom[i]; + bottom[i] = value; + } + } +} + +static unsigned char *read_cover_pixels(const char *path, int width, + int height, size_t pitch, size_t bytes) { + unsigned char *pixels = malloc(bytes); + if (!pixels) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh allocation failed for %zu bytes: %s", + bytes, path); + return NULL; + } + while (glGetError() != GL_NO_ERROR) {} + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + GLenum error = glGetError(); + if (error != GL_NO_ERROR) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover framebuffer readback failed: path=%s gl_error=0x%x", + path, error); + free(pixels); + return NULL; + } + flip_cover_pixels(pixels, height, pitch); + return pixels; +} + +static bool write_cover_png(const char *temporary, const char *path, + const unsigned char *pixels, int width, int height, size_t pitch) { + FILE *file = bongo_cat_file_open(temporary, "wb"); + CoverWriter writer = {file, file != NULL}; + bool ok = file && stbi_write_png_to_func(cover_write, &writer, + width, height, 4, pixels, (int)pitch) && writer.ok; + if (file && fclose(file) != 0) ok = false; + if (!ok) { + bongo_cat_file_remove(temporary); + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover could not be encoded: %s", path); + return false; + } + if (bongo_cat_file_replace(temporary, path, false)) return true; + bongo_cat_file_remove(temporary); + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover temporary file could not replace target: %s", path); + return false; +} + +bool bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height) { + if (!app || !bongo_cat_model_cover_pending(app)) return false; + uint64_t started_ns = SDL_GetTicksNS(); + const char *pending_path = bongo_cat_model_cover_pending_path(app); + if (!pending_path) return false; + char path[BONGO_CAT_PATH_CAP]; + int path_length = snprintf(path, sizeof(path), "%s", pending_path); + if (path_length < 0 || (size_t)path_length >= sizeof(path)) return false; + if (width < 2 || height < 2) return false; + /* Keep the desktop's native pixel size while bounding readback memory. */ + if (width > 4096 || height > 4096) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh abandoned for unsupported size %dx%d: %s", + width, height, path); + bongo_cat_model_cover_finish(app); + return true; + } + size_t pitch = (size_t)width * 4; + size_t bytes = pitch * (size_t)height; + if (pitch / 4 != (size_t)width || bytes / pitch != (size_t)height) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh skipped due to size overflow: %s", path); + return false; + } + char temporary[BONGO_CAT_PATH_CAP]; + int temporary_length = snprintf(temporary, sizeof(temporary), "%s.tmp.%s", + path, app->secondary_pet ? "secondary" : "primary"); + if (temporary_length < 0 || (size_t)temporary_length >= sizeof(temporary)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover temporary path is too long: %s", path); + bongo_cat_model_cover_finish(app); + return true; + } + unsigned char *pixels = read_cover_pixels(path, width, height, pitch, bytes); + if (!pixels) return false; + bool written = write_cover_png(temporary, path, pixels, + width, height, pitch); + free(pixels); + if (!written) return false; + bongo_cat_model_cover_finish(app); + if (app->preferences) + bongo_cat_preferences_model_cover_reload(app, path); + SDL_Log("Model cover refreshed: path=%s (%dx%d) elapsed_ms=%.2f", path, + width, height, (double)(SDL_GetTicksNS() - started_ns) / 1000000.0); + return true; +} diff --git a/src/runtime/model/model_cover.h b/src/runtime/model/model_cover.h new file mode 100644 index 00000000..ab96e375 --- /dev/null +++ b/src/runtime/model/model_cover.h @@ -0,0 +1,20 @@ +#ifndef BONGO_CAT_MODEL_COVER_H +#define BONGO_CAT_MODEL_COVER_H + +#include "bongo_cat/app.h" +#include "model_cover_paths.h" + +/* Pending cover queue and retry policy. */ +void bongo_cat_model_cover_schedule(BongoCatApp *app, + const BongoCatModelEntry *entry); +bool bongo_cat_model_cover_pending(const BongoCatApp *app); +bool bongo_cat_model_cover_capture_due(const BongoCatApp *app, uint64_t now); +const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app); +void bongo_cat_model_cover_defer(BongoCatApp *app, const char *reason); +void bongo_cat_model_cover_finish(BongoCatApp *app); + +/* Capture workflow and framebuffer readback. */ +void bongo_cat_model_cover_capture_before_switch(BongoCatApp *app); +bool bongo_cat_model_cover_capture(BongoCatApp *app, int width, int height); + +#endif diff --git a/src/runtime/model/model_cover_paths.h b/src/runtime/model/model_cover_paths.h new file mode 100644 index 00000000..ee543522 --- /dev/null +++ b/src/runtime/model/model_cover_paths.h @@ -0,0 +1,7 @@ +#ifndef BONGO_CAT_MODEL_COVER_PATHS_H +#define BONGO_CAT_MODEL_COVER_PATHS_H + +#define BONGO_CAT_MODEL_COVER_RESOURCES "resources" +#define BONGO_CAT_MODEL_COVER_FILE BONGO_CAT_MODEL_COVER_RESOURCES "/cover.png" + +#endif diff --git a/src/runtime/model/model_cover_queue.c b/src/runtime/model/model_cover_queue.c new file mode 100644 index 00000000..1ebb456c --- /dev/null +++ b/src/runtime/model/model_cover_queue.c @@ -0,0 +1,159 @@ +#include "model_cover.h" + +#include "bongo_cat/path.h" + +#include +#include +#include + +enum { + COVER_RETRY_INITIAL_MS = 250, + COVER_RETRY_MAX_MS = 5000 +}; + +static size_t find_task(const BongoCatApp *app, const char *model_id) { + if (!app || !model_id || !model_id[0]) return SIZE_MAX; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) + if (strcmp(app->pending_model_cover_ids[i], model_id) == 0) + return i; + return SIZE_MAX; +} + +static void remove_task_at(BongoCatApp *app, size_t index) { + if (!app || index >= app->pending_model_cover_count) return; + size_t remaining = app->pending_model_cover_count - index - 1; + if (remaining) { + memmove(app->pending_model_cover_ids + index, + app->pending_model_cover_ids + index + 1, + remaining * sizeof(app->pending_model_cover_ids[0])); + memmove(app->pending_model_cover_paths + index, + app->pending_model_cover_paths + index + 1, + remaining * sizeof(app->pending_model_cover_paths[0])); + memmove(app->pending_model_cover_retry_ns + index, + app->pending_model_cover_retry_ns + index + 1, + remaining * sizeof(app->pending_model_cover_retry_ns[0])); + memmove(app->pending_model_cover_attempts + index, + app->pending_model_cover_attempts + index + 1, + remaining * sizeof(app->pending_model_cover_attempts[0])); + } + app->pending_model_cover_count--; + app->pending_model_cover_ids[app->pending_model_cover_count][0] = '\0'; + app->pending_model_cover_paths[app->pending_model_cover_count][0] = '\0'; + app->pending_model_cover_retry_ns[app->pending_model_cover_count] = 0; + app->pending_model_cover_attempts[app->pending_model_cover_count] = 0; +} + +static void discard_removed_tasks(BongoCatApp *app) { + if (!app || !app->models.count) return; + for (size_t i = 0; i < app->pending_model_cover_count;) { + if (bongo_cat_models_find(&app->models, + app->pending_model_cover_ids[i])) { + ++i; + continue; + } + SDL_Log("Discarding model cover task for removed model: id=%s", + app->pending_model_cover_ids[i]); + remove_task_at(app, i); + } +} + +static bool discard_oldest_task(BongoCatApp *app, + const char *keep_model_id) { + if (!app) return false; + size_t candidate = SIZE_MAX; + for (size_t i = 0; i < app->pending_model_cover_count; ++i) { + if (strcmp(app->pending_model_cover_ids[i], keep_model_id) == 0) + continue; + if (app->pending_model_cover_attempts[i]) { + candidate = i; + break; + } + if (candidate == SIZE_MAX) candidate = i; + } + if (candidate == SIZE_MAX) return false; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Discarding superseded model cover task: id=%s attempts=%u", + app->pending_model_cover_ids[candidate], + app->pending_model_cover_attempts[candidate]); + remove_task_at(app, candidate); + return true; +} + +void bongo_cat_model_cover_schedule(BongoCatApp *app, + const BongoCatModelEntry *entry) { + if (!app || !entry || !entry->adapter_directory[0]) return; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, + BONGO_CAT_MODEL_COVER_FILE)) return; + discard_removed_tasks(app); + for (size_t i = 0; i < app->pending_model_cover_count; ++i) { + if (strcmp(app->pending_model_cover_ids[i], entry->id) != 0 && + strcmp(app->pending_model_cover_paths[i], path) != 0) continue; + /* A model use is a fresh request, so clear any previous backoff. */ + snprintf(app->pending_model_cover_ids[i], + sizeof(app->pending_model_cover_ids[i]), "%s", entry->id); + snprintf(app->pending_model_cover_paths[i], + sizeof(app->pending_model_cover_paths[i]), "%s", path); + app->pending_model_cover_retry_ns[i] = 0; + app->pending_model_cover_attempts[i] = 0; + app->dirty = true; + SDL_Log("Rescheduling model cover refresh: id=%s path=%s", + entry->id, path); + return; + } + if (app->pending_model_cover_count >= BONGO_CAT_MODEL_COVER_PENDING_CAP && + !discard_oldest_task(app, entry->id)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover queue is full; dropping refresh: id=%s", entry->id); + return; + } + size_t index = app->pending_model_cover_count++; + snprintf(app->pending_model_cover_ids[index], + sizeof(app->pending_model_cover_ids[index]), "%s", entry->id); + snprintf(app->pending_model_cover_paths[index], + sizeof(app->pending_model_cover_paths[index]), "%s", path); + app->pending_model_cover_retry_ns[index] = 0; + app->pending_model_cover_attempts[index] = 0; + SDL_Log("Scheduling model cover refresh: id=%s source=runtime path=%s", + entry->id, path); + app->dirty = true; +} + +bool bongo_cat_model_cover_pending(const BongoCatApp *app) { + return find_task(app, app ? app->loaded_model : NULL) != SIZE_MAX; +} + +bool bongo_cat_model_cover_capture_due(const BongoCatApp *app, uint64_t now) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + return index != SIZE_MAX && + (!app->pending_model_cover_retry_ns[index] || + now >= app->pending_model_cover_retry_ns[index]); +} + +const char *bongo_cat_model_cover_pending_path(const BongoCatApp *app) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + return index == SIZE_MAX ? NULL : app->pending_model_cover_paths[index]; +} + +void bongo_cat_model_cover_defer(BongoCatApp *app, const char *reason) { + if (!app) return; + size_t index = find_task(app, app->loaded_model); + if (index == SIZE_MAX) return; + unsigned attempts = ++app->pending_model_cover_attempts[index]; + unsigned shift = attempts > 5 ? 5 : attempts - 1; + uint64_t delay_ms = (uint64_t)COVER_RETRY_INITIAL_MS << shift; + if (delay_ms > COVER_RETRY_MAX_MS) delay_ms = COVER_RETRY_MAX_MS; + app->pending_model_cover_retry_ns[index] = + SDL_GetTicksNS() + delay_ms * 1000000ull; + app->dirty = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Model cover refresh deferred: model=%s attempt=%u retry_ms=%llu " + "reason=%s path=%s", app->loaded_model, attempts, + (unsigned long long)delay_ms, reason && reason[0] ? reason : "unknown", + app->pending_model_cover_paths[index]); +} + +void bongo_cat_model_cover_finish(BongoCatApp *app) { + size_t index = find_task(app, app ? app->loaded_model : NULL); + if (index != SIZE_MAX) remove_task_at(app, index); +} diff --git a/src/runtime/model/model_files.c b/src/runtime/model/model_files.c new file mode 100644 index 00000000..ae865c4a --- /dev/null +++ b/src/runtime/model/model_files.c @@ -0,0 +1,284 @@ +#include "runtime.h" +#include "model_import.h" +#include "model_behavior_cache.h" +#include "model_cover.h" +#include "model_geometry.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/overlay.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include +#include +#include +static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry) { + if (app->settings.model.multiple_pets) + bongo_cat_session_remove_model(&app->session, entry->id); + else bongo_cat_session_clear_additional_models(&app->session); + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), "%s", + entry->id); + app->loaded_mode = entry->mode; + bongo_cat_gamepads_set_enabled(app, entry->mode == BONGO_CAT_MODE_GAMEPAD); +} +static void request_model_frame(BongoCatApp *app, bool reveal) { + if (!app) return; + if (app->window) { + SDL_WindowFlags flags = SDL_GetWindowFlags(app->window); + bool hidden = (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) != 0; + if (reveal && (!app->session.window.visible || hidden || + app->hover_hidden)) + bongo_cat_window_set_visible(app, true); + else if (app->session.window.visible) + bongo_cat_window_clamp_to_display(app); + } + bongo_cat_window_mark_hit_dirty(app); + app->dirty = true; +} + +static void model_runtime_stage(BongoCatApp *app, const char *stage, + const BongoCatModelEntry *entry) { + char state[BONGO_CAT_ID_CAP + 32]; + const char *id = entry ? entry->id : "unknown"; + snprintf(state, sizeof(state), "model-load:%s:%s", stage, id); + bongo_cat_runtime_stage(app, state); + SDL_Log("[runtime] Model load %s: id=%s", stage, id); +} + +static void model_running_stage(BongoCatApp *app, const char *model_id) { + char state[BONGO_CAT_ID_CAP + 16]; + snprintf(state, sizeof(state), "running:model:%s", + model_id && model_id[0] ? model_id : "none"); + bongo_cat_runtime_stage(app, state); +} + +static void model_progress_runtime_stage(BongoCatApp *app, float progress) { + static const char *names[] = {"", "model-core", "texture-loading", + "finalizing"}; + unsigned stage = progress >= .95f ? 3 : progress >= .50f ? 2 : 1; + if (!app || stage == app->model_load_runtime_stage) return; + app->model_load_runtime_stage = stage; + char state[BONGO_CAT_ID_CAP + 40]; + snprintf(state, sizeof(state), "model-load:%s:%s", names[stage], + app->loading_model[0] ? app->loading_model : "unknown"); + bongo_cat_runtime_stage(app, state); +} + +static void model_load_progress(void *userdata, float progress) { + BongoCatApp *app = userdata; + model_progress_runtime_stage(app, progress); + /* Keep native window procedures responsive while the main loop is + synchronously loading Cubism and OpenGL resources. */ + SDL_PumpEvents(); + if (app && app->preferences) + bongo_cat_preferences_model_load_progress(app->preferences, progress); + /* The main loop is blocked during loading, so advance the visible model here. */ + if (!app || !app->loaded_model[0] || !app->model_load_last_frame_ns || + !app->live2d) + return; + uint64_t now = SDL_GetTicksNS(); + if (progress < .98f && now - app->model_load_last_frame_ns < 16000000ull) + return; + uint64_t previous = app->model_load_last_frame_ns; + app->model_load_last_frame_ns = now; + float elapsed = (float)((now - previous) / 1000000000.0); + bongo_cat_app_drain_input(app, false); + now = SDL_GetTicksNS(); + bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); + if (!app->smoke_freeze_model && elapsed > 0.0f) + bongo_cat_app_step_live2d(app, elapsed); + app->last_frame_ns = now; + bongo_cat_app_render_now(app); +} + +static void commit_model(BongoCatApp *app, + const BongoCatModelEntry *entry, bool force_refresh, bool reveal) { + bool changed = strcmp(app->session.active_model_id, entry->id) != 0 || + app->loaded_mode != entry->mode; + if (!changed && !force_refresh) return; + select_model_state(app, entry); + app->model_selection_serial++; + request_model_frame(app, reveal); +} + +bool bongo_cat_app_select_model_with_error(BongoCatApp *app, + const char *id, BongoCatError *error) { + BongoCatError local = {0}; + BongoCatError *failure = error ? error : &local; + *failure = (BongoCatError){0}; + if (!app || !app->live2d || !id) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_ARGUMENT, + "Cannot select a model without an active renderer and model id"); + return false; + } + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + if (!entry) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id); + return false; + } + bongo_cat_window_snapshot_end(app); + if (app->loaded_model[0] && strcmp(app->loaded_model, entry->id) == 0) { + commit_model(app, entry, false, false); + return true; + } + bongo_cat_app_capture_behavior_state(app); + bool replacing_model = app->loaded_model[0] != '\0'; + if (replacing_model) + bongo_cat_model_cover_capture_before_switch(app); + BongoCatError optional = {0}; + BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); + if (!behaviors) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_MEMORY, + "Cannot allocate model behavior state"); + return false; + } + bool behavior_catalog_valid = bongo_cat_model_behavior_cache_matches( + app, entry); + if (behavior_catalog_valid) { + *behaviors = *app->behavior_cache; + } else { + behavior_catalog_valid = bongo_cat_behaviors_load( + behaviors, entry, &optional) == BONGO_CAT_OK; + if (!behavior_catalog_valid) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + } + BongoCatLive2DRenderOptions render_options = {0}; + bool adapted_profile = bongo_cat_import_render_options( + entry->adapter_directory, &render_options); + if (adapted_profile) SDL_Log("Imported runtime profile: projection=%.4f " + "force_mouse=%d left_handed=%d pointer_bounds=%d", + render_options.projection_scale, render_options.mouse_force_move, + render_options.pointer_left_handed, render_options.custom_pointer_bounds); + BongoCatModelContentAnchor content_anchor = + bongo_cat_model_content_anchor(app); + int pixel_width = app->session.window.width, pixel_height = app->session.window.height; + if (app->window) SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bool restore_context = previous_window != app->window || + previous_context != app->gl_context; + if (restore_context && !SDL_GL_MakeCurrent(app->window, app->gl_context)) { + bongo_cat_error_set(failure, BONGO_CAT_ERROR_PLATFORM, + "Cannot activate the main OpenGL context: %s", SDL_GetError()); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); + free(behaviors); + return false; + } + char previous_model[BONGO_CAT_ID_CAP]; + snprintf(previous_model, sizeof(previous_model), "%s", + app->loaded_model[0] ? app->loaded_model : "none"); + uint64_t load_started_ns = SDL_GetTicksNS(); + GLenum initial_gl_error = glGetError(); + SDL_Log("[runtime] Model switch transaction: stage=begin previous=%s next=%s " + "main_window=%p main_context=%p current_window=%p current_context=%p " + "gl_error=0x%x", previous_model, entry->id, (void *)app->window, + (void *)app->gl_context, (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext(), (unsigned)initial_gl_error); + bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); + snprintf(app->loading_model, sizeof(app->loading_model), "%s", entry->id); + app->model_load_runtime_stage = 0; + model_runtime_stage(app, "started", entry); + app->model_load_last_frame_ns = replacing_model ? SDL_GetTicksNS() : 0; + BongoCatResult load_result = bongo_cat_live2d_load(app->live2d, entry->directory, + entry->setting_file, entry->preset, &render_options, + model_load_progress, app, failure); + app->model_load_last_frame_ns = 0; + if (replacing_model) app->last_frame_ns = SDL_GetTicksNS(); + if (load_result != BONGO_CAT_OK) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch transaction: " + "stage=failed previous=%s next=%s elapsed_ms=%.2f gl_error=0x%x " + "error=%s", previous_model, entry->id, + (double)(SDL_GetTicksNS() - load_started_ns) / 1000000.0, + (unsigned)glGetError(), failure->message); + if (restore_context && previous_window && previous_context && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Cannot restore the previous OpenGL context: %s", SDL_GetError()); + free(behaviors); + if (!bongo_cat_live2d_ready(app->live2d)) app->loaded_model[0] = '\0'; + request_model_frame(app, false); + app->loading_model[0] = '\0'; + app->model_load_runtime_stage = 0; + model_running_stage(app, app->loaded_model); + return false; + } + model_runtime_stage(app, "committing", entry); + BongoCatParameterRange pointer_x, pointer_y; + bool model_pointer = + bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &pointer_x) && + bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &pointer_y) && + pointer_x.maximum > pointer_x.minimum && + pointer_y.maximum > pointer_y.minimum; + app->model_render_options = render_options; + bongo_cat_app_reset_pointer_tracking(app); + bongo_cat_live2d_set_render_options(app->live2d, &render_options); + if (app->loaded_model[0] && app->behavior_catalog_valid) { + const BongoCatModelEntry *previous_entry = bongo_cat_models_find( + &app->models, app->loaded_model); + bongo_cat_model_behavior_cache_store(app, previous_entry); + } else if (app->loaded_model[0]) { + app->behavior_cache_valid = false; + } + app->behaviors = *behaviors; + app->behavior_catalog_valid = behavior_catalog_valid; + free(behaviors); + optional = (BongoCatError){0}; + if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, + model_pointer, &render_options, &optional) != BONGO_CAT_OK) { + if (optional.message[0]) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); + bongo_cat_overlay_clear(app->overlay); + } + snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); + size_t saved_behaviors = bongo_cat_app_saved_behavior_count(app, entry->id); + bongo_cat_app_restore_behavior_state(app, entry->id); + SDL_Log("[runtime] Behavior restore summary: model=%s saved=%zu " + "motions=%zu expression=%d", entry->id, saved_behaviors, + bongo_cat_app_selected_motion_count(app), + bongo_cat_live2d_expression(app->live2d)); + bongo_cat_model_cover_schedule(app, entry); + bongo_cat_random_expression_reset(app); + app->pointer_known = false; + bool geometry_changed = bongo_cat_model_apply_aspect(app, &render_options, + &content_anchor, replacing_model); + if (app->window) { + if (geometry_changed) SDL_SyncWindow(app->window); + SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); + } + bongo_cat_live2d_resize(app->live2d, pixel_width, pixel_height); + bongo_cat_audio_reset(app->audio); + memset(&app->sound_shortcut_state, 0, sizeof(app->sound_shortcut_state)); + memset(app->sound_shortcut_active, 0, sizeof(app->sound_shortcut_active)); + commit_model(app, entry, true, replacing_model); + bongo_cat_app_reapply_input(app); + bongo_cat_app_apply_mouse(app); + SDL_Log("[runtime] Model cover load phase: id=%s phase=%s", + entry->id, replacing_model ? "switch" : "startup"); + /* Finish the handoff before the independent readback so startup and model + switches both capture the restored state, never the initial state. */ + app->loading_model[0] = '\0'; + app->model_load_runtime_stage = 0; + bongo_cat_app_capture_pending_model_cover(app); + /* Do not leave the previous frame cropped during the UI completion pass. */ + if (replacing_model) bongo_cat_app_render_now(app); + if (restore_context && previous_window && previous_context && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Cannot restore the previous OpenGL context: %s", SDL_GetError()); + bongo_cat_memory_policy_model_loaded(); + SDL_Log("[runtime] Model switch transaction: stage=complete previous=%s " + "next=%s elapsed_ms=%.2f current_window=%p current_context=%p " + "gl_error=0x%x", previous_model, entry->id, + (double)(SDL_GetTicksNS() - load_started_ns) / 1000000.0, + (void *)SDL_GL_GetCurrentWindow(), (void *)SDL_GL_GetCurrentContext(), + (unsigned)glGetError()); + model_runtime_stage(app, "completed", entry); + model_running_stage(app, entry->id); + return true; +} + +bool bongo_cat_app_select_model(BongoCatApp *app, const char *id) { + return bongo_cat_app_select_model_with_error(app, id, NULL); +} diff --git a/src/runtime/model/model_geometry.c b/src/runtime/model/model_geometry.c new file mode 100644 index 00000000..c45247b4 --- /dev/null +++ b/src/runtime/model/model_geometry.c @@ -0,0 +1,73 @@ +#include "model_geometry.h" + +#include + +BongoCatModelContentAnchor bongo_cat_model_content_anchor(BongoCatApp *app) { + BongoCatModelContentAnchor anchor = {0}; + int window_x = 0, window_y = 0; + if (!app || !app->window || + !SDL_GetWindowPosition(app->window, &window_x, &window_y)) return anchor; + int left = 0, top = 0, ignored_width = 0, ignored_height = 0; + int content_width = app->session.window.content_width > 0 + ? app->session.window.content_width : app->session.window.width; + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : app->session.window.height; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &ignored_width, &ignored_height, &left, &top)) return anchor; + anchor.x = window_x + left + content_width / 2; + anchor.y = window_y + top + content_height / 2; + anchor.valid = true; + return anchor; +} + +bool bongo_cat_model_apply_aspect(BongoCatApp *app, + const BongoCatLive2DRenderOptions *options, + const BongoCatModelContentAnchor *anchor, bool replacing_model) { + if (!app || !app->window) return false; + int reference_width = 612; + int reference_height = 354; + if (options && options->mver_projection) { + reference_width = options->reference_width; + reference_height = options->reference_height; + } else { + int canvas_width = 0, canvas_height = 0; + if (bongo_cat_live2d_canvas_size(app->live2d, + &canvas_width, &canvas_height)) { + reference_width = canvas_width; + reference_height = canvas_height; + } + } + int x, y, width, height; + if (reference_width <= 0 || reference_height <= 0 || + !SDL_GetWindowPosition(app->window, &x, &y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + int content_height = app->session.window.content_height > 0 + ? app->session.window.content_height : height; + int content_width = (int)((double)content_height * reference_width / + reference_height + 0.5); + if (content_width < 64) content_width = 64; + if (content_width > 8192) content_width = 8192; + if (content_height < 64) content_height = 64; + if (content_height > 8192) content_height = 8192; + int next_width = 0, next_height = 0, left = 0, top = 0; + if (!bongo_cat_window_frame_size(app, content_width, content_height, + &next_width, &next_height, &left, &top)) return false; + bool restored_frame = !replacing_model && + SDL_abs(width - next_width) <= 1 && SDL_abs(height - next_height) <= 1; + int next_x = x, next_y = y; + if (anchor && anchor->valid && !restored_frame) { + next_x = anchor->x - left - content_width / 2; + next_y = anchor->y - top - content_height / 2; + } + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + if (next_x == x && next_y == y && next_width == width && + next_height == height) return false; + bool changed = bongo_cat_window_apply_geometry(app, next_x, next_y, + app->session.window.scale_percent, next_width, next_height); + if (changed) { + app->session.window.content_width = content_width; + app->session.window.content_height = content_height; + } + return changed; +} diff --git a/src/runtime/model/model_geometry.h b/src/runtime/model/model_geometry.h new file mode 100644 index 00000000..1616c46d --- /dev/null +++ b/src/runtime/model/model_geometry.h @@ -0,0 +1,16 @@ +#ifndef BONGO_CAT_MODEL_GEOMETRY_H +#define BONGO_CAT_MODEL_GEOMETRY_H + +#include "runtime.h" + +typedef struct BongoCatModelContentAnchor { + int x, y; + bool valid; +} BongoCatModelContentAnchor; + +BongoCatModelContentAnchor bongo_cat_model_content_anchor(BongoCatApp *app); +bool bongo_cat_model_apply_aspect(BongoCatApp *app, + const BongoCatLive2DRenderOptions *options, + const BongoCatModelContentAnchor *anchor, bool replacing_model); + +#endif diff --git a/src/runtime/model_storage.c b/src/runtime/model/model_storage.c similarity index 55% rename from src/runtime/model_storage.c rename to src/runtime/model/model_storage.c index 0fa3a1ed..ca86856e 100644 --- a/src/runtime/model_storage.c +++ b/src/runtime/model/model_storage.c @@ -3,9 +3,11 @@ #include #include +#include #include #define MODEL_REMOVE_DEPTH_CAP 32 +#define MODEL_COPY_DEPTH_CAP 32 typedef struct RemoveContext { BongoCatError *error; unsigned depth; } RemoveContext; static bool remove_tree(const char *path, unsigned depth, BongoCatError *error); @@ -72,3 +74,59 @@ bool bongo_cat_model_cleanup_imports(const char *root, BongoCatError *error) { return root && bongo_cat_path_is_dir(root) && bongo_cat_path_enumerate(root, cleanup_item, error); } + +typedef struct CopyContext { + const char *source; + const char *target; + BongoCatError *error; + unsigned depth; +} CopyContext; + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error); + +static BongoCatPathVisit copy_item(void *userdata, + const char *dirname, const char *name) { + (void)dirname; + CopyContext *context = userdata; + char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || + !bongo_cat_path_join(target, sizeof(target), context->target, name)) { + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Model path is too long"); + return BONGO_CAT_PATH_FAILURE; + } + bool directory = bongo_cat_path_is_dir(source); + bool file = !directory && bongo_cat_path_is_file(source); + bool ok = directory ? copy_tree(source, target, context->depth + 1, + context->error) : file && bongo_cat_path_copy_file(source, target); + if (!ok && context->error && !context->error->message[0]) + bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, + "Cannot copy %s: %s", source, SDL_GetError()); + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +static bool copy_tree(const char *source, const char *target, unsigned depth, + BongoCatError *error) { + if (depth > MODEL_COPY_DEPTH_CAP) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, + "Model directory nesting exceeds %u levels", MODEL_COPY_DEPTH_CAP); + return false; + } + if (!bongo_cat_path_create_directory(target)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, + "Cannot create %s: %s", target, SDL_GetError()); + return false; + } + CopyContext context = {source, target, error, depth}; + return bongo_cat_path_enumerate(source, copy_item, &context); +} + +BongoCatResult bongo_cat_model_copy_directory(const char *source, + const char *target, BongoCatError *error) { + if (!source || !target || !bongo_cat_path_is_dir(source)) + return BONGO_CAT_ERROR_ARGUMENT; + return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : + error && error->code == BONGO_CAT_ERROR_FORMAT ? + BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; +} diff --git a/src/runtime/model_storage.h b/src/runtime/model/model_storage.h similarity index 52% rename from src/runtime/model_storage.h rename to src/runtime/model/model_storage.h index 0ae79853..ce721bf2 100644 --- a/src/runtime/model_storage.h +++ b/src/runtime/model/model_storage.h @@ -5,5 +5,9 @@ bool bongo_cat_model_remove_tree(const char *path, BongoCatError *error); bool bongo_cat_model_cleanup_imports(const char *root, BongoCatError *error); +BongoCatResult bongo_cat_model_copy_directory(const char *source, + const char *target, BongoCatError *error); +BongoCatResult bongo_cat_model_install_builtins(const char *asset_root, + const char *models_root, BongoCatError *error); #endif diff --git a/src/runtime/model/model_update.c b/src/runtime/model/model_update.c new file mode 100644 index 00000000..6b4acb77 --- /dev/null +++ b/src/runtime/model/model_update.c @@ -0,0 +1,83 @@ +#include "runtime.h" + +#define NANOSECONDS_PER_SECOND 1000000000ull + +static uint32_t random_expression_next(BongoCatApp *app, uint64_t now) { + uint32_t state = app->random_expression_state; + if (!state) { + state = (uint32_t)now ^ (uint32_t)(now >> 32) ^ 0x9e3779b9u; + const unsigned char *text = (const unsigned char *)app->loaded_model; + while (*text) state = (state ^ *text++) * 16777619u; + if (!state) state = 0x6d2b79f5u; + } + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + app->random_expression_state = state; + return state; +} + +void bongo_cat_random_expression_reset(BongoCatApp *app) { + if (!app) return; + app->random_expression_due_ns = 0; + app->random_expression_interval_seconds = 0.0f; +} + +void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now) { + if (app && app->window_snapshot) return; + if (!app || !app->settings.window.random_expression || !app->live2d) { + bongo_cat_random_expression_reset(app); + return; + } + float seconds = app->settings.window.random_expression_interval_seconds; + if (!(seconds >= 1.0f && seconds <= 3600.0f)) + seconds = BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS; + if (!app->random_expression_due_ns || + app->random_expression_interval_seconds != seconds) { + app->random_expression_interval_seconds = seconds; + app->random_expression_due_ns = now + + (uint64_t)(seconds * (float)NANOSECONDS_PER_SECOND); + return; + } + if (now < app->random_expression_due_ns) return; + app->random_expression_due_ns = now + + (uint64_t)(seconds * (float)NANOSECONDS_PER_SECOND); + + int current = bongo_cat_live2d_expression(app->live2d); + size_t expression_count = 0; + size_t alternate_count = 0; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_EXPRESSION) continue; + expression_count++; + if (entry->index != current) alternate_count++; + } + if (!expression_count) return; + + bool choose_alternate = alternate_count > 0; + size_t candidate_count = choose_alternate ? alternate_count : expression_count; + size_t choice = random_expression_next(app, now) % candidate_count; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_EXPRESSION || + (choose_alternate && entry->index == current)) continue; + if (choice--) continue; + if (bongo_cat_live2d_set_expression(app->live2d, entry->index)) + app->dirty = true; + return; + } +} + +bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed_seconds) { + if (app && app->window_snapshot) return false; + if (!app || !app->live2d || elapsed_seconds <= 0.0f) return false; + if (elapsed_seconds > 0.25f) elapsed_seconds = 0.25f; + unsigned steps = 1; + while (steps < 8 && elapsed_seconds / steps > 1.0f / 30.0f) steps++; + float step = elapsed_seconds / steps; + bool changed = false; + for (unsigned i = 0; i < steps; ++i) + changed = bongo_cat_live2d_update(app->live2d, step) || changed; + if (changed) app->dirty = true; + return changed; +} diff --git a/src/runtime/model/multi_pet.c b/src/runtime/model/multi_pet.c new file mode 100644 index 00000000..264d4c36 --- /dev/null +++ b/src/runtime/model/multi_pet.c @@ -0,0 +1,223 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +#define CONTROL_FILE "control.txt" +#define REMOVE_REQUEST_FILE "remove.request" +#define CONTROL_CHECK_NS 100000000ull +#define CONTROL_STALE_SECONDS 10 +#define CONTROL_RETRY_GRACE_NS 5000000000ull +#define SETTINGS_CHECK_NS 2000000000ull + +typedef enum ControlReadResult { + CONTROL_READ_OK, CONTROL_READ_RETRY, CONTROL_READ_END +} ControlReadResult; + +typedef struct ControlState { + bool visible; + bool pass_through; +} ControlState; + +static uint64_t model_hash(const char *model_id) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char *p = (const unsigned char *)model_id; p && *p; ++p) { + hash ^= *p; + hash *= 1099511628211ull; + } + return hash; +} + +bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, + const char *root, const char *model_id) { + char pets[BONGO_CAT_PATH_CAP], name[24]; + snprintf(name, sizeof(name), "%016llx", + (unsigned long long)model_hash(model_id)); + return root && model_id && model_id[0] && + bongo_cat_path_join(pets, sizeof(pets), root, "pets") && + bongo_cat_path_join(target, capacity, pets, name); +} + +static bool primary_remove_request_path(char *target, size_t capacity, + const BongoCatApp *app, const char *model_id) { + char directory[BONGO_CAT_PATH_CAP]; + return app && bongo_cat_multi_pet_state_directory(directory, + sizeof(directory), app->primary_state_root, model_id) && + bongo_cat_path_join(target, capacity, directory, REMOVE_REQUEST_FILE); +} + +bool bongo_cat_multi_pet_take_remove_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (!primary_remove_request_path(path, sizeof(path), app, model_id) || + !bongo_cat_path_is_file(path)) return false; + if (bongo_cat_file_remove(path)) return true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet remove request could not be consumed: " + "model=%s path=%s", model_id, path); + return false; +} + +void bongo_cat_multi_pet_clear_remove_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (primary_remove_request_path(path, sizeof(path), app, model_id) && + bongo_cat_path_is_file(path)) bongo_cat_file_remove(path); +} + +bool bongo_cat_multi_pet_secondary_argument(int argc, char **argv) { + static const char prefix[] = "--secondary-pet="; + for (int i = 1; i < argc; ++i) + if (!strncmp(argv[i], prefix, sizeof(prefix) - 1) && + argv[i][sizeof(prefix) - 1]) return true; + return false; +} + +bool bongo_cat_multi_pet_request_remove(BongoCatApp *app) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !bongo_cat_path_join(path, sizeof(path), app->state_root, + REMOVE_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s\n", app->secondary_model_id) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet remove request failed: model=%s path=%s", + app->secondary_model_id, path); + return false; + } + return true; +} + +static ControlReadResult read_control(BongoCatApp *app, + ControlState *control, const char **retry_reason) { + char path[BONGO_CAT_PATH_CAP], content[64] = {0}; + if (!bongo_cat_path_join(path, sizeof(path), app->state_root, + CONTROL_FILE)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control path is too long: model=%s", + app->secondary_model_id); + return CONTROL_READ_END; + } + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + *retry_reason = "missing"; + return CONTROL_READ_RETRY; + } + size_t length = file ? fread(content, 1, sizeof(content) - 1, file) : 0; + if (file) fclose(file); + content[length] = '\0'; + char state[8] = {0}, extra = 0; + long long heartbeat = 0; + int pass_through = app->settings.window.pass_through; + int fields = sscanf(content, "%7s %lld %d %c", state, &heartbeat, + &pass_through, &extra); + bool valid = fields == 3 && + (pass_through == 0 || pass_through == 1); + time_t now = time(NULL), then = (time_t)heartbeat; + if (!valid) { + *retry_reason = "invalid-format"; + return CONTROL_READ_RETRY; + } + if (now < then || now - then > CONTROL_STALE_SECONDS) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control expired: model=%s " + "reason=stale age=%lld path=%s", app->secondary_model_id, + (long long)(now - then), path); + return CONTROL_READ_END; + } + control->visible = !strcmp(state, "visible"); + if (!control->visible && strcmp(state, "hidden")) { + *retry_reason = "invalid-state"; + return CONTROL_READ_RETRY; + } + control->pass_through = pass_through != 0; + return CONTROL_READ_OK; +} + +static void reload_secondary_settings(BongoCatApp *app) { + BongoCatSettings settings = app->settings; + BongoCatError error = {0}; + BongoCatResult loaded = bongo_cat_settings_load( + app->settings_path, &settings, &error); + if (loaded != BONGO_CAT_OK) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child settings sync failed: model=%s " + "result=%d error=%s", app->secondary_model_id, loaded, + error.message[0] ? error.message : "unknown error"); + return; + } + if (app->secondary_control_known) + settings.window.pass_through = app->secondary_control_pass_through; + bool model_changed = memcmp(&settings.model, &app->settings.model, + sizeof(settings.model)) != 0; + bool window_changed = memcmp(&settings.window, &app->settings.window, + sizeof(settings.window)) != 0; + app->settings = settings; + if (model_changed) app->dirty = true; + if (window_changed) { + app->dirty = true; + bongo_cat_platform_set_always_on_top(&app->platform, + app->settings.window.always_on_top); + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } +} + +static void update_secondary(BongoCatApp *app, uint64_t now) { + if (!app->secondary_control_check_ns || + now >= app->secondary_control_check_ns) { + ControlState control = {0}; + const char *retry_reason = "unknown"; + ControlReadResult result = read_control(app, &control, &retry_reason); + if (result == CONTROL_READ_END) app->running = false; + else if (result == CONTROL_READ_RETRY) { + if (!app->secondary_control_failure_ns) + app->secondary_control_failure_ns = now; + else if (now - app->secondary_control_failure_ns >= + CONTROL_RETRY_GRACE_NS) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet child control unavailable: model=%s " + "reason=%s", app->secondary_model_id, retry_reason); + app->running = false; + } + } else { + bool visibility_changed = !app->secondary_control_known || + control.visible != app->session.window.visible; + bool pass_changed = !app->secondary_control_known || + control.pass_through != app->settings.window.pass_through; + app->secondary_control_failure_ns = 0; + app->secondary_control_known = true; + app->secondary_control_visible = control.visible; + app->secondary_control_pass_through = control.pass_through; + if (pass_changed) { + app->settings.window.pass_through = control.pass_through; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } + if (visibility_changed) + bongo_cat_window_set_visible(app, control.visible); + } + app->secondary_control_check_ns = now + CONTROL_CHECK_NS; + } + if (!app->secondary_settings_check_ns || + now >= app->secondary_settings_check_ns) { + reload_secondary_settings(app); + app->secondary_settings_check_ns = now + SETTINGS_CHECK_NS; + } +} + +void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now) { + if (!app) return; + if (app->secondary_pet) update_secondary(app, now); + else bongo_cat_multi_pet_primary_update(app, now); +} diff --git a/src/runtime/model/multi_pet_process.c b/src/runtime/model/multi_pet_process.c new file mode 100644 index 00000000..8d2f2a1e --- /dev/null +++ b/src/runtime/model/multi_pet_process.c @@ -0,0 +1,279 @@ +#include "runtime.h" +#include "preferences_notice.h" +#include "bongo_cat/file.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include +#define CONTROL_FILE "control.txt" +#define CONTROL_HEARTBEAT_NS 2000000000ull +#define CHILD_RETRY_NS 2000000000ull +#define CHILD_STOP_NS 1000000000ull +typedef struct SecondaryPet { + char model_id[BONGO_CAT_ID_CAP]; + SDL_Process *process; + uint64_t retry_ns, stop_ns; + unsigned failures; + bool stopping, control_ready, control_visible, control_pass_through; +} SecondaryPet; +struct BongoCatMultiPetRuntime { + SecondaryPet pets[BONGO_CAT_ADDITIONAL_MODEL_CAP]; + size_t count; + uint64_t heartbeat_ns; +}; +static bool control_path(char *target, size_t capacity, + const BongoCatApp *app, const char *model_id, bool create) { + char pets[BONGO_CAT_PATH_CAP], directory[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(pets, sizeof(pets), app->primary_state_root, + "pets") || (create && !bongo_cat_path_create_directory(pets)) || + !bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + app->primary_state_root, model_id) || + (create && !bongo_cat_path_create_directory(directory))) return false; + return bongo_cat_path_join(target, capacity, directory, CONTROL_FILE); +} + +static bool write_control(BongoCatApp *app, SecondaryPet *pet) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!control_path(path, sizeof(path), app, pet->model_id, true) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet control path preparation failed: model=%s", + pet->model_id); + return false; + } + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s %lld %d\n", + app->session.window.visible ? "visible" : "hidden", + (long long)time(NULL), app->settings.window.pass_through) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet control write failed: model=%s path=%s " + "visible=%d pass_through=%d", pet->model_id, path, + app->session.window.visible, app->settings.window.pass_through); + return false; + } + pet->control_ready = true; + pet->control_visible = app->session.window.visible; + pet->control_pass_through = app->settings.window.pass_through; + return true; +} + +static void remove_control(BongoCatApp *app, SecondaryPet *pet) { + char path[BONGO_CAT_PATH_CAP]; + if (control_path(path, sizeof(path), app, pet->model_id, false)) + bongo_cat_file_remove(path); + pet->control_ready = false; +} + +static SecondaryPet *find_pet(BongoCatMultiPetRuntime *runtime, + const char *model_id) { + for (size_t i = 0; i < runtime->count; ++i) + if (!strcmp(runtime->pets[i].model_id, model_id)) + return &runtime->pets[i]; + return NULL; +} + +static void remove_pet(BongoCatMultiPetRuntime *runtime, size_t index) { + if (index + 1 < runtime->count) + memmove(&runtime->pets[index], &runtime->pets[index + 1], + (runtime->count - index - 1) * sizeof(runtime->pets[0])); + memset(&runtime->pets[--runtime->count], 0, sizeof(runtime->pets[0])); +} + +static bool desired(const BongoCatApp *app, const char *model_id) { + if (!app->settings.model.multiple_pets) return false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) + if (!strcmp(app->session.additional_model_ids[i], model_id)) + return true; + return false; +} + +static SDL_Process *create_pet_process(const char *const *args) { + SDL_PropertiesID properties = SDL_CreateProperties(); + if (!properties) return NULL; + bool configured = SDL_SetPointerProperty(properties, + SDL_PROP_PROCESS_CREATE_ARGS_POINTER, (void *)args) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDIN_NUMBER, + SDL_PROCESS_STDIO_NULL) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDOUT_NUMBER, + SDL_PROCESS_STDIO_NULL) && + SDL_SetNumberProperty(properties, + SDL_PROP_PROCESS_CREATE_STDERR_NUMBER, + SDL_PROCESS_STDIO_NULL); + SDL_Process *process = configured ? + SDL_CreateProcessWithProperties(properties) : NULL; + if (!configured) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process properties failed: %s", SDL_GetError()); + SDL_DestroyProperties(properties); + return process; +} + +static bool spawn_pet(BongoCatApp *app, SecondaryPet *pet, size_t index, + uint64_t now) { + char model_arg[BONGO_CAT_ID_CAP + 20]; + char storage_arg[BONGO_CAT_PATH_CAP + 20]; + char nearby_arg[BONGO_CAT_PATH_CAP + 20]; + char position_arg[80]; + int x = app->session.window.x, y = app->session.window.y; + if (app->window) SDL_GetWindowPosition(app->window, &x, &y); + snprintf(model_arg, sizeof(model_arg), "--secondary-pet=%s", pet->model_id); + snprintf(position_arg, sizeof(position_arg), "--secondary-position=%d,%d", + x + 36 * (int)(index + 1), y + 36 * (int)(index + 1)); + const char *args[6] = {app->executable_path[0] ? app->executable_path : + BONGO_CAT_EXECUTABLE, model_arg, position_arg, NULL, NULL, NULL}; + size_t argument = 3; + if (app->storage_root[0]) { + snprintf(storage_arg, sizeof(storage_arg), "--storage-root=%s", + app->storage_root); + args[argument++] = storage_arg; + } + if (app->nearby_root[0]) { + snprintf(nearby_arg, sizeof(nearby_arg), "--nearby-root=%s", + app->nearby_root); + args[argument++] = nearby_arg; + } + bongo_cat_multi_pet_clear_remove_request(app, pet->model_id); + bongo_cat_multi_pet_clear_pass_through_request(app, pet->model_id); + bongo_cat_multi_pet_clear_primary_request(app, pet->model_id); + if (!write_control(app, pet)) { + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Unable to prepare additional desktop pet %s", + pet->model_id); + return false; + } + /* GUI launches may not have inheritable standard handles on Windows. */ + pet->process = create_pet_process(args); + if (pet->process) { + pet->stopping = false; + return true; + } + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Unable to start additional desktop pet %s: %s", + pet->model_id, SDL_GetError()); + return false; +} + +static void reap_pets(BongoCatApp *app, uint64_t now) { + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = runtime->count; i > 0; --i) { + SecondaryPet *pet = &runtime->pets[i - 1]; + int exit_code = -1; + bool exited = pet->process && + SDL_WaitProcess(pet->process, false, &exit_code); + if (exited) { + if (!pet->stopping) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process exited unexpectedly: " + "model=%s exit_code=%d", pet->model_id, exit_code); + SDL_DestroyProcess(pet->process); + pet->process = NULL; + if (!pet->stopping) { + pet->failures++; + pet->retry_ns = now + CHILD_RETRY_NS; + } + } + if (pet->stopping && pet->process && now >= pet->stop_ns) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet process stop timed out; killing: model=%s", + pet->model_id); + SDL_KillProcess(pet->process, true); + SDL_WaitProcess(pet->process, true, NULL); + SDL_DestroyProcess(pet->process); + pet->process = NULL; + } + if (pet->stopping && !pet->process) remove_pet(runtime, i - 1); + } +} + +static void consume_remove_requests(BongoCatApp *app) { + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = runtime->count; i > 0; --i) { + SecondaryPet *pet = &runtime->pets[i - 1]; + if (!pet->process || pet->stopping || !desired(app, pet->model_id) || + !bongo_cat_multi_pet_take_remove_request(app, pet->model_id)) + continue; + bongo_cat_session_remove_model(&app->session, pet->model_id); + bongo_cat_preferences_invalidate(app->preferences); + } +} + +void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now) { + bongo_cat_multi_pet_prune_selection(app); + if (!app->multi_pet && app->session.additional_model_count) + app->multi_pet = calloc(1, sizeof(*app->multi_pet)); + if (!app->multi_pet) return; + BongoCatMultiPetRuntime *runtime = app->multi_pet; + consume_remove_requests(app); + bongo_cat_multi_pet_pass_through_requests_update(app); + bongo_cat_multi_pet_primary_requests_update(app); + reap_pets(app, now); + for (size_t i = 0; i < runtime->count; ++i) { + SecondaryPet *pet = &runtime->pets[i]; + if (!desired(app, pet->model_id) && !pet->stopping) { + remove_control(app, pet); + pet->stopping = true; + pet->stop_ns = now + CHILD_STOP_NS; + } + } + bool heartbeat = !runtime->heartbeat_ns || + now - runtime->heartbeat_ns >= CONTROL_HEARTBEAT_NS; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + const char *id = app->session.additional_model_ids[i]; + SecondaryPet *pet = find_pet(runtime, id); + if (!pet && runtime->count < BONGO_CAT_ADDITIONAL_MODEL_CAP) { + pet = &runtime->pets[runtime->count++]; + snprintf(pet->model_id, sizeof(pet->model_id), "%s", id); + } + if (!pet || pet->stopping) continue; + if (heartbeat || !pet->control_ready || + pet->control_visible != app->session.window.visible || + pet->control_pass_through != app->settings.window.pass_through) + write_control(app, pet); + if (!pet->process && pet->failures < 3 && now >= pet->retry_ns) + spawn_pet(app, pet, i, now); + if (!pet->process && pet->failures >= 3) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet model removed after launch failures: " + "model=%s failures=%u", pet->model_id, pet->failures); + bongo_cat_session_remove_model(&app->session, pet->model_id); + remove_control(app, pet); + pet->stopping = true; + if (app->preferences) bongo_cat_preferences_notice_show(app, + bongo_cat_i18n_get(app->i18n, "native.modelLoadFailed", + "Unable to display this model"), true); + } + } + if (heartbeat) runtime->heartbeat_ns = now; +} + +void bongo_cat_multi_pet_shutdown(BongoCatApp *app) { + if (!app || !app->multi_pet) return; + BongoCatMultiPetRuntime *runtime = app->multi_pet; + for (size_t i = 0; i < runtime->count; ++i) + remove_control(app, &runtime->pets[i]); + uint64_t deadline = SDL_GetTicksNS() + 500000000ull; + for (size_t i = 0; i < runtime->count; ++i) { + SecondaryPet *pet = &runtime->pets[i]; + while (pet->process && SDL_GetTicksNS() < deadline && + !SDL_WaitProcess(pet->process, false, NULL)) SDL_Delay(10); + if (pet->process && !SDL_WaitProcess(pet->process, false, NULL)) { + SDL_KillProcess(pet->process, true); + SDL_WaitProcess(pet->process, true, NULL); + } + if (pet->process) SDL_DestroyProcess(pet->process); + } + free(runtime); + app->multi_pet = NULL; +} diff --git a/src/runtime/model/multi_pet_selection.c b/src/runtime/model/multi_pet_selection.c new file mode 100644 index 00000000..1178bfbf --- /dev/null +++ b/src/runtime/model/multi_pet_selection.c @@ -0,0 +1,96 @@ +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool load_pet_window(const BongoCatApp *app, const char *model_id, + BongoCatWindowState *window) { + char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + BongoCatSessionState session; + BongoCatError error = {0}; + bongo_cat_session_defaults(&session); + if (!bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + app->primary_state_root, model_id) || + !bongo_cat_path_join(path, sizeof(path), directory, "session.json") || + bongo_cat_session_load(path, &session, &error) != BONGO_CAT_OK || + !session.window.position_known) return false; + *window = session.window; + return true; +} + +static void adopt_pet_window(BongoCatApp *app, + const BongoCatWindowState *window) { + if (!bongo_cat_window_apply_geometry(app, window->x, window->y, + window->scale_percent, window->width, window->height)) return; + app->session.window.opacity_percent = window->opacity_percent; + bongo_cat_app_cancel_hover_fade(app); + app->hover_hidden = app->hover_inside = false; + app->hover_deadline_ns = 0; + bongo_cat_platform_set_opacity(&app->platform, + window->opacity_percent / 100.0f); +} + +bool bongo_cat_app_model_active(const BongoCatApp *app, const char *id) { + if (!app || !id) return false; + if (!strcmp(app->session.active_model_id, id)) return true; + return app->settings.model.multiple_pets && + bongo_cat_session_model_active(&app->session, id); +} + +size_t bongo_cat_app_active_model_count(const BongoCatApp *app) { + return app && app->settings.model.multiple_pets + ? 1 + app->session.additional_model_count : app ? 1 : 0; +} + +bool bongo_cat_app_set_model_active(BongoCatApp *app, const char *id, + bool active, BongoCatError *error) { + if (!app || !id || !bongo_cat_models_find(&app->models, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "Model is not installed: %s", id ? id : ""); + return false; + } + if (!app->settings.model.multiple_pets) + return active ? bongo_cat_app_select_model_with_error(app, id, error) + : true; + bool primary = !strcmp(app->session.active_model_id, id); + if (active) { + if (!bongo_cat_session_add_model(&app->session, id)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, + "At most %d desktop pets can be displayed", + BONGO_CAT_ACTIVE_MODEL_CAP); + return false; + } + } else if (primary) { + if (!app->session.additional_model_count) return true; + char replacement[BONGO_CAT_ID_CAP]; + BongoCatWindowState replacement_window; + snprintf(replacement, sizeof(replacement), "%s", + app->session.additional_model_ids[0]); + bool preserve_window = load_pet_window(app, replacement, + &replacement_window); + if (!bongo_cat_app_select_model_with_error(app, replacement, error)) + return false; + bongo_cat_session_remove_model(&app->session, replacement); + if (preserve_window) adopt_pet_window(app, &replacement_window); + } else bongo_cat_session_remove_model(&app->session, id); + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); + return true; +} + +void bongo_cat_app_set_multiple_pets(BongoCatApp *app, bool enabled) { + if (!app) return; + app->settings.model.multiple_pets = enabled; + if (!enabled) bongo_cat_session_clear_additional_models(&app->session); + if (!app->secondary_pet) + bongo_cat_multi_pet_primary_update(app, SDL_GetTicksNS()); +} + +void bongo_cat_multi_pet_prune_selection(BongoCatApp *app) { + bongo_cat_session_validate(&app->session); + for (size_t i = app->session.additional_model_count; i > 0; --i) { + const char *id = app->session.additional_model_ids[i - 1]; + if (!bongo_cat_models_find(&app->models, id)) + bongo_cat_session_remove_model(&app->session, id); + } +} diff --git a/src/runtime/model/multi_pet_settings.c b/src/runtime/model/multi_pet_settings.c new file mode 100644 index 00000000..fbf87d3b --- /dev/null +++ b/src/runtime/model/multi_pet_settings.c @@ -0,0 +1,196 @@ +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" + +#include +#include + +#define PASS_THROUGH_REQUEST_FILE "pass-through.request" +#define PRIMARY_REQUEST_FILE "primary.request" + +static bool request_path(char *target, size_t capacity, const char *root, + const char *model_id, const char *request_file) { + char directory[BONGO_CAT_PATH_CAP]; + return bongo_cat_multi_pet_state_directory(directory, sizeof(directory), + root, model_id) && + bongo_cat_path_join(target, capacity, directory, request_file); +} + +void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (app && request_path(path, sizeof(path), app->primary_state_root, + model_id, PASS_THROUGH_REQUEST_FILE) && + bongo_cat_path_is_file(path) && + !bongo_cat_file_remove(path)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Stale multi-pet pass-through request could not be " + "removed: model=%s path=%s", model_id, path); +} + +void bongo_cat_multi_pet_clear_primary_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP]; + if (app && request_path(path, sizeof(path), app->primary_state_root, + model_id, PRIMARY_REQUEST_FILE) && bongo_cat_path_is_file(path) && + !bongo_cat_file_remove(path)) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Stale multi-pet primary request could not be removed: " + "model=%s path=%s", model_id, path); +} + +static bool write_primary_request(BongoCatApp *app, const char *action) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !request_path(path, sizeof(path), app->primary_state_root, + app->secondary_model_id, PRIMARY_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%s\n", action) > 0; + if (file && fclose(file) != 0) written = false; + if (written && bongo_cat_file_replace(temporary, path, true)) return true; + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request failed: model=%s action=%s " + "path=%s", app->secondary_model_id, action, path); + return false; +} + +bool bongo_cat_multi_pet_request_preferences(BongoCatApp *app) { + return write_primary_request(app, "preferences"); +} + +bool bongo_cat_multi_pet_request_exit(BongoCatApp *app) { + return write_primary_request(app, "exit"); +} + +bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, + bool enabled) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + int length; + if (!app || !app->secondary_pet || + !bongo_cat_path_join(path, sizeof(path), app->state_root, + PASS_THROUGH_REQUEST_FILE) || + (length = snprintf(temporary, sizeof(temporary), "%s.tmp", path)) < 0 || + (size_t)length >= sizeof(temporary)) return false; + FILE *file = bongo_cat_file_open(temporary, "wb"); + bool written = file && fprintf(file, "%d\n", enabled) > 0; + if (file && fclose(file) != 0) written = false; + if (!written || !bongo_cat_file_replace(temporary, path, true)) { + bongo_cat_file_remove(temporary); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request failed: model=%s " + "path=%s", app->secondary_model_id, path); + return false; + } + return true; +} + +static bool take_request(BongoCatApp *app, const char *model_id, + bool *enabled) { + char path[BONGO_CAT_PATH_CAP], content[8] = {0}; + if (!request_path(path, sizeof(path), app->primary_state_root, model_id, + PASS_THROUGH_REQUEST_FILE) || + !bongo_cat_path_is_file(path)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request could not be read: " + "model=%s path=%s", model_id, path); + return false; + } + size_t length = file ? fread(content, 1, sizeof(content) - 1, file) : 0; + if (file) fclose(file); + if (!bongo_cat_file_remove(path)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request could not be consumed: " + "model=%s path=%s", model_id, path); + return false; + } + content[length] = '\0'; + bool valid = content[0] == '0' || content[0] == '1'; + for (size_t i = 1; valid && content[i]; ++i) + valid = content[i] == ' ' || content[i] == '\t' || + content[i] == '\r' || content[i] == '\n'; + if (!valid) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet pass-through request invalid: model=%s " + "path=%s content=%s", model_id, path, content); + return false; + } + *enabled = content[0] == '1'; + return true; +} + +typedef enum PrimaryRequest { + PRIMARY_REQUEST_NONE, + PRIMARY_REQUEST_PREFERENCES, + PRIMARY_REQUEST_EXIT +} PrimaryRequest; + +static PrimaryRequest take_primary_request(BongoCatApp *app, + const char *model_id) { + char path[BONGO_CAT_PATH_CAP], content[32] = {0}; + if (!request_path(path, sizeof(path), app->primary_state_root, model_id, + PRIMARY_REQUEST_FILE) || !bongo_cat_path_is_file(path)) + return PRIMARY_REQUEST_NONE; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request could not be read: " + "model=%s path=%s", model_id, path); + return PRIMARY_REQUEST_NONE; + } + size_t length = fread(content, 1, sizeof(content) - 1, file); + fclose(file); + if (!bongo_cat_file_remove(path)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request could not be consumed: " + "model=%s path=%s", model_id, path); + return PRIMARY_REQUEST_NONE; + } + while (length && (content[length - 1] == ' ' || + content[length - 1] == '\t' || content[length - 1] == '\r' || + content[length - 1] == '\n')) length--; + content[length] = '\0'; + if (!strcmp(content, "preferences")) return PRIMARY_REQUEST_PREFERENCES; + if (!strcmp(content, "exit")) return PRIMARY_REQUEST_EXIT; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary request invalid: model=%s path=%s", + model_id, path); + return PRIMARY_REQUEST_NONE; +} + +void bongo_cat_multi_pet_primary_requests_update(BongoCatApp *app) { + if (!app || app->secondary_pet) return; + bool show_preferences = false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + PrimaryRequest request = take_primary_request(app, + app->session.additional_model_ids[i]); + if (request == PRIMARY_REQUEST_EXIT) { + app->running = false; + return; + } + if (request == PRIMARY_REQUEST_PREFERENCES) show_preferences = true; + } + if (show_preferences) bongo_cat_preferences_show(app->preferences); +} + +void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app) { + if (!app || app->secondary_pet) return; + bool changed = false; + for (size_t i = 0; i < app->session.additional_model_count; ++i) { + bool enabled; + const char *model_id = app->session.additional_model_ids[i]; + if (!take_request(app, model_id, &enabled)) continue; + app->settings.window.pass_through = enabled; + changed = true; + } + if (!changed) return; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + bongo_cat_preferences_invalidate(app->preferences); +} diff --git a/src/runtime/model_files.c b/src/runtime/model_files.c deleted file mode 100644 index 120b77b7..00000000 --- a/src/runtime/model_files.c +++ /dev/null @@ -1,298 +0,0 @@ -#include "runtime.h" -#include "model_import.h" -#include "model_storage.h" -#include "bongo_cat/file.h" -#include "bongo_cat/overlay.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include -typedef struct TreeContext { - const char *source; - const char *target; - BongoCatError *error; - unsigned depth; -} TreeContext; -#define MODEL_TREE_DEPTH_CAP 32 -static void select_model_state(BongoCatApp *app, const BongoCatModelEntry *entry) { - snprintf(app->config.current_model, sizeof(app->config.current_model), "%s", - entry->id); - app->config.current_mode = entry->mode; - bongo_cat_gamepads_set_enabled(app, entry->mode == BONGO_CAT_MODE_GAMEPAD); -} -static void request_model_frame(BongoCatApp *app) { - if (!app) return; - if (app->window && app->config.window.visible) { - if (SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN) - bongo_cat_window_set_visible(app, true); - else bongo_cat_window_clamp_to_display(app); - } - bongo_cat_window_mark_hit_dirty(app); - app->dirty = true; -} - -static void apply_model_aspect(BongoCatApp *app, - const BongoCatLive2DRenderOptions *options) { - if (!app || !app->window) return; - int reference_width = options && options->mver_projection - ? options->reference_width : 612; - int reference_height = options && options->mver_projection - ? options->reference_height : 354; - int x, y, width, height; - if (reference_width <= 0 || reference_height <= 0 || - !SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height) || height <= 0) return; - int next_width = (int)((double)height * reference_width / - reference_height + 0.5); - if (next_width < 64) next_width = 64; - if (next_width > 8192) next_width = 8192; - if (next_width != width) - bongo_cat_window_apply_geometry(app, x, y, - app->config.window.scale_percent, next_width, height); -} - -static void commit_model(BongoCatApp *app, - const BongoCatModelEntry *entry) { - select_model_state(app, entry); - app->model_selection_serial++; - request_model_frame(app); -} - -bool bongo_cat_app_select_model_with_error(BongoCatApp *app, - const char *id, BongoCatError *error) { - BongoCatError local = {0}; - BongoCatError *failure = error ? error : &local; - *failure = (BongoCatError){0}; - if (!app || !app->live2d || !id) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_ARGUMENT, - "Cannot select a model without an active renderer and model id"); - return false; - } - const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); - if (!entry) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_ARGUMENT, - "Model is not installed: %s", id); - return false; - } - if (app->loaded_model[0] && strcmp(app->loaded_model, entry->id) == 0) { - commit_model(app, entry); - return true; - } - BongoCatError optional = {0}; - BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); - if (!behaviors) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate model behavior state"); - return false; - } - if (bongo_cat_behaviors_load(behaviors, entry, &optional) != BONGO_CAT_OK) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); - BongoCatLive2DRenderOptions render_options = {0}; - bool mver_profile = bongo_cat_import_mver_render_options( - entry->adapter_directory, &render_options); - if (mver_profile) SDL_Log("Mver runtime profile: projection=%.4f " - "force_mouse=%d left_handed=%d pointer_bounds=%d", - render_options.projection_scale, render_options.mouse_force_move, - render_options.pointer_left_handed, render_options.custom_pointer_bounds); - int pixel_width = app->config.window.width, pixel_height = app->config.window.height; - if (app->window) SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); - SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); - SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); - bool restore_context = previous_window != app->window || - previous_context != app->gl_context; - if (restore_context && !SDL_GL_MakeCurrent(app->window, app->gl_context)) { - bongo_cat_error_set(failure, BONGO_CAT_ERROR_PLATFORM, - "Cannot activate the main OpenGL context: %s", SDL_GetError()); - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); - free(behaviors); - return false; - } - bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); - if (bongo_cat_live2d_load(app->live2d, entry->directory, - entry->setting_file, entry->preset, &render_options, failure) != BONGO_CAT_OK) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", failure->message); - if (restore_context && previous_window && previous_context && - !SDL_GL_MakeCurrent(previous_window, previous_context)) - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Cannot restore the previous OpenGL context: %s", SDL_GetError()); - free(behaviors); - if (!bongo_cat_live2d_ready(app->live2d)) app->loaded_model[0] = '\0'; - request_model_frame(app); - return false; - } - app->model_render_options = render_options; - BongoCatParameterRange mouse_range; app->model_mouse_parameters = - bongo_cat_live2d_parameter(app->live2d, "ParamMouseLeftDown", &mouse_range) || - bongo_cat_live2d_parameter(app->live2d, "ParamMouseRightDown", &mouse_range); - bongo_cat_app_reset_pointer_tracking(app); - bongo_cat_live2d_set_render_options(app->live2d, &render_options); - apply_model_aspect(app, &render_options); - if (app->window) { - SDL_SyncWindow(app->window); - SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height); - bongo_cat_live2d_reshape(app->live2d, pixel_width, pixel_height); - } - app->behaviors = *behaviors; - free(behaviors); - optional = (BongoCatError){0}; - if (bongo_cat_overlay_load(app->overlay, entry->adapter_directory, - &optional) != BONGO_CAT_OK && optional.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", optional.message); - snprintf(app->loaded_model, sizeof(app->loaded_model), "%s", entry->id); - app->pointer_known = false; - bongo_cat_live2d_resize(app->live2d, pixel_width, pixel_height); - if (restore_context && previous_window && previous_context && - !SDL_GL_MakeCurrent(previous_window, previous_context)) - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Cannot restore the previous OpenGL context: %s", SDL_GetError()); - bongo_cat_memory_policy_model_loaded(); commit_model(app, entry); - return true; -} - -bool bongo_cat_app_select_model(BongoCatApp *app, const char *id) { - return bongo_cat_app_select_model_with_error(app, id, NULL); -} -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error); -static BongoCatPathVisit copy_item(void *userdata, - const char *dirname, const char *name) { - (void)dirname; - TreeContext *context = userdata; - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), context->source, name) || - !bongo_cat_path_join(target, sizeof(target), context->target, name)) { - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, "Model path is too long"); - return BONGO_CAT_PATH_FAILURE; - } - bool directory = bongo_cat_path_is_dir(source); - bool file = !directory && bongo_cat_path_is_file(source); - bool ok = directory - ? copy_tree(source, target, context->depth + 1, context->error) - : file && bongo_cat_path_copy_file(source, target); - if (!ok && context->error && !context->error->message[0]) - bongo_cat_error_set(context->error, BONGO_CAT_ERROR_IO, "Cannot copy %s: %s", - source, SDL_GetError()); - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool copy_tree(const char *source, const char *target, unsigned depth, - BongoCatError *error) { - if (depth > MODEL_TREE_DEPTH_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model directory nesting exceeds %u levels", MODEL_TREE_DEPTH_CAP); - return false; - } - if (!bongo_cat_path_create_directory(target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, "Cannot create %s: %s", target, SDL_GetError()); - return false; - } - TreeContext context = {source, target, error, depth}; - return bongo_cat_path_enumerate(source, copy_item, &context); -} - -BongoCatResult bongo_cat_copy_directory(const char *source, const char *target, - BongoCatError *error) { - if (!source || !target || !bongo_cat_path_is_dir(source)) return BONGO_CAT_ERROR_ARGUMENT; - return copy_tree(source, target, 0, error) ? BONGO_CAT_OK : - error && error->code == BONGO_CAT_ERROR_FORMAT ? - BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; -} - -static bool custom_root(BongoCatApp *app, char *path, size_t capacity) { - return app->data_root[0] && - bongo_cat_path_join(path, capacity, app->data_root, "custom-models") && - bongo_cat_path_create_directory(path); -} - -static bool shortcut_model_exists(const BongoCatModelCatalog *models, - const char *shortcut_id) { - const char *separator = shortcut_id ? strchr(shortcut_id, ':') : NULL; - if (!separator) return false; - size_t length = (size_t)(separator - shortcut_id); - for (size_t i = 0; i < models->count; ++i) { - const char *id = models->entries[i].id; - if (strlen(id) == length && strncmp(id, shortcut_id, length) == 0) return true; - } - return false; -} - -static void prune_behavior_shortcuts(BongoCatApp *app) { - size_t output = 0; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - BongoCatBehaviorShortcut *value = &app->config.behavior_shortcuts[i]; - if (!shortcut_model_exists(&app->models, value->id)) continue; - if (output != i) app->config.behavior_shortcuts[output] = *value; - output++; - } - app->config.behavior_shortcut_count = output; -} - -static void scan_portable_root(BongoCatApp *app, const char *root) { - if (!root || !root[0] || !bongo_cat_path_is_dir(root)) return; - size_t before = app->models.count; - BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_portable_mver_scan(app, root, &error); - size_t added = app->models.count - before; - if (added) SDL_Log("Nearby model scan added %llu model modes from %s", - (unsigned long long)added, root); - if (result != BONGO_CAT_OK && error.message[0]) - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); -} - -void bongo_cat_app_rescan_models(BongoCatApp *app) { - if (!app) return; - BongoCatError error = {0}; - char root[BONGO_CAT_PATH_CAP]; - bongo_cat_models_init(&app->models); - bongo_cat_path_join(root, sizeof(root), app->asset_root, "models"); - bongo_cat_models_scan(&app->models, root, true, &error); - if (custom_root(app, root, sizeof(root))) { - bongo_cat_model_cleanup_imports(root, &error); - bongo_cat_models_scan(&app->models, root, false, &error); - } - const char *base = SDL_GetBasePath(); - if (!SDL_getenv("BONGO_CAT_DISABLE_NEARBY_MODEL_SCAN")) - scan_portable_root(app, base); - prune_behavior_shortcuts(app); - for (size_t i = 0; i < app->models.count; ++i) - if (!app->models.entries[i].preset) - bongo_cat_import_apply_metadata(app, app->models.entries[i].id, - app->models.entries[i].adapter_directory); -} - -BongoCatResult bongo_cat_app_remove_model(BongoCatApp *app, const char *id, - BongoCatError *error) { - if (!app || !id) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Missing model id"); - return BONGO_CAT_ERROR_ARGUMENT; - } - const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); - if (!entry) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, "Model is not installed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - if (entry->preset || entry->managed) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - entry->managed ? "Portable Mver models are managed by their source directory: %s" : - "Built-in models cannot be removed: %s", id); - return BONGO_CAT_ERROR_ARGUMENT; - } - bool selected = strcmp(id, app->config.current_model) == 0; - char directory[BONGO_CAT_PATH_CAP]; - snprintf(directory, sizeof(directory), "%s", entry->storage_directory); - if (!bongo_cat_model_remove_tree(directory, error)) return BONGO_CAT_ERROR_IO; - bongo_cat_app_rescan_models(app); - if (selected) { - BongoCatError load_error = {0}; - for (size_t i = 0; i < app->models.count; ++i) - if (bongo_cat_app_select_model_with_error(app, - app->models.entries[i].id, &load_error)) return BONGO_CAT_OK; - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Model was removed, but no replacement could be displayed: %s", - load_error.message[0] ? load_error.message : "no installed models"); - return BONGO_CAT_ERROR_CUBISM; - } - return BONGO_CAT_OK; -} diff --git a/src/runtime/model_import.c b/src/runtime/model_import.c deleted file mode 100644 index e5e22d70..00000000 --- a/src/runtime/model_import.c +++ /dev/null @@ -1,249 +0,0 @@ -#include "model_import.h" -#include "model_storage.h" -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include - -typedef struct ImportInstall { - char id[BONGO_CAT_ID_CAP]; - char temporary[BONGO_CAT_PATH_CAP]; - char target[BONGO_CAT_PATH_CAP]; - bool committed; -} ImportInstall; - -static bool custom_root(const char *data_root, char *path, size_t capacity) { - return data_root && data_root[0] && - bongo_cat_path_join(path, capacity, data_root, "custom-models") && - bongo_cat_path_create_directory(path); -} - -static bool copy_optional(const char *source_dir, const char *source_name, - const char *target_dir, const char *target_name) { - char source[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(source, sizeof(source), source_dir, source_name) && - bongo_cat_path_join(target, sizeof(target), target_dir, target_name) && - bongo_cat_path_copy_file(source, target); -} - -static bool copy_first(const char *source_dir, const char *const *names, size_t count, - const char *target_dir, const char *target_name) { - for (size_t i = 0; i < count; ++i) { - char source[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source, sizeof(source), source_dir, names[i]) || - !bongo_cat_path_is_file(source)) continue; - return copy_optional(source_dir, names[i], target_dir, target_name); - } - return true; -} - -static bool copy_preview_file(const char *source_resources, const char *source_root, - const char *const *names, size_t count, const char *target_resources, - const char *target_name) { - for (size_t i = 0; i < count; ++i) { - char source[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(source, sizeof(source), source_resources, names[i]) && - bongo_cat_path_is_file(source)) - return copy_optional(source_resources, names[i], target_resources, target_name); - } - return copy_first(source_root, names, count, target_resources, target_name); -} - -static bool preview_file_exists(const char *directory, const char *name) { - char path[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(path, sizeof(path), directory, name) && - bongo_cat_path_is_file(path); -} - -static void mark_preview_fallback(const char *resources) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), resources, - ".bongo-cat-cover-fallback")) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) fclose(file); -} - -static bool copy_preview(const BongoCatImportCandidate *candidate, const char *target, - BongoCatError *error) { - char source_resources[BONGO_CAT_PATH_CAP], target_resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(source_resources, sizeof(source_resources), - candidate->assets, "resources") || - !bongo_cat_path_join(target_resources, sizeof(target_resources), target, "resources")) - return false; - bool target_exists = bongo_cat_path_is_dir(target_resources); - if (bongo_cat_path_is_dir(source_resources) && !target_exists) { - if (bongo_cat_copy_directory(source_resources, target_resources, error) != BONGO_CAT_OK) - return false; - target_exists = true; - } - if (!target_exists && !bongo_cat_path_create_directory(target_resources)) return false; - const char *covers[] = {"cover.png", "cat.png", "bg.png", "mousebg.png", - "tabletbg.png"}; - const char *backgrounds[] = {"background.png", "bg.png", "mousebg.png", - "tabletbg.png"}; - bool authored_cover = preview_file_exists(source_resources, "cover.png") || - preview_file_exists(candidate->assets, "cover.png"); - bool ok = (preview_file_exists(target_resources, "cover.png") || - copy_preview_file(source_resources, candidate->assets, covers, 5, - target_resources, "cover.png")) && - (preview_file_exists(target_resources, "background.png") || - copy_preview_file(source_resources, candidate->assets, backgrounds, 4, - target_resources, "background.png")); - if (ok && !authored_cover && preview_file_exists(target_resources, "cover.png")) - mark_preview_fallback(target_resources); - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot copy model preview assets: %s", SDL_GetError()); - return ok; -} - -static bool write_mode(const char *target, BongoCatModelMode mode, BongoCatError *error) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, ".bongo-cat-mode")) return false; - FILE *file = bongo_cat_file_open(path, "wb"); - if (!file) return false; - const char *name = mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : - mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; - bool ok = fputs(name, file) >= 0; - if (fclose(file) != 0) ok = false; - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write imported model metadata"); - return ok; -} - -bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - if (!candidate || !target || - (!bongo_cat_path_is_dir(target) && !bongo_cat_path_create_directory(target))) return false; - return copy_preview(candidate, target, error) && - bongo_cat_import_mver_assets(candidate, target, error) && - bongo_cat_import_mver_metadata(candidate, target, error) && - bongo_cat_import_write_report(candidate, target, error) && - write_mode(target, candidate->mode, error); -} - -static void cleanup(ImportInstall *installs, size_t count, bool committed) { - for (size_t i = 0; i < count; ++i) - bongo_cat_model_remove_tree(committed && installs[i].committed ? installs[i].target : - installs[i].temporary, NULL); -} - -static bool prepare_install(const BongoCatImportCandidate *candidate, - ImportInstall *install, const char *root, unsigned long long stamp, - size_t index, size_t count, BongoCatError *error) { - const char *mode = candidate->mode == BONGO_CAT_MODE_KEYBOARD ? "keyboard" : - candidate->mode == BONGO_CAT_MODE_GAMEPAD ? "gamepad" : "standard"; - if (count == 1) snprintf(install->id, sizeof(install->id), "model-%llx", stamp); - else snprintf(install->id, sizeof(install->id), "model-%llx-%s-%zu", - stamp, mode, index + 1); - char temporary_name[BONGO_CAT_ID_CAP + 8]; - snprintf(temporary_name, sizeof(temporary_name), ".import-%llx-%zu.tmp", - stamp, index + 1); - BongoCatImportCandidate installed; - if (!bongo_cat_path_join(install->temporary, sizeof(install->temporary), - root, temporary_name) || - !bongo_cat_path_join(install->target, sizeof(install->target), root, install->id) || - !bongo_cat_import_prepare_package(candidate, install->temporary, - &installed, error)) return false; - char adapter[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(adapter, sizeof(adapter), install->temporary, "adapter") || - !bongo_cat_import_prepare_adapter(&installed, adapter, error) || - !bongo_cat_import_write_package(&installed, install->temporary, error)) return false; - return bongo_cat_import_manifest_valid(installed.directory, - installed.setting, error); -} - -BongoCatResult bongo_cat_import_install(const char *source, const char *data_root, - BongoCatImportReceipt *receipt, BongoCatError *error) { - if (receipt) memset(receipt, 0, sizeof(*receipt)); - if (!source || !data_root || !bongo_cat_path_is_dir(source)) - return BONGO_CAT_ERROR_ARGUMENT; - BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); - ImportInstall *installs = calloc(BONGO_CAT_IMPORT_CANDIDATE_CAP, sizeof(*installs)); - if (!discovery || !installs) { - free(discovery); free(installs); - bongo_cat_error_set(error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate model import workspace"); - return BONGO_CAT_ERROR_MEMORY; - } - if (!bongo_cat_import_discover(source, discovery, error)) { - free(discovery); free(installs); return BONGO_CAT_ERROR_FORMAT; - } - char root[BONGO_CAT_PATH_CAP]; - if (!custom_root(data_root, root, sizeof(root)) || - !bongo_cat_model_cleanup_imports(root, error)) { - free(discovery); free(installs); return BONGO_CAT_ERROR_IO; - } - unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - for (size_t i = 0; i < discovery->count; ++i) { - if (prepare_install(&discovery->candidates[i], &installs[i], root, - stamp, i, discovery->count, error)) continue; - cleanup(installs, i + 1, false); - BongoCatResult result = error && error->code == BONGO_CAT_ERROR_FORMAT - ? BONGO_CAT_ERROR_FORMAT : BONGO_CAT_ERROR_IO; - free(discovery); free(installs); return result; - } - for (size_t i = 0; i < discovery->count; ++i) { - if (!bongo_cat_path_rename(installs[i].temporary, installs[i].target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot finish model import: %s", SDL_GetError()); - cleanup(installs, discovery->count, true); - free(discovery); free(installs); - return BONGO_CAT_ERROR_IO; - } - installs[i].committed = true; - } - if (receipt) { - receipt->count = discovery->count; - for (size_t i = 0; i < discovery->count; ++i) - snprintf(receipt->ids[i], sizeof(receipt->ids[i]), "%s", installs[i].id); - } - free(discovery); free(installs); - return BONGO_CAT_OK; -} - -static void remove_receipt(const char *data_root, - const BongoCatImportReceipt *receipt) { - char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - if (!receipt || !custom_root(data_root, root, sizeof(root))) return; - for (size_t i = 0; i < receipt->count; ++i) - if (bongo_cat_path_join(path, sizeof(path), root, receipt->ids[i])) - bongo_cat_model_remove_tree(path, NULL); -} - -BongoCatResult bongo_cat_app_import_model(BongoCatApp *app, const char *source, - BongoCatError *error) { - if (!app || !source) return BONGO_CAT_ERROR_ARGUMENT; - BongoCatImportReceipt receipt; - BongoCatResult result = bongo_cat_import_install(source, app->data_root, - &receipt, error); - if (result != BONGO_CAT_OK) { - if (error && !error->message[0]) bongo_cat_error_set(error, result, - "Model import failed while installing: %s", source); - return result; - } - char previous[BONGO_CAT_PATH_CAP]; - snprintf(previous, sizeof(previous), "%s", app->config.current_model); - bongo_cat_app_rescan_models(app); - if (receipt.count && bongo_cat_app_select_model(app, receipt.ids[0])) - return BONGO_CAT_OK; -#ifndef BONGO_CAT_HAS_CUBISM - const BongoCatModelEntry *entry = receipt.count ? - bongo_cat_models_find(&app->models, receipt.ids[0]) : NULL; - if (entry) { - snprintf(app->config.current_model, sizeof(app->config.current_model), - "%s", receipt.ids[0]); - app->config.current_mode = entry->mode; - } - return BONGO_CAT_OK; -#else - remove_receipt(app->data_root, &receipt); - bongo_cat_app_rescan_models(app); - if (previous[0]) bongo_cat_app_select_model(app, previous); - bongo_cat_error_set(error, BONGO_CAT_ERROR_CUBISM, - "Model import was rolled back because the Live2D model could not be loaded"); - return BONGO_CAT_ERROR_CUBISM; -#endif -} diff --git a/src/runtime/model_import.h b/src/runtime/model_import.h deleted file mode 100644 index 61f6c2aa..00000000 --- a/src/runtime/model_import.h +++ /dev/null @@ -1,85 +0,0 @@ -#ifndef BONGO_CAT_MODEL_IMPORT_H -#define BONGO_CAT_MODEL_IMPORT_H - -#include "bongo_cat/common.h" -#include "bongo_cat/model.h" - -#define BONGO_CAT_IMPORT_CANDIDATE_CAP 16 - -typedef enum BongoCatImportFormat { - BONGO_CAT_IMPORT_TAURI, - BONGO_CAT_IMPORT_MVER, - BONGO_CAT_IMPORT_MVER_PATCH -} BongoCatImportFormat; - -typedef struct BongoCatImportCandidate { - char directory[BONGO_CAT_PATH_CAP]; - char setting[BONGO_CAT_PATH_CAP]; - char assets[BONGO_CAT_PATH_CAP]; - char package_root[BONGO_CAT_PATH_CAP]; - char patch_root[BONGO_CAT_PATH_CAP]; - char config[BONGO_CAT_PATH_CAP]; - char overrides[BONGO_CAT_PATH_CAP]; - BongoCatModelMode mode; - BongoCatImportFormat format; - bool gamepad_buttons; -} BongoCatImportCandidate; - -typedef struct BongoCatImportDiscovery { - BongoCatImportCandidate candidates[BONGO_CAT_IMPORT_CANDIDATE_CAP]; - size_t count; - int depth; - bool ambiguous; -} BongoCatImportDiscovery; - -typedef struct BongoCatImportReceipt { - char ids[BONGO_CAT_IMPORT_CANDIDATE_CAP][BONGO_CAT_ID_CAP]; - size_t count; -} BongoCatImportReceipt; - -typedef BongoCatResult (*BongoCatPortableVisitor)(void *userdata, - const char *source, BongoCatImportDiscovery *discovery, BongoCatError *error); - -typedef struct BongoCatApp BongoCatApp; - -bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, - BongoCatError *error); -int bongo_cat_import_mver_discover(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_patch_discover(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -int bongo_cat_import_mver_patch_discover_exact(const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error); -bool bongo_cat_import_manifest_valid(const char *root, const char *setting, - BongoCatError *error); -bool bongo_cat_import_mver_assets(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); -bool bongo_cat_import_mver_metadata(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); -bool bongo_cat_import_write_report(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); -bool bongo_cat_import_prepare_adapter(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); -bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatImportCandidate *installed, BongoCatError *error); -bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error); -void bongo_cat_import_apply_metadata(BongoCatApp *app, const char *model_id, - const char *directory); -bool bongo_cat_import_mver_render_options(const char *directory, - BongoCatLive2DRenderOptions *options); -BongoCatResult bongo_cat_import_portable_mver(BongoCatApp *app, - const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_portable_mver_scan(BongoCatApp *app, - const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, - const char *root, BongoCatError *error); -BongoCatResult bongo_cat_import_portable_scan(const char *root, - BongoCatPortableVisitor visitor, void *userdata, BongoCatError *error); -BongoCatResult bongo_cat_import_install(const char *source, - const char *data_root, BongoCatImportReceipt *receipt, - BongoCatError *error); - -#endif diff --git a/src/runtime/model_import_discovery.c b/src/runtime/model_import_discovery.c deleted file mode 100644 index 0d3e8966..00000000 --- a/src/runtime/model_import_discovery.c +++ /dev/null @@ -1,283 +0,0 @@ -#include "model_import.h" -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include -#include - -static bool safe_reference(const char *value) { - if (!value || !value[0] || value[0] == '/' || value[0] == '\\' || - strchr(value, ':')) return false; - const char *part = value; - while (*part) { - while (*part == '/' || *part == '\\') part++; - if (part[0] == '.' && part[1] == '.' && - (!part[2] || part[2] == '/' || part[2] == '\\')) return false; - part = strpbrk(part, "/\\"); - if (!part) break; - } - return true; -} - -static bool referenced_file(const char *root, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - return safe_reference(relative) && - bongo_cat_path_join(path, sizeof(path), root, relative) && bongo_cat_path_is_file(path); -} - -static bool referenced_texture(const char *root, const char *relative) { - char path[BONGO_CAT_PATH_CAP]; - if (!safe_reference(relative) || - !bongo_cat_path_join(path, sizeof(path), root, relative)) return false; - FILE *file = bongo_cat_file_open(path, "rb"); - int width = 0, height = 0, channels = 0; - bool valid = file && stbi_info_from_file(file, &width, &height, &channels) && - width > 0 && height > 0 && channels > 0; - if (file) fclose(file); - return valid; -} - -static bool optional_reference(const char *root, yyjson_val *refs, - const char *name) { - yyjson_val *value = yyjson_obj_get(refs, name); - if (!value) return true; - const char *relative = yyjson_get_str(value); - return relative && referenced_file(root, relative); -} - -static bool behavior_references(const char *root, yyjson_val *refs) { - yyjson_val *expressions = yyjson_obj_get(refs, "Expressions"); - if (expressions && !yyjson_is_arr(expressions)) return false; - size_t index, count; yyjson_val *item; - yyjson_arr_foreach(expressions, index, count, item) - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) - return false; - yyjson_val *motions = yyjson_obj_get(refs, "Motions"); - if (motions && !yyjson_is_obj(motions)) return false; - size_t group_index, group_count; yyjson_val *key, *group; - yyjson_obj_foreach(motions, group_index, group_count, key, group) { - if (!yyjson_is_arr(group)) return false; - yyjson_arr_foreach(group, index, count, item) { - if (!referenced_file(root, yyjson_get_str(yyjson_obj_get(item, "File")))) - return false; - const char *sound = yyjson_get_str(yyjson_obj_get(item, "Sound")); - if (sound && !safe_reference(sound)) return false; - } - } - return true; -} - -bool bongo_cat_import_manifest_valid(const char *root, const char *setting, - BongoCatError *error) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), root, setting)) return false; - FILE *file = bongo_cat_file_open(path, "rb"); - yyjson_doc *document = file ? yyjson_read_fp(file, 0, NULL, NULL) : NULL; - if (file) fclose(file); - yyjson_val *manifest = document ? yyjson_doc_get_root(document) : NULL; - yyjson_val *refs = yyjson_is_obj(manifest) - ? yyjson_obj_get(manifest, "FileReferences") : NULL; - const char *moc = yyjson_get_str(yyjson_obj_get(refs, "Moc")); - yyjson_val *textures = yyjson_obj_get(refs, "Textures"); - bool valid = yyjson_get_int(yyjson_obj_get(manifest, "Version")) == 3 && - yyjson_is_obj(refs) && referenced_file(root, moc) && yyjson_is_arr(textures) && - yyjson_arr_size(textures) > 0; - size_t index, maximum; yyjson_val *texture; - yyjson_arr_foreach(textures, index, maximum, texture) - valid = valid && referenced_texture(root, yyjson_get_str(texture)); - valid = valid && optional_reference(root, refs, "Physics") && - optional_reference(root, refs, "Pose") && - optional_reference(root, refs, "DisplayInfo") && behavior_references(root, refs); - yyjson_doc_free(document); - if (!valid && error) bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Model manifest or referenced assets are invalid: %s", path); - return valid; -} - -static bool path_parent(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; -} - -static BongoCatModelMode import_mode(const char *path) { - const char *cursor = path; - BongoCatModelMode mode = BONGO_CAT_MODE_STANDARD; - while (cursor && *cursor) { - while (*cursor == '/' || *cursor == '\\') cursor++; - const char *end = strpbrk(cursor, "/\\"); - size_t length = end ? (size_t)(end - cursor) : strlen(cursor); - if (length == 8 && SDL_strncasecmp(cursor, "keyboard", length) == 0) - mode = BONGO_CAT_MODE_KEYBOARD; - else if (length == 7 && SDL_strncasecmp(cursor, "gamepad", length) == 0) - mode = BONGO_CAT_MODE_GAMEPAD; - else if (length == 8 && SDL_strncasecmp(cursor, "standard", length) == 0) - mode = BONGO_CAT_MODE_STANDARD; - if (!end) break; - cursor = end + 1; - } - return mode; -} - -static bool has_preview_assets(const char *directory) { - const char *names[] = {"resources", "cover.png", "cat.png", "bg.png", - "mousebg.png", "tabletbg.png"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), directory, names[i])) continue; - if (bongo_cat_path_is_file(path) || bongo_cat_path_is_dir(path)) return true; - } - return false; -} - -static bool has_cover_asset(const char *directory) { - const char *names[] = {"resources/cover.png", "cover.png", "cat.png", "bg.png"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), directory, names[i]) && - bongo_cat_path_is_file(path)) return true; - } - return false; -} - -static bool has_background_asset(const char *directory) { - const char *names[] = {"resources/background.png", "background.png", - "bg.png", "mousebg.png", "tabletbg.png"}; - for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), directory, names[i]) && - bongo_cat_path_is_file(path)) return true; - } - return false; -} - -static bool add_candidate(BongoCatImportDiscovery *discovery, const char *directory, - const char *setting) { - if (discovery->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) return false; - for (size_t i = 0; i < discovery->count; ++i) - if (strcmp(discovery->candidates[i].directory, directory) == 0) { - if (strcmp(discovery->candidates[i].setting, setting) == 0) return true; - discovery->ambiguous = true; - return false; - } - BongoCatImportCandidate *candidate = &discovery->candidates[discovery->count++]; - snprintf(candidate->directory, sizeof(candidate->directory), "%s", directory); - snprintf(candidate->setting, sizeof(candidate->setting), "%s", setting); - snprintf(candidate->assets, sizeof(candidate->assets), "%s", directory); - snprintf(candidate->package_root, sizeof(candidate->package_root), "%s", directory); - candidate->format = BONGO_CAT_IMPORT_TAURI; - char parent[BONGO_CAT_PATH_CAP]; - if ((!has_cover_asset(directory) || !has_background_asset(directory)) && - path_parent(directory, parent, sizeof(parent)) && has_preview_assets(parent)) - snprintf(candidate->assets, sizeof(candidate->assets), "%s", parent); - candidate->mode = import_mode(candidate->assets); - return true; -} - -static bool suffix(const char *name, const char *ending) { - size_t a = name ? strlen(name) : 0, b = ending ? strlen(ending) : 0; - return a >= b && strcmp(name + a - b, ending) == 0; -} - -static BongoCatPathVisit discover_item(void *userdata, - const char *dirname, const char *name) { - BongoCatImportDiscovery *discovery = userdata; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), dirname, name)) - return BONGO_CAT_PATH_FAILURE; - if (bongo_cat_path_is_file(path) && suffix(name, ".model3.json")) { - if (bongo_cat_import_manifest_valid(dirname, name, NULL) && - !add_candidate(discovery, dirname, name)) return BONGO_CAT_PATH_FAILURE; - return BONGO_CAT_PATH_CONTINUE; - } - if (!bongo_cat_path_is_dir(path) || discovery->depth >= 8 || name[0] == '.') - return BONGO_CAT_PATH_CONTINUE; - discovery->depth++; - bool ok = bongo_cat_path_enumerate(path, discover_item, discovery); - discovery->depth--; - return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static int rank(const BongoCatImportCandidate *candidate) { - return candidate->mode == BONGO_CAT_MODE_STANDARD ? 0 : - candidate->mode == BONGO_CAT_MODE_KEYBOARD ? 1 : 2; -} - -static int compare_candidates(const void *left, const void *right) { - const BongoCatImportCandidate *a = left, *b = right; - int difference = rank(a) - rank(b); - return difference ? difference : strcmp(a->directory, b->directory); -} - -typedef struct ContainerDiscovery { - BongoCatImportDiscovery *output; -} ContainerDiscovery; - -static BongoCatResult collect_container(void *userdata, const char *source, - BongoCatImportDiscovery *found, BongoCatError *error) { - (void)source; - ContainerDiscovery *container = userdata; - for (size_t i = 0; i < found->count; ++i) { - if (container->output->count >= BONGO_CAT_IMPORT_CANDIDATE_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Selected folder contains too many model variants"); - return BONGO_CAT_ERROR_FORMAT; - } - container->output->candidates[container->output->count++] = - found->candidates[i]; - } - return BONGO_CAT_OK; -} - -bool bongo_cat_import_discover(const char *source, BongoCatImportDiscovery *discovery, - BongoCatError *error) { - memset(discovery, 0, sizeof(*discovery)); - int mver = bongo_cat_import_mver_discover(source, discovery, error); - if (mver > 0) return true; - if (mver < 0) return false; - BongoCatError patch_error = {0}; - int patch = bongo_cat_import_mver_patch_discover(source, discovery, &patch_error); - if (patch > 0) return true; - memset(discovery, 0, sizeof(*discovery)); - ContainerDiscovery container = {discovery}; - BongoCatResult container_result = bongo_cat_import_portable_scan(source, - collect_container, &container, error); - if (container_result != BONGO_CAT_OK) return false; - if (discovery->count) { - qsort(discovery->candidates, discovery->count, - sizeof(discovery->candidates[0]), compare_candidates); - return true; - } - if (patch < 0) { - if (error) *error = patch_error; - return false; - } - if (error) *error = (BongoCatError){0}; - if (!bongo_cat_path_enumerate(source, discover_item, discovery)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - discovery->ambiguous ? "A model directory contains multiple model3 manifests" : - "Cannot scan model directory or it contains too many models"); - return false; - } - if (!discovery->count) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Selected directory contains no valid Live2D model3 JSON"); - return false; - } - for (size_t i = 0; i < discovery->count; ++i) - if (discovery->candidates[i].format == BONGO_CAT_IMPORT_TAURI) - snprintf(discovery->candidates[i].package_root, - sizeof(discovery->candidates[i].package_root), "%s", source); - qsort(discovery->candidates, discovery->count, - sizeof(discovery->candidates[0]), compare_candidates); - return true; -} diff --git a/src/runtime/model_import_package.c b/src/runtime/model_import_package.c deleted file mode 100644 index 9b537c59..00000000 --- a/src/runtime/model_import_package.c +++ /dev/null @@ -1,178 +0,0 @@ -#include "model_import.h" -#include "runtime.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include - -#define PACKAGE_FILE ".bongo-cat-package.json" - -static bool separator(char value) { return value == '/' || value == '\\'; } - -static bool path_prefix(const char *root, const char *path, size_t length) { -#ifdef _WIN32 - return SDL_strncasecmp(root, path, length) == 0; -#else - return strncmp(root, path, length) == 0; -#endif -} - -static bool relative_path(const char *root, const char *path, - char *relative, size_t capacity) { - if (!root || !path || !relative || !capacity) return false; - size_t root_length = strlen(root); - while (root_length && separator(root[root_length - 1])) root_length--; - if (!root_length || !path_prefix(root, path, root_length) || - (path[root_length] && !separator(path[root_length]))) return false; - const char *cursor = path + root_length; - while (separator(*cursor)) cursor++; - size_t length = strlen(cursor); - while (length && separator(cursor[length - 1])) length--; - if (length >= capacity) return false; - memcpy(relative, cursor, length); - relative[length] = '\0'; - return true; -} - -static bool rebase(const char *source_root, const char *installed_root, - const char *source, char *target, size_t capacity) { - if (!source[0]) { target[0] = '\0'; return true; } - char relative[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative))) return false; - if (!relative[0]) { - snprintf(target, capacity, "%s", installed_root); - return true; - } - return bongo_cat_path_join(target, capacity, installed_root, relative); -} - -static bool copy_relative_directory(const char *source_root, - const char *source, const char *target_root, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || - !relative[0] || !bongo_cat_path_join(target, sizeof(target), - target_root, relative)) return false; - return bongo_cat_copy_directory(source, target, error) == BONGO_CAT_OK; -} - -static bool copy_relative_file(const char *source_root, const char *source, - const char *target_root, BongoCatError *error) { - char relative[BONGO_CAT_PATH_CAP], target[BONGO_CAT_PATH_CAP]; - if (!relative_path(source_root, source, relative, sizeof(relative)) || - !relative[0] || !bongo_cat_path_join(target, sizeof(target), - target_root, relative)) return false; - if (bongo_cat_path_is_file(target)) return true; - size_t length = strlen(target); - while (length && target[length - 1] != '/' && target[length - 1] != '\\') - length--; - if (!length) return false; - char parent[BONGO_CAT_PATH_CAP]; - memcpy(parent, target, length - 1); - parent[length - 1] = '\0'; - if (!bongo_cat_path_create_directory(parent) || - !bongo_cat_path_copy_file(source, target)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot preserve imported model file: %s", source); - return false; - } - return true; -} - -static bool copy_mver_payload(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - if (!bongo_cat_path_create_directory(target) || - !copy_relative_directory(candidate->package_root, candidate->assets, - target, error)) return false; - char inside[BONGO_CAT_PATH_CAP]; - if (!relative_path(candidate->assets, candidate->directory, - inside, sizeof(inside)) && - !copy_relative_directory(candidate->package_root, candidate->directory, - target, error)) return false; - return copy_relative_file(candidate->package_root, candidate->config, - target, error); -} - -static bool copy_payload(const BongoCatImportCandidate *candidate, - const char *target, BongoCatImportCandidate *installed, - BongoCatError *error) { - char payload[BONGO_CAT_PATH_CAP], base[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(payload, sizeof(payload), target, "payload")) return false; - bool patch = candidate->format == BONGO_CAT_IMPORT_MVER_PATCH; - if (patch) { - char patch_path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_create_directory(payload) || - !bongo_cat_path_join(base, sizeof(base), payload, "base") || - !bongo_cat_path_join(patch_path, sizeof(patch_path), payload, "patch") || - bongo_cat_copy_directory(candidate->package_root, base, error) != - BONGO_CAT_OK || - bongo_cat_copy_directory(candidate->patch_root, patch_path, error) != - BONGO_CAT_OK || - !rebase(candidate->patch_root, patch_path, candidate->overrides, - installed->overrides, sizeof(installed->overrides))) return false; - snprintf(installed->patch_root, sizeof(installed->patch_root), "%s", patch_path); - } else if (candidate->format == BONGO_CAT_IMPORT_MVER) { - snprintf(base, sizeof(base), "%s", payload); - if (!copy_mver_payload(candidate, base, error)) return false; - } else { - snprintf(base, sizeof(base), "%s", payload); - if (bongo_cat_copy_directory(candidate->package_root, base, error) != - BONGO_CAT_OK) return false; - } - if (!rebase(candidate->package_root, base, candidate->directory, - installed->directory, sizeof(installed->directory)) || - !rebase(candidate->package_root, base, candidate->assets, - installed->assets, sizeof(installed->assets)) || - !rebase(candidate->package_root, base, candidate->config, - installed->config, sizeof(installed->config))) return false; - snprintf(installed->package_root, sizeof(installed->package_root), "%s", base); - return true; -} - -bool bongo_cat_import_prepare_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatImportCandidate *installed, BongoCatError *error) { - if (!candidate || !target || !installed || !bongo_cat_path_create_directory(target)) return false; - *installed = *candidate; - if (!copy_payload(candidate, target, installed, error)) { - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot preserve imported model payload"); - return false; - } - char adapter[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(adapter, sizeof(adapter), target, "adapter") && - bongo_cat_path_create_directory(adapter); -} - -static const char *format_name(BongoCatImportFormat format) { - if (format == BONGO_CAT_IMPORT_MVER) return "bongo-cat-mver"; - if (format == BONGO_CAT_IMPORT_MVER_PATCH) return "bongo-cat-mver-patch"; - return "tauri-live2d"; -} - -bool bongo_cat_import_write_package(const BongoCatImportCandidate *candidate, - const char *target, BongoCatError *error) { - char directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - bool relative = relative_path(target, candidate->directory, - directory, sizeof(directory)); - if (relative && !directory[0]) snprintf(directory, sizeof(directory), "."); - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - if (document) yyjson_mut_doc_set_root(document, root); - bool ok = relative && root && - yyjson_mut_obj_add_int(document, root, "schemaVersion", 1) && - yyjson_mut_obj_add_str(document, root, "layout", "preserved-payload") && - yyjson_mut_obj_add_strcpy(document, root, "format", format_name(candidate->format)) && - yyjson_mut_obj_add_strcpy(document, root, "mode", - bongo_cat_mode_name(candidate->mode)) && - yyjson_mut_obj_add_strcpy(document, root, "directory", directory) && - yyjson_mut_obj_add_str(document, root, "adapter", "adapter") && - yyjson_mut_obj_add_strcpy(document, root, "setting", candidate->setting) && - bongo_cat_path_join(path, sizeof(path), target, PACKAGE_FILE) && - bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); - yyjson_mut_doc_free(document); - if (!ok) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot write model package adapter description"); - return ok; -} diff --git a/src/runtime/model_import_portable.c b/src/runtime/model_import_portable.c deleted file mode 100644 index 37dc61a1..00000000 --- a/src/runtime/model_import_portable.c +++ /dev/null @@ -1,341 +0,0 @@ -#include "model_import.h" -#include "model_storage.h" -#include "runtime.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" -#include "bongo_cat/sha256.h" - -#include -#include -#include -#include -#include -#include - -#define PORTABLE_DIRECTORY "portable-mver" -#define PORTABLE_MARKER ".bongo-cat-portable.json" -#define PORTABLE_SCHEMA_VERSION 9 /* Rebuild cached pointer metadata. */ - -typedef struct PortableStamp { - uint64_t sum, exclusive, bytes, latest, files, entries; -} PortableStamp; - -typedef struct StampWalk { - PortableStamp *stamp; - int depth; -} StampWalk; - -static bool parent_path(const char *path, char *parent, size_t capacity) { - size_t length = path ? strlen(path) : 0; - while (length && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - while (length && path[length - 1] != '/' && path[length - 1] != '\\') length--; - while (length > 1 && (path[length - 1] == '/' || path[length - 1] == '\\')) length--; - if (!length || length >= capacity) return false; - memcpy(parent, path, length); - parent[length] = '\0'; - return true; -} - -static void candidate_name(const char *source, - const BongoCatImportCandidate *candidate, char *name, size_t capacity) { - const char *value = bongo_cat_path_name(source); - char parent[BONGO_CAT_PATH_CAP]; - if (candidate->format == BONGO_CAT_IMPORT_MVER_PATCH && - strcmp(value, bongo_cat_path_name(candidate->package_root)) == 0 && - parent_path(source, parent, sizeof(parent))) { - const char *variant = bongo_cat_path_name(parent); - if (variant[0]) value = variant; - } - snprintf(name, capacity, "%s", value[0] ? value : "Nearby model"); -} - -static uint64_t text_hash(const char *value) { - uint64_t hash = 1469598103934665603ull; - for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) { - hash ^= *cursor; - hash *= 1099511628211ull; - } - return hash; -} - -static uint64_t mix(uint64_t value) { - value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; - value ^= value >> 27; value *= 0x94d049bb133111ebull; - return value ^ (value >> 31); -} - -static bool stamp_path(const char *path, PortableStamp *stamp, int depth); - -static BongoCatPathVisit stamp_item(void *userdata, - const char *dirname, const char *name) { - StampWalk *walk = userdata; - char path[BONGO_CAT_PATH_CAP]; - return ++walk->stamp->entries <= 16384 && - bongo_cat_path_join(path, sizeof(path), dirname, name) && - stamp_path(path, walk->stamp, walk->depth) - ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; -} - -static bool stamp_path(const char *path, PortableStamp *stamp, int depth) { - if (bongo_cat_path_is_dir(path)) { - if (depth >= 24) return false; - StampWalk walk = {stamp, depth + 1}; - return bongo_cat_path_enumerate(path, stamp_item, &walk); - } - uint64_t size, modified; - if (!bongo_cat_path_file_info(path, &size, &modified)) return false; - if (stamp->files >= 8192 || size > 1073741824ull - stamp->bytes) - return false; - uint64_t value = text_hash(path) ^ mix(size) ^ mix(modified); - value = mix(value); - stamp->sum += value; - stamp->exclusive ^= value; - stamp->bytes += size; - if (modified > stamp->latest) stamp->latest = modified; - stamp->files++; - return true; -} - -static bool candidate_signature(const BongoCatImportCandidate *candidate, - char output[65], BongoCatError *error) { - char config_hash[65], summary[256]; - if (bongo_cat_sha256_file(candidate->config, config_hash, error) != - BONGO_CAT_OK) return false; - PortableStamp stamp = {0}; - if (!stamp_path(candidate->directory, &stamp, 0) || - !stamp_path(candidate->assets, &stamp, 0) || - (candidate->overrides[0] && !stamp_path(candidate->overrides, &stamp, 0))) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot inspect portable Mver model assets: %s", candidate->package_root); - return false; - } - int length = snprintf(summary, sizeof(summary), "%s|%016llx|%016llx|%llu|%llu|%llu", - config_hash, (unsigned long long)stamp.sum, - (unsigned long long)stamp.exclusive, (unsigned long long)stamp.bytes, - (unsigned long long)stamp.latest, (unsigned long long)stamp.files); - if (length < 0 || (size_t)length >= sizeof(summary)) return false; - bongo_cat_sha256_bytes(summary, (size_t)length, output); - return true; -} - -static bool marker_matches(const char *target, const char *source, - const char *signature) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), target, PORTABLE_MARKER)) return false; - yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - const char *stored_signature = yyjson_get_str(yyjson_obj_get(root, "signature")); - bool matches = yyjson_is_obj(root) && - yyjson_get_int(yyjson_obj_get(root, "schemaVersion")) == - PORTABLE_SCHEMA_VERSION && - stored_source && stored_signature && strcmp(source, stored_source) == 0 && - strcmp(signature, stored_signature) == 0; - yyjson_doc_free(document); - return matches; -} - -static bool write_marker(const char *target, const char *source, - const char *signature, BongoCatModelMode mode) { - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - if (document) yyjson_mut_doc_set_root(document, root); - char path[BONGO_CAT_PATH_CAP]; - bool ok = root && yyjson_mut_obj_add_int(document, root, "schemaVersion", - PORTABLE_SCHEMA_VERSION) && - yyjson_mut_obj_add_str(document, root, "kind", "portable-mver") && - yyjson_mut_obj_add_strcpy(document, root, "source", source) && - yyjson_mut_obj_add_strcpy(document, root, "signature", signature) && - yyjson_mut_obj_add_strcpy(document, root, "mode", bongo_cat_mode_name(mode)) && - bongo_cat_path_join(path, sizeof(path), target, PORTABLE_MARKER) && - bongo_cat_json_write_file(path, document, YYJSON_WRITE_PRETTY, NULL); - yyjson_mut_doc_free(document); - return ok; -} - -static bool previous_cache_usable(const char *target, const char *source) { - char marker[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; - char metadata[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_is_dir(target) || - !bongo_cat_path_join(marker, sizeof(marker), target, PORTABLE_MARKER) || - !bongo_cat_path_join(mode, sizeof(mode), target, ".bongo-cat-mode") || - !bongo_cat_path_join(metadata, sizeof(metadata), target, ".bongo-cat-mver.json") || - !bongo_cat_path_join(resources, sizeof(resources), target, "resources") || - !bongo_cat_path_is_file(mode) || !bongo_cat_path_is_file(metadata) || - !bongo_cat_path_is_dir(resources)) return false; - yyjson_doc *document = bongo_cat_json_read_file(marker, 0, NULL); - yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - const char *kind = yyjson_get_str(yyjson_obj_get(root, "kind")); - const char *stored_source = yyjson_get_str(yyjson_obj_get(root, "source")); - bool usable = yyjson_is_obj(root) && kind && stored_source && - strcmp(kind, "portable-mver") == 0 && strcmp(stored_source, source) == 0; - yyjson_doc_free(document); - return usable; -} - -static bool use_previous_cache(const char *target, const char *source, - BongoCatError *error) { - if (!previous_cache_usable(target, source)) return false; - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Cannot refresh nearby model cache; using the previous safe cache: %s", - error && error->message[0] ? error->message : source); - if (error) *error = (BongoCatError){0}; - return true; -} - -static bool refresh_cache(const BongoCatImportCandidate *candidate, - const char *cache_root, const char *id, const char *source, - const char *signature, bool *created, BongoCatError *error) { - char target[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; - char backup[BONGO_CAT_PATH_CAP], name[BONGO_CAT_ID_CAP + 8]; - snprintf(name, sizeof(name), ".%s.tmp", id); - if (!bongo_cat_path_join(target, sizeof(target), cache_root, id) || - !bongo_cat_path_join(temporary, sizeof(temporary), cache_root, name)) return false; - snprintf(name, sizeof(name), ".%s.old", id); - if (!bongo_cat_path_join(backup, sizeof(backup), cache_root, name)) return false; - bongo_cat_model_remove_tree(temporary, NULL); - if (!bongo_cat_path_is_dir(target) && bongo_cat_path_is_dir(backup)) - bongo_cat_path_rename(backup, target); - if (bongo_cat_path_is_dir(target)) bongo_cat_model_remove_tree(backup, NULL); - *created = !bongo_cat_path_is_dir(target); - if (!*created && marker_matches(target, source, signature)) return true; - if (!bongo_cat_path_create_directory(temporary) || - !bongo_cat_import_prepare_adapter(candidate, temporary, error) || - !write_marker(temporary, source, signature, candidate->mode)) { - bongo_cat_model_remove_tree(temporary, NULL); - if (error && !error->message[0]) bongo_cat_error_set(error, - BONGO_CAT_ERROR_IO, "Cannot build portable Mver adapter: %s", source); - return use_previous_cache(target, source, error); - } - bool had_target = bongo_cat_path_is_dir(target); - if (had_target && !bongo_cat_path_rename(target, backup)) { - bongo_cat_model_remove_tree(temporary, NULL); - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot update portable Mver adapter: %s", SDL_GetError()); - return use_previous_cache(target, source, error); - } - if (!bongo_cat_path_rename(temporary, target)) { - if (had_target) bongo_cat_path_rename(backup, target); - bongo_cat_model_remove_tree(temporary, NULL); - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Cannot activate portable Mver adapter: %s", SDL_GetError()); - return use_previous_cache(target, source, error); - } - bongo_cat_model_remove_tree(backup, NULL); - return true; -} - -static bool add_candidate(BongoCatApp *app, const char *cache_root, - const char *source, const BongoCatImportCandidate *candidate, - char *first_created, BongoCatError *error) { - char source_hash[65], signature[65], id[BONGO_CAT_ID_CAP]; - bongo_cat_sha256_bytes(source, strlen(source), source_hash); - snprintf(id, sizeof(id), "mver-%.16s-%s", source_hash, - bongo_cat_mode_name(candidate->mode)); - if (bongo_cat_models_find(&app->models, id)) return true; - if (!candidate_signature(candidate, signature, error)) return false; - bool created = false; - if (!refresh_cache(candidate, cache_root, id, source, signature, &created, error)) - return false; - if (app->models.count >= BONGO_CAT_MODEL_CAP) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_FORMAT, - "Too many models while adding portable Mver package"); - return false; - } - char adapter[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(adapter, sizeof(adapter), cache_root, id)) return false; - BongoCatModelEntry *entry = &app->models.entries[app->models.count++]; - snprintf(entry->id, sizeof(entry->id), "%s", id); - candidate_name(source, candidate, entry->display_name, - sizeof(entry->display_name)); - snprintf(entry->directory, sizeof(entry->directory), "%s", candidate->directory); - snprintf(entry->adapter_directory, sizeof(entry->adapter_directory), "%s", adapter); - snprintf(entry->storage_directory, sizeof(entry->storage_directory), "%s", source); - snprintf(entry->setting_file, sizeof(entry->setting_file), "%s", candidate->setting); - entry->mode = candidate->mode; - entry->managed = true; - if (created && !first_created[0]) - snprintf(first_created, BONGO_CAT_ID_CAP, "%s", id); - return true; -} - -static BongoCatResult add_discovery(BongoCatApp *app, const char *cache_root, - const char *source, BongoCatImportDiscovery *discovery, - char *first_created, BongoCatError *error) { - for (size_t i = 0; i < discovery->count; ++i) { - if (!add_candidate(app, cache_root, source, &discovery->candidates[i], - first_created, error)) return error && error->code ? error->code : - BONGO_CAT_ERROR_IO; - } - return BONGO_CAT_OK; -} - -typedef struct PortableAdd { - BongoCatApp *app; const char *cache_root; char *first_created; -} PortableAdd; - -static BongoCatResult add_scanned(void *userdata, const char *source, - BongoCatImportDiscovery *discovery, BongoCatError *error) { - PortableAdd *add = userdata; - return add_discovery(add->app, add->cache_root, source, discovery, - add->first_created, error); -} - -static BongoCatResult import_portable_mver(BongoCatApp *app, - const char *root, bool bounded_scan, BongoCatError *error) { - if (!app || !root || !app->data_root[0]) return BONGO_CAT_ERROR_ARGUMENT; - if (!bongo_cat_path_is_dir(root)) return BONGO_CAT_OK; - char cache_root[BONGO_CAT_PATH_CAP], first_created[BONGO_CAT_ID_CAP] = {0}; - if (!bongo_cat_path_join(cache_root, sizeof(cache_root), app->data_root, - PORTABLE_DIRECTORY) || !bongo_cat_path_create_directory(cache_root)) return BONGO_CAT_ERROR_IO; - BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); - if (!discovery) return BONGO_CAT_ERROR_MEMORY; - int direct = bounded_scan - ? bongo_cat_import_mver_discover_exact(root, discovery, error) - : bongo_cat_import_mver_discover(root, discovery, error); - if (bounded_scan && direct <= 0) { - memset(discovery, 0, sizeof(*discovery)); - if (error) *error = (BongoCatError){0}; - direct = bongo_cat_import_mver_patch_discover_exact(root, - discovery, error); - } - const char *direct_source = direct > 0 && discovery->candidates[0].package_root[0] - ? discovery->candidates[0].package_root : root; - BongoCatResult result = direct > 0 - ? add_discovery(app, cache_root, direct_source, discovery, first_created, error) : - BONGO_CAT_OK; - if (direct <= 0) { - if (error) *error = (BongoCatError){0}; - PortableAdd add = {app, cache_root, first_created}; - result = bongo_cat_import_portable_scan(root, add_scanned, &add, error); - } - const char *selected = app->config.current_model; - if (first_created[0] && (!selected[0] || strcmp(selected, "standard") == 0 || - strcmp(selected, "keyboard") == 0 || strcmp(selected, "gamepad") == 0)) - snprintf(app->config.current_model, sizeof(app->config.current_model), "%s", first_created); - free(discovery); - return result; -} - -BongoCatResult bongo_cat_import_portable_mver(BongoCatApp *app, - const char *root, BongoCatError *error) { - return import_portable_mver(app, root, false, error); -} - -BongoCatResult bongo_cat_import_portable_mver_scan(BongoCatApp *app, - const char *root, BongoCatError *error) { - return import_portable_mver(app, root, true, error); -} - -BongoCatResult bongo_cat_import_nearby_mver(BongoCatApp *app, - const char *root, BongoCatError *error) { - if (!app || !root) return BONGO_CAT_ERROR_ARGUMENT; - size_t before = app->models.count; - BongoCatResult result = bongo_cat_import_portable_mver(app, root, error); - if (result != BONGO_CAT_OK || app->models.count != before) return result; - char parent[BONGO_CAT_PATH_CAP]; - if (!parent_path(root, parent, sizeof(parent)) || strcmp(parent, root) == 0) - return BONGO_CAT_OK; - if (error) *error = (BongoCatError){0}; - return bongo_cat_import_portable_mver(app, parent, error); -} diff --git a/src/runtime/model_import_portable_scan.c b/src/runtime/model_import_portable_scan.c deleted file mode 100644 index 47772e42..00000000 --- a/src/runtime/model_import_portable_scan.c +++ /dev/null @@ -1,185 +0,0 @@ -#include "model_import.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include - -#define PORTABLE_CHILD_CAP 64 -#define PORTABLE_ENTRY_CAP 256 -#define PORTABLE_NODE_CAP 256 -#define PORTABLE_SCAN_DEPTH 3 -#define PORTABLE_SCAN_BUDGET_NS 2000000000ull - -typedef struct ChildList { - char paths[PORTABLE_CHILD_CAP][BONGO_CAT_PATH_CAP]; - size_t count, entries; - bool limited; -} ChildList; - -typedef struct PortableWorkspace { - BongoCatImportDiscovery discovery; - ChildList children; -} PortableWorkspace; - -typedef struct PortableScan { - BongoCatPortableVisitor visitor; - void *userdata; - BongoCatError *error; - size_t nodes; - uint64_t deadline; - bool limited; -} PortableScan; - -typedef struct PortableQueue { - char paths[PORTABLE_NODE_CAP][BONGO_CAT_PATH_CAP]; - unsigned char depths[PORTABLE_NODE_CAP], priorities[PORTABLE_NODE_CAP]; - size_t head, count; -} PortableQueue; - -typedef struct ModelHint { size_t entries; bool found; } ModelHint; - -static BongoCatPathVisit collect_child(void *userdata, - const char *dirname, const char *name) { - ChildList *list = userdata; - if (++list->entries > PORTABLE_ENTRY_CAP || list->count >= PORTABLE_CHILD_CAP) { - list->limited = true; return BONGO_CAT_PATH_SUCCESS; - } - if (name[0] == '.') return BONGO_CAT_PATH_CONTINUE; - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), dirname, name) && - bongo_cat_path_is_dir(path)) - snprintf(list->paths[list->count++], BONGO_CAT_PATH_CAP, "%s", path); - return BONGO_CAT_PATH_CONTINUE; -} - -static int compare_path(const void *left, const void *right) { -#ifdef _WIN32 - return SDL_strcasecmp(left, right); -#else - return strcmp(left, right); -#endif -} - -static bool within_budget(PortableScan *scan) { - if (scan->nodes >= PORTABLE_NODE_CAP || SDL_GetTicksNS() >= scan->deadline) { - scan->limited = true; return false; - } - scan->nodes++; return true; -} - -static bool direct_image_root(const char *path) { - char image[BONGO_CAT_PATH_CAP]; - return bongo_cat_path_join(image, sizeof(image), path, "img") && - bongo_cat_path_is_dir(image); -} - -static BongoCatPathVisit find_model_hint(void *userdata, - const char *dirname, const char *name) { - ModelHint *hint = userdata; - if (++hint->entries > PORTABLE_CHILD_CAP) return BONGO_CAT_PATH_SUCCESS; - char path[BONGO_CAT_PATH_CAP]; - if (name[0] != '.' && bongo_cat_path_join(path, sizeof(path), dirname, name) && - bongo_cat_path_is_dir(path) && direct_image_root(path)) { - hint->found = true; return BONGO_CAT_PATH_SUCCESS; - } - return BONGO_CAT_PATH_CONTINUE; -} - -static bool model_hint(const char *path) { - if (direct_image_root(path)) return true; - ModelHint hint = {0}; - bongo_cat_path_enumerate(path, find_model_hint, &hint); - return hint.found; -} - -static void enqueue_children(PortableScan *scan, PortableQueue *queue, - const ChildList *children, int depth) { - for (size_t i = 0; i < children->count; ++i) { - if (queue->count >= PORTABLE_NODE_CAP) { - scan->limited = true; return; - } - snprintf(queue->paths[queue->count], BONGO_CAT_PATH_CAP, "%s", - children->paths[i]); - queue->depths[queue->count++] = (unsigned char)depth; - queue->priorities[queue->count - 1] = model_hint(children->paths[i]); - } -} - -static void prioritize_next(PortableQueue *queue) { - size_t preferred = queue->head; - while (preferred < queue->count && !queue->priorities[preferred]) preferred++; - if (preferred >= queue->count || preferred == queue->head) return; - char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s", queue->paths[queue->head]); - memmove(queue->paths[queue->head], queue->paths[preferred], BONGO_CAT_PATH_CAP); - memmove(queue->paths[preferred], path, BONGO_CAT_PATH_CAP); - unsigned char depth = queue->depths[queue->head]; - queue->depths[queue->head] = queue->depths[preferred]; - queue->depths[preferred] = depth; - queue->priorities[preferred] = queue->priorities[queue->head]; - queue->priorities[queue->head] = 1; -} - -static BongoCatResult scan_node(PortableScan *scan, PortableQueue *queue, - const char *source, int depth) { - if (!within_budget(scan)) return BONGO_CAT_OK; - PortableWorkspace *work = calloc(1, sizeof(*work)); - if (!work) { - bongo_cat_error_set(scan->error, BONGO_CAT_ERROR_MEMORY, - "Cannot allocate portable model scan workspace"); - return BONGO_CAT_ERROR_MEMORY; - } - BongoCatError local = {0}; - int found = bongo_cat_import_mver_discover_exact(source, &work->discovery, &local); - if (found <= 0) { - memset(&work->discovery, 0, sizeof(work->discovery)); local = (BongoCatError){0}; - found = bongo_cat_import_mver_patch_discover_exact(source, - &work->discovery, &local); - } - if (found > 0) { - uint64_t started = SDL_GetTicksNS(); - BongoCatResult result = scan->visitor(scan->userdata, source, - &work->discovery, scan->error); - uint64_t finished = SDL_GetTicksNS(), elapsed = finished - started; - if (scan->deadline <= UINT64_MAX - elapsed) scan->deadline += elapsed; - else scan->deadline = UINT64_MAX; - free(work); return result; - } - if (depth < PORTABLE_SCAN_DEPTH && - bongo_cat_path_enumerate(source, collect_child, &work->children)) { - if (work->children.limited) scan->limited = true; - qsort(work->children.paths, work->children.count, - sizeof(work->children.paths[0]), compare_path); - enqueue_children(scan, queue, &work->children, depth + 1); - } - free(work); return BONGO_CAT_OK; -} - -BongoCatResult bongo_cat_import_portable_scan(const char *root, - BongoCatPortableVisitor visitor, void *userdata, BongoCatError *error) { - if (!root || !visitor) return BONGO_CAT_ERROR_ARGUMENT; - PortableWorkspace *work = calloc(1, sizeof(*work)); - PortableQueue *queue = calloc(1, sizeof(*queue)); - if (!work || !queue) { free(work); free(queue); return BONGO_CAT_ERROR_MEMORY; } - PortableScan scan = {visitor, userdata, error, 0, - SDL_GetTicksNS() + PORTABLE_SCAN_BUDGET_NS, false}; - BongoCatResult result = BONGO_CAT_OK; - if (bongo_cat_path_enumerate(root, collect_child, &work->children)) { - if (work->children.limited) scan.limited = true; - qsort(work->children.paths, work->children.count, - sizeof(work->children.paths[0]), compare_path); - enqueue_children(&scan, queue, &work->children, 1); - while (queue->head < queue->count) { - prioritize_next(queue); - size_t current = queue->head++; - BongoCatResult item = scan_node(&scan, queue, - queue->paths[current], queue->depths[current]); - if (item != BONGO_CAT_OK && result == BONGO_CAT_OK) result = item; - } - } - if (scan.limited) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Portable model discovery reached its startup scan budget"); - free(queue); free(work); return result; -} diff --git a/src/runtime/model_update.c b/src/runtime/model_update.c deleted file mode 100644 index 7002e92f..00000000 --- a/src/runtime/model_update.c +++ /dev/null @@ -1,14 +0,0 @@ -#include "runtime.h" - -bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed_seconds) { - if (!app || !app->live2d || elapsed_seconds <= 0.0f) return false; - if (elapsed_seconds > 0.25f) elapsed_seconds = 0.25f; - unsigned steps = 1; - while (steps < 8 && elapsed_seconds / steps > 1.0f / 30.0f) steps++; - float step = elapsed_seconds / steps; - bool changed = false; - for (unsigned i = 0; i < steps; ++i) - changed = bongo_cat_live2d_update(app->live2d, step) || changed; - if (changed) app->dirty = true; - return changed; -} diff --git a/src/runtime/mouse.c b/src/runtime/mouse.c deleted file mode 100644 index 59eec97e..00000000 --- a/src/runtime/mouse.c +++ /dev/null @@ -1,102 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include - -static void audit_mouse(BongoCatApp *app, double x, double y) { - if (!app->smoke_input_audit) return; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, "input-audit.txt")) return; - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - fprintf(file, "mouse x=%.2f y=%.2f\n", x, y); - fclose(file); -} - -void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y) { - int window_x, window_y, width, height; - SDL_GetWindowPosition(app->window, &window_x, &window_y); - SDL_GetWindowSize(app->window, &width, &height); - bool inside = x >= window_x && x <= window_x + width && - y >= window_y && y <= window_y + height; - app->pointer_known = true; - app->pointer_x = x; - app->pointer_y = y; - bongo_cat_window_schedule_pointer_hit(app); - if (inside == app->hover_inside) return; - app->hover_inside = inside; - app->hover_deadline_ns = inside ? SDL_GetTicksNS() + - (uint64_t)(app->config.window.hide_delay_seconds * 1000000000.0) : 0; - if (!inside && app->hover_hidden) { - bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - app->hover_hidden = false; - bongo_cat_window_sync_click_through(app); - } -} - -void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now) { - if (!app->config.window.hide_on_hover || !app->hover_inside || app->hover_hidden || - !app->hover_deadline_ns || now < app->hover_deadline_ns) return; - bongo_cat_platform_set_opacity(&app->platform, 0.0f); - app->hover_hidden = true; - bongo_cat_window_sync_click_through(app); -} - -static bool reconcile_button(BongoCatApp *app, bool *current, bool pressed, - const char *parameter) { - if (*current == pressed) return false; - *current = pressed; - bongo_cat_live2d_set_parameter(app->live2d, parameter, - pressed ? 1.0f : 0.0f); - if (!pressed) bongo_cat_window_mark_hit_dirty(app); - app->dirty = true; - return true; -} - -void bongo_cat_app_apply_mouse(BongoCatApp *app) { - if (!app) return; - double target_x, target_y; - bool received = bongo_cat_input_take_mouse(&app->input, &target_x, &target_y); - float global_x = 0.0f, global_y = 0.0f; - SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState(&global_x, &global_y); - bool button_changed = reconcile_button(app, &app->left_mouse_down, - (buttons & SDL_BUTTON_LMASK) != 0, "ParamMouseLeftDown"); - button_changed = reconcile_button(app, &app->right_mouse_down, - (buttons & SDL_BUTTON_RMASK) != 0, "ParamMouseRightDown") || button_changed; - bool side_down = (buttons & (SDL_BUTTON_X1MASK | SDL_BUTTON_X2MASK)) != 0; - if (app->side_mouse_down != side_down) { - app->side_mouse_down = side_down; - button_changed = true; - app->dirty = true; - } - if (!received || target_x != global_x || target_y != global_y) { - target_x = global_x; - target_y = global_y; - } - bool moved = !app->pointer_known || app->pointer_x != target_x || - app->pointer_y != target_y; - if (moved) { - audit_mouse(app, target_x, target_y); - bongo_cat_app_track_hover(app, target_x, target_y); - // Refresh the hit pixel before the next button press. Waiting for the - // scheduled frame can leave a stale transparent state for fast clicks. - if (app->click_through_applied && !app->left_mouse_down && - !app->right_mouse_down) - bongo_cat_window_capture_pointer_hit(app); - } - bongo_cat_window_sync_click_through(app); - uint64_t now = SDL_GetTicksNS(); - app->mouse_last_ns = now; - double model_x = target_x, model_y = target_y; - bool model_moved = moved; - if (app->model_render_options.mver_projection && - !app->config.model.mouse_centered && - !bongo_cat_app_map_mver_pointer(app, target_x, target_y, - &model_x, &model_y, &model_moved)) return; - if (app->config.model.ignore_mouse || (!model_moved && !button_changed)) return; - bongo_cat_app_apply_mouse_coordinates(app, model_x, model_y, - target_x, target_y); -} diff --git a/src/runtime/mouse_mapping.c b/src/runtime/mouse_mapping.c deleted file mode 100644 index 3c99a6e9..00000000 --- a/src/runtime/mouse_mapping.c +++ /dev/null @@ -1,225 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/mver_pointer.h" -#include "bongo_cat/overlay.h" - -#include -#include - -#ifdef _WIN32 -#include -#endif - -static void set_parameter(BongoCatApp *app, const char *id, - float x_ratio, float y_ratio) { - BongoCatParameterRange range; - if (!bongo_cat_live2d_parameter(app->live2d, id, &range)) return; - size_t length = strlen(id); - char axis = length ? id[length - 1] : 'X'; - float value = bongo_cat_mouse_parameter_value(range.minimum, range.maximum, - x_ratio, y_ratio, axis, app->config.model.mouse_mirror); - bongo_cat_live2d_set_parameter(app->live2d, id, value); -} - -static bool mver_pointer_bounds(const BongoCatApp *app, SDL_Rect *bounds) { - const BongoCatLive2DRenderOptions *options = &app->model_render_options; - if (!options->mver_projection || !bounds) return false; - if (options->custom_pointer_bounds) { - *bounds = (SDL_Rect){options->pointer_left, options->pointer_top, - options->pointer_right - options->pointer_left, - options->pointer_bottom - options->pointer_top}; - return bounds->w > 0 && bounds->h > 0; - } -#ifdef _WIN32 - typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextFn)( - DPI_AWARENESS_CONTEXT); - FARPROC set_thread_dpi_proc = GetProcAddress( - GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"); - SetThreadDpiAwarenessContextFn set_thread_dpi = NULL; - memcpy(&set_thread_dpi, &set_thread_dpi_proc, sizeof(set_thread_dpi)); - DPI_AWARENESS_CONTEXT previous = set_thread_dpi - ? set_thread_dpi(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) : NULL; - RECT desktop = {0}; - bool available = GetWindowRect(GetDesktopWindow(), &desktop) != FALSE; - if (previous && set_thread_dpi) set_thread_dpi(previous); - if (available && desktop.right > 0 && desktop.bottom > 0) { - /* Mver 0.1.6 uses a zero origin and stores right/bottom as dimensions. */ - *bounds = (SDL_Rect){0, 0, desktop.right, desktop.bottom}; - return true; - } -#endif - SDL_DisplayID primary = SDL_GetPrimaryDisplay(); - SDL_Rect display; - if (!primary || !SDL_GetDisplayBounds(primary, &display)) return false; - *bounds = (SDL_Rect){0, 0, display.w, display.h}; - return bounds->w > 0 && bounds->h > 0; -} - -bool bongo_cat_app_map_mver_pointer(BongoCatApp *app, double absolute_x, - double absolute_y, double *x, double *y, bool *changed) { - SDL_Rect bounds; - if (!mver_pointer_bounds(app, &bounds)) return false; - BongoCatMverPointerBounds pointer_bounds = { - bounds.x, bounds.y, bounds.w, bounds.h - }; - double relative_x = 0.0, relative_y = 0.0; - bool use_relative = app->model_render_options.mouse_force_move && - bongo_cat_platform_relative_pointer(&app->platform, - &relative_x, &relative_y); - bool initialized = app->mver_pointer.initialized; - double previous_x = app->mver_pointer.x, previous_y = app->mver_pointer.y; - if (!bongo_cat_mver_pointer_update(&app->mver_pointer, - absolute_x, absolute_y, relative_x, relative_y, use_relative, - &pointer_bounds, x, y)) return false; - *changed = !initialized || previous_x != *x || previous_y != *y; - return true; -} - -static float clamp_ratio(float value) { - if (value < 0.0f) return 0.0f; - if (value > 1.0f) return 1.0f; - return value; -} - -static bool model_pointer_center(BongoCatApp *app, double *x, double *y) { - int window_x, window_y, width, height; - if (!app || !app->window || !x || !y || - !SDL_GetWindowPosition(app->window, &window_x, &window_y) || - !SDL_GetWindowSize(app->window, &width, &height) || - width <= 0 || height <= 0) return false; - if (!app->model_pointer_anchor_ready) { - BongoCatLive2DVisualState state = {0}; - if (bongo_cat_live2d_visual_state(app->live2d, &state) && state.visible) { - float center_x = (state.visible_min_x + state.visible_max_x) * 0.5f; - float center_y = (state.visible_min_y + state.visible_max_y) * 0.5f; - if (isfinite(center_x) && isfinite(center_y)) { - app->model_pointer_anchor_x = clamp_ratio(center_x * 0.5f + 0.5f); - app->model_pointer_anchor_y = clamp_ratio(0.5f - center_y * 0.5f); - app->model_pointer_anchor_ready = true; - } - } - } - float anchor_x = app->model_pointer_anchor_ready - ? app->model_pointer_anchor_x : 0.5f; - float anchor_y = app->model_pointer_anchor_ready - ? app->model_pointer_anchor_y : 0.5f; - *x = window_x + (double)width * anchor_x; - *y = window_y + (double)height * anchor_y; - return true; -} - -static bool model_pointer_bounds(BongoCatApp *app, SDL_Rect *bounds, - double *center_x, double *center_y) { - double visible_center_x, visible_center_y; - if (!bounds || !model_pointer_center(app, &visible_center_x, - &visible_center_y)) return false; - if (center_x) *center_x = visible_center_x; - if (center_y) *center_y = visible_center_y; - SDL_Point point = {(int)visible_center_x, (int)visible_center_y}; - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - return display && SDL_GetDisplayBounds(display, bounds) && - bounds->w > 0 && bounds->h > 0; -} - -static bool model_pointer_ratios(BongoCatApp *app, double x, double y, - float *x_ratio, float *y_ratio) { - double center_x, center_y; SDL_Rect bounds; - if (!x_ratio || !y_ratio || !model_pointer_bounds(app, &bounds, - ¢er_x, ¢er_y)) return false; - *x_ratio = clamp_ratio(0.5f + (float)((x - center_x) / bounds.w)); - *y_ratio = clamp_ratio(0.5f + (float)((y - center_y) / bounds.h)); - return true; -} - -void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, - double hand_y, double gaze_x, double gaze_y) { - SDL_Point point = {(int)hand_x, (int)hand_y}; SDL_Rect bounds; - bool screen_mapped = app->config.model.mouse_centered; - if (!(screen_mapped ? model_pointer_bounds(app, &bounds, NULL, NULL) : - mver_pointer_bounds(app, &bounds))) { - SDL_DisplayID display = SDL_GetDisplayForPoint(&point); - if (!display || !SDL_GetDisplayBounds(display, &bounds)) return; - } - BongoCatMverPointerBounds pointer_bounds = { - bounds.x, bounds.y, bounds.w, bounds.h - }; - float hand_x_ratio, hand_y_ratio; - if (!bongo_cat_mver_pointer_ratios(hand_x, hand_y, &pointer_bounds, - &hand_x_ratio, &hand_y_ratio)) return; - float gaze_x_ratio = hand_x_ratio, gaze_y_ratio = hand_y_ratio; - if (app->config.model.mouse_centered) - model_pointer_ratios(app, gaze_x, gaze_y, &gaze_x_ratio, &gaze_y_ratio); - bool exact_pointer = !app->model_mouse_parameters && - bongo_cat_overlay_mver_pointer_enabled(app->overlay); - bool mver = app->model_render_options.mver_projection; - bool left_handed = app->model_render_options.pointer_left_handed || - (exact_pointer && bongo_cat_overlay_mver_pointer_left_handed(app->overlay)); - float mver_x = left_handed ? 1.0f - gaze_x_ratio : gaze_x_ratio; - float drag_x = mver ? 2.0f * mver_x - 1.0f : - 1.0f - 2.0f * gaze_x_ratio; - float drag_y = 1.0f - 2.0f * gaze_y_ratio; - if (!mver && app->config.model.mouse_mirror) drag_x = -drag_x; - bongo_cat_overlay_set_mver_pointer(app->overlay, hand_x_ratio, hand_y_ratio, - app->left_mouse_down, app->right_mouse_down, app->side_mouse_down); - if (app->model_render_options.mver_projection && !exact_pointer) { - set_parameter(app, "ParamMouseX", 1.0f - hand_x_ratio, hand_y_ratio); - set_parameter(app, "ParamMouseY", hand_x_ratio, hand_y_ratio); - } - bongo_cat_live2d_set_dragging(app->live2d, drag_x, drag_y); - app->dirty = true; -} - -void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app) { - if (!app) return; - bongo_cat_platform_relative_pointer_reset(&app->platform); - app->mver_pointer = (BongoCatMverPointerState){0}; - app->model_pointer_anchor_ready = false; - app->pointer_known = false; - app->dirty = true; -} - -void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, - float elapsed_seconds) { - if (!app || app->config.model.ignore_mouse) return; - (void)elapsed_seconds; - bongo_cat_app_apply_mouse_coordinates(app, x, y, x, y); -} - -bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app) { - SDL_Rect bounds; - SDL_DisplayID display = SDL_GetPrimaryDisplay(); - if (!app || !display || !SDL_GetDisplayBounds(display, &bounds)) return false; - bool mouse_centered = app->config.model.mouse_centered; - app->config.model.mouse_centered = false; - bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w * 0.5, - bounds.y + bounds.h * 0.5, 1.0f / 60.0f); - for (int frame = 0; frame < 90; ++frame) - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - BongoCatParameterRange x, y; - bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamAngleX", &x) && - bongo_cat_live2d_parameter(app->live2d, "ParamAngleY", &y) && - fabsf(x.value) < 0.5f && fabsf(y.value) < 0.5f; - app->config.model.mouse_centered = mouse_centered; - return passed; -} - -bool bongo_cat_app_audit_display_pointer(BongoCatApp *app) { - SDL_Rect bounds; - if (!app) return false; - bool centered = app->config.model.mouse_centered; - app->config.model.mouse_centered = true; - if (!model_pointer_bounds(app, &bounds, NULL, NULL)) { - app->config.model.mouse_centered = centered; - return false; - } - BongoCatParameterRange tl_x = {0}, tl_y = {0}, br_x = {0}, br_y = {0}; - bongo_cat_app_apply_mouse_position(app, bounds.x, bounds.y, 0.0f); - bool passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &tl_x) && - bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &tl_y); - bongo_cat_app_apply_mouse_position(app, bounds.x + bounds.w - 1, - bounds.y + bounds.h - 1, 0.0f); - passed = bongo_cat_live2d_parameter(app->live2d, "ParamMouseX", &br_x) && - bongo_cat_live2d_parameter(app->live2d, "ParamMouseY", &br_y) && passed; - app->config.model.mouse_centered = centered; - return passed && tl_x.value < -20.0f && tl_y.value > 20.0f && - br_x.value > 20.0f && br_y.value < -20.0f; -} diff --git a/src/runtime/runtime.h b/src/runtime/runtime.h index 2032ba62..4eace0b3 100644 --- a/src/runtime/runtime.h +++ b/src/runtime/runtime.h @@ -3,14 +3,37 @@ #include "bongo_cat/app.h" #include "bongo_cat/memory_policy.h" +#include "update_service.h" +#include "window_snapshot.h" #include BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error); +bool bongo_cat_app_initialize(BongoCatApp *app, int argc, + char **argv, BongoCatError *error); +void bongo_cat_app_loop(BongoCatApp *app); +BongoCatResult bongo_cat_model_catalog_scan(BongoCatApp *app, bool cleanup, + const char *nearby_root); +void bongo_cat_model_catalog_finish(BongoCatApp *app); +bool bongo_cat_model_catalog_add_bundled(BongoCatApp *app, bool replace); +void bongo_cat_model_catalog_finish_package(BongoCatApp *app, + const char *package_id); +void bongo_cat_model_refresh_invalidate(BongoCatApp *app); +bool bongo_cat_model_refresh_event(BongoCatApp *app, + const SDL_Event *event); +void bongo_cat_model_refresh_update(BongoCatApp *app); +void bongo_cat_model_refresh_shutdown(BongoCatApp *app); BongoCatResult bongo_cat_app_locate_assets(BongoCatApp *app, BongoCatError *error); bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, BongoCatError *error); +bool bongo_cat_startup_arguments(BongoCatApp *app, int argc, char **argv, + BongoCatError *error); void bongo_cat_startup_stage(BongoCatApp *app, const char *stage); void bongo_cat_startup_ready(BongoCatApp *app); +void bongo_cat_runtime_stage(BongoCatApp *app, const char *stage); +void bongo_cat_runtime_log_stop(void); +void bongo_cat_runtime_clean_shutdown(BongoCatApp *app, int exit_code); +void bongo_cat_app_shutdown(BongoCatApp *app, const char *stage, + int exit_code); void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_startup_ci_failure(BongoCatApp *app, const BongoCatError *error); void bongo_cat_window_destroy(BongoCatApp *app); @@ -37,9 +60,17 @@ void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app); bool bongo_cat_window_wheel_self_test(BongoCatApp *app); bool bongo_cat_window_scaled_size(int base_width, int base_height, float base_scale, float requested_scale, float *actual_scale, int *width, int *height); +bool bongo_cat_window_frame_size(BongoCatApp *app, + int content_width, int content_height, int *width, int *height, + int *left, int *top); +bool bongo_cat_window_content_size(BongoCatApp *app, + int width, int height, int *content_width, int *content_height); bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, float scale, int width, int height); bool bongo_cat_window_set_scale(BongoCatApp *app, float scale); +bool bongo_cat_window_apply_scale_centered(BongoCatApp *app, float scale, + int base_width, int base_height, float base_scale, + float center_x, float center_y); void bongo_cat_window_clamp_to_display(BongoCatApp *app); void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y); void bongo_cat_window_drag_bounds_refresh(BongoCatApp *app); @@ -60,10 +91,6 @@ void bongo_cat_gamepads_set_enabled(BongoCatApp *app, bool enabled); void bongo_cat_app_reset_gamepad(BongoCatApp *app); void bongo_cat_app_apply_mouse(BongoCatApp *app); void bongo_cat_app_reset_pointer_tracking(BongoCatApp *app); -bool bongo_cat_app_map_mver_pointer(BongoCatApp *app, double absolute_x, - double absolute_y, double *x, double *y, bool *changed); -void bongo_cat_app_apply_mouse_coordinates(BongoCatApp *app, double hand_x, - double hand_y, double gaze_x, double gaze_y); void bongo_cat_app_drain_input(BongoCatApp *app, bool allow_shortcuts); void bongo_cat_app_apply_mouse_position(BongoCatApp *app, double x, double y, float elapsed_seconds); @@ -71,9 +98,11 @@ bool bongo_cat_app_audit_screen_pointer(BongoCatApp *app); bool bongo_cat_app_audit_display_pointer(BongoCatApp *app); void bongo_cat_app_track_hover(BongoCatApp *app, double x, double y); void bongo_cat_app_update_hover(BongoCatApp *app, uint64_t now); -BongoCatResult bongo_cat_copy_directory(const char *source, const char *target, - BongoCatError *error); +void bongo_cat_app_update_hover_fade(BongoCatApp *app, uint64_t now); +void bongo_cat_app_cancel_hover_fade(BongoCatApp *app); bool bongo_cat_app_shortcuts_self_test(BongoCatApp *app); +bool bongo_cat_app_sound_shortcuts(BongoCatApp *app, const BongoCatInputEvent *event, + bool *changed); void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action); bool bongo_cat_window_menu_self_test(BongoCatApp *app); bool bongo_cat_window_geometry_self_test(BongoCatApp *app); @@ -82,11 +111,42 @@ void bongo_cat_live2d_audit_run(BongoCatApp *app); bool bongo_cat_live2d_visual_audit_run(BongoCatApp *app); bool bongo_cat_live2d_viewer_audit_run(BongoCatApp *app); void bongo_cat_frame_audit(BongoCatApp *app, int width, int height); -void bongo_cat_frame_capture_pending(BongoCatApp *app, int width, int height); +void bongo_cat_frame_presentation_prepare(BongoCatApp *app, + const unsigned char *pixels, int width, int height, bool visible); +void bongo_cat_frame_presented_audit(BongoCatApp *app); +void bongo_cat_window_clear_background(BongoCatApp *app); +void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height); +void bongo_cat_window_destroy_corner_mask(void); void bongo_cat_app_render_now(BongoCatApp *app); +bool bongo_cat_app_capture_pending_model_cover(BongoCatApp *app); void bongo_cat_runtime_flow_update(BongoCatApp *app, uint64_t now); -void bongo_cat_config_store_initialize(BongoCatApp *app); +void bongo_cat_random_expression_update(BongoCatApp *app, uint64_t now); +void bongo_cat_random_expression_reset(BongoCatApp *app); +bool bongo_cat_system_language(BongoCatLanguage *language); +void bongo_cat_config_store_load(BongoCatApp *app); void bongo_cat_config_store_update(BongoCatApp *app, uint64_t now); void bongo_cat_config_store_flush(BongoCatApp *app); +bool bongo_cat_multi_pet_secondary_argument(int argc, char **argv); +bool bongo_cat_multi_pet_state_directory(char *target, size_t capacity, + const char *root, const char *model_id); +void bongo_cat_multi_pet_update(BongoCatApp *app, uint64_t now); +void bongo_cat_multi_pet_prune_selection(BongoCatApp *app); +void bongo_cat_multi_pet_primary_update(BongoCatApp *app, uint64_t now); +bool bongo_cat_multi_pet_request_remove(BongoCatApp *app); +bool bongo_cat_multi_pet_request_preferences(BongoCatApp *app); +bool bongo_cat_multi_pet_request_exit(BongoCatApp *app); +bool bongo_cat_multi_pet_request_pass_through(BongoCatApp *app, + bool enabled); +void bongo_cat_multi_pet_primary_requests_update(BongoCatApp *app); +void bongo_cat_multi_pet_clear_primary_request(BongoCatApp *app, + const char *model_id); +void bongo_cat_multi_pet_pass_through_requests_update(BongoCatApp *app); +void bongo_cat_multi_pet_clear_pass_through_request(BongoCatApp *app, + const char *model_id); +bool bongo_cat_multi_pet_take_remove_request(BongoCatApp *app, + const char *model_id); +void bongo_cat_multi_pet_clear_remove_request(BongoCatApp *app, + const char *model_id); +void bongo_cat_multi_pet_shutdown(BongoCatApp *app); #endif diff --git a/src/runtime/modal_frame.c b/src/runtime/shell/modal_frame.c similarity index 90% rename from src/runtime/modal_frame.c rename to src/runtime/shell/modal_frame.c index 71cd0e53..1a658587 100644 --- a/src/runtime/modal_frame.c +++ b/src/runtime/shell/modal_frame.c @@ -24,8 +24,11 @@ void bongo_cat_modal_frame_tick(void *userdata) { float elapsed = modal_elapsed(state, app->last_frame_ns, now); app->last_frame_ns = now; state->tick_count++; - bongo_cat_app_drain_input(app, false); + bongo_cat_app_drain_input(app, true); + bongo_cat_window_snapshot_update(app, SDL_GetTicksNS()); + now = SDL_GetTicksNS(); bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); if (!app->smoke_freeze_model && elapsed > 0.0f) bongo_cat_app_step_live2d(app, elapsed); bongo_cat_app_render_now(app); diff --git a/src/runtime/modal_frame.h b/src/runtime/shell/modal_frame.h similarity index 100% rename from src/runtime/modal_frame.h rename to src/runtime/shell/modal_frame.h diff --git a/src/runtime/tray.c b/src/runtime/shell/tray.c similarity index 50% rename from src/runtime/tray.c rename to src/runtime/shell/tray.c index 6d7d9e76..84095522 100644 --- a/src/runtime/tray.c +++ b/src/runtime/shell/tray.c @@ -6,6 +6,7 @@ #include "bongo_cat/preferences.h" #include "modal_frame.h" #include "runtime.h" +#include "ui_native_theme.h" #include #include @@ -20,7 +21,9 @@ struct BongoCatTray { SDL_TrayEntry *exit; BongoCatImage icon; BongoCatModalFrame modal_frame; - bool state_valid; + bool state_valid, restore_requested; + uint64_t restore_after_ns; + unsigned restore_count; bool last_visible, last_pass_through, last_always_on_top; BongoCatLanguage last_language; }; @@ -29,7 +32,7 @@ static void on_visible(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; BongoCatApp *app = tray->app; - bongo_cat_window_set_visible(app, !app->config.window.visible); + bongo_cat_window_set_visible(app, !app->session.window.visible); bongo_cat_tray_sync(tray); bongo_cat_preferences_invalidate(app->preferences); } @@ -37,7 +40,7 @@ static void on_visible(void *userdata, SDL_TrayEntry *entry) { static void on_pass_through(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; - tray->app->config.window.pass_through = !tray->app->config.window.pass_through; + tray->app->settings.window.pass_through = !tray->app->settings.window.pass_through; bongo_cat_window_mark_hit_dirty(tray->app); bongo_cat_window_sync_click_through(tray->app); bongo_cat_tray_sync(tray); @@ -47,9 +50,9 @@ static void on_pass_through(void *userdata, SDL_TrayEntry *entry) { static void on_always_on_top(void *userdata, SDL_TrayEntry *entry) { (void)entry; BongoCatTray *tray = userdata; - tray->app->config.window.always_on_top = !tray->app->config.window.always_on_top; + tray->app->settings.window.always_on_top = !tray->app->settings.window.always_on_top; bongo_cat_platform_set_always_on_top(&tray->app->platform, - tray->app->config.window.always_on_top); + tray->app->settings.window.always_on_top); bongo_cat_window_mark_hit_dirty(tray->app); bongo_cat_window_sync_click_through(tray->app); bongo_cat_tray_sync(tray); @@ -78,6 +81,21 @@ static void on_exit(void *userdata, SDL_TrayEntry *entry) { ((BongoCatTray *)userdata)->app->running = false; } +static void on_tray_restore(void *userdata) { + BongoCatTray *tray = userdata; + if (!tray) return; + if (!tray->restore_requested) + SDL_Log("Windows shell restarted; scheduling tray restoration"); + tray->restore_requested = true; + tray->restore_after_ns = 0; +} + +void bongo_cat_tray_prepare_menu(BongoCatTray *tray, void *native_handle) { + if (!tray || !tray->app) return; + bool dark = SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; + bongo_cat_ui_native_menu_prepare_native(native_handle, dark); +} + static SDL_TrayEntry *add(SDL_TrayMenu *menu, const char *label, SDL_TrayEntryFlags flags, SDL_TrayCallback callback, BongoCatTray *tray) { SDL_TrayEntry *entry = SDL_InsertTrayEntryAt(menu, -1, label, flags); @@ -85,68 +103,115 @@ static SDL_TrayEntry *add(SDL_TrayMenu *menu, const char *label, return entry; } +static void destroy_native_tray(BongoCatTray *tray) { + if (!tray || !tray->handle) return; + bongo_cat_platform_set_tray_callbacks( + tray->handle, NULL, NULL, NULL, NULL); + SDL_DestroyTray(tray->handle); + tray->handle = NULL; + tray->visible = tray->pass_through = tray->always_on_top = NULL; + tray->preferences = tray->exit = NULL; + tray->state_valid = false; +} + +static bool create_native_tray(BongoCatTray *tray, BongoCatError *error) { + tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_TRAY_TITLE); + if (!tray->handle) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Tray creation failed: %s", SDL_GetError()); + return false; + } + SDL_TrayMenu *menu = SDL_CreateTrayMenu(tray->handle); + SDL_TrayEntry *separator1 = NULL, *separator2 = NULL; + if (menu) { + tray->preferences = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.preference", "Preferences"), + SDL_TRAYENTRY_BUTTON, on_preferences, tray); + separator1 = add(menu, NULL, 0, NULL, tray); + tray->visible = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.showCat", "Show BongoCat"), + SDL_TRAYENTRY_CHECKBOX, on_visible, tray); + tray->pass_through = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.passThrough", "Mouse pass-through"), + SDL_TRAYENTRY_CHECKBOX, on_pass_through, tray); + tray->always_on_top = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), + SDL_TRAYENTRY_CHECKBOX, on_always_on_top, tray); + separator2 = add(menu, NULL, 0, NULL, tray); + tray->exit = add(menu, bongo_cat_i18n_get(tray->app->i18n, + "composables.useAppMenu.labels.quitApp", "Exit"), + SDL_TRAYENTRY_BUTTON, on_exit, tray); + } + if (!menu || !tray->preferences || !separator1 || !tray->visible || + !tray->pass_through || !tray->always_on_top || !separator2 || + !tray->exit) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Tray menu creation failed: %s", SDL_GetError()); + destroy_native_tray(tray); + return false; + } + bongo_cat_platform_set_tray_callbacks(tray->handle, + on_tray_left_click, on_tray_modal_frame, on_tray_restore, tray); + tray->state_valid = false; + return true; +} + BongoCatTray *bongo_cat_tray_create(BongoCatApp *app, BongoCatError *error) { - if (!app || !app->config.app.tray_visible) return NULL; + if (!app || !app->settings.app.tray_visible) return NULL; BongoCatTray *tray = calloc(1, sizeof(*tray)); if (!tray) return NULL; tray->app = app; bongo_cat_modal_frame_init(&tray->modal_frame, app); char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), app->asset_root, -#ifdef __APPLE__ - "tray-mac.png"); -#else - "tray.png"); -#endif + bongo_cat_path_join(path, sizeof(path), app->asset_root, "bongocat.png"); if (bongo_cat_image_load(path, &tray->icon, error) != BONGO_CAT_OK) { bongo_cat_tray_destroy(tray); return NULL; } - tray->handle = SDL_CreateTray(tray->icon.surface, BONGO_CAT_NAME); - bongo_cat_image_free(&tray->icon); - if (!tray->handle) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, "Tray creation failed: %s", SDL_GetError()); + if (!create_native_tray(tray, error)) { bongo_cat_tray_destroy(tray); return NULL; } - SDL_TrayMenu *menu = SDL_CreateTrayMenu(tray->handle); - tray->preferences = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.preference", "Preferences"), SDL_TRAYENTRY_BUTTON, - on_preferences, tray); - add(menu, NULL, 0, NULL, tray); - tray->visible = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.showCat", "Show BongoCat"), SDL_TRAYENTRY_CHECKBOX, - on_visible, tray); - tray->pass_through = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.passThrough", "Mouse pass-through"), SDL_TRAYENTRY_CHECKBOX, - on_pass_through, tray); - tray->always_on_top = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), SDL_TRAYENTRY_CHECKBOX, - on_always_on_top, tray); - add(menu, NULL, 0, NULL, tray); - tray->exit = add(menu, bongo_cat_i18n_get(app->i18n, - "composables.useAppMenu.labels.quitApp", "Exit"), SDL_TRAYENTRY_BUTTON, on_exit, tray); - bongo_cat_platform_set_tray_callbacks(tray->handle, - on_tray_left_click, on_tray_modal_frame, tray); + SDL_Log("Tray created"); bongo_cat_tray_sync(tray); return tray; } +static bool restore_native_tray(BongoCatTray *tray) { + if (!tray->restore_requested) return tray->handle != NULL; + uint64_t now = SDL_GetTicksNS(); + if (now < tray->restore_after_ns) return tray->handle != NULL; + destroy_native_tray(tray); + BongoCatError error = {0}; + if (!create_native_tray(tray, &error)) { + tray->restore_requested = true; + tray->restore_after_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Tray restoration failed; retrying: %s", error.message); + return false; + } + tray->restore_requested = false; + tray->restore_count++; + SDL_Log("Tray restored after Windows shell restart (count=%u)", + tray->restore_count); + return true; +} + void bongo_cat_tray_sync(BongoCatTray *tray) { - if (!tray) return; + if (!tray || !restore_native_tray(tray)) return; BongoCatApp *app = tray->app; - if (tray->state_valid && tray->last_visible == app->config.window.visible && - tray->last_pass_through == app->config.window.pass_through && - tray->last_always_on_top == app->config.window.always_on_top && - tray->last_language == app->config.app.language) return; + if (tray->state_valid && tray->last_visible == app->session.window.visible && + tray->last_pass_through == app->settings.window.pass_through && + tray->last_always_on_top == app->settings.window.always_on_top && + tray->last_language == app->settings.app.language) return; tray->state_valid = true; - tray->last_visible = app->config.window.visible; - tray->last_pass_through = app->config.window.pass_through; - tray->last_always_on_top = app->config.window.always_on_top; - tray->last_language = app->config.app.language; - SDL_SetTrayEntryChecked(tray->visible, tray->app->config.window.visible); - SDL_SetTrayEntryChecked(tray->pass_through, tray->app->config.window.pass_through); - SDL_SetTrayEntryChecked(tray->always_on_top, tray->app->config.window.always_on_top); + tray->last_visible = app->session.window.visible; + tray->last_pass_through = app->settings.window.pass_through; + tray->last_always_on_top = app->settings.window.always_on_top; + tray->last_language = app->settings.app.language; + SDL_SetTrayEntryChecked(tray->visible, tray->app->session.window.visible); + SDL_SetTrayEntryChecked(tray->pass_through, tray->app->settings.window.pass_through); + SDL_SetTrayEntryChecked(tray->always_on_top, tray->app->settings.window.always_on_top); // The checkbox already communicates visibility; keep the action name // stable so users do not have to reinterpret the menu after each click. SDL_SetTrayEntryLabel(tray->visible, bongo_cat_i18n_get(tray->app->i18n, @@ -170,10 +235,18 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { const SDL_TrayEntry **entries = SDL_GetTrayEntries( SDL_GetTrayEntryParent(tray->preferences), &count); if (!entries || count < 1 || entries[0] != tray->preferences) return false; +#ifdef _WIN32 + on_tray_restore(tray); + bongo_cat_tray_sync(tray); + if (tray->restore_requested || !tray->handle || !tray->preferences || + !tray->visible || !tray->pass_through || !tray->always_on_top || + !tray->exit) return false; + SDL_Log("Tray self-test: shell restart restored"); +#endif BongoCatApp *app = tray->app; - bool visible = app->config.window.visible; - bool pass_through = app->config.window.pass_through; - bool always_on_top = app->config.window.always_on_top; + bool visible = app->session.window.visible; + bool pass_through = app->settings.window.pass_through; + bool always_on_top = app->settings.window.always_on_top; on_visible(tray, tray->visible); on_visible(tray, tray->visible); on_pass_through(tray, tray->pass_through); on_pass_through(tray, tray->pass_through); @@ -186,17 +259,16 @@ bool bongo_cat_tray_self_test(BongoCatTray *tray) { bongo_cat_preferences_close(app->preferences); SDL_Log("Tray self-test: preferences closed"); bool result = preferences && bongo_cat_modal_frame_self_test() && - app->config.window.visible == visible && - app->config.window.pass_through == pass_through && - app->config.window.always_on_top == always_on_top; + app->session.window.visible == visible && + app->settings.window.pass_through == pass_through && + app->settings.window.always_on_top == always_on_top; SDL_Log("Tray self-test: result=%d", result); return result; } void bongo_cat_tray_destroy(BongoCatTray *tray) { if (!tray) return; - bongo_cat_platform_set_tray_callbacks(tray->handle, NULL, NULL, NULL); - if (tray->handle) SDL_DestroyTray(tray->handle); + destroy_native_tray(tray); bongo_cat_image_free(&tray->icon); free(tray); } diff --git a/src/runtime/shell/window.c b/src/runtime/shell/window.c new file mode 100644 index 00000000..6a6a152d --- /dev/null +++ b/src/runtime/shell/window.c @@ -0,0 +1,247 @@ +#include "runtime.h" +#include +#include + +static bool set_gl_attributes(int samples) { + SDL_GL_ResetAttributes(); +#ifdef __APPLE__ + const int major = 4, minor = 1, profile = SDL_GL_CONTEXT_PROFILE_CORE; +#elif defined(BONGO_CAT_HAS_CUBISM) + const int major = 3, minor = 3, profile = SDL_GL_CONTEXT_PROFILE_COMPATIBILITY; +#else + const int major = 3, minor = 3, profile = SDL_GL_CONTEXT_PROFILE_CORE; +#endif + return SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major) && + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor) && + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, profile) && + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1) && + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0) && + SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8) && + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, samples > 0 ? 1 : 0) && + SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, samples); +} + +static bool try_window(BongoCatApp *app, bool transparent, int samples, + char *failure, size_t capacity) { + if (SDL_getenv("BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY")) + transparent = false; + if (!set_gl_attributes(samples)) { + snprintf(failure, capacity, "OpenGL attributes: %s", SDL_GetError()); return false; + } + SDL_WindowFlags flags = SDL_WINDOW_OPENGL | SDL_WINDOW_BORDERLESS | + SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_HIDDEN; + if (transparent) flags |= SDL_WINDOW_TRANSPARENT; + app->window = SDL_CreateWindow(BONGO_CAT_PET_WINDOW_TITLE, + app->session.window.width, + app->session.window.height, flags); + if (!app->window) { + snprintf(failure, capacity, "Window creation: %s", SDL_GetError()); return false; + } + app->gl_context = SDL_GL_CreateContext(app->window); + if (!app->gl_context || !SDL_GL_MakeCurrent(app->window, app->gl_context)) { + snprintf(failure, capacity, "OpenGL context: %s", SDL_GetError()); + if (app->gl_context) SDL_GL_DestroyContext(app->gl_context); + SDL_DestroyWindow(app->window); app->gl_context = NULL; app->window = NULL; + return false; + } + return true; +} + +BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { + SDL_SetHintWithPriority(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1", SDL_HINT_OVERRIDE); + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); +#ifdef _WIN32 + /* The native input thread owns keyboard Raw Input for this process. */ + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); + /* Transparent OpenGL windows must never receive SDL's default black + WM_ERASEBKGND fill before the first frame is submitted. */ + SDL_SetHint(SDL_HINT_WINDOWS_ERASE_BACKGROUND_MODE, "0"); +#endif + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "SDL initialization failed: %s", SDL_GetError()); + return BONGO_CAT_ERROR_PLATFORM; + } + /* Keep a lower-cost MSAA path for drivers that cannot provide 4 samples. */ + const int options[][2] = {{true, 4}, {true, 2}, {true, 0}, {false, 0}}; + char failure[256] = {0}; + bool force_fallback = SDL_getenv("BONGO_CAT_TEST_GL_FALLBACK") != NULL; + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); ++i) { + if (force_fallback && options[i][1] > 0) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Test requested OpenGL fallback"); continue; + } + if (try_window(app, options[i][0], options[i][1], failure, sizeof(failure))) { + int sample_buffers = 0, sample_count = 0; + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); + SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); + const GLubyte *vendor = glGetString(GL_VENDOR); + const GLubyte *renderer = glGetString(GL_RENDERER); + const GLubyte *version = glGetString(GL_VERSION); + SDL_Log("[runtime] OpenGL window ready (transparent=%d, MSAA=%d, " + "sample_buffers=%d, sample_count=%d)", options[i][0], options[i][1], + sample_buffers, sample_count); + SDL_Log("[runtime] OpenGL context: vendor=%s renderer=%s version=%s", + vendor ? (const char *)vendor : "unknown", + renderer ? (const char *)renderer : "unknown", + version ? (const char *)version : "unknown"); + if (!SDL_GL_SetSwapInterval(1)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Vertical sync unavailable: %s", SDL_GetError()); + return BONGO_CAT_OK; + } + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "OpenGL attempt %llu failed: %s", + (unsigned long long)(i + 1), failure); + } + bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, + "Window and OpenGL initialization failed after compatibility retries: %s", failure); + return BONGO_CAT_ERROR_PLATFORM; +} + +void bongo_cat_window_apply(BongoCatApp *app) { + BongoCatWindowPreferences *preferences = &app->settings.window; + BongoCatWindowState *state = &app->session.window; + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + state->opacity_percent / 100.0f); + SDL_SetWindowSize(app->window, state->width, state->height); + if (state->position_known) + SDL_SetWindowPosition(app->window, state->x, state->y); + SDL_SyncWindow(app->window); + if (preferences->keep_in_screen) bongo_cat_window_clamp_to_display(app); + else bongo_cat_window_recover_to_display(app); + SDL_SyncWindow(app->window); + /* A visible session is revealed by the first successful frame. Keeping + the native window hidden while loading avoids exposing an uninitialised + (and on some drivers black) back buffer. */ + bongo_cat_platform_set_visible(&app->platform, + state->visible && !app->startup_visibility_pending); + bongo_cat_window_sync_click_through(app); + bongo_cat_platform_set_always_on_top(&app->platform, + preferences->always_on_top); +} + +static bool event_targets_main_window(BongoCatApp *app, + const SDL_Event *event) { + SDL_WindowID id = SDL_GetWindowID(app->window); + if (event->type >= SDL_EVENT_WINDOW_FIRST && + event->type <= SDL_EVENT_WINDOW_LAST) + return event->window.windowID == id; + switch (event->type) { + case SDL_EVENT_MOUSE_MOTION: + return event->motion.windowID == id || app->window_drag_active || + app->drag_candidate || app->resize_gesture; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + return event->button.windowID == id; + case SDL_EVENT_MOUSE_BUTTON_UP: + return event->button.windowID == id || app->window_drag_active || + app->drag_candidate || app->resize_gesture; + case SDL_EVENT_MOUSE_WHEEL: + return event->wheel.windowID == id; + default: + return true; + } +} + +bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { + bongo_cat_window_display_event(app, event); + if (!event_targets_main_window(app, event)) return true; + if (event->type == SDL_EVENT_QUIT) return false; + if (event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { + bongo_cat_window_set_visible(app, false); + return true; + } + if (event->type == SDL_EVENT_WINDOW_HIDDEN) { + bongo_cat_window_drag_end(app); + } + if (event->type == SDL_EVENT_WINDOW_MINIMIZED) { + app->window_minimized = true; + bongo_cat_window_drag_end(app); + } + if (event->type == SDL_EVENT_WINDOW_RESIZED) { + /* Queued notifications may describe an earlier animation frame. */ + int width = app->session.window.width, height = app->session.window.height; + SDL_GetWindowSize(app->window, &width, &height); + if (width != app->session.window.width || height != app->session.window.height) { + app->session.window.width = width; + app->session.window.height = height; + bongo_cat_window_content_size(app, width, + height, &app->session.window.content_width, + &app->session.window.content_height); + bongo_cat_window_clamp_to_display(app); + app->dirty = true; + } + } + if (event->type == SDL_EVENT_WINDOW_EXPOSED || + event->type == SDL_EVENT_WINDOW_SHOWN || + event->type == SDL_EVENT_WINDOW_RESTORED) { + /* An expose can arrive without a restore on XWayland, but a queued + expose is not proof that the window is currently restored. */ + app->window_minimized = + (SDL_GetWindowFlags(app->window) & SDL_WINDOW_MINIMIZED) != 0; + if (event->type != SDL_EVENT_WINDOW_EXPOSED) + bongo_cat_app_reset_pointer_tracking(app); + /* DWM can discard the transparent redirection surface after an + Explorer/display refresh. Repaint even when the model is idle so + the restored alpha surface is submitted immediately. */ + app->dirty = true; + } + if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED || + event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + bongo_cat_app_reset_pointer_tracking(app); + } + if (event->type == SDL_EVENT_WINDOW_RESIZED || + event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { + int width = 0, height = 0; + if (SDL_GetWindowSizeInPixels(app->window, &width, &height) && + (width != app->resize_pixel_width || height != app->resize_pixel_height)) { + app->resize_pixel_width = width; + app->resize_pixel_height = height; + app->resize_pending = true; + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); + } + } else if (event->type == SDL_EVENT_WINDOW_MOVED) { + int x = 0, y = 0; + if (SDL_GetWindowPosition(app->window, &x, &y) && + (!app->session.window.position_known || + x != app->session.window.x || y != app->session.window.y)) { + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + app->pointer_known = false; + if (!app->window_drag_active) bongo_cat_window_clamp_to_display(app); + bongo_cat_window_mark_hit_dirty(app); + } + } else if (event->type == SDL_EVENT_WINDOW_DISPLAY_CHANGED || + event->type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED) { + bongo_cat_app_reset_pointer_tracking(app); + } else if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->button.button == SDL_BUTTON_LEFT) { + bongo_cat_window_drag_begin(app, &event->button); + } else if (event->type == SDL_EVENT_MOUSE_MOTION) { + bongo_cat_window_resize_by_pointer(app, event); + bongo_cat_window_drag_motion(app, &event->motion); + } else if (event->type == SDL_EVENT_MOUSE_WHEEL) { + bongo_cat_window_wheel(app, &event->wheel); + } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && + event->button.button == SDL_BUTTON_LEFT) { + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_drag_end(app); + } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && + event->button.button == SDL_BUTTON_RIGHT) { + bongo_cat_window_mark_hit_dirty(app); + if (app->resize_gesture) app->resize_gesture = false; + else bongo_cat_window_show_context_menu(app); + } + return true; +} + +void bongo_cat_window_destroy(BongoCatApp *app) { + bongo_cat_window_drag_end(app); + if (app->gl_context && SDL_GL_MakeCurrent(app->window, app->gl_context)) + bongo_cat_window_destroy_corner_mask(); + if (app->gl_context) SDL_GL_DestroyContext(app->gl_context); + if (app->window) SDL_DestroyWindow(app->window); + app->gl_context = NULL; + app->window = NULL; + SDL_Quit(); +} diff --git a/src/runtime/shell/window_background.c b/src/runtime/shell/window_background.c new file mode 100644 index 00000000..579d8abe --- /dev/null +++ b/src/runtime/shell/window_background.c @@ -0,0 +1,130 @@ +#include "runtime.h" +#include "bongo_cat/gl_api.h" + +#include + +static BongoCatGL corner_gl; +static GLuint corner_program, corner_vao; +static GLint corner_width, corner_height, corner_radius; +static PFNGLBLENDEQUATIONSEPARATEPROC corner_equation_separate; +static bool corner_failed; + +void bongo_cat_window_destroy_corner_mask(void) { + if (corner_vao) corner_gl.delete_vertex_arrays(1, &corner_vao); + if (corner_program) corner_gl.delete_program(corner_program); + corner_vao = corner_program = 0; + corner_failed = false; +} + +static bool prepare_corner_mask(void) { + if (corner_program) return true; + if (corner_failed) return false; + const char *vertex = + "#version 330 core\n" + "void main() {\n" + " vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n" + " gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n" + "}\n"; + const char *fragment = + "#version 330 core\n" + "uniform int width, height, radiusMilli;\n" + "out vec4 color;\n" + "void main() {\n" + " vec2 halfSize = vec2(width, height) * 0.5;\n" + " float radius = float(radiusMilli) * 0.001;\n" + " vec2 q = abs(gl_FragCoord.xy - halfSize) - (halfSize - radius);\n" + " float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;\n" + " float coverage = clamp(0.5 - d, 0.0, 1.0);\n" + " color = vec4(coverage);\n" + "}\n"; + BongoCatError error = {0}; + corner_equation_separate = (PFNGLBLENDEQUATIONSEPARATEPROC) + SDL_GL_GetProcAddress("glBlendEquationSeparate"); + if (corner_equation_separate && bongo_cat_gl_load(&corner_gl, &error)) + corner_program = bongo_cat_gl_program(&corner_gl, vertex, fragment, &error); + if (corner_program) { + corner_gl.gen_vertex_arrays(1, &corner_vao); + if (corner_vao) { + corner_width = corner_gl.uniform_location(corner_program, "width"); + corner_height = corner_gl.uniform_location(corner_program, "height"); + corner_radius = corner_gl.uniform_location(corner_program, "radiusMilli"); + return true; + } + bongo_cat_window_destroy_corner_mask(); + } + corner_failed = true; + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "Cannot initialize window corners: %s", + error.message); + return false; +} + +void bongo_cat_window_mask_corners(BongoCatApp *app, int width, int height) { + if (!app->settings.window.rounded_corners || + app->settings.window.corner_radius_percent <= 0.0f || + width <= 0 || height <= 0 || + !prepare_corner_mask()) return; + GLint program, vao, equation_rgb, equation_alpha, src_rgb, dst_rgb, + src_alpha, dst_alpha; + GLboolean color_mask[4]; + const GLenum capabilities[] = {GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, + GL_SCISSOR_TEST, GL_CULL_FACE}; + GLboolean enabled[SDL_arraysize(capabilities)]; + glGetIntegerv(GL_CURRENT_PROGRAM, &program); + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao); + glGetIntegerv(GL_BLEND_EQUATION_RGB, &equation_rgb); + glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &equation_alpha); + glGetIntegerv(GL_BLEND_SRC_RGB, &src_rgb); + glGetIntegerv(GL_BLEND_DST_RGB, &dst_rgb); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &src_alpha); + glGetIntegerv(GL_BLEND_DST_ALPHA, &dst_alpha); + glGetBooleanv(GL_COLOR_WRITEMASK, color_mask); + for (size_t i = 0; i < SDL_arraysize(capabilities); ++i) { + enabled[i] = glIsEnabled(capabilities[i]); + if (i == 0) glEnable(capabilities[i]); + else glDisable(capabilities[i]); + } + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + corner_gl.blend_equation(GL_FUNC_ADD); + /* Multiply premultiplied RGB and alpha together, including the solid + capture background, before either native presentation path reads it. */ + glBlendFunc(GL_ZERO, GL_SRC_ALPHA); + corner_gl.use_program(corner_program); + corner_gl.bind_vertex_array(corner_vao); + corner_gl.uniform_1i(corner_width, width); + corner_gl.uniform_1i(corner_height, height); + float percent = SDL_clamp(app->settings.window.corner_radius_percent, 0.0f, 50.0f); + corner_gl.uniform_1i(corner_radius, + (GLint)(SDL_min(width, height) * percent * 10.0f + 0.5f)); + glDrawArrays(GL_TRIANGLES, 0, 3); + corner_gl.bind_vertex_array((GLuint)vao); + corner_gl.use_program((GLuint)program); + corner_equation_separate(equation_rgb, equation_alpha); + corner_gl.blend_func_separate(src_rgb, dst_rgb, src_alpha, dst_alpha); + glColorMask(color_mask[0], color_mask[1], color_mask[2], color_mask[3]); + for (size_t i = 0; i < SDL_arraysize(capabilities); ++i) { + if (enabled[i]) glEnable(capabilities[i]); + else glDisable(capabilities[i]); + } +} + +void bongo_cat_window_clear_background(BongoCatApp *app) { + BongoCatWindowPreferences *window = &app->settings.window; + uint32_t rgb = bongo_cat_obs_background_color_rgb( + window->obs_background_color); + float alpha = window->obs_background ? 1.0f : 0.0f; + glClearColor(window->obs_background ? ((rgb >> 16) & 255) / 255.0f : 0.0f, + window->obs_background ? ((rgb >> 8) & 255) / 255.0f : 0.0f, + window->obs_background ? (rgb & 255) / 255.0f : 0.0f, alpha); + glClear(GL_COLOR_BUFFER_BIT); + static int last_enabled = -1; + static BongoCatObsBackgroundColor last_color = + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; + if (last_enabled != (window->obs_background ? 1 : 0) || + last_color != window->obs_background_color) { + last_enabled = window->obs_background; + last_color = window->obs_background_color; + SDL_Log("OBS background mode: enabled=%d color=%s", + window->obs_background, + bongo_cat_obs_background_color_name(window->obs_background_color)); + } +} diff --git a/src/runtime/window_display.c b/src/runtime/shell/window_display.c similarity index 94% rename from src/runtime/window_display.c rename to src/runtime/shell/window_display.c index 6a26d43e..668be24d 100644 --- a/src/runtime/window_display.c +++ b/src/runtime/shell/window_display.c @@ -170,15 +170,16 @@ static bool fit_to_display(BongoCatApp *app, SDL_DisplayID display, SDL_Point next = fitted_position(window, &bounds); if (next.x == window->x && next.y == window->y) return false; if (!SDL_SetWindowPosition(app->window, next.x, next.y)) return false; - app->config.window.x = next.x; - app->config.window.y = next.y; + app->session.window.x = next.x; + app->session.window.y = next.y; + app->session.window.position_known = true; bongo_cat_window_mark_hit_dirty(app); return true; } void bongo_cat_window_clamp_to_display(BongoCatApp *app) { SDL_Rect rect; - if (!app || !app->config.window.keep_in_screen || !window_rect(app, &rect)) return; + if (!app || !app->settings.window.keep_in_screen || !window_rect(app, &rect)) return; if (available_displays_cover(app, &rect)) return; fit_to_display(app, target_display(app, &rect), &rect); } @@ -189,7 +190,7 @@ void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y) { width <= 0 || height <= 0) return; SDL_Rect requested = {x, y, width, height}; SDL_Point next = {x, y}; - if (app->config.window.keep_in_screen && + if (app->settings.window.keep_in_screen && !available_displays_cover(app, &requested)) { SDL_Rect bounds; SDL_DisplayID display = target_display(app, &requested); if (display && (SDL_GetDisplayUsableBounds(display, &bounds) || @@ -198,8 +199,13 @@ void bongo_cat_window_drag_to(BongoCatApp *app, int x, int y) { if (SDL_GetWindowPosition(app->window, ¤t_x, ¤t_y) && current_x == next.x && current_y == next.y) return; if (!SDL_SetWindowPosition(app->window, next.x, next.y)) return; - app->config.window.x = next.x; app->config.window.y = next.y; + app->session.window.x = next.x; app->session.window.y = next.y; + app->session.window.position_known = true; bongo_cat_window_mark_hit_dirty(app); + if (app->window_snapshot) { + bongo_cat_window_snapshot_geometry(app, NULL); + app->dirty = true; + } } bool bongo_cat_window_recover_to_display(BongoCatApp *app) { @@ -216,7 +222,8 @@ void bongo_cat_window_display_event(BongoCatApp *app, const SDL_Event *event) { if (!app || !event) return; if (event->type < SDL_EVENT_DISPLAY_FIRST || event->type > SDL_EVENT_DISPLAY_LAST) return; - if (app->window_drag_active && app->config.window.keep_in_screen) + bongo_cat_window_snapshot_end(app); + if (app->window_drag_active && app->settings.window.keep_in_screen) bongo_cat_window_drag_bounds_refresh(app); bongo_cat_app_reset_pointer_tracking(app); app->display_recovery_due_ns = SDL_GetTicksNS() + DISPLAY_RECOVERY_DELAY_NS; @@ -226,7 +233,7 @@ void bongo_cat_window_update_display_recovery(BongoCatApp *app, uint64_t now) { if (!app || !app->display_recovery_due_ns || now < app->display_recovery_due_ns) return; app->display_recovery_due_ns = 0; - if (app->config.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); + if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); } diff --git a/src/runtime/window_drag.c b/src/runtime/shell/window_drag.c similarity index 70% rename from src/runtime/window_drag.c rename to src/runtime/shell/window_drag.c index f2bcc758..61beecdd 100644 --- a/src/runtime/window_drag.c +++ b/src/runtime/shell/window_drag.c @@ -5,6 +5,17 @@ static int rounded_delta(float value) { return value < 0.0f ? (int)(value - 0.5f) : (int)(value + 0.5f); } +static bool use_pointer_drag(const BongoCatApp *app) { +#ifdef _WIN32 + /* Native caption dragging pulls partially offscreen windows back down + at the top edge, even when keep-in-screen is disabled. */ + (void)app; + return true; +#else + return app->settings.window.keep_in_screen; +#endif +} + static void move_with_pointer(BongoCatApp *app, float x, float y) { int next_x = app->drag_window_x + rounded_delta(x - app->drag_start_x); int next_y = app->drag_window_y + rounded_delta(y - app->drag_start_y); @@ -33,7 +44,7 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, bongo_cat_window_drag_end(app); return; } if (app->window_drag_active) { - if (app->config.window.keep_in_screen) + if (use_pointer_drag(app)) move_with_pointer(app, pointer_x, pointer_y); return; } @@ -42,10 +53,12 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, if (x * x + y * y < 9.0f) return; app->drag_candidate = false; app->window_drag_active = true; - if (app->config.window.keep_in_screen) { - bongo_cat_window_drag_bounds_refresh(app); + bongo_cat_window_snapshot_begin(app); + if (use_pointer_drag(app)) { + if (app->settings.window.keep_in_screen) + bongo_cat_window_drag_bounds_refresh(app); if (!SDL_CaptureMouse(true)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Mouse capture is unavailable during constrained window drag: %s", + "Mouse capture is unavailable during window drag: %s", SDL_GetError()); move_with_pointer(app, pointer_x, pointer_y); } else { @@ -58,9 +71,16 @@ void bongo_cat_window_drag_motion(BongoCatApp *app, void bongo_cat_window_drag_end(BongoCatApp *app) { if (!app) return; - if (app->window_drag_active) SDL_CaptureMouse(false); + bool was_active = app->window_drag_active; + if (was_active) SDL_CaptureMouse(false); app->drag_candidate = false; bongo_cat_window_clamp_to_display(app); app->window_drag_active = false; + bongo_cat_window_snapshot_end(app); bongo_cat_window_drag_bounds_clear(app); + if (was_active && app->settings.model.mouse_centered) { + float pointer_x = 0.0f, pointer_y = 0.0f; + SDL_GetGlobalMouseState(&pointer_x, &pointer_y); + bongo_cat_app_apply_mouse_position(app, pointer_x, pointer_y, 0.0f); + } } diff --git a/src/runtime/window_geometry.c b/src/runtime/shell/window_geometry.c similarity index 54% rename from src/runtime/window_geometry.c rename to src/runtime/shell/window_geometry.c index a18732e4..307bb101 100644 --- a/src/runtime/window_geometry.c +++ b/src/runtime/shell/window_geometry.c @@ -1,5 +1,6 @@ #include "runtime.h" +#include #include #define WINDOW_MIN_DIMENSION 64 @@ -11,6 +12,51 @@ static int round_dimension(double value) { return (int)(value + 0.5); } +static BongoCatLive2DFrame model_frame(BongoCatApp *app) { + BongoCatLive2DFrame frame = {0}; + if (app && app->live2d) bongo_cat_live2d_frame(app->live2d, &frame); + if (!isfinite(frame.left) || frame.left < 0.0f) frame.left = 0.0f; + if (!isfinite(frame.top) || frame.top < 0.0f) frame.top = 0.0f; + if (!isfinite(frame.right) || frame.right < 0.0f) frame.right = 0.0f; + if (!isfinite(frame.bottom) || frame.bottom < 0.0f) frame.bottom = 0.0f; + return frame; +} + +bool bongo_cat_window_frame_size(BongoCatApp *app, + int content_width, int content_height, int *width, int *height, + int *left, int *top) { + if (content_width <= 0 || content_height <= 0 || !width || !height) + return false; + BongoCatLive2DFrame frame = model_frame(app); + double frame_width = content_width * + (1.0 + frame.left + frame.right); + double frame_height = content_height * + (1.0 + frame.top + frame.bottom); + if (!isfinite(frame_width) || !isfinite(frame_height) || + frame_width < 1.0 || frame_height < 1.0 || + frame_width > WINDOW_MAX_DIMENSION || + frame_height > WINDOW_MAX_DIMENSION) return false; + *width = round_dimension(frame_width); + *height = round_dimension(frame_height); + if (left) *left = round_dimension(content_width * frame.left); + if (top) *top = round_dimension(content_height * frame.top); + return true; +} + +bool bongo_cat_window_content_size(BongoCatApp *app, + int width, int height, int *content_width, int *content_height) { + if (width <= 0 || height <= 0 || !content_width || !content_height) + return false; + BongoCatLive2DFrame frame = model_frame(app); + double horizontal = 1.0 + frame.left + frame.right; + double vertical = 1.0 + frame.top + frame.bottom; + if (!isfinite(horizontal) || !isfinite(vertical) || + horizontal <= 0.0 || vertical <= 0.0) return false; + *content_width = SDL_max(1, round_dimension(width / horizontal)); + *content_height = SDL_max(1, round_dimension(height / vertical)); + return true; +} + bool bongo_cat_window_scaled_size(int base_width, int base_height, float base_scale, float requested_scale, float *actual_scale, int *width, int *height) { if (base_width <= 0 || base_height <= 0 || base_scale <= 0.0f || @@ -38,11 +84,15 @@ bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, height > WINDOW_MAX_DIMENSION) return false; if (!bongo_cat_platform_set_geometry(&app->platform, x, y, width, height)) return false; - app->config.window.scale_percent = scale; - app->config.window.x = x; - app->config.window.y = y; - app->config.window.width = width; - app->config.window.height = height; + app->session.window.scale_percent = scale; + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + app->session.window.width = width; + app->session.window.height = height; + bongo_cat_window_content_size(app, width, height, + &app->session.window.content_width, + &app->session.window.content_height); if (SDL_GetWindowSizeInPixels(app->window, &app->resize_pixel_width, &app->resize_pixel_height)) app->resize_pending = true; @@ -51,31 +101,18 @@ bool bongo_cat_window_apply_geometry(BongoCatApp *app, int x, int y, return true; } -bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { - if (!app || !app->window) return false; - int x, y, width, height; - if (!SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height)) return false; - float actual; - int next_width, next_height; - if (!bongo_cat_window_scaled_size(width, height, - app->config.window.scale_percent, scale, - &actual, &next_width, &next_height)) return false; - if (actual == app->config.window.scale_percent && - next_width == width && next_height == height) return false; - return bongo_cat_window_apply_geometry(app, x, y, - actual, next_width, next_height); -} - void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event) { - bool shift = (SDL_GetModState() & SDL_KMOD_SHIFT) != 0 || - bongo_cat_input_shift_down(&app->input); + bool shift = bongo_cat_input_shift_down(&app->input); +#ifndef _WIN32 + shift = shift || (SDL_GetModState() & SDL_KMOD_SHIFT) != 0; +#endif + /* Match wheel handling: SDL may retain modifiers without keyboard focus. */ if (!(event->motion.state & SDL_BUTTON_RMASK) || !shift) return; bongo_cat_window_cancel_wheel_animation(app); if (!app->resize_gesture) { if (!SDL_GetWindowSize(app->window, &app->resize_base_width, &app->resize_base_height)) return; - app->resize_scale_start = app->config.window.scale_percent; + app->resize_scale_start = app->session.window.scale_percent; app->resize_scale_target = app->resize_scale_start; app->resize_gesture = true; } @@ -90,20 +127,21 @@ void bongo_cat_window_resize_by_pointer(BongoCatApp *app, const SDL_Event *event !SDL_GetWindowPosition(app->window, &x, &y)) return; app->resize_scale_target = actual; if (!bongo_cat_window_apply_geometry(app, x, y, actual, width, height)) - app->resize_scale_target = app->config.window.scale_percent; + app->resize_scale_target = app->session.window.scale_percent; } bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { if (!app || !app->window) return false; SDL_SyncWindow(app->window); - BongoCatWindowOptions backup = app->config.window; + BongoCatWindowPreferences preferences_backup = app->settings.window; + BongoCatWindowState state_backup = app->session.window; int original_x, original_y, original_width, original_height; SDL_GetWindowPosition(app->window, &original_x, &original_y); SDL_GetWindowSize(app->window, &original_width, &original_height); SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); SDL_Rect bounds; if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return false; - app->config.window.keep_in_screen = true; + app->settings.window.keep_in_screen = true; app->model_pointer_anchor_ready = true; bongo_cat_window_apply_geometry(app, bounds.x - 2000, bounds.y - 2000, 100.0f, 320, 240); @@ -123,10 +161,16 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); bool opacity = SDL_fabsf(bongo_cat_platform_get_opacity( &app->platform) - 0.5f) < 0.02f; - app->config.window.hide_on_hover = true; - app->config.window.hide_delay_seconds = 0.0f; - app->config.window.pass_through = false; - app->config.window.opacity_percent = 100.0f; + app->settings.window.hide_on_hover = true; + app->settings.window.hide_delay_seconds = 0.0f; + app->settings.window.hide_fade_seconds = 0.0f; + app->settings.window.pass_through = true; + app->settings.window.always_on_top = true; + app->settings.window.obs_background = true; + app->settings.window.rounded_corners = false; + app->session.window.opacity_percent = 100.0f; + bongo_cat_window_sync_click_through(app); + bongo_cat_app_render_now(app); bongo_cat_app_track_hover(app, x + 10, y + 10); bongo_cat_app_update_hover(app, SDL_GetTicksNS() + 1); bool hidden = app->hover_hidden && @@ -134,6 +178,39 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_app_track_hover(app, bounds.x - 10, bounds.y - 10); bool restored = !app->hover_hidden && SDL_fabsf(bongo_cat_platform_get_opacity(&app->platform) - 1.0f) < 0.02f; + bongo_cat_app_track_hover(app, x + 10, y + 10); + app->settings.window.always_on_top = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; + app->settings.window.always_on_top = true; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + hidden = hidden && app->hover_hidden; + app->settings.window.pass_through = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; + app->settings.window.pass_through = true; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + hidden = hidden && app->hover_hidden; + app->settings.window.hide_on_hover = false; + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + restored = restored && !app->hover_hidden; + /* With a fade duration the hide no longer snaps: progress animates from + the on-screen opacity toward the target and completes there. */ + app->settings.window.hide_on_hover = true; + app->settings.window.hide_fade_seconds = 0.5f; + bongo_cat_app_track_hover(app, x + 10, y + 10); + uint64_t fade_started = SDL_GetTicksNS(); + bongo_cat_app_update_hover_fade(app, fade_started + 125000000ull); + float faded = bongo_cat_platform_get_opacity(&app->platform); + bongo_cat_app_update_hover_fade(app, fade_started + 600000000ull); + bool fade = app->hover_hidden && faded > 0.02f && faded < 0.98f && + bongo_cat_platform_get_opacity(&app->platform) < 0.02f; + bongo_cat_app_track_hover(app, bounds.x - 10, bounds.y - 10); + bongo_cat_app_update_hover_fade(app, SDL_GetTicksNS() + 700000000ull); + fade = fade && !app->hover_hidden && + SDL_fabsf(bongo_cat_platform_get_opacity(&app->platform) - 1.0f) < 0.02f; + app->settings.window.hide_fade_seconds = 0.0f; + app->settings.window.hide_on_hover = false; float safe_scale; int safe_width, safe_height; bool bounded = bongo_cat_window_scaled_size(8000, 4000, 100.0f, 500.0f, @@ -157,9 +234,10 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { SDL_GetWindowSize(app->window, &width, &height); int gesture_width = width, gesture_height = height; bool gesture = app->resize_gesture && - app->config.window.scale_percent == 120.0f && + app->session.window.scale_percent == 120.0f && width == 384 && height == 288; SDL_Event released = {.type = SDL_EVENT_MOUSE_BUTTON_UP}; + released.button.windowID = SDL_GetWindowID(app->window); released.button.button = SDL_BUTTON_RIGHT; bongo_cat_window_event(app, &released); gesture = gesture && !app->resize_gesture; @@ -174,19 +252,27 @@ bool bongo_cat_window_geometry_self_test(BongoCatApp *app) { bongo_cat_input_push(&app->input, &shift); bongo_cat_input_pop(&app->input, &discarded); app->resize_gesture = false; +#ifdef _WIN32 + SDL_SetModState(old_modifiers | SDL_KMOD_SHIFT); + float released_scale = app->session.window.scale_percent; + bongo_cat_window_resize_by_pointer(app, &motion); + gesture = gesture && !app->resize_gesture && + app->session.window.scale_percent == released_scale; +#endif SDL_SetModState(old_modifiers); bongo_cat_window_apply_geometry(app, original_x, original_y, - backup.scale_percent, original_width, original_height); - app->config.window = backup; + state_backup.scale_percent, original_width, original_height); + app->settings.window = preferences_backup; + app->session.window = state_backup; bongo_cat_platform_set_opacity(&app->platform, - backup.opacity_percent / 100.0f); + state_backup.opacity_percent / 100.0f); bongo_cat_window_sync_click_through(app); SDL_SyncWindow(app->window); bool passed = clamped && anchor_reset && scaled && opacity && hidden && restored && - bounded && gesture && display_reset; + fade && bounded && gesture && display_reset; if (!passed) fprintf(stderr, "geometry self-test: clamped=%d scaled=%d(%dx%d) " - "anchor=%d opacity=%d hidden=%d restored=%d bounded=%d gesture=%d(%dx%d) display=%d\n", + "anchor=%d opacity=%d hidden=%d restored=%d fade=%d bounded=%d gesture=%d(%dx%d) display=%d\n", clamped, scaled, scaled_width, scaled_height, anchor_reset, opacity, hidden, restored, - bounded, gesture, gesture_width, gesture_height, display_reset); + fade, bounded, gesture, gesture_width, gesture_height, display_reset); return passed; } diff --git a/src/runtime/window_hit.c b/src/runtime/shell/window_hit.c similarity index 65% rename from src/runtime/window_hit.c rename to src/runtime/shell/window_hit.c index 7592edce..841542fc 100644 --- a/src/runtime/window_hit.c +++ b/src/runtime/shell/window_hit.c @@ -4,10 +4,22 @@ #include bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { + if (app->window_snapshot) return bongo_cat_window_snapshot_hit(app, x, y); int width, height, pixel_width, pixel_height; if (!SDL_GetWindowSize(app->window, &width, &height) || !SDL_GetWindowSizeInPixels(app->window, &pixel_width, &pixel_height) || width <= 0 || height <= 0 || pixel_width <= 0 || pixel_height <= 0) return false; + if (app->settings.window.rounded_corners) { + float radius = SDL_min(pixel_width, pixel_height) * + SDL_clamp(app->settings.window.corner_radius_percent, 0.0f, 50.0f) / 100.0f; + float px = x * pixel_width / width; + float py = y * pixel_height / height; + float dx = SDL_max(SDL_fabsf(px - pixel_width * 0.5f) - + (pixel_width * 0.5f - radius), 0.0f); + float dy = SDL_max(SDL_fabsf(py - pixel_height * 0.5f) - + (pixel_height * 0.5f - radius), 0.0f); + if (dx * dx + dy * dy > radius * radius) return false; + } int pixel_x = SDL_clamp((int)(x * pixel_width / width), 0, pixel_width - 1); int pixel_y = pixel_height - 1 - SDL_clamp((int)(y * pixel_height / height), 0, pixel_height - 1); @@ -33,8 +45,11 @@ bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { } void bongo_cat_window_capture_pointer_hit(BongoCatApp *app) { + /* Backends without dynamic hit testing would discard the readback, so the + alpha sample would only stall the pipeline for every presented frame. */ if (!app || !app->window || !app->pointer_known || - app->config.window.pass_through || app->hover_hidden || + !bongo_cat_platform_dynamic_hit_supported() || + app->settings.window.pass_through || app->hover_hidden || app->left_mouse_down || app->right_mouse_down) return; float local_x, local_y; bool inside = bongo_cat_platform_pointer_local(&app->platform, @@ -53,8 +68,19 @@ void bongo_cat_window_mark_hit_dirty(BongoCatApp *app) { void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { if (!app || !app->window) return; - app->config.window.visible = visible; + app->session.window.visible = visible; + if (!visible) bongo_cat_window_snapshot_discard(app); if (!visible) { + app->startup_visibility_pending = false; +#if defined(__linux__) + /* Remember XWayland placement before unmapping the surface. */ + int x = 0, y = 0; + if (SDL_GetWindowPosition(app->window, &x, &y)) { + app->session.window.x = x; + app->session.window.y = y; + app->session.window.position_known = true; + } +#endif bongo_cat_platform_set_visible(&app->platform, false); return; } @@ -62,18 +88,29 @@ void bongo_cat_window_set_visible(BongoCatApp *app, bool visible) { SDL_RestoreWindow(app->window); app->window_minimized = false; app->hover_hidden = false; + bongo_cat_app_cancel_hover_fade(app); bongo_cat_app_reset_pointer_tracking(app); bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - if (app->config.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); + app->session.window.opacity_percent / 100.0f); +#if defined(__linux__) + /* Restore before clamping so stale off-screen coordinates cannot undo it. */ + if (app->session.window.position_known) + SDL_SetWindowPosition(app->window, app->session.window.x, + app->session.window.y); +#endif + if (app->settings.window.keep_in_screen) bongo_cat_window_clamp_to_display(app); else bongo_cat_window_recover_to_display(app); - bongo_cat_platform_set_visible(&app->platform, true); + /* Keep the native surface hidden until the first complete frame has been + submitted. The render loop will reveal it next to that presentation. */ + bongo_cat_platform_set_visible(&app->platform, + !app->startup_visibility_pending); bongo_cat_window_mark_hit_dirty(app); app->dirty = true; } void bongo_cat_window_raise_when_due(BongoCatApp *app, uint64_t now) { - if (!app || !app->startup_raise_due_ns || now < app->startup_raise_due_ns) return; + if (!app || !app->startup_raise_due_ns || + app->startup_visibility_pending || now < app->startup_raise_due_ns) return; app->startup_raise_due_ns = 0; bongo_cat_window_set_visible(app, true); bongo_cat_platform_raise_window(app->window); @@ -99,13 +136,14 @@ void bongo_cat_window_schedule_hit_check(BongoCatApp *app) { void bongo_cat_window_sync_click_through(BongoCatApp *app) { if (!app || !app->window) return; - bool forced = app->config.window.pass_through || app->hover_hidden; + bool forced = app->settings.window.pass_through || app->hover_hidden; + if (forced && app->window_snapshot) bongo_cat_window_snapshot_end(app); if (!forced && !bongo_cat_platform_dynamic_hit_supported()) { app->pointer_transparent = false; app->pointer_hit_dirty = false; } if (!forced && (app->left_mouse_down || app->right_mouse_down)) return; - if (!forced && app->config.window.visible && app->pointer_known && + if (!forced && app->session.window.visible && app->pointer_known && app->pointer_hit_dirty && (!app->pointer_hit_deadline_ns || SDL_GetTicksNS() >= app->pointer_hit_deadline_ns)) { float local_x, local_y; @@ -129,14 +167,22 @@ void bongo_cat_window_sync_click_through(BongoCatApp *app) { } void bongo_cat_window_apply_pending_resize(BongoCatApp *app) { - if (!app || !app->resize_pending) return; - app->model_pointer_anchor_ready = false; + if (!app) return; + if (app->window_snapshot) return; if (app->wheel_animation_active) { + if (!app->resize_pending) return; + app->resize_pending = false; + app->resize_render_target_pending = true; + /* Keep the normalized gaze anchor stable throughout the gesture. */ bongo_cat_live2d_reshape(app->live2d, app->resize_pixel_width, app->resize_pixel_height); return; } + if (!app->resize_pending && !app->resize_render_target_pending) return; app->resize_pending = false; + if (!app->resize_render_target_pending) + app->model_pointer_anchor_ready = false; + app->resize_render_target_pending = false; bongo_cat_live2d_resize(app->live2d, app->resize_pixel_width, app->resize_pixel_height); bongo_cat_window_mark_hit_dirty(app); diff --git a/src/runtime/window_menu.h b/src/runtime/shell/window_menu.h similarity index 58% rename from src/runtime/window_menu.h rename to src/runtime/shell/window_menu.h index 49168713..8ff20822 100644 --- a/src/runtime/window_menu.h +++ b/src/runtime/shell/window_menu.h @@ -5,10 +5,9 @@ typedef struct BongoCatWindowMenuPreview { BongoCatApp *app; - char model[BONGO_CAT_ID_CAP]; float scale, opacity; int expression; - BongoCatMenuAction last; + BongoCatMenuAction last, applied; BongoCatModalFrame modal_frame; } BongoCatWindowMenuPreview; @@ -17,13 +16,22 @@ void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action); void bongo_cat_window_menu_preview_tick(void *userdata); void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected); +bool bongo_cat_window_menu_preview_applied( + const BongoCatWindowMenuPreview *state, BongoCatMenuAction selected); +void bongo_cat_window_audio_labels(BongoCatApp *app, + char names[][BONGO_CAT_MENU_LABEL_CAP], bool *checked, size_t *count); void bongo_cat_window_behavior_labels(BongoCatApp *app, - const char **motions, size_t *motion_count, - const char **expressions, size_t *expression_count, + char motions[][BONGO_CAT_MENU_LABEL_CAP], bool *motion_checked, + size_t *motion_count, char expressions[][BONGO_CAT_MENU_LABEL_CAP], + size_t *expression_count, size_t *current_expression); bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action); bool bongo_cat_window_behavior_action(BongoCatApp *app, BongoCatMenuAction action); +bool bongo_cat_window_behavior_preview(BongoCatApp *app, + BongoCatMenuAction action); +bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, + BongoCatMenuAction action); bool bongo_cat_window_behavior_self_test(BongoCatApp *app); #endif diff --git a/src/runtime/shell/window_menu_actions.c b/src/runtime/shell/window_menu_actions.c new file mode 100644 index 00000000..a5a42504 --- /dev/null +++ b/src/runtime/shell/window_menu_actions.c @@ -0,0 +1,194 @@ +#include "runtime.h" +#include "window_menu.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" +#include "preferences_notice.h" + +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static bool select_model(BongoCatApp *app, const char *id) { + BongoCatError error = {0}; + if (bongo_cat_app_select_model_with_error(app, id, &error)) return true; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Model switch failed: %s", + error.message[0] ? error.message : "unknown error"); + const char *message = tr(app, "native.modelLoadFailed", + "Unable to display this model"); + if (app->preferences && bongo_cat_preferences_visible(app->preferences)) + bongo_cat_preferences_notice_show(app, message, true); + else SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, + message, app->window); + return false; +} + +void bongo_cat_window_show_context_menu(BongoCatApp *app) { + if (!app) return; + bool dark_theme = app->settings.app.theme == BONGO_CAT_THEME_DARK || + (app->settings.app.theme == BONGO_CAT_THEME_AUTO && + SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + const char *model_names[BONGO_CAT_MODEL_CAP]; + const char *model_cover_directories[BONGO_CAT_MODEL_CAP]; + size_t current_model = app->models.count; + for (size_t i = 0; i < app->models.count; ++i) { + model_cover_directories[i] = app->models.entries[i].adapter_directory; + model_names[i] = bongo_cat_model_name(&app->settings, + &app->models.entries[i]); + if (!strcmp(app->models.entries[i].id, app->session.active_model_id)) + current_model = i; + } + char motion_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + char expression_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + size_t motion_count, expression_count, current_expression; + bongo_cat_window_behavior_labels(app, motion_names, motion_checked, + &motion_count, expression_names, &expression_count, + ¤t_expression); + BongoCatMenuLabels labels = { + tr(app, "composables.useAppMenu.labels.preference", "Preferences"), + tr(app, "composables.useAppMenu.labels.hideCat", "Hide Cat"), + tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), + tr(app, "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), + tr(app, "composables.useAppMenu.labels.windowSize", "Window Size"), + tr(app, "composables.useAppMenu.labels.opacity", "Opacity"), + tr(app, "composables.useAppMenu.labels.model", "Model"), + tr(app, "composables.useAppMenu.labels.addModel", "Add Model"), + tr(app, "composables.useAppMenu.labels.quitApp", "Exit"), + tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), + tr(app, "composables.useAppMenu.labels.wheelOpacityHint", + "Ctrl+Wheel: opacity"), + tr(app, "composables.useAppMenu.labels.motion", "Motions"), + tr(app, "composables.useAppMenu.labels.expression", "Expressions"), + model_names, motion_names, expression_names, motion_checked, + app->models.count, current_model, motion_count, expression_count, + current_expression, app->session.window.scale_percent, + app->session.window.opacity_percent, app->settings.window.pass_through, + app->settings.window.always_on_top, dark_theme, + bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, + bongo_cat_window_menu_restore, &preview, + tr(app, "native.removeDesktopPet", "Close this desktop pet"), + app->secondary_pet || (app->settings.model.multiple_pets && + app->session.additional_model_count > 0), NULL, NULL, NULL, 0, + model_cover_directories}; + char audio_names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool audio_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + labels.audio = tr(app, "pages.preference.model.behaviorModal.labels.audio", "Audio"); + labels.audio_names = audio_names; + labels.audio_checked = audio_checked; + bongo_cat_window_audio_labels(app, audio_names, audio_checked, &labels.audio_count); + BongoCatMenuAction action = bongo_cat_platform_context_menu( + &app->platform, &labels); + if (bongo_cat_window_menu_preview_applied(&preview, action)) + bongo_cat_preferences_invalidate(app->preferences); + else bongo_cat_window_menu_action(app, action); +} + +void bongo_cat_window_menu_action(BongoCatApp *app, + BongoCatMenuAction action) { + if (action < BONGO_CAT_MENU_SCALE_50 || action > BONGO_CAT_MENU_SCALE_200) + bongo_cat_window_snapshot_end(app); + if (action == BONGO_CAT_MENU_PREFERENCES) { + if (app->secondary_pet) + bongo_cat_multi_pet_request_preferences(app); + else if (app->preferences) + bongo_cat_preferences_show(app->preferences); + } else if (action == BONGO_CAT_MENU_MODEL_ADD) { + if (app->preferences) + bongo_cat_preferences_open_model_import(app->preferences, + app->window); + } else if (action == BONGO_CAT_MENU_HIDE) + bongo_cat_window_set_visible(app, false); + else if (action == BONGO_CAT_MENU_PASS_THROUGH) { + bool enabled = !app->settings.window.pass_through; + if (!app->secondary_pet || + bongo_cat_multi_pet_request_pass_through(app, enabled)) { + app->settings.window.pass_through = enabled; + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } + } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { + app->settings.window.always_on_top = !app->settings.window.always_on_top; + bongo_cat_platform_set_always_on_top(&app->platform, + app->settings.window.always_on_top); + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } else if (action >= BONGO_CAT_MENU_SCALE_50 && + action <= BONGO_CAT_MENU_SCALE_200) { + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_set_scale(app, + (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); + } else if (action >= BONGO_CAT_MENU_OPACITY_10 && + action <= BONGO_CAT_MENU_OPACITY_100) { + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.opacity_percent = + (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + } + } else if (bongo_cat_window_behavior_action(app, action)) { + bongo_cat_app_render_now(app); + } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && + action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { + size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); + if (!app->secondary_pet && index < app->models.count) + select_model(app, app->models.entries[index].id); + } else if (action == BONGO_CAT_MENU_REMOVE_PET) { + if (app->secondary_pet) { + if (bongo_cat_multi_pet_request_remove(app)) { + bongo_cat_platform_set_visible(&app->platform, false); + app->running = false; + } + } else if (app->settings.model.multiple_pets && + app->session.additional_model_count) { + char model_id[BONGO_CAT_ID_CAP]; + BongoCatError error = {0}; + snprintf(model_id, sizeof(model_id), "%s", + app->session.active_model_id); + bool visible = app->session.window.visible; + if (visible) bongo_cat_platform_set_visible(&app->platform, false); + if (!bongo_cat_app_set_model_active(app, model_id, false, &error)) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "[runtime] Multi-pet primary remove failed: model=%s " + "error=%s", model_id, + error.message[0] ? error.message : "unknown error"); + if (visible) bongo_cat_platform_set_visible(&app->platform, true); + } + } else if (action == BONGO_CAT_MENU_EXIT) { + if (app->secondary_pet) { + if (bongo_cat_multi_pet_request_exit(app)) { + bongo_cat_platform_set_visible(&app->platform, false); + app->running = false; + } + } else app->running = false; + } + bongo_cat_preferences_invalidate(app->preferences); +} + +bool bongo_cat_window_menu_self_test(BongoCatApp *app) { + if (!app || !app->preferences) return false; + app->settings.window.pass_through = false; + app->settings.window.always_on_top = false; + app->session.window.scale_percent = 100.0f; + app->session.window.opacity_percent = 100.0f; + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PASS_THROUGH); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_ALWAYS_ON_TOP); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_SCALE_120); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); + bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PREFERENCES); + bool behavior = bongo_cat_window_behavior_self_test(app); + bool result = app->settings.window.pass_through && + app->settings.window.always_on_top && + app->session.window.scale_percent == 120.0f && + app->session.window.opacity_percent == 50.0f && + bongo_cat_preferences_visible(app->preferences) && behavior; + bongo_cat_preferences_close(app->preferences); + return result; +} diff --git a/src/runtime/shell/window_menu_behavior.c b/src/runtime/shell/window_menu_behavior.c new file mode 100644 index 00000000..d6ca72cb --- /dev/null +++ b/src/runtime/shell/window_menu_behavior.c @@ -0,0 +1,219 @@ +#include "window_menu.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/shortcut.h" + +#include +#include + +static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, + BongoCatBehaviorKind kind, size_t position) { + if (!app) return NULL; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != kind) continue; + if (kind == BONGO_CAT_BEHAVIOR_SOUND && !entry->sound[0] && !entry->sound_clear) continue; + if (kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (!position) return entry; + position--; + } + return NULL; +} + +static const char *behavior_shortcut(const BongoCatApp *app, const char *id) { + if (!app || !id) return NULL; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; + if (strcmp(binding->id, id) == 0) return binding->shortcut; + } + return NULL; +} + +static bool hidden_toggle_has_visible_binding(BongoCatApp *app, + const BongoCatBehaviorEntry *entry, const char *shortcut) { + if (!app || !entry || !shortcut || !shortcut[0] || + entry->kind != BONGO_CAT_BEHAVIOR_MOTION || + bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) return false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *candidate = &app->behaviors.entries[i]; + const char *bound = behavior_shortcut(app, candidate->id); + if (candidate != entry && + candidate->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(app->live2d, + candidate->group, candidate->index) && + bongo_cat_live2d_motion_same_toggle(app->live2d, + entry->group, entry->index, + candidate->group, candidate->index) && + bound && strcmp(bound, shortcut) == 0) return true; + } + return false; +} + +static const char *behavior_label(const BongoCatApp *app, + const BongoCatBehaviorEntry *entry) { + if (!app || !entry) return ""; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = &app->settings.behavior_shortcuts[i]; + if (strcmp(binding->id, entry->id) == 0 && binding->label[0]) + return binding->label; + } + if (entry->sound_clear) return bongo_cat_i18n_get(app->i18n, + "pages.preference.model.behaviorModal.labels.stopAllAudio", "Stop all audio"); + return entry->label; +} + +static void menu_label(char output[BONGO_CAT_MENU_LABEL_CAP], + const BongoCatApp *app, const BongoCatBehaviorEntry *entry) { + const char *label = behavior_label(app, entry); + const char *shortcut = behavior_shortcut(app, entry->id); + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(shortcut, shortcut_label, sizeof(shortcut_label)); + if (shortcut && shortcut[0]) snprintf(output, BONGO_CAT_MENU_LABEL_CAP, + "%s - %s", label, shortcut_label); + else snprintf(output, BONGO_CAT_MENU_LABEL_CAP, "%s", label); +} + +static bool run_binding(BongoCatApp *app, const BongoCatBehaviorEntry *selected) { + const char *shortcut = behavior_shortcut(app, selected->id); + bool handled = bongo_cat_app_run_behavior(app, selected); + if (!shortcut || !shortcut[0]) return handled; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + const char *bound = behavior_shortcut(app, entry->id); + if (entry != selected && bound && strcmp(bound, shortcut) == 0 && + !hidden_toggle_has_visible_binding(app, entry, shortcut)) + handled = bongo_cat_app_run_behavior(app, entry) || handled; + } + return handled; +} + +void bongo_cat_window_behavior_labels(BongoCatApp *app, + char motions[][BONGO_CAT_MENU_LABEL_CAP], bool *motion_checked, + size_t *motion_count, char expressions[][BONGO_CAT_MENU_LABEL_CAP], + size_t *expression_count, + size_t *current_expression) { + if (!motion_count || !expression_count) return; + *motion_count = 0; *expression_count = 0; + if (current_expression) *current_expression = BONGO_CAT_BEHAVIOR_CAP; + if (!app) return; + int active_expression = bongo_cat_live2d_expression(app->live2d); + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && motions) { + if (!bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (motion_checked) motion_checked[*motion_count] = + bongo_cat_live2d_motion_selected(app->live2d, + entry->group, entry->index); + menu_label(motions[*motion_count], app, entry); + (*motion_count)++; + } else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && expressions) { + if (current_expression && entry->index == active_expression) + *current_expression = *expression_count; + menu_label(expressions[*expression_count], app, entry); + (*expression_count)++; + } + } +} + +bool bongo_cat_window_behavior_action(BongoCatApp *app, + BongoCatMenuAction action) { + BongoCatBehaviorKind kind; + size_t position; + if (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + kind = BONGO_CAT_BEHAVIOR_MOTION; + position = (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST); + } else if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + kind = BONGO_CAT_BEHAVIOR_EXPRESSION; + position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); + } else if (action >= BONGO_CAT_MENU_AUDIO_FIRST && + action < BONGO_CAT_MENU_AUDIO_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + const BongoCatBehaviorEntry *sound = nth_behavior(app, BONGO_CAT_BEHAVIOR_SOUND, + (size_t)(action - BONGO_CAT_MENU_AUDIO_FIRST)); + if (!sound) return false; + if (bongo_cat_audio_is_playing(app->audio, sound->sound)) + bongo_cat_audio_stop_path(app->audio, sound->sound); + else return bongo_cat_app_run_behavior(app, sound); + return true; + } else return false; + const BongoCatBehaviorEntry *entry = nth_behavior(app, kind, position); + return entry && run_binding(app, entry); +} + +bool bongo_cat_window_behavior_preview(BongoCatApp *app, + BongoCatMenuAction action) { + if (!app) return false; + if (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + size_t position = (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST); + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, position); + return entry && bongo_cat_live2d_preview_motion(app->live2d, + entry->group, entry->index); + } + if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { + size_t position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, position); + if (!entry) return false; + int target = bongo_cat_live2d_expression(app->live2d) == entry->index ? + -1 : entry->index; + return bongo_cat_live2d_set_expression(app->live2d, target); + } + return false; +} + +bool bongo_cat_window_behavior_commit_preview(BongoCatApp *app, + BongoCatMenuAction action) { + if (!app || action < BONGO_CAT_MENU_MOTION_FIRST || + action >= BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) + return false; + const BongoCatBehaviorEntry *entry = nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, + (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST)); + if (!entry || !bongo_cat_live2d_commit_motion_preview(app->live2d, + entry->group, entry->index)) return false; + if (entry->sound[0]) { + BongoCatError error = {0}; + bongo_cat_audio_play(app->audio, entry->sound, &error); + } + const char *shortcut = behavior_shortcut(app, entry->id); + if (shortcut && shortcut[0]) for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *companion = &app->behaviors.entries[i]; + const char *bound = behavior_shortcut(app, companion->id); + if (companion != entry && bound && strcmp(bound, shortcut) == 0 && + !hidden_toggle_has_visible_binding(app, companion, shortcut)) + bongo_cat_app_run_behavior(app, companion); + } + bongo_cat_app_capture_behavior_state(app); + app->dirty = true; + return true; +} + +bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action) { + return (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) || + (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); +} + +void bongo_cat_window_audio_labels(BongoCatApp *app, + char names[][BONGO_CAT_MENU_LABEL_CAP], bool *checked, size_t *count) { + *count = 0; + bool available = false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_SOUND || (!entry->sound[0] && !entry->sound_clear)) continue; + available = available || entry->sound[0]; + menu_label(names[*count], app, entry); + checked[*count] = bongo_cat_audio_is_playing(app->audio, entry->sound); + ++*count; + } + if (!available) *count = 0; +} diff --git a/src/runtime/shell/window_menu_behavior_test.c b/src/runtime/shell/window_menu_behavior_test.c new file mode 100644 index 00000000..8ddd34ed --- /dev/null +++ b/src/runtime/shell/window_menu_behavior_test.c @@ -0,0 +1,250 @@ +#include "window_menu.h" +#include "bongo_cat/shortcut.h" + +#include +#include + +static const BongoCatBehaviorEntry *test_nth_behavior(BongoCatApp *app, + BongoCatBehaviorKind kind, size_t position) { + if (!app) return NULL; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != kind) continue; + if (kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (!position) return entry; + position--; + } + return NULL; +} + +static const char *test_behavior_shortcut(const BongoCatApp *app, + const char *id) { + if (!app || !id) return NULL; + for (size_t i = 0; i < app->settings.behavior_shortcut_count; ++i) { + const BongoCatBehaviorShortcut *binding = + &app->settings.behavior_shortcuts[i]; + if (strcmp(binding->id, id) == 0) return binding->shortcut; + } + return NULL; +} + +static bool expression_self_test(BongoCatApp *app) { + size_t count = 0; + while (test_nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, count)) count++; + if (!count) return true; + int original = bongo_cat_live2d_expression(app->live2d); + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool motion_checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bool passed = bongo_cat_live2d_set_expression(app->live2d, -1); + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = passed && expression_count == count && + current_expression == BONGO_CAT_BEHAVIOR_CAP; + size_t selected_position = count > 2 ? 2 : count - 1; + const BongoCatBehaviorEntry *selected = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, selected_position); + passed = selected && bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position) && + bongo_cat_live2d_expression(app->live2d) == selected->index && passed; + bongo_cat_window_behavior_labels(app, motions, motion_checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = passed && expression_count == count && + current_expression == selected_position; + if (passed && count > 1) { + size_t alternate_position = selected_position ? 0 : 1; + const BongoCatBehaviorEntry *alternate = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_EXPRESSION, alternate_position); + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + passed = alternate && bongo_cat_live2d_expression(app->live2d) == + alternate->index; + bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); + passed = passed && bongo_cat_live2d_expression(app->live2d) == + selected->index; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + bongo_cat_window_menu_restore(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + passed = passed && bongo_cat_live2d_expression(app->live2d) == + alternate->index && bongo_cat_window_menu_preview_applied(&preview, + BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); + } + if (passed) { + bongo_cat_live2d_set_expression(app->live2d, selected->index); + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + BongoCatMenuAction selected_action = + BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position; + bongo_cat_window_menu_preview(&preview, selected_action); + passed = bongo_cat_live2d_expression(app->live2d) == -1; + bongo_cat_window_menu_restore(&preview, selected_action); + passed = passed && bongo_cat_live2d_expression(app->live2d) == -1 && + bongo_cat_window_menu_preview_applied(&preview, selected_action); + } + bool restored = bongo_cat_live2d_set_expression(app->live2d, original); + if (!passed || !restored) SDL_Log("Expression menu self-test failed: " + "count=%llu mapped=%llu current=%llu original=%d restored=%d", + (unsigned long long)count, (unsigned long long)expression_count, + (unsigned long long)current_expression, original, restored); + return restored && passed; +} + +static void advance_motion(BongoCatApp *app, unsigned steps) { + for (unsigned i = 0; i < steps; ++i) + bongo_cat_app_step_live2d(app, 0.25f); +} + +static bool motion_lifecycle_self_test(BongoCatApp *app) { + const BongoCatBehaviorEntry *persistent = NULL, *one_shot = NULL; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; + if (entry->kind != BONGO_CAT_BEHAVIOR_MOTION || + !bongo_cat_live2d_motion_visible(app->live2d, + entry->group, entry->index)) continue; + if (bongo_cat_live2d_motion_persistent(app->live2d, + entry->group, entry->index)) { + if (!persistent) persistent = entry; + } else if (!one_shot) one_shot = entry; + } + bool persistent_kept = true, checked = true, cleared = true; + bool isolated = true, started = true; + if (persistent) { + if (bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index)) { + started = bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index) && started; + advance_motion(app, 8); + } + started = bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index) && started; + advance_motion(app, 240); + persistent_kept = bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index); + } + unsigned steps = 0; + if (one_shot) { + started = bongo_cat_live2d_start_motion(app->live2d, + one_shot->group, one_shot->index) && started; + checked = bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index); + while (steps++ < 1200 && bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index)) advance_motion(app, 1); + cleared = !bongo_cat_live2d_motion_selected(app->live2d, + one_shot->group, one_shot->index); + isolated = !persistent || bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index); + } + if (persistent && bongo_cat_live2d_motion_selected(app->live2d, + persistent->group, persistent->index)) + bongo_cat_live2d_start_motion(app->live2d, + persistent->group, persistent->index); + bool passed = started && persistent_kept && checked && cleared && isolated; + SDL_Log("Motion lifecycle self-test: persistent=%d one_shot=%d " + "checked=%d cleared=%d isolated=%d steps=%u result=%d", + persistent != NULL, one_shot != NULL, checked, cleared, isolated, + steps, passed); + return passed; +} + +bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { + if (!app) return false; + bool passed = true; + if (test_nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) { + const BongoCatBehaviorEntry *first = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 0); + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + char motions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + bool before[BONGO_CAT_BEHAVIOR_CAP] = {false}; + char expressions[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + size_t motion_count, expression_count, current_expression; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = motion_count > 0 && checked[0] && passed; + bool initial_passed = passed; + const char *first_shortcut = test_behavior_shortcut(app, first->id); + if (first_shortcut && first_shortcut[0]) { + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(first_shortcut, shortcut_label, sizeof(shortcut_label)); + passed = strstr(motions[0], shortcut_label) != NULL && passed; + } + memcpy(before, checked, motion_count * sizeof(before[0])); + size_t preview_position = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, 1) ? 1 : 0; + BongoCatWindowMenuPreview preview; + bongo_cat_window_menu_preview_init(&preview, app); + bongo_cat_window_menu_preview(&preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + bool preview_stable = memcmp(before, checked, + motion_count * sizeof(before[0])) == 0; + passed = preview_stable && passed; + bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); + passed = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + preview_position) && passed; + bongo_cat_window_behavior_labels(app, motions, checked, &motion_count, + expressions, &expression_count, ¤t_expression); + passed = checked[preview_position] && passed; + const BongoCatBehaviorEntry *preview_entry = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, preview_position); + BongoCatWindowMenuPreview cancel_preview; + bongo_cat_window_menu_preview_init(&cancel_preview, app); + bongo_cat_window_menu_preview(&cancel_preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + bongo_cat_window_menu_restore(&cancel_preview, + BONGO_CAT_MENU_MOTION_FIRST + preview_position); + passed = preview_entry && bongo_cat_window_menu_preview_applied( + &cancel_preview, BONGO_CAT_MENU_MOTION_FIRST + preview_position) && + !bongo_cat_live2d_motion_selected(app->live2d, + preview_entry->group, preview_entry->index) && passed; + bool independent = false; + for (size_t i = 1; i < motion_count && !independent; ++i) { + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST + i) && passed; + bool first_selected = bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + const BongoCatBehaviorEntry *candidate = test_nth_behavior(app, + BONGO_CAT_BEHAVIOR_MOTION, i); + bool candidate_selected = bongo_cat_live2d_motion_selected(app->live2d, + candidate->group, candidate->index); + independent = independent || (first_selected && candidate_selected); + } + if (motion_count > 2 && strcmp(first->group, "CAT_motion_lock") == 0) + passed = independent && passed; + passed = bongo_cat_window_behavior_action(app, + BONGO_CAT_MENU_MOTION_FIRST) && passed; + bool paired = false; + for (size_t i = 0; i < app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *item = &app->behaviors.entries[i]; + if (item->kind == BONGO_CAT_BEHAVIOR_MOTION && + !bongo_cat_live2d_motion_visible(app->live2d, + item->group, item->index) && + bongo_cat_live2d_motion_same_toggle(app->live2d, + first->group, first->index, + item->group, item->index)) paired = true; + } + bool toggled_off = !bongo_cat_live2d_motion_selected(app->live2d, + first->group, first->index); + passed = (!paired || toggled_off) && passed; + if (!passed) SDL_Log("Motion menu self-test failed: initial=%d " + "preview=%d count=%llu independent=%d off=%d", + initial_passed, preview_stable, (unsigned long long)motion_count, + independent, toggled_off); + } + passed = motion_lifecycle_self_test(app) && passed; + passed = expression_self_test(app) && passed; + return passed; +} diff --git a/src/runtime/shell/window_menu_preview.c b/src/runtime/shell/window_menu_preview.c new file mode 100644 index 00000000..262db90d --- /dev/null +++ b/src/runtime/shell/window_menu_preview.c @@ -0,0 +1,157 @@ +#include "window_menu.h" + +static const BongoCatModelEntry *menu_model(const BongoCatApp *app, + BongoCatMenuAction action) { + if (!app || action < BONGO_CAT_MENU_MODEL_FIRST || + action >= BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) return NULL; + size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); + return index < app->models.count ? &app->models.entries[index] : NULL; +} + +static bool previewable(BongoCatMenuAction action) { + return (action >= BONGO_CAT_MENU_SCALE_50 && + action <= BONGO_CAT_MENU_OPACITY_100) || + bongo_cat_window_behavior_menu_action(action); +} + +static int preview_group(BongoCatMenuAction action) { + if (action >= BONGO_CAT_MENU_SCALE_50 && + action <= BONGO_CAT_MENU_SCALE_200) return 1; + if (action >= BONGO_CAT_MENU_OPACITY_10 && + action <= BONGO_CAT_MENU_OPACITY_100) return 2; + if (action >= BONGO_CAT_MENU_MOTION_FIRST && + action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) return 4; + if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && + action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) return 5; + return 0; +} + +void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, + BongoCatApp *app) { + if (!state) return; + *state = (BongoCatWindowMenuPreview){.app = app, + .scale = app ? app->session.window.scale_percent : 100.0f, + .opacity = app ? app->session.window.opacity_percent : 100.0f, + .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, + .last = BONGO_CAT_MENU_NONE, .applied = BONGO_CAT_MENU_NONE}; + bongo_cat_modal_frame_init(&state->modal_frame, app); +} + +void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { + BongoCatWindowMenuPreview *state = userdata; + if (!state || !state->app || action == state->last) return; + BongoCatApp *app = state->app; + int previous_group = preview_group(state->last); + int next_group = preview_group(action); + if (state->last != BONGO_CAT_MENU_NONE && previous_group != next_group) + bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); + if (!previewable(action)) { + bongo_cat_window_snapshot_end(app); + if (previous_group == 0) + bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); + state->last = action; + return; + } + state->last = action; + if (action >= BONGO_CAT_MENU_SCALE_50 && + action <= BONGO_CAT_MENU_SCALE_200) { + bongo_cat_window_set_scale(app, + (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); + /* The shared scale path already presents the resized frame. */ + return; + } else if (action >= BONGO_CAT_MENU_OPACITY_10 && + action <= BONGO_CAT_MENU_OPACITY_100) { + bongo_cat_window_snapshot_end(app); + app->session.window.opacity_percent = + (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + app->session.window.opacity_percent / 100.0f); + } + } else if (action == state->applied && + bongo_cat_window_behavior_menu_action(action)) { + bongo_cat_modal_frame_tick(&state->modal_frame); + return; + } else { + bongo_cat_window_snapshot_end(app); + if (next_group == 5 && + bongo_cat_live2d_expression(app->live2d) != state->expression) + bongo_cat_live2d_set_expression(app->live2d, state->expression); + if (!bongo_cat_window_behavior_preview(app, action)) { + bongo_cat_app_render_now(app); + return; + } + state->applied = action; + bongo_cat_modal_frame_tick(&state->modal_frame); + return; + } + bongo_cat_app_render_now(app); +} + +void bongo_cat_window_menu_preview_tick(void *userdata) { + BongoCatWindowMenuPreview *state = userdata; + if (!state) return; + bongo_cat_modal_frame_tick(&state->modal_frame); +} + +void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) { + BongoCatWindowMenuPreview *state = userdata; + if (!state || !state->app) return; + BongoCatApp *app = state->app; + bool changed = false; + bool keep_motion = selected >= BONGO_CAT_MENU_MOTION_FIRST && + selected < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP; + bool committed_motion = keep_motion && + bongo_cat_window_behavior_commit_preview(app, selected); + if (!committed_motion && bongo_cat_live2d_restore_motion_preview(app->live2d)) { + state->applied = BONGO_CAT_MENU_NONE; + changed = true; + } + if (committed_motion) { state->applied = selected; changed = true; } + if (selected == BONGO_CAT_MENU_NONE) + state->applied = BONGO_CAT_MENU_NONE; + bool keep_model = menu_model(app, selected) != NULL; + bool keep_scale = selected >= BONGO_CAT_MENU_SCALE_50 && + selected <= BONGO_CAT_MENU_SCALE_200; + bool keep_opacity = selected >= BONGO_CAT_MENU_OPACITY_10 && + selected <= BONGO_CAT_MENU_OPACITY_100; + /* Expressions belong to the model active when the menu opened. */ + bool keep_expression = keep_model || + (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && + selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); + bool committed_expression = selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && + selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP; + if (!keep_scale && + SDL_fabsf(app->session.window.scale_percent - state->scale) > .01f) { + bongo_cat_window_set_scale(app, state->scale); + changed = true; + } + if (!keep_opacity && + SDL_fabsf(app->session.window.opacity_percent - state->opacity) > .01f) { + bongo_cat_window_snapshot_end(app); + app->session.window.opacity_percent = state->opacity; + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + state->opacity / 100.0f); + } + changed = true; + } + if (!keep_expression && + bongo_cat_live2d_expression(app->live2d) != state->expression && + bongo_cat_live2d_set_expression(app->live2d, state->expression)) { + bongo_cat_app_step_live2d(app, 1.0f / 60.0f); + changed = true; + } + if (committed_expression) + bongo_cat_app_capture_behavior_state(app); + bongo_cat_window_snapshot_end(app); + if (changed) bongo_cat_app_render_now(app); +} + +bool bongo_cat_window_menu_preview_applied( + const BongoCatWindowMenuPreview *state, BongoCatMenuAction selected) { + return state && bongo_cat_window_behavior_menu_action(selected) && + state->applied == selected; +} diff --git a/src/runtime/shell/window_scale.c b/src/runtime/shell/window_scale.c new file mode 100644 index 00000000..fae5ad66 --- /dev/null +++ b/src/runtime/shell/window_scale.c @@ -0,0 +1,77 @@ +#include "runtime.h" + +static int round_position(double value) { + return (int)(value + (value < 0.0 ? -0.5 : 0.5)); +} + +bool bongo_cat_window_set_scale(BongoCatApp *app, float scale) { + if (!app || !app->window) return false; + int x, y, width, height; + if (!SDL_GetWindowPosition(app->window, &x, &y) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + float actual; + int next_width, next_height; + if (!bongo_cat_window_scaled_size(width, height, + app->session.window.scale_percent, scale, + &actual, &next_width, &next_height)) return false; + if (actual == app->session.window.scale_percent && + next_width == width && next_height == height) return false; + return bongo_cat_window_apply_scale_centered(app, scale, + width, height, app->session.window.scale_percent, + x + width * 0.5f, y + height * 0.5f); +} + +bool bongo_cat_window_apply_scale_centered(BongoCatApp *app, float scale, + int base_width, int base_height, float base_scale, + float center_x, float center_y) { + float actual; + int width, height; + if (!app || !app->window || !bongo_cat_window_scaled_size(base_width, base_height, + base_scale, scale, &actual, &width, &height)) return false; + int x = round_position(center_x - width * 0.5); + int y = round_position(center_y - height * 0.5); + SDL_FRect image = {0, 0, base_width * actual / base_scale, + base_height * actual / base_scale}; + image.x = center_x - image.w * 0.5f; + image.y = center_y - image.h * 0.5f; + if (app->settings.window.keep_in_screen) { + SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); + SDL_Rect bounds; + if (display && SDL_GetDisplayUsableBounds(display, &bounds)) { + x = SDL_clamp(x, bounds.x, SDL_max(bounds.x, bounds.x + bounds.w - width)); + y = SDL_clamp(y, bounds.y, SDL_max(bounds.y, bounds.y + bounds.h - height)); + image.x = SDL_clamp(image.x, (float)bounds.x, + SDL_max((float)bounds.x, bounds.x + bounds.w - image.w)); + image.y = SDL_clamp(image.y, (float)bounds.y, + SDL_max((float)bounds.y, bounds.y + bounds.h - image.h)); + } + } + if (x == app->session.window.x && y == app->session.window.y && + width == app->session.window.width && height == app->session.window.height) { + app->session.window.scale_percent = actual; + if (app->window_snapshot) { + bongo_cat_window_snapshot_geometry(app, &image); + bongo_cat_window_snapshot_present(app); + } + return true; + } + bongo_cat_window_snapshot_begin(app); + bongo_cat_window_snapshot_geometry(app, &image); + if (!bongo_cat_window_apply_geometry(app, x, y, actual, width, height)) { + bongo_cat_window_snapshot_end(app); + return false; + } + /* Uniform scaling preserves the normalized gaze anchor. Finish drawing + the resized surface before returning to event/model processing, where + the compositor could otherwise display the moved previous frame. */ + app->resize_render_target_pending = true; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bongo_cat_app_render_now(app); + if (previous_window && previous_context && + (previous_window != app->window || previous_context != app->gl_context) && + !SDL_GL_MakeCurrent(previous_window, previous_context)) + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Cannot restore OpenGL context after window scaling: %s", SDL_GetError()); + return true; +} diff --git a/src/runtime/shell/window_snapshot.c b/src/runtime/shell/window_snapshot.c new file mode 100644 index 00000000..8034b159 --- /dev/null +++ b/src/runtime/shell/window_snapshot.c @@ -0,0 +1,123 @@ +#include "runtime.h" + +#ifdef _WIN32 +#include "windows_snapshot.h" +#endif + +#define SNAPSHOT_IDLE_NS 180000000ull + +void bongo_cat_window_snapshot_begin(BongoCatApp *app) { + if (!app) return; + app->snapshot_deadline_ns = SDL_GetTicksNS() + SNAPSHOT_IDLE_NS; + if (app->window_snapshot || app->snapshot_blocked) return; +#ifdef _WIN32 + if (!app->window || !app->live2d || !app->session.window.visible || + app->startup_visibility_pending || app->window_minimized || app->hover_hidden || + app->hover_fade_active || + app->settings.window.pass_through || + !(SDL_GetWindowFlags(app->window) & SDL_WINDOW_TRANSPARENT)) return; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + if (!SDL_GL_MakeCurrent(app->window, app->gl_context)) return; + /* Capture a complete, up-to-date frame before changing native geometry. */ + bongo_cat_app_render_now(app); + if (!app->render_retry_ns) { + app->window_snapshot = bongo_cat_windows_snapshot_create(app->window, + app->platform.window_opacity); + if (!app->window_snapshot) { + app->snapshot_blocked = true; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Interaction snapshot unavailable; using live rendering: %s", SDL_GetError()); + } + } + SDL_GetGlobalMouseState(&app->snapshot_pointer_x, &app->snapshot_pointer_y); + if (previous_window && previous_context) + SDL_GL_MakeCurrent(previous_window, previous_context); +#endif +} + +void bongo_cat_window_snapshot_discard(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; +#ifdef _WIN32 + bongo_cat_windows_snapshot_destroy(app->window_snapshot); +#endif + app->window_snapshot = NULL; + app->last_frame_ns = SDL_GetTicksNS(); + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); +} + +void bongo_cat_window_snapshot_end(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; + void *snapshot = app->window_snapshot; + app->window_snapshot = NULL; + app->snapshot_blocked = true; + app->last_frame_ns = SDL_GetTicksNS(); + app->dirty = true; + /* Keep the preview visible until the final real frame is submitted. */ + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bongo_cat_app_render_now(app); +#ifdef _WIN32 + bongo_cat_windows_snapshot_destroy(snapshot); +#else + (void)snapshot; +#endif + if (previous_window && previous_context) + SDL_GL_MakeCurrent(previous_window, previous_context); + bongo_cat_window_mark_hit_dirty(app); +} + +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now) { + if (!app) return; + if (!app->window_snapshot) { + if (!app->window_drag_active && !app->wheel_animation_active && + now >= app->snapshot_deadline_ns) app->snapshot_blocked = false; + return; + } +#ifdef _WIN32 + bongo_cat_windows_snapshot_pointer(app->window_snapshot); +#endif + bool finish = !app->session.window.visible || app->window_minimized || + app->settings.window.pass_through || app->hover_hidden; + if (!app->window_drag_active) { + float x, y; + SDL_GetGlobalMouseState(&x, &y); + float dx = x - app->snapshot_pointer_x, dy = y - app->snapshot_pointer_y; + finish = finish || dx * dx + dy * dy >= 4.0f || + (!app->wheel_animation_active && now >= app->snapshot_deadline_ns); + } + if (finish) bongo_cat_window_snapshot_end(app); + else if (app->window_drag_active && + !(SDL_GetGlobalMouseState(NULL, NULL) & SDL_BUTTON_LMASK)) + bongo_cat_window_drag_end(app); +} + +void bongo_cat_window_snapshot_geometry(BongoCatApp *app, const SDL_FRect *rect) { +#ifdef _WIN32 + if (app) bongo_cat_windows_snapshot_geometry(app->window_snapshot, rect); +#else + (void)app; (void)rect; +#endif +} + +void bongo_cat_window_snapshot_present(BongoCatApp *app) { + if (!app || !app->window_snapshot) return; +#ifdef _WIN32 + if (!bongo_cat_windows_snapshot_present(app->window_snapshot)) { + app->snapshot_blocked = true; + bongo_cat_window_snapshot_discard(app); + return; + } +#endif + app->dirty = false; +} + +bool bongo_cat_window_snapshot_hit(BongoCatApp *app, float x, float y) { +#ifdef _WIN32 + return app && bongo_cat_windows_snapshot_hit(app->window_snapshot, x, y); +#else + (void)app; (void)x; (void)y; + return false; +#endif +} diff --git a/src/runtime/shell/window_snapshot.h b/src/runtime/shell/window_snapshot.h new file mode 100644 index 00000000..59f8adda --- /dev/null +++ b/src/runtime/shell/window_snapshot.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_WINDOW_SNAPSHOT_H +#define BONGO_CAT_WINDOW_SNAPSHOT_H + +#include "bongo_cat/app.h" +#include + +/* Begin also refreshes the idle deadline of an existing preview. */ +void bongo_cat_window_snapshot_begin(BongoCatApp *app); +/* Draw the final live frame before releasing the preview. */ +void bongo_cat_window_snapshot_end(BongoCatApp *app); +/* Release without rendering, for hiding, shutdown or presentation failure. */ +void bongo_cat_window_snapshot_discard(BongoCatApp *app); +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now); +void bongo_cat_window_snapshot_present(BongoCatApp *app); +/* NULL follows the native window rectangle (drag); otherwise use subpixels. */ +void bongo_cat_window_snapshot_geometry(BongoCatApp *app, const SDL_FRect *rect); +bool bongo_cat_window_snapshot_hit(BongoCatApp *app, float x, float y); + +#endif diff --git a/src/runtime/shell/window_wheel.c b/src/runtime/shell/window_wheel.c new file mode 100644 index 00000000..30708c32 --- /dev/null +++ b/src/runtime/shell/window_wheel.c @@ -0,0 +1,191 @@ +#include "runtime.h" +#include "window_wheel_internal.h" +#include "bongo_cat/preferences.h" + +#define WHEEL_OPACITY_STEP 5.0f +#define WHEEL_SCALE_STEP 2.0f +#define WHEEL_SCALE_TARGET_LEAD (2.0f * WHEEL_SCALE_STEP) +#define WHEEL_OPACITY_RESPONSE_SECONDS 0.05f +#define WHEEL_SCALE_RESPONSE_SECONDS 0.025f +#define WHEEL_OPACITY_SPEED_PER_SECOND 100.0f +#define WHEEL_SCALE_SPEED_PER_SECOND 150.0f +#define WHEEL_MAX_FRAME_SECONDS 0.05f + +static float wheel_delta(const SDL_MouseWheelEvent *event) { + float value = event ? event->y : 0.0f; + return event && event->direction == SDL_MOUSEWHEEL_FLIPPED ? -value : value; +} + +static bool wheel_targets_window(BongoCatApp *app, + const SDL_MouseWheelEvent *event) { + int width = 0, height = 0; + if (!app->session.window.visible || + event->windowID != SDL_GetWindowID(app->window) || + !SDL_GetWindowSize(app->window, &width, &height)) return false; + if (event->mouse_x < 0.0f || event->mouse_x >= width || + event->mouse_y < 0.0f || event->mouse_y >= height) return false; + /* A transparent/click-through window can occasionally retain mouse focus + while a wheel message is generated elsewhere. SDL then reuses the last + local wheel coordinates, so also verify the current desktop cursor for + real events. Timestamp-zero events are synthetic self-test fixtures. */ + if (event->timestamp) { + float screen_x = 0.0f, screen_y = 0.0f; + float local_x = 0.0f, local_y = 0.0f; + SDL_GetGlobalMouseState(&screen_x, &screen_y); + if (!bongo_cat_platform_pointer_local(&app->platform, screen_x, + screen_y, &local_x, &local_y) || local_x < 0.0f || + local_x >= width || local_y < 0.0f || local_y >= height) + return false; + if (!bongo_cat_window_visible_at_pointer(app, local_x, local_y)) + return false; + } + return true; +} + +static bool initialize_targets(BongoCatApp *app, bool reset) { + if (!reset) return true; + int x = 0, y = 0, width = 0, height = 0; + if (!SDL_GetWindowPosition(app->window, &x, &y) || + !SDL_GetWindowSize(app->window, &width, &height) || + width <= 0 || height <= 0 || app->session.window.scale_percent <= 0.0f) + return false; + app->wheel_opacity_target = app->session.window.opacity_percent; + app->wheel_scale_target = app->session.window.scale_percent; + app->wheel_center_x = x + width * 0.5f; + app->wheel_center_y = y + height * 0.5f; + app->wheel_geometry_scale = app->session.window.scale_percent; + app->wheel_base_width = width; + app->wheel_base_height = height; + app->wheel_animation_ns = SDL_GetTicksNS(); + app->wheel_input_ns = app->wheel_animation_ns; + return true; +} + +int bongo_cat_window_wheel_round_position(float value) { + return (int)(value + (value < 0.0f ? -0.5f : 0.5f)); +} + +void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) { + if (!app || !app->window || !event || + !wheel_targets_window(app, event)) return; + float delta = wheel_delta(event); + if (SDL_fabsf(delta) < 0.001f) return; + delta = SDL_clamp(delta, -1.0f, 1.0f); + uint64_t event_ns = event->timestamp ? event->timestamp : SDL_GetTicksNS(); + bool continuing = app->wheel_gesture_active && event_ns >= app->wheel_event_ns && + event_ns - app->wheel_event_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; + if (!initialize_targets(app, !continuing && !app->wheel_animation_active)) return; + app->wheel_event_ns = event_ns; + app->wheel_gesture_active = true; + float old_opacity_target = app->wheel_opacity_target; + float old_scale_target = app->wheel_scale_target; + bool control = bongo_cat_input_control_down(&app->input); +#ifndef _WIN32 + control = control || (SDL_GetModState() & SDL_KMOD_CTRL) != 0; +#endif + /* Windows Raw Input tracks releases without keyboard focus; SDL's + cached modifiers can retain Ctrl after it has been released. */ + if (!control) { + float minimum = SDL_max(10.0f, + app->session.window.scale_percent - WHEEL_SCALE_TARGET_LEAD); + float maximum = SDL_min(500.0f, + app->session.window.scale_percent + WHEEL_SCALE_TARGET_LEAD); + app->wheel_scale_target = SDL_clamp(app->wheel_scale_target + + delta * WHEEL_SCALE_STEP, minimum, maximum); + } else { + bongo_cat_window_snapshot_end(app); + app->wheel_opacity_target = SDL_clamp(app->wheel_opacity_target + + delta * WHEEL_OPACITY_STEP, 10.0f, 100.0f); + } + uint64_t input_ns = SDL_GetTicksNS(); + if (old_opacity_target == app->wheel_opacity_target && + old_scale_target == app->wheel_scale_target) { + if (app->wheel_animation_active) app->wheel_input_ns = input_ns; + return; + } + if (!app->wheel_animation_active) app->wheel_animation_ns = input_ns; + app->wheel_input_ns = input_ns; + app->wheel_animation_active = true; + app->dirty = true; +} + +static float approach(float current, float target, float elapsed_seconds, + float response_seconds, float speed_per_second, float snap_distance) { + float difference = target - current; + if (SDL_fabsf(difference) < snap_distance) return target; + float alpha = elapsed_seconds / (response_seconds + elapsed_seconds); + float step = difference * alpha; + float maximum = speed_per_second * elapsed_seconds; + step = SDL_clamp(step, -maximum, maximum); + return SDL_fabsf(step) >= SDL_fabsf(difference) ? target : current + step; +} + +static void apply_scale(BongoCatApp *app, float scale) { + float old = app->session.window.scale_percent; + if (old <= 0.0f || SDL_fabsf(scale - old) < 0.001f) return; + float actual; + int next_width, next_height; + if (!bongo_cat_window_scaled_size(app->wheel_base_width, + app->wheel_base_height, app->wheel_geometry_scale, + scale, &actual, &next_width, &next_height)) return; + if (actual != scale) app->wheel_scale_target = actual; + if (!app->window_snapshot && next_width == app->session.window.width && + next_height == app->session.window.height) { + app->session.window.scale_percent = actual; + return; + } + if (!bongo_cat_window_apply_scale_centered(app, actual, + app->wheel_base_width, app->wheel_base_height, + app->wheel_geometry_scale, app->wheel_center_x, + app->wheel_center_y)) { + app->wheel_scale_target = old; + app->wheel_animation_active = false; + app->wheel_gesture_active = false; + } +} + +void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { + if (!app || !app->wheel_animation_active) return; + uint64_t elapsed_ns = now >= app->wheel_animation_ns + ? now - app->wheel_animation_ns : 0; + /* Resize/expose messages can wake the loop before the next animation frame. */ + if (elapsed_ns < BONGO_CAT_WHEEL_FRAME_INTERVAL_NS) return; + app->wheel_animation_ns = now; + float elapsed_seconds = SDL_min((float)elapsed_ns / 1000000000.0f, + WHEEL_MAX_FRAME_SECONDS); + float opacity = approach(app->session.window.opacity_percent, + app->wheel_opacity_target, elapsed_seconds, + WHEEL_OPACITY_RESPONSE_SECONDS, WHEEL_OPACITY_SPEED_PER_SECOND, 0.01f); + float scale = approach(app->session.window.scale_percent, + app->wheel_scale_target, elapsed_seconds, + WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.05f); + bool changed = SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f || + SDL_fabsf(scale - app->session.window.scale_percent) > 0.001f; + bool opacity_changed = + SDL_fabsf(opacity - app->session.window.opacity_percent) > 0.001f; + if (opacity_changed) { + app->session.window.opacity_percent = opacity; + if (!app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, opacity / 100.0f); + } + } + apply_scale(app, scale); + bool reached = + SDL_fabsf(app->session.window.opacity_percent - app->wheel_opacity_target) < 0.01f && + SDL_fabsf(app->session.window.scale_percent - app->wheel_scale_target) < 0.01f; + bool recent_input = now >= app->wheel_input_ns && + now - app->wheel_input_ns < BONGO_CAT_WHEEL_GESTURE_IDLE_NS; + app->wheel_animation_active = !reached || recent_input; + /* Geometry marks actual pixel changes dirty; subpixel scale bookkeeping + alone does not need another model draw and buffer swap. */ + if (opacity_changed || (!app->wheel_animation_active && + app->resize_render_target_pending)) app->dirty = true; + if (changed) bongo_cat_preferences_invalidate(app->preferences); +} + +void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app) { + if (!app) return; + app->wheel_animation_active = false; + app->wheel_gesture_active = false; +} diff --git a/src/runtime/shell/window_wheel_internal.h b/src/runtime/shell/window_wheel_internal.h new file mode 100644 index 00000000..394ec799 --- /dev/null +++ b/src/runtime/shell/window_wheel_internal.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_WINDOW_WHEEL_INTERNAL_H +#define BONGO_CAT_WINDOW_WHEEL_INTERNAL_H + +#define BONGO_CAT_WHEEL_GESTURE_IDLE_NS 120000000ull +#define BONGO_CAT_WHEEL_FRAME_INTERVAL_NS 8000000ull + +int bongo_cat_window_wheel_round_position(float value); + +#endif diff --git a/src/runtime/shell/window_wheel_test.c b/src/runtime/shell/window_wheel_test.c new file mode 100644 index 00000000..23e96877 --- /dev/null +++ b/src/runtime/shell/window_wheel_test.c @@ -0,0 +1,139 @@ +#include "runtime.h" +#include "window_wheel_internal.h" + +#include + +bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { + if (!app || !app->window) return false; + BongoCatWindowState backup = app->session.window; + int original_x, original_y, original_width, original_height; + SDL_GetWindowPosition(app->window, &original_x, &original_y); + SDL_GetWindowSize(app->window, &original_width, &original_height); + SDL_Keymod modifiers = SDL_GetModState(); + uint_fast8_t original_control = atomic_load(&app->input.control); + atomic_store(&app->input.control, 1); + SDL_SetModState(modifiers | SDL_KMOD_CTRL); + app->session.window.opacity_percent = 80.0f; + app->session.window.scale_percent = 100.0f; + bongo_cat_window_cancel_wheel_animation(app); + SDL_MouseWheelEvent wheel = {.y = -1.0f, + .windowID = SDL_GetWindowID(app->window), + .mouse_x = original_width * 0.5f, + .mouse_y = original_height * 0.5f}; + float untouched_scale = app->session.window.scale_percent; + SDL_WindowID own_window = wheel.windowID; + wheel.windowID = 0; + bongo_cat_window_wheel(app, &wheel); + bool foreign = !app->wheel_animation_active && + app->session.window.scale_percent == untouched_scale; + wheel.windowID = own_window; + bongo_cat_window_wheel(app, &wheel); + uint64_t started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool opacity = SDL_fabsf( + app->session.window.opacity_percent - 75.0f) < 0.1f; + atomic_store(&app->input.control, 0); +#ifndef _WIN32 + SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); +#endif + /* On Windows leave SDL Ctrl set to reproduce a stale unfocused cache. */ + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + float scale_before_frame = app->session.window.scale_percent; + for (uint64_t offset = 1000000ull; + offset < BONGO_CAT_WHEEL_FRAME_INTERVAL_NS; offset += 1000000ull) + bongo_cat_window_update_wheel_animation(app, started + offset); + bool coalesced = app->session.window.scale_percent == scale_before_frame && + app->wheel_animation_ns == started; + bongo_cat_window_update_wheel_animation(app, + started + BONGO_CAT_WHEEL_FRAME_INTERVAL_NS); + coalesced = coalesced && app->session.window.scale_percent > scale_before_frame && + app->session.window.scale_percent < app->wheel_scale_target; + for (int i = 1; i <= 12; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool responsive = SDL_fabsf( + app->session.window.scale_percent - 102.0f) < 0.1f; + bongo_cat_window_update_wheel_animation(app, + started + 250000000ull); + bool scale = responsive && !app->wheel_animation_active && + SDL_fabsf(app->session.window.opacity_percent - 75.0f) < 0.1f; + SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 1000.0f; + for (int i = 0; i < 2; ++i) bongo_cat_window_wheel(app, &wheel); + bool burst = app->wheel_scale_target >= 103.5f && + app->wheel_scale_target <= 104.1f; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 3.0f; + bongo_cat_window_wheel(app, &wheel); + bool aggregated = app->wheel_scale_target >= 101.5f && + app->wheel_scale_target <= 102.1f; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, + original_width, original_height); + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool reversal = !app->wheel_animation_active && + SDL_fabsf(app->session.window.scale_percent - 100.0f) < 0.1f; + wheel.y = 1.0f; + wheel.direction = SDL_MOUSEWHEEL_FLIPPED; + bongo_cat_window_wheel(app, &wheel); + started = app->wheel_animation_ns; + for (int i = 1; i <= 30; ++i) + bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); + bool flipped = SDL_fabsf( + app->session.window.scale_percent - 98.0f) < 0.1f; + wheel.direction = SDL_MOUSEWHEEL_NORMAL; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.scale_percent = 500.0f; + bongo_cat_window_wheel(app, &wheel); + bool maximum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.scale_percent = 10.0f; + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + bool minimum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + /* Right Ctrl must work even when SDL has not received the key press. */ + atomic_store(&app->input.control, 2); + app->session.window.opacity_percent = 100.0f; + wheel.y = 1.0f; + bongo_cat_window_wheel(app, &wheel); + bool opacity_maximum = !app->wheel_animation_active; + bongo_cat_window_cancel_wheel_animation(app); + app->session.window.opacity_percent = 10.0f; + wheel.y = -1.0f; + bongo_cat_window_wheel(app, &wheel); + bool opacity_minimum = !app->wheel_animation_active; + bool rounding = bongo_cat_window_wheel_round_position(-10.6f) == -11 && + bongo_cat_window_wheel_round_position(10.6f) == 11; + SDL_SetModState(modifiers); + atomic_store(&app->input.control, original_control); + app->session.window = backup; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_platform_set_opacity(&app->platform, + backup.opacity_percent / 100.0f); + bongo_cat_platform_set_geometry(&app->platform, original_x, original_y, + original_width, original_height); + bool passed = foreign && opacity && scale && burst && aggregated && + reversal && flipped && maximum && minimum && opacity_maximum && + opacity_minimum && rounding && coalesced; + if (!passed) + fprintf(stderr, "wheel self-test: foreign=%d opacity=%d scale=%d " + "burst=%d aggregated=%d reversal=%d flipped=%d maximum=%d " + "minimum=%d opacity_max=%d opacity_min=%d rounding=%d coalesced=%d\n", + foreign, opacity, scale, burst, aggregated, reversal, flipped, + maximum, minimum, opacity_maximum, opacity_minimum, rounding, coalesced); + return passed; +} diff --git a/src/runtime/startup.c b/src/runtime/startup.c deleted file mode 100644 index 36ac4b9b..00000000 --- a/src/runtime/startup.c +++ /dev/null @@ -1,256 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#else -#include -#endif - -static char startup_log_path[BONGO_CAT_PATH_CAP]; -static bool startup_is_ready; - -static void SDLCALL log_output(void *userdata, int category, - SDL_LogPriority priority, const char *message) { - (void)userdata; - FILE *file = startup_log_path[0] - ? bongo_cat_file_open(startup_log_path, "ab") : NULL; - if (file) { - fprintf(file, "%lld [%d:%d] %s\n", (long long)time(NULL), category, - (int)priority, message ? message : ""); - fclose(file); - } - fprintf(stderr, "%s\n", message ? message : ""); -} - -static bool store_argument(char *target, size_t capacity, const char *value, - const char *name, BongoCatError *error) { - int length = snprintf(target, capacity, "%s", value ? value : ""); - if (length >= 0 && (size_t)length < capacity) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_ARGUMENT, - "%s path is too long", name); - return false; -} - -static bool parse_arguments(BongoCatApp *app, int argc, char **argv, - BongoCatError *error) { - for (int i = 1; i < argc; ++i) { - const char *arg = argv[i]; - if (strcmp(arg, "--autostart") == 0) app->autostart_launch = true; - else if (strcmp(arg, "--ci-smoke") == 0) app->smoke = true; - else if (strcmp(arg, "--ci-preferences") == 0) app->smoke_preferences = true; - else if (strcmp(arg, "--ci-preference-shortcut") == 0) - app->smoke_preference_shortcut = true; - else if (strcmp(arg, "--ci-preference-model-select") == 0) - app->smoke_preference_model_select = true; - else if (strcmp(arg, "--ci-remove-imported") == 0) app->smoke_remove_imported = true; - else if (strcmp(arg, "--ci-shortcuts") == 0) app->smoke_shortcuts = true; - else if (strcmp(arg, "--ci-menu") == 0) app->smoke_menu = true; - else if (strcmp(arg, "--ci-input-audit") == 0) app->smoke_input_audit = true; - else if (strcmp(arg, "--ci-ignore-global-input") == 0) app->smoke_ignore_global_input = true; - else if (strcmp(arg, "--ci-pass-through") == 0) app->smoke_pass_through = true; - else if (strcmp(arg, "--ci-context-menu") == 0) app->smoke_context_menu = true; - else if (strcmp(arg, "--ci-frame-series") == 0) app->smoke_frame_series = true; - else if (strcmp(arg, "--ci-runtime-flow") == 0) app->smoke_runtime_flow = true; - else if (strcmp(arg, "--ci-freeze-model") == 0) app->smoke_freeze_model = true; - else if (strncmp(arg, "--ci-preference-page=", 21) == 0) { - int page = atoi(arg + 21); - if (page >= 0 && page < 5) app->smoke_preference_page = page; - } else if (strncmp(arg, "--ci-language=", 14) == 0) { - const char *name = arg + 14; - for (int value = 0; value < BONGO_CAT_LANG_COUNT; ++value) - if (strcmp(name, bongo_cat_language_name((BongoCatLanguage)value)) == 0) - app->smoke_language = value; - } else if (strncmp(arg, "--ci-theme=", 11) == 0) { - const char *name = arg + 11; - for (int value = 0; value <= BONGO_CAT_THEME_DARK; ++value) - if (strcmp(name, bongo_cat_theme_name((BongoCatTheme)value)) == 0) - app->smoke_theme = value; - } else if (strncmp(arg, "--ci-exit-ms=", 13) == 0) { - uint64_t delay = strtoull(arg + 13, NULL, 10); - app->smoke_deadline_ns = delay > UINT64_MAX / 1000000ull - ? UINT64_MAX : delay * 1000000ull; - } else if (strncmp(arg, "--preferences=", 14) == 0) { - if (!store_argument(app->preferences_path, sizeof(app->preferences_path), - arg + 14, "Preferences", error)) return false; - } else if (strncmp(arg, "--session=", 10) == 0) { - if (!store_argument(app->session_path, sizeof(app->session_path), - arg + 10, "Session", error)) return false; - } else if (strncmp(arg, "--data-root=", 12) == 0) { - if (!store_argument(app->data_root, sizeof(app->data_root), - arg + 12, "Data", error)) return false; - } else if (strncmp(arg, "--ci-import=", 12) == 0) { - if (!store_argument(app->smoke_import_path, sizeof(app->smoke_import_path), - arg + 12, "Import", error)) return false; - } else if (strncmp(arg, "--ci-model=", 11) == 0) { - if (!store_argument(app->smoke_model, sizeof(app->smoke_model), - arg + 11, "Model", error)) return false; - } else if (strncmp(arg, "--ci-viewer-trace=", 18) == 0) { - if (!store_argument(app->smoke_viewer_trace, - sizeof(app->smoke_viewer_trace), arg + 18, - "Viewer trace", error)) return false; - } else if (strncmp(arg, "--ci-live2d-scenario=", 21) == 0 && - !store_argument(app->smoke_live2d_scenario, - sizeof(app->smoke_live2d_scenario), arg + 21, "Scenario", error)) return false; - } - if (app->smoke && !app->smoke_deadline_ns) app->smoke_deadline_ns = 1500000000ull; - return true; -} - -static bool writable_directory(const char *root) { - char probe[BONGO_CAT_PATH_CAP]; - if (!root || !root[0] || !bongo_cat_path_create_directory(root) || - !bongo_cat_path_join(probe, sizeof(probe), root, ".startup-write-test")) return false; - FILE *file = bongo_cat_file_open(probe, "wb"); - bool ok = file && fputc('1', file) != EOF; - if (file && fclose(file) != 0) ok = false; - bongo_cat_file_remove(probe); - return ok; -} - -static bool use_root(BongoCatApp *app, const char *root) { - if (!root || !root[0] || !store_argument(app->data_root, - sizeof(app->data_root), root, "Data", NULL)) return false; - if (writable_directory(app->data_root)) return true; - app->data_root[0] = '\0'; return false; -} - -static bool locate_data_root(BongoCatApp *app, BongoCatError *error) { - if (app->data_root[0]) { - if (writable_directory(app->data_root)) return true; - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "The selected data directory is not writable: %s", app->data_root); - return false; - } - char *preferred = SDL_GetPrefPath("BongoCat", BONGO_CAT_NAME); - bool found = use_root(app, preferred); - SDL_free(preferred); - char fallback[BONGO_CAT_PATH_CAP]; - const char *home = SDL_GetUserFolder(SDL_FOLDER_HOME); - if (!found && home && bongo_cat_path_join(fallback, sizeof(fallback), - home, ".bongo-cat")) found = use_root(app, fallback); - const char *temporary = SDL_getenv( -#ifdef _WIN32 - "TEMP"); const char *temporary_name = "BongoCat"; -#else - "TMPDIR"); if (!temporary || !temporary[0]) temporary = "/tmp"; - char temporary_name[64]; snprintf(temporary_name, sizeof(temporary_name), - "bongo-cat-%lu", (unsigned long)getuid()); -#endif - if (!found && temporary && bongo_cat_path_join(fallback, sizeof(fallback), - temporary, temporary_name)) found = use_root(app, fallback); - if (!found) bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "No writable application data directory is available"); - return found; -} - -static void begin_log(BongoCatApp *app) { - char previous[BONGO_CAT_PATH_CAP], stage_path[BONGO_CAT_PATH_CAP]; - char interrupted[128] = {0}; - bongo_cat_path_join(startup_log_path, sizeof(startup_log_path), - app->data_root, "startup.log"); - bongo_cat_path_join(previous, sizeof(previous), app->data_root, - "startup-previous.log"); - bongo_cat_path_join(stage_path, sizeof(stage_path), app->data_root, - "startup-stage.txt"); - FILE *stage = bongo_cat_file_open(stage_path, "rb"); - if (stage) { - size_t length = fread(interrupted, 1, sizeof(interrupted) - 1, stage); - interrupted[ferror(stage) ? 0 : length] = '\0'; - fclose(stage); - } - if (bongo_cat_path_is_file(startup_log_path)) - bongo_cat_file_replace(startup_log_path, previous, true); - FILE *file = bongo_cat_file_open(startup_log_path, "wb"); - if (file) { fprintf(file, "BongoCat %s startup log\n", BONGO_CAT_VERSION); fclose(file); } - SDL_SetLogOutputFunction(log_output, NULL); - SDL_Log("Startup on %s; data=%s", SDL_GetPlatform(), app->data_root); - if (interrupted[0]) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Previous startup ended before readiness at stage: %s", interrupted); -} - -bool bongo_cat_startup_prepare(BongoCatApp *app, int argc, char **argv, - BongoCatError *error) { - if (!parse_arguments(app, argc, argv, error) || !locate_data_root(app, error)) return false; - if ((!app->preferences_path[0] && !bongo_cat_path_join(app->preferences_path, - sizeof(app->preferences_path), app->data_root, "preferences.json")) || - (!app->session_path[0] && !bongo_cat_path_join(app->session_path, - sizeof(app->session_path), app->data_root, "session.json"))) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_IO, - "Configuration path is too long"); return false; - } - begin_log(app); bongo_cat_startup_stage(app, "paths-ready"); return true; -} - -void bongo_cat_startup_stage(BongoCatApp *app, const char *stage) { - if (!app || !app->data_root[0] || !stage) return; - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), app->data_root, "startup-stage.txt")) { - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { fputs(stage, file); fclose(file); } - } - SDL_Log("Startup stage: %s", stage); -} - -void bongo_cat_startup_ready(BongoCatApp *app) { - if (!app || startup_is_ready) return; - startup_is_ready = true; - const char *names[] = {"startup-stage.txt", "startup-error.log"}; - for (size_t i = 0; i < 2; ++i) { - char path[BONGO_CAT_PATH_CAP]; - if (bongo_cat_path_join(path, sizeof(path), app->data_root, names[i])) - bongo_cat_file_remove(path); - } - SDL_Log("Startup ready"); -} - -static void write_error(BongoCatApp *app, const char *name, const char *message) { - if (!app || !app->data_root[0]) return; - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), app->data_root, name)) return; - FILE *file = bongo_cat_file_open(path, "wb"); - if (file) { fputs(message, file); fputc('\n', file); fclose(file); } -} - -#ifdef _WIN32 -static void native_error_box(const char *message) { - int count = MultiByteToWideChar(CP_UTF8, 0, message, -1, NULL, 0); - wchar_t *wide = count > 0 ? calloc((size_t)count, sizeof(*wide)) : NULL; - if (wide) { - MultiByteToWideChar(CP_UTF8, 0, message, -1, wide, count); - MessageBoxW(NULL, wide, BONGO_CAT_NAME_W, MB_OK | MB_ICONERROR); - free(wide); - } -} -#endif - -void bongo_cat_startup_failure(BongoCatApp *app, const BongoCatError *error) { - const char *message = error && error->message[0] ? error->message : "Initialization failed"; - if (app) { bongo_cat_startup_stage(app, "failed"); write_error(app, "startup-error.log", message); } - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Startup failed: %s", message); - char body[BONGO_CAT_PATH_CAP + 384]; - snprintf(body, sizeof(body), "BongoCat could not start.\n\n%s%s%s", message, - app && app->data_root[0] ? "\n\nDiagnostic log:\n" : "", - app && app->data_root[0] ? startup_log_path : ""); - if (app && app->smoke) return; - if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, BONGO_CAT_NAME, body, - app ? app->window : NULL)) { -#ifdef _WIN32 - native_error_box(body); -#endif - } -} - -void bongo_cat_startup_ci_failure(BongoCatApp *app, - const BongoCatError *error) { - if (!app) return; - app->exit_code = 1; - write_error(app, "ci-error.log", error && error->message[0] - ? error->message : "CI operation failed"); -} diff --git a/src/runtime/update/update_channel.c b/src/runtime/update/update_channel.c new file mode 100644 index 00000000..0a64f5ae --- /dev/null +++ b/src/runtime/update/update_channel.c @@ -0,0 +1,136 @@ +#include "update_internal.h" +#include "bongo_cat/i18n.h" +#include "preferences_notice.h" + +#include +#include +#include + +#ifdef _WIN32 +#include "windows_package.h" +#include + +bool bongo_cat_update_platform_supported(void) { + return true; +} + +bool bongo_cat_update_platform_store(void) { + return bongo_cat_windows_is_packaged(); +} + +static bool current_executable(wchar_t *path, size_t capacity) { + DWORD length = GetModuleFileNameW(NULL, path, (DWORD)capacity); + return length > 0 && length < capacity; +} + +bool bongo_cat_update_platform_installed(void) { + wchar_t running[2048] = {0}; + if (!current_executable(running, _countof(running))) return false; + const DWORD views[] = { + RRF_SUBKEY_WOW6464KEY, RRF_SUBKEY_WOW6432KEY + }; + const wchar_t *keys[] = { + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat_is1", + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\BongoCat" + }; + for (size_t key = 0; key < _countof(keys); ++key) { + for (size_t index = 0; index < _countof(views); ++index) { + wchar_t install_root[2048] = {0}; + DWORD size = sizeof(install_root); + LSTATUS result = RegGetValueW(HKEY_CURRENT_USER, + keys[key], + L"InstallLocation", RRF_RT_REG_SZ | views[index], NULL, + install_root, &size); + if (result != ERROR_SUCCESS || !install_root[0]) continue; + size_t length = wcslen(install_root); + while (length && (install_root[length - 1] == L'\\' || + install_root[length - 1] == L'/')) install_root[--length] = L'\0'; + static const wchar_t executable[] = L"\\BongoCat.exe"; + if (length + _countof(executable) > _countof(install_root)) continue; + memcpy(install_root + length, executable, sizeof(executable)); + if (_wcsicmp(running, install_root) == 0) return true; + } + } + return false; +} + +const char *bongo_cat_update_platform_asset(void) { +#ifdef _WIN64 + return "windows-x64"; +#else + return "windows-x86"; +#endif +} + +#else + +/* Unix packages are published as a single archive for each architecture. + * They do not have an installer registration to inspect, but they can still + * use the release API and open the matching archive when an update exists. */ +bool bongo_cat_update_platform_supported(void) { +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) || defined(__x86_64__) + return true; +#else + return false; +#endif +#elif defined(__linux__) && (defined(__x86_64__) || defined(__amd64__)) + return true; +#else + return false; +#endif +} +bool bongo_cat_update_platform_store(void) { return false; } +bool bongo_cat_update_platform_installed(void) { return false; } +const char *bongo_cat_update_platform_asset(void) { +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + return "macos-arm64"; +#else + return "macos-x64"; +#endif +#elif defined(__x86_64__) || defined(__amd64__) + return "linux-x64"; +#else + return "unsupported"; +#endif +} + +#endif + +static const char *tr(BongoCatUpdateService *service, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(service->app->i18n, key, fallback); +} + +void bongo_cat_update_show_completion(BongoCatUpdateService *service) { + BongoCatUpdateSnapshot snapshot; + bongo_cat_update_snapshot(service, &snapshot); + char message[192]; + if (snapshot.status == BONGO_CAT_UPDATE_CURRENT) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.latest", "Already up to date"), BONGO_CAT_VERSION); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { + snprintf(message, sizeof(message), "%s v%s", tr(service, + "native.support.updateAvailable", "New version available:"), + snapshot.release.version); + bongo_cat_preferences_notice_show(service->app, message, false); + } else if (snapshot.status == BONGO_CAT_UPDATE_ERROR) { + char detail[384], status_text[16]; + static const char prefix[] = "GitHub returned HTTP status "; + const char *value = snapshot.error + sizeof(prefix) - 1; + char *end = NULL; + unsigned long status = strncmp(snapshot.error, prefix, + sizeof(prefix) - 1) == 0 ? strtoul(value, &end, 10) : 0; + if (status && end && !*end) { + snprintf(status_text, sizeof(status_text), "%lu", status); + snprintf(detail, sizeof(detail), tr(service, + "native.support.updateHttpFailed", + "GitHub returned HTTP status %s"), status_text); + } else snprintf(detail, sizeof(detail), "%s", snapshot.error[0] ? + snapshot.error : tr(service, "native.support.updateFailed", + "Unable to check for updates")); + bongo_cat_preferences_notice_show(service->app, detail, true); + } +} diff --git a/src/runtime/update/update_http.c b/src/runtime/update/update_http.c new file mode 100644 index 00000000..e92b080e --- /dev/null +++ b/src/runtime/update/update_http.c @@ -0,0 +1,163 @@ +#include "update_internal.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#include + +#define UPDATE_RESPONSE_LIMIT (1024u * 1024u) +#define UPDATE_INITIAL_CAPACITY 8192u + +static bool track_handle(BongoCatUpdateService *service, void **slot, + HINTERNET handle) { + SDL_LockMutex(service->http_mutex); + bool accepted = !service->http_cancelled; + if (accepted) *slot = handle; + SDL_UnlockMutex(service->http_mutex); + if (!accepted) WinHttpCloseHandle(handle); + return accepted; +} + +static void release_handle(BongoCatUpdateService *service, void **slot, + HINTERNET handle) { + SDL_LockMutex(service->http_mutex); + bool owned = *slot == handle; + if (owned) *slot = NULL; + SDL_UnlockMutex(service->http_mutex); + if (owned) WinHttpCloseHandle(handle); +} + +static bool cancelled(BongoCatUpdateService *service) { + SDL_LockMutex(service->http_mutex); + bool value = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + return value; +} + +static bool response_status(HINTERNET request, DWORD *status) { + DWORD size = sizeof(*status); + return WinHttpQueryHeaders(request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, status, &size, + WINHTTP_NO_HEADER_INDEX) != FALSE; +} + +static const char *winhttp_error_name(DWORD code) { + switch (code) { + case ERROR_WINHTTP_TIMEOUT: return "network timeout"; + case ERROR_WINHTTP_NAME_NOT_RESOLVED: return "DNS lookup failed"; + case ERROR_WINHTTP_CANNOT_CONNECT: return "network connection refused"; + case ERROR_WINHTTP_CONNECTION_ERROR: return "network connection error"; + case ERROR_WINHTTP_SECURE_FAILURE: return "TLS/SSL certificate error"; + default: return "network request failed"; + } +} + +static char *read_response(BongoCatUpdateService *service, + HINTERNET request) { + size_t capacity = UPDATE_INITIAL_CAPACITY, length = 0; + char *buffer = malloc(capacity); + if (!buffer) return NULL; + while (!cancelled(service)) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) break; + if (!available) { + buffer[length] = '\0'; + return buffer; + } + if (available > UPDATE_RESPONSE_LIMIT - length) break; + size_t required = length + available + 1; + if (required > capacity) { + size_t next = capacity; + while (next < required && next <= UPDATE_RESPONSE_LIMIT / 2) + next *= 2; + if (next < required) next = required; + char *resized = next <= UPDATE_RESPONSE_LIMIT + 1 + ? realloc(buffer, next) : NULL; + if (!resized) break; + buffer = resized; + capacity = next; + } + DWORD read = 0; + if (!WinHttpReadData(request, buffer + length, available, &read) || + !read) break; + length += read; + } + free(buffer); + return NULL; +} + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity) { + *response = NULL; + HINTERNET session = WinHttpOpen(L"BongoCat Update Checker/1.0", + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, 0); + if (!session || !track_handle(service, &service->http_session, session)) + return cancelled(service) ? BONGO_CAT_UPDATE_FETCH_CANCELLED : + BONGO_CAT_UPDATE_FETCH_NETWORK; + WinHttpSetTimeouts(session, 5000, 5000, 10000, 10000); + HINTERNET connection = WinHttpConnect(session, L"api.github.com", + INTERNET_DEFAULT_HTTPS_PORT, 0); + bool connected = connection && track_handle(service, + &service->http_connection, connection); + HINTERNET request = connected ? WinHttpOpenRequest(connection, L"GET", + L"/repos/vladelaina/BongoCat/releases/latest", NULL, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE) : NULL; + bool requested = request && track_handle(service, + &service->http_request, request); + static const wchar_t headers[] = + L"Accept: application/vnd.github+json\r\n" + L"X-GitHub-Api-Version: 2022-11-28\r\n"; + bool sent = requested && WinHttpSendRequest(request, headers, + (DWORD)-1L, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && + WinHttpReceiveResponse(request, NULL); + DWORD request_error = sent ? ERROR_SUCCESS : GetLastError(); + DWORD status = 0; + bool valid_status = sent && response_status(request, &status) && + status == 200; + if (valid_status) *response = read_response(service, request); + if (request) release_handle(service, &service->http_request, request); + if (connection) release_handle(service, &service->http_connection, + connection); + release_handle(service, &service->http_session, session); + if (cancelled(service)) return BONGO_CAT_UPDATE_FETCH_CANCELLED; + if (!sent) { + snprintf(error, error_capacity, "Network error: %s (code %lu)", + winhttp_error_name(request_error), (unsigned long)request_error); + return BONGO_CAT_UPDATE_FETCH_NETWORK; + } + if (!valid_status) { + snprintf(error, error_capacity, + status == 404 ? "No published BongoCat release is available" : + "GitHub returned HTTP status %lu", (unsigned long)status); + return BONGO_CAT_UPDATE_FETCH_RESPONSE; + } + if (!*response) { + snprintf(error, error_capacity, "Cannot read the GitHub response"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + return BONGO_CAT_UPDATE_FETCH_OK; +} + +void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { + if (!service || !service->http_mutex) return; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = true; + HINTERNET request = service->http_request; + HINTERNET connection = service->http_connection; + HINTERNET session = service->http_session; + service->http_request = service->http_connection = + service->http_session = NULL; + SDL_UnlockMutex(service->http_mutex); + if (request) WinHttpCloseHandle(request); + if (connection) WinHttpCloseHandle(connection); + if (session) WinHttpCloseHandle(session); +} + +#endif diff --git a/src/runtime/update/update_http_unix.c b/src/runtime/update/update_http_unix.c new file mode 100644 index 00000000..4ddd33b5 --- /dev/null +++ b/src/runtime/update/update_http_unix.c @@ -0,0 +1,173 @@ +#include "update_internal.h" + +#ifndef _WIN32 + +#include +#include +#include +#include +#include + +#define UPDATE_RESPONSE_LIMIT (1024u * 1024u) + +typedef struct UpdateResponseBuffer { + char *data; + size_t length; + size_t capacity; + bool failed; +} UpdateResponseBuffer; + +static bool cancelled(BongoCatUpdateService *service) { + SDL_LockMutex(service->http_mutex); + bool value = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + return value; +} + +static size_t write_response(const void *data, size_t size, size_t count, + void *userdata) { + UpdateResponseBuffer *buffer = userdata; + if (!buffer || size == 0 || count > SIZE_MAX / size) return 0; + size_t amount = size * count; + if (amount > UPDATE_RESPONSE_LIMIT - buffer->length) { + buffer->failed = true; + return 0; + } + size_t required = buffer->length + amount + 1; + if (required > buffer->capacity) { + size_t next = buffer->capacity; + while (next < required && next <= UPDATE_RESPONSE_LIMIT / 2) + next *= 2; + if (next < required) next = required; + char *resized = realloc(buffer->data, next); + if (!resized) { + buffer->failed = true; + return 0; + } + buffer->data = resized; + buffer->capacity = next; + } + memcpy(buffer->data + buffer->length, data, amount); + buffer->length += amount; + buffer->data[buffer->length] = '\0'; + return count; +} + +static int transfer_progress(void *userdata, curl_off_t download_total, + curl_off_t download_now, curl_off_t upload_total, curl_off_t upload_now) { + (void)download_total; + (void)download_now; + (void)upload_total; + (void)upload_now; + return cancelled(userdata) ? 1 : 0; +} + +static const char *curl_error_name(CURLcode code) { + switch (code) { + case CURLE_OPERATION_TIMEDOUT: return "network timeout"; + case CURLE_COULDNT_RESOLVE_HOST: return "DNS lookup failed"; + case CURLE_COULDNT_CONNECT: return "network connection refused"; + case CURLE_SSL_CONNECT_ERROR: + case CURLE_PEER_FAILED_VERIFICATION: return "TLS/SSL certificate error"; + case CURLE_ABORTED_BY_CALLBACK: return "request cancelled"; + default: return "network request failed"; + } +} + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity) { + *response = NULL; + if (!service || !error || !error_capacity) + return BONGO_CAT_UPDATE_FETCH_MEMORY; + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + snprintf(error, error_capacity, "Cannot initialize the network client"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + CURL *curl = curl_easy_init(); + if (!curl) { + snprintf(error, error_capacity, "Cannot initialize the network client"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + SDL_LockMutex(service->http_mutex); + bool accepted = !service->http_cancelled; + if (accepted) service->http_request = curl; + SDL_UnlockMutex(service->http_mutex); + if (!accepted) { + curl_easy_cleanup(curl); + return BONGO_CAT_UPDATE_FETCH_CANCELLED; + } + + UpdateResponseBuffer buffer = { + .data = malloc(1), .length = 0, .capacity = 1, .failed = false + }; + if (!buffer.data) { + curl_easy_cleanup(curl); + SDL_LockMutex(service->http_mutex); + service->http_request = NULL; + SDL_UnlockMutex(service->http_mutex); + snprintf(error, error_capacity, + "Cannot allocate the GitHub response buffer"); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + buffer.data[0] = '\0'; + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Accept: application/vnd.github+json"); + headers = curl_slist_append(headers, "X-GitHub-Api-Version: 2022-11-28"); + curl_easy_setopt(curl, CURLOPT_URL, + "https://api.github.com/repos/vladelaina/BongoCat/releases/latest"); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "BongoCat Update Checker/1.0"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 5000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 15000L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_response); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, transfer_progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, service); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + CURLcode result = curl_easy_perform(curl); + long status = 0; + if (result == CURLE_OK) + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_slist_free_all(headers); + SDL_LockMutex(service->http_mutex); + service->http_request = NULL; + bool was_cancelled = service->http_cancelled; + SDL_UnlockMutex(service->http_mutex); + curl_easy_cleanup(curl); + + if (was_cancelled || result == CURLE_ABORTED_BY_CALLBACK) { + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_CANCELLED; + } + if (buffer.failed) { + snprintf(error, error_capacity, "Cannot read the GitHub response"); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_MEMORY; + } + if (result != CURLE_OK) { + snprintf(error, error_capacity, "Network error: %s (code %d)", + curl_error_name(result), (int)result); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_NETWORK; + } + if (status != 200) { + snprintf(error, error_capacity, + status == 404 ? "No published BongoCat release is available" : + "GitHub returned HTTP status %ld", status); + free(buffer.data); + return BONGO_CAT_UPDATE_FETCH_RESPONSE; + } + *response = buffer.data; + return BONGO_CAT_UPDATE_FETCH_OK; +} + +void bongo_cat_update_http_cancel(BongoCatUpdateService *service) { + if (!service || !service->http_mutex) return; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = true; + SDL_UnlockMutex(service->http_mutex); +} + +#endif diff --git a/src/runtime/update/update_internal.h b/src/runtime/update/update_internal.h new file mode 100644 index 00000000..259d81bb --- /dev/null +++ b/src/runtime/update/update_internal.h @@ -0,0 +1,46 @@ +#ifndef BONGO_CAT_UPDATE_INTERNAL_H +#define BONGO_CAT_UPDATE_INTERNAL_H + +#include "update_service.h" +#include "bongo_cat/app.h" +#include + +typedef enum BongoCatUpdateFetchResult { + BONGO_CAT_UPDATE_FETCH_OK, + BONGO_CAT_UPDATE_FETCH_CANCELLED, + BONGO_CAT_UPDATE_FETCH_UNSUPPORTED, + BONGO_CAT_UPDATE_FETCH_NETWORK, + BONGO_CAT_UPDATE_FETCH_RESPONSE, + BONGO_CAT_UPDATE_FETCH_MEMORY +} BongoCatUpdateFetchResult; + +struct BongoCatUpdateService { + BongoCatApp *app; + SDL_Mutex *mutex; + SDL_Mutex *http_mutex; + SDL_Thread *worker; + Uint32 event_type; + BongoCatUpdateStatus status; + BongoCatUpdateRelease release; + char error[256]; + bool manual; + bool open_after_check; + bool installed; + bool shutting_down; + bool http_cancelled; + void *http_session; + void *http_connection; + void *http_request; +}; + +BongoCatUpdateFetchResult bongo_cat_update_http_fetch( + BongoCatUpdateService *service, char **response, char *error, + size_t error_capacity); +void bongo_cat_update_http_cancel(BongoCatUpdateService *service); +void bongo_cat_update_show_completion(BongoCatUpdateService *service); +bool bongo_cat_update_platform_supported(void); +bool bongo_cat_update_platform_store(void); +bool bongo_cat_update_platform_installed(void); +const char *bongo_cat_update_platform_asset(void); + +#endif diff --git a/src/runtime/update/update_service.c b/src/runtime/update/update_service.c new file mode 100644 index 00000000..896b4ebd --- /dev/null +++ b/src/runtime/update/update_service.c @@ -0,0 +1,273 @@ +#include "update_internal.h" +#include "preferences_notice.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/log.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include + +#define RELEASES_URL \ + "https://github.com/vladelaina/BongoCat/releases/latest" +#define STORE_URI "ms-windows-store://pdp/?ProductId=9P41MLSX72XW" +#define STORE_WEB_URL "https://apps.microsoft.com/detail/9P41MLSX72XW" + +static void reap_worker(BongoCatUpdateService *service) { + SDL_LockMutex(service->mutex); + SDL_Thread *worker = service->status != BONGO_CAT_UPDATE_CHECKING + ? service->worker : NULL; + if (worker) service->worker = NULL; + SDL_UnlockMutex(service->mutex); + if (worker) SDL_WaitThread(worker, NULL); +} + +static int local_day(void) { + SDL_Time timestamp = 0; + SDL_DateTime local = {0}; + if (!SDL_GetCurrentTime(×tamp) || + !SDL_TimeToDateTime(timestamp, &local, true)) return 0; + return local.year * 10000 + local.month * 100 + local.day; +} + +static void complete(BongoCatUpdateService *service, + BongoCatUpdateStatus status, const BongoCatUpdateRelease *release, + const char *error) { + SDL_LockMutex(service->mutex); + bool notify = !service->shutting_down; + if (notify) { + service->status = status; + if (release) service->release = *release; + else if (status != BONGO_CAT_UPDATE_ERROR) + memset(&service->release, 0, sizeof(service->release)); + snprintf(service->error, sizeof(service->error), "%s", + error ? error : ""); + } + SDL_UnlockMutex(service->mutex); + if (!notify) return; + if (status == BONGO_CAT_UPDATE_ERROR) + SDL_LogError(BONGO_CAT_LOG_UPDATE, "Update check failed: %s", + error && error[0] ? error : "unknown error"); + else SDL_LogInfo(BONGO_CAT_LOG_UPDATE, "Update check: %s version=%s", + status == BONGO_CAT_UPDATE_AVAILABLE ? "available" : "current", + release ? release->version : BONGO_CAT_VERSION); + SDL_Event event = {0}; + event.type = service->event_type; + event.user.data1 = service; + SDL_PushEvent(&event); +} + +static int SDLCALL update_worker(void *userdata) { + BongoCatUpdateService *service = userdata; + char *response = NULL; + char message[256] = {0}; + BongoCatUpdateFetchResult fetched = bongo_cat_update_http_fetch(service, + &response, message, sizeof(message)); + if (fetched != BONGO_CAT_UPDATE_FETCH_OK) { + if (fetched != BONGO_CAT_UPDATE_FETCH_CANCELLED) + complete(service, BONGO_CAT_UPDATE_ERROR, NULL, message); + return 0; + } + BongoCatUpdateRelease release; + BongoCatError error = {0}; + bool parsed = bongo_cat_update_parse_release(response, + bongo_cat_update_platform_asset(), &release, &error); + free(response); + if (!parsed) { + complete(service, BONGO_CAT_UPDATE_ERROR, NULL, error.message); + return 0; + } + BongoCatUpdateStatus status = bongo_cat_update_compare_versions( + release.version, BONGO_CAT_VERSION) > 0 + ? BONGO_CAT_UPDATE_AVAILABLE : BONGO_CAT_UPDATE_CURRENT; + complete(service, status, &release, NULL); + return 0; +} + +BongoCatUpdateService *bongo_cat_update_create(BongoCatApp *app) { + if (!app) return NULL; + BongoCatUpdateService *service = calloc(1, sizeof(*service)); + if (!service) return NULL; + service->app = app; + service->mutex = SDL_CreateMutex(); + service->http_mutex = SDL_CreateMutex(); + service->event_type = SDL_RegisterEvents(1); + if (!service->mutex || !service->http_mutex || + service->event_type == (Uint32)-1) { + if (service->mutex) SDL_DestroyMutex(service->mutex); + if (service->http_mutex) SDL_DestroyMutex(service->http_mutex); + free(service); + return NULL; + } + if (!bongo_cat_update_platform_supported()) + service->status = BONGO_CAT_UPDATE_UNSUPPORTED; + else if (bongo_cat_update_platform_store()) + service->status = BONGO_CAT_UPDATE_STORE; + else { + service->status = BONGO_CAT_UPDATE_IDLE; + service->installed = bongo_cat_update_platform_installed(); + if (app->session.available_update_version[0] && + bongo_cat_update_compare_versions( + app->session.available_update_version, + BONGO_CAT_VERSION) > 0) { + snprintf(service->release.version, + sizeof(service->release.version), "%s", + app->session.available_update_version); + service->status = BONGO_CAT_UPDATE_AVAILABLE; + } else { + app->session.available_update_version[0] = '\0'; + } + } + return service; +} + +bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual) { + if (!service) return false; + reap_worker(service); + SDL_LockMutex(service->mutex); + if (service->shutting_down || service->worker || + service->status == BONGO_CAT_UPDATE_STORE || + service->status == BONGO_CAT_UPDATE_UNSUPPORTED) { + SDL_UnlockMutex(service->mutex); + return false; + } + service->status = BONGO_CAT_UPDATE_CHECKING; + service->manual = manual; + service->error[0] = '\0'; + SDL_LockMutex(service->http_mutex); + service->http_cancelled = false; + SDL_UnlockMutex(service->http_mutex); + SDL_LogInfo(BONGO_CAT_LOG_UPDATE, "Update check started"); + service->worker = SDL_CreateThread(update_worker, + BONGO_CAT_SLUG "-update-check", service); + bool started = service->worker != NULL; + if (!started) { + service->status = BONGO_CAT_UPDATE_ERROR; + SDL_LogError(BONGO_CAT_LOG_UPDATE, "Cannot start the update checker"); + snprintf(service->error, sizeof(service->error), + "Cannot start the update checker"); + } + SDL_UnlockMutex(service->mutex); + return started; +} + +bool bongo_cat_update_refresh_and_open(BongoCatUpdateService *service) { + if (!service) return false; + SDL_LockMutex(service->mutex); + service->open_after_check = true; + SDL_UnlockMutex(service->mutex); + if (bongo_cat_update_check(service, true)) return true; + SDL_LockMutex(service->mutex); + service->open_after_check = false; + SDL_UnlockMutex(service->mutex); + return false; +} + +void bongo_cat_update_start_automatic(BongoCatUpdateService *service) { + if (!service || !service->app || service->app->smoke || + service->app->secondary_pet) return; + int today = local_day(); + BongoCatSessionState *session = &service->app->session; + if (today && session->last_update_check_day == today && + strcmp(session->last_update_check_version, BONGO_CAT_VERSION) == 0) + return; + if (!bongo_cat_update_check(service, false)) return; +} + +void bongo_cat_update_snapshot(BongoCatUpdateService *service, + BongoCatUpdateSnapshot *snapshot) { + if (!snapshot) return; + memset(snapshot, 0, sizeof(*snapshot)); + if (!service) { + snapshot->status = BONGO_CAT_UPDATE_UNSUPPORTED; + return; + } + SDL_LockMutex(service->mutex); + snapshot->status = service->status; + snapshot->release = service->release; + snapshot->installed = service->installed; + snprintf(snapshot->error, sizeof(snapshot->error), "%s", service->error); + SDL_UnlockMutex(service->mutex); +} + +static const char *tr(BongoCatUpdateService *service, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(service->app->i18n, key, fallback); +} + +bool bongo_cat_update_event(BongoCatUpdateService *service, + const SDL_Event *event) { + if (!service || !event || event->type != service->event_type || + event->user.data1 != service) return false; + SDL_LockMutex(service->mutex); + bool manual = service->manual; + BongoCatUpdateStatus status = service->status; + bool open_after_check = service->open_after_check; + service->open_after_check = false; + BongoCatUpdateRelease release = service->release; + SDL_UnlockMutex(service->mutex); + reap_worker(service); + if ((status == BONGO_CAT_UPDATE_AVAILABLE || + status == BONGO_CAT_UPDATE_ERROR) && release.version[0]) + snprintf(service->app->session.available_update_version, + sizeof(service->app->session.available_update_version), "%s", + release.version); + else if (status == BONGO_CAT_UPDATE_CURRENT) + service->app->session.available_update_version[0] = '\0'; + if (!manual && (status == BONGO_CAT_UPDATE_CURRENT || + status == BONGO_CAT_UPDATE_AVAILABLE)) { + int today = local_day(); + if (today) { + BongoCatSessionState *session = &service->app->session; + session->last_update_check_day = today; + snprintf(session->last_update_check_version, + sizeof(session->last_update_check_version), "%s", + BONGO_CAT_VERSION); + } + } + bongo_cat_preferences_invalidate(service->app->preferences); + if (open_after_check && status == BONGO_CAT_UPDATE_AVAILABLE) { + if (!bongo_cat_update_open(service)) { + char detail[384]; + const char *reason = SDL_GetError(); + snprintf(detail, sizeof(detail), "%s: %s", tr(service, + "native.support.openUpdateFailed", + "Unable to open the update page"), + reason && reason[0] ? reason : "system rejected the URL"); + bongo_cat_preferences_notice_show(service->app, detail, true); + } + } else if (manual) bongo_cat_update_show_completion(service); + return true; +} + +bool bongo_cat_update_open(BongoCatUpdateService *service) { + BongoCatUpdateSnapshot snapshot; + bongo_cat_update_snapshot(service, &snapshot); + if (snapshot.status == BONGO_CAT_UPDATE_STORE) + return SDL_OpenURL(STORE_URI) || SDL_OpenURL(STORE_WEB_URL); + const char *url = RELEASES_URL; + if (snapshot.status == BONGO_CAT_UPDATE_AVAILABLE) { + if (snapshot.installed && snapshot.release.installer_url[0]) + url = snapshot.release.installer_url; + else if (!snapshot.installed && snapshot.release.portable_url[0]) + url = snapshot.release.portable_url; + else if (snapshot.release.release_url[0]) + url = snapshot.release.release_url; + } + return SDL_OpenURL(url); +} + +void bongo_cat_update_destroy(BongoCatUpdateService *service) { + if (!service) return; + SDL_LockMutex(service->mutex); + service->shutting_down = true; + SDL_UnlockMutex(service->mutex); + bongo_cat_update_http_cancel(service); + if (service->worker) SDL_WaitThread(service->worker, NULL); + SDL_Event event; + while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, service->event_type, + service->event_type) > 0) {} + SDL_DestroyMutex(service->http_mutex); + SDL_DestroyMutex(service->mutex); + free(service); +} diff --git a/src/runtime/update/update_service.h b/src/runtime/update/update_service.h new file mode 100644 index 00000000..b1132b35 --- /dev/null +++ b/src/runtime/update/update_service.h @@ -0,0 +1,38 @@ +#ifndef BONGO_CAT_UPDATE_SERVICE_H +#define BONGO_CAT_UPDATE_SERVICE_H + +#include "bongo_cat/update.h" + +typedef struct BongoCatApp BongoCatApp; +typedef union SDL_Event SDL_Event; +typedef struct BongoCatUpdateService BongoCatUpdateService; + +typedef enum BongoCatUpdateStatus { + BONGO_CAT_UPDATE_IDLE, + BONGO_CAT_UPDATE_CHECKING, + BONGO_CAT_UPDATE_CURRENT, + BONGO_CAT_UPDATE_AVAILABLE, + BONGO_CAT_UPDATE_ERROR, + BONGO_CAT_UPDATE_STORE, + BONGO_CAT_UPDATE_UNSUPPORTED +} BongoCatUpdateStatus; + +typedef struct BongoCatUpdateSnapshot { + BongoCatUpdateStatus status; + BongoCatUpdateRelease release; + char error[256]; + bool installed; +} BongoCatUpdateSnapshot; + +BongoCatUpdateService *bongo_cat_update_create(BongoCatApp *app); +void bongo_cat_update_start_automatic(BongoCatUpdateService *service); +bool bongo_cat_update_check(BongoCatUpdateService *service, bool manual); +bool bongo_cat_update_event(BongoCatUpdateService *service, + const SDL_Event *event); +void bongo_cat_update_snapshot(BongoCatUpdateService *service, + BongoCatUpdateSnapshot *snapshot); +bool bongo_cat_update_open(BongoCatUpdateService *service); +bool bongo_cat_update_refresh_and_open(BongoCatUpdateService *service); +void bongo_cat_update_destroy(BongoCatUpdateService *service); + +#endif diff --git a/src/runtime/window.c b/src/runtime/window.c deleted file mode 100644 index 4760e047..00000000 --- a/src/runtime/window.c +++ /dev/null @@ -1,281 +0,0 @@ -#include "runtime.h" -#include "window_menu.h" -#include "bongo_cat/i18n.h" -#include "bongo_cat/preferences.h" - -#include -#include - -static bool set_gl_attributes(bool multisampling) { - SDL_GL_ResetAttributes(); -#ifdef __APPLE__ - const int major = 4, minor = 1, profile = SDL_GL_CONTEXT_PROFILE_CORE; -#elif defined(BONGO_CAT_HAS_CUBISM) - const int major = 3, minor = 3, profile = SDL_GL_CONTEXT_PROFILE_COMPATIBILITY; -#else - const int major = 3, minor = 3, profile = SDL_GL_CONTEXT_PROFILE_CORE; -#endif - return SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major) && - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor) && - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, profile) && - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1) && - SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0) && - SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8) && - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, multisampling ? 1 : 0) && - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, multisampling ? 4 : 0); -} - -static bool try_window(BongoCatApp *app, bool transparent, bool multisampling, - char *failure, size_t capacity) { - if (!set_gl_attributes(multisampling)) { - snprintf(failure, capacity, "OpenGL attributes: %s", SDL_GetError()); return false; - } - SDL_WindowFlags flags = SDL_WINDOW_OPENGL | SDL_WINDOW_BORDERLESS | - SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_HIDDEN; - if (transparent) flags |= SDL_WINDOW_TRANSPARENT; - app->window = SDL_CreateWindow(BONGO_CAT_PET_WINDOW_TITLE, - app->config.window.width, - app->config.window.height, flags); - if (!app->window) { - snprintf(failure, capacity, "Window creation: %s", SDL_GetError()); return false; - } - app->gl_context = SDL_GL_CreateContext(app->window); - if (!app->gl_context || !SDL_GL_MakeCurrent(app->window, app->gl_context)) { - snprintf(failure, capacity, "OpenGL context: %s", SDL_GetError()); - if (app->gl_context) SDL_GL_DestroyContext(app->gl_context); - SDL_DestroyWindow(app->window); app->gl_context = NULL; app->window = NULL; - return false; - } - return true; -} - -BongoCatResult bongo_cat_window_create(BongoCatApp *app, BongoCatError *error) { - SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); - if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "SDL initialization failed: %s", SDL_GetError()); - return BONGO_CAT_ERROR_PLATFORM; - } - const bool options[][2] = {{true, true}, {true, false}, {false, false}}; - char failure[256] = {0}; - bool force_fallback = SDL_getenv("BONGO_CAT_TEST_GL_FALLBACK") != NULL; - for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); ++i) { - if (force_fallback && i == 0) { - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Test requested OpenGL fallback"); continue; - } - if (try_window(app, options[i][0], options[i][1], failure, sizeof(failure))) { - int sample_buffers = 0, sample_count = 0; - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLEBUFFERS, &sample_buffers); - SDL_GL_GetAttribute(SDL_GL_MULTISAMPLESAMPLES, &sample_count); - SDL_Log("OpenGL window ready (transparent=%d, MSAA=%d, " - "sample_buffers=%d, sample_count=%d)", options[i][0], options[i][1], - sample_buffers, sample_count); - if (!SDL_GL_SetSwapInterval(1)) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Vertical sync unavailable: %s", SDL_GetError()); - return BONGO_CAT_OK; - } - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "OpenGL attempt %llu failed: %s", - (unsigned long long)(i + 1), failure); - } - bongo_cat_error_set(error, BONGO_CAT_ERROR_PLATFORM, - "Window and OpenGL initialization failed after compatibility retries: %s", failure); - return BONGO_CAT_ERROR_PLATFORM; -} - -void bongo_cat_window_apply(BongoCatApp *app) { - BongoCatWindowOptions *value = &app->config.window; - bongo_cat_platform_set_opacity(&app->platform, - value->opacity_percent / 100.0f); - SDL_SetWindowSize(app->window, value->width, value->height); - if (value->x || value->y) SDL_SetWindowPosition(app->window, value->x, value->y); - SDL_SyncWindow(app->window); - if (value->keep_in_screen) bongo_cat_window_clamp_to_display(app); - else bongo_cat_window_recover_to_display(app); - SDL_SyncWindow(app->window); - bongo_cat_platform_set_visible(&app->platform, value->visible); - bongo_cat_window_sync_click_through(app); - bongo_cat_platform_set_always_on_top(&app->platform, value->always_on_top); -} - -static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { - return bongo_cat_i18n_get(app->i18n, key, fallback); -} - -static void context_menu(BongoCatApp *app) { - BongoCatWindowMenuPreview preview; - bongo_cat_window_menu_preview_init(&preview, app); - const char *model_names[BONGO_CAT_MODEL_CAP]; - size_t current_model = app->models.count; - for (size_t i = 0; i < app->models.count; ++i) { - model_names[i] = app->models.entries[i].id; - if (strcmp(app->models.entries[i].id, app->config.current_model) == 0) - current_model = i; - } - const char *motion_names[BONGO_CAT_BEHAVIOR_CAP]; - const char *expression_names[BONGO_CAT_BEHAVIOR_CAP]; - size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motion_names, &motion_count, - expression_names, &expression_count, ¤t_expression); - bool dark_theme = app->config.app.theme == BONGO_CAT_THEME_DARK || - (app->config.app.theme == BONGO_CAT_THEME_AUTO && - SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK); - BongoCatMenuLabels labels = { - tr(app, "composables.useAppMenu.labels.preference", "Preferences"), - tr(app, "composables.useAppMenu.labels.hideCat", "Hide Cat"), - tr(app, "composables.useAppMenu.labels.passThrough", "Pass Through"), - tr(app, "composables.useAppMenu.labels.alwaysOnTop", "Always on top"), - tr(app, "composables.useAppMenu.labels.windowSize", "Window Size"), - tr(app, "composables.useAppMenu.labels.opacity", "Opacity"), - tr(app, "composables.useAppMenu.labels.model", "Model"), - tr(app, "composables.useAppMenu.labels.addModel", "Add Model"), - tr(app, "composables.useAppMenu.labels.quitApp", "Exit"), - tr(app, "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), - tr(app, "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), - tr(app, "composables.useAppMenu.labels.motion", "Motions"), - tr(app, "composables.useAppMenu.labels.expression", "Expressions"), - model_names, motion_names, expression_names, - app->models.count, current_model, motion_count, expression_count, - current_expression, - app->config.window.scale_percent, app->config.window.opacity_percent, - app->config.window.pass_through, app->config.window.always_on_top, - dark_theme, - bongo_cat_window_menu_preview, bongo_cat_window_menu_preview_tick, - bongo_cat_window_menu_restore, &preview}; - BongoCatMenuAction action = bongo_cat_platform_context_menu(&app->platform, &labels); - bongo_cat_window_menu_action(app, action); -} - -void bongo_cat_window_show_context_menu(BongoCatApp *app) { context_menu(app); } - -void bongo_cat_window_menu_action(BongoCatApp *app, BongoCatMenuAction action) { - if (action == BONGO_CAT_MENU_PREFERENCES) bongo_cat_preferences_show(app->preferences); - else if (action == BONGO_CAT_MENU_MODEL_ADD) - bongo_cat_preferences_open_model_import(app->preferences, app->window); - else if (action == BONGO_CAT_MENU_HIDE) - bongo_cat_window_set_visible(app, false); - else if (action == BONGO_CAT_MENU_PASS_THROUGH) { - app->config.window.pass_through = !app->config.window.pass_through; - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } else if (action == BONGO_CAT_MENU_ALWAYS_ON_TOP) { - app->config.window.always_on_top = !app->config.window.always_on_top; - bongo_cat_platform_set_always_on_top(&app->platform, app->config.window.always_on_top); - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } else if (action >= BONGO_CAT_MENU_SCALE_50 && action <= BONGO_CAT_MENU_SCALE_200) { - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_set_scale(app, - (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); - } else if (action >= BONGO_CAT_MENU_OPACITY_10 && action <= BONGO_CAT_MENU_OPACITY_100) { - bongo_cat_window_cancel_wheel_animation(app); - app->config.window.opacity_percent = (float)(10 * (action-BONGO_CAT_MENU_OPACITY_10+1)); - bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - } else if (bongo_cat_window_behavior_action(app, action)) { - bongo_cat_app_render_now(app); - } else if (action >= BONGO_CAT_MENU_MODEL_FIRST && - action < BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) { - size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); - if (index < app->models.count) - bongo_cat_app_select_model(app, app->models.entries[index].id); - } else if (action == BONGO_CAT_MENU_EXIT) app->running = false; - bongo_cat_preferences_invalidate(app->preferences); -} - -bool bongo_cat_window_menu_self_test(BongoCatApp *app) { - if (!app || !app->preferences) return false; - app->config.window.pass_through = false; - app->config.window.always_on_top = false; - app->config.window.scale_percent = 100.0f; - app->config.window.opacity_percent = 100.0f; - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PASS_THROUGH); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_ALWAYS_ON_TOP); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_SCALE_120); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_OPACITY_50); - bongo_cat_window_menu_action(app, BONGO_CAT_MENU_PREFERENCES); - bool behavior = bongo_cat_window_behavior_self_test(app); - bool result = app->config.window.pass_through && app->config.window.always_on_top && - app->config.window.scale_percent == 120.0f && - app->config.window.opacity_percent == 50.0f && - bongo_cat_preferences_visible(app->preferences) && behavior; - bongo_cat_preferences_close(app->preferences); - return result; -} - -bool bongo_cat_window_event(BongoCatApp *app, const SDL_Event *event) { - bongo_cat_window_display_event(app, event); - if (event->type >= SDL_EVENT_WINDOW_FIRST && event->type <= SDL_EVENT_WINDOW_LAST && - event->window.windowID != SDL_GetWindowID(app->window)) return true; - if (event->type == SDL_EVENT_QUIT) return false; - if (event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { - bongo_cat_window_set_visible(app, false); - return true; - } - if (event->type == SDL_EVENT_WINDOW_HIDDEN) { - bongo_cat_window_drag_end(app); - } - if (event->type == SDL_EVENT_WINDOW_MINIMIZED) { - app->window_minimized = true; - bongo_cat_window_drag_end(app); - } - if (event->type == SDL_EVENT_WINDOW_RESIZED) { - app->config.window.width = event->window.data1; - app->config.window.height = event->window.data2; - bongo_cat_window_clamp_to_display(app); - app->dirty = true; - } - if (event->type == SDL_EVENT_WINDOW_EXPOSED || - event->type == SDL_EVENT_WINDOW_SHOWN || - event->type == SDL_EVENT_WINDOW_RESTORED) { - if (event->type != SDL_EVENT_WINDOW_EXPOSED) - app->window_minimized = false; - bongo_cat_app_reset_pointer_tracking(app); - } - if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED || - event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { - bongo_cat_app_reset_pointer_tracking(app); - } - if (event->type == SDL_EVENT_WINDOW_RESIZED || - event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) { - SDL_GetWindowSizeInPixels(app->window, - &app->resize_pixel_width, &app->resize_pixel_height); - app->resize_pending = true; - bongo_cat_window_mark_hit_dirty(app); - } else if (event->type == SDL_EVENT_WINDOW_MOVED) { - app->config.window.x = event->window.data1; - app->config.window.y = event->window.data2; - app->pointer_known = false; - if (!app->window_drag_active) bongo_cat_window_clamp_to_display(app); - bongo_cat_window_mark_hit_dirty(app); - } else if (event->type == SDL_EVENT_WINDOW_DISPLAY_CHANGED || - event->type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED) { - bongo_cat_app_reset_pointer_tracking(app); - } else if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && - event->button.button == SDL_BUTTON_LEFT) { - bongo_cat_window_drag_begin(app, &event->button); - } else if (event->type == SDL_EVENT_MOUSE_MOTION) { - bongo_cat_window_resize_by_pointer(app, event); - bongo_cat_window_drag_motion(app, &event->motion); - } else if (event->type == SDL_EVENT_MOUSE_WHEEL) { - bongo_cat_window_wheel(app, &event->wheel); - } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && - event->button.button == SDL_BUTTON_LEFT) { - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_drag_end(app); - } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && - event->button.button == SDL_BUTTON_RIGHT) { - bongo_cat_window_mark_hit_dirty(app); - if (app->resize_gesture) app->resize_gesture = false; - else context_menu(app); - } - return true; -} - -void bongo_cat_window_destroy(BongoCatApp *app) { - bongo_cat_window_drag_end(app); - if (app->gl_context) SDL_GL_DestroyContext(app->gl_context); - if (app->window) SDL_DestroyWindow(app->window); - app->gl_context = NULL; - app->window = NULL; - SDL_Quit(); -} diff --git a/src/runtime/window_menu_behavior.c b/src/runtime/window_menu_behavior.c deleted file mode 100644 index 33c2cf9e..00000000 --- a/src/runtime/window_menu_behavior.c +++ /dev/null @@ -1,148 +0,0 @@ -#include "window_menu.h" - -#include - -static const BongoCatBehaviorEntry *nth_behavior(BongoCatApp *app, - BongoCatBehaviorKind kind, size_t position) { - if (!app) return NULL; - for (size_t i = 0; i < app->behaviors.count; ++i) { - const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; - if (entry->kind != kind) continue; - if (!position) return entry; - position--; - } - return NULL; -} - -static const char *behavior_shortcut(const BongoCatApp *app, const char *id) { - if (!app || !id) return NULL; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *binding = &app->config.behavior_shortcuts[i]; - if (strcmp(binding->id, id) == 0) return binding->shortcut; - } - return NULL; -} - -static const char *behavior_label(const BongoCatApp *app, - const BongoCatBehaviorEntry *entry) { - if (!app || !entry) return ""; - for (size_t i = 0; i < app->config.behavior_shortcut_count; ++i) { - const BongoCatBehaviorShortcut *binding = &app->config.behavior_shortcuts[i]; - if (strcmp(binding->id, entry->id) == 0 && binding->label[0]) - return binding->label; - } - return entry->label; -} - -static bool run_binding(BongoCatApp *app, const BongoCatBehaviorEntry *selected) { - const char *shortcut = behavior_shortcut(app, selected->id); - bool handled = bongo_cat_app_run_behavior(app, selected); - if (!shortcut || !shortcut[0]) return handled; - for (size_t i = 0; i < app->behaviors.count; ++i) { - const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; - const char *bound = behavior_shortcut(app, entry->id); - if (entry != selected && bound && strcmp(bound, shortcut) == 0) - handled = bongo_cat_app_run_behavior(app, entry) || handled; - } - return handled; -} - -void bongo_cat_window_behavior_labels(BongoCatApp *app, - const char **motions, size_t *motion_count, - const char **expressions, size_t *expression_count, - size_t *current_expression) { - if (!motion_count || !expression_count) return; - *motion_count = 0; *expression_count = 0; - if (current_expression) *current_expression = BONGO_CAT_BEHAVIOR_CAP; - if (!app) return; - int active_expression = bongo_cat_live2d_expression(app->live2d); - for (size_t i = 0; i < app->behaviors.count; ++i) { - const BongoCatBehaviorEntry *entry = &app->behaviors.entries[i]; - if (entry->kind == BONGO_CAT_BEHAVIOR_MOTION && motions) - motions[(*motion_count)++] = behavior_label(app, entry); - else if (entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION && expressions) { - if (current_expression && entry->index == active_expression) - *current_expression = *expression_count; - expressions[(*expression_count)++] = behavior_label(app, entry); - } - } -} - -bool bongo_cat_window_behavior_action(BongoCatApp *app, - BongoCatMenuAction action) { - BongoCatBehaviorKind kind; - size_t position; - if (action >= BONGO_CAT_MENU_MOTION_FIRST && - action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { - kind = BONGO_CAT_BEHAVIOR_MOTION; - position = (size_t)(action - BONGO_CAT_MENU_MOTION_FIRST); - } else if (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && - action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP) { - kind = BONGO_CAT_BEHAVIOR_EXPRESSION; - position = (size_t)(action - BONGO_CAT_MENU_EXPRESSION_FIRST); - } else return false; - const BongoCatBehaviorEntry *entry = nth_behavior(app, kind, position); - return entry && run_binding(app, entry); -} - -bool bongo_cat_window_behavior_menu_action(BongoCatMenuAction action) { - return (action >= BONGO_CAT_MENU_MOTION_FIRST && - action < BONGO_CAT_MENU_MOTION_FIRST + BONGO_CAT_BEHAVIOR_CAP) || - (action >= BONGO_CAT_MENU_EXPRESSION_FIRST && - action < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); -} - -static bool expression_self_test(BongoCatApp *app) { - size_t count = 0; - while (nth_behavior(app, BONGO_CAT_BEHAVIOR_EXPRESSION, count)) count++; - if (!count) return true; - int original = bongo_cat_live2d_expression(app->live2d); - size_t selected_position = count > 2 ? 2 : count - 1; - const BongoCatBehaviorEntry *selected = nth_behavior(app, - BONGO_CAT_BEHAVIOR_EXPRESSION, selected_position); - bool passed = selected && bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_EXPRESSION_FIRST + selected_position) && - bongo_cat_live2d_expression(app->live2d) == selected->index; - - const char *motions[BONGO_CAT_BEHAVIOR_CAP]; - const char *expressions[BONGO_CAT_BEHAVIOR_CAP]; - size_t motion_count, expression_count, current_expression; - bongo_cat_window_behavior_labels(app, motions, &motion_count, - expressions, &expression_count, ¤t_expression); - passed = passed && expression_count == count && - current_expression == selected_position; - - if (passed && count > 1) { - size_t alternate_position = selected_position ? 0 : 1; - const BongoCatBehaviorEntry *alternate = nth_behavior(app, - BONGO_CAT_BEHAVIOR_EXPRESSION, alternate_position); - BongoCatWindowMenuPreview preview; - bongo_cat_window_menu_preview_init(&preview, app); - bongo_cat_window_menu_preview(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - passed = alternate && bongo_cat_live2d_expression(app->live2d) == - alternate->index; - bongo_cat_window_menu_restore(&preview, BONGO_CAT_MENU_NONE); - passed = passed && bongo_cat_live2d_expression(app->live2d) == - selected->index; - - bongo_cat_window_menu_preview_init(&preview, app); - bongo_cat_window_menu_preview(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - bongo_cat_window_menu_restore(&preview, - BONGO_CAT_MENU_EXPRESSION_FIRST + alternate_position); - passed = passed && bongo_cat_live2d_expression(app->live2d) == - alternate->index; - } - return bongo_cat_live2d_set_expression(app->live2d, original) && passed; -} - -bool bongo_cat_window_behavior_self_test(BongoCatApp *app) { - if (!app) return false; - bool passed = true; - if (nth_behavior(app, BONGO_CAT_BEHAVIOR_MOTION, 0)) - passed = bongo_cat_window_behavior_action(app, - BONGO_CAT_MENU_MOTION_FIRST) && passed; - passed = expression_self_test(app) && passed; - return passed; -} diff --git a/src/runtime/window_menu_preview.c b/src/runtime/window_menu_preview.c deleted file mode 100644 index 0ba96482..00000000 --- a/src/runtime/window_menu_preview.c +++ /dev/null @@ -1,120 +0,0 @@ -#include "window_menu.h" - -#include -#include - -static const BongoCatModelEntry *menu_model(const BongoCatApp *app, - BongoCatMenuAction action) { - if (!app || action < BONGO_CAT_MENU_MODEL_FIRST || - action >= BONGO_CAT_MENU_MODEL_FIRST + BONGO_CAT_MODEL_CAP) return NULL; - size_t index = (size_t)(action - BONGO_CAT_MENU_MODEL_FIRST); - return index < app->models.count ? &app->models.entries[index] : NULL; -} - -static bool previewable(const BongoCatApp *app, BongoCatMenuAction action) { - return (action >= BONGO_CAT_MENU_SCALE_50 && - action <= BONGO_CAT_MENU_OPACITY_100) || - menu_model(app, action) != NULL || - bongo_cat_window_behavior_menu_action(action); -} - -static int preview_group(const BongoCatApp *app, BongoCatMenuAction action) { - if (action >= BONGO_CAT_MENU_SCALE_50 && - action <= BONGO_CAT_MENU_SCALE_200) return 1; - if (action >= BONGO_CAT_MENU_OPACITY_10 && - action <= BONGO_CAT_MENU_OPACITY_100) return 2; - if (menu_model(app, action)) return 3; - if (bongo_cat_window_behavior_menu_action(action)) return 4; - return 0; -} - -void bongo_cat_window_menu_preview_init(BongoCatWindowMenuPreview *state, - BongoCatApp *app) { - if (!state) return; - *state = (BongoCatWindowMenuPreview){.app = app, - .scale = app ? app->config.window.scale_percent : 100.0f, - .opacity = app ? app->config.window.opacity_percent : 100.0f, - .expression = app ? bongo_cat_live2d_expression(app->live2d) : -1, - .last = BONGO_CAT_MENU_NONE}; - bongo_cat_modal_frame_init(&state->modal_frame, app); - if (app) snprintf(state->model, sizeof(state->model), "%.*s", - (int)sizeof(state->model) - 1, app->config.current_model); -} - -void bongo_cat_window_menu_preview(void *userdata, BongoCatMenuAction action) { - BongoCatWindowMenuPreview *state = userdata; - if (!state || !state->app || action == state->last) return; - BongoCatApp *app = state->app; - int previous_group = preview_group(app, state->last); - int next_group = preview_group(app, action); - if (state->last != BONGO_CAT_MENU_NONE && previous_group != next_group) - bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); - if (!previewable(app, action)) { - if (previous_group == 0) - bongo_cat_window_menu_restore(state, BONGO_CAT_MENU_NONE); - state->last = action; - return; - } - state->last = action; - const BongoCatModelEntry *model = menu_model(app, action); - if (model) bongo_cat_app_select_model(app, model->id); - else if (action >= BONGO_CAT_MENU_SCALE_50 && - action <= BONGO_CAT_MENU_SCALE_200) - bongo_cat_window_set_scale(app, - (float)(50 + 10 * (action - BONGO_CAT_MENU_SCALE_50))); - else if (action >= BONGO_CAT_MENU_OPACITY_10 && - action <= BONGO_CAT_MENU_OPACITY_100) { - app->config.window.opacity_percent = - (float)(10 * (action - BONGO_CAT_MENU_OPACITY_10 + 1)); - bongo_cat_platform_set_opacity(&app->platform, - app->config.window.opacity_percent / 100.0f); - } else if (bongo_cat_window_behavior_action(app, action)) { - bongo_cat_modal_frame_tick(&state->modal_frame); - return; - } - bongo_cat_app_render_now(app); -} - -void bongo_cat_window_menu_preview_tick(void *userdata) { - BongoCatWindowMenuPreview *state = userdata; - if (state) bongo_cat_modal_frame_tick(&state->modal_frame); -} - -void bongo_cat_window_menu_restore(void *userdata, BongoCatMenuAction selected) { - BongoCatWindowMenuPreview *state = userdata; - if (!state || !state->app) return; - BongoCatApp *app = state->app; - bool changed = false; - bool keep_model = menu_model(app, selected) != NULL; - bool keep_scale = selected >= BONGO_CAT_MENU_SCALE_50 && - selected <= BONGO_CAT_MENU_SCALE_200; - bool keep_opacity = selected >= BONGO_CAT_MENU_OPACITY_10 && - selected <= BONGO_CAT_MENU_OPACITY_100; - /* Expressions belong to the model active when the menu opened. */ - bool keep_expression = keep_model || - (selected >= BONGO_CAT_MENU_EXPRESSION_FIRST && - selected < BONGO_CAT_MENU_EXPRESSION_FIRST + BONGO_CAT_BEHAVIOR_CAP); - if (!keep_model && strcmp(app->config.current_model, state->model) != 0) { - bongo_cat_app_select_model(app, state->model); - changed = true; - } - if (!keep_scale && - SDL_fabsf(app->config.window.scale_percent - state->scale) > .01f) { - bongo_cat_window_set_scale(app, state->scale); - changed = true; - } - if (!keep_opacity && - SDL_fabsf(app->config.window.opacity_percent - state->opacity) > .01f) { - app->config.window.opacity_percent = state->opacity; - bongo_cat_platform_set_opacity(&app->platform, - state->opacity / 100.0f); - changed = true; - } - if (!keep_expression && - bongo_cat_live2d_expression(app->live2d) != state->expression && - bongo_cat_live2d_set_expression(app->live2d, state->expression)) { - bongo_cat_app_step_live2d(app, 1.0f / 60.0f); - changed = true; - } - if (changed) bongo_cat_app_render_now(app); -} diff --git a/src/runtime/window_wheel.c b/src/runtime/window_wheel.c deleted file mode 100644 index 5f3ed068..00000000 --- a/src/runtime/window_wheel.c +++ /dev/null @@ -1,260 +0,0 @@ -#include "runtime.h" -#include "bongo_cat/preferences.h" -#include - -#define WHEEL_OPACITY_STEP 5.0f -#define WHEEL_SCALE_STEP 5.0f -#define WHEEL_SCALE_TARGET_LEAD 10.0f -#define WHEEL_OPACITY_RESPONSE_SECONDS 0.05f -#define WHEEL_SCALE_RESPONSE_SECONDS 0.01f -#define WHEEL_OPACITY_SPEED_PER_SECOND 100.0f -#define WHEEL_SCALE_SPEED_PER_SECOND 150.0f -#define WHEEL_MAX_FRAME_SECONDS (1.0f / 60.0f) -#define WHEEL_GESTURE_IDLE_NS 120000000ull - -static float wheel_delta(const SDL_MouseWheelEvent *event) { - float value = event ? event->y : 0.0f; - return event && event->direction == SDL_MOUSEWHEEL_FLIPPED ? -value : value; -} - -static bool initialize_targets(BongoCatApp *app, bool reset) { - if (!reset) return true; - int x = 0, y = 0, width = 0, height = 0; - if (!SDL_GetWindowPosition(app->window, &x, &y) || - !SDL_GetWindowSize(app->window, &width, &height) || - width <= 0 || height <= 0 || app->config.window.scale_percent <= 0.0f) - return false; - app->wheel_opacity_target = app->config.window.opacity_percent; - app->wheel_scale_target = app->config.window.scale_percent; - app->wheel_center_x = x + width * 0.5f; - app->wheel_center_y = y + height * 0.5f; - app->wheel_geometry_scale = app->config.window.scale_percent; - app->wheel_base_width = width; - app->wheel_base_height = height; - app->wheel_animation_ns = SDL_GetTicksNS(); - app->wheel_input_ns = app->wheel_animation_ns; - return true; -} - -static int round_position(float value) { - return (int)(value + (value < 0.0f ? -0.5f : 0.5f)); -} - -static void clamp_position(BongoCatApp *app, int width, int height, int *x, int *y) { - if (!app->config.window.keep_in_screen) return; - SDL_DisplayID display = SDL_GetDisplayForWindow(app->window); - SDL_Rect bounds; - if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return; - int max_x = SDL_max(bounds.x, bounds.x + bounds.w - width); - int max_y = SDL_max(bounds.y, bounds.y + bounds.h - height); - *x = SDL_clamp(*x, bounds.x, max_x); - *y = SDL_clamp(*y, bounds.y, max_y); -} - -void bongo_cat_window_wheel(BongoCatApp *app, const SDL_MouseWheelEvent *event) { - if (!app || !app->window || !event) return; - float delta = wheel_delta(event); - if (SDL_fabsf(delta) < 0.001f) return; - delta = SDL_clamp(delta, -1.0f, 1.0f); - uint64_t event_ns = event->timestamp ? event->timestamp : SDL_GetTicksNS(); - bool continuing = app->wheel_gesture_active && event_ns >= app->wheel_event_ns && - event_ns - app->wheel_event_ns < WHEEL_GESTURE_IDLE_NS; - if (!initialize_targets(app, !continuing)) return; - app->wheel_event_ns = event_ns; - app->wheel_gesture_active = true; - float old_opacity_target = app->wheel_opacity_target; - float old_scale_target = app->wheel_scale_target; - bool control = (SDL_GetModState() & SDL_KMOD_CTRL) != 0 || - bongo_cat_input_control_down(&app->input); - if (!control) { - float minimum = SDL_max(10.0f, - app->config.window.scale_percent - WHEEL_SCALE_TARGET_LEAD); - float maximum = SDL_min(500.0f, - app->config.window.scale_percent + WHEEL_SCALE_TARGET_LEAD); - app->wheel_scale_target = SDL_clamp(app->wheel_scale_target + - delta * WHEEL_SCALE_STEP, minimum, maximum); - } else { - app->wheel_opacity_target = SDL_clamp(app->wheel_opacity_target + - delta * WHEEL_OPACITY_STEP, 10.0f, 100.0f); - } - uint64_t input_ns = SDL_GetTicksNS(); - if (old_opacity_target == app->wheel_opacity_target && - old_scale_target == app->wheel_scale_target) { - if (app->wheel_animation_active) app->wheel_input_ns = input_ns; - return; - } - if (!app->wheel_animation_active) app->wheel_animation_ns = input_ns; - app->wheel_input_ns = input_ns; - app->wheel_animation_active = true; - app->dirty = true; -} - -static float approach(float current, float target, float elapsed_seconds, - float response_seconds, float speed_per_second, float snap_distance) { - float difference = target - current; - if (SDL_fabsf(difference) < snap_distance) return target; - float alpha = elapsed_seconds / (response_seconds + elapsed_seconds); - float step = difference * alpha; - float maximum = speed_per_second * elapsed_seconds; - step = SDL_clamp(step, -maximum, maximum); - return SDL_fabsf(step) >= SDL_fabsf(difference) ? target : current + step; -} - -static void apply_scale(BongoCatApp *app, float scale) { - float old = app->config.window.scale_percent; - if (old <= 0.0f || SDL_fabsf(scale - old) < 0.001f) return; - float actual; - int next_width, next_height; - if (!bongo_cat_window_scaled_size(app->wheel_base_width, - app->wheel_base_height, app->wheel_geometry_scale, - scale, &actual, &next_width, &next_height)) return; - if (actual != scale) app->wheel_scale_target = actual; - if (next_width == app->config.window.width && - next_height == app->config.window.height) { - app->config.window.scale_percent = actual; - return; - } - int next_x = round_position(app->wheel_center_x - next_width * 0.5f); - int next_y = round_position(app->wheel_center_y - next_height * 0.5f); - clamp_position(app, next_width, next_height, &next_x, &next_y); - if (!bongo_cat_window_apply_geometry(app, next_x, next_y, - actual, next_width, next_height)) { - app->wheel_scale_target = old; - app->wheel_animation_active = false; - app->wheel_gesture_active = false; - } -} - -void bongo_cat_window_update_wheel_animation(BongoCatApp *app, uint64_t now) { - if (!app || !app->wheel_animation_active) return; - uint64_t elapsed_ns = now >= app->wheel_animation_ns - ? now - app->wheel_animation_ns : 0; - app->wheel_animation_ns = now; - float elapsed_seconds = SDL_min((float)elapsed_ns / 1000000000.0f, - WHEEL_MAX_FRAME_SECONDS); - float opacity = approach(app->config.window.opacity_percent, - app->wheel_opacity_target, elapsed_seconds, - WHEEL_OPACITY_RESPONSE_SECONDS, WHEEL_OPACITY_SPEED_PER_SECOND, 0.01f); - float scale = approach(app->config.window.scale_percent, - app->wheel_scale_target, elapsed_seconds, - WHEEL_SCALE_RESPONSE_SECONDS, WHEEL_SCALE_SPEED_PER_SECOND, 0.5f); - bool changed = SDL_fabsf(opacity - app->config.window.opacity_percent) > 0.001f || - SDL_fabsf(scale - app->config.window.scale_percent) > 0.001f; - if (SDL_fabsf(opacity - app->config.window.opacity_percent) > 0.001f) { - app->config.window.opacity_percent = opacity; - if (!app->hover_hidden) bongo_cat_platform_set_opacity( - &app->platform, opacity / 100.0f); - } - apply_scale(app, scale); - bool reached = - SDL_fabsf(app->config.window.opacity_percent - app->wheel_opacity_target) < 0.01f && - SDL_fabsf(app->config.window.scale_percent - app->wheel_scale_target) < 0.01f; - bool recent_input = now >= app->wheel_input_ns && - now - app->wheel_input_ns < WHEEL_GESTURE_IDLE_NS; - app->wheel_animation_active = !reached || recent_input; - if (changed || !app->wheel_animation_active) app->dirty = true; - if (changed) bongo_cat_preferences_invalidate(app->preferences); -} - -void bongo_cat_window_cancel_wheel_animation(BongoCatApp *app) { - if (!app) return; - app->wheel_animation_active = false; - app->wheel_gesture_active = false; -} - -bool bongo_cat_window_wheel_self_test(BongoCatApp *app) { - if (!app || !app->window) return false; - BongoCatWindowOptions backup = app->config.window; - int original_x, original_y, original_width, original_height; - SDL_GetWindowPosition(app->window, &original_x, &original_y); - SDL_GetWindowSize(app->window, &original_width, &original_height); - SDL_Keymod modifiers = SDL_GetModState(); - SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->config.window.opacity_percent = 80.0f; - app->config.window.scale_percent = 100.0f; - bongo_cat_window_cancel_wheel_animation(app); - SDL_MouseWheelEvent wheel = {.y = -1.0f}; - bongo_cat_window_wheel(app, &wheel); - uint64_t started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool opacity = SDL_fabsf(app->config.window.opacity_percent - 75.0f) < 0.1f; - SDL_SetModState(modifiers & ~SDL_KMOD_CTRL); - wheel.y = 1.0f; bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 8; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool responsive = SDL_fabsf(app->config.window.scale_percent - 105.0f) < 0.1f; - bongo_cat_window_update_wheel_animation(app, - app->wheel_input_ns + WHEEL_GESTURE_IDLE_NS + 1); - bool scale = responsive && !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 1000.0f; - for (int i = 0; i < 2; ++i) bongo_cat_window_wheel(app, &wheel); - bool burst = app->wheel_scale_target >= 109.5f && - app->wheel_scale_target <= 110.1f; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 3.0f; - bongo_cat_window_wheel(app, &wheel); - bool aggregated = app->wheel_scale_target >= 104.5f && - app->wheel_scale_target <= 105.1f; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_apply_geometry(app, original_x, original_y, 100.0f, - original_width, original_height); - wheel.y = 1.0f; - bongo_cat_window_wheel(app, &wheel); - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool reversal = !app->wheel_animation_active && - SDL_fabsf(app->config.window.scale_percent - 100.0f) < 0.1f; - wheel.y = 1.0f; - wheel.direction = SDL_MOUSEWHEEL_FLIPPED; - bongo_cat_window_wheel(app, &wheel); - started = app->wheel_animation_ns; - for (int i = 1; i <= 30; ++i) - bongo_cat_window_update_wheel_animation(app, started + i * 16666667ull); - bool flipped = SDL_fabsf(app->config.window.scale_percent - 95.0f) < 0.1f; - wheel.direction = SDL_MOUSEWHEEL_NORMAL; - bongo_cat_window_cancel_wheel_animation(app); - app->config.window.scale_percent = 500.0f; - bongo_cat_window_wheel(app, &wheel); - bool maximum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - app->config.window.scale_percent = 10.0f; - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - bool minimum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - SDL_SetModState(modifiers | SDL_KMOD_CTRL); - app->config.window.opacity_percent = 100.0f; - wheel.y = 1.0f; - bongo_cat_window_wheel(app, &wheel); - bool opacity_maximum = !app->wheel_animation_active; - bongo_cat_window_cancel_wheel_animation(app); - app->config.window.opacity_percent = 10.0f; - wheel.y = -1.0f; - bongo_cat_window_wheel(app, &wheel); - bool opacity_minimum = !app->wheel_animation_active; - bool rounding = round_position(-10.6f) == -11 && round_position(10.6f) == 11; - SDL_SetModState(modifiers); - app->config.window = backup; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_platform_set_opacity(&app->platform, - backup.opacity_percent / 100.0f); - bongo_cat_platform_set_geometry(&app->platform, original_x, original_y, - original_width, original_height); - bool passed = opacity && scale && burst && aggregated && reversal && flipped && - maximum && minimum && opacity_maximum && opacity_minimum && rounding; - if (!passed) fprintf(stderr, "wheel self-test: opacity=%d scale=%d burst=%d " - "aggregated=%d reversal=%d flipped=%d maximum=%d minimum=%d opacity_max=%d " - "opacity_min=%d rounding=%d\n", opacity, scale, burst, aggregated, reversal, - flipped, maximum, minimum, opacity_maximum, opacity_minimum, rounding); - return passed; -} diff --git a/src/tools/asset_packer.c b/src/tools/asset_packer.c index 9d4f5a02..b8acf3df 100644 --- a/src/tools/asset_packer.c +++ b/src/tools/asset_packer.c @@ -93,13 +93,6 @@ static int read_file(const wchar_t *path, unsigned char **data, size_t *size) { return ok; } -static int excluded_path(const char *path) { - return strcmp(path, "assets/logo-mac.png") == 0 || - strcmp(path, "assets/ui-icons.png") == 0 || - strcmp(path, "assets/ui-symbols-1x.png") == 0 || - strcmp(path, "assets/ui-symbols-4x.png") == 0; -} - static int append_asset(AssetList *list, const wchar_t *file, const char *relative) { Asset *asset; @@ -113,7 +106,6 @@ static int append_asset(AssetList *list, const wchar_t *file, if (!path) { free(data); return 0; } memcpy(path, "assets/", 7); memcpy(path + 7, relative, relative_length + 1); - if (excluded_path(path)) { free(path); free(data); return 1; } if (list->count == list->capacity && !reserve_assets(list)) { free(path); free(data); return 0; } diff --git a/src/tools/cubism_viewer_blind_frames.c b/src/tools/cubism_viewer_blind_frames.c new file mode 100644 index 00000000..eed2fb66 --- /dev/null +++ b/src/tools/cubism_viewer_blind_frames.c @@ -0,0 +1,114 @@ +#define WIN32_LEAN_AND_MEAN +#include + +#include "cubism_viewer_blind_frames.h" + +#include +#include + +wchar_t *blind_join_path(const wchar_t *directory, const wchar_t *name) { + size_t a = wcslen(directory), b = wcslen(name); + wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); + if (!result) return NULL; + memcpy(result, directory, a * sizeof(*result)); + result[a] = L'\\'; + memcpy(result + a + 1, name, (b + 1) * sizeof(*result)); + return result; +} + +wchar_t *blind_join_suffix(const wchar_t *directory, const wchar_t *name, + const wchar_t *suffix) { + size_t a = wcslen(directory), b = wcslen(name), c = wcslen(suffix); + wchar_t *result = (wchar_t *)malloc((a + b + c + 3) * sizeof(*result)); + if (!result) return NULL; + swprintf(result, a + b + c + 3, L"%ls\\%ls%ls", directory, name, suffix); + return result; +} + +static int reserve_frames(FrameList *list) { + size_t capacity = list->capacity ? list->capacity * 2 : 32; + Frame *items; + if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; + items = (Frame *)realloc(list->items, capacity * sizeof(*items)); + if (!items) return 0; + list->items = items; list->capacity = capacity; + return 1; +} + +void blind_free_frames(FrameList *list) { + size_t index; + for (index = 0; index < list->count; ++index) { + free(list->items[index].name); + free(list->items[index].viewer_path); + free(list->items[index].native_path); + bongo_cat_validation_image_free(&list->items[index].viewer); + bongo_cat_validation_image_free(&list->items[index].native); + } + free(list->items); + memset(list, 0, sizeof(*list)); +} + +static wchar_t *without_extension(const wchar_t *name) { + const wchar_t *dot = wcsrchr(name, L'.'); + size_t length = dot ? (size_t)(dot - name) : wcslen(name); + wchar_t *result = (wchar_t *)malloc((length + 1) * sizeof(*result)); + if (result) { memcpy(result, name, length * sizeof(*result)); result[length] = L'\0'; } + return result; +} + +static wchar_t *find_native(const wchar_t *directory, const wchar_t *name) { + static const wchar_t *extensions[] = {L".bmp", L".png"}; + size_t index; + for (index = 0; index < sizeof(extensions) / sizeof(extensions[0]); ++index) { + wchar_t *path = blind_join_suffix(directory, name, extensions[index]); + if (!path) return NULL; + if (_waccess(path, 0) == 0) return path; + free(path); + } + return NULL; +} + +static int compare_frames(const void *left, const void *right) { + const Frame *a = (const Frame *)left, *b = (const Frame *)right; + return wcscmp(a->name, b->name); +} + +int blind_collect_frames(const wchar_t *viewer_directory, + const wchar_t *native_directory, FrameList *list) { + WIN32_FIND_DATAW entry; + HANDLE find; + wchar_t *pattern; + static const wchar_t *extensions[] = {L"*.png", L"*.bmp"}; + size_t extension; + for (extension = 0; extension < sizeof(extensions) / sizeof(extensions[0]); ++extension) { + pattern = blind_join_path(viewer_directory, extensions[extension]); + if (!pattern) return 0; + find = FindFirstFileW(pattern, &entry); + free(pattern); + if (find == INVALID_HANDLE_VALUE) continue; + do { + wchar_t *name, *viewer_path, *native_path; + Frame *frame; + if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + name = without_extension(entry.cFileName); + viewer_path = blind_join_path(viewer_directory, entry.cFileName); + native_path = name ? find_native(native_directory, name) : NULL; + if (!name || !viewer_path) { + free(name); free(viewer_path); free(native_path); + FindClose(find); return 0; + } + if (!native_path) { free(name); free(viewer_path); continue; } + if (list->count == list->capacity && !reserve_frames(list)) { + free(name); free(viewer_path); free(native_path); + FindClose(find); return 0; + } + frame = &list->items[list->count++]; + memset(frame, 0, sizeof(*frame)); + frame->name = name; frame->viewer_path = viewer_path; + frame->native_path = native_path; + } while (FindNextFileW(find, &entry)); + FindClose(find); + } + qsort(list->items, list->count, sizeof(*list->items), compare_frames); + return list->count != 0; +} diff --git a/src/tools/cubism_viewer_blind_frames.h b/src/tools/cubism_viewer_blind_frames.h new file mode 100644 index 00000000..c83ffb09 --- /dev/null +++ b/src/tools/cubism_viewer_blind_frames.h @@ -0,0 +1,30 @@ +#ifndef BONGO_CAT_CUBISM_VIEWER_BLIND_FRAMES_H +#define BONGO_CAT_CUBISM_VIEWER_BLIND_FRAMES_H + +#include "validation_image.h" + +#include +#include + +typedef struct Frame { + wchar_t *name; + wchar_t *viewer_path; + wchar_t *native_path; + BongoCatValidationImage viewer; + BongoCatValidationImage native; +} Frame; + +typedef struct FrameList { + Frame *items; + size_t count; + size_t capacity; +} FrameList; + +wchar_t *blind_join_path(const wchar_t *directory, const wchar_t *name); +wchar_t *blind_join_suffix(const wchar_t *directory, const wchar_t *name, + const wchar_t *suffix); +void blind_free_frames(FrameList *list); +int blind_collect_frames(const wchar_t *viewer_directory, + const wchar_t *native_directory, FrameList *list); + +#endif diff --git a/src/tools/cubism_viewer_blind_test.c b/src/tools/cubism_viewer_blind_test.c index 7c2e8d04..f9d23df9 100644 --- a/src/tools/cubism_viewer_blind_test.c +++ b/src/tools/cubism_viewer_blind_test.c @@ -2,10 +2,10 @@ #include #include +#include "cubism_viewer_blind_frames.h" #include "validation_image.h" #include "windows_tool.h" -#include #include #include #include @@ -16,20 +16,6 @@ enum { CANVAS = 800, MARGIN = 40 }; -typedef struct Frame { - wchar_t *name; - wchar_t *viewer_path; - wchar_t *native_path; - BongoCatValidationImage viewer; - BongoCatValidationImage native; -} Frame; - -typedef struct FrameList { - Frame *items; - size_t count; - size_t capacity; -} FrameList; - typedef struct Region { const char *name; int left, top, right, bottom; @@ -41,113 +27,6 @@ typedef struct DotNetRandom { int inextp; } DotNetRandom; -static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { - size_t a = wcslen(directory), b = wcslen(name); - wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); - if (!result) return NULL; - memcpy(result, directory, a * sizeof(*result)); - result[a] = L'\\'; - memcpy(result + a + 1, name, (b + 1) * sizeof(*result)); - return result; -} - -static wchar_t *join_suffix(const wchar_t *directory, const wchar_t *name, - const wchar_t *suffix) { - size_t a = wcslen(directory), b = wcslen(name), c = wcslen(suffix); - wchar_t *result = (wchar_t *)malloc((a + b + c + 3) * sizeof(*result)); - if (!result) return NULL; - swprintf(result, a + b + c + 3, L"%ls\\%ls%ls", directory, name, suffix); - return result; -} - -static int reserve_frames(FrameList *list) { - size_t capacity = list->capacity ? list->capacity * 2 : 32; - Frame *items; - if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; - items = (Frame *)realloc(list->items, capacity * sizeof(*items)); - if (!items) return 0; - list->items = items; list->capacity = capacity; - return 1; -} - -static void free_frames(FrameList *list) { - size_t index; - for (index = 0; index < list->count; ++index) { - free(list->items[index].name); - free(list->items[index].viewer_path); - free(list->items[index].native_path); - bongo_cat_validation_image_free(&list->items[index].viewer); - bongo_cat_validation_image_free(&list->items[index].native); - } - free(list->items); - memset(list, 0, sizeof(*list)); -} - -static wchar_t *without_extension(const wchar_t *name) { - const wchar_t *dot = wcsrchr(name, L'.'); - size_t length = dot ? (size_t)(dot - name) : wcslen(name); - wchar_t *result = (wchar_t *)malloc((length + 1) * sizeof(*result)); - if (result) { memcpy(result, name, length * sizeof(*result)); result[length] = L'\0'; } - return result; -} - -static wchar_t *find_native(const wchar_t *directory, const wchar_t *name) { - static const wchar_t *extensions[] = {L".bmp", L".png"}; - size_t index; - for (index = 0; index < sizeof(extensions) / sizeof(extensions[0]); ++index) { - wchar_t *path = join_suffix(directory, name, extensions[index]); - if (!path) return NULL; - if (_waccess(path, 0) == 0) return path; - free(path); - } - return NULL; -} - -static int compare_frames(const void *left, const void *right) { - const Frame *a = (const Frame *)left, *b = (const Frame *)right; - return wcscmp(a->name, b->name); -} - -static int collect_frames(const wchar_t *viewer_directory, - const wchar_t *native_directory, FrameList *list) { - WIN32_FIND_DATAW entry; - HANDLE find; - wchar_t *pattern; - static const wchar_t *extensions[] = {L"*.png", L"*.bmp"}; - size_t extension; - for (extension = 0; extension < sizeof(extensions) / sizeof(extensions[0]); ++extension) { - pattern = join_path(viewer_directory, extensions[extension]); - if (!pattern) return 0; - find = FindFirstFileW(pattern, &entry); - free(pattern); - if (find == INVALID_HANDLE_VALUE) continue; - do { - wchar_t *name, *viewer_path, *native_path; - Frame *frame; - if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - name = without_extension(entry.cFileName); - viewer_path = join_path(viewer_directory, entry.cFileName); - native_path = name ? find_native(native_directory, name) : NULL; - if (!name || !viewer_path) { - free(name); free(viewer_path); free(native_path); - FindClose(find); return 0; - } - if (!native_path) { free(name); free(viewer_path); continue; } - if (list->count == list->capacity && !reserve_frames(list)) { - free(name); free(viewer_path); free(native_path); - FindClose(find); return 0; - } - frame = &list->items[list->count++]; - memset(frame, 0, sizeof(*frame)); - frame->name = name; frame->viewer_path = viewer_path; - frame->native_path = native_path; - } while (FindNextFileW(find, &entry)); - FindClose(find); - } - qsort(list->items, list->count, sizeof(*list->items), compare_frames); - return list->count != 0; -} - static int foreground_pixel(const unsigned char *pixel) { return min(pixel[0], min(pixel[1], pixel[2])) < 237; } @@ -285,20 +164,20 @@ int wmain(int argc, wchar_t **argv) { if (seed == 0) seed = (int)(GetTickCount() & INT_MAX); if (FAILED(CoInitializeEx(NULL, COINIT_MULTITHREADED))) return 1; if (!bongo_cat_tool_ensure_directory(argv[3]) || - !collect_frames(argv[1], argv[2], &frames)) + !blind_collect_frames(argv[1], argv[2], &frames)) goto done; - normalized = join_path(argv[3], L"normalized"); - ballots = join_path(argv[3], L"ballots"); - metrics_path = join_path(argv[3], L"metrics.csv"); - key_path = join_path(argv[3], L"answer-key.csv"); - summary_path = join_path(argv[3], L"summary.json"); + normalized = blind_join_path(argv[3], L"normalized"); + ballots = blind_join_path(argv[3], L"ballots"); + metrics_path = blind_join_path(argv[3], L"metrics.csv"); + key_path = blind_join_path(argv[3], L"answer-key.csv"); + summary_path = blind_join_path(argv[3], L"summary.json"); if (!normalized || !ballots || !metrics_path || !key_path || !summary_path || !bongo_cat_tool_ensure_directory(normalized) || !bongo_cat_tool_ensure_directory(ballots)) goto done; for (index = 0; index < frames.count; ++index) if (wcscmp(frames.items[index].name, L"track-000") == 0) baseline_index = index; { - wchar_t *idle = join_suffix(argv[1], L"idle", L".png"); + wchar_t *idle = blind_join_suffix(argv[1], L"idle", L".png"); int have_idle = idle && _waccess(idle, 0) == 0; wchar_t *viewer_bounds_path = have_idle ? idle : frames.items[baseline_index].viewer_path; BongoCatValidationImage bounds_image = {0}; @@ -326,8 +205,8 @@ int wmain(int argc, wchar_t **argv) { !bongo_cat_validation_image_keep_largest(&frames.items[index].native)) { goto done; } - viewer_output = join_suffix(normalized, frames.items[index].name, L"-viewer.png"); - native_output = join_suffix(normalized, frames.items[index].name, L"-native.png"); + viewer_output = blind_join_suffix(normalized, frames.items[index].name, L"-viewer.png"); + native_output = blind_join_suffix(normalized, frames.items[index].name, L"-native.png"); if (!viewer_output || !native_output || !bongo_cat_validation_image_save(viewer_output, &frames.items[index].viewer, 1) || !bongo_cat_validation_image_save(native_output, &frames.items[index].native, 1)) { @@ -353,7 +232,7 @@ int wmain(int argc, wchar_t **argv) { } { int viewer_first = random_next_two(&random) == 0; - wchar_t *ballot = join_suffix(ballots, frames.items[index].name, L".png"); + wchar_t *ballot = blind_join_suffix(ballots, frames.items[index].name, L".png"); if (!ballot || !write_ballot(ballot, &frames.items[index], viewer_first)) { free(ballot); fclose(metrics); fclose(key); goto done; } @@ -369,7 +248,7 @@ int wmain(int argc, wchar_t **argv) { result = 0; done: free(normalized); free(ballots); free(metrics_path); free(key_path); free(summary_path); - free_frames(&frames); + blind_free_frames(&frames); CoUninitialize(); if (result) fwprintf(stderr, L"blind test failed\n"); return result; diff --git a/src/tools/cubism_viewer_desktop_capture.c b/src/tools/cubism_viewer_desktop_capture.c index 5af416b9..7e249ac8 100644 --- a/src/tools/cubism_viewer_desktop_capture.c +++ b/src/tools/cubism_viewer_desktop_capture.c @@ -1,8 +1,5 @@ -#define COBJMACROS #define WIN32_LEAN_AND_MEAN #include -#include -#include #include #include @@ -10,25 +7,11 @@ #include #include +#include "cubism_viewer_desktop_capture.h" #include "windows_tool.h" typedef struct WindowSearch { DWORD process; HWND window; } WindowSearch; -typedef struct Frame { - LONGLONG present; - unsigned char *pixels; - size_t size; -} Frame; - -typedef struct DesktopCapture { - RECT crop; - RECT output; - ID3D11Device *device; - ID3D11DeviceContext *context; - IDXGIOutputDuplication *duplication; - ID3D11Texture2D *staging; -} DesktopCapture; - typedef struct Sample { char name[24]; Frame frame; @@ -38,10 +21,6 @@ typedef struct Sample { static LARGE_INTEGER timer_frequency; -static void release_unknown(IUnknown *value) { - if (value) IUnknown_Release(value); -} - static LONGLONG ticks(void) { LARGE_INTEGER value; QueryPerformanceCounter(&value); @@ -110,146 +89,6 @@ static int prepare_viewer(HWND window, int expression) { return 1; } -static void desktop_destroy(DesktopCapture *capture) { - release_unknown((IUnknown *)capture->staging); - release_unknown((IUnknown *)capture->duplication); - release_unknown((IUnknown *)capture->context); - release_unknown((IUnknown *)capture->device); - memset(capture, 0, sizeof(*capture)); -} - -static int desktop_init(DesktopCapture *capture, RECT crop) { - IDXGIFactory1 *factory = NULL; - UINT adapter_index; - HRESULT hr = CreateDXGIFactory1(&IID_IDXGIFactory1, (void **)&factory); - memset(capture, 0, sizeof(*capture)); capture->crop = crop; - if (FAILED(hr)) return 0; - for (adapter_index = 0; ; ++adapter_index) { - IDXGIAdapter1 *adapter = NULL; - UINT output_index; - hr = IDXGIFactory1_EnumAdapters1(factory, adapter_index, &adapter); - if (hr == DXGI_ERROR_NOT_FOUND) break; - if (FAILED(hr)) continue; - for (output_index = 0; ; ++output_index) { - IDXGIOutput *output = NULL; - IDXGIOutput1 *output1 = NULL; - DXGI_OUTPUT_DESC description; - D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, - D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; - D3D_FEATURE_LEVEL level; - hr = IDXGIAdapter1_EnumOutputs(adapter, output_index, &output); - if (hr == DXGI_ERROR_NOT_FOUND) break; - if (FAILED(hr)) continue; - memset(&description, 0, sizeof(description)); - IDXGIOutput_GetDesc(output, &description); - if (!description.AttachedToDesktop || crop.left < - description.DesktopCoordinates.left || crop.top < - description.DesktopCoordinates.top || crop.right > - description.DesktopCoordinates.right || crop.bottom > - description.DesktopCoordinates.bottom) { - release_unknown((IUnknown *)output); continue; - } - hr = D3D11CreateDevice((IDXGIAdapter *)adapter, - D3D_DRIVER_TYPE_UNKNOWN, NULL, D3D11_CREATE_DEVICE_BGRA_SUPPORT, - levels, sizeof(levels) / sizeof(levels[0]), D3D11_SDK_VERSION, - &capture->device, &level, &capture->context); - if (FAILED(hr)) { release_unknown((IUnknown *)output); continue; } - hr = IDXGIOutput_QueryInterface(output, &IID_IDXGIOutput1, - (void **)&output1); - if (SUCCEEDED(hr)) hr = IDXGIOutput1_DuplicateOutput(output1, - (IUnknown *)capture->device, &capture->duplication); - release_unknown((IUnknown *)output1); - release_unknown((IUnknown *)output); - if (SUCCEEDED(hr)) { - capture->output = description.DesktopCoordinates; - release_unknown((IUnknown *)adapter); - release_unknown((IUnknown *)factory); - return 1; - } - release_unknown((IUnknown *)capture->context); capture->context = NULL; - release_unknown((IUnknown *)capture->device); capture->device = NULL; - } - release_unknown((IUnknown *)adapter); - } - release_unknown((IUnknown *)factory); - desktop_destroy(capture); - return 0; -} - -static int copy_frame(DesktopCapture *capture, ID3D11Texture2D *source, - Frame *frame) { - D3D11_TEXTURE2D_DESC source_description; - D3D11_MAPPED_SUBRESOURCE mapped; - D3D11_BOX box; - int width = capture->crop.right - capture->crop.left; - int height = capture->crop.bottom - capture->crop.top; - int y; - ID3D11Texture2D_GetDesc(source, &source_description); - if (source_description.Format != DXGI_FORMAT_B8G8R8A8_UNORM) return 0; - if (!capture->staging) { - D3D11_TEXTURE2D_DESC description; - memset(&description, 0, sizeof(description)); - description.Width = (UINT)width; description.Height = (UINT)height; - description.MipLevels = description.ArraySize = 1; - description.Format = source_description.Format; - description.SampleDesc.Count = 1; - description.Usage = D3D11_USAGE_STAGING; - description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; - if (FAILED(ID3D11Device_CreateTexture2D(capture->device, &description, - NULL, &capture->staging))) return 0; - } - box.left = (UINT)(capture->crop.left - capture->output.left); - box.top = (UINT)(capture->crop.top - capture->output.top); box.front = 0; - box.right = (UINT)(capture->crop.right - capture->output.left); - box.bottom = (UINT)(capture->crop.bottom - capture->output.top); box.back = 1; - ID3D11DeviceContext_CopySubresourceRegion(capture->context, - (ID3D11Resource *)capture->staging, 0, 0, 0, 0, - (ID3D11Resource *)source, 0, &box); - memset(&mapped, 0, sizeof(mapped)); - if (FAILED(ID3D11DeviceContext_Map(capture->context, - (ID3D11Resource *)capture->staging, 0, D3D11_MAP_READ, 0, &mapped))) - return 0; - frame->size = (size_t)width * height * 4; - frame->pixels = (unsigned char *)malloc(frame->size); - if (!frame->pixels) { - ID3D11DeviceContext_Unmap(capture->context, - (ID3D11Resource *)capture->staging, 0); return 0; - } - for (y = 0; y < height; ++y) - memcpy(frame->pixels + (size_t)y * width * 4, - (const unsigned char *)mapped.pData + (size_t)y * mapped.RowPitch, - (size_t)width * 4); - ID3D11DeviceContext_Unmap(capture->context, - (ID3D11Resource *)capture->staging, 0); - return 1; -} - -static int capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame) { - for (;;) { - DXGI_OUTDUPL_FRAME_INFO info; - IDXGIResource *resource = NULL; - HRESULT hr; - int selected, copied = 0; - memset(&info, 0, sizeof(info)); - hr = IDXGIOutputDuplication_AcquireNextFrame(capture->duplication, - 1000, &info, &resource); - if (hr == DXGI_ERROR_WAIT_TIMEOUT) continue; - if (FAILED(hr)) return 0; - selected = info.LastPresentTime.QuadPart >= target; - if (selected) { - ID3D11Texture2D *texture = NULL; - hr = IDXGIResource_QueryInterface(resource, &IID_ID3D11Texture2D, - (void **)&texture); - if (SUCCEEDED(hr)) copied = copy_frame(capture, texture, frame); - release_unknown((IUnknown *)texture); - frame->present = info.LastPresentTime.QuadPart; - } - release_unknown((IUnknown *)resource); - IDXGIOutputDuplication_ReleaseFrame(capture->duplication); - if (selected) return copied; - } -} - #pragma pack(push, 1) typedef struct BitmapFileHeader { uint16_t type; @@ -330,13 +169,13 @@ int wmain(int argc, wchar_t **argv) { Sleep(800); { RECT crop = {left, top, left + width, top + height}; - if (!desktop_init(&capture, crop)) goto done; + if (!desktop_capture_init(&capture, crop)) goto done; } strcpy_s(samples[count].name, sizeof(samples[count].name), "idle"); - if (!capture_at(&capture, ticks(), &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, ticks(), &samples[count++].frame)) goto done; if (!mouse_input(MOUSEEVENTF_LEFTDOWN)) goto done; Sleep(settle); strcpy_s(samples[count].name, sizeof(samples[count].name), "track-000"); - if (!capture_at(&capture, ticks(), &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, ticks(), &samples[count++].frame)) goto done; if (!move_cursor(target_x, target_y)) goto done; track_start = ticks(); for (index = 0; index < 6; ++index) { @@ -346,7 +185,7 @@ int wmain(int argc, wchar_t **argv) { sample_name(samples[count].name, sizeof(samples[count].name), "track", frame_numbers[index]); samples[count].phase = track_start; samples[count].requested = requested; - if (!capture_at(&capture, target, &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, target, &samples[count++].frame)) goto done; } if (!mouse_input(MOUSEEVENTF_LEFTUP)) goto done; return_start = ticks(); @@ -357,7 +196,7 @@ int wmain(int argc, wchar_t **argv) { sample_name(samples[count].name, sizeof(samples[count].name), "return", frame_numbers[index]); samples[count].phase = return_start; samples[count].requested = requested; - if (!capture_at(&capture, target, &samples[count++].frame)) goto done; + if (!desktop_capture_at(&capture, target, &samples[count++].frame)) goto done; } mouse_input(MOUSEEVENTF_LEFTUP); trace_path = sample_path(argv[1], "trace", L".csv"); @@ -390,7 +229,7 @@ int wmain(int argc, wchar_t **argv) { if (trace) fclose(trace); free(trace_path); for (index = 0; index < count; ++index) free(samples[index].frame.pixels); - desktop_destroy(&capture); + desktop_capture_destroy(&capture); if (result) fwprintf(stderr, L"desktop capture failed\n"); return result; } diff --git a/src/tools/cubism_viewer_desktop_capture.h b/src/tools/cubism_viewer_desktop_capture.h new file mode 100644 index 00000000..77b63b2a --- /dev/null +++ b/src/tools/cubism_viewer_desktop_capture.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_CUBISM_VIEWER_DESKTOP_CAPTURE_H +#define BONGO_CAT_CUBISM_VIEWER_DESKTOP_CAPTURE_H + +#define COBJMACROS +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#include + +typedef struct Frame { + LONGLONG present; + unsigned char *pixels; + size_t size; +} Frame; + +typedef struct DesktopCapture { + RECT crop; + RECT output; + ID3D11Device *device; + ID3D11DeviceContext *context; + IDXGIOutputDuplication *duplication; + ID3D11Texture2D *staging; +} DesktopCapture; + +void desktop_capture_destroy(DesktopCapture *capture); +int desktop_capture_init(DesktopCapture *capture, RECT crop); +int desktop_capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame); + +#endif diff --git a/src/tools/cubism_viewer_desktop_capture_dxgi.c b/src/tools/cubism_viewer_desktop_capture_dxgi.c new file mode 100644 index 00000000..fa52d52d --- /dev/null +++ b/src/tools/cubism_viewer_desktop_capture_dxgi.c @@ -0,0 +1,148 @@ +#include "cubism_viewer_desktop_capture.h" + +#include +#include + +static void release_unknown(IUnknown *value) { + if (value) IUnknown_Release(value); +} + +void desktop_capture_destroy(DesktopCapture *capture) { + release_unknown((IUnknown *)capture->staging); + release_unknown((IUnknown *)capture->duplication); + release_unknown((IUnknown *)capture->context); + release_unknown((IUnknown *)capture->device); + memset(capture, 0, sizeof(*capture)); +} + +int desktop_capture_init(DesktopCapture *capture, RECT crop) { + IDXGIFactory1 *factory = NULL; + UINT adapter_index; + HRESULT hr = CreateDXGIFactory1(&IID_IDXGIFactory1, (void **)&factory); + memset(capture, 0, sizeof(*capture)); capture->crop = crop; + if (FAILED(hr)) return 0; + for (adapter_index = 0; ; ++adapter_index) { + IDXGIAdapter1 *adapter = NULL; + UINT output_index; + hr = IDXGIFactory1_EnumAdapters1(factory, adapter_index, &adapter); + if (hr == DXGI_ERROR_NOT_FOUND) break; + if (FAILED(hr)) continue; + for (output_index = 0; ; ++output_index) { + IDXGIOutput *output = NULL; + IDXGIOutput1 *output1 = NULL; + DXGI_OUTPUT_DESC description; + D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; + D3D_FEATURE_LEVEL level; + hr = IDXGIAdapter1_EnumOutputs(adapter, output_index, &output); + if (hr == DXGI_ERROR_NOT_FOUND) break; + if (FAILED(hr)) continue; + memset(&description, 0, sizeof(description)); + IDXGIOutput_GetDesc(output, &description); + if (!description.AttachedToDesktop || crop.left < + description.DesktopCoordinates.left || crop.top < + description.DesktopCoordinates.top || crop.right > + description.DesktopCoordinates.right || crop.bottom > + description.DesktopCoordinates.bottom) { + release_unknown((IUnknown *)output); continue; + } + hr = D3D11CreateDevice((IDXGIAdapter *)adapter, + D3D_DRIVER_TYPE_UNKNOWN, NULL, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + levels, sizeof(levels) / sizeof(levels[0]), D3D11_SDK_VERSION, + &capture->device, &level, &capture->context); + if (FAILED(hr)) { release_unknown((IUnknown *)output); continue; } + hr = IDXGIOutput_QueryInterface(output, &IID_IDXGIOutput1, + (void **)&output1); + if (SUCCEEDED(hr)) hr = IDXGIOutput1_DuplicateOutput(output1, + (IUnknown *)capture->device, &capture->duplication); + release_unknown((IUnknown *)output1); + release_unknown((IUnknown *)output); + if (SUCCEEDED(hr)) { + capture->output = description.DesktopCoordinates; + release_unknown((IUnknown *)adapter); + release_unknown((IUnknown *)factory); + return 1; + } + release_unknown((IUnknown *)capture->context); capture->context = NULL; + release_unknown((IUnknown *)capture->device); capture->device = NULL; + } + release_unknown((IUnknown *)adapter); + } + release_unknown((IUnknown *)factory); + desktop_capture_destroy(capture); + return 0; +} + +static int copy_frame(DesktopCapture *capture, ID3D11Texture2D *source, + Frame *frame) { + D3D11_TEXTURE2D_DESC source_description; + D3D11_MAPPED_SUBRESOURCE mapped; + D3D11_BOX box; + int width = capture->crop.right - capture->crop.left; + int height = capture->crop.bottom - capture->crop.top; + int y; + ID3D11Texture2D_GetDesc(source, &source_description); + if (source_description.Format != DXGI_FORMAT_B8G8R8A8_UNORM) return 0; + if (!capture->staging) { + D3D11_TEXTURE2D_DESC description; + memset(&description, 0, sizeof(description)); + description.Width = (UINT)width; description.Height = (UINT)height; + description.MipLevels = description.ArraySize = 1; + description.Format = source_description.Format; + description.SampleDesc.Count = 1; + description.Usage = D3D11_USAGE_STAGING; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + if (FAILED(ID3D11Device_CreateTexture2D(capture->device, &description, + NULL, &capture->staging))) return 0; + } + box.left = (UINT)(capture->crop.left - capture->output.left); + box.top = (UINT)(capture->crop.top - capture->output.top); box.front = 0; + box.right = (UINT)(capture->crop.right - capture->output.left); + box.bottom = (UINT)(capture->crop.bottom - capture->output.top); box.back = 1; + ID3D11DeviceContext_CopySubresourceRegion(capture->context, + (ID3D11Resource *)capture->staging, 0, 0, 0, 0, + (ID3D11Resource *)source, 0, &box); + memset(&mapped, 0, sizeof(mapped)); + if (FAILED(ID3D11DeviceContext_Map(capture->context, + (ID3D11Resource *)capture->staging, 0, D3D11_MAP_READ, 0, &mapped))) + return 0; + frame->size = (size_t)width * height * 4; + frame->pixels = (unsigned char *)malloc(frame->size); + if (!frame->pixels) { + ID3D11DeviceContext_Unmap(capture->context, + (ID3D11Resource *)capture->staging, 0); return 0; + } + for (y = 0; y < height; ++y) + memcpy(frame->pixels + (size_t)y * width * 4, + (const unsigned char *)mapped.pData + (size_t)y * mapped.RowPitch, + (size_t)width * 4); + ID3D11DeviceContext_Unmap(capture->context, + (ID3D11Resource *)capture->staging, 0); + return 1; +} + +int desktop_capture_at(DesktopCapture *capture, LONGLONG target, Frame *frame) { + for (;;) { + DXGI_OUTDUPL_FRAME_INFO info; + IDXGIResource *resource = NULL; + HRESULT hr; + int selected, copied = 0; + memset(&info, 0, sizeof(info)); + hr = IDXGIOutputDuplication_AcquireNextFrame(capture->duplication, + 1000, &info, &resource); + if (hr == DXGI_ERROR_WAIT_TIMEOUT) continue; + if (FAILED(hr)) return 0; + selected = info.LastPresentTime.QuadPart >= target; + if (selected) { + ID3D11Texture2D *texture = NULL; + hr = IDXGIResource_QueryInterface(resource, &IID_ID3D11Texture2D, + (void **)&texture); + if (SUCCEEDED(hr)) copied = copy_frame(capture, texture, frame); + release_unknown((IUnknown *)texture); + frame->present = info.LastPresentTime.QuadPart; + } + release_unknown((IUnknown *)resource); + IDXGIOutputDuplication_ReleaseFrame(capture->duplication); + if (selected) return copied; + } +} diff --git a/src/tools/mver_phase_frames.c b/src/tools/mver_phase_frames.c new file mode 100644 index 00000000..cd822d05 --- /dev/null +++ b/src/tools/mver_phase_frames.c @@ -0,0 +1,105 @@ +#define WIN32_LEAN_AND_MEAN +#include + +#include "mver_phase_frames.h" +#include "validation_image.h" + +#include +#include +#include + +static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { + size_t a = wcslen(directory), b = wcslen(name); + wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); + if (result) swprintf(result, a + b + 2, L"%ls\\%ls", directory, name); + return result; +} + +static int reserve_paths(PathList *list) { + size_t capacity = list->capacity ? list->capacity * 2 : 32; + wchar_t **items; + if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; + items = (wchar_t **)realloc(list->items, capacity * sizeof(*items)); + if (!items) return 0; + list->items = items; list->capacity = capacity; + return 1; +} + +void phase_free_paths(PathList *list) { + size_t index; + for (index = 0; index < list->count; ++index) free(list->items[index]); + free(list->items); memset(list, 0, sizeof(*list)); +} + +static int compare_paths(const void *left, const void *right) { + return wcscmp(*(const wchar_t *const *)left, *(const wchar_t *const *)right); +} + +int phase_collect_paths(const wchar_t *directory, PathList *list) { + static const wchar_t *patterns[] = {L"*.png", L"*.bmp"}; + size_t pattern_index; + for (pattern_index = 0; pattern_index < sizeof(patterns) / sizeof(patterns[0]); + ++pattern_index) { + WIN32_FIND_DATAW entry; + wchar_t *pattern = join_path(directory, patterns[pattern_index]); + HANDLE find; + if (!pattern) return 0; + find = FindFirstFileW(pattern, &entry); free(pattern); + if (find == INVALID_HANDLE_VALUE) continue; + do { + wchar_t *path; + if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + path = join_path(directory, entry.cFileName); + if (!path || (list->count == list->capacity && !reserve_paths(list))) { + free(path); FindClose(find); return 0; + } + list->items[list->count++] = path; + } while (FindNextFileW(find, &entry)); + FindClose(find); + } + qsort(list->items, list->count, sizeof(*list->items), compare_paths); + return list->count != 0; +} + +static int load_frame(const wchar_t *path, Frame *frame) { + BongoCatValidationImage image = {0}; + int gx, gy; + if (!bongo_cat_validation_image_load(path, &image)) return 0; + frame->width = image.width; frame->height = image.height; + frame->grid_width = (image.width + PIXEL_STEP - 1) / PIXEL_STEP; + frame->grid_height = (image.height + PIXEL_STEP - 1) / PIXEL_STEP; + frame->bgr = (unsigned char *)malloc((size_t)frame->grid_width * + frame->grid_height * 3); + if (!frame->bgr) { bongo_cat_validation_image_free(&image); return 0; } + for (gy = 0; gy < frame->grid_height; ++gy) + for (gx = 0; gx < frame->grid_width; ++gx) { + int sx = min(image.width - 1, gx * PIXEL_STEP); + int sy = min(image.height - 1, gy * PIXEL_STEP); + const unsigned char *source = image.bgra + ((size_t)sy * image.width + sx) * 4; + unsigned char *output = frame->bgr + + ((size_t)gy * frame->grid_width + gx) * 3; + memcpy(output, source, 3); + } + bongo_cat_validation_image_free(&image); + return 1; +} + +void phase_free_frames(Frame *frames, size_t count) { + size_t index; + for (index = 0; index < count; ++index) free(frames[index].bgr); + free(frames); +} + +Frame *phase_load_frames(const PathList *paths) { + Frame *frames = (Frame *)calloc(paths->count, sizeof(*frames)); + size_t index; + if (!frames) return NULL; + for (index = 0; index < paths->count; ++index) { + if (!load_frame(paths->items[index], &frames[index]) || (index && + (frames[index].width != frames[0].width || + frames[index].height != frames[0].height))) { + phase_free_frames(frames, paths->count); return NULL; + } + } + return frames; +} diff --git a/src/tools/mver_phase_frames.h b/src/tools/mver_phase_frames.h new file mode 100644 index 00000000..e412274e --- /dev/null +++ b/src/tools/mver_phase_frames.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_MVER_PHASE_FRAMES_H +#define BONGO_CAT_MVER_PHASE_FRAMES_H + +#include +#include + +enum { PIXEL_STEP = 4 }; + +typedef struct PathList { + wchar_t **items; + size_t count; + size_t capacity; +} PathList; + +typedef struct Frame { + int width, height, grid_width, grid_height; + unsigned char *bgr; +} Frame; + +int phase_collect_paths(const wchar_t *directory, PathList *list); +void phase_free_paths(PathList *list); +Frame *phase_load_frames(const PathList *paths); +void phase_free_frames(Frame *frames, size_t count); + +#endif diff --git a/src/tools/mver_phase_metrics.c b/src/tools/mver_phase_metrics.c index c4acd8c5..7efa4e8f 100644 --- a/src/tools/mver_phase_metrics.c +++ b/src/tools/mver_phase_metrics.c @@ -2,7 +2,7 @@ #include #include -#include "validation_image.h" +#include "mver_phase_frames.h" #include #include @@ -11,18 +11,7 @@ #include #include -enum { PIXEL_STEP = 4, REGION_COUNT = 4 }; - -typedef struct PathList { - wchar_t **items; - size_t count; - size_t capacity; -} PathList; - -typedef struct Frame { - int width, height, grid_width, grid_height; - unsigned char *bgr; -} Frame; +enum { REGION_COUNT = 4 }; typedef struct Score { long long absolute[REGION_COUNT]; @@ -43,102 +32,6 @@ typedef struct Candidate { double motion[REGION_COUNT]; } Candidate; -static wchar_t *join_path(const wchar_t *directory, const wchar_t *name) { - size_t a = wcslen(directory), b = wcslen(name); - wchar_t *result = (wchar_t *)malloc((a + b + 2) * sizeof(*result)); - if (result) swprintf(result, a + b + 2, L"%ls\\%ls", directory, name); - return result; -} - -static int reserve_paths(PathList *list) { - size_t capacity = list->capacity ? list->capacity * 2 : 32; - wchar_t **items; - if (capacity < list->capacity || capacity > SIZE_MAX / sizeof(*items)) return 0; - items = (wchar_t **)realloc(list->items, capacity * sizeof(*items)); - if (!items) return 0; - list->items = items; list->capacity = capacity; - return 1; -} - -static void free_paths(PathList *list) { - size_t index; - for (index = 0; index < list->count; ++index) free(list->items[index]); - free(list->items); memset(list, 0, sizeof(*list)); -} - -static int compare_paths(const void *left, const void *right) { - return wcscmp(*(const wchar_t *const *)left, *(const wchar_t *const *)right); -} - -static int collect_paths(const wchar_t *directory, PathList *list) { - static const wchar_t *patterns[] = {L"*.png", L"*.bmp"}; - size_t pattern_index; - for (pattern_index = 0; pattern_index < sizeof(patterns) / sizeof(patterns[0]); - ++pattern_index) { - WIN32_FIND_DATAW entry; - wchar_t *pattern = join_path(directory, patterns[pattern_index]); - HANDLE find; - if (!pattern) return 0; - find = FindFirstFileW(pattern, &entry); free(pattern); - if (find == INVALID_HANDLE_VALUE) continue; - do { - wchar_t *path; - if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - path = join_path(directory, entry.cFileName); - if (!path || (list->count == list->capacity && !reserve_paths(list))) { - free(path); FindClose(find); return 0; - } - list->items[list->count++] = path; - } while (FindNextFileW(find, &entry)); - FindClose(find); - } - qsort(list->items, list->count, sizeof(*list->items), compare_paths); - return list->count != 0; -} - -static int load_frame(const wchar_t *path, Frame *frame) { - BongoCatValidationImage image = {0}; - int gx, gy; - if (!bongo_cat_validation_image_load(path, &image)) return 0; - frame->width = image.width; frame->height = image.height; - frame->grid_width = (image.width + PIXEL_STEP - 1) / PIXEL_STEP; - frame->grid_height = (image.height + PIXEL_STEP - 1) / PIXEL_STEP; - frame->bgr = (unsigned char *)malloc((size_t)frame->grid_width * - frame->grid_height * 3); - if (!frame->bgr) { bongo_cat_validation_image_free(&image); return 0; } - for (gy = 0; gy < frame->grid_height; ++gy) - for (gx = 0; gx < frame->grid_width; ++gx) { - int sx = min(image.width - 1, gx * PIXEL_STEP); - int sy = min(image.height - 1, gy * PIXEL_STEP); - const unsigned char *source = image.bgra + ((size_t)sy * image.width + sx) * 4; - unsigned char *output = frame->bgr + - ((size_t)gy * frame->grid_width + gx) * 3; - memcpy(output, source, 3); - } - bongo_cat_validation_image_free(&image); - return 1; -} - -static void free_frames(Frame *frames, size_t count) { - size_t index; - for (index = 0; index < count; ++index) free(frames[index].bgr); - free(frames); -} - -static Frame *load_all(const PathList *paths) { - Frame *frames = (Frame *)calloc(paths->count, sizeof(*frames)); - size_t index; - if (!frames) return NULL; - for (index = 0; index < paths->count; ++index) { - if (!load_frame(paths->items[index], &frames[index]) || (index && - (frames[index].width != frames[0].width || - frames[index].height != frames[0].height))) { - free_frames(frames, paths->count); return NULL; - } - } - return frames; -} - static int in_region(int region, int x, int y, int width, int height) { double nx = x / (double)width, ny = y / (double)height; if (region == 0) return 1; @@ -277,10 +170,12 @@ int wmain(int argc, wchar_t **argv) { interval_ms = argc > 5 ? _wtoi(argv[5]) : 17; if (max_lag < 0 || minimum_overlap < 2) return 2; if (FAILED(CoInitializeEx(NULL, COINIT_MULTITHREADED))) return 1; - if (!collect_paths(argv[1], &mver_paths) || !collect_paths(argv[2], &native_paths) || + if (!phase_collect_paths(argv[1], &mver_paths) || + !phase_collect_paths(argv[2], &native_paths) || mver_paths.count < (size_t)minimum_overlap || native_paths.count < (size_t)minimum_overlap) goto done; - mver = load_all(&mver_paths); native = load_all(&native_paths); + mver = phase_load_frames(&mver_paths); + native = phase_load_frames(&native_paths); if (!mver || !native) goto done; coarse = (Candidate *)malloc((size_t)(max_lag * 2 + 1) * sizeof(*coarse)); fine = (Candidate *)malloc(8 * sizeof(*fine)); @@ -313,8 +208,9 @@ int wmain(int argc, wchar_t **argv) { result = 0; done: free(coarse); free(fine); - free_frames(mver, mver_paths.count); free_frames(native, native_paths.count); - free_paths(&mver_paths); free_paths(&native_paths); + phase_free_frames(mver, mver_paths.count); + phase_free_frames(native, native_paths.count); + phase_free_paths(&mver_paths); phase_free_paths(&native_paths); CoUninitialize(); if (result) fwprintf(stderr, L"phase analysis failed\n"); return result; diff --git a/src/ui/nuklear_config.h b/src/ui/backend/nuklear_config.h similarity index 80% rename from src/ui/nuklear_config.h rename to src/ui/backend/nuklear_config.h index 55585331..a8dd2cec 100644 --- a/src/ui/nuklear_config.h +++ b/src/ui/backend/nuklear_config.h @@ -2,8 +2,6 @@ #define BONGO_CAT_NUKLEAR_CONFIG_H #define NK_INCLUDE_FIXED_TYPES -#define NK_INCLUDE_STANDARD_IO -#define NK_INCLUDE_STANDARD_VARARGS #define NK_INCLUDE_DEFAULT_ALLOCATOR #define NK_INCLUDE_VERTEX_BUFFER_OUTPUT #define NK_INCLUDE_FONT_BAKING diff --git a/src/ui/nuklear_impl.c b/src/ui/backend/nuklear_impl.c similarity index 100% rename from src/ui/nuklear_impl.c rename to src/ui/backend/nuklear_impl.c diff --git a/src/ui/ui_backend.c b/src/ui/backend/ui_backend.c similarity index 69% rename from src/ui/ui_backend.c rename to src/ui/backend/ui_backend.c index bfac4941..f99f55ca 100644 --- a/src/ui/ui_backend.c +++ b/src/ui/backend/ui_backend.c @@ -1,11 +1,14 @@ #include "ui_backend.h" #include "ui_font_atlas.h" #include "ui_paint.h" +#include "ui_paint_cache.h" #include #include #include +#define UI_COMMAND_BUFFER_TRIM_LIMIT (1024u * 1024u) + typedef struct UIVertex { float position[2]; float uv[2]; @@ -68,6 +71,12 @@ static bool create_device(BongoCatUIBackend *ui, BongoCatError *error) { return true; } +static void trim_commands(BongoCatUIBackend *ui) { + if (!ui || ui->commands.allocated <= UI_COMMAND_BUFFER_TRIM_LIMIT) return; + nk_buffer_free(&ui->commands); + nk_buffer_init_default(&ui->commands); +} + bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, const char *body_font_path, const char *body_fallback_path, const char *body_korean_fallback_path, const char *heading_font_path, @@ -100,25 +109,47 @@ bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, return true; } -void bongo_cat_ui_destroy(BongoCatUIBackend *ui) { +static void release(BongoCatUIBackend *ui, bool release_gl) { if (!ui) return; if (context_backend == ui) context_backend = NULL; - bongo_cat_ui_paint_destroy(ui); - bongo_cat_ui_resize_cache_destroy(ui); + if (release_gl) { + bongo_cat_ui_paint_destroy(ui); + bongo_cat_ui_resize_cache_destroy(ui); + } else { + bongo_cat_ui_paint_cache_abandon(ui); + ui->resize_cache_texture = 0; + ui->resize_cache_width = ui->resize_cache_height = 0; + } if (ui->atlas.permanent.alloc) bongo_cat_ui_font_atlas_destroy(ui); nk_buffer_free(&ui->commands); nk_free(&ui->context); - if (ui->font_texture) glDeleteTextures(1, &ui->font_texture); - if (ui->vbo) ui->gl.delete_buffers(1, &ui->vbo); - if (ui->ebo) ui->gl.delete_buffers(1, &ui->ebo); - if (ui->vao) ui->gl.delete_vertex_arrays(1, &ui->vao); - if (ui->program) ui->gl.delete_program(ui->program); + if (release_gl) { + if (ui->font_texture) glDeleteTextures(1, &ui->font_texture); + if (ui->vbo) ui->gl.delete_buffers(1, &ui->vbo); + if (ui->ebo) ui->gl.delete_buffers(1, &ui->ebo); + if (ui->vao) ui->gl.delete_vertex_arrays(1, &ui->vao); + if (ui->program) ui->gl.delete_program(ui->program); + } free(ui->vertices); free(ui->elements); memset(ui, 0, sizeof(*ui)); } -static void convert(BongoCatUIBackend *ui) { +void bongo_cat_ui_destroy(BongoCatUIBackend *ui) { release(ui, true); } + +void bongo_cat_ui_abandon(BongoCatUIBackend *ui) { release(ui, false); } + +static bool grow_buffer(void **data, size_t *capacity, size_t limit) { + if (*capacity >= limit) return false; + size_t next = NK_MIN(*capacity * 2, limit); + void *grown = realloc(*data, next); + if (!grown) return false; + *data = grown; + *capacity = next; + return true; +} + +static bool convert(BongoCatUIBackend *ui) { static const struct nk_draw_vertex_layout_element layout[] = { {NK_VERTEX_POSITION, NK_FORMAT_FLOAT, offsetof(UIVertex, position)}, {NK_VERTEX_TEXCOORD, NK_FORMAT_FLOAT, offsetof(UIVertex, uv)}, @@ -137,10 +168,22 @@ static void convert(BongoCatUIBackend *ui) { config.shape_AA = NK_ANTI_ALIASING_ON; config.line_AA = NK_ANTI_ALIASING_ON; struct nk_buffer vertices, elements; - nk_buffer_init_fixed(&vertices, ui->vertices, ui->vertex_capacity); - nk_buffer_init_fixed(&elements, ui->elements, ui->element_capacity); - ui->last_convert_result = nk_convert(&ui->context, - &ui->commands, &vertices, &elements, &config); + /* Bound transient storage and stay below the 16-bit vertex index limit. + Never upload partially converted geometry when either buffer fills. */ + for (;;) { + nk_buffer_clear(&ui->commands); + nk_buffer_init_fixed(&vertices, ui->vertices, ui->vertex_capacity); + nk_buffer_init_fixed(&elements, ui->elements, ui->element_capacity); + ui->last_convert_result = (int)nk_convert(&ui->context, + &ui->commands, &vertices, &elements, &config); + if (ui->last_convert_result == NK_CONVERT_SUCCESS) break; + int full = NK_CONVERT_VERTEX_BUFFER_FULL | NK_CONVERT_ELEMENT_BUFFER_FULL; + if (ui->last_convert_result & ~full) return false; + if ((ui->last_convert_result & NK_CONVERT_VERTEX_BUFFER_FULL) && + !grow_buffer(&ui->vertices, &ui->vertex_capacity, 1024u * 1024u)) return false; + if ((ui->last_convert_result & NK_CONVERT_ELEMENT_BUFFER_FULL) && + !grow_buffer(&ui->elements, &ui->element_capacity, 512u * 1024u)) return false; + } ui->last_vertex_bytes = vertices.allocated; ui->last_element_bytes = elements.allocated; if (vertices.allocated >= sizeof(UIVertex)) { @@ -152,23 +195,38 @@ static void convert(BongoCatUIBackend *ui) { if (sample[i].color[3] > ui->max_alpha) ui->max_alpha = sample[i].color[3]; } } + ui->gl.bind_vertex_array(ui->vao); ui->gl.bind_buffer(GL_ARRAY_BUFFER, ui->vbo); ui->gl.buffer_data(GL_ARRAY_BUFFER, (GLsizeiptr)vertices.allocated, ui->vertices, GL_STREAM_DRAW); ui->gl.bind_buffer(GL_ELEMENT_ARRAY_BUFFER, ui->ebo); ui->gl.buffer_data(GL_ELEMENT_ARRAY_BUFFER, (GLsizeiptr)elements.allocated, ui->elements, GL_STREAM_DRAW); + return true; } -void bongo_cat_ui_render(BongoCatUIBackend *ui) { +bool bongo_cat_ui_render(BongoCatUIBackend *ui) { int pixel_width, pixel_height; float width = 0.0f, height = 0.0f; bongo_cat_ui_logical_size(ui, &width, &height); SDL_GetWindowSizeInPixels(ui->window, &pixel_width, &pixel_height); + if (width <= 0 || height <= 0 || pixel_width <= 0 || pixel_height <= 0) { + nk_clear(&ui->context); + nk_buffer_clear(&ui->commands); + return false; + } float projection[4][4] = {{2.0f / width, 0, 0, 0}, {0, -2.0f / height, 0, 0}, {0, 0, -1, 0}, {-1, 1, 0, 1}}; bongo_cat_gl_clear_errors(); - convert(ui); + if (!convert(ui)) { + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "UI conversion rejected incomplete geometry (flags %d)", + ui->last_convert_result); + ui->last_vertex_bytes = ui->last_element_bytes = 0; + ui->last_draw_commands = ui->last_draw_elements = 0; + nk_clear(&ui->context); + nk_buffer_clear(&ui->commands); + return false; + } glViewport(0, 0, pixel_width, pixel_height); glEnable(GL_BLEND); ui->gl.blend_equation(GL_FUNC_ADD); @@ -183,7 +241,7 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { ui->gl.active_texture(GL_TEXTURE0); ui->gl.bind_vertex_array(ui->vao); const struct nk_draw_command *command; - const nk_draw_index *offset = NULL; + size_t offset = 0; float sx = pixel_width / width, sy = pixel_height / height; ui->last_draw_commands = 0; ui->last_draw_elements = 0; @@ -196,13 +254,25 @@ void bongo_cat_ui_render(BongoCatUIBackend *ui) { (GLint)((height - command->clip_rect.y - command->clip_rect.h) * sy), (GLsizei)(command->clip_rect.w * sx), (GLsizei)(command->clip_rect.h * sy)); glDrawElements(GL_TRIANGLES, (GLsizei)command->elem_count, - sizeof(nk_draw_index) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, offset); - offset += command->elem_count; + sizeof(nk_draw_index) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, + (const void *)(uintptr_t)offset); + offset += (size_t)command->elem_count * sizeof(nk_draw_index); } nk_clear(&ui->context); nk_buffer_clear(&ui->commands); + /* Complex pages can grow this buffer substantially. Once a simple frame + is back on screen, release that transient capacity without affecting + the commands that were just presented. */ + if (ui->last_draw_commands < 256) trim_commands(ui); glDisable(GL_SCISSOR_TEST); ui->last_gl_error = glGetError(); + return ui->last_gl_error == GL_NO_ERROR; +} + +void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui) { + if (!ui) return; + bongo_cat_ui_paint_cache_trim_idle(ui); + trim_commands(ui); } bool bongo_cat_ui_frame_valid(const BongoCatUIBackend *ui) { @@ -219,37 +289,3 @@ BongoCatUIBackend *bongo_cat_ui_backend_for_context( return context_backend && context == &context_backend->context ? context_backend : NULL; } - -static const BongoCatUIBackend *backend(const struct nk_context *context) { - return bongo_cat_ui_backend_for_context(context); -} - -const struct nk_user_font *bongo_cat_ui_caption_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->caption_font ? ui->caption_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_body_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->body_font ? ui->body_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_label_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->label_font ? ui->label_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_heading_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->heading_font ? ui->heading_font : context->style.font; -} - -const struct nk_user_font *bongo_cat_ui_hero_font( - const struct nk_context *context) { - const BongoCatUIBackend *ui = backend(context); - return ui && ui->hero_font ? ui->hero_font : context->style.font; -} diff --git a/src/ui/ui_backend.h b/src/ui/backend/ui_backend.h similarity index 95% rename from src/ui/ui_backend.h rename to src/ui/backend/ui_backend.h index deb0ff63..e4184049 100644 --- a/src/ui/ui_backend.h +++ b/src/ui/backend/ui_backend.h @@ -83,10 +83,13 @@ bool bongo_cat_ui_init(BongoCatUIBackend *ui, SDL_Window *window, const nk_rune *glyph_ranges, float layout_scale, float raster_scale, BongoCatError *error); void bongo_cat_ui_destroy(BongoCatUIBackend *ui); +void bongo_cat_ui_abandon(BongoCatUIBackend *ui); void bongo_cat_ui_input_begin(BongoCatUIBackend *ui); void bongo_cat_ui_input_end(BongoCatUIBackend *ui); +void bongo_cat_ui_input_reset(BongoCatUIBackend *ui); bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event); -void bongo_cat_ui_render(BongoCatUIBackend *ui); +bool bongo_cat_ui_render(BongoCatUIBackend *ui); +void bongo_cat_ui_trim_idle(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_capture(BongoCatUIBackend *ui); bool bongo_cat_ui_resize_cache_present(BongoCatUIBackend *ui); void bongo_cat_ui_resize_cache_destroy(BongoCatUIBackend *ui); diff --git a/src/ui/backend/ui_backend_fonts.c b/src/ui/backend/ui_backend_fonts.c new file mode 100644 index 00000000..fdae9498 --- /dev/null +++ b/src/ui/backend/ui_backend_fonts.c @@ -0,0 +1,35 @@ +#include "ui_backend.h" + +static const BongoCatUIBackend *backend(const struct nk_context *context) { + return bongo_cat_ui_backend_for_context(context); +} + +const struct nk_user_font *bongo_cat_ui_caption_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->caption_font ? ui->caption_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_body_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->body_font ? ui->body_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_label_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->label_font ? ui->label_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_heading_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->heading_font ? ui->heading_font : context->style.font; +} + +const struct nk_user_font *bongo_cat_ui_hero_font( + const struct nk_context *context) { + const BongoCatUIBackend *ui = backend(context); + return ui && ui->hero_font ? ui->hero_font : context->style.font; +} diff --git a/src/ui/ui_cursor.c b/src/ui/backend/ui_cursor.c similarity index 100% rename from src/ui/ui_cursor.c rename to src/ui/backend/ui_cursor.c diff --git a/src/ui/ui_input.c b/src/ui/backend/ui_input.c similarity index 70% rename from src/ui/ui_input.c rename to src/ui/backend/ui_input.c index eecf1938..8dcd65fc 100644 --- a/src/ui/ui_input.c +++ b/src/ui/backend/ui_input.c @@ -11,13 +11,39 @@ void bongo_cat_ui_input_end(BongoCatUIBackend *ui) { if (ui) nk_input_end(&ui->context); } +void bongo_cat_ui_input_reset(BongoCatUIBackend *ui) { + if (!ui) return; + struct nk_mouse *mouse = &ui->context.input.mouse; + mouse->pos = nk_vec2(-1.0f, -1.0f); + mouse->prev = mouse->pos; + mouse->delta = nk_vec2(0.0f, 0.0f); + mouse->scroll_delta = nk_vec2(0.0f, 0.0f); + mouse->grab = 0; + mouse->grabbed = 0; + mouse->ungrab = 0; + for (int i = 0; i < NK_BUTTON_MAX; ++i) { + mouse->buttons[i].down = nk_false; + mouse->buttons[i].clicked = 0; + mouse->buttons[i].clicked_pos = mouse->pos; + } + for (int i = 0; i < NK_KEY_MAX; ++i) { + ui->context.input.keyboard.keys[i].down = nk_false; + ui->context.input.keyboard.keys[i].clicked = 0; + } + ui->context.input.keyboard.text_len = 0; + ui->last_left_click_ns = 0; + ui->double_click_down = false; +} + static void key_event(BongoCatUIBackend *ui, const SDL_KeyboardEvent *event) { bool down = event->down; bool control = (event->mod & SDL_KMOD_CTRL) != 0; struct nk_context *context = &ui->context; switch (event->key) { - case SDLK_LSHIFT: case SDLK_RSHIFT: nk_input_key(context, NK_KEY_SHIFT, down); break; - case SDLK_LALT: case SDLK_RALT: nk_input_key(context, NK_KEY_ALT, down); break; + case SDLK_LSHIFT: case SDLK_RSHIFT: + nk_input_key(context, NK_KEY_SHIFT, (event->mod & SDL_KMOD_SHIFT) != 0); break; + case SDLK_LALT: case SDLK_RALT: + nk_input_key(context, NK_KEY_ALT, (event->mod & SDL_KMOD_ALT) != 0); break; case SDLK_DELETE: nk_input_key(context, NK_KEY_DEL, down); break; case SDLK_RETURN: case SDLK_KP_ENTER: nk_input_key(context, NK_KEY_ENTER, down); break; case SDLK_TAB: nk_input_key(context, NK_KEY_TAB, down); break; @@ -35,16 +61,18 @@ static void key_event(BongoCatUIBackend *ui, const SDL_KeyboardEvent *event) { case SDLK_UP: nk_input_key(context, NK_KEY_UP, down); break; case SDLK_DOWN: nk_input_key(context, NK_KEY_DOWN, down); break; case SDLK_LEFT: - nk_input_key(context, control ? NK_KEY_TEXT_WORD_LEFT : NK_KEY_LEFT, down); + nk_input_key(context, NK_KEY_TEXT_WORD_LEFT, down && control); + nk_input_key(context, NK_KEY_LEFT, down && !control); break; case SDLK_RIGHT: - nk_input_key(context, control ? NK_KEY_TEXT_WORD_RIGHT : NK_KEY_RIGHT, down); + nk_input_key(context, NK_KEY_TEXT_WORD_RIGHT, down && control); + nk_input_key(context, NK_KEY_RIGHT, down && !control); break; - case SDLK_A: if (control) nk_input_key(context, NK_KEY_TEXT_SELECT_ALL, down); break; - case SDLK_C: if (control) nk_input_key(context, NK_KEY_COPY, down); break; - case SDLK_V: if (control) nk_input_key(context, NK_KEY_PASTE, down); break; - case SDLK_X: if (control) nk_input_key(context, NK_KEY_CUT, down); break; - case SDLK_Z: if (control) nk_input_key(context, NK_KEY_TEXT_UNDO, down); break; + case SDLK_A: nk_input_key(context, NK_KEY_TEXT_SELECT_ALL, down && control); break; + case SDLK_C: nk_input_key(context, NK_KEY_COPY, down && control); break; + case SDLK_V: nk_input_key(context, NK_KEY_PASTE, down && control); break; + case SDLK_X: nk_input_key(context, NK_KEY_CUT, down && control); break; + case SDLK_Z: nk_input_key(context, NK_KEY_TEXT_UNDO, down && control); break; default: break; } } @@ -111,8 +139,7 @@ bool bongo_cat_ui_event(BongoCatUIBackend *ui, const SDL_Event *event) { nk_input_scroll(context, nk_vec2(event->wheel.x, event->wheel.y)); return true; case SDL_EVENT_WINDOW_FOCUS_LOST: - ui->last_left_click_ns = 0; - ui->double_click_down = false; + bongo_cat_ui_input_reset(ui); return false; default: return false; } diff --git a/src/ui/ui_resize_cache.c b/src/ui/backend/ui_resize_cache.c similarity index 100% rename from src/ui/ui_resize_cache.c rename to src/ui/backend/ui_resize_cache.c diff --git a/src/ui/ui_scale.c b/src/ui/backend/ui_scale.c similarity index 100% rename from src/ui/ui_scale.c rename to src/ui/backend/ui_scale.c diff --git a/src/ui/backend/ui_tooltip.c b/src/ui/backend/ui_tooltip.c new file mode 100644 index 00000000..7ba9f99f --- /dev/null +++ b/src/ui/backend/ui_tooltip.c @@ -0,0 +1,109 @@ +#include "ui_tooltip.h" +#include "ui_backend.h" +#include "ui_catime.h" + +#include + +static int tooltip_wrap_length(const struct nk_user_font *font, + const char *text, int length, float width) { + int consumed = 0; + int last_space = 0; + while (consumed < length) { + nk_rune rune = 0; + int glyph_length = nk_utf_decode(text + consumed, &rune, + length - consumed); + if (glyph_length <= 0) glyph_length = 1; + int next = consumed + glyph_length; + float measured = font->width(font->userdata, font->height, + text, next); + if (consumed > 0 && measured > width) + return last_space > 0 ? last_space : consumed; + consumed = next; + if (rune == ' ') last_space = consumed; + } + return consumed > 0 ? consumed : length; +} + +static void tooltip_multiline(struct nk_context *context, const char *text, + struct nk_color color) { + const struct nk_user_font *font = context->style.font; + struct nk_vec2 window_size = nk_window_get_size(context); + struct nk_vec2 mouse = context->input.mouse.pos; + struct nk_vec2 offset = context->style.window.tooltip_offset; + float margin = 8.0f; + float outer_padding = context->style.window.padding.x * 4.0f; + float left_space = mouse.x - offset.x - margin; + float right_space = window_size.x - mouse.x - offset.x - margin; + bool place_left = left_space > right_space; + float available = place_left ? left_space : right_space; + if (available <= outer_padding) { + available = NK_MAX(1.0f, window_size.x - margin * 2.0f); + place_left = false; + } + const char *segment = text; + float natural_width = 0.0f; + while (segment) { + const char *end = strchr(segment, '\n'); + int length = end ? (int)(end - segment) : nk_strlen(segment); + natural_width = NK_MAX(natural_width, font->width(font->userdata, + font->height, + segment, length)); + segment = end ? end + 1 : NULL; + } + float content_width = NK_MIN(natural_width, + NK_MAX(1.0f, available - outer_padding)); + float width = content_width + outer_padding; + enum nk_tooltip_pos position; + bool place_above = mouse.y > window_size.y * .55f; + if (place_above) + position = place_left ? NK_BOTTOM_RIGHT : NK_BOTTOM_LEFT; + else + position = place_left ? NK_TOP_RIGHT : NK_TOP_LEFT; + if (!nk_tooltip_begin_offset(context, width, position, offset)) return; + float height = font->height + context->style.window.padding.y * 2.0f; + segment = text; + while (segment) { + const char *end = strchr(segment, '\n'); + int length = end ? (int)(end - segment) : nk_strlen(segment); + if (!length) { + nk_layout_row_dynamic(context, height, 1); + nk_text_colored(context, "", 0, NK_TEXT_LEFT, color); + } else { + while (length > 0) { + int fitting = tooltip_wrap_length(font, segment, length, + content_width); + nk_layout_row_dynamic(context, height, 1); + nk_text_colored(context, segment, fitting, NK_TEXT_LEFT, + color); + segment += fitting; + length -= fitting; + } + } + if (!end) break; + segment = end ? end + 1 : NULL; + } + nk_tooltip_end(context); +} + +void bongo_cat_ui_question_tooltip(struct nk_context *context, + const char *question, const char *reply) { + if (!context || !question || !question[0] || !reply || !reply[0]) return; + nk_layout_row_dynamic(context, 19.0f, 1); + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + const struct nk_user_font *font = bongo_cat_ui_caption_font(context); + BongoCatUIPalette palette = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + float question_width = font->width(font->userdata, font->height, + question, nk_strlen(question)); + struct nk_rect text = nk_rect(bounds.x + 5.0f, + bounds.y + (bounds.h - font->height) * .5f, question_width, + font->height); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, text); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, text, + BONGO_CAT_UI_CURSOR_POINTER); + } + nk_draw_text(nk_window_get_canvas(context), text, question, + nk_strlen(question), font, nk_rgba(0, 0, 0, 0), palette.accent); + if (hover) tooltip_multiline(context, reply, palette.pink); +} diff --git a/src/ui/backend/ui_tooltip.h b/src/ui/backend/ui_tooltip.h new file mode 100644 index 00000000..0717d40d --- /dev/null +++ b/src/ui/backend/ui_tooltip.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_UI_TOOLTIP_H +#define BONGO_CAT_UI_TOOLTIP_H + +#include "nuklear_config.h" + +void bongo_cat_ui_question_tooltip(struct nk_context *context, + const char *question, const char *reply); + +#endif diff --git a/src/ui/dial/dial_covers.c b/src/ui/dial/dial_covers.c new file mode 100644 index 00000000..7181ae48 --- /dev/null +++ b/src/ui/dial/dial_covers.c @@ -0,0 +1,42 @@ +#include "dial_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" + +void dial_covers_tick(Dial *d) { + if (d->active != 9) return; + for (int i = 0; i < dial_child_count(d); ++i) { + size_t index = (size_t)(d->page*DIAL_PAGE+i); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) continue; + DialCover *cover = &d->covers[index]; + if (cover->attempted) continue; + cover->attempted = true; + const char *directory = d->labels->model_cover_directories ? + d->labels->model_cover_directories[index] : NULL; + char path[BONGO_CAT_PATH_CAP]; + if (directory && bongo_cat_path_join(path,sizeof(path),directory,"resources/cover.png")) { + int width = 0, height = 0; + BongoCatError ignored = {0}; + int pixels = (int)fminf(160,ceilf(44*d->scale*d->raster_scale*1.25f)); + cover->texture = bongo_cat_image_texture_thumbnail(path,pixels,pixels,&width,&height,&ignored); + if (cover->texture && width > 0 && height > 0) { + float fit = fminf(44.0f/(float)width,40.0f/(float)height); + cover->width = (float)width*fit; cover->height = (float)height*fit; + } + } + d->dirty = true; + return; /* One image per frame; failed reads are also cached. */ + } +} + +bool dial_cover_draw(Dial *d, int child, float x, float y, float opacity) { + if (d->active != 9 || child < 0) return false; + size_t index = (size_t)(d->page*DIAL_PAGE+child); + if (index >= d->labels->model_count || index >= BONGO_CAT_MODEL_CAP) return false; + DialCover *cover = &d->covers[index]; + uint32_t color = 0xffffff | ((uint32_t)(255*opacity)<<24); + if (cover->texture && cover->width > 0 && cover->height > 0) + dial_quad(d,cover->texture,x-cover->width/2,y-cover->height/2, + cover->width,cover->height,0,0,1,1,color); + else dial_icon(d,9,x,y,28,(color&0xff000000)|0xa78bfa); + return true; +} diff --git a/src/ui/dial/dial_draw.c b/src/ui/dial/dial_draw.c new file mode 100644 index 00000000..99ff7933 --- /dev/null +++ b/src/ui/dial/dial_draw.c @@ -0,0 +1,80 @@ +#include "dial_internal.h" +#include + +static void vertex(Dial *d, GLuint texture, float x, float y, float u, float v, + uint32_t color) { + DialPaint *p = &d->paint; + if (p->failed) return; + if (p->count == p->capacity) { + size_t capacity = p->capacity ? p->capacity * 2 : 32768; + if (capacity > 524288) { p->failed = true; return; } + void *memory = realloc(p->vertices, capacity * sizeof(*p->vertices)); + if (!memory) { p->failed = true; return; } + p->vertices = memory; p->capacity = capacity; + } + if (!p->batch_count || p->batches[p->batch_count-1].texture != texture) { + if (p->batch_count == 512) { p->failed = true; return; } + p->batches[p->batch_count++] = (DialBatch){texture, (int)p->count, 0}; + } + float scale = d->scale * d->opening; + p->vertices[p->count++] = (DialVertex){ + (float)d->width / 2 + (x*p->zoom+p->offset_x)*scale, + (float)d->height / 2 + (y*p->zoom+p->offset_y)*scale, u, v, + (uint8_t)(color>>16), (uint8_t)(color>>8), (uint8_t)color, + (uint8_t)((float)(color>>24)*p->alpha)}; + p->batches[p->batch_count-1].count++; +} + +void dial_triangle(Dial *d, DialPoint a, DialPoint b, DialPoint c, + uint32_t ca, uint32_t cb, uint32_t cc) { + vertex(d,d->paint.white,a.x,a.y,0,0,ca); + vertex(d,d->paint.white,b.x,b.y,0,0,cb); + vertex(d,d->paint.white,c.x,c.y,0,0,cc); +} + +void dial_quad(Dial *d, GLuint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, uint32_t color) { + vertex(d,texture,x,y,u0,v0,color); + vertex(d,texture,x+w,y,u1,v0,color); + vertex(d,texture,x+w,y+h,u1,v1,color); + vertex(d,texture,x,y,u0,v0,color); + vertex(d,texture,x+w,y+h,u1,v1,color); + vertex(d,texture,x,y+h,u0,v1,color); +} + +static void strip(Dial *d, DialPoint a, DialPoint b, float nx, float ny, + float inner, float outer, uint32_t ci, uint32_t co) { + DialPoint a0 = {a.x+nx*inner,a.y+ny*inner}, a1 = {a.x+nx*outer,a.y+ny*outer}; + DialPoint b0 = {b.x+nx*inner,b.y+ny*inner}, b1 = {b.x+nx*outer,b.y+ny*outer}; + dial_triangle(d,a0,b0,b1,ci,ci,co); + dial_triangle(d,a0,b1,a1,ci,co,co); +} + +void dial_dot(Dial *d, float x, float y, float r, uint32_t color) { + const int count = 20; + for (int i = 0; i < count; ++i) { + float a = (float)i*2*DIAL_PI/(float)count, b = (float)(i+1)*2*DIAL_PI/(float)count; + DialPoint p = {x+r*cosf(a),y+r*sinf(a)}, q = {x+r*cosf(b),y+r*sinf(b)}; + dial_triangle(d,(DialPoint){x,y},p,q,color,color,color); + } +} + +void dial_stroke(Dial *d, const DialPoint *points, int count, float width, + uint32_t color, bool smooth) { + float edge = .7f / fmaxf(.1f, d->scale*d->opening); + for (int i = 1; i < count; ++i) { + DialPoint a = points[i-1], b = points[i]; + float dx = b.x-a.x, dy = b.y-a.y, length = hypotf(dx,dy); + if (length < .001f) continue; + float nx = -dy/length, ny = dx/length; + strip(d,a,b,nx,ny,-width/2,width/2,color,color); + if (smooth) { + strip(d,a,b,nx,ny,width/2,width/2+edge,color,color&0xffffff); + strip(d,a,b,nx,ny,-width/2,-width/2-edge,color,color&0xffffff); + } + } + if (smooth && count > 1) { + dial_dot(d,points[0].x,points[0].y,width/2,color); + dial_dot(d,points[count-1].x,points[count-1].y,width/2,color); + } +} diff --git a/src/ui/dial/dial_fonts.c b/src/ui/dial/dial_fonts.c new file mode 100644 index 00000000..6c47803f --- /dev/null +++ b/src/ui/dial/dial_fonts.c @@ -0,0 +1,172 @@ +#include "dial_internal.h" +#include "ui_font.h" +#include "ui_font_atlas_internal.h" +#include +#include + +static bool contains(const uint8_t *used, nk_rune rune) { + return (used[rune >> 3] & (1u << (rune & 7))) != 0; +} + +static void collect(uint8_t *used, const char *s) { + if (!s) return; + int length = (int)strlen(s); + while (length > 0) { + nk_rune rune; + int n = nk_utf_decode(s,&rune,length); + if (n <= 0) break; + if (rune >= 32 && rune < 0x110000) + used[rune >> 3] |= (uint8_t)(1u << (rune & 7)); + s += n; length -= n; + } +} + +static nk_rune *ranges(Dial *d) { + uint8_t *used = calloc(0x110000 / 8,1); + if (!used) return NULL; + for (int i = 32; i < 127; ++i) + used[i >> 3] |= (uint8_t)(1u << (i & 7)); + for (int i = 0; i < d->count; ++i) collect(used,d->items[i].label); + collect(used,d->labels->add_model); + for (size_t i = 0; i < d->labels->model_count; ++i) collect(used,d->labels->model_names[i]); + for (size_t i = 0; i < d->labels->motion_count; ++i) collect(used,d->labels->motion_names[i]); + for (size_t i = 0; i < d->labels->expression_count; ++i) collect(used,d->labels->expression_names[i]); + for (size_t i = 0; i < d->labels->audio_count; ++i) collect(used,d->labels->audio_names[i]); + size_t count = 0; + for (nk_rune i = 32; i < 0x110000; ++i) + if (contains(used,i) && (i == 32 || !contains(used,i-1))) ++count; + nk_rune *result = calloc(count*2+1,sizeof(*result)); + if (result) { + size_t out = 0; + for (nk_rune i = 32; i < 0x110000; ++i) { + if (!contains(used,i)) continue; + result[out++] = i; + while (i+1 < 0x110000 && contains(used,i+1)) ++i; + result[out++] = i; + } + } + free(used); + return result; +} + +bool dial_fonts_init(Dial *d) { + DialPaint *p = &d->paint; + p->ranges[0] = ranges(d); + if (!p->ranges[0] || !bongo_cat_ui_font_split_ranges(p->ranges[0], + &p->ranges[1],&p->ranges[2],&p->ranges[3])) { + /* split_ranges frees partial allocations on failure. */ + p->ranges[1] = p->ranges[2] = p->ranges[3] = NULL; + return false; + } + char paths[3][BONGO_CAT_PATH_CAP]; + const char *names[] = { + bongo_cat_ui_system_font(paths[0],sizeof(paths[0]),false), + bongo_cat_ui_system_font(paths[1],sizeof(paths[1]),true), + bongo_cat_ui_system_korean_font(paths[2],sizeof(paths[2]))}; + UIFontSource sources[3] = {0}; + for (int i = 0; i < 3; ++i) + if (names[i]) bongo_cat_ui_font_source_load(&sources[i],names[i]); + nk_font_atlas_init_default(&p->atlas); + nk_font_atlas_begin(&p->atlas); + float height = 24 * fmaxf(1,fminf(2,d->scale*d->raster_scale)); + p->font = bongo_cat_ui_font_add_family(&p->atlas,&sources[0],&sources[1],&sources[2], + height,p->ranges[0],p->ranges[1],p->ranges[2],p->ranges[3]); + int width = 0, rows = 0, maximum = 0; + const void *pixels = p->font ? nk_font_atlas_bake(&p->atlas,&width,&rows,NK_FONT_ATLAS_ALPHA8) : NULL; + glGetIntegerv(GL_MAX_TEXTURE_SIZE,&maximum); + bool ok = pixels && width > 0 && rows > 0 && width <= maximum && rows <= maximum; + if (ok) { + glGenTextures(1,&p->font_texture); + glBindTexture(GL_TEXTURE_2D,p->font_texture); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); + /* Match the preferences atlas: white RGB with single-channel alpha. */ + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_R,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_G,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_B,GL_ONE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_SWIZZLE_A,GL_RED); + glPixelStorei(GL_UNPACK_ALIGNMENT,1); + glTexImage2D(GL_TEXTURE_2D,0,GL_R8,width,rows,0,GL_RED,GL_UNSIGNED_BYTE,pixels); + glPixelStorei(GL_UNPACK_ALIGNMENT,4); + ok = p->font_texture && glGetError() == GL_NO_ERROR; + nk_font_atlas_end(&p->atlas,nk_handle_id((int)p->font_texture),NULL); + } + for (int i = 0; i < 3; ++i) { + bongo_cat_ui_font_detach_source(&p->atlas,&sources[i]); + bongo_cat_ui_font_source_release(&sources[i]); + } + nk_font_atlas_cleanup(&p->atlas); + return ok; +} + +static void line(Dial *d, const char *s, int length, float x, float y, + float width, float size, uint32_t color) { + struct nk_font *font = d->paint.font; + const struct nk_font_glyph *glyphs[BONGO_CAT_MENU_LABEL_CAP]; + int count = 0; + float total = 0; + while (length > 0 && count < BONGO_CAT_MENU_LABEL_CAP) { + nk_rune rune; + int n = nk_utf_decode(s,&rune,length); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) { glyphs[count++] = g; total += g->xadvance; } + s += n; length -= n; + } + float scale = size/font->info.height; + if (total*scale > width && total > 0) scale = width/total; + float left = x-total*scale/2, top = y-font->info.height*scale/2; + for (int i = 0; i < count; ++i) { + const struct nk_font_glyph *g = glyphs[i]; + if (g->w > 0 && g->h > 0) + dial_quad(d,d->paint.font_texture,left+g->x0*scale,top+g->y0*scale, + g->w*scale,g->h*scale,g->u0,g->v0,g->u1,g->v1,color); + left += g->xadvance*scale; + } +} + +void dial_text(Dial *d, const char *s, float x, float y, float width, + float size, uint32_t color) { + if (!s || !d->paint.font) return; + if (!strncmp(s,"live2d_",7)) { + line(d,"live2d",6,x,y-size*.55f,width,size,color); + line(d,s+7,(int)strlen(s+7),x,y+size*.55f,width,size,color); + } else { + /* Wrap at a UTF-8 boundary before shrinking a long name. Two lines + fit the child ring while keeping normal labels at full size. */ + int length = (int)strlen(s), offset = 0, split = 0; + float total = 0, best = width; + struct nk_font *font = d->paint.font; + float scale = size/font->info.height; + while (offset < length) { + nk_rune rune; + int n = nk_utf_decode(s+offset,&rune,length-offset); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) total += g->xadvance*scale; + offset += n; + } + if (total > width) { + float advance = 0; + offset = 0; best = total; + while (offset < length) { + nk_rune rune; + int n = nk_utf_decode(s+offset,&rune,length-offset); + if (n <= 0) break; + const struct nk_font_glyph *g = nk_font_find_glyph(font,rune); + if (g) advance += g->xadvance*scale; + offset += n; + float difference = fabsf(total-2*advance); + if (offset < length && difference < best) { + best = difference; split = offset; + } + } + } + if (split) { + line(d,s,split,x,y-size*.55f,width,size,color); + line(d,s+split,length-split,x,y+size*.55f,width,size,color); + } else line(d,s,length,x,y,width,size,color); + } +} diff --git a/src/ui/dial/dial_geometry.c b/src/ui/dial/dial_geometry.c new file mode 100644 index 00000000..d57368d5 --- /dev/null +++ b/src/ui/dial/dial_geometry.c @@ -0,0 +1,137 @@ +#include "dial_internal.h" +#include + +static DialPoint polar(float r, float a) { + return (DialPoint){r * cosf(a), r * sinf(a)}; +} +static void point(DialPath *p, DialPoint v) { + if (p->count && hypotf(v.x - p->points[p->count-1].x, + v.y - p->points[p->count-1].y) < .001f) return; + if (p->count < DIAL_PATH_POINTS) p->points[p->count++] = v; +} +static void quad(DialPath *p, DialPoint a, DialPoint c, DialPoint b) { + point(p, a); + for (int i = 1; i <= 6; ++i) { + float t = (float)i / 6, u = 1 - t; + point(p, (DialPoint){u*u*a.x + 2*u*t*c.x + t*t*b.x, + u*u*a.y + 2*u*t*c.y + t*t*b.y}); + } +} +static void arc(DialPath *p, float r, float a, float b) { + int steps = (int)ceilf(fabsf(b-a) * r / 3); + if (steps < 1) steps = 1; + for (int i = 0; i <= steps; ++i) + point(p, polar(r, a + (b-a) * (float)i / (float)steps)); +} +static float cross(DialPoint a, DialPoint b, DialPoint c) { + return (b.x-a.x)*(c.y-a.y) - (b.y-a.y)*(c.x-a.x); +} +static void triangulate(DialPath *p) { + unsigned short remaining[DIAL_PATH_POINTS]; + float area = 0; + for (int i = 0; i < p->count; ++i) { + remaining[i] = (unsigned short)i; + DialPoint a = p->points[i], b = p->points[(i+1)%p->count]; + area += a.x*b.y - b.x*a.y; + } + float sign = area > 0 ? 1.0f : -1.0f; + int count = p->count; + while (count >= 3) { + bool found = false; + for (int i = 0; i < count; ++i) { + int a = remaining[(i+count-1)%count], b = remaining[i], c = remaining[(i+1)%count]; + if (cross(p->points[a], p->points[b], p->points[c])*sign <= .00001f) continue; + bool inside = false; + for (int j = 0; j < count; ++j) { + int v = remaining[j]; + if (v == a || v == b || v == c) continue; + DialPoint q = p->points[v]; + if (cross(p->points[a], p->points[b], q)*sign >= 0 && + cross(p->points[b], p->points[c], q)*sign >= 0 && + cross(p->points[c], p->points[a], q)*sign >= 0) { + inside = true; break; + } + } + if (inside) continue; + p->triangles[p->triangle_count++] = (unsigned short)a; + p->triangles[p->triangle_count++] = (unsigned short)b; + p->triangles[p->triangle_count++] = (unsigned short)c; + memmove(remaining+i, remaining+i+1, (size_t)(count-i-1)*sizeof(*remaining)); + --count; found = true; break; + } + if (!found) break; + } +} +void dial_sector(DialPath *p, float inner, float outer, float start, float end) { + memset(p, 0, sizeof(*p)); + const float corner = 12, a = start + .024f, b = end - .024f; + const float ci = corner / inner, co = corner / outer; + point(p, polar(outer-corner, b)); + quad(p, polar(inner+corner,b), polar(inner,b), polar(inner,b-ci)); + arc(p, inner,b-ci,a+ci); + quad(p, polar(inner,a+ci),polar(inner,a),polar(inner+corner,a)); + p->rim_start = p->count; + quad(p,polar(outer-corner,a),polar(outer,a),polar(outer,a+co)); + arc(p,outer,a+co,b-co); + quad(p,polar(outer,b-co),polar(outer,b),polar(outer-corner,b)); + if (p->count > 1) --p->count; /* Last point closes back onto the first. */ + p->min_x = p->min_y = 1000; p->max_x = p->max_y = -1000; + for (int i = 0; i < p->count; ++i) { + p->min_x = fminf(p->min_x, p->points[i].x); p->max_x = fmaxf(p->max_x,p->points[i].x); + p->min_y = fminf(p->min_y, p->points[i].y); p->max_y = fmaxf(p->max_y,p->points[i].y); + } + triangulate(p); +} +void dial_child_paths(Dial *d) { + int count = dial_child_count(d); + float step = dial_child_step(d); + DialPaint *p = &d->paint; + /* Page contents do not affect geometry. Keep the last layout even when + the child ring is temporarily hidden; the draw loop uses child_count. */ + if (!count) return; + if (p->child_path_count == count && p->child_path_active == d->active && + p->child_path_roots == d->count && p->child_path_step == step) return; + memset(d->paint.children, 0, sizeof(d->paint.children)); + for (int i = 0; i < count; ++i) { + float angle = dial_child_angle(d,i), half = dial_child_step(d)/2; + dial_sector(&d->paint.children[i],198,262,angle-half,angle+half); + } + p->child_path_count = count; + p->child_path_active = d->active; + p->child_path_roots = d->count; + p->child_path_step = step; +} + +static float angle_distance(float a, float b) { + return atan2f(sinf(a - b), cosf(a - b)); +} + +void dial_hit(Dial *d, float x, float y, int *root, int *child) { + float radius = hypotf(x, y), angle = atan2f(y, x); + *root = -1; *child = -1; + if (d->active >= 0 && radius >= 190 && radius <= 277) { + for (int i = 0; i < dial_child_count(d); ++i) { + if (fabsf(angle_distance(angle, dial_child_angle(d, i))) < + dial_child_step(d) / 2) { + *root = d->active; *child = i; return; + } + } + } + if (radius >= 73 && radius <= 198) { + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) continue; + float center = -DIAL_PI / 2 + i * 2 * DIAL_PI / d->count; + if (fabsf(angle_distance(angle, center)) <= DIAL_PI / d->count) { + *root = i; return; + } + } + } + /* Keep the iris open while crossing its radial gap or the center hub. */ + if (radius <= 280) *root = d->active; +} + +void dial_transform(Dial *d, float zoom, float x, float y) { + d->paint.zoom = zoom; + d->paint.offset_x = x; + d->paint.offset_y = y; +} diff --git a/src/ui/dial/dial_icons.c b/src/ui/dial/dial_icons.c new file mode 100644 index 00000000..625328e6 --- /dev/null +++ b/src/ui/dial/dial_icons.c @@ -0,0 +1,210 @@ +/* Static vector contours transcribed from the supplied index.html. */ +#include "dial_internal.h" +typedef struct IconContour { unsigned short offset, count; } IconContour; +static const DialPoint points[] = { + {12.2200f, 2.0000f}, {11.7800f, 2.0000f}, {11.3350f, 2.0501f}, {10.9122f, 2.1981f}, + {10.5330f, 2.4363f}, {10.2163f, 2.7530f}, {9.9781f, 3.1322f}, {9.8301f, 3.5550f}, + {9.7800f, 4.0000f}, {9.7800f, 4.1800f}, {9.7360f, 4.5954f}, {9.6066f, 4.9925f}, + {9.3976f, 5.3542f}, {9.1180f, 5.6645f}, {8.7800f, 5.9100f}, {8.3500f, 6.1600f}, + {7.9680f, 6.3301f}, {7.5591f, 6.4170f}, {7.1409f, 6.4170f}, {6.7320f, 6.3301f}, + {6.3500f, 6.1600f}, {6.2000f, 6.0800f}, {5.7899f, 5.9012f}, {5.3504f, 5.8180f}, + {4.9033f, 5.8346f}, {4.4712f, 5.9502f}, {4.0755f, 6.1589f}, {3.7361f, 6.4504f}, + {3.4700f, 6.8100f}, {3.2500f, 7.1900f}, {3.0712f, 7.6001f}, {2.9880f, 8.0396f}, + {3.0046f, 8.4867f}, {3.1202f, 8.9188f}, {3.3289f, 9.3145f}, {3.6204f, 9.6539f}, + {3.9800f, 9.9200f}, {4.1300f, 10.0200f}, {4.4665f, 10.2641f}, {4.7452f, 10.5726f}, + {4.9542f, 10.9320f}, {5.0843f, 11.3268f}, {5.1300f, 11.7400f}, {5.1300f, 12.2500f}, + {5.0876f, 12.6676f}, {4.9590f, 13.0671f}, {4.7499f, 13.4309f}, {4.4694f, 13.7432f}, + {4.1300f, 13.9900f}, {3.9800f, 14.0800f}, {3.6204f, 14.3461f}, {3.3289f, 14.6855f}, + {3.1202f, 15.0812f}, {3.0046f, 15.5133f}, {2.9880f, 15.9604f}, {3.0712f, 16.3999f}, + {3.2500f, 16.8100f}, {3.4700f, 17.1900f}, {3.7361f, 17.5496f}, {4.0755f, 17.8411f}, + {4.4712f, 18.0498f}, {4.9033f, 18.1654f}, {5.3504f, 18.1820f}, {5.7899f, 18.0988f}, + {6.2000f, 17.9200f}, {6.3500f, 17.8400f}, {6.7320f, 17.6699f}, {7.1409f, 17.5830f}, + {7.5591f, 17.5830f}, {7.9680f, 17.6699f}, {8.3500f, 17.8400f}, {8.7800f, 18.0900f}, + {9.1180f, 18.3355f}, {9.3976f, 18.6458f}, {9.6066f, 19.0075f}, {9.7360f, 19.4046f}, + {9.7800f, 19.8200f}, {9.7800f, 20.0000f}, {9.8301f, 20.4450f}, {9.9781f, 20.8678f}, + {10.2163f, 21.2470f}, {10.5330f, 21.5637f}, {10.9122f, 21.8019f}, {11.3350f, 21.9499f}, + {11.7800f, 22.0000f}, {12.2200f, 22.0000f}, {12.6650f, 21.9499f}, {13.0878f, 21.8019f}, + {13.4670f, 21.5637f}, {13.7837f, 21.2470f}, {14.0219f, 20.8678f}, {14.1699f, 20.4450f}, + {14.2200f, 20.0000f}, {14.2200f, 19.8200f}, {14.2640f, 19.4046f}, {14.3934f, 19.0075f}, + {14.6024f, 18.6458f}, {14.8820f, 18.3355f}, {15.2200f, 18.0900f}, {15.6500f, 17.8400f}, + {16.0320f, 17.6699f}, {16.4409f, 17.5830f}, {16.8591f, 17.5830f}, {17.2680f, 17.6699f}, + {17.6500f, 17.8400f}, {17.8000f, 17.9200f}, {18.2101f, 18.0988f}, {18.6496f, 18.1820f}, + {19.0967f, 18.1654f}, {19.5288f, 18.0498f}, {19.9245f, 17.8411f}, {20.2639f, 17.5496f}, + {20.5300f, 17.1900f}, {20.7500f, 16.8000f}, {20.9288f, 16.3899f}, {21.0120f, 15.9504f}, + {20.9954f, 15.5033f}, {20.8798f, 15.0712f}, {20.6711f, 14.6755f}, {20.3796f, 14.3361f}, + {20.0200f, 14.0700f}, {19.8700f, 13.9900f}, {19.5306f, 13.7432f}, {19.2501f, 13.4309f}, + {19.0410f, 13.0671f}, {18.9124f, 12.6676f}, {18.8700f, 12.2500f}, {18.8700f, 11.7500f}, + {18.9124f, 11.3324f}, {19.0410f, 10.9329f}, {19.2501f, 10.5691f}, {19.5306f, 10.2568f}, + {19.8700f, 10.0100f}, {20.0200f, 9.9200f}, {20.3796f, 9.6539f}, {20.6711f, 9.3145f}, + {20.8798f, 8.9188f}, {20.9954f, 8.4867f}, {21.0120f, 8.0396f}, {20.9288f, 7.6001f}, + {20.7500f, 7.1900f}, {20.5300f, 6.8100f}, {20.2639f, 6.4504f}, {19.9245f, 6.1589f}, + {19.5288f, 5.9502f}, {19.0967f, 5.8346f}, {18.6496f, 5.8180f}, {18.2101f, 5.9012f}, + {17.8000f, 6.0800f}, {17.6500f, 6.1600f}, {17.2680f, 6.3301f}, {16.8591f, 6.4170f}, + {16.4409f, 6.4170f}, {16.0320f, 6.3301f}, {15.6500f, 6.1600f}, {15.2200f, 5.9100f}, + {14.8820f, 5.6645f}, {14.6024f, 5.3542f}, {14.3934f, 4.9925f}, {14.2640f, 4.5954f}, + {14.2200f, 4.1800f}, {14.2200f, 4.0000f}, {14.1699f, 3.5550f}, {14.0219f, 3.1322f}, + {13.7837f, 2.7530f}, {13.4670f, 2.4363f}, {13.0878f, 2.1981f}, {12.6650f, 2.0501f}, + {12.2200f, 2.0000f}, {12.2200f, 2.0000f}, {15.0000f, 12.0000f}, {14.9743f, 12.3916f}, + {14.8978f, 12.7765f}, {14.7716f, 13.1481f}, {14.5981f, 13.5000f}, {14.3801f, 13.8263f}, + {14.1213f, 14.1213f}, {13.8263f, 14.3801f}, {13.5000f, 14.5981f}, {13.1481f, 14.7716f}, + {12.7765f, 14.8978f}, {12.3916f, 14.9743f}, {12.0000f, 15.0000f}, {11.6084f, 14.9743f}, + {11.2235f, 14.8978f}, {10.8519f, 14.7716f}, {10.5000f, 14.5981f}, {10.1737f, 14.3801f}, + {9.8787f, 14.1213f}, {9.6199f, 13.8263f}, {9.4019f, 13.5000f}, {9.2284f, 13.1481f}, + {9.1022f, 12.7765f}, {9.0257f, 12.3916f}, {9.0000f, 12.0000f}, {9.0257f, 11.6084f}, + {9.1022f, 11.2235f}, {9.2284f, 10.8519f}, {9.4019f, 10.5000f}, {9.6199f, 10.1737f}, + {9.8787f, 9.8787f}, {10.1737f, 9.6199f}, {10.5000f, 9.4019f}, {10.8519f, 9.2284f}, + {11.2235f, 9.1022f}, {11.6084f, 9.0257f}, {12.0000f, 9.0000f}, {12.3916f, 9.0257f}, + {12.7765f, 9.1022f}, {13.1481f, 9.2284f}, {13.5000f, 9.4019f}, {13.8263f, 9.6199f}, + {14.1213f, 9.8787f}, {14.3801f, 10.1737f}, {14.5981f, 10.5000f}, {14.7716f, 10.8519f}, + {14.8978f, 11.2235f}, {14.9743f, 11.6084f}, {15.0000f, 12.0000f}, {9.8800f, 9.8800f}, + {9.5553f, 10.2353f}, {9.2917f, 10.6380f}, {9.0958f, 11.0776f}, {8.9727f, 11.5429f}, + {8.9256f, 12.0219f}, {8.9558f, 12.5023f}, {9.0623f, 12.9716f}, {9.2426f, 13.4179f}, + {9.4919f, 13.8296f}, {9.8038f, 14.1962f}, {10.1704f, 14.5081f}, {10.5821f, 14.7574f}, + {11.0284f, 14.9377f}, {11.4977f, 15.0442f}, {11.9781f, 15.0744f}, {12.4571f, 15.0273f}, + {12.9224f, 14.9042f}, {13.3620f, 14.7083f}, {13.7647f, 14.4447f}, {14.1200f, 14.1200f}, + {10.7300f, 5.0800f}, {11.1521f, 5.0361f}, {11.5757f, 5.0094f}, {12.0000f, 5.0000f}, + {13.6672f, 5.1377f}, {15.1713f, 5.5185f}, {16.5156f, 6.0938f}, {17.7037f, 6.8148f}, + {18.7390f, 7.6331f}, {19.6250f, 8.5000f}, {20.3652f, 9.3669f}, {20.9630f, 10.1852f}, + {21.4219f, 10.9062f}, {21.7454f, 11.4815f}, {21.9369f, 11.8623f}, {22.0000f, 12.0000f}, + {21.8016f, 12.4063f}, {21.5894f, 12.8056f}, {21.3636f, 13.1974f}, {21.1245f, 13.5811f}, + {20.8723f, 13.9564f}, {20.6074f, 14.3229f}, {20.3300f, 14.6800f}, {6.6100f, 6.6100f}, + {6.2194f, 6.8862f}, {5.8388f, 7.1761f}, {5.4687f, 7.4793f}, {5.1096f, 7.7953f}, + {4.7619f, 8.1239f}, {4.4260f, 8.4646f}, {4.1023f, 8.8169f}, {3.7914f, 9.1805f}, + {3.4935f, 9.5548f}, {3.2090f, 9.9395f}, {2.9383f, 10.3339f}, {2.6818f, 10.7377f}, + {2.4397f, 11.1504f}, {2.2123f, 11.5713f}, {2.0000f, 12.0000f}, {2.0631f, 12.1377f}, + {2.2546f, 12.5185f}, {2.5781f, 13.0938f}, {3.0370f, 13.8148f}, {3.6348f, 14.6331f}, + {4.3750f, 15.5000f}, {5.2610f, 16.3669f}, {6.2963f, 17.1852f}, {7.4844f, 17.9062f}, + {8.8287f, 18.4815f}, {10.3328f, 18.8623f}, {12.0000f, 19.0000f}, {12.4754f, 18.9897f}, + {12.9497f, 18.9561f}, {13.4218f, 18.8995f}, {13.8906f, 18.8199f}, {14.3550f, 18.7175f}, + {14.8138f, 18.5926f}, {15.2659f, 18.4454f}, {15.7103f, 18.2764f}, {16.1460f, 18.0858f}, + {16.5718f, 17.8742f}, {16.9868f, 17.6421f}, {17.3900f, 17.3900f}, {2.0000f, 2.0000f}, + {22.0000f, 22.0000f}, {5.0000f, 3.0000f}, {18.0000f, 12.0000f}, {12.0000f, 13.0000f}, + {9.0000f, 19.0000f}, {5.0000f, 3.0000f}, {17.0000f, 3.0000f}, {17.0000f, 7.0000f}, + {15.0000f, 5.0000f}, {19.0000f, 5.0000f}, {17.0000f, 17.0000f}, {20.0000f, 20.0000f}, + {12.0000f, 17.0000f}, {12.0000f, 22.0000f}, {5.0000f, 17.0000f}, {19.0000f, 17.0000f}, + {17.3000f, 10.2000f}, {17.1473f, 9.7954f}, {16.9112f, 9.4330f}, {16.6027f, 9.1299f}, + {16.2363f, 8.9002f}, {15.8290f, 8.7546f}, {15.4000f, 8.7000f}, {8.6000f, 8.7000f}, + {8.1710f, 8.7546f}, {7.7637f, 8.9002f}, {7.3973f, 9.1299f}, {7.0888f, 9.4330f}, + {6.8527f, 9.7954f}, {6.7000f, 10.2000f}, {5.0000f, 17.0000f}, {5.0000f, 17.0000f}, + {9.0000f, 8.0000f}, {9.0000f, 4.0000f}, {9.0761f, 3.6173f}, {9.2929f, 3.2929f}, + {9.6173f, 3.0761f}, {10.0000f, 3.0000f}, {14.0000f, 3.0000f}, {14.3827f, 3.0761f}, + {14.7071f, 3.2929f}, {14.9239f, 3.6173f}, {15.0000f, 4.0000f}, {15.0000f, 8.0000f}, + {15.0000f, 3.0000f}, {21.0000f, 3.0000f}, {21.0000f, 9.0000f}, {9.0000f, 21.0000f}, + {3.0000f, 21.0000f}, {3.0000f, 15.0000f}, {21.0000f, 3.0000f}, {14.0000f, 10.0000f}, + {3.0000f, 21.0000f}, {10.0000f, 14.0000f}, {12.0000f, 2.0000f}, {11.8623f, 2.1597f}, + {11.4815f, 2.6111f}, {10.9062f, 3.3125f}, {10.1852f, 4.2222f}, {9.3669f, 5.2986f}, + {8.5000f, 6.5000f}, {7.6331f, 7.7847f}, {6.8148f, 9.1111f}, {6.0938f, 10.4375f}, + {5.5185f, 11.7222f}, {5.1377f, 12.9236f}, {5.0000f, 14.0000f}, {5.0178f, 14.4994f}, + {5.0712f, 14.9962f}, {5.1600f, 15.4880f}, {5.2835f, 15.9721f}, {5.4414f, 16.4462f}, + {5.6326f, 16.9079f}, {5.8562f, 17.3547f}, {6.1112f, 17.7845f}, {6.3962f, 18.1949f}, + {6.7098f, 18.5840f}, {7.0503f, 18.9497f}, {7.4160f, 19.2902f}, {7.8051f, 19.6038f}, + {8.2155f, 19.8888f}, {8.6453f, 20.1438f}, {9.0921f, 20.3674f}, {9.5538f, 20.5586f}, + {10.0279f, 20.7165f}, {10.5120f, 20.8400f}, {11.0038f, 20.9288f}, {11.5006f, 20.9822f}, + {12.0000f, 21.0000f}, {12.4994f, 20.9822f}, {12.9962f, 20.9288f}, {13.4880f, 20.8400f}, + {13.9721f, 20.7165f}, {14.4462f, 20.5586f}, {14.9079f, 20.3674f}, {15.3547f, 20.1438f}, + {15.7845f, 19.8888f}, {16.1949f, 19.6038f}, {16.5840f, 19.2902f}, {16.9497f, 18.9497f}, + {17.2902f, 18.5840f}, {17.6038f, 18.1949f}, {17.8888f, 17.7845f}, {18.1438f, 17.3547f}, + {18.3674f, 16.9079f}, {18.5586f, 16.4462f}, {18.7165f, 15.9721f}, {18.8400f, 15.4880f}, + {18.9288f, 14.9962f}, {18.9822f, 14.4994f}, {19.0000f, 14.0000f}, {18.8623f, 12.9236f}, + {18.4815f, 11.7222f}, {17.9062f, 10.4375f}, {17.1852f, 9.1111f}, {16.3669f, 7.7847f}, + {15.5000f, 6.5000f}, {14.6331f, 5.2986f}, {13.8148f, 4.2222f}, {13.0938f, 3.3125f}, + {12.5185f, 2.6111f}, {12.1377f, 2.1597f}, {12.0000f, 2.0000f}, {12.0000f, 2.0000f}, + {12.0000f, 8.0000f}, {12.0000f, 20.0000f}, {13.0000f, 2.0000f}, {3.0000f, 14.0000f}, + {12.0000f, 14.0000f}, {11.0000f, 22.0000f}, {21.0000f, 10.0000f}, {12.0000f, 10.0000f}, + {13.0000f, 2.0000f}, {13.0000f, 2.0000f}, {22.0000f, 12.0000f}, {21.9144f, 13.3053f}, + {21.6593f, 14.5882f}, {21.2388f, 15.8268f}, {20.6603f, 17.0000f}, {19.9335f, 18.0876f}, + {19.0711f, 19.0711f}, {18.0876f, 19.9335f}, {17.0000f, 20.6603f}, {15.8268f, 21.2388f}, + {14.5882f, 21.6593f}, {13.3053f, 21.9144f}, {12.0000f, 22.0000f}, {10.6947f, 21.9144f}, + {9.4118f, 21.6593f}, {8.1732f, 21.2388f}, {7.0000f, 20.6603f}, {5.9124f, 19.9335f}, + {4.9289f, 19.0711f}, {4.0665f, 18.0876f}, {3.3397f, 17.0000f}, {2.7612f, 15.8268f}, + {2.3407f, 14.5882f}, {2.0856f, 13.3053f}, {2.0000f, 12.0000f}, {2.0856f, 10.6947f}, + {2.3407f, 9.4118f}, {2.7612f, 8.1732f}, {3.3397f, 7.0000f}, {4.0665f, 5.9124f}, + {4.9289f, 4.9289f}, {5.9124f, 4.0665f}, {7.0000f, 3.3397f}, {8.1732f, 2.7612f}, + {9.4118f, 2.3407f}, {10.6947f, 2.0856f}, {12.0000f, 2.0000f}, {13.3053f, 2.0856f}, + {14.5882f, 2.3407f}, {15.8268f, 2.7612f}, {17.0000f, 3.3397f}, {18.0876f, 4.0665f}, + {19.0711f, 4.9289f}, {19.9335f, 5.9124f}, {20.6603f, 7.0000f}, {21.2388f, 8.1732f}, + {21.6593f, 9.4118f}, {21.9144f, 10.6947f}, {22.0000f, 12.0000f}, {8.0000f, 14.0000f}, + {8.0310f, 14.0394f}, {8.1227f, 14.1481f}, {8.2734f, 14.3125f}, {8.4815f, 14.5185f}, + {8.7451f, 14.7523f}, {9.0625f, 15.0000f}, {9.4320f, 15.2477f}, {9.8519f, 15.4815f}, + {10.3203f, 15.6875f}, {10.8356f, 15.8519f}, {11.3961f, 15.9606f}, {12.0000f, 16.0000f}, + {12.6039f, 15.9606f}, {13.1644f, 15.8519f}, {13.6797f, 15.6875f}, {14.1481f, 15.4815f}, + {14.5680f, 15.2477f}, {14.9375f, 15.0000f}, {15.2549f, 14.7523f}, {15.5185f, 14.5185f}, + {15.7266f, 14.3125f}, {15.8773f, 14.1481f}, {15.9690f, 14.0394f}, {16.0000f, 14.0000f}, + {9.0000f, 9.0000f}, {9.0100f, 9.0000f}, {15.0000f, 9.0000f}, {15.0100f, 9.0000f}, + {9.0000f, 18.0000f}, {9.0000f, 5.0000f}, {21.0000f, 3.0000f}, {21.0000f, 16.0000f}, + {9.0000f, 18.0000f}, {8.9743f, 18.3916f}, {8.8978f, 18.7765f}, {8.7716f, 19.1481f}, + {8.5981f, 19.5000f}, {8.3801f, 19.8263f}, {8.1213f, 20.1213f}, {7.8263f, 20.3801f}, + {7.5000f, 20.5981f}, {7.1481f, 20.7716f}, {6.7765f, 20.8978f}, {6.3916f, 20.9743f}, + {6.0000f, 21.0000f}, {5.6084f, 20.9743f}, {5.2235f, 20.8978f}, {4.8519f, 20.7716f}, + {4.5000f, 20.5981f}, {4.1737f, 20.3801f}, {3.8787f, 20.1213f}, {3.6199f, 19.8263f}, + {3.4019f, 19.5000f}, {3.2284f, 19.1481f}, {3.1022f, 18.7765f}, {3.0257f, 18.3916f}, + {3.0000f, 18.0000f}, {3.0257f, 17.6084f}, {3.1022f, 17.2235f}, {3.2284f, 16.8519f}, + {3.4019f, 16.5000f}, {3.6199f, 16.1737f}, {3.8787f, 15.8787f}, {4.1737f, 15.6199f}, + {4.5000f, 15.4019f}, {4.8519f, 15.2284f}, {5.2235f, 15.1022f}, {5.6084f, 15.0257f}, + {6.0000f, 15.0000f}, {6.3916f, 15.0257f}, {6.7765f, 15.1022f}, {7.1481f, 15.2284f}, + {7.5000f, 15.4019f}, {7.8263f, 15.6199f}, {8.1213f, 15.8787f}, {8.3801f, 16.1737f}, + {8.5981f, 16.5000f}, {8.7716f, 16.8519f}, {8.8978f, 17.2235f}, {8.9743f, 17.6084f}, + {9.0000f, 18.0000f}, {21.0000f, 16.0000f}, {20.9743f, 16.3916f}, {20.8978f, 16.7765f}, + {20.7716f, 17.1481f}, {20.5981f, 17.5000f}, {20.3801f, 17.8263f}, {20.1213f, 18.1213f}, + {19.8263f, 18.3801f}, {19.5000f, 18.5981f}, {19.1481f, 18.7716f}, {18.7765f, 18.8978f}, + {18.3916f, 18.9743f}, {18.0000f, 19.0000f}, {17.6084f, 18.9743f}, {17.2235f, 18.8978f}, + {16.8519f, 18.7716f}, {16.5000f, 18.5981f}, {16.1737f, 18.3801f}, {15.8787f, 18.1213f}, + {15.6199f, 17.8263f}, {15.4019f, 17.5000f}, {15.2284f, 17.1481f}, {15.1022f, 16.7765f}, + {15.0257f, 16.3916f}, {15.0000f, 16.0000f}, {15.0257f, 15.6084f}, {15.1022f, 15.2235f}, + {15.2284f, 14.8519f}, {15.4019f, 14.5000f}, {15.6199f, 14.1737f}, {15.8787f, 13.8787f}, + {16.1737f, 13.6199f}, {16.5000f, 13.4019f}, {16.8519f, 13.2284f}, {17.2235f, 13.1022f}, + {17.6084f, 13.0257f}, {18.0000f, 13.0000f}, {18.3916f, 13.0257f}, {18.7765f, 13.1022f}, + {19.1481f, 13.2284f}, {19.5000f, 13.4019f}, {19.8263f, 13.6199f}, {20.1213f, 13.8787f}, + {20.3801f, 14.1737f}, {20.5981f, 14.5000f}, {20.7716f, 14.8519f}, {20.8978f, 15.2235f}, + {20.9743f, 15.6084f}, {21.0000f, 16.0000f}, {21.0000f, 16.0000f}, {21.0000f, 8.0000f}, + {20.9560f, 7.5846f}, {20.8266f, 7.1875f}, {20.6176f, 6.8258f}, {20.3380f, 6.5155f}, + {20.0000f, 6.2700f}, {13.0000f, 2.2700f}, {12.6180f, 2.0999f}, {12.2091f, 2.0130f}, + {11.7909f, 2.0130f}, {11.3820f, 2.0999f}, {11.0000f, 2.2700f}, {4.0000f, 6.2700f}, + {3.6620f, 6.5155f}, {3.3824f, 6.8258f}, {3.1734f, 7.1875f}, {3.0440f, 7.5846f}, + {3.0000f, 8.0000f}, {3.0000f, 16.0000f}, {3.0440f, 16.4154f}, {3.1734f, 16.8125f}, + {3.3824f, 17.1742f}, {3.6620f, 17.4845f}, {4.0000f, 17.7300f}, {11.0000f, 21.7300f}, + {11.3820f, 21.9001f}, {11.7909f, 21.9870f}, {12.2091f, 21.9870f}, {12.6180f, 21.9001f}, + {13.0000f, 21.7300f}, {20.0000f, 17.7300f}, {20.3380f, 17.4845f}, {20.6176f, 17.1742f}, + {20.8266f, 16.8125f}, {20.9560f, 16.4154f}, {21.0000f, 16.0000f}, {21.0000f, 16.0000f}, + {9.0000f, 21.0000f}, {5.0000f, 21.0000f}, {4.5550f, 20.9499f}, {4.1322f, 20.8019f}, + {3.7530f, 20.5637f}, {3.4363f, 20.2470f}, {3.1981f, 19.8678f}, {3.0501f, 19.4450f}, + {3.0000f, 19.0000f}, {3.0000f, 5.0000f}, {3.0501f, 4.5550f}, {3.1981f, 4.1322f}, + {3.4363f, 3.7530f}, {3.7530f, 3.4363f}, {4.1322f, 3.1981f}, {4.5550f, 3.0501f}, + {5.0000f, 3.0000f}, {9.0000f, 3.0000f}, {16.0000f, 17.0000f}, {21.0000f, 12.0000f}, + {16.0000f, 7.0000f}, {21.0000f, 12.0000f}, {9.0000f, 12.0000f}, {18.0000f, 6.0000f}, + {6.0000f, 18.0000f}, {6.0000f, 6.0000f}, {18.0000f, 18.0000f}, {5.0000f, 3.0000f}, + {18.0000f, 12.0000f}, {12.0000f, 13.0000f}, {9.0000f, 19.0000f}, {5.0000f, 3.0000f}, + {4.0000f, 4.0000f}, {20.0000f, 20.0000f}, {2.0000f, 2.0000f}, {22.0000f, 22.0000f}, + {12.0000f, 17.0000f}, {12.0000f, 22.0000f}, {9.0000f, 8.0000f}, {9.0000f, 4.0000f}, + {9.0761f, 3.6173f}, {9.2929f, 3.2929f}, {9.6173f, 3.0761f}, {10.0000f, 3.0000f}, + {14.0000f, 3.0000f}, {14.3827f, 3.0761f}, {14.7071f, 3.2929f}, {14.9239f, 3.6173f}, + {15.0000f, 4.0000f}, {15.0000f, 8.0000f}, +}; +static const IconContour contours[] = { + {0, 170}, {170, 49}, {219, 21}, {240, 23}, {263, 40}, {303, 2}, + {305, 5}, {310, 2}, {312, 2}, {314, 2}, {316, 2}, {318, 18}, + {336, 12}, {348, 3}, {351, 3}, {354, 2}, {356, 2}, {358, 70}, + {428, 2}, {430, 8}, {438, 49}, {487, 25}, {512, 2}, {514, 2}, + {516, 4}, {520, 49}, {569, 49}, {618, 38}, {656, 18}, {674, 3}, + {677, 2}, {679, 2}, {681, 2}, {683, 5}, {688, 2}, {690, 2}, + {692, 2}, {694, 12}, +}; +static const IconContour icons[] = { + {0, 2}, {2, 4}, {6, 4}, {10, 3}, {13, 4}, {17, 2}, {19, 1}, {20, 4}, {24, 3}, {27, 1}, {28, 3}, {31, 2}, {33, 2}, {35, 3} +}; + +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color) { + if (icon < 0 || icon >= (int)(sizeof(icons) / sizeof(icons[0]))) return; + IconContour range = icons[icon]; + for (int i = 0; i < range.count; ++i) { + IconContour contour = contours[range.offset + i]; + DialPoint transformed[512]; + if (contour.count > 512) continue; + for (int j = 0; j < contour.count; ++j) { + DialPoint p = points[contour.offset + j]; + transformed[j] = (DialPoint){x + (p.x - 12) * size / 24, + y + (p.y - 12) * size / 24}; + } + dial_stroke(d, transformed, contour.count, 1.95f * size / 24, color, true); + } +} diff --git a/src/ui/dial/dial_input.c b/src/ui/dial/dial_input.c new file mode 100644 index 00000000..1baffff8 --- /dev/null +++ b/src/ui/dial/dial_input.c @@ -0,0 +1,106 @@ +#include "dial_internal.h" + +static void pointer(Dial *d, float px, float py, bool click, int *root, int *child) { + float scale = d->scale*d->opening; + float x = (px-(float)d->width/2)/scale, y = (py-(float)d->height/2)/scale; + float radius = hypotf(x,y); + if (d->child_focus && radius < 190) { d->child_focus = false; d->dirty = true; } + dial_hit(d,x,y,root,child); + if (click && *child < 0 && *root >= 0) { + float angle = atan2f(y,x)-(-DIAL_PI/2+(float)*root*2*DIAL_PI/(float)d->count); + if (radius < 73 || radius > 198 || + fabsf(atan2f(sinf(angle),cosf(angle))) > DIAL_PI/(float)d->count) *root = -1; + } +} + +static void activate(Dial *d) { + if (d->active < 0) return; + char text[32]; + DialItem item = d->child < 0 ? d->items[d->active] : dial_child_item(d,d->child,text,sizeof(text)); + if (item.children) { dial_select(d,d->active,0); return; } + if (item.command != BONGO_CAT_MENU_NONE) { d->result = item.command; d->done = true; } +} + +static void page(Dial *d, int direction) { + if (d->active < 0 || d->items[d->active].children <= DIAL_PAGE) return; + int pages = ((int)d->items[d->active].children+DIAL_PAGE-1)/DIAL_PAGE; + d->page = (d->page+direction+pages)%pages; + d->child = -1; + if (d->preview != BONGO_CAT_MENU_NONE) dial_preview(d,BONGO_CAT_MENU_NONE); + d->preview = BONGO_CAT_MENU_NONE; + d->changed_at = SDL_GetTicks(); + dial_child_paths(d); d->dirty = true; +} + +static void key(Dial *d, const SDL_KeyboardEvent *event) { + SDL_Keycode value = event->key; + if (value == SDLK_ESCAPE) { + if (d->active >= 0 && dial_child_count(d)) dial_select(d,-1,-1); + else d->done = true; + } else if (value == SDLK_RETURN || value == SDLK_SPACE) activate(d); + else if (value == SDLK_PAGEUP || value == SDLK_PAGEDOWN) page(d,value == SDLK_PAGEDOWN ? 1 : -1); + else if (value == SDLK_LEFT || value == SDLK_UP || value == SDLK_RIGHT || + value == SDLK_DOWN || value == SDLK_TAB) { + int direction = value == SDLK_LEFT || value == SDLK_UP || + (value == SDLK_TAB && (event->mod & SDL_KMOD_SHIFT)) ? -1 : 1; + int count = dial_child_count(d); + if (d->child >= 0 && count) dial_select(d,d->active,(d->child+direction+count)%count); + else dial_select(d,d->active < 0 ? 0 : (d->active+direction+d->count)%d->count,-1); + } else if (value == SDLK_BACKSPACE) { + d->child_focus = false; d->dirty = true; dial_select(d,d->active,-1); + } +} + +void dial_event(Dial *d, const SDL_Event *e) { + int root, child; + switch (e->type) { + case SDL_EVENT_MOUSE_MOTION: + pointer(d,e->motion.x,e->motion.y,false,&root,&child); + dial_select(d,root,child); break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if (e->button.button != SDL_BUTTON_LEFT) { d->done = true; break; } + pointer(d,e->button.x,e->button.y,true,&root,&child); + if (root < 0) { d->done = true; break; } + dial_select(d,root,child); + d->pressed = child >= 0 ? DIAL_ROOTS+child : root; d->dirty = true; + break; + case SDL_EVENT_MOUSE_BUTTON_UP: { + if (e->button.button != SDL_BUTTON_LEFT) break; + int pressed = d->pressed; d->pressed = -1; d->dirty = true; + pointer(d,e->button.x,e->button.y,true,&root,&child); + if (root >= 0 && pressed == (child >= 0 ? DIAL_ROOTS+child : root)) { + dial_select(d,root,child); activate(d); + } + break; + } + case SDL_EVENT_MOUSE_WHEEL: { + float delta = e->wheel.direction == SDL_MOUSEWHEEL_FLIPPED ? -e->wheel.y : e->wheel.y; + if (delta != 0) page(d,delta < 0 ? 1 : -1); + break; + } + case SDL_EVENT_KEY_DOWN: key(d,&e->key); break; + case SDL_EVENT_WINDOW_MOUSE_LEAVE: dial_select(d,-1,-1); break; + case SDL_EVENT_WINDOW_SHOWN: d->shown = true; d->dirty = true; break; + case SDL_EVENT_WINDOW_HIDDEN: + if (d->shown && (SDL_GetWindowFlags(d->window) & SDL_WINDOW_HIDDEN)) d->done = true; + break; + case SDL_EVENT_WINDOW_FOCUS_LOST: + /* Creation/show may queue a transient loss before focus settles. */ + if (d->shown && SDL_GetKeyboardFocus() != d->window) d->done = true; + break; + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: d->done = true; break; + case SDL_EVENT_WINDOW_EXPOSED: d->dirty = true; break; + case SDL_EVENT_WINDOW_RESIZED: case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + if (!SDL_GetWindowSize(d->window,&d->width,&d->height) || + !SDL_GetWindowSizeInPixels(d->window,&d->pixel_width,&d->pixel_height) || + d->width < 1 || d->height < 1) { d->done = true; break; } + d->scale = (float)SDL_min(d->width,d->height)/608.0f; + d->raster_scale = (float)d->pixel_width/(float)d->width; + /* SDL also sends DISPLAY_SCALE_CHANGED during window creation. + Keep the menu open; existing textures remain valid and new covers + use the updated density. Fonts are rebaked on the next opening. */ + d->dirty = true; + break; + } +} diff --git a/src/ui/dial/dial_internal.h b/src/ui/dial/dial_internal.h new file mode 100644 index 00000000..fac7bbd1 --- /dev/null +++ b/src/ui/dial/dial_internal.h @@ -0,0 +1,102 @@ +#ifndef BONGO_CAT_DIAL_INTERNAL_H +#define BONGO_CAT_DIAL_INTERNAL_H +#include "bongo_cat/platform.h" +#include "bongo_cat/gl_api.h" +#include "nuklear_config.h" +#include +#include +#define DIAL_PI 3.14159265358979323846f +#define DIAL_PAGE 16 +#define DIAL_ROOTS 12 +#define DIAL_REVEAL_DELAY_MS 22 +#define DIAL_REVEAL_DURATION_MS 360 +#define DIAL_PATH_POINTS 160 +#define DIAL_CENTER_SEGMENTS 64 +#define DIAL_CENTER_RINGS 16 +typedef struct DialPoint { float x, y; } DialPoint; +typedef struct DialPath { + DialPoint points[DIAL_PATH_POINTS]; + unsigned short triangles[DIAL_PATH_POINTS * 3]; + int count, triangle_count, rim_start; + float min_x, min_y, max_x, max_y; +} DialPath; +typedef struct DialItem { + const char *label; + BongoCatMenuAction command; + uint32_t color; + int icon; + bool checked; + size_t children; +} DialItem; +typedef struct DialVertex { float x, y, u, v; uint8_t r, g, b, a; } DialVertex; +typedef struct DialBatch { GLuint texture; int first, count; } DialBatch; +typedef struct DialCover { GLuint texture; float width, height; bool attempted; } DialCover; +typedef struct DialPaint { + BongoCatGL gl; + GLuint program, vao, vbo, white, font_texture; + GLint projection, sampler; + DialVertex *vertices; + size_t count, capacity; + DialBatch batches[512]; + int batch_count; + struct nk_font_atlas atlas; + struct nk_font *font; + nk_rune *ranges[4]; + DialPath roots[DIAL_ROOTS], children[DIAL_PAGE]; + int child_path_count, child_path_active, child_path_roots; + float child_path_step; + float zoom, offset_x, offset_y, alpha; + DialPoint center_points[DIAL_CENTER_RINGS + 1][DIAL_CENTER_SEGMENTS]; + uint8_t center_alpha[DIAL_CENTER_RINGS + 1]; + bool center_ready; + bool failed; +} DialPaint; +typedef struct Dial { + SDL_Window *window, *owner; + SDL_GLContext context, previous_context; + SDL_Window *previous_window; + SDL_WindowID window_id; + const BongoCatMenuLabels *labels; + DialItem items[DIAL_ROOTS]; + int count, active, child, page, pressed; + bool done, dark, dirty, child_focus, shown, popup; + uint64_t input_after_ns; + BongoCatMenuAction result, preview; + int width, height, pixel_width, pixel_height; + float scale, opening, lift[DIAL_ROOTS], raster_scale; + uint64_t opened_at, changed_at, animation_at; + SDL_Event events[128]; + int event_count; + DialPaint paint; + DialCover covers[BONGO_CAT_MODEL_CAP]; +} Dial; +void dial_items(Dial *d); +int dial_child_count(const Dial *d); +float dial_child_angle(const Dial *d, int index); +float dial_child_step(const Dial *d); +DialItem dial_child_item(const Dial *d, int index, char *text, size_t capacity); +void dial_select(Dial *d, int root, int child); +void dial_hit(Dial *d, float x, float y, int *root, int *child); +void dial_child_paths(Dial *d); +void dial_sector(DialPath *path, float inner, float outer, float start, float end); +void dial_transform(Dial *d, float zoom, float x, float y); +bool dial_paint_init(Dial *d); +void dial_paint_free(Dial *d); +bool dial_paint_frame(Dial *d); +void dial_scene(Dial *d); +void dial_triangle(Dial *d, DialPoint a, DialPoint b, DialPoint c, + uint32_t ca, uint32_t cb, uint32_t cc); +void dial_quad(Dial *d, GLuint texture, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, uint32_t color); +void dial_stroke(Dial *d, const DialPoint *points, int count, float width, + uint32_t color, bool smooth); +void dial_dot(Dial *d, float x, float y, float radius, uint32_t color); +void dial_icon(Dial *d, int icon, float x, float y, float size, uint32_t color); +bool dial_fonts_init(Dial *d); +void dial_text(Dial *d, const char *text, float x, float y, float width, + float size, uint32_t color); +void dial_covers_tick(Dial *d); +bool dial_cover_draw(Dial *d, int child, float x, float y, float opacity); +void dial_event(Dial *d, const SDL_Event *event); +void dial_preview(Dial *d, BongoCatMenuAction action); +#endif diff --git a/src/ui/dial/dial_items.c b/src/ui/dial/dial_items.c new file mode 100644 index 00000000..8867c971 --- /dev/null +++ b/src/ui/dial/dial_items.c @@ -0,0 +1,107 @@ +#include "dial_internal.h" +#include + +void dial_items(Dial *d) { + const BongoCatMenuLabels *l = d->labels; + const DialItem items[] = { + {l->preferences, BONGO_CAT_MENU_PREFERENCES, 0xff54aeff, 0, false, 0}, + {l->hide, BONGO_CAT_MENU_HIDE, 0xff60a5fa, 1, false, 0}, + {l->pass_through, BONGO_CAT_MENU_PASS_THROUGH, 0xff38bdf8, 2, + l->pass_through_checked, 0}, + {l->always_on_top, BONGO_CAT_MENU_ALWAYS_ON_TOP, 0xfff77daa, 3, + l->always_on_top_checked, 0}, + {l->window_size, BONGO_CAT_MENU_NONE, 0xff34d399, 4, false, 16}, + {l->opacity, BONGO_CAT_MENU_NONE, 0xff818cf8, 5, false, 10}, + {l->motion, BONGO_CAT_MENU_NONE, 0xfffbbf24, 6, false, l->motion_count}, + {l->expression, BONGO_CAT_MENU_NONE, 0xfff472b6, 7, false, l->expression_count}, + {l->audio, BONGO_CAT_MENU_NONE, 0xff4ade80, 8, false, l->audio_count}, + {l->model, BONGO_CAT_MENU_NONE, 0xffa78bfa, 9, false, l->model_count + 1}, + {l->exit, BONGO_CAT_MENU_EXIT, 0xffff453a, 10, false, 0}, + {l->remove_pet, BONGO_CAT_MENU_REMOVE_PET, 0xffff453a, 11, false, 0} + }; + d->count = l->remove_pet_visible ? 12 : 11; + for (int i = 0; i < d->count; ++i) d->items[i] = items[i]; +} + +int dial_child_count(const Dial *d) { + if (d->active < 0) return 0; + int remaining = (int)d->items[d->active].children - d->page * DIAL_PAGE; + return remaining < 0 ? 0 : (remaining > DIAL_PAGE ? DIAL_PAGE : remaining); +} + +float dial_child_step(const Dial *d) { + /* Preserve HTML spacing; large real catalogs use pages to avoid overlap. */ + return d->active == 4 || d->active == 5 || dial_child_count(d) > 11 ? .32f : .50f; +} + +float dial_child_angle(const Dial *d, int index) { + return -DIAL_PI / 2 + d->active * 2 * DIAL_PI / d->count + + (index - (dial_child_count(d) - 1) / 2.0f) * dial_child_step(d); +} + +DialItem dial_child_item(const Dial *d, int child, char *text, size_t capacity) { + DialItem item = {0}; + if (child < 0 || child >= dial_child_count(d)) return item; + const BongoCatMenuLabels *l = d->labels; + size_t i = (size_t)(d->page * DIAL_PAGE + child); + item = d->items[d->active]; + item.children = 0; + switch (d->active) { + case 4: + snprintf(text, capacity, "%d%%", 50 + (int)i * 10); + item.label = text; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_SCALE_50 + i); + item.checked = fabsf(l->scale_percent - (50 + (int)i * 10)) < .5f; + break; + case 5: + snprintf(text, capacity, "%d%%", 10 + (int)i * 10); + item.label = text; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_OPACITY_10 + i); + item.checked = fabsf(l->opacity_percent - (10 + (int)i * 10)) < .5f; + break; + case 6: + item.label = l->motion_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_MOTION_FIRST + i); + item.checked = l->motion_checked && l->motion_checked[i]; + break; + case 7: + item.label = l->expression_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_EXPRESSION_FIRST + i); + item.checked = i == l->current_expression; + break; + case 8: + item.label = l->audio_names[i]; + item.command = (BongoCatMenuAction)(BONGO_CAT_MENU_AUDIO_FIRST + i); + item.checked = l->audio_checked && l->audio_checked[i]; + break; + case 9: + item.label = i == l->model_count ? l->add_model : l->model_names[i]; + item.command = i == l->model_count ? BONGO_CAT_MENU_MODEL_ADD : + (BongoCatMenuAction)(BONGO_CAT_MENU_MODEL_FIRST + i); + item.checked = i < l->model_count && i == l->current_model; + break; + } + return item; +} + +void dial_select(Dial *d, int root, int child) { + if (d->active == root && d->child == child) return; + bool changed = d->active != root; + d->active = root; + d->child = child; + if (changed) d->child_focus = false; + if (child >= 0) d->child_focus = true; + if (changed) { + d->page = 0; + d->changed_at = SDL_GetTicks(); + dial_child_paths(d); + } + char text[32]; + BongoCatMenuAction action = child >= 0 ? + dial_child_item(d, child, text, sizeof(text)).command : BONGO_CAT_MENU_NONE; + if (action != d->preview) { + d->preview = action; + dial_preview(d, action); + } + d->dirty = true; +} diff --git a/src/ui/dial/dial_paint.c b/src/ui/dial/dial_paint.c new file mode 100644 index 00000000..cf5ccb78 --- /dev/null +++ b/src/ui/dial/dial_paint.c @@ -0,0 +1,93 @@ +#include "dial_internal.h" +#include +#include + +static const char *vertex_shader = + "#version 330 core\n" + "layout(location=0) in vec2 Position; layout(location=1) in vec2 UV;" + "layout(location=2) in vec4 Color; uniform mat4 Projection;" + "out vec2 uv; out vec4 color;" + "void main(){uv=UV;color=Color;gl_Position=Projection*vec4(Position,0,1);}"; +static const char *fragment_shader = + "#version 330 core\n" + "in vec2 uv; in vec4 color; uniform sampler2D Texture; out vec4 pixel;" + "void main(){pixel=color*texture(Texture,uv);}"; + +bool dial_paint_init(Dial *d) { + DialPaint *p = &d->paint; + BongoCatError error = {0}; + if (!bongo_cat_gl_load(&p->gl, &error)) return SDL_SetError("Radial menu GL loader: %s",error.message); + bongo_cat_gl_clear_errors(); + p->program = bongo_cat_gl_program(&p->gl,vertex_shader,fragment_shader,&error); + if (!p->program) { SDL_SetError("%s",error.message); return false; } + p->projection = p->gl.uniform_location(p->program,"Projection"); + p->sampler = p->gl.uniform_location(p->program,"Texture"); + p->gl.gen_vertex_arrays(1,&p->vao); + p->gl.gen_buffers(1,&p->vbo); + p->gl.bind_vertex_array(p->vao); + p->gl.bind_buffer(GL_ARRAY_BUFFER,p->vbo); + p->gl.enable_attribute(0); p->gl.enable_attribute(1); p->gl.enable_attribute(2); + p->gl.attribute_pointer(0,2,GL_FLOAT,GL_FALSE,sizeof(DialVertex),(void *)offsetof(DialVertex,x)); + p->gl.attribute_pointer(1,2,GL_FLOAT,GL_FALSE,sizeof(DialVertex),(void *)offsetof(DialVertex,u)); + p->gl.attribute_pointer(2,4,GL_UNSIGNED_BYTE,GL_TRUE,sizeof(DialVertex),(void *)offsetof(DialVertex,r)); + glGenTextures(1,&p->white); glBindTexture(GL_TEXTURE_2D,p->white); + const uint8_t white[] = {255,255,255,255}; + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,1,1,0,GL_RGBA,GL_UNSIGNED_BYTE,white); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR); + if (!dial_fonts_init(d)) return SDL_SetError("Radial menu font atlas could not be created"); + for (int i = 0; i < d->count; ++i) { + float angle = -DIAL_PI/2 + (float)i*2*DIAL_PI/(float)d->count; + float half = DIAL_PI/(float)d->count; + dial_sector(&p->roots[i],82,184,angle-half,angle+half); + if (!p->roots[i].triangle_count) return SDL_SetError("Radial menu sector %d could not be triangulated",i); + } + GLenum status = glGetError(); + return status == GL_NO_ERROR || SDL_SetError("Radial menu GL initialization error: 0x%x",(unsigned)status); +} + +bool dial_paint_frame(Dial *d) { + DialPaint *p = &d->paint; + p->count = 0; p->batch_count = 0; + /* Never show a completely transparent first frame: it cannot receive + pointer hits on some native transparent-window implementations. */ + p->alpha = fmaxf(.15f,fminf(1,(float)(SDL_GetTicks()-d->opened_at)/180)); + dial_scene(d); + if (p->failed) return SDL_SetError("Radial menu geometry budget exceeded"); + glViewport(0,0,d->pixel_width,d->pixel_height); + glDisable(GL_SCISSOR_TEST); glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); + glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE); + glClearColor(0,0,0,0); glClear(GL_COLOR_BUFFER_BIT); + glEnable(GL_BLEND); p->gl.blend_equation(GL_FUNC_ADD); + p->gl.blend_func_separate(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA,GL_ONE,GL_ONE_MINUS_SRC_ALPHA); + float width = (float)d->width, height = (float)d->height; + const float projection[16] = {2/width,0,0,0, 0,-2/height,0,0, 0,0,-1,0, -1,1,0,1}; + p->gl.use_program(p->program); + p->gl.uniform_matrix_4fv(p->projection,1,GL_FALSE,projection); + p->gl.uniform_1i(p->sampler,0); + p->gl.active_texture(GL_TEXTURE0); + p->gl.bind_vertex_array(p->vao); + p->gl.bind_buffer(GL_ARRAY_BUFFER,p->vbo); + p->gl.buffer_data(GL_ARRAY_BUFFER,(GLsizeiptr)(p->count*sizeof(*p->vertices)),p->vertices,GL_STREAM_DRAW); + for (int i = 0; i < p->batch_count; ++i) { + DialBatch b = p->batches[i]; + glBindTexture(GL_TEXTURE_2D,b.texture); + glDrawArrays(GL_TRIANGLES,b.first,b.count); + } + if (glGetError() != GL_NO_ERROR) return SDL_SetError("Radial menu OpenGL draw failed"); + return SDL_GL_SwapWindow(d->window); +} + +void dial_paint_free(Dial *d) { + DialPaint *p = &d->paint; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (d->covers[i].texture) glDeleteTextures(1,&d->covers[i].texture); + if (p->font_texture) glDeleteTextures(1,&p->font_texture); + if (p->atlas.permanent.alloc) nk_font_atlas_clear(&p->atlas); + for (int i = 0; i < 4; ++i) free(p->ranges[i]); + if (p->white) glDeleteTextures(1,&p->white); + if (p->vbo) p->gl.delete_buffers(1,&p->vbo); + if (p->vao) p->gl.delete_vertex_arrays(1,&p->vao); + if (p->program) p->gl.delete_program(p->program); + free(p->vertices); +} diff --git a/src/ui/dial/dial_scene.c b/src/ui/dial/dial_scene.c new file mode 100644 index 00000000..89ebd8cf --- /dev/null +++ b/src/ui/dial/dial_scene.c @@ -0,0 +1,172 @@ +#include "dial_internal.h" +#include + +static float reveal(uint64_t now, uint64_t started, int index) { + float elapsed = (float)(now - started) - index * DIAL_REVEAL_DELAY_MS; + float t = fmaxf(0, fminf(1, elapsed / DIAL_REVEAL_DURATION_MS)); + return 1 - powf(1 - t, 3); +} + +static uint32_t alpha(uint32_t color, float opacity) { + return (color&0xffffff) | ((uint32_t)((float)(color>>24)*opacity)<<24); +} +static uint32_t mix(uint32_t a, uint32_t b, float t) { + uint32_t color = 0; + for (int shift = 0; shift <= 24; shift += 8) { + float av = (float)((a>>shift)&255), bv = (float)((b>>shift)&255); + color |= (uint32_t)(av+(bv-av)*t)<min_x)/(p->max_x-p->min_x)+(v.y-p->min_y)/(p->max_y-p->min_y)); + t = fmaxf(0,fminf(1,t)); + return alpha(t < .5f ? mix(colors[0],colors[1],t*2) : mix(colors[1],colors[2],(t-.5f)*2),opacity); +} +static void surface(Dial *d, const DialPath *p, int tint, float opacity) { + if (!p->count) return; + uint32_t colors[3]; + if (tint == 1) { colors[0]=0xff6ec2ff; colors[1]=0xff54aeff; colors[2]=0xff1e82f0; } + else if (tint == 2) { colors[0]=0xffff97be; colors[1]=0xfff77daa; colors[2]=0xffe34882; } + else if (tint == 3) { colors[0]=0xffff6960; colors[1]=0xffff453a; colors[2]=0xffd10014; } + else if (d->dark) { colors[0]=0xff010409; colors[1]=0xff010409; colors[2]=0xff010409; } + else { colors[0]=0xffffffff; colors[1]=0xfff6f9ff; colors[2]=0xffeef3fc; } + DialPoint closed[DIAL_PATH_POINTS+1]; + for (int i = 0; i < p->count; ++i) closed[i]=p->points[i]; + closed[p->count]=p->points[0]; + uint32_t glow = tint ? (colors[1]&0xffffff)|0x06000000 : 0x03000000; + for (int i = 6; i > 0; --i) + dial_stroke(d,closed,p->count+1,(float)i*5,alpha(glow,opacity),false); + /* Shared polygon vertices only need their gradient calculated once. */ + uint32_t vertex_colors[DIAL_PATH_POINTS]; + for (int i = 0; i < p->count; ++i) + vertex_colors[i] = gradient(p,p->points[i],colors,opacity); + for (int i = 0; i < p->triangle_count; i += 3) { + int a=p->triangles[i], b=p->triangles[i+1], c=p->triangles[i+2]; + dial_triangle(d,p->points[a],p->points[b],p->points[c], + vertex_colors[a],vertex_colors[b],vertex_colors[c]); + } + dial_stroke(d,closed,p->count+1,1.1f,alpha(tint || !d->dark ? 0xfaffffff : 0x38ffffff,opacity),true); + dial_stroke(d,closed+p->rim_start,p->count+1-p->rim_start,1.4f, + alpha(d->dark ? 0xd3ffffff : 0xebffffff,opacity),true); +} +static void text(Dial *d, const char *s, float x, float y, float width, bool title, uint32_t color) { + dial_text(d,s,x,y,width,title ? 19.0f : 14.0f,color); +} +static int item_icon(DialItem item) { + return item.checked && item.icon == 2 ? 12 : + (item.checked && item.icon == 3 ? 13 : item.icon); +} + +static void root(Dial *d, int index, uint64_t now) { + DialItem item = d->items[index]; + float angle = -DIAL_PI / 2 + index * 2 * DIAL_PI / d->count; + float x = 132 * cosf(angle), y = 132 * sinf(angle); + float ease = reveal(now, d->opened_at, index); + /* Keep the first sector hittable on transparent native windows. */ + if (index == 0) ease = fmaxf(.15f, ease); + float lift = d->lift[index], zoom = .72f + .28f * ease + .055f * lift; + float pull = -26 * (1 - ease) + 7.5f * lift; + if (d->pressed == index) { zoom *= .96f; pull *= .4f; } + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + bool active = index == d->active; + float opacity = ease; + int tint = active ? (index >= 10 ? 3 : 1) : 0; + surface(d, &d->paint.roots[index], tint, opacity); + uint32_t color = active ? 0xffffffff : (item.checked ? 0xfff77daa : item.color); + if (index >= 6 && index <= 8 && !item.children) opacity *= .4f; + dial_icon(d, item_icon(item), x, y, 28 * (1 + .15f * lift), alpha(color, opacity)); + if (item.checked && item.command != BONGO_CAT_MENU_ALWAYS_ON_TOP) { + for (int i = 4; i > 0; --i) + dial_dot(d, x + 17, y - 17, 3.2f + i * 1.5f, alpha(0x0af77daa, ease)); + dial_dot(d, x + 17, y - 17, 3.2f, alpha(0xfff77daa, ease)); + } +} + +static void center_background(Dial *d) { + enum { segments = DIAL_CENTER_SEGMENTS, rings = DIAL_CENTER_RINGS }; + const float radius = 80.0f; + uint32_t color = d->dark ? 0x00010409 : 0x00f6f9ff; + DialPaint *p = &d->paint; + /* Cache the original mesh per menu; transforms and opening alpha stay live. */ + if (!p->center_ready) { + DialPoint directions[segments]; + for (int i = 0; i < segments; ++i) { + float angle = (float)i * 2 * DIAL_PI / segments; + directions[i] = (DialPoint){cosf(angle), sinf(angle)}; + } + for (int ring = 0; ring <= rings; ++ring) { + float t = (float)ring / rings; + p->center_alpha[ring] = (uint8_t)(alpha(0xe6000000, + 1 - t * t * (3 - 2 * t)) >> 24); + for (int i = 0; i < segments; ++i) + p->center_points[ring][i] = (DialPoint){ + radius * t * directions[i].x, radius * t * directions[i].y}; + } + p->center_ready = true; + } + /* Adjacent rings share edges; smoothstep fades to a fully clear rim. */ + for (int ring = 0; ring < rings; ++ring) { + uint32_t ci = color | ((uint32_t)p->center_alpha[ring] << 24); + uint32_t co = color | ((uint32_t)p->center_alpha[ring+1] << 24); + for (int i = 0; i < segments; ++i) { + int next = (i + 1) % segments; + DialPoint a = p->center_points[ring][i]; + DialPoint b = p->center_points[ring+1][i]; + DialPoint c = p->center_points[ring+1][next]; + DialPoint e = p->center_points[ring][next]; + dial_triangle(d, a, b, c, ci, co, co); + if (ring > 0) dial_triangle(d, a, c, e, ci, co, ci); + } + } +} + +static void center(Dial *d) { + if (d->active < 0) return; + dial_transform(d, 1, 0, 0); + center_background(d); + DialItem item = d->items[d->active]; + char buffer[32]; + bool child_hovered = d->child >= 0; + uint32_t color = child_hovered ? 0xfff77daa : 0xff52a9f8; + const char *label = child_hovered ? + dial_child_item(d, d->child, buffer, sizeof(buffer)).label : item.label; + if (!child_hovered) + dial_icon(d, item_icon(item), 0, -5, 38, item.checked ? 0xfff77daa : item.color); + text(d, label, 0, child_hovered ? 0.0f : 30.0f, 140, true, color); + if (item.children > DIAL_PAGE) { + snprintf(buffer, sizeof(buffer), "%d / %d", d->page + 1, + ((int)item.children + DIAL_PAGE - 1) / DIAL_PAGE); + text(d, buffer, 0, 58, 80, false, color); + } +} + +static void children(Dial *d, uint64_t now) { + for (int i = 0; i < dial_child_count(d); ++i) { + /* Child indices increase with screen angle, so this reveals clockwise. */ + float ease = reveal(now, d->changed_at, i); + float angle = dial_child_angle(d, i), x = 230 * cosf(angle), y = 230 * sinf(angle); + bool hover = i == d->child; + float zoom = .72f + .28f * ease + (hover ? .06f : 0); + float pull = -26 * (1 - ease) + (hover ? 6 : 0); + dial_transform(d, zoom, x * (1 - zoom) + cosf(angle) * pull, + y * (1 - zoom) + sinf(angle) * pull); + char buffer[32]; + DialItem item = dial_child_item(d, i, buffer, sizeof(buffer)); + surface(d, &d->paint.children[i], + item.checked ? 2 : (hover ? 1 : 0), ease); + uint32_t color = hover || item.checked || d->dark ? 0xffffffff : 0xff181c28; + if (!dial_cover_draw(d, i, x, y, ease)) + text(d, item.label, x, y, dial_child_step(d) > .4f ? 92.0f : 62.0f, + false, alpha(color, ease)); + } +} + +void dial_scene(Dial *d) { + uint64_t now = SDL_GetTicks(); + for (int i = 0; i < d->count; ++i) + if (!d->child_focus || i == d->active) root(d,i,now); + center(d); + children(d,now); +} diff --git a/src/ui/dial/dial_window.c b/src/ui/dial/dial_window.c new file mode 100644 index 00000000..de9fba5c --- /dev/null +++ b/src/ui/dial/dial_window.c @@ -0,0 +1,192 @@ +#include "dial_internal.h" +#include "ui_backend.h" +#include "bongo_cat/log.h" +#include "bongo_cat/memory.h" +#include + +static bool SDLCALL collect_event(void *userdata, SDL_Event *event) { + Dial *d = userdata; + if (event->type == SDL_EVENT_QUIT || event->type == SDL_EVENT_TERMINATING) { + d->done = true; return true; /* Let the main application handle shutdown. */ + } + SDL_Window *window = SDL_GetWindowFromEvent(event); + if (window != d->window) { + if ((event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->common.timestamp >= d->input_after_ns) || + (window == d->owner && event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)) d->done = true; + return true; /* Never consume another pet's/preferences/input events. */ + } + bool input = (event->type >= SDL_EVENT_WINDOW_FIRST && event->type <= SDL_EVENT_WINDOW_LAST) || + event->type == SDL_EVENT_MOUSE_MOTION || event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || + event->type == SDL_EVENT_MOUSE_BUTTON_UP || event->type == SDL_EVENT_MOUSE_WHEEL || + event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP; + if (!input || d->event_count == 128) return true; + d->events[d->event_count++] = *event; + return false; +} + +void dial_preview(Dial *d, BongoCatMenuAction action) { + if (!d->labels->preview) return; + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + d->labels->preview(d->labels->preview_userdata,action); + if (!SDL_GL_MakeCurrent(d->window,d->context)) d->done = true; +} + +static void animate(Dial *d, uint64_t now) { + float elapsed = fminf(.1f,(float)(now-d->animation_at)/1000); + d->animation_at = now; + float t = fminf(1,(float)(now-d->opened_at)/700); + float opening = .85f+.15f*(1-powf(1-t,4)); + if (opening != d->opening) { d->opening = opening; d->dirty = true; } + float response = 1-expf(-17*elapsed); + for (int i = 0; i < d->count; ++i) { + if (d->child_focus && i != d->active) { d->lift[i] = 0; continue; } + float target = d->active == i ? 1.0f : 0.0f; + float next = d->lift[i]+(target-d->lift[i])*response; + if (fabsf(next-target) < .002f) next = target; + if (next != d->lift[i]) { d->lift[i] = next; d->dirty = true; } + } + if (dial_child_count(d) && now-d->changed_at <= + (uint64_t)(DIAL_REVEAL_DURATION_MS + + (dial_child_count(d)-1)*DIAL_REVEAL_DELAY_MS + 16)) + d->dirty = true; + if (now-d->opened_at <= (uint64_t)(DIAL_REVEAL_DURATION_MS + + (d->count-1)*DIAL_REVEAL_DELAY_MS + 16)) d->dirty = true; +} + +static bool create(Dial *d) { + float layout = 1; + bongo_cat_ui_query_window_scale(d->owner,&layout,NULL); + SDL_Rect bounds; + if (!SDL_GetDisplayUsableBounds(SDL_GetDisplayForWindow(d->owner),&bounds)) return false; + float side = fminf(608*740.0f/560*layout,.96f*(float)SDL_min(bounds.w,bounds.h)); + int size = (int)ceilf(side); + if (size < 1) return false; + float x = 0, y = 0; + if (SDL_GetMouseFocus() == d->owner) SDL_GetMouseState(&x,&y); + else { + int width, height; + if (!SDL_GetWindowSize(d->owner,&width,&height)) return false; + x = (float)width/2; y = (float)height/2; + } + SDL_WindowFlags flags = SDL_WINDOW_OPENGL | SDL_WINDOW_TRANSPARENT | + SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_HIDDEN; + const char *driver = SDL_GetCurrentVideoDriver(); + d->popup = driver && SDL_strcmp(driver,"wayland") == 0; + if (d->popup) { + /* Wayland requires compositor-managed popup placement and input grabs. */ + d->window = SDL_CreatePopupWindow(d->owner,(int)x-size/2,(int)y-size/2, + size,size,flags | SDL_WINDOW_POPUP_MENU); + } else { + /* A desktop pet may be nonactivating or fully transparent. A native + child popup inherits its focus/stacking restrictions on Win32. + An independent SDL utility window owns its focus and GL surface. */ + d->window = SDL_CreateWindow("BongoCat - Menu",size,size,flags | + SDL_WINDOW_BORDERLESS | SDL_WINDOW_UTILITY | SDL_WINDOW_ALWAYS_ON_TOP); + if (d->window) { + SDL_GetGlobalMouseState(&x,&y); + SDL_Point position = {(int)x,(int)y}; + SDL_DisplayID display = SDL_GetDisplayForPoint(&position); + SDL_GetDisplayUsableBounds(display,&bounds); + int left = SDL_clamp((int)x-size/2,bounds.x,SDL_max(bounds.x,bounds.x+bounds.w-size)); + int top = SDL_clamp((int)y-size/2,bounds.y,SDL_max(bounds.y,bounds.y+bounds.h-size)); + SDL_SetWindowPosition(d->window,left,top); + } + } + if (!d->window) return false; + d->window_id = SDL_GetWindowID(d->window); + int sharing = 0; + SDL_GL_GetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,&sharing); + if (!SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,0)) return false; + d->context = SDL_GL_CreateContext(d->window); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT,sharing); + if (!d->context || !SDL_GL_MakeCurrent(d->window,d->context)) return false; + SDL_GL_SetSwapInterval(0); /* The modal tick supplies a 16 ms frame budget. */ + if (!SDL_GetWindowSize(d->window,&d->width,&d->height) || + !SDL_GetWindowSizeInPixels(d->window,&d->pixel_width,&d->pixel_height) || + d->width < 1 || d->height < 1) return false; + d->scale = (float)SDL_min(d->width,d->height)/608; + d->raster_scale = (float)d->pixel_width/(float)d->width; + if (!dial_paint_init(d)) return false; + d->opened_at = d->changed_at = d->animation_at = SDL_GetTicks(); + d->opening = .85f; + d->input_after_ns = SDL_GetTicksNS(); + if (!dial_paint_frame(d) || !SDL_ShowWindow(d->window)) return false; + if (!d->popup) SDL_RaiseWindow(d->window); + d->dirty = true; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "Radial menu opened: driver=%s window=%u size=%dx%d popup=%d", + driver ? driver : "unknown",(unsigned)d->window_id,d->width,d->height,d->popup); + return true; +} + +BongoCatMenuAction bongo_cat_platform_context_menu(BongoCatPlatform *platform, + const BongoCatMenuLabels *labels) { + if (!platform || !platform->window || !labels) return BONGO_CAT_MENU_NONE; + SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE,"Radial menu requested: owner=%u", + (unsigned)SDL_GetWindowID(platform->window)); + Dial *d = calloc(1,sizeof(*d)); + if (!d) return BONGO_CAT_MENU_NONE; + d->owner = platform->window; d->labels = labels; d->dark = labels->dark_theme; + d->previous_window = SDL_GL_GetCurrentWindow(); + d->previous_context = SDL_GL_GetCurrentContext(); + d->active = d->child = d->pressed = -1; + dial_items(d); + bool ready = create(d); + if (!ready) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,"Radial menu initialization failed: %s",SDL_GetError()); + while (ready && !d->done) { + uint64_t start = SDL_GetTicks(); + SDL_PumpEvents(); + if (!SDL_GetWindowFromID(d->window_id)) { d->done = true; break; } + d->event_count = 0; + SDL_FilterEvents(collect_event,d); + for (int i = 0; i < d->event_count && !d->done; ++i) { + dial_event(d,&d->events[i]); + if (d->done) SDL_LogInfo(BONGO_CAT_LOG_LIFECYCLE, + "Radial menu dismissed: event=0x%x action=%d elapsed_ms=%llu", + (unsigned)d->events[i].type,(int)d->result, + (unsigned long long)(SDL_GetTicks()-d->opened_at)); + } + if (d->done) break; + if (labels->preview_tick) { + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + labels->preview_tick(labels->preview_userdata); + } + if (!SDL_GL_MakeCurrent(d->window,d->context)) { + SDL_LogError(SDL_LOG_CATEGORY_VIDEO,"Radial menu context activation failed: %s",SDL_GetError()); + break; + } + dial_covers_tick(d); + animate(d,SDL_GetTicks()); + if (d->dirty) { + d->dirty = false; + if (!dial_paint_frame(d)) { + SDL_LogError(SDL_LOG_CATEGORY_VIDEO,"Radial menu rendering failed: %s",SDL_GetError()); + break; + } + } + uint64_t elapsed = SDL_GetTicks()-start; + if (elapsed < 16) SDL_Delay((Uint32)(16-elapsed)); + } + if (!d->context || (SDL_GetWindowFromID(d->window_id) && + SDL_GL_MakeCurrent(d->window,d->context))) dial_paint_free(d); + else { + /* A destroyed window cannot safely own GL deletion calls. Its context + releases GPU allocations; release only CPU allocations here. */ + if (d->paint.atlas.permanent.alloc) nk_font_atlas_clear(&d->paint.atlas); + for (int i = 0; i < 4; ++i) free(d->paint.ranges[i]); + free(d->paint.vertices); + } + if (d->context) SDL_GL_DestroyContext(d->context); + if (d->window_id && SDL_GetWindowFromID(d->window_id)) SDL_DestroyWindow(d->window); + if (d->previous_window && d->previous_context) + SDL_GL_MakeCurrent(d->previous_window,d->previous_context); + if (labels->restore) labels->restore(labels->preview_userdata,d->result); + BongoCatMenuAction result = d->result; + free(d); + /* Trim only after GL/CPU teardown and preview restoration have finished. */ + bongo_cat_platform_trim_memory(); + return result; +} diff --git a/src/ui/preferences_about.c b/src/ui/preferences/about/preferences_about.c similarity index 66% rename from src/ui/preferences_about.c rename to src/ui/preferences/about/preferences_about.c index 4802f2bc..70491dc7 100644 --- a/src/ui/preferences_about.c +++ b/src/ui/preferences/about/preferences_about.c @@ -1,6 +1,7 @@ +#include "preferences_about_internal.h" #include "preferences_state.h" #include "preferences_about_community.h" -#include "preferences_notice.h" +#include "preferences_about_footer.h" #include "ui_backend.h" #include "ui_catime.h" #include "ui_animation.h" @@ -78,26 +79,24 @@ static void image_contain(struct nk_command_buffer *canvas, unsigned int texture struct nk_image image = nk_image_id((int)texture); nk_draw_image(canvas, target, &image, nk_rgb(255, 255, 255)); } -static void logo(BongoCatPreferences *value, struct nk_context *context, +static void support_logo(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); float lift = bongo_cat_ui_animate_eased(context, "support-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); - struct nk_rect raised = bounds; raised.y -= 5.0f * lift; - if (p.effects) { - bongo_cat_ui_paint_shadow(context, raised, 36, 0, 18, 42, 0, - nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); - bongo_cat_ui_paint_shadow(context, raised, 36, 0, 8, 22, 0, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - bongo_cat_ui_paint_gradient(context, raised, 36, p.accent, p.pink); - } else nk_fill_rect(canvas, raised, 36, p.accent); - struct nk_rect inner = nk_rect(raised.x + 5, raised.y + 5, - raised.w - 10, raised.h - 10); - nk_fill_rect(canvas, inner, 31, p.surface); + float scale = 1.0f + .04f * lift; + struct nk_rect raised = nk_rect( + bounds.x + (bounds.w - bounds.w * scale) * .5f, + bounds.y - 5.0f * lift + (bounds.h - bounds.h * scale) * .5f, + bounds.w * scale, bounds.h * scale); + /* The logo asset is the complete brand mark; avoid wrapping it in a + second card so the support page and sidebar share the same identity. */ + if (p.effects) bongo_cat_ui_paint_shadow(context, raised, 42, 0, 10, 24, 0, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, 70)); image_contain(canvas, value->logo_texture, value->logo_width, - value->logo_height, nk_rect(inner.x + 12, inner.y + 12, - inner.w - 24, inner.h - 24)); + value->logo_height, raised); link_cursor(context, bounds); if (hit(context, bounds)) open_url("https://bongocat.pet"); } @@ -119,17 +118,19 @@ static void star_button(BongoCatPreferences *value, nk_rect(bounds.x + 22, bounds.y + 14.5f, 19, 19), nk_rgb(255, 255, 255)); text(canvas, nk_rect(bounds.x + 50, bounds.y + (bounds.h - value->ui.label_font->height) * .5f, - bounds.w - 62, value->ui.label_font->height), "Star on GitHub", + bounds.w - 62, value->ui.label_font->height), tr(value, + "native.support.starOnGitHub", "Star on GitHub!"), value->ui.label_font, nk_rgb(255, 255, 255)); link_cursor(context, bounds); if (hit(context, bounds)) - open_url("https://github.com/bongocat-pet/BongoCat"); + open_url("https://github.com/vladelaina/BongoCat"); } static void hero_title(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatUIPalette p) { const char *title = "BongoCat"; - const char *by = "by ", *developer = "BongoCat-pet"; + const char *by = tr(value, "native.support.by", "by "); + const char *developer = "vladelaina"; float gap = 8, title_width = width(value->ui.hero_font, title); float by_width = width(value->ui.caption_font, by), developer_width = width(value->ui.caption_font, developer); @@ -147,69 +148,12 @@ static void hero_title(BongoCatPreferences *value, struct nk_context *context, text(canvas, link, developer, value->ui.caption_font, bongo_cat_ui_color_mix(p.accent, p.pink, amount)); link_cursor(context, link); if (hit(context, link)) - open_url("https://github.com/bongocat-pet"); -} -static void footer(BongoCatPreferences *value, struct nk_context *context, - struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - const char *version_label = tr(value, "native.support.version", "App version"); - const char *update = tr(value, "native.support.checkUpdate", "Check for updates"); - const char *feedback = tr(value, "native.support.feedback", "Feedback"); - char version[32]; snprintf(version, sizeof(version), "v%s", BONGO_CAT_VERSION); - float label_width = width(value->ui.caption_font, version_label) + 2; - float version_width = width(value->ui.label_font, version) + 2; - float update_width = NK_MAX(110.0f, - width(value->ui.caption_font, update) + 28.0f); - float feedback_width = NK_MAX(56.0f, - width(value->ui.caption_font, feedback) + 4.0f); - float total = label_width + version_width + update_width + - feedback_width + 36.0f; - bool stacked = total > bounds.w; - float info_width = label_width + version_width + 8.0f; - float x = bounds.x + (bounds.w - (stacked ? info_width : total)) * .5f; - float info_y = bounds.y + (stacked ? -8.0f : 0.0f); - centered(canvas, nk_rect(x, info_y, label_width, 36), version_label, - value->ui.caption_font, p.muted); - centered(canvas, nk_rect(x + label_width + 8, info_y, - version_width, 36), version, value->ui.label_font, p.text); - float actions_width = update_width + feedback_width + 14.0f; - float actions_x = stacked ? bounds.x + (bounds.w - actions_width) * .5f : - x + info_width + 14.0f; - float actions_y = bounds.y + (stacked ? 22.0f : 0.0f); - struct nk_rect update_button = nk_rect(actions_x, actions_y, update_width, 36); - if (p.effects) bongo_cat_ui_paint_shadow(context, update_button, 10, - 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); - nk_fill_rect(canvas, update_button, 10, p.accent); - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_SYNC, nk_rect(update_button.x + 14, - update_button.y + 10, 16, 16), nk_rgb(255, 255, 255)); - centered(canvas, nk_rect(update_button.x + 28, update_button.y, - update_button.w - 32, update_button.h), update, value->ui.caption_font, - nk_rgb(255, 255, 255)); - link_cursor(context, update_button); - if (hit(context, update_button)) { - char message[160]; - snprintf(message, sizeof(message), "%s v%s", tr(value, - "native.support.latest", "Already up to date"), - BONGO_CAT_VERSION); - bongo_cat_preferences_notice_show(value->app, message, false); - } - struct nk_rect feedback_link = nk_rect(actions_x + update_width + 14, - actions_y, feedback_width, 36); - bool feedback_hover = nk_input_is_mouse_hovering_rect(&context->input, feedback_link); - float feedback_amount = bongo_cat_ui_animate_eased(context, - "support-feedback-hover", feedback_hover ? 1.0f : 0.0f, 200, - BONGO_CAT_UI_EASE_STANDARD); - centered(canvas, feedback_link, feedback, value->ui.caption_font, - bongo_cat_ui_color_mix(p.accent, p.pink, feedback_amount)); - link_cursor(context, feedback_link); - if (hit(context, feedback_link)) - open_url("https://github.com/bongocat-pet/BongoCat/issues"); + open_url("https://vladelaina.com"); } - -static void hero(BongoCatPreferences *value, struct nk_context *context) { +void bongo_cat_preferences_about_hero(BongoCatPreferences *value, + struct nk_context *context) { struct nk_rect bounds; - nk_layout_row_dynamic(context, 320, 1); + nk_layout_row_dynamic(context, 340, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); @@ -224,19 +168,23 @@ static void hero(BongoCatPreferences *value, struct nk_context *context) { } star_button(value, context, canvas, nk_rect(bounds.x + bounds.w - 188, bounds.y + 6, 177, 46), p); - logo(value, context, canvas, nk_rect(bounds.x + (bounds.w - 144) * .5f + 3, - bounds.y + 18, 144, 144), p); - hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 178, + support_logo(value, context, canvas, + nk_rect(bounds.x + (bounds.w - 168) * .5f + 3, + bounds.y + 10, 168, 168), p); + hero_title(value, context, canvas, nk_rect(bounds.x + 3, bounds.y + 190, bounds.w, 36), p); - centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 215, + bongo_cat_preferences_about_localized_link(value, context, canvas, + nk_rect(bounds.x, bounds.y + 226, bounds.w, 24), + "native.support.website", "Website: bongocat.pet", + "https://bongocat.pet", "support-website-hover", p); + centered_wrapped(canvas, nk_rect(bounds.x + 36, bounds.y + 254, bounds.w - 72, 40), tr(value, "native.support.heroText", "Thank you for your support. Every use and share helps BongoCat grow."), value->ui.caption_font, p.muted); - footer(value, context, canvas, - nk_rect(bounds.x, bounds.y + 260, bounds.w, 40), p); + bongo_cat_preferences_about_footer(value, context, canvas, + nk_rect(bounds.x, bounds.y + 300, bounds.w, 34), p); } - static void project(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, unsigned int texture, int image_width, int image_height, const char *name, const char *url, @@ -268,14 +216,13 @@ static void project(BongoCatPreferences *value, struct nk_context *context, if (hit(context, bounds)) open_url(url); } -static void projects(BongoCatPreferences *value, struct nk_context *context) { +void bongo_cat_preferences_about_projects(BongoCatPreferences *value, + struct nk_context *context) { struct nk_rect bounds; nk_layout_row_dynamic(context, 330, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - nk_stroke_line(canvas, bounds.x + 8, bounds.y - 5, bounds.x + bounds.w - 8, - bounds.y - 5, 1, p.border_subtle); bongo_cat_preferences_about_projects_heading(value, context, bounds); float card_width = 220, center = bounds.x + bounds.w * .5f + 3; project(value, context, canvas, nk_rect(center - 262, bounds.y + 112, @@ -283,13 +230,6 @@ static void projects(BongoCatPreferences *value, struct nk_context *context) { value->catime_height, "Catime", "https://cati.me/", false, p); project(value, context, canvas, nk_rect(center + 42, bounds.y + 112, card_width, 200), value->vlaina_texture, value->vlaina_width, - value->vlaina_height, "vlaina", "https://vlaina.com/", true, p); -} - -void bongo_cat_preferences_page_about(BongoCatPreferences *value, - struct nk_context *context) { - bongo_cat_preferences_support_assets_load(value); - hero(value, context); - projects(value, context); - bongo_cat_preferences_about_community(value, context); + value->vlaina_height, "vlaina", "https://vlaina.com/r/bongocat", + true, p); } diff --git a/src/ui/preferences/about/preferences_about.h b/src/ui/preferences/about/preferences_about.h new file mode 100644 index 00000000..41cd47f1 --- /dev/null +++ b/src/ui/preferences/about/preferences_about.h @@ -0,0 +1,42 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_H +#define BONGO_CAT_PREFERENCES_ABOUT_H + +#include "preferences_about_online.h" +#include "nuklear_config.h" + +typedef struct BongoCatPreferences BongoCatPreferences; + +/* UI-owned state. Workers publish results through the request objects. */ +typedef struct BongoCatAboutState { + bool qr_attempted, qr_open; + bool qr_texture_dirty; + BongoCatAboutRequest *qr_request; + /* Retained for this settings session, including GL asset rebuilds. */ + unsigned char *qr_pixels; + unsigned int qr_texture, portraits[BONGO_ABOUT_CONTRIBUTOR_CAP]; + unsigned int wechat_icon_texture; + bool wechat_icon_attempted; + BongoCatAboutRequest *contributors_request; + BongoCatAboutFeed *contributors; + bool contributors_attempted; + bool portraits_loaded; + int portraits_next; + Uint32 event_type; + Uint64 qr_hide_at; + struct nk_rect qr_anchor; + struct nk_user_font detail_font; + struct nk_user_font title_font, description_font; + /* Stable addresses: Nuklear text commands retain font pointers until render. */ + struct nk_user_font css_fonts[64]; +} BongoCatAboutState; + +/* Preferences lifecycle and render-loop integration. */ +/* Starts each request once; retries explicitly reset the corresponding flag. */ +void bongo_cat_about_refresh(BongoCatPreferences *value); +void bongo_cat_about_overlays(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_clear(BongoCatPreferences *value, bool gl_ready); +void bongo_cat_about_assets_clear(BongoCatPreferences *value, bool gl_ready); +void bongo_cat_about_shutdown(BongoCatPreferences *value); +bool bongo_cat_about_event(BongoCatPreferences *value, const SDL_Event *event); + +#endif diff --git a/src/ui/preferences_about_community.c b/src/ui/preferences/about/preferences_about_community.c similarity index 51% rename from src/ui/preferences_about_community.c rename to src/ui/preferences/about/preferences_about_community.c index 8c05b3b8..ec675f37 100644 --- a/src/ui/preferences_about_community.c +++ b/src/ui/preferences/about/preferences_about_community.c @@ -1,3 +1,4 @@ +#include "preferences_about_internal.h" #include "preferences_about_community.h" #include "preferences_state.h" #include "ui_animation.h" @@ -21,13 +22,20 @@ static void text(struct nk_command_buffer *canvas, struct nk_rect bounds, nk_rgba(0, 0, 0, 0), color); } -static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, - const char *value, const struct nk_user_font *font, struct nk_color color) { +static void centered_span(struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *value, int length, + const struct nk_user_font *font, struct nk_color color) { float width = font->width(font->userdata, font->height, - value, nk_strlen(value)); - text(canvas, nk_rect(bounds.x + (bounds.w - width) * .5f, + value, length); + nk_draw_text(canvas, nk_rect(bounds.x + (bounds.w - width) * .5f, bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(width + 1, bounds.w), font->height), value, font, color); + NK_MIN(width + 1, bounds.w), font->height), value, length, font, + nk_rgba(0, 0, 0, 0), color); +} + +static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, + const char *value, const struct nk_user_font *font, struct nk_color color) { + centered_span(canvas, bounds, value, nk_strlen(value), font, color); } static float span_width(const struct nk_user_font *font, @@ -52,65 +60,82 @@ static void open_url(const char *url) { "Cannot open URL: %s", SDL_GetError()); } +/* Localized labels feed the font atlas and must not be hardcoded here. */ +void bongo_cat_preferences_about_localized_link(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *key, const char *fallback, + const char *url, const char *animation_id, BongoCatUIPalette palette) { + const char *label = tr(value, key, fallback); + const struct nk_user_font *font = value->ui.caption_font; + float text_width = font->width(font->userdata, font->height, + label, nk_strlen(label)); + struct nk_rect link = nk_rect(bounds.x + (bounds.w - text_width) * .5f, + bounds.y, text_width + 2, bounds.h); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, link); + float amount = bongo_cat_ui_animate_eased(context, animation_id, + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + centered(canvas, link, label, font, + bongo_cat_ui_color_mix(palette.pink, palette.accent, amount)); + if (hover) bongo_cat_ui_cursor_hover_rect(context, link, + BONGO_CAT_UI_CURSOR_POINTER); + if (hit(context, link)) open_url(url); +} + static void community_link(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, int index, BongoCatUIPalette p) { const char *labels[] = {"Discord", tr(value, - "native.support.qqGroup", "QQ Group")}; - const char *details[] = {"https://discord.gg/vf8jqnattk", "1016541762"}; + "native.support.qqGroup", "QQ Group"), tr(value,"native.about.wechat","WeChat")}; + const char *details[] = {"https://discord.gg/vf8jqnattk", "422616922", + tr(value,"native.about.wechatHint","Hover to scan and chat")}; const char *urls[] = {"https://discord.gg/vf8jqnattk", - "https://qm.qq.com/q/jmr39ESZIk"}; - struct nk_color brand = index ? p.accent : nk_rgb(88, 101, 242); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + "https://qm.qq.com/q/6ksRMCZIuA"}; + struct nk_color brand = index == 2 ? nk_rgb(7,193,96) : index ? p.accent : nk_rgb(88, 101, 242); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, bounds); char id[32]; snprintf(id, sizeof(id), "community-hover-%d", index); float lift = bongo_cat_ui_animate_eased(context, id, hover ? 1.0f : 0.0f, 220.0f, BONGO_CAT_UI_EASE_SWIFT); bounds.y -= 2.0f * lift; - struct nk_color background = bongo_cat_ui_color_mix(p.surface_glass, - brand, hover ? .10f : .055f); + const struct nk_color light[] = {nk_rgb(242, 244, 253), nk_rgb(242, 248, 254), nk_rgb(237, 248, 241)}; + struct nk_color background = bongo_cat_ui_dark(context) + ? bongo_cat_ui_color_mix(p.surface_glass, brand, .055f) : light[index]; if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 18, 0, 10, 24, 0, nk_rgba(brand.r, brand.g, brand.b, 28)); - nk_fill_rect(canvas, bounds, 18, background); - struct nk_rect mark = nk_rect(bounds.x + 12, bounds.y + 12, 44, 44); - nk_fill_rect(canvas, mark, 14, brand); - bongo_cat_preferences_icon_draw(value, canvas, + nk_fill_rect(canvas, bounds, 16, background); + struct nk_rect mark = nk_rect(bounds.x + 12, bounds.y + (bounds.h-36)*.5f, 36, 36); + nk_fill_rect(canvas, mark, 12, brand); + if(index == 2) { + bongo_cat_about_wechat_icon(value, context, nk_rect(mark.x + 5.5f, mark.y + 5.5f, 25, 25)); + } else bongo_cat_preferences_icon_draw(value, canvas, index ? BONGO_CAT_UI_ICON_QQ : BONGO_CAT_UI_ICON_DISCORD, - nk_rect(mark.x + 9.5f, mark.y + 9.5f, 25, 25), nk_rgb(255, 255, 255)); - text(canvas, nk_rect(bounds.x + 68, bounds.y + 13, bounds.w - 94, 22), - labels[index], value->ui.label_font, p.text); - text(canvas, nk_rect(bounds.x + 68, bounds.y + 37, bounds.w - 94, 18), - details[index], value->ui.caption_font, p.muted); - nk_stroke_line(canvas, bounds.x + bounds.w - 22, bounds.y + 31, - bounds.x + bounds.w - 17, bounds.y + 34, 1.5f, brand); - nk_stroke_line(canvas, bounds.x + bounds.w - 17, bounds.y + 34, - bounds.x + bounds.w - 22, bounds.y + 37, 1.5f, brand); + nk_rect(mark.x + 5.5f, mark.y + 5.5f, 25, 25), nk_rgb(255, 255, 255)); + text(canvas, nk_rect(bounds.x + 57, bounds.y + bounds.h*.5f-21, bounds.w - 77, 22), + labels[index], bongo_cat_about_font(value, 20), p.text); + const struct nk_user_font *detail_font = bongo_cat_about_font(value, 14.0f); + char detail[256]; + snprintf(detail, sizeof(detail), "%s", details[index]); + int length = nk_strlen(detail); + float detail_width = NK_MAX(0.0f, bounds.w - 80.0f); + if (detail_font->width(detail_font->userdata, detail_font->height, detail, length) > detail_width) { + float dots = detail_font->width(detail_font->userdata, detail_font->height, "...", 3); + while (length && detail_font->width(detail_font->userdata, detail_font->height, detail, length) + dots > detail_width) { + length--; + while (length && ((unsigned char)detail[length] & 0xc0) == 0x80) length--; + } + snprintf(detail + length, sizeof(detail) - (size_t)length, "..."); + } + text(canvas, nk_rect(bounds.x + 57, bounds.y + bounds.h*.5f+3, detail_width, 18), + detail, detail_font, p.muted); + float right = bounds.x + bounds.w - (bounds.h < 80 ? 20.0f : 10.0f); + float middle = bounds.y + bounds.h * .5f; + nk_stroke_line(canvas, right - 5, middle - 3, right, middle, 1.5f, brand); + nk_stroke_line(canvas, right, middle, right - 5, middle + 3, 1.5f, brand); if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, bounds)) open_url(urls[index]); -} - -static void coffee(BongoCatPreferences *value, struct nk_context *context, - struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - const char *label = tr(value, "native.support.coffee", - "Buy the development team a coffee?"); - float width = value->ui.caption_font->width(value->ui.caption_font->userdata, - value->ui.caption_font->height, label, nk_strlen(label)) + 48.0f; - struct nk_rect pill = nk_rect(bounds.x + (bounds.w - width) * .5f, - bounds.y, width, 36); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, pill); - if (p.effects) bongo_cat_ui_paint_shadow(context, pill, 18, 0, - hover ? 9.0f : 7.0f, hover ? 22.0f : 18.0f, 0, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, hover ? 77 : 61)); - nk_fill_rect(canvas, pill, 18, p.pink); - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_COFFEE, - nk_rect(pill.x + 16, pill.y + 10, 16, 16), nk_rgb(255, 255, 255)); - centered(canvas, nk_rect(pill.x + 32, pill.y, pill.w - 38, pill.h), label, - value->ui.caption_font, nk_rgb(255, 255, 255)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, pill, - BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, pill)) open_url("https://bongocat.pet/support"); + if(index == 2) { + if(hover) bongo_cat_about_wechat(value,bounds); + } else if (hit(context, bounds)) open_url(urls[index]); } void bongo_cat_preferences_about_projects_heading( @@ -123,9 +148,10 @@ void bongo_cat_preferences_about_projects_heading( value->ui.heading_font, p.text); const char *caption = tr(value, "native.support.worksText", "More software from vladelaina"); - const char *developer = strstr(caption, "vladelaina"); + static const char developer_name[] = "vladelaina"; + const char *developer = strstr(caption, developer_name); int prefix = developer ? (int)(developer - caption) : nk_strlen(caption); - int name_length = developer ? 11 : 0; + int name_length = developer ? (int)(sizeof(developer_name) - 1) : 0; const char *suffix = developer ? developer + name_length : caption + prefix; int suffix_length = nk_strlen(suffix); float prefix_width = span_width(value->ui.caption_font, caption, prefix); @@ -147,28 +173,28 @@ void bongo_cat_preferences_about_projects_heading( suffix, suffix_length, value->ui.caption_font, p.muted); if (hover) bongo_cat_ui_cursor_hover_rect(context, link, BONGO_CAT_UI_CURSOR_POINTER); - if (developer && hit(context, link)) open_url("https://vladelaina.com/"); - centered(canvas, nk_rect(bounds.x, bounds.y + 72, bounds.w, 22), - tr(value, "native.support.refactorDeveloper", - "(Developer who rebuilt BongoCat in C)"), - value->ui.caption_font, p.muted); + if (developer && hit(context, link)) + open_url("https://vladelaina.com"); } void bongo_cat_preferences_about_community( BongoCatPreferences *value, struct nk_context *context) { struct nk_rect bounds; - nk_layout_row_dynamic(context, 220, 1); + float viewport, height; + bongo_cat_ui_logical_size(&value->ui, &viewport, &height); + (void)height; + bool stacked = viewport <= 780.0f; + nk_layout_row_dynamic(context, stacked ? 334.0f : 194.0f, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; BongoCatUIPalette p = bongo_cat_ui_palette( bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - centered(canvas, nk_rect(bounds.x, bounds.y, bounds.w, 30), + centered(canvas, nk_rect(bounds.x, bounds.y + 24, bounds.w, 30), tr(value, "native.support.community", "Community"), - value->ui.heading_font, p.text); - for (int i = 0; i < 2; ++i) + bongo_cat_about_font(value, 24), p.text); + for (int i = 0; i < 3; ++i) community_link(value, context, canvas, - nk_rect(bounds.x + bounds.w * .5f - 289 + i * 298, - bounds.y + 48, 280, 68), i, p); - coffee(value, context, canvas, - nk_rect(bounds.x, bounds.y + 136, bounds.w, 36), p); + nk_rect(bounds.x + (stacked?0:i*(bounds.w+12)/3), + bounds.y + 74 + (stacked?i*80:0), stacked?bounds.w:(bounds.w-24)/3, + stacked?68.0f:88.0f), i, p); } diff --git a/src/ui/preferences_about_community.h b/src/ui/preferences/about/preferences_about_community.h similarity index 57% rename from src/ui/preferences_about_community.h rename to src/ui/preferences/about/preferences_about_community.h index cf525418..a4e87cca 100644 --- a/src/ui/preferences_about_community.h +++ b/src/ui/preferences/about/preferences_about_community.h @@ -8,5 +8,10 @@ void bongo_cat_preferences_about_community( void bongo_cat_preferences_about_projects_heading( BongoCatPreferences *value, struct nk_context *context, struct nk_rect bounds); +void bongo_cat_preferences_about_localized_link( + BongoCatPreferences *value, struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + const char *key, const char *fallback, const char *url, + const char *animation_id, BongoCatUIPalette palette); #endif diff --git a/src/ui/preferences/about/preferences_about_contributors.c b/src/ui/preferences/about/preferences_about_contributors.c new file mode 100644 index 00000000..005517ef --- /dev/null +++ b/src/ui/preferences/about/preferences_about_contributors.c @@ -0,0 +1,242 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include +#include "ui_catime.h" +#include "ui_animation.h" +#include "ui_paint.h" +#include "ui_icons.h" +#include +#include +#include + +static void contributors_load(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + bongo_cat_about_refresh(value); + if (s->contributors && !s->portraits_loaded) { + if (!s->portraits_next) { + glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); + memset(s->portraits, 0, sizeof(s->portraits)); + } + /* Bound GL work per frame, including the maximum-size contributor list. */ + int end = NK_MIN(s->portraits_next + 4, s->contributors->count); + for (int i = s->portraits_next; i < end; i++) { + unsigned char *pixels = s->contributors->people[i].pixels; + if (!pixels) continue; + glGenTextures(1, &s->portraits[i]); + glBindTexture(GL_TEXTURE_2D, s->portraits[i]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, BONGO_ABOUT_AVATAR_SIZE, + BONGO_ABOUT_AVATAR_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + } + s->portraits_next = end; + s->portraits_loaded = end == s->contributors->count; + if (!s->portraits_loaded) value->render_dirty = true; + } +} + +void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context *context) { + contributors_load(value); + float available = nk_window_get_content_region_size(context).x; + float viewport, viewport_height; + bongo_cat_ui_logical_size(&value->ui, &viewport, &viewport_height); + (void)viewport_height; + bool compact = viewport <= 560.0f; + float inset = compact ? 8.0f : 16.0f; + float avatar_size = compact ? 54.0f : 64.0f; + float gap = compact ? 18.0f : 24.0f; + float stride = avatar_size + gap; + float inner = available - 2 * inset; + const struct nk_user_font *body_font = bongo_cat_about_font(value, 16); + const struct nk_user_font *tag_font = bongo_cat_about_font(value, 14); + const struct nk_user_font *join_font = bongo_cat_about_font(value, compact ? 23.0f : 24.0f); + const char *description = bongo_cat_i18n_get(value->app->i18n, "native.about.contributorsText", + "Every step of BongoCat comes from open source. Thank you to all our contributors."); + const char *join_title = bongo_cat_i18n_get(value->app->i18n, "native.about.joinTitle", + "Want to appear in the contributors list?"); + const char *join_text = bongo_cat_i18n_get(value->app->i18n, "native.about.joinText", + "Code, documentation, bug reports and translations: every contribution makes BongoCat better."); + const char *join_button = bongo_cat_i18n_get(value->app->i18n, "native.about.joinGithub", "Contribute on GitHub"); + float natural_button_width = NK_MAX(220.0f, body_font->width(body_font->userdata, + body_font->height, join_button, nk_strlen(join_button)) + 80.0f); + bool wide = viewport > 768.0f && inner >= 344.0f + natural_button_width; + bool centered = viewport <= 768.0f; + float content_width = wide ? inner - natural_button_width - 24.0f : inner; + float title_height = compact ? 31.25f : 37.5f; + float desc_y = 44.0f + title_height + 16.0f; + float grid_y = desc_y + bongo_cat_about_paragraph(NULL, nk_rect(0, 0, inner, 0), + description, body_font, nk_rgb(0, 0, 0), true, 28.8f) + 32.0f; + int columns = NK_MAX(1, (int)((inner + gap) / stride)); + int total = value->about.contributors ? value->about.contributors->count : 0; + int rows = (NK_MAX(1, total) + columns - 1) / columns; + float grid_height = rows * stride - gap; + float join_y = grid_y + grid_height + 32.0f; + float heading_y = 28.0f + 13.0f + 10.4f; + float paragraph_y = heading_y + bongo_cat_about_text_height(join_title, join_font, content_width) + 6.0f; + float tags_y = paragraph_y + bongo_cat_about_paragraph(NULL, nk_rect(0, 0, content_width, 0), + join_text, body_font, nk_rgb(0, 0, 0), centered, 24.8f) + 18.0f; + const char *keys[] = {"native.about.code", "native.about.docs", "native.about.bugs", "native.about.translations"}; + const char *fallbacks[] = {"Code", "Documentation", "Bug reports", "Translations"}; + const char *marks[] = {"〈/〉", "▤", "♧", "文"}; + float mark_widths[4]; + const char *labels[4]; + struct nk_rect tags[4]; + float tag_x = 0, tag_y = tags_y; + for (int i = 0; i < 4; i++) { + labels[i] = bongo_cat_i18n_get(value->app->i18n, keys[i], fallbacks[i]); + mark_widths[i] = tag_font->width(tag_font->userdata, tag_font->height, marks[i], nk_strlen(marks[i])); + float tw = NK_MIN(content_width, tag_font->width(tag_font->userdata, tag_font->height, + labels[i], nk_strlen(labels[i])) + mark_widths[i] + 26.4f); + if (tag_x && tag_x + tw > content_width) { tag_x = 0; tag_y += 40.0f; } + tags[i] = nk_rect(tag_x, tag_y, tw, 32); + tag_x += tw + 8.0f; + } + if (centered) { + for (int first = 0; first < 4;) { + int last = first; + while (last + 1 < 4 && tags[last + 1].y == tags[first].y) last++; + float shift = (content_width - tags[last].x - tags[last].w) * .5f; + for (int i = first; i <= last; i++) tags[i].x += shift; + first = last + 1; + } + } + float content_height = tag_y + 32.0f; + float button_y = wide ? 28.0f + (content_height - 28.0f - 46.0f) * .5f : content_height + 24.0f; + struct nk_rect bounds; + nk_layout_row_dynamic(context, join_y + NK_MAX(content_height, button_y + 46.0f) + 44.0f, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) + return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + struct nk_rect avatars[BONGO_ABOUT_CONTRIBUTOR_CAP]; + for (int i = 0; i < total; i++) { + int row = i / columns, count = NK_MIN(columns, total - row * columns); + avatars[i] = nk_rect(bounds.x + (bounds.w - (count * stride - gap)) * .5f + (i % columns) * stride, + bounds.y + grid_y + row * stride, avatar_size, avatar_size); + } + value->about.title_font = *value->ui.heading_font; + value->about.title_font.height = compact ? 25.0f : 30.0f; + const struct nk_user_font *title_font = &value->about.title_font; + const char *title = bongo_cat_i18n_get(value->app->i18n, "native.about.contributorsTitle", + "Open source & community contributions"); + int title_length = nk_strlen(title); + float title_width = + title_font->width(title_font->userdata, title_font->height, title, title_length); + if (title_width > bounds.w - 32) { + value->about.title_font.height *= (bounds.w - 32) / title_width; + title_width = bounds.w - 32; + } + float title_x = bounds.x + (bounds.w - title_width) * .5f; + float prefix = 0; + for (int i = 0; i < title_length;) { + nk_rune rune; + int bytes = nk_utf_decode(title + i, &rune, title_length - i); + if (bytes <= 0) + break; + float glyph = title_font->width(title_font->userdata, title_font->height, title + i, bytes); + struct nk_color color = + bongo_cat_ui_color_mix(p.accent, p.pink, title_width ? prefix / title_width : 0); + nk_draw_text(canvas, + nk_rect(title_x + prefix, bounds.y + 44, glyph + 2, title_font->height), + title + i, bytes, title_font, nk_rgba(0, 0, 0, 0), color); + i += bytes; + prefix += glyph; + } + bongo_cat_about_paragraph(context, nk_rect(bounds.x + inset, bounds.y + desc_y, inner, grid_y - desc_y), + description, body_font, p.muted, true, 28.8f); + if (!total) { + struct nk_rect status = nk_rect(bounds.x + inset, bounds.y + grid_y, inner, avatar_size); + bool loading = value->about.contributors_request != NULL; + bongo_cat_about_text(value, context, status, + loading ? "native.about.loading" : "native.about.contributorsFailed", + loading ? "Loading..." : "Cannot load contributors. Click to retry.", + body_font, p.muted, true); + if (!loading && nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, status)) { + bongo_cat_ui_cursor_hover_rect(context, status, BONGO_CAT_UI_CURSOR_POINTER); + if (nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, status)) { + value->about.contributors_attempted = false; + value->render_dirty = true; + } + } + } + for (int i = 0; i < total; i++) { + struct nk_rect avatar = avatars[i]; + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, avatar); + char id[32]; + snprintf(id, sizeof(id), "contributor-%d", i); + float amount = + bongo_cat_ui_animate_eased(context, id, hover ? 1.0f : 0.0f, 400, BONGO_CAT_UI_EASE_STANDARD); + float growth = avatar_size * .075f * amount; + struct nk_rect raised = nk_rect(avatar.x - growth, avatar.y - growth - 6.0f * amount, + avatar_size + growth * 2, avatar_size + growth * 2); + if (hover && p.effects) + bongo_cat_ui_paint_shadow(context, raised, 36, 0, 8, 18, 0, nk_rgba(130, 80, 223, 65)); + nk_fill_circle(canvas, raised, p.field); + if (value->about.portraits[i]) { + struct nk_image image = nk_image_id((int)value->about.portraits[i]); + nk_draw_image(canvas, raised, &image, nk_rgb(255, 255, 255)); + } + nk_stroke_circle(canvas, raised, 2, bongo_cat_ui_color_mix(nk_rgb(255, 255, 255), p.pink, amount)); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, avatar, BONGO_CAT_UI_CURSOR_POINTER); + const char *profile = value->about.contributors->people[i].profile; + if (*profile && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, avatar)) + SDL_OpenURL(profile); + } + } + float x = bounds.x + inset, y = bounds.y + join_y, w = inner; + for (int i = 0; i < 48; i++) { + float t = ((float)i + .5f) / 48.0f; + float alpha = NK_MIN(1.0f, NK_MIN(t, 1.0f - t) / .15f) * .25f; + struct nk_color c = bongo_cat_ui_color_mix(nk_rgb(130, 80, 223), nk_rgb(30, 150, 235), + 1.0f - fabsf(t * 2.0f - 1.0f)); + nk_fill_rect(canvas, nk_rect(x + w * (float)i / 48.0f, y, w / 48.0f + .5f, 1), 0, + bongo_cat_ui_color_alpha(c, alpha)); + } + bongo_cat_about_text(value, context, nk_rect(x, y + 28, content_width, 18), NULL, "OPEN SOURCE", + bongo_cat_about_font(value, 13), p.pink, centered); + bongo_cat_about_text(value, context, nk_rect(x, y + heading_y, content_width, paragraph_y - heading_y), + NULL, join_title, join_font, p.text, centered); + bongo_cat_about_paragraph(context, nk_rect(x, y + paragraph_y, content_width, tags_y - paragraph_y), + join_text, body_font, p.muted, centered, 24.8f); + struct nk_color colors[] = {p.pink, nk_rgb(122, 162, 247), p.pink, nk_rgb(39, 201, 63)}; + for (int i = 0; i < 4; i++) { + struct nk_rect tag = tags[i]; tag.x += x; tag.y += y; + nk_fill_rect(canvas, tag, 16, bongo_cat_ui_color_mix(p.surface, colors[i], .05f)); + nk_stroke_rect(canvas, tag, 16, 1, bongo_cat_ui_color_alpha(colors[i], .15f)); + bongo_cat_about_text(value, context, nk_rect(tag.x + 10, tag.y + 9, mark_widths[i] + 1, 16), NULL, + marks[i], tag_font, colors[i], true); + bongo_cat_about_text(value, context, nk_rect(tag.x + 16.4f + mark_widths[i], tag.y + 9, + tag.w - 26.4f - mark_widths[i] + 1, 16), NULL, + labels[i], tag_font, colors[i], true); + } + float button_width = NK_MIN(w, centered ? NK_MAX(280.0f, natural_button_width) : natural_button_width); + struct nk_rect button = nk_rect(x + (wide ? w - button_width : centered ? (w - button_width) * .5f : 0), + y + button_y, button_width, 46); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, canvas->clip) && + nk_input_is_mouse_hovering_rect(&context->input, button); + float lift = bongo_cat_ui_animate_eased(context, "about-join-hover", hover ? 1.0f : 0.0f, + 400, BONGO_CAT_UI_EASE_STANDARD); + button.y -= lift * 3.0f; + if (p.effects) bongo_cat_ui_paint_shadow(context, button, 23, 0, 8, 20, 0, + bongo_cat_ui_color_alpha(p.pink, .04f + .18f * lift)); + nk_fill_rect(canvas, button, 23, bongo_cat_ui_color_mix(p.surface, p.pink, .08f * lift)); + nk_stroke_rect(canvas, button, 23, 1, bongo_cat_ui_color_alpha(p.pink, .35f + .3f * lift)); + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_GITHUB, + nk_rect(button.x + 17, button.y + 13, 20, 20), p.pink); + bongo_cat_about_text(value, context, nk_rect(button.x + 43, button.y + 16, button.w - 74, 20), + "native.about.joinGithub", "Contribute on GitHub", body_font, p.pink, true); + float arrow = button.x + button.w - 23 + 4 * lift, ay = button.y + 23; + nk_stroke_line(canvas, arrow - 8, ay, arrow, ay, 1.3f, p.pink); + nk_stroke_line(canvas, arrow - 4, ay - 4, arrow, ay, 1.3f, p.pink); + nk_stroke_line(canvas, arrow - 4, ay + 4, arrow, ay, 1.3f, p.pink); + if (hover) { + bongo_cat_ui_cursor_hover_rect(context, button, BONGO_CAT_UI_CURSOR_POINTER); + if (nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, button)) + SDL_OpenURL("https://github.com/vladelaina/BongoCat"); + } +} diff --git a/src/ui/preferences/about/preferences_about_feed.c b/src/ui/preferences/about/preferences_about_feed.c new file mode 100644 index 00000000..d5e88a3f --- /dev/null +++ b/src/ui/preferences/about/preferences_about_feed.c @@ -0,0 +1,185 @@ +#include "preferences_about_feed.h" +#include "preferences_about_svg.h" +#include +#include +#include +#include +#include + +typedef struct FeedParser { + BongoCatAboutFeed *feed; + SDL_AtomicInt *cancel; + bool in_link, in_title, failed; + BongoCatAboutContributor person; +} FeedParser; + +static int base64_digit(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + return c == '+' ? 62 : c == '/' ? 63 : -1; +} + +static unsigned char *decode_uri(const char *uri, int *length) { + const char *data = NULL; + const char *prefixes[] = {"data:image/png;base64,", "data:image/jpeg;base64,", + "data:image/webp;base64,"}; + for (size_t i = 0; i < sizeof(prefixes) / sizeof(prefixes[0]); i++) + if (!strncmp(uri, prefixes[i], strlen(prefixes[i]))) data = uri + strlen(prefixes[i]); + if (!data) return NULL; + size_t size = strlen(data); + if (!size || size > 1024u * 1024u || size % 4) return NULL; + unsigned char *bytes = malloc(size / 4 * 3); + if (!bytes) return NULL; + int used = 0; + for (size_t i = 0; i < size; i += 4) { + int a = base64_digit((unsigned char)data[i]); + int b = base64_digit((unsigned char)data[i + 1]); + int c = data[i + 2] == '=' ? 0 : base64_digit((unsigned char)data[i + 2]); + int d = data[i + 3] == '=' ? 0 : base64_digit((unsigned char)data[i + 3]); + if (a < 0 || b < 0 || c < 0 || d < 0 || + ((data[i + 2] == '=' || data[i + 3] == '=') && i + 4 != size) || + (data[i + 2] == '=' && data[i + 3] != '=')) { free(bytes); return NULL; } + bytes[used++] = (unsigned char)((a << 2) | (b >> 4)); + if (data[i + 2] != '=') bytes[used++] = (unsigned char)((b << 4) | (c >> 2)); + if (data[i + 3] != '=') bytes[used++] = (unsigned char)((c << 6) | d); + } + *length = used; + return bytes; +} + +static unsigned char *portrait(const char *uri, SDL_AtomicInt *cancel) { + if (SDL_GetAtomicInt(cancel)) return NULL; + int length = 0, width = 0, height = 0, channels = 0; + unsigned char *data = decode_uri(uri, &length); + if (!data) return NULL; + bool webp = length >= 12 && !memcmp(data, "RIFF", 4) && !memcmp(data + 8, "WEBP", 4); + bool known = webp ? WebPGetInfo(data, (size_t)length, &width, &height) != 0 : + stbi_info_from_memory(data, length, &width, &height, &channels) != 0; + if (!known || width <= 0 || height <= 0 || width > 4096 || height > 4096 || + (size_t)width * (size_t)height > 4u * 1024u * 1024u) { free(data); return NULL; } + const int size = BONGO_ABOUT_AVATAR_SIZE; + if (SDL_GetAtomicInt(cancel)) { free(data); return NULL; } + /* Decode WebP directly at display resolution instead of allocating the + full source image and then discarding almost all of its pixels. */ + WebPDecoderConfig config; + unsigned char *source = NULL; + if (webp) { + if (!WebPInitDecoderConfig(&config)) { free(data); return NULL; } + source = malloc((size_t)size * (size_t)size * 4); + if (!source) { free(data); return NULL; } + config.output.colorspace = MODE_RGBA; + config.output.is_external_memory = 1; + config.output.u.RGBA.rgba = source; + config.output.u.RGBA.stride = size * 4; + config.output.u.RGBA.size = (size_t)size * (size_t)size * 4; + config.options.use_scaling = 1; + config.options.scaled_width = size; + config.options.scaled_height = size; + if (WebPDecode(data, (size_t)length, &config) == VP8_STATUS_OK) { + source = config.output.u.RGBA.rgba; + width = height = size; + } else { + WebPFreeDecBuffer(&config.output); + free(source); + source = NULL; + } + } else { + source = stbi_load_from_memory(data, length, &width, &height, &channels, 4); + } + free(data); + if (!source) return NULL; + if (SDL_GetAtomicInt(cancel)) { + if (webp) { WebPFreeDecBuffer(&config.output); free(source); } + else stbi_image_free(source); + return NULL; + } + unsigned char *pixels = webp ? source : malloc((size_t)size * (size_t)size * 4); + if (pixels) for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { + unsigned char *out = pixels + (y * size + x) * 4; + int sx = x * width / size, sy = y * height / size; + if (!webp) memcpy(out, source + ((size_t)sy * (size_t)width + (size_t)sx) * 4, 4); + int dx = 2 * x + 1 - size, dy = 2 * y + 1 - size; + if (dx * dx + dy * dy > size * size) out[3] = 0; + } + if (webp) WebPFreeDecBuffer(&config.output); else stbi_image_free(source); + return pixels; +} + +static const char *attribute(const char **attrs, const char *name) { + for (int i = 0; attrs[i] && attrs[i + 1]; i += 2) + if (!strcmp(attrs[i], name)) return attrs[i + 1]; + return ""; +} + +static void xml_text(char *out, size_t capacity, const char *input) { + size_t used = 0; + while (*input && used + 1 < capacity) { + const char *entities[] = {"&", "<", ">", """, "'"}; + const char values[] = {'&', '<', '>', '"', '\''}; + bool entity = false; + for (size_t i = 0; i < 5; i++) if (!strncmp(input, entities[i], strlen(entities[i]))) { + out[used++] = values[i]; input += strlen(entities[i]); entity = true; break; + } + if (!entity) out[used++] = *input++; + } + out[used] = 0; +} + +static void start(void *user, const char *element, const char **attrs) { + FeedParser *p = user; + if (p->failed || SDL_GetAtomicInt(p->cancel)) return; + if (!strcmp(element, "a")) { + if (p->in_link) { p->failed = true; return; } + p->in_link = true; + memset(&p->person, 0, sizeof(p->person)); + const char *href = attribute(attrs, "href"); + if (!*href) href = attribute(attrs, "xlink:href"); + if (strlen(href) < sizeof(p->person.profile) && !strncmp(href, "https://", 8)) + xml_text(p->person.profile, sizeof(p->person.profile), href); + } else if (p->in_link && !strcmp(element, "title")) p->in_title = true; + else if (p->in_link && !strcmp(element, "image") && !p->person.pixels && + p->feed->count < BONGO_ABOUT_CONTRIBUTOR_CAP) { + const char *href = attribute(attrs, "href"); + if (!*href) href = attribute(attrs, "xlink:href"); + p->person.pixels = portrait(href, p->cancel); + } +} + +static void end(void *user, const char *element) { + FeedParser *p = user; + if (!strcmp(element, "title")) p->in_title = false; + if (!strcmp(element, "a")) { + if (!p->failed && p->person.pixels && p->feed->count < BONGO_ABOUT_CONTRIBUTOR_CAP) { + if (!p->person.name[0]) snprintf(p->person.name, sizeof(p->person.name), "Contributor"); + p->feed->people[p->feed->count++] = p->person; + p->person.pixels = NULL; + } + free(p->person.pixels); p->person.pixels = NULL; + p->in_link = p->in_title = false; + } +} + +static void content(void *user, const char *text) { + FeedParser *p = user; + if (p->in_link && p->in_title) xml_text(p->person.name, sizeof(p->person.name), text); +} + +BongoCatAboutFeed *bongo_cat_about_feed_parse(char *svg, SDL_AtomicInt *cancel) { + if (!svg || !strstr(svg, "count; i++) free(feed->people[i].pixels); + free(feed); +} diff --git a/src/ui/preferences/about/preferences_about_feed.h b/src/ui/preferences/about/preferences_about_feed.h new file mode 100644 index 00000000..b356a6d2 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_feed.h @@ -0,0 +1,21 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_FEED_H +#define BONGO_CAT_PREFERENCES_ABOUT_FEED_H + +#include + +#define BONGO_ABOUT_CONTRIBUTOR_CAP 64 +#define BONGO_ABOUT_AVATAR_SIZE 128 +typedef struct BongoCatAboutContributor { + char name[256]; + char profile[1024]; + unsigned char *pixels; +} BongoCatAboutContributor; +typedef struct BongoCatAboutFeed { + int count; + BongoCatAboutContributor people[BONGO_ABOUT_CONTRIBUTOR_CAP]; +} BongoCatAboutFeed; +/* Parsing owns decoded avatar pixels until feed_free. */ +BongoCatAboutFeed *bongo_cat_about_feed_parse(char *svg, SDL_AtomicInt *cancel); +void bongo_cat_about_feed_free(BongoCatAboutFeed *feed); + +#endif diff --git a/src/ui/preferences/about/preferences_about_footer.c b/src/ui/preferences/about/preferences_about_footer.c new file mode 100644 index 00000000..df265507 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_footer.c @@ -0,0 +1,230 @@ +#include "preferences_about_footer.h" +#include "preferences_notice.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "ui_icons.h" +#include "ui_paint.h" +#include "update_service.h" + +#include +#include + +#define SUPPORT_LOGS_SCALE (4.0f / 5.0f) +#define SUPPORT_LOGS_ICON_SIZE (14.0f * SUPPORT_LOGS_SCALE) +#define SUPPORT_LOGS_GAP 4.0f + +static const char *tr(BongoCatPreferences *value, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(value->app->i18n, key, fallback); +} + +static float width(const struct nk_user_font *font, const char *value) { + return font->width(font->userdata, font->height, value, nk_strlen(value)); +} + +static const struct nk_user_font *logs_font(BongoCatPreferences *value) { + value->support_logs_font = *value->ui.caption_font; + value->support_logs_font.height = + value->ui.caption_font->height * SUPPORT_LOGS_SCALE; + return &value->support_logs_font; +} + +static float logs_content_width(const struct nk_user_font *font, + const char *label) { + return SUPPORT_LOGS_ICON_SIZE + SUPPORT_LOGS_GAP + width(font, label); +} + +static void centered(struct nk_command_buffer *canvas, struct nk_rect bounds, + const char *value, const struct nk_user_font *font, struct nk_color color) { + float text_width = width(font, value); + nk_draw_text(canvas, nk_rect(bounds.x + (bounds.w - text_width) * .5f, + bounds.y + (bounds.h - font->height) * .5f, + NK_MIN(text_width + 1.0f, bounds.w), font->height), value, + nk_strlen(value), font, nk_rgba(0, 0, 0, 0), color); +} + +static bool hit(struct nk_context *context, struct nk_rect bounds) { + return nk_input_is_mouse_hovering_rect(&context->input, bounds) && + nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); +} + +static void link_cursor(struct nk_context *context, struct nk_rect bounds) { + if (nk_input_is_mouse_hovering_rect(&context->input, bounds)) + bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); +} + +static struct nk_color link_color(struct nk_context *context, + struct nk_rect bounds, const char *animation_id, BongoCatUIPalette p) { + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + float amount = bongo_cat_ui_animate_eased(context, animation_id, + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + return bongo_cat_ui_color_mix(p.accent, p.pink, amount); +} + +static void open_logs(BongoCatPreferences *value) { + if (bongo_cat_platform_open_directory(value->app->log_root)) return; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot open logs directory: %s", value->app->log_root); + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.openLogsFailed", "Unable to open logs folder"), true); +} + +static void draw_update(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *label, float content_width, + BongoCatUIPalette p, const BongoCatUpdateSnapshot *snapshot) { + if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 10, + 0, 4, 14, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 89)); + nk_fill_rect(canvas, bounds, 10, p.accent); + if (snapshot->status == BONGO_CAT_UPDATE_AVAILABLE || + (snapshot->status == BONGO_CAT_UPDATE_ERROR && + snapshot->release.version[0])) { + /* Mark the actionable update without obscuring its label. */ + nk_fill_circle(canvas, nk_rect(bounds.x + bounds.w - 9.0f, + bounds.y + 5.0f, 6.0f, 6.0f), p.danger); + } + float content_x = bounds.x + (bounds.w - content_width) * .5f; + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_SYNC, + nk_rect(content_x, bounds.y + 10, 16, 16), nk_rgb(255, 255, 255)); + centered(canvas, nk_rect(content_x + 24, bounds.y, + content_width - 23, bounds.h), label, value->ui.caption_font, + nk_rgb(255, 255, 255)); + link_cursor(context, bounds); + if (hit(context, bounds)) { + if (snapshot->status == BONGO_CAT_UPDATE_CHECKING) return; + if (snapshot->status == BONGO_CAT_UPDATE_AVAILABLE || + (snapshot->status == BONGO_CAT_UPDATE_ERROR && + snapshot->release.version[0])) { + if (!bongo_cat_update_refresh_and_open(value->app->update)) + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.updateFailed", + "Unable to check for updates"), true); + } else if (snapshot->status == BONGO_CAT_UPDATE_STORE || + snapshot->status == BONGO_CAT_UPDATE_UNSUPPORTED) { + if (!bongo_cat_update_open(value->app->update)) + { + char detail[384]; + const char *reason = SDL_GetError(); + snprintf(detail, sizeof(detail), "%s: %s", tr(value, + "native.support.openUpdateFailed", + "Unable to open the update page"), + reason && reason[0] ? reason : "system rejected the URL"); + bongo_cat_preferences_notice_show(value->app, detail, true); + } + } else if (!bongo_cat_update_check(value->app->update, true)) { + bongo_cat_preferences_notice_show(value->app, tr(value, + "native.support.updateFailed", + "Unable to check for updates"), true); + } else value->render_dirty = true; + } +} + +static const char *update_label(BongoCatPreferences *value, + const BongoCatUpdateSnapshot *snapshot, char *buffer, size_t capacity) { + switch (snapshot->status) { + case BONGO_CAT_UPDATE_CHECKING: + return tr(value, "native.support.checkingUpdate", "Checking..."); + case BONGO_CAT_UPDATE_AVAILABLE: + snprintf(buffer, capacity, tr(value, + "native.support.downloadUpdate", "Update to v%s"), + snapshot->release.version); + return buffer; + case BONGO_CAT_UPDATE_ERROR: + if (snapshot->release.version[0]) { + snprintf(buffer, capacity, tr(value, + "native.support.downloadUpdate", "Update to v%s"), + snapshot->release.version); + return buffer; + } + return tr(value, "native.support.checkUpdate", "Check for updates"); + case BONGO_CAT_UPDATE_STORE: + return tr(value, "native.support.openStore", "Open Microsoft Store"); + case BONGO_CAT_UPDATE_UNSUPPORTED: + return tr(value, "native.support.viewReleases", "View releases"); + default: + return tr(value, "native.support.checkUpdate", "Check for updates"); + } +} + +static void draw_links(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect feedback, struct nk_rect logs, const char *feedback_label, + const char *logs_label, BongoCatUIPalette p) { + const struct nk_user_font *small_font = logs_font(value); + float feedback_hover = bongo_cat_ui_animate_eased(context, "support-feedback-hover", + nk_input_is_mouse_hovering_rect(&context->input, feedback) ? 1.0f : 0.0f, + 200, BONGO_CAT_UI_EASE_STANDARD); + struct nk_color feedback_color = bongo_cat_ui_color_mix(nk_rgb(111, 204, 225), + nk_rgb(78, 172, 192), feedback_hover); + centered(canvas, feedback, feedback_label, value->ui.caption_font, + feedback_color); + link_cursor(context, feedback); + if (hit(context, feedback) && !SDL_OpenURL("https://bongocat.pet/?feedback=1")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open feedback page: %s", SDL_GetError()); + + struct nk_color logs_color = link_color(context, logs, + "support-logs-hover", p); + float content_width = logs_content_width(small_font, logs_label); + float content_x = logs.x + (logs.w - content_width) * .5f; + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_FOLDER, + nk_rect(content_x, logs.y + (logs.h - SUPPORT_LOGS_ICON_SIZE) * .5f, + SUPPORT_LOGS_ICON_SIZE, SUPPORT_LOGS_ICON_SIZE), logs_color); + centered(canvas, nk_rect(content_x + SUPPORT_LOGS_ICON_SIZE + + SUPPORT_LOGS_GAP, logs.y, width(small_font, logs_label) + 1, + logs.h), logs_label, small_font, logs_color); + link_cursor(context, logs); + if (hit(context, logs)) open_logs(value); +} + +void bongo_cat_preferences_about_footer(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatUIPalette p) { + const char *version_label = tr(value, + "native.support.version", "App version"); + BongoCatUpdateSnapshot update_snapshot; + bongo_cat_update_snapshot(value->app->update, &update_snapshot); + char update_buffer[96]; + const char *update = update_label(value, &update_snapshot, + update_buffer, sizeof(update_buffer)); + const char *feedback = tr(value, "native.support.feedback", "Feedback"); + const char *logs = tr(value, "native.support.openLogs", "Open logs"); + const struct nk_user_font *small_font = logs_font(value); + const char *version = BONGO_CAT_VERSION_DISPLAY; + float label_width = width(value->ui.caption_font, version_label) + 2; + float version_width = width(value->ui.label_font, version) + 2; + float update_text_width = width(value->ui.caption_font, update); + float update_content_width = 24.0f + update_text_width; + float update_width = NK_MAX(110.0f, update_content_width + 28.0f); + float feedback_width = NK_MAX(56.0f, + width(value->ui.caption_font, feedback) + 4.0f); + float logs_width = logs_content_width(small_font, logs) + 4.0f; + float links_width = feedback_width + 20.0f + logs_width; + float actions_width = update_width + 14.0f + links_width; + float info_width = label_width + version_width + 8.0f; + float total = info_width + actions_width + 14.0f; + bool stacked = total > bounds.w; + float info_x = bounds.x + (bounds.w - + (stacked ? info_width : total)) * .5f; + float info_y = bounds.y + (stacked ? -8.0f : 0.0f); + centered(canvas, nk_rect(info_x, info_y, label_width, 36), version_label, + value->ui.caption_font, p.muted); + centered(canvas, nk_rect(info_x + label_width + 8, info_y, + version_width, 36), version, value->ui.label_font, p.text); + + float actions_x = stacked ? bounds.x + + (bounds.w - actions_width) * .5f : info_x + info_width + 14.0f; + float actions_y = bounds.y + (stacked ? 22.0f : 0.0f); + struct nk_rect update_button = nk_rect(actions_x, actions_y, + update_width, 36); + draw_update(value, context, canvas, update_button, update, + update_content_width, p, &update_snapshot); + struct nk_rect feedback_link = nk_rect(actions_x + update_width + 14, + actions_y, feedback_width, 36); + struct nk_rect logs_link = nk_rect(feedback_link.x + feedback_width + 20, + actions_y, logs_width, 36); + draw_links(value, context, canvas, feedback_link, logs_link, + feedback, logs, p); +} diff --git a/src/ui/preferences/about/preferences_about_footer.h b/src/ui/preferences/about/preferences_about_footer.h new file mode 100644 index 00000000..02a53ecb --- /dev/null +++ b/src/ui/preferences/about/preferences_about_footer.h @@ -0,0 +1,10 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_FOOTER_H +#define BONGO_CAT_PREFERENCES_ABOUT_FOOTER_H + +#include "preferences_internal.h" + +void bongo_cat_preferences_about_footer(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatUIPalette palette); + +#endif diff --git a/src/ui/preferences/about/preferences_about_internal.h b/src/ui/preferences/about/preferences_about_internal.h new file mode 100644 index 00000000..baa704b5 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_internal.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_INTERNAL_H +#define BONGO_CAT_PREFERENCES_ABOUT_INTERNAL_H + +#include "preferences_about.h" + +/* Shared text layout and rendering. */ +const struct nk_user_font *bongo_cat_about_font(BongoCatPreferences *value, float size); +float bongo_cat_about_text_height(const char *text, const struct nk_user_font *font, float width); +float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bounds, + const char *text, const struct nk_user_font *font, struct nk_color color, + bool center, float leading); +void bongo_cat_about_text(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds, const char *key, const char *fallback, + const struct nk_user_font *font, struct nk_color color, bool center); + +/* Page sections and their UI helpers. */ +void bongo_cat_preferences_about_hero(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_preferences_about_projects(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_contributors(BongoCatPreferences *value, struct nk_context *context); +void bongo_cat_about_wechat(BongoCatPreferences *value, struct nk_rect anchor); +void bongo_cat_about_wechat_icon(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds); +void bongo_cat_about_ensure_event(BongoCatPreferences *value); + +#endif diff --git a/src/ui/preferences/about/preferences_about_lifecycle.c b/src/ui/preferences/about/preferences_about_lifecycle.c new file mode 100644 index 00000000..8fe7cea1 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_lifecycle.c @@ -0,0 +1,121 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include +#include +#include + +void bongo_cat_about_ensure_event(BongoCatPreferences *value) { + if (!value->about.event_type) + value->about.event_type = SDL_RegisterEvents(1); +} + +static bool same_contributors(const BongoCatAboutFeed *a, const BongoCatAboutFeed *b) { + if (!a || !b || a->count != b->count) return false; + for (int i = 0; i < a->count; i++) { + const BongoCatAboutContributor *left = &a->people[i], *right = &b->people[i]; + if (strcmp(left->name, right->name) || strcmp(left->profile, right->profile) || + (!left->pixels != !right->pixels) || + (left->pixels && memcmp(left->pixels, right->pixels, + BONGO_ABOUT_AVATAR_SIZE * BONGO_ABOUT_AVATAR_SIZE * 4))) return false; + } + return true; +} + +/* Collect CPU results even on other settings pages; GL uploads remain lazy. + Cancelled jobs keep their slot until completion, bounding rapid reopen work. */ +void bongo_cat_about_refresh(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + BongoCatAboutRequest **slots[] = {&s->qr_request, &s->contributors_request}; + for (int i = 0; i < 2; i++) { + BongoCatAboutRequest *job = *slots[i]; + if (!job || !SDL_GetAtomicInt(&job->done)) continue; + bool refresh = value->visible && job->refresh_needed && + !SDL_GetAtomicInt(&job->cancel); + int kind = job->kind; + if (value->visible && job->status == 200 && !SDL_GetAtomicInt(&job->cancel)) { + if (value->page == 3) value->render_dirty = true; + if (job->kind == BONGO_ABOUT_WECHAT) { + if (!s->qr_pixels || !job->qr_pixels || + memcmp(s->qr_pixels, job->qr_pixels, 240 * 240 * 4)) { + free(s->qr_pixels); + s->qr_pixels = job->qr_pixels; + job->qr_pixels = NULL; + s->qr_texture_dirty = true; + } + } else if (!same_contributors(s->contributors, job->feed)) { + bongo_cat_about_feed_free(s->contributors); + s->contributors = job->feed; + job->feed = NULL; + s->portraits_loaded = false; + s->portraits_next = 0; + } + } + bongo_cat_about_request_free(job); + *slots[i] = NULL; + if (refresh) + *slots[i] = bongo_cat_about_request(kind, s->event_type, + SDL_GetWindowID(value->window), value->app->cache_root, true); + } + if (!value->visible) return; + bongo_cat_about_ensure_event(value); + if (!s->contributors_attempted && !s->contributors_request) { + s->contributors_attempted = true; + s->contributors_request = bongo_cat_about_request(BONGO_ABOUT_CONTRIBUTORS, + s->event_type, SDL_GetWindowID(value->window), value->app->cache_root, false); + } + if (!s->qr_attempted && !s->qr_request) { + s->qr_attempted = true; + s->qr_request = bongo_cat_about_request(BONGO_ABOUT_WECHAT, + s->event_type, SDL_GetWindowID(value->window), value->app->cache_root, false); + } +} + +bool bongo_cat_about_event(BongoCatPreferences *value, const SDL_Event *event) { + if (value->about.event_type && event->type == value->about.event_type) { + bongo_cat_about_refresh(value); + if (value->visible && value->page == 3) value->render_dirty = true; + return true; + } + return false; +} + +void bongo_cat_about_assets_clear(BongoCatPreferences *value, bool gl_ready) { + BongoCatAboutState *s = &value->about; + if (gl_ready) { + if (s->wechat_icon_texture) + glDeleteTextures(1, &s->wechat_icon_texture); + if (s->qr_texture) + glDeleteTextures(1, &s->qr_texture); + glDeleteTextures(BONGO_ABOUT_CONTRIBUTOR_CAP, s->portraits); + } + s->qr_texture = 0; + s->qr_texture_dirty = false; + s->wechat_icon_texture = 0; + s->wechat_icon_attempted = false; + memset(s->portraits, 0, sizeof(s->portraits)); + s->portraits_loaded = false; + s->portraits_next = 0; +} + +void bongo_cat_about_clear(BongoCatPreferences *value, bool gl_ready) { + BongoCatAboutState *s = &value->about; + /* Closing settings never waits for DNS/TLS. Shutdown joins remaining jobs. */ + if (s->qr_request) + SDL_SetAtomicInt(&s->qr_request->cancel, 1); + if (s->contributors_request) + SDL_SetAtomicInt(&s->contributors_request->cancel, 1); + bongo_cat_about_feed_free(s->contributors); + s->contributors = NULL; + free(s->qr_pixels); + s->qr_pixels = NULL; + s->contributors_attempted = false; + bongo_cat_about_assets_clear(value, gl_ready); + s->portraits_loaded = s->qr_attempted = s->qr_open = false; +} + +void bongo_cat_about_shutdown(BongoCatPreferences *value) { + bongo_cat_about_request_free(value->about.contributors_request); + value->about.contributors_request = NULL; + bongo_cat_about_request_free(value->about.qr_request); + value->about.qr_request = NULL; +} diff --git a/src/ui/preferences/about/preferences_about_online.c b/src/ui/preferences/about/preferences_about_online.c new file mode 100644 index 00000000..4aa3554d --- /dev/null +++ b/src/ui/preferences/about/preferences_about_online.c @@ -0,0 +1,277 @@ +#include "preferences_about_online.h" +#include "preferences_about_svg.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include +#include +#ifdef _WIN32 +#include +#include +#else +#include +#include +#endif + +#define RESPONSE_LIMIT (4u * 1024u * 1024u) +#define CACHE_TTL_NS (SDL_NS_PER_SECOND * 86400LL) + +/* Capacity excludes the terminator. Network growth is geometric and bounded; + disk reads reserve their exact known size without touching a 4 MiB buffer. */ +static bool reserve_response(BongoCatAboutRequest *job, size_t capacity) { + if (capacity > job->limit) return false; + if (job->response && capacity <= job->capacity) return true; + char *response = realloc(job->response, capacity + 1); + if (!response) return false; + job->response = response; + job->capacity = capacity; + job->response[job->length] = 0; + return true; +} + +static bool cache_path(BongoCatAboutRequest *job, char *path, size_t capacity) { + return job->cache_directory[0] && bongo_cat_path_join(path, capacity, + job->cache_directory, job->kind == BONGO_ABOUT_CONTRIBUTORS + ? "contributors-v1.svg" : "wechat-v1.svg"); +} + +/* SVG parsers modify their input. Only network results need a preserved copy + for cache writes; disk reads can be parsed in place. */ +static bool decode_response(BongoCatAboutRequest *job, bool preserve_response) { + if (SDL_GetAtomicInt(&job->cancel) || !job->length || + memchr(job->response, 0, job->length) || !strstr(job->response, "response, "response, "length + 1) : job->response; + if (!copy) return false; + if (preserve_response) memcpy(copy, job->response, job->length + 1); + bool valid; + if (job->kind == BONGO_ABOUT_CONTRIBUTORS) { + job->feed = bongo_cat_about_feed_parse(copy, &job->cancel); + valid = job->feed && job->feed->count; + if (!valid) { + bongo_cat_about_feed_free(job->feed); + job->feed = NULL; + } + } else { + job->qr_pixels = bongo_cat_about_qr_pixels(copy); + valid = job->qr_pixels != NULL; + } + if (preserve_response) free(copy); + return valid; +} + +static bool cache_read(BongoCatAboutRequest *job) { + char path[BONGO_CAT_PATH_CAP]; + if (!cache_path(job, path, sizeof(path))) return false; + SDL_PathInfo info; + if (!SDL_GetPathInfo(path, &info) || info.type != SDL_PATHTYPE_FILE || + !info.size || info.size > job->limit) return false; + if (!reserve_response(job, (size_t)info.size)) return false; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + size_t size = (size_t)info.size; + bool ok = fread(job->response, 1, size, file) == size; + if (fclose(file) != 0) ok = false; + job->length = size; + job->response[size] = 0; + if (!ok || !decode_response(job, false)) { + job->length = 0; + job->response[0] = 0; + return false; + } + SDL_Time now = 0; + job->refresh_needed = !SDL_GetCurrentTime(&now) || info.modify_time <= 0 || + now < info.modify_time || now - info.modify_time >= CACHE_TTL_NS; + job->status = 200; + return true; +} + +static void cache_write(BongoCatAboutRequest *job) { + char path[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP + 64]; + if (SDL_GetAtomicInt(&job->cancel) || !cache_path(job, path, sizeof(path)) || + !bongo_cat_path_create_directory(job->cache_directory)) return; + /* A sibling temporary file preserves the previous cache on failed writes. */ + unsigned long long process_id; +#ifdef _WIN32 + process_id = (unsigned long long)GetCurrentProcessId(); +#else + process_id = (unsigned long long)getpid(); +#endif + int length = snprintf(temporary, sizeof(temporary), "%s.%llu.%llu.tmp", path, + process_id, + (unsigned long long)SDL_GetCurrentThreadID()); + if (length < 0 || (size_t)length >= sizeof(temporary)) return; + FILE *file = bongo_cat_file_open(temporary, "wb"); + if (!file) return; + bool ok = fwrite(job->response, 1, job->length, file) == job->length; + if (fclose(file) != 0) ok = false; + if (!ok || SDL_GetAtomicInt(&job->cancel) || + !bongo_cat_file_replace(temporary, path, false)) + bongo_cat_file_remove(temporary); +} + +static int complete(BongoCatAboutRequest *job) { + free(job->response); + job->response = NULL; + job->length = 0; + job->capacity = 0; + SDL_SetAtomicInt(&job->done, 1); + SDL_Event event; + memset(&event, 0, sizeof(event)); + event.type = job->event_type; + event.user.windowID = job->window_id; + SDL_PushEvent(&event); + return 0; +} + +static bool append(BongoCatAboutRequest *job, const void *data, size_t size) { + if (SDL_GetAtomicInt(&job->cancel) || size > job->limit - job->length) + return false; + size_t needed = job->length + size; + if (!job->response || needed > job->capacity) { + size_t capacity = job->capacity ? job->capacity : 4096; + while (capacity < needed) + capacity = capacity > job->limit / 2 ? job->limit : capacity * 2; + if (!reserve_response(job, capacity)) return false; + } + memcpy(job->response + job->length, data, size); + job->length += size; + job->response[job->length] = 0; + return true; +} + +#ifndef _WIN32 +static size_t receive(char *data, size_t size, size_t count, void *user) { + if (size && count > RESPONSE_LIMIT / size) + return 0; + return append(user, data, size * count) ? size * count : 0; +} +static int progress(void *user, curl_off_t a, curl_off_t b, curl_off_t c, curl_off_t d) { + (void)a; + (void)b; + (void)c; + (void)d; + return SDL_GetAtomicInt(&((BongoCatAboutRequest *)user)->cancel); +} +#endif + +static int SDLCALL request_worker(void *user) { + BongoCatAboutRequest *job = user; + /* Best effort: background assets should yield CPU time to interaction. */ + SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW); + /* Publish stale data before starting the separate background refresh. */ + if (!job->network_only && cache_read(job)) return complete(job); + if (SDL_GetAtomicInt(&job->cancel)) return complete(job); + bool ok = false; +#ifdef _WIN32 + HINTERNET session = WinHttpOpen(L"BongoCat About/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + HINTERNET connection = NULL, request = NULL; + if (session) { + WinHttpSetTimeouts(session, 3000, 3000, 4000, 4000); + connection = + WinHttpConnect(session, L"bongocat.pet", + INTERNET_DEFAULT_HTTPS_PORT, 0); + } + if (connection) + request = WinHttpOpenRequest( + connection, L"GET", + job->kind == BONGO_ABOUT_CONTRIBUTORS ? L"/co" : L"/wechat", + NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (request) { + DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)); + ok = !SDL_GetAtomicInt(&job->cancel) && + WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && + WinHttpReceiveResponse(request, NULL); + DWORD status = 0, bytes = sizeof(status); + if (ok) + ok = WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + NULL, &status, &bytes, NULL) != FALSE; + job->status = (int)status; + /* Error bodies cannot produce usable images; do not download them. */ + if (status != 200) ok = false; + Uint64 deadline = SDL_GetTicks() + 15000; + while (ok && !SDL_GetAtomicInt(&job->cancel)) { + char buffer[4096]; + DWORD read = 0; + ok = SDL_GetTicks() < deadline && + WinHttpReadData(request, buffer, sizeof(buffer), &read); + if (!ok || !read) + break; + ok = append(job, buffer, read); + } + } + if (request) + WinHttpCloseHandle(request); + if (connection) + WinHttpCloseHandle(connection); + if (session) + WinHttpCloseHandle(session); +#else + bool initialized = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK; + CURL *curl = initialized ? curl_easy_init() : NULL; + if (curl) { + curl_easy_setopt(curl, CURLOPT_URL, + job->kind == BONGO_ABOUT_CONTRIBUTORS ? "https://bongocat.pet/co" + : "https://bongocat.pet/wechat"); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "BongoCat About/1.0"); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 15000L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, job); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, job); + ok = curl_easy_perform(curl) == CURLE_OK; + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + job->status = (int)status; + curl_easy_cleanup(curl); + } + /* curl is shared with the update service; process lifetime owns its global state. */ +#endif + if (!ok || SDL_GetAtomicInt(&job->cancel)) + job->status = 0; + if (job->status == 200) { + if (decode_response(job, true)) cache_write(job); + else job->status = 0; + } + return complete(job); +} + +BongoCatAboutRequest *bongo_cat_about_request(int kind, Uint32 event_type, + Uint32 window_id, const char *cache_root, bool network_only) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + if (!job) + return NULL; + job->limit = kind == BONGO_ABOUT_CONTRIBUTORS ? RESPONSE_LIMIT : 64u * 1024u; + job->kind = kind; + job->event_type = event_type; + job->window_id = window_id; + job->network_only = network_only; + if (cache_root && cache_root[0] && + !bongo_cat_path_join(job->cache_directory, sizeof(job->cache_directory), + cache_root, "about")) job->cache_directory[0] = 0; + job->thread = SDL_CreateThread(request_worker, "bongocat-about", job); + if (!job->thread) { + free(job->response); + free(job); + return NULL; + } + return job; +} + +void bongo_cat_about_request_free(BongoCatAboutRequest *job) { + if (!job) + return; + SDL_SetAtomicInt(&job->cancel, 1); + if (job->thread) SDL_WaitThread(job->thread, NULL); + bongo_cat_about_feed_free(job->feed); + free(job->qr_pixels); + free(job->response); + free(job); +} diff --git a/src/ui/preferences/about/preferences_about_online.h b/src/ui/preferences/about/preferences_about_online.h new file mode 100644 index 00000000..776e5e4c --- /dev/null +++ b/src/ui/preferences/about/preferences_about_online.h @@ -0,0 +1,34 @@ +#ifndef BONGO_CAT_ABOUT_ONLINE_H +#define BONGO_CAT_ABOUT_ONLINE_H + +#include "preferences_about_feed.h" +#include "bongo_cat/common.h" +#include +#include + +/* One bounded cache read or network request per job. Worker results are published + by done. A stale cache succeeds with refresh_needed, then the UI launches one + network_only job while keeping the cached content visible. */ +enum { BONGO_ABOUT_WECHAT, BONGO_ABOUT_CONTRIBUTORS }; +typedef struct BongoCatAboutRequest { + SDL_Thread *thread; + SDL_AtomicInt done; + SDL_AtomicInt cancel; + int kind; + size_t limit; + BongoCatAboutFeed *feed; + unsigned char *qr_pixels; + char *response; + size_t length, capacity; + int status; + char cache_directory[BONGO_CAT_PATH_CAP]; + bool network_only, refresh_needed; + Uint32 event_type; + Uint32 window_id; +} BongoCatAboutRequest; + +BongoCatAboutRequest *bongo_cat_about_request(int kind, Uint32 event_type, + Uint32 window_id, const char *cache_root, bool network_only); +void bongo_cat_about_request_free(BongoCatAboutRequest *request); + +#endif diff --git a/src/ui/preferences/about/preferences_about_page.c b/src/ui/preferences/about/preferences_about_page.c new file mode 100644 index 00000000..801fb3e4 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_page.c @@ -0,0 +1,12 @@ +#include "preferences_about_internal.h" +#include "preferences_about_community.h" +#include "preferences_state.h" + +void bongo_cat_preferences_page_about(BongoCatPreferences *value, + struct nk_context *context) { + bongo_cat_preferences_support_assets_load(value); + bongo_cat_preferences_about_hero(value, context); + bongo_cat_preferences_about_community(value, context); + bongo_cat_about_contributors(value, context); + bongo_cat_preferences_about_projects(value, context); +} diff --git a/src/ui/preferences/about/preferences_about_svg.c b/src/ui/preferences/about/preferences_about_svg.c new file mode 100644 index 00000000..29a8c890 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_svg.c @@ -0,0 +1,49 @@ +#include "preferences_about_svg.h" +#include +#include +#define NANOSVG_IMPLEMENTATION +#include "../../../../vendor/nanosvg/nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "../../../../vendor/nanosvg/nanosvgrast.h" + +static unsigned char *svg_pixels(char *svg, int size) { + NSVGimage *image = nsvgParse(svg, "px", 96); + if (!image) + return NULL; + unsigned char *pixels = NULL; + if (isfinite(image->width) && isfinite(image->height) && image->width > 0 && + image->height > 0 && image->width <= 4096 && image->height <= 4096) { + NSVGrasterizer *raster = nsvgCreateRasterizer(); + pixels = calloc((size_t)size * (size_t)size, 4); + if (raster && pixels) { + float scale = (float)size / fmaxf(image->width, image->height); + nsvgRasterize(raster, image, ((float)size - image->width * scale) * .5f, + ((float)size - image->height * scale) * .5f, scale, pixels, size, size, size * 4); + } else { + free(pixels); + pixels = NULL; + } + nsvgDeleteRasterizer(raster); + } + nsvgDelete(image); + return pixels; +} + +unsigned char *bongo_cat_about_qr_pixels(char *svg) { + return svg_pixels(svg, 240); +} + +unsigned char *bongo_cat_about_wechat_pixels(int size) { + /* Exact two-bubble WeChat paths from the supplied 1.html, rasterized once. */ + char svg[] = "" + "" + ""; + if (size < 25 || size > 100) return NULL; + return svg_pixels(svg, size); +} + +int bongo_cat_about_svg_parse_xml(char *input, + void (*start)(void *, const char *, const char **), + void (*end)(void *, const char *), void (*content)(void *, const char *), void *user) { + return nsvg__parseXML(input, start, end, content, user); +} diff --git a/src/ui/preferences/about/preferences_about_svg.h b/src/ui/preferences/about/preferences_about_svg.h new file mode 100644 index 00000000..06d0650b --- /dev/null +++ b/src/ui/preferences/about/preferences_about_svg.h @@ -0,0 +1,13 @@ +#ifndef BONGO_CAT_PREFERENCES_ABOUT_SVG_H +#define BONGO_CAT_PREFERENCES_ABOUT_SVG_H + +/* SVG input is modified in place. Pixel buffers are released with free(). */ +unsigned char *bongo_cat_about_qr_pixels(char *svg); +unsigned char *bongo_cat_about_wechat_pixels(int size); + +/* Bounded feed parsing uses the same XML tokenizer as SVG rasterization. */ +int bongo_cat_about_svg_parse_xml(char *input, + void (*start)(void *, const char *, const char **), + void (*end)(void *, const char *), void (*content)(void *, const char *), void *user); + +#endif diff --git a/src/ui/preferences/about/preferences_about_text.c b/src/ui/preferences/about/preferences_about_text.c new file mode 100644 index 00000000..3dac096d --- /dev/null +++ b/src/ui/preferences/about/preferences_about_text.c @@ -0,0 +1,56 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include + +const struct nk_user_font *bongo_cat_about_font(BongoCatPreferences *value, float size) { + int index = NK_CLAMP(0, (int)(size * 2.0f), 63); + struct nk_user_font *font = &value->about.css_fonts[index]; + *font = *(size >= 18.0f ? value->ui.heading_font : value->ui.caption_font); + font->height = size; + return font; +} + +/* The same wrapping routine measures and draws, keeping translated layouts aligned. */ +float bongo_cat_about_paragraph(struct nk_context *context, struct nk_rect bounds, + const char *text, const struct nk_user_font *font, struct nk_color color, + bool center, float leading) { + float y = bounds.y; + int remaining = (int)strlen(text); + while (*text && (!context || y + font->height <= bounds.y + bounds.h + 1)) { + int length = 0; + float width = 0; + while (length < remaining) { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) break; + /* Nuklear's atlas width is the sum of glyph advances (no kerning). + Measure each UTF-8 glyph once instead of every growing prefix. */ + float next = width + font->width(font->userdata, font->height, text + length, bytes); + if (length && next > bounds.w) break; + length += bytes; + width = next; + if (rune == '\n') break; + } + if (!length) break; + if (context) nk_draw_text(nk_window_get_canvas(context), + nk_rect(bounds.x + (center ? (bounds.w - width) * .5f : 0), y, bounds.w, font->height), + text, length, font, nk_rgba(0, 0, 0, 0), color); + y += leading; + text += length; + remaining -= length; + while (*text == ' ' || *text == '\n') { text++; remaining--; } + } + return y - bounds.y; +} + +float bongo_cat_about_text_height(const char *text, const struct nk_user_font *font, float width) { + return bongo_cat_about_paragraph(NULL, nk_rect(0, 0, width, 0), text, font, + nk_rgb(0, 0, 0), false, font->height + 6.0f); +} + +void bongo_cat_about_text(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds, const char *key, const char *fallback, + const struct nk_user_font *font, struct nk_color color, bool center) { + const char *label = key ? bongo_cat_i18n_get(value->app->i18n, key, fallback) : fallback; + bongo_cat_about_paragraph(context, bounds, label, font, color, center, font->height + 6.0f); +} diff --git a/src/ui/preferences/about/preferences_about_wechat.c b/src/ui/preferences/about/preferences_about_wechat.c new file mode 100644 index 00000000..b92f6901 --- /dev/null +++ b/src/ui/preferences/about/preferences_about_wechat.c @@ -0,0 +1,107 @@ +#include "preferences_state.h" +#include "preferences_about_internal.h" +#include "preferences_about_svg.h" +#include "ui_catime.h" +#include "ui_paint.h" +#include +#include + +void bongo_cat_about_wechat_icon(BongoCatPreferences *value, struct nk_context *context, + struct nk_rect bounds) { + BongoCatAboutState *s = &value->about; + if (!s->wechat_icon_attempted) { + s->wechat_icon_attempted = true; + float scale = NK_CLAMP(1.0f, value->ui.raster_scale, 4.0f); + int size = (int)(25.0f * scale + .5f); + unsigned char *pixels = bongo_cat_about_wechat_pixels(size); + if (pixels) { + glGenTextures(1, &s->wechat_icon_texture); + glBindTexture(GL_TEXTURE_2D, s->wechat_icon_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + free(pixels); + } + } + if (s->wechat_icon_texture) { + struct nk_image icon = nk_image_id((int)s->wechat_icon_texture); + nk_draw_image(nk_window_get_canvas(context), bounds, &icon, nk_rgb(255, 255, 255)); + } +} + +void bongo_cat_about_wechat(BongoCatPreferences *value, struct nk_rect anchor) { + BongoCatAboutState *state = &value->about; + state->qr_open = true; + state->qr_anchor = anchor; + state->qr_hide_at = SDL_GetTicks() + 250; + bongo_cat_about_refresh(value); +} + +void bongo_cat_about_overlays(BongoCatPreferences *value, struct nk_context *context) { + bongo_cat_about_refresh(value); + BongoCatAboutState *s = &value->about; + if (!s->qr_open || value->page != 3) + return; + if (s->qr_texture_dirty) { + if (s->qr_texture) glDeleteTextures(1, &s->qr_texture); + s->qr_texture = 0; + s->qr_texture_dirty = false; + } + if (!s->qr_texture && s->qr_pixels) { + glGenTextures(1, &s->qr_texture); + glBindTexture(GL_TEXTURE_2D, s->qr_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 240, 240, 0, GL_RGBA, + GL_UNSIGNED_BYTE, s->qr_pixels); + } + float width, height; + bongo_cat_ui_logical_size(&value->ui, &width, &height); + struct nk_rect anchor = s->qr_anchor; + float size = NK_MIN(200.0f, NK_MIN(width - 24.0f, height - 24.0f)); + bool above = anchor.y >= size + 22.0f; + float y = above ? anchor.y - size - 10.0f : anchor.y + anchor.h + 10.0f; + struct nk_rect panel = nk_rect(NK_CLAMP(12, anchor.x + anchor.w * .5f - size * .5f, width - size - 12), + NK_CLAMP(12, y, height - size - 12), size, size); + bool over = nk_input_is_mouse_hovering_rect(&context->input, panel); + if (over) + s->qr_hide_at = SDL_GetTicks() + 250; + if (SDL_GetTicks() >= s->qr_hide_at) { + s->qr_open = false; + return; + } + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_push_scissor(canvas, nk_rect(0, 0, width, height)); + if (bongo_cat_ui_palette(bongo_cat_ui_dark(context)).effects) + bongo_cat_ui_paint_shadow(context, panel, 10, 0, 2, 6, 0, nk_rgba(0, 0, 0, 8)); + nk_fill_rect(canvas, panel, 10, nk_rgb(255, 255, 255)); + nk_stroke_rect(canvas, panel, 10, 1, nk_rgba(0, 0, 0, 10)); + float arrow_x = NK_CLAMP(panel.x + 16, anchor.x + anchor.w * .5f, panel.x + panel.w - 16); + float edge = above ? panel.y + panel.h : panel.y; + float tip = edge + (above ? 6.0f : -6.0f); + nk_fill_triangle(canvas, arrow_x - 5, edge, arrow_x + 5, edge, arrow_x, tip, + nk_rgb(255, 255, 255)); + if (s->qr_texture) { + struct nk_image image = nk_image_id((int)s->qr_texture); + nk_draw_image(canvas, nk_rect(panel.x + 4, panel.y + 4, size - 8, size - 8), &image, + nk_rgb(255, 255, 255)); + } else { + bongo_cat_about_text(value, context, nk_rect(panel.x + 16, panel.y + size * .3f, size - 32, 52), + s->qr_request ? "native.about.loading" : "native.about.qrUnavailable", + s->qr_request ? "Loading..." : "QR code unavailable", + bongo_cat_about_font(value, 13), nk_rgb(85, 85, 85), true); + if (!s->qr_request) { + struct nk_rect retry = nk_rect(panel.x + (size - 88) * .5f, panel.y + size * .55f, 88, 30); + nk_fill_rect(canvas, retry, 6, nk_rgb(7, 193, 96)); + bongo_cat_about_text(value, context, nk_rect(retry.x, retry.y + 8, retry.w, 18), + "native.about.reload", "Reload", bongo_cat_about_font(value, 12), nk_rgb(255, 255, 255), true); + bongo_cat_ui_cursor_hover_rect(context, retry, BONGO_CAT_UI_CURSOR_POINTER); + if (over && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, retry)) { + s->qr_attempted = false; + bongo_cat_about_wechat(value, s->qr_anchor); + } + } + } +} diff --git a/src/ui/preferences.c b/src/ui/preferences/preferences.c similarity index 59% rename from src/ui/preferences.c rename to src/ui/preferences/preferences.c index 1164e1da..7d359079 100644 --- a/src/ui/preferences.c +++ b/src/ui/preferences/preferences.c @@ -1,6 +1,8 @@ +#include "bongo_cat/audio.h" #include "bongo_cat/preferences.h" #include "bongo_cat/app.h" #include "bongo_cat/memory.h" +#include "bongo_cat/memory_policy.h" #include "bongo_cat/platform.h" #include "preferences_controls.h" #include "preferences_model_cover.h" @@ -13,8 +15,8 @@ #include int bongo_cat_preferences_resolved_theme(const BongoCatPreferences *value) { - if (value->app->config.app.theme == BONGO_CAT_THEME_DARK) return 1; - if (value->app->config.app.theme == BONGO_CAT_THEME_LIGHT) return 0; + if (value->app->settings.app.theme == BONGO_CAT_THEME_DARK) return 1; + if (value->app->settings.app.theme == BONGO_CAT_THEME_LIGHT) return 0; return SDL_GetSystemTheme() == SDL_SYSTEM_THEME_DARK; } void bongo_cat_preferences_apply_theme(BongoCatPreferences *value) { @@ -25,6 +27,18 @@ void bongo_cat_preferences_apply_theme(BongoCatPreferences *value) { bongo_cat_ui_native_theme_apply(value->window, dark != 0); } +void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, + int previous_page, int next_page) { + if (!value || previous_page == next_page) return; + bool released = false; + if (previous_page == 3 && next_page != 3) { + value->about.qr_open = false; + bongo_cat_preferences_support_assets_clear(value); + released = true; + } + if (released) bongo_cat_memory_policy_ui_loaded(); +} + BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { if (!app) return NULL; BongoCatPreferences *value = calloc(1, sizeof(*value)); @@ -35,66 +49,7 @@ BongoCatPreferences *bongo_cat_preferences_create(BongoCatApp *app) { value->page = app->smoke_preference_page; return value; } -void bongo_cat_preferences_show(BongoCatPreferences *value) { - if (!value) return; - const BongoCatModelEntry *entry = bongo_cat_models_find( - &value->app->models, value->app->config.current_model); - bongo_cat_preferences_model_cover_capture(value->app, entry); - if (!value->window && !bongo_cat_preferences_open_window(value)) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Preferences failed: %s", SDL_GetError()); - bongo_cat_preferences_close(value); - return; - } - value->render_dirty = true; - bongo_cat_preferences_render(value); - bongo_cat_platform_raise_window(value->window); -} -void bongo_cat_preferences_close(BongoCatPreferences *value) { - if (!value || !value->window) return; - bongo_cat_preferences_live_resize_uninstall(value); - if (bongo_cat_preferences_behavior_dialog_active(value)) - bongo_cat_preferences_behavior_dialog_close(value); - bongo_cat_preferences_shortcut_cancel(value); - if (value->gl_context) SDL_GL_MakeCurrent(value->window, value->gl_context); - if (value->ui_initialized && value->input_active) - bongo_cat_preferences_input_end(value); - SDL_StopTextInput(value->window); - bongo_cat_preferences_model_cache_clear(value->app); - if (value->ui_initialized) { - bongo_cat_pref_controls_reset(&value->ui.context); - bongo_cat_ui_animations_reset(&value->ui.context); - } - bongo_cat_preferences_assets_clear(value); - if (value->ui_initialized) { - bongo_cat_ui_cursor_destroy(&value->ui); - bongo_cat_ui_destroy(&value->ui); - } - if (value->chrome_dragging) SDL_CaptureMouse(false); - if (value->owns_gl_context && value->gl_context) - SDL_GL_DestroyContext(value->gl_context); - SDL_DestroyWindow(value->window); - value->window = NULL; - value->gl_context = NULL; - value->owns_gl_context = false; - value->transparent_window = false; - value->ui_initialized = false; - value->font_reload_pending = false; - value->smoke_behavior_open_pending = false; - value->native_drag = false; - value->chrome_dragging = false; - value->pending_raster_scale = 0.0f; - value->raster_retry_ns = 0; - value->render_retry_ns = 0; - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - SDL_GL_SetSwapInterval(1); - bongo_cat_platform_trim_memory(); - bongo_cat_config_store_flush(value->app); -} -void bongo_cat_preferences_destroy(BongoCatPreferences *value) { - if (value) { bongo_cat_preferences_close(value); - bongo_cat_preferences_import_destroy(value->import_dialog); - free(value); } -} + void bongo_cat_preferences_request_model_import(BongoCatPreferences *value) { if (value && !bongo_cat_preferences_import_is_open(value->import_dialog)) value->import_requested = true; } @@ -103,16 +58,42 @@ bool bongo_cat_preferences_open_model_import(BongoCatPreferences *value, return value && parent && bongo_cat_preferences_import_open( value->import_dialog, parent); } -bool bongo_cat_preferences_visible(const BongoCatPreferences *value) { return value && value->window; } -bool bongo_cat_preferences_needs_frame(const BongoCatPreferences *value) { - if (!value || !value->window) return false; +bool bongo_cat_preferences_visible(const BongoCatPreferences *value) { + return value && value->window && value->visible; +} +bool bongo_cat_preferences_needs_frame(BongoCatPreferences *value) { + if (!value) return false; uint64_t now = SDL_GetTicksNS(); + if (value->model_load_visual_active && + !value->model_load_visual_completion_ns && !value->model_loading && + !value->model_selection_pending && now - + value->model_load_visual_started_ns >= + BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS) { + value->model_load_visual_active = false; + value->model_load_progress = 1.0f; + value->render_dirty = true; + } + if (!value->window || !value->visible) return false; + if (value->about.qr_open && SDL_GetTicks() >= value->about.qr_hide_at) + value->render_dirty = true; + if (value->behavior_dialog && value->app) { + for (size_t i = 0; i < value->app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; + bool playing = entry->kind == BONGO_CAT_BEHAVIOR_SOUND && + (entry->sound_clear ? bongo_cat_audio_any_playing(value->app->audio) : + bongo_cat_audio_is_playing(value->app->audio, entry->sound)); + if (value->behavior_audio_playing[i] != playing) { + value->behavior_audio_playing[i] = playing; + value->render_dirty = true; + } + } + } if (value->render_retry_ns > now) return false; bool raster_due = value->pending_raster_scale > 0.0f && value->raster_retry_ns <= now; return value->render_dirty || raster_due || value->chrome_dragging; } void bongo_cat_preferences_input_begin(BongoCatPreferences *value) { - if (!value || !value->window || value->input_active) return; + if (!value || !value->window || !value->visible || value->input_active) return; bongo_cat_ui_input_begin(&value->ui); value->input_active = true; } void bongo_cat_preferences_input_end(BongoCatPreferences *value) { @@ -130,13 +111,33 @@ static Uint32 event_window(const SDL_Event *event) { case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: return event->button.windowID; case SDL_EVENT_MOUSE_WHEEL: return event->wheel.windowID; - case SDL_EVENT_DROP_FILE: return event->drop.windowID; + case SDL_EVENT_DROP_BEGIN: case SDL_EVENT_DROP_FILE: + case SDL_EVENT_DROP_COMPLETE: case SDL_EVENT_DROP_POSITION: + return event->drop.windowID; default: return 0; } } +static void enter_model_drop(BongoCatPreferences *value) { + value->page = 1; + /* Reveal the import card and cancel any pending scroll animation. */ + value->scroll_current[1] = 0.0f; + value->scroll_target[1] = 0.0f; + value->scroll_ready[1] = true; + value->import_drop_active = true; + value->render_dirty = true; +} + +bool bongo_cat_preferences_chrome_drag_allowed( + const BongoCatPreferences *value) { + return value && value->app && !value->native_drag && + !bongo_cat_preferences_behavior_dialog_active(value) && + !bongo_cat_preferences_remove_dialog_active(value->app); +} + void bongo_cat_preferences_drag_tick(BongoCatPreferences *value) { - if (!value || !value->window || !value->chrome_dragging) return; + if (!value || !value->window || !value->visible || + !value->chrome_dragging) return; float pointer_x = 0.0f, pointer_y = 0.0f; SDL_MouseButtonFlags buttons = SDL_GetGlobalMouseState( &pointer_x, &pointer_y); @@ -157,6 +158,11 @@ void bongo_cat_preferences_drag_tick(BongoCatPreferences *value) { static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { if (event->type == SDL_EVENT_KEY_DOWN && event->key.key == SDLK_ESCAPE) { + if (value->about.qr_open) { + value->about.qr_open = false; + value->render_dirty = true; + return true; + } if (bongo_cat_preferences_behavior_dialog_active(value)) { bongo_cat_preferences_behavior_dialog_close(value); value->render_dirty = true; @@ -170,7 +176,15 @@ static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { bongo_cat_preferences_close(value); return true; } - if (value->native_drag) return false; + if (!bongo_cat_preferences_chrome_drag_allowed(value)) { + if (event->type == SDL_EVENT_MOUSE_BUTTON_UP && + event->button.button == SDL_BUTTON_LEFT && value->chrome_dragging) { + SDL_CaptureMouse(false); + value->chrome_dragging = false; + return true; + } + return false; + } if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && event->button.button == SDL_BUTTON_LEFT) { int width = 0; @@ -202,23 +216,47 @@ static bool chrome_event(BongoCatPreferences *value, const SDL_Event *event) { bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *event) { if (!value || !event) return false; + if (bongo_cat_about_event(value, event)) return true; if (bongo_cat_preferences_import_event(value->import_dialog, value->app, - event)) { value->render_dirty = value->window != NULL; + event)) { value->render_dirty = value->visible; return true; } if (!value->window) return false; if (event->type == SDL_EVENT_SYSTEM_THEME_CHANGED) { value->render_dirty = true; return false; } + if (!value->visible) return false; if (event_window(event) != SDL_GetWindowID(value->window)) return false; + if (value->shown_ns && event->common.timestamp && + event->common.timestamp < value->shown_ns) return true; + if (event->type == SDL_EVENT_DROP_BEGIN) { + enter_model_drop(value); + return true; + } + if (event->type == SDL_EVENT_DROP_POSITION) { + enter_model_drop(value); + return true; + } + if (event->type == SDL_EVENT_DROP_COMPLETE) { + value->import_drop_active = false; + value->render_dirty = true; + return true; + } if (bongo_cat_preferences_scale_event(value, event)) return true; +#ifdef __APPLE__ + /* The user returns from System Settings through this window. */ + if (event->type == SDL_EVENT_WINDOW_FOCUS_GAINED) { + bongo_cat_preferences_input_monitoring_refresh(value); + value->render_dirty = true; + } +#endif if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + bongo_cat_ui_input_reset(&value->ui); if (value->chrome_dragging) SDL_CaptureMouse(false); value->chrome_dragging = false; + value->import_drop_active = false; bongo_cat_pref_controls_reset(&value->ui.context); } - if (value->app->smoke_input_audit && (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || - event->type == SDL_EVENT_MOUSE_BUTTON_UP)) SDL_Log("Preferences mouse %s at %.1f,%.1f", - event->button.down ? "down" : "up", event->button.x, event->button.y); + if (bongo_cat_preferences_model_rename_event(value, event)) return true; if (bongo_cat_preferences_behavior_rename_event(value, event)) return true; if (bongo_cat_preferences_shortcut_event(value, event)) return true; if (event->type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { @@ -226,6 +264,8 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev return true; } if (event->type == SDL_EVENT_DROP_FILE) { + enter_model_drop(value); + value->import_drop_active = false; bongo_cat_preferences_import_path(value->app, value->window, event->drop.data); return true; } @@ -238,3 +278,13 @@ bool bongo_cat_preferences_event(BongoCatPreferences *value, const SDL_Event *ev void bongo_cat_preferences_invalidate(BongoCatPreferences *value) { if (value) value->render_dirty = true; } + +void bongo_cat_preferences_models_changed(BongoCatPreferences *value) { + if (!value) return; + value->render_dirty = true; + value->model_directory_watch_known = false; + value->font_reload_pending = value->ui_initialized; + /* Catalog refreshes finish between UI frames. Rebuild before drawing the + new cards so names never appear with missing-glyph placeholders. */ + value->font_reload_defer_once = false; +} diff --git a/src/ui/preferences_assets.c b/src/ui/preferences/preferences_assets.c similarity index 73% rename from src/ui/preferences_assets.c rename to src/ui/preferences/preferences_assets.c index 903f88d2..142cd188 100644 --- a/src/ui/preferences_assets.c +++ b/src/ui/preferences/preferences_assets.c @@ -36,22 +36,27 @@ static int raster_size(const BongoCatPreferences *value, int logical) { } void bongo_cat_preferences_assets_load(BongoCatPreferences *value) { - value->logo_texture = load(value, "logo.png", raster_size(value, 192), + value->logo_texture = load(value, "bongocat.png", raster_size(value, 192), &value->logo_width, &value->logo_height); int width = 0, height = 0; value->icon_texture = load(value, "ui-symbols.png", 0, &width, &height); - value->icon_texture_hidpi = load(value, "ui-symbols@4x.png", 0, - &width, &height); + value->icon_hidpi_attempted = false; } -void bongo_cat_preferences_icon_draw(const BongoCatPreferences *value, +void bongo_cat_preferences_icon_draw(BongoCatPreferences *value, struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color) { if (!value || !value->icon_texture || icon < 0 || icon >= BONGO_CAT_UI_ICON_COUNT) return; bool large = bounds.w > 24.0f || bounds.h > 24.0f; - bool hidpi = value->icon_texture_hidpi && (large || - value->ui.raster_scale > 1.05f); + bool needs_hidpi = large || value->ui.raster_scale > 1.05f; + if (needs_hidpi && !value->icon_hidpi_attempted) { + int width = 0, height = 0; + value->icon_hidpi_attempted = true; + value->icon_texture_hidpi = load(value, "ui-symbols@4x.png", 0, + &width, &height); + } + bool hidpi = value->icon_texture_hidpi && needs_hidpi; int cell = hidpi ? 96 : 24; int atlas_width = cell * BONGO_CAT_UI_ICON_COUNT; unsigned int texture = hidpi ? value->icon_texture_hidpi : value->icon_texture; @@ -83,11 +88,31 @@ static void clear(unsigned int *texture) { *texture = 0; } +void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value) { + if (!value) return; + clear(&value->catime_texture); + clear(&value->vlaina_texture); + value->support_assets_loaded = false; +} + void bongo_cat_preferences_assets_clear(BongoCatPreferences *value) { + if (!value) return; + bongo_cat_about_assets_clear(value, true); clear(&value->logo_texture); clear(&value->icon_texture); clear(&value->icon_texture_hidpi); - clear(&value->catime_texture); - clear(&value->vlaina_texture); + value->icon_hidpi_attempted = false; + bongo_cat_preferences_support_assets_clear(value); +} + +void bongo_cat_preferences_assets_abandon(BongoCatPreferences *value) { + if (!value) return; + bongo_cat_about_assets_clear(value, false); + value->logo_texture = 0; + value->icon_texture = 0; + value->icon_texture_hidpi = 0; + value->catime_texture = 0; + value->vlaina_texture = 0; + value->icon_hidpi_attempted = false; value->support_assets_loaded = false; } diff --git a/src/ui/preferences_behavior_dialog.c b/src/ui/preferences/preferences_behavior_dialog.c similarity index 75% rename from src/ui/preferences_behavior_dialog.c rename to src/ui/preferences/preferences_behavior_dialog.c index 0eff69d8..3ac47744 100644 --- a/src/ui/preferences_behavior_dialog.c +++ b/src/ui/preferences/preferences_behavior_dialog.c @@ -34,15 +34,29 @@ static bool hit(struct nk_context *context, struct nk_rect bounds, bool enabled) return enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds) && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); } -static bool matches(const BongoCatBehaviorEntry *entry, int tab) { - return tab == 0 ? entry->kind == BONGO_CAT_BEHAVIOR_MOTION : - entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; +static bool matches(const BongoCatPreferences *value, + const BongoCatBehaviorEntry *entry, int tab) { + if (tab == 0) return entry->kind == BONGO_CAT_BEHAVIOR_MOTION && + bongo_cat_live2d_motion_visible(value->app->live2d, + entry->group, entry->index); + if (tab == 1) return entry->kind == BONGO_CAT_BEHAVIOR_EXPRESSION; + return entry->kind == BONGO_CAT_BEHAVIOR_SOUND && (entry->sound[0] || entry->sound_clear); +} + +static bool tab_available(const BongoCatPreferences *value, int tab) { + for (size_t i = 0; i < value->app->behaviors.count; ++i) { + const BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; + if (matches(value, entry, tab) && (tab != 2 || entry->sound[0])) return true; + } + return false; } static size_t row_count(const BongoCatPreferences *value) { + if (!tab_available(value, value->behavior_tab)) return 0; size_t count = 0; for (size_t i = 0; i < value->app->behaviors.count; ++i) - if (matches(&value->app->behaviors.entries[i], value->behavior_tab)) + if (matches(value, &value->app->behaviors.entries[i], + value->behavior_tab)) count++; return count; } @@ -69,6 +83,7 @@ void bongo_cat_preferences_behavior_dialog_close( if (!value || !value->behavior_dialog || value->behavior_dialog_closing_ns) return; value->behavior_dialog_closing_ns = SDL_GetTicksNS(); + bongo_cat_preferences_scrollbar_reset(&value->behavior_scrollbar); bongo_cat_preferences_behavior_rename_finish(value, true); bongo_cat_preferences_shortcut_cancel(value); value->render_dirty = true; @@ -79,16 +94,10 @@ static bool draw_header(BongoCatPreferences *value, struct nk_context *context, BongoCatUIPalette p, float opacity, bool enabled) { text(canvas, nk_rect(panel.x + 20, panel.y + 21, panel.w - 74, 24), tr(value, "pages.preference.model.behaviorModal.title", - "Motions and expressions"), value->ui.label_font, alpha(p.text, opacity)); + "Motions, expressions and audio"), value->ui.label_font, alpha(p.text, opacity)); struct nk_rect close = nk_rect(panel.x + panel.w - 52, panel.y + 17, 32, 32); - bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, close); - nk_fill_rect(canvas, close, 8, alpha(hover ? p.hover : p.field, opacity)); - bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CLOSE, - nk_rect(close.x + 7, close.y + 7, 18, 18), - alpha(hover ? p.accent : p.muted, opacity)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, close, - BONGO_CAT_UI_CURSOR_POINTER); - return hit(context, close, enabled); + return bongo_cat_ui_close_button(context, canvas, close, + alpha(p.muted, opacity), alpha(p.accent, opacity), enabled); } static void draw_segments(BongoCatPreferences *value, @@ -99,10 +108,15 @@ static void draw_segments(BongoCatPreferences *value, nk_fill_rect(canvas, wrapper, 10, alpha(p.field, opacity)); const char *labels[] = {tr(value, "pages.preference.model.behaviorModal.labels.motion", "Motions"), tr(value, - "pages.preference.model.behaviorModal.labels.expression", "Expressions")}; - float width = (wrapper.w - 6) * .5f; - for (int i = 0; i < 2; ++i) { - struct nk_rect button = nk_rect(wrapper.x + 3 + width * i, + "pages.preference.model.behaviorModal.labels.expression", "Expressions"), tr(value, + "pages.preference.model.behaviorModal.labels.audio", "Audio")}; + int count = 0, position = 0; + for (int i = 0; i < 3; ++i) count += tab_available(value, i); + if (!count) return; + float width = (wrapper.w - 6) / count; + for (int i = 0; i < 3; ++i) { + if (!tab_available(value, i)) continue; + struct nk_rect button = nk_rect(wrapper.x + 3 + width * position++, wrapper.y + 3, width, 37); bool hover = enabled && nk_input_is_mouse_hovering_rect( &context->input, button); @@ -123,6 +137,8 @@ static void draw_segments(BongoCatPreferences *value, BONGO_CAT_UI_CURSOR_POINTER); if (hit(context, button, enabled) && value->behavior_tab != i) { value->behavior_tab = i; + bongo_cat_preferences_scrollbar_reset( + &value->behavior_scrollbar); value->behavior_tab_transition_ns = SDL_GetTicksNS(); bongo_cat_preferences_shortcut_cancel(value); } @@ -134,15 +150,21 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, BongoCatUIPalette p, float opacity, bool enabled, size_t count) { struct nk_rect viewport = nk_rect(panel.x + 20, panel.y + 144, panel.w - 40, panel.h - 164); - float maximum = NK_MAX(0.0f, count * 56.0f - viewport.h); - if (enabled && nk_input_is_mouse_hovering_rect(&context->input, viewport) && - context->input.mouse.scroll_delta.y != 0) { - value->behavior_scroll[value->behavior_tab] = NK_CLAMP(0.0f, - value->behavior_scroll[value->behavior_tab] - - context->input.mouse.scroll_delta.y * 48.0f, maximum); + float content_height = count * 56.0f; + float maximum = NK_MAX(0.0f, content_height - viewport.h); + float saved_offset = value->behavior_scroll[value->behavior_tab]; + BongoCatPreferencesScrollbarResult scroll = + bongo_cat_preferences_scrollbar_draw(context, canvas, viewport, + content_height, saved_offset, &value->behavior_scrollbar, p.accent, + opacity, enabled); + if (scroll.changed) { + value->behavior_scroll[value->behavior_tab] = scroll.offset; value->render_dirty = true; } - float offset = NK_CLAMP(0.0f, value->behavior_scroll[value->behavior_tab], maximum); + float offset = NK_CLAMP(0.0f, scroll.offset, maximum); + float render_offset = offset; + float row_width = bongo_cat_preferences_scrollbar_content_width( + viewport, content_height); float content_opacity = opacity; if (value->behavior_tab_transition_ns) { float progress = (float)(SDL_GetTicksNS() - @@ -151,7 +173,8 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, else { float eased = bongo_cat_ui_ease(BONGO_CAT_UI_EASE_SWIFT, NK_CLAMP(0.0f, progress, 1.0f)); - content_opacity *= eased; offset += 5.0f * (1.0f - eased); + content_opacity *= eased; + render_offset += 5.0f * (1.0f - eased); value->render_dirty = true; } } @@ -159,9 +182,9 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, size_t shown = 0; for (size_t i = 0; i < value->app->behaviors.count; ++i) { BongoCatBehaviorEntry *entry = &value->app->behaviors.entries[i]; - if (!matches(entry, value->behavior_tab)) continue; + if (!matches(value, entry, value->behavior_tab)) continue; struct nk_rect row = nk_rect(viewport.x, - viewport.y + shown++ * 56.0f - offset, viewport.w, 56); + viewport.y + shown++ * 56.0f - render_offset, row_width, 56); if (row.y + row.h >= viewport.y && row.y <= viewport.y + viewport.h) bongo_cat_preferences_behavior_row_draw(value, context, canvas, row, entry, p, @@ -175,6 +198,10 @@ static void draw_rows(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_behavior_dialog_draw( BongoCatPreferences *value, struct nk_context *context) { if (!bongo_cat_preferences_behavior_dialog_active(value)) return; + if (!tab_available(value, value->behavior_tab)) + for (int i = 0; i < 3; ++i) if (tab_available(value, i)) { + value->behavior_tab = i; break; + } bongo_cat_ui_cursor_reset(context); struct nk_rect region = nk_window_get_bounds(context); size_t count = row_count(value); diff --git a/src/ui/preferences/preferences_behavior_rename.c b/src/ui/preferences/preferences_behavior_rename.c new file mode 100644 index 00000000..eab37f75 --- /dev/null +++ b/src/ui/preferences/preferences_behavior_rename.c @@ -0,0 +1,77 @@ +#include "preferences_state.h" +#include "preferences_text_edit.h" + +#include +#include +#include + +static BongoCatBehaviorShortcut *binding_for(BongoCatSettings *config, + const char *id) { + for (size_t i = 0; i < config->behavior_shortcut_count; ++i) + if (!strcmp(config->behavior_shortcuts[i].id, id)) + return &config->behavior_shortcuts[i]; + if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) + return NULL; + BongoCatBehaviorShortcut *binding = + &config->behavior_shortcuts[config->behavior_shortcut_count++]; + memset(binding, 0, sizeof(*binding)); + snprintf(binding->id, sizeof(binding->id), "%s", id); + return binding; +} + +static const char *display_label(const BongoCatBehaviorEntry *entry, + const BongoCatBehaviorShortcut *binding) { + return binding && binding->label[0] ? binding->label : entry->label; +} + +void bongo_cat_preferences_behavior_rename_finish( + BongoCatPreferences *value, bool save) { + if (!value || !bongo_cat_preferences_text_session_active( + &value->behavior_rename)) return; + BongoCatPreferencesTextSession *session = &value->behavior_rename; + bool label_changed = false; + if (save) { + bongo_cat_text_edit_trim(session->text); + BongoCatBehaviorEntry *entry = NULL; + for (size_t i = 0; i < value->app->behaviors.count; ++i) + if (!strcmp(value->app->behaviors.entries[i].id, session->id)) { + entry = &value->app->behaviors.entries[i]; + break; + } + BongoCatBehaviorShortcut *binding = binding_for(&value->app->settings, + session->id); + if (binding) { + const char *label = entry && !strcmp(session->text, entry->label) ? + "" : session->text; + label_changed = strcmp(binding->label, label) != 0; + snprintf(binding->label, sizeof(binding->label), "%s", label); + } + } + bongo_cat_preferences_text_session_reset(session); + SDL_StopTextInput(value->window); + if (label_changed) bongo_cat_preferences_reload_fonts(value); + value->render_dirty = true; +} + +void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, + const BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, + struct nk_rect bounds) { + if (!value || !entry) return; + bongo_cat_preferences_shortcut_cancel(value); + bongo_cat_preferences_text_session_begin(&value->behavior_rename, + entry->id, display_label(entry, binding), bounds); + SDL_StartTextInput(value->window); + value->render_dirty = true; +} + +bool bongo_cat_preferences_behavior_rename_event( + BongoCatPreferences *value, const SDL_Event *event) { + if (!value) return false; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&value->behavior_rename, + event, value->ui.layout_scale, value->ui.caption_font, 8.0f); + if (result.finish) + bongo_cat_preferences_behavior_rename_finish(value, result.save); + if (result.handled) value->render_dirty = true; + return result.handled; +} diff --git a/src/ui/preferences_behavior_row.c b/src/ui/preferences/preferences_behavior_row.c similarity index 70% rename from src/ui/preferences_behavior_row.c rename to src/ui/preferences/preferences_behavior_row.c index a125eb26..13945670 100644 --- a/src/ui/preferences_behavior_row.c +++ b/src/ui/preferences/preferences_behavior_row.c @@ -1,5 +1,6 @@ #include "preferences_state.h" -#include "preferences_notice.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/shortcut.h" #include "preferences_overlay.h" #include "preferences_shortcut_clear.h" #include "ui_animation.h" @@ -27,12 +28,12 @@ static bool hit(struct nk_context *context, struct nk_rect bounds, bool enabled) nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds); } -static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, +static BongoCatBehaviorShortcut *binding_for(BongoCatSettings *config, const char *id) { for (size_t i = 0; i < config->behavior_shortcut_count; ++i) if (!strcmp(config->behavior_shortcuts[i].id, id)) return &config->behavior_shortcuts[i]; - if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) return NULL; + if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_BINDING_CAP) return NULL; BongoCatBehaviorShortcut *binding = &config->behavior_shortcuts[config->behavior_shortcut_count++]; memset(binding, 0, sizeof(*binding)); @@ -40,9 +41,13 @@ static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, return binding; } -static const char *display_label(const BongoCatBehaviorEntry *entry, +static const char *display_label(BongoCatPreferences *value, const BongoCatBehaviorEntry *entry, const BongoCatBehaviorShortcut *binding) { - return binding && binding->label[0] ? binding->label : entry->label; + if (binding && binding->label[0]) + return binding->label; + if (entry->sound_clear) return bongo_cat_i18n_get(value->app->i18n, + "pages.preference.model.behaviorModal.labels.stopAllAudio", "Stop all audio"); + return entry->label; } static bool shortcut_editor(BongoCatPreferences *value, @@ -63,9 +68,11 @@ static bool shortcut_editor(BongoCatPreferences *value, (hover ? p.hover : p.field), opacity)); nk_stroke_rect(canvas, bounds, 10, 1, alpha(active ? p.pink : (hover ? p.accent : p.border_subtle), opacity)); + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(shortcut->shortcut, shortcut_label, sizeof(shortcut_label)); const char *shown = active ? bongo_cat_i18n_get(value->app->i18n, "components.shortcut.hints.pressRecordShortcut", "Press shortcut") : - (shortcut->shortcut[0] ? shortcut->shortcut : + (shortcut->shortcut[0] ? shortcut_label : bongo_cat_i18n_get(value->app->i18n, "components.shortcut.hints.clickRecordShortcut", "Click to record shortcut")); @@ -83,7 +90,9 @@ static bool shortcut_editor(BongoCatPreferences *value, bounds.y + 8, 17, 20); if (bongo_cat_pref_shortcut_clear(context, canvas, id, clear, p, opacity, enabled && !active && shortcut->shortcut[0])) { - shortcut->shortcut[0] = '\0'; value->render_dirty = true; return false; + shortcut->shortcut[0] = '\0'; + shortcut->shortcut_disabled = true; + value->render_dirty = true; return false; } if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); @@ -94,8 +103,9 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, BongoCatUIPalette p, float opacity, bool enabled) { - const char *label = display_label(entry, binding); - bool renaming = !strcmp(value->behavior_rename_id, entry->id); + const char *label = display_label(value, entry, binding); + BongoCatPreferencesTextSession *session = &value->behavior_rename; + bool renaming = !strcmp(session->id, entry->id); bool hover = enabled && nk_input_is_mouse_hovering_rect( &context->input, bounds); if (renaming || hover) { @@ -104,18 +114,24 @@ static void draw_name(BongoCatPreferences *value, struct nk_context *context, nk_stroke_rect(canvas, bounds, 8, 1, alpha(renaming ? p.pink : p.accent, opacity)); } - const char *shown = renaming ? value->behavior_rename_text : label; - text(canvas, nk_rect(bounds.x + 8, bounds.y + 9, bounds.w - 16, 21), - shown, value->ui.caption_font, alpha(p.text, opacity)); + const char *shown = renaming ? session->text : label; + struct nk_rect text_bounds = nk_rect(bounds.x + 8, + bounds.y + 9, bounds.w - 16, 21); + if (renaming && session->select_all) + nk_fill_rect(canvas, text_bounds, 4, + alpha(p.selection, opacity)); + text(canvas, text_bounds, shown, value->ui.caption_font, + alpha(p.text, opacity)); if (renaming) { - value->behavior_rename_bounds = bounds; + session->bounds = bounds; float caret = value->ui.caption_font->width( value->ui.caption_font->userdata, value->ui.caption_font->height, - shown, nk_strlen(shown)); + shown, (int)session->cursor); caret = NK_MIN(caret, bounds.w - 18); - nk_stroke_line(canvas, bounds.x + 8 + caret, bounds.y + 8, - bounds.x + 8 + caret, bounds.y + bounds.h - 8, 1, - alpha(p.pink, opacity)); + if (!session->select_all) + nk_stroke_line(canvas, bounds.x + 8 + caret, bounds.y + 8, + bounds.x + 8 + caret, bounds.y + bounds.h - 8, 1, + alpha(p.pink, opacity)); } else if (hover) { bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_TEXT); if (hit(context, bounds, enabled) && binding) @@ -132,25 +148,29 @@ void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, struct nk_rect shortcut_bounds = nk_rect(play.x - 188, row.y + 10, 180, 36); struct nk_rect name = nk_rect(row.x + 8, row.y + 9, NK_MAX(48.0f, shortcut_bounds.x - row.x - 16), 38); - BongoCatBehaviorShortcut *binding = binding_for(&value->app->config, + BongoCatBehaviorShortcut *binding = binding_for(&value->app->settings, entry->id); draw_name(value, context, canvas, name, entry, binding, p, opacity, enabled); - bool play_hover = enabled && nk_input_is_mouse_hovering_rect( + bool play_enabled = enabled; + bool play_hover = play_enabled && nk_input_is_mouse_hovering_rect( &context->input, play); nk_fill_rect(canvas, play, 10, alpha(play_hover ? p.hover : p.field, opacity)); nk_stroke_rect(canvas, play, 10, 1, alpha(play_hover ? p.accent : p.border_subtle, opacity)); - bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_PLAY, + bool playing = entry->kind == BONGO_CAT_BEHAVIOR_SOUND && + bongo_cat_audio_is_playing(value->app->audio, entry->sound); + if (playing) + nk_fill_rect(canvas, nk_rect(play.x + 12, play.y + 12, 12, 12), 2, + alpha(!play_enabled ? p.muted : play_hover ? p.accent : p.text, opacity)); + else bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_PLAY, nk_rect(play.x + 10, play.y + 10, 16, 16), - alpha(play_hover ? p.accent : p.text, opacity)); + alpha(!play_enabled ? p.muted : play_hover ? p.accent : p.text, opacity)); if (play_hover) bongo_cat_ui_cursor_hover_rect(context, play, BONGO_CAT_UI_CURSOR_POINTER); - if (hit(context, play, enabled)) { - const char *label = display_label(entry, binding); - bongo_cat_app_run_behavior(value->app, entry); - char message[BONGO_CAT_ID_CAP + 5]; - snprintf(message, sizeof(message), "%s \xE2\x9C\x93", label); - bongo_cat_preferences_notice_show(value->app, message, false); + if (hit(context, play, play_enabled)) { + if (playing) bongo_cat_audio_stop_path(value->app->audio, entry->sound); + else bongo_cat_app_run_behavior(value->app, entry); + value->render_dirty = true; } if (!binding) return; char id[BONGO_CAT_ID_CAP + 16]; diff --git a/src/ui/preferences_combo.c b/src/ui/preferences/preferences_combo.c similarity index 100% rename from src/ui/preferences_combo.c rename to src/ui/preferences/preferences_combo.c diff --git a/src/ui/preferences_controls.c b/src/ui/preferences/preferences_controls.c similarity index 92% rename from src/ui/preferences_controls.c rename to src/ui/preferences/preferences_controls.c index 08b59ff0..aa80ecc5 100644 --- a/src/ui/preferences_controls.c +++ b/src/ui/preferences/preferences_controls.c @@ -1,4 +1,5 @@ #include "preferences_controls.h" +#include "preferences_number_edit.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -100,6 +101,7 @@ bool bongo_cat_pref_controls_animating(struct nk_context *context) { } void bongo_cat_pref_controls_reset(struct nk_context *context) { + bongo_cat_pref_number_edit_reset(context); if (repeat_state.context == context) memset(&repeat_state, 0, sizeof(repeat_state)); slider_drag_stop(context); @@ -155,25 +157,34 @@ static double stepper(struct nk_context *context, const char *id, nk_stroke_line(canvas, plus.x + 13, cy, plus.x + 27, cy, 2, plus_color); nk_stroke_line(canvas, plus.x + 20, cy - 7, plus.x + 20, cy + 7, 2, plus_color); + if (minus_hover || plus_hover) bongo_cat_ui_cursor_hover_rect(context, interaction, + BONGO_CAT_UI_CURSOR_POINTER); + double before = value; + bool reset_default = nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_DOUBLE, number_box) != 0; + if (reset_default) { + value = NK_CLAMP(minimum, default_value, maximum); + bongo_cat_pref_number_edit_reset(context); + nk_edit_unfocus(context); + } char number[32]; if (integer) snprintf(number, sizeof(number), "%.0f", value); else if (step < .1) snprintf(number, sizeof(number), "%.2f", value); else if (step < 1.0) snprintf(number, sizeof(number), "%.1f", value); else snprintf(number, sizeof(number), "%.0f", value); - centered(canvas, number_box, number, bongo_cat_ui_body_font(context), p.text); - if (hover) bongo_cat_ui_cursor_hover_rect(context, interaction, - BONGO_CAT_UI_CURSOR_POINTER); + bool editing = !reset_default && bongo_cat_pref_number_edit(context, id, + number_box, number, integer, minimum, maximum, &value); + if (!editing) centered(canvas, number_box, number, + bongo_cat_ui_body_font(context), p.text); int direction = repeat_direction(context, id, minus, plus_hit); float wheel = context->input.mouse.scroll_delta.y; - if (nk_input_is_mouse_hovering_rect(&context->input, number_box) && wheel != 0) { + if (!editing && nk_input_is_mouse_hovering_rect(&context->input, number_box) && wheel != 0) { direction = wheel > 0 ? 1 : -1; context->input.mouse.scroll_delta.y = 0; } - bool reset = nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_DOUBLE, number_box) != 0; - double next = reset ? NK_CLAMP(minimum, default_value, maximum) : - NK_CLAMP(minimum, value + direction * step, maximum); - if (next != value) { value = next; *changed = true; } + if (!reset_default) + value = NK_CLAMP(minimum, value + direction * step, maximum); + *changed = value != before; return value; } @@ -183,6 +194,7 @@ bool bongo_cat_pref_control_float(struct nk_context *context, const char *id, bool changed; double result = stepper(context, id, minimum, *value, maximum, step, default_value, false, &changed); + changed = changed && *value != (float)result; if (changed) *value = (float)result; return changed; } diff --git a/src/ui/preferences_controls.h b/src/ui/preferences/preferences_controls.h similarity index 69% rename from src/ui/preferences_controls.h rename to src/ui/preferences/preferences_controls.h index 037f3a6a..243da4dc 100644 --- a/src/ui/preferences_controls.h +++ b/src/ui/preferences/preferences_controls.h @@ -2,8 +2,13 @@ #define BONGO_CAT_PREFERENCES_CONTROLS_H #include +#include "bongo_cat/config.h" #include "nuklear_config.h" +/* Stable, unique ids retain numeric drafts; valid edits update value immediately. + * Integer fields accept digits and an optional minus; floats also accept a dot. + * Values are clamped to the supplied range. Clicking away discards incomplete text. + */ bool bongo_cat_pref_control_float(struct nk_context *context, const char *id, float minimum, float *value, float maximum, float step, float default_value); @@ -14,6 +19,8 @@ bool bongo_cat_pref_control_slider(struct nk_context *context, const char *id, float default_value); bool bongo_cat_pref_control_toggle(struct nk_context *context, const char *id, bool *value); +bool bongo_cat_pref_control_obs_background(struct nk_context *context, + const char *id, bool *enabled, BongoCatObsBackgroundColor *color); int bongo_cat_pref_control_combo(struct nk_context *context, const char *id, const char *const *items, int count, int selected); bool bongo_cat_pref_controls_animating(struct nk_context *context); diff --git a/src/ui/preferences_dialog.c b/src/ui/preferences/preferences_dialog.c similarity index 81% rename from src/ui/preferences_dialog.c rename to src/ui/preferences/preferences_dialog.c index 7e805976..ffb73c20 100644 --- a/src/ui/preferences_dialog.c +++ b/src/ui/preferences/preferences_dialog.c @@ -13,6 +13,7 @@ typedef struct RemoveDialog { BongoCatApp *app; char model_id[BONGO_CAT_ID_CAP]; + char model_name[BONGO_CAT_ID_CAP]; uint64_t opened_ns; uint64_t closing_ns; bool input_armed; @@ -58,6 +59,9 @@ void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, if (!app || !id || !id[0]) return; remove_dialog.app = app; snprintf(remove_dialog.model_id, sizeof(remove_dialog.model_id), "%s", id); + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, id); + snprintf(remove_dialog.model_name, sizeof(remove_dialog.model_name), "%s", + bongo_cat_model_name(&app->settings, entry)); remove_dialog.opened_ns = SDL_GetTicksNS(); remove_dialog.closing_ns = 0; remove_dialog.input_armed = false; @@ -80,13 +84,13 @@ static bool button(BongoCatApp *app, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect bounds, const char *label, bool danger, BongoCatUIPalette p, float opacity, bool enabled) { bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds); - struct nk_color background = danger ? - (hover ? p.danger : p.danger_background) : (hover ? p.hover : p.field); - struct nk_color foreground = danger ? (hover ? nk_rgb(255, 255, 255) : - p.danger) : (hover ? p.accent : p.text); + struct nk_color background = danger ? p.danger : + (hover ? p.hover : p.field); + struct nk_color foreground = danger ? nk_rgb(255, 255, 255) : + (hover ? p.accent : p.text); nk_fill_rect(canvas, bounds, 10, alpha(background, opacity)); - nk_stroke_rect(canvas, bounds, 10, 1, alpha(danger ? p.danger : - (hover ? p.accent : p.border_subtle), opacity)); + if (danger) nk_stroke_rect(canvas, bounds, 10, 1, + alpha(p.danger, opacity)); centered(canvas, bounds, label, app->preferences->ui.caption_font, alpha(foreground, opacity)); if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, @@ -94,30 +98,30 @@ static bool button(BongoCatApp *app, struct nk_context *context, return hit(context, bounds, enabled); } -static bool close_button(BongoCatApp *app, struct nk_context *context, +static bool close_button(struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect panel, BongoCatUIPalette p, float opacity, bool enabled) { struct nk_rect bounds = nk_rect(panel.x + panel.w - 52, panel.y + 17, 32, 32); - bool hover = enabled && nk_input_is_mouse_hovering_rect(&context->input, bounds); - nk_fill_rect(canvas, bounds, 8, alpha(hover ? p.hover : p.field, opacity)); - bongo_cat_preferences_icon_draw(app->preferences, canvas, - BONGO_CAT_UI_ICON_CLOSE, nk_rect(bounds.x + 7, - bounds.y + 7, 18, 18), alpha(hover ? p.accent : p.muted, opacity)); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - return hit(context, bounds, enabled); + return bongo_cat_ui_close_button(context, canvas, bounds, + alpha(p.muted, opacity), alpha(p.accent, opacity), enabled); } static void remove_model(BongoCatApp *app) { BongoCatError error = {0}; if (bongo_cat_app_remove_model(app, remove_dialog.model_id, &error) != BONGO_CAT_OK) { - bongo_cat_preferences_notice_show(app, error.message, true); - } else { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model removal failed: %s", error.message); bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.model.hints.deleteFailed", + "Unable to delete this model"), true); + } else { + char message[sizeof(remove_dialog.model_name) + 128]; + snprintf(message, sizeof(message), tr(app, "pages.preference.model.hints.deleteSuccess", - "Deleted successfully"), false); + "Deleted \"%s\""), remove_dialog.model_name); + bongo_cat_preferences_notice_show(app, message, false); } bongo_cat_preferences_remove_dialog_close(app); } @@ -144,10 +148,12 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 21, frame.panel.w - 74, 24), tr(app, "native.delete", "Delete"), app->preferences->ui.label_font, alpha(p.text, opacity)); - text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 75, - frame.panel.w - 40, 44), tr(app, + char prompt[sizeof(remove_dialog.model_name) + 128]; + snprintf(prompt, sizeof(prompt), tr(app, "pages.preference.model.hints.deleteModel", - "Delete this custom model?"), app->preferences->ui.body_font, + "Are you sure you want to delete \"%s\"?"), remove_dialog.model_name); + text(canvas, nk_rect(frame.panel.x + 20, frame.panel.y + 75, + frame.panel.w - 40, 44), prompt, app->preferences->ui.body_font, alpha(p.text, opacity)); const char *cancel = tr(app, "native.cancel", "Cancel"); const char *remove = tr(app, "native.delete", "Delete"); @@ -157,7 +163,7 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, remove_bounds.y, 88, 36); bool enabled = !closing && bongo_cat_preferences_overlay_input_ready( context, &remove_dialog.input_armed); - bool close = close_button(app, context, canvas, frame.panel, + bool close = close_button(context, canvas, frame.panel, p, opacity, enabled); if (button(app, context, canvas, cancel_bounds, cancel, false, p, opacity, enabled)) close = true; diff --git a/src/ui/preferences/preferences_fonts.c b/src/ui/preferences/preferences_fonts.c new file mode 100644 index 00000000..9d550c34 --- /dev/null +++ b/src/ui/preferences/preferences_fonts.c @@ -0,0 +1,99 @@ +#include "preferences_fonts.h" + +#include "preferences_model_cover.h" +#include "preferences_model_glyphs.h" +#include "ui_font.h" +#include "ui_font_atlas.h" +#include "ui_paint.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/memory_policy.h" + +#include +#include + +void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *value, + BongoCatPreferenceFonts *fonts) { + memset(fonts, 0, sizeof(*fonts)); + fonts->body = bongo_cat_ui_system_font(fonts->body_path, + sizeof(fonts->body_path), false); + fonts->body_fallback = bongo_cat_ui_system_font(fonts->body_fallback_path, + sizeof(fonts->body_fallback_path), true); + fonts->body_korean_fallback = bongo_cat_ui_system_korean_font( + fonts->body_korean_fallback_path, + sizeof(fonts->body_korean_fallback_path)); + fonts->heading = bongo_cat_ui_system_heading_font(fonts->heading_path, + sizeof(fonts->heading_path), false); + fonts->heading_fallback = bongo_cat_ui_system_heading_font( + fonts->heading_fallback_path, + sizeof(fonts->heading_fallback_path), true); + fonts->heading_korean_fallback = bongo_cat_ui_system_korean_heading_font( + fonts->heading_korean_fallback_path, + sizeof(fonts->heading_korean_fallback_path)); + if (!fonts->heading) fonts->heading = fonts->body; + if (!fonts->heading_fallback) fonts->heading_fallback = + fonts->body_fallback; + if (!value->app->i18n) return; + size_t range_capacity = sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0]); + size_t range_count = bongo_cat_i18n_glyph_ranges(value->app->i18n, + value->glyph_ranges, range_capacity - 2); + /* Shortcut arrows must be available even before a binding is recorded. */ + if (range_count && range_count + 2 <= range_capacity) { + value->glyph_ranges[range_count - 1] = 0x2190; + value->glyph_ranges[range_count] = 0x2193; + value->glyph_ranges[range_count + 1] = 0; + } + /* Include label glyphs in the initial atlas so page switches need no bake. */ + bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, + sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); + fonts->ranges = value->glyph_ranges; +} + +static bool reload(BongoCatPreferences *value, + const BongoCatPreferenceFonts *fonts, float raster_scale) { + bool reloaded = bongo_cat_ui_font_atlas_reload(&value->ui, fonts->body, + fonts->body_fallback, fonts->body_korean_fallback, fonts->heading, + fonts->heading_fallback, fonts->heading_korean_fallback, fonts->ranges, + raster_scale); + if (reloaded) bongo_cat_memory_policy_ui_loaded(); + return reloaded; +} + +bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { + if (!value || !value->ui_initialized) return false; + if (value->ui.frame_building) { + value->font_reload_pending = true; + value->render_dirty = true; + return true; + } + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); + bool reloaded = reload(value, &fonts, value->ui.raster_scale); + if (reloaded) { + value->font_reload_pending = false; + bongo_cat_preferences_model_glyphs_clear_pending(value); + } + return reloaded; +} + +bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value) { + if (!value || value->pending_raster_scale <= 0.0f) return true; + uint64_t now = SDL_GetTicksNS(); + if (value->raster_retry_ns > now) return false; + float raster_scale = value->pending_raster_scale; + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); + if (!reload(value, &fonts, raster_scale)) { + value->raster_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Preferences font atlas could not be rebuilt for %.2fx", + raster_scale); + return false; + } + value->pending_raster_scale = 0.0f; + value->raster_retry_ns = 0; + bongo_cat_ui_paint_destroy(&value->ui); + bongo_cat_preferences_model_cover_cache_clear(value->app); + bongo_cat_preferences_assets_clear(value); + bongo_cat_preferences_assets_load(value); + return true; +} diff --git a/src/ui/preferences/preferences_fonts.h b/src/ui/preferences/preferences_fonts.h new file mode 100644 index 00000000..d5233550 --- /dev/null +++ b/src/ui/preferences/preferences_fonts.h @@ -0,0 +1,25 @@ +#ifndef BONGO_CAT_PREFERENCES_FONTS_H +#define BONGO_CAT_PREFERENCES_FONTS_H + +#include "preferences_state.h" + +typedef struct BongoCatPreferenceFonts { + char body_path[BONGO_CAT_PATH_CAP]; + char body_fallback_path[BONGO_CAT_PATH_CAP]; + char body_korean_fallback_path[BONGO_CAT_PATH_CAP]; + char heading_path[BONGO_CAT_PATH_CAP]; + char heading_fallback_path[BONGO_CAT_PATH_CAP]; + char heading_korean_fallback_path[BONGO_CAT_PATH_CAP]; + const char *body; + const char *body_fallback; + const char *body_korean_fallback; + const char *heading; + const char *heading_fallback; + const char *heading_korean_fallback; + const nk_rune *ranges; +} BongoCatPreferenceFonts; + +void bongo_cat_preferences_fonts_resolve(BongoCatPreferences *preferences, + BongoCatPreferenceFonts *fonts); + +#endif diff --git a/src/ui/preferences/preferences_form_label.c b/src/ui/preferences/preferences_form_label.c new file mode 100644 index 00000000..6b399877 --- /dev/null +++ b/src/ui/preferences/preferences_form_label.c @@ -0,0 +1,60 @@ +#include "preferences_widgets.h" +#include "preferences_widgets_internal.h" +#include "ui_backend.h" +#include "ui_catime.h" + +static struct nk_context *icon_context; +static BongoCatPrefIcon pending_icon; +static bool icon_pending; + +void bongo_cat_pref_row_icon(struct nk_context *context, + BongoCatPrefIcon icon) { + icon_context = context; + pending_icon = icon; + icon_pending = true; +} + +void bongo_cat_pref_row_icon_clear(struct nk_context *context) { + if (icon_context != context) return; + icon_context = NULL; + icon_pending = false; +} + +void bongo_cat_pref_form_title_sized(struct nk_context *context, + const char *title, float control_width) { + float available = nk_window_get_content_region(context).w; + float left = NK_MAX(220.0f, available - control_width - 8.0f); + nk_layout_row_begin(context, NK_STATIC, 36, 2); + nk_layout_row_push(context, left); + bongo_cat_pref_form_label(context, title); + nk_layout_row_push(context, NK_MAX(control_width, + available - left - 8.0f)); +} + +void bongo_cat_pref_form_label(struct nk_context *context, const char *title) { + if (!icon_pending || icon_context != context) { + nk_style_push_font(context, bongo_cat_ui_label_font(context)); + struct nk_vec2 padding = context->style.text.padding; + context->style.text.padding.x += 5.0f; + nk_label(context, title, NK_TEXT_LEFT); + context->style.text.padding = padding; + nk_style_pop_font(context); + return; + } + struct nk_rect bounds; + enum nk_widget_layout_states state = nk_widget(&bounds, context); + BongoCatPrefIcon icon = pending_icon; + bongo_cat_pref_row_icon_clear(context); + if (state == NK_WIDGET_INVALID) return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + struct nk_rect icon_bounds = nk_rect(bounds.x + 5, + bounds.y + (bounds.h - 18) * .5f, 18, 18); + bongo_cat_pref_icon_draw(canvas, icon_bounds, icon, p.accent); + const struct nk_user_font *font = bongo_cat_ui_label_font(context); + struct nk_rect text = nk_rect(bounds.x + 33, + bounds.y + (bounds.h - font->height) * .5f, + bounds.w - 33, font->height); + nk_draw_text(canvas, text, title, nk_strlen(title), font, + nk_rgba(0, 0, 0, 0), p.text); +} diff --git a/src/ui/preferences/preferences_gl.c b/src/ui/preferences/preferences_gl.c new file mode 100644 index 00000000..cce0c05c --- /dev/null +++ b/src/ui/preferences/preferences_gl.c @@ -0,0 +1,75 @@ +#include "preferences_gl.h" +#include "preferences_state.h" + +#include +#include + +static bool fail(BongoCatPreferences *value, const char *message) { + char detail[256]; + snprintf(detail, sizeof(detail), "%s", + message && message[0] ? message : "unknown OpenGL error"); + SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Preferences dedicated OpenGL context failed: %s " + "main_window=%p settings_window=%p main_context=%p", + detail, (void *)value->app->window, (void *)value->window, + (void *)value->app->gl_context); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + SDL_SetError("%s", detail); + return false; +} + +bool bongo_cat_preferences_gl_create(BongoCatPreferences *value) { + if (!value || !value->app || !value->window || !value->app->window || + !value->app->gl_context) + return false; + if (!SDL_GL_MakeCurrent(value->app->window, value->app->gl_context)) + return fail(value, SDL_GetError()); + if (!SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1)) + return fail(value, SDL_GetError()); + SDL_GLContext context = SDL_GL_CreateContext(value->window); + char creation_error[256] = {0}; + if (!context) snprintf(creation_error, sizeof(creation_error), "%s", + SDL_GetError()); + bool reset = SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + if (!context) + return fail(value, creation_error[0] ? creation_error : + "SDL_GL_CreateContext returned no context"); + if (!reset) { + snprintf(creation_error, sizeof(creation_error), "%s", SDL_GetError()); + SDL_GL_DestroyContext(context); + return fail(value, creation_error); + } + if (!SDL_GL_MakeCurrent(value->window, context)) { + snprintf(creation_error, sizeof(creation_error), "%s", SDL_GetError()); + SDL_GL_DestroyContext(context); + return fail(value, creation_error); + } + value->gl_context = context; + value->owns_gl_context = true; + const GLubyte *vendor = glGetString(GL_VENDOR); + const GLubyte *renderer = glGetString(GL_RENDERER); + const GLubyte *version = glGetString(GL_VERSION); + SDL_Log("[runtime] Preferences OpenGL context: shared=1 dedicated=1 " + "main_window=%p settings_window=%p main_context=%p settings_context=%p " + "vendor=%s renderer=%s version=%s", + (void *)value->app->window, (void *)value->window, + (void *)value->app->gl_context, (void *)value->gl_context, + vendor ? (const char *)vendor : "unknown", + renderer ? (const char *)renderer : "unknown", + version ? (const char *)version : "unknown"); + return true; +} + +void bongo_cat_preferences_gl_destroy(BongoCatPreferences *value) { + if (!value || !value->gl_context) return; + SDL_Log("[runtime] Preferences OpenGL context release: " + "settings_window=%p settings_context=%p current_window=%p " + "current_context=%p", (void *)value->window, + (void *)value->gl_context, (void *)SDL_GL_GetCurrentWindow(), + (void *)SDL_GL_GetCurrentContext()); + if (SDL_GL_GetCurrentContext() == value->gl_context) + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + if (value->owns_gl_context) SDL_GL_DestroyContext(value->gl_context); + value->gl_context = NULL; + value->owns_gl_context = false; +} diff --git a/src/ui/preferences/preferences_gl.h b/src/ui/preferences/preferences_gl.h new file mode 100644 index 00000000..b2c89fb2 --- /dev/null +++ b/src/ui/preferences/preferences_gl.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_GL_H +#define BONGO_CAT_PREFERENCES_GL_H + +#include + +typedef struct BongoCatPreferences BongoCatPreferences; + +bool bongo_cat_preferences_gl_create(BongoCatPreferences *value); +void bongo_cat_preferences_gl_destroy(BongoCatPreferences *value); + +#endif diff --git a/src/ui/preferences/preferences_icons.c b/src/ui/preferences/preferences_icons.c new file mode 100644 index 00000000..8041065c --- /dev/null +++ b/src/ui/preferences/preferences_icons.c @@ -0,0 +1,9 @@ +#include "preferences_icons.h" +#include "preferences_icons_internal.h" + +void bongo_cat_pref_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color) { + if (!canvas || icon < 0 || icon >= BONGO_CAT_PREF_ICON_COUNT) return; + if (bongo_cat_pref_section_icon_draw(canvas, bounds, icon, color)) return; + bongo_cat_pref_row_icon_draw(canvas, bounds, icon, color); +} diff --git a/src/ui/preferences/preferences_icons.h b/src/ui/preferences/preferences_icons.h new file mode 100644 index 00000000..d5068840 --- /dev/null +++ b/src/ui/preferences/preferences_icons.h @@ -0,0 +1,39 @@ +#ifndef BONGO_CAT_PREFERENCES_ICONS_H +#define BONGO_CAT_PREFERENCES_ICONS_H + +#include + +#include "nuklear_config.h" + +typedef enum BongoCatPrefIcon { + BONGO_CAT_PREF_ICON_SECTION_WINDOW, + BONGO_CAT_PREF_ICON_SECTION_MODEL, + BONGO_CAT_PREF_ICON_SECTION_APPLICATION, + BONGO_CAT_PREF_ICON_SECTION_APPEARANCE, + BONGO_CAT_PREF_ICON_MULTIPLE_MODELS, + BONGO_CAT_PREF_ICON_PASS_THROUGH, + BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP, + BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN, + BONGO_CAT_PREF_ICON_SOLID_BACKGROUND, + BONGO_CAT_PREF_ICON_WINDOW_SIZE, + BONGO_CAT_PREF_ICON_WINDOW_CORNERS, + BONGO_CAT_PREF_ICON_OPACITY, + BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION, + BONGO_CAT_PREF_ICON_MIRROR, + BONGO_CAT_PREF_ICON_MOUSE_MIRROR, + BONGO_CAT_PREF_ICON_MOUSE_CENTERED, + BONGO_CAT_PREF_ICON_IGNORE_MOUSE, + BONGO_CAT_PREF_ICON_MAX_FPS, + BONGO_CAT_PREF_ICON_AUTOSTART, + BONGO_CAT_PREF_ICON_LANGUAGE, + BONGO_CAT_PREF_ICON_THEME, + BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY, + BONGO_CAT_PREF_ICON_SHORTCUT_PREFERENCES, + BONGO_CAT_PREF_ICON_HIDE_FADE, + BONGO_CAT_PREF_ICON_COUNT +} BongoCatPrefIcon; + +void bongo_cat_pref_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); + +#endif diff --git a/src/ui/preferences/preferences_icons_internal.h b/src/ui/preferences/preferences_icons_internal.h new file mode 100644 index 00000000..d7e8f51e --- /dev/null +++ b/src/ui/preferences/preferences_icons_internal.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_ICONS_INTERNAL_H +#define BONGO_CAT_PREFERENCES_ICONS_INTERNAL_H + +#include "preferences_icons.h" + +bool bongo_cat_pref_section_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); +bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color); + +#endif diff --git a/src/ui/preferences_import.c b/src/ui/preferences/preferences_import.c similarity index 53% rename from src/ui/preferences_import.c rename to src/ui/preferences/preferences_import.c index 69e5fc59..c60889d8 100644 --- a/src/ui/preferences_import.c +++ b/src/ui/preferences/preferences_import.c @@ -1,50 +1,33 @@ -#include "preferences_internal.h" +#include "preferences_import_internal.h" #include "preferences_state.h" -#include "preferences_model_cover.h" -#include "../runtime/model_import.h" - +#include "model_import.h" +#ifdef _WIN32 +#include "windows_dialog.h" +#endif #include #include #include #include - -typedef struct BongoCatImportJob { - size_t count; - char **paths; - char data_root[BONGO_CAT_PATH_CAP]; - char first_id[BONGO_CAT_ID_CAP]; - size_t imported_count; - BongoCatResult result; - BongoCatError error; - BongoCatImportDialog *dialog; - bool completion; -} BongoCatImportJob; - -enum { - BONGO_CAT_IMPORT_EVENT_CODE = 0x42434e49, - BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4a -}; - -struct BongoCatImportDialog { - SDL_Mutex *mutex; - Uint32 event_type; - SDL_WindowID window_id; - SDL_Thread *worker; - BongoCatImportJob *worker_job; - int references; - bool active; - bool open; - bool busy; -}; - -static void free_job(BongoCatImportJob *job) { +void bongo_cat_preferences_import_job_free(BongoCatImportJob *job) { if (!job) return; for (size_t i = 0; i < job->count; ++i) SDL_free(job->paths[i]); SDL_free(job->paths); SDL_free(job); } -static void release_dialog(BongoCatImportDialog *dialog) { +static void free_import_event(const SDL_Event *event, bool *release_worker) { + if (!event) return; + if (event->user.code == BONGO_CAT_IMPORT_PROGRESS_CODE) { + SDL_free(event->user.data1); + return; + } + BongoCatImportJob *job = (BongoCatImportJob *)event->user.data1; + if (release_worker && event->user.code == BONGO_CAT_IMPORT_COMPLETE_CODE) + *release_worker = true; + bongo_cat_preferences_import_job_free(job); +} +void bongo_cat_preferences_import_dialog_release( + BongoCatImportDialog *dialog) { bool destroy = false; SDL_LockMutex(dialog->mutex); destroy = --dialog->references == 0; @@ -54,22 +37,6 @@ static void release_dialog(BongoCatImportDialog *dialog) { SDL_free(dialog); } } - -BongoCatImportDialog *bongo_cat_preferences_import_create(void) { - BongoCatImportDialog *dialog = SDL_calloc(1, sizeof(*dialog)); - if (!dialog) return NULL; - dialog->event_type = SDL_RegisterEvents(1); - dialog->mutex = SDL_CreateMutex(); - if (dialog->event_type == (Uint32)-1 || !dialog->mutex) { - if (dialog->mutex) SDL_DestroyMutex(dialog->mutex); - SDL_free(dialog); - return NULL; - } - dialog->references = 1; - dialog->active = true; - return dialog; -} - void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { if (!dialog) return; SDL_LockMutex(dialog->mutex); @@ -85,25 +52,16 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog) { while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, dialog->event_type, dialog->event_type) > 0) { if (event.user.data2 != dialog) continue; - BongoCatImportJob *job = (BongoCatImportJob *)event.user.data1; - release_worker = release_worker || (job && job->completion); - free_job(job); + free_import_event(&event, &release_worker); } dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; SDL_UnlockMutex(dialog->mutex); - if (release_worker) release_dialog(dialog); - release_dialog(dialog); -} - -bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog) { - if (!dialog) return false; - SDL_LockMutex(dialog->mutex); - bool open = dialog->open || dialog->busy; - SDL_UnlockMutex(dialog->mutex); - return open; + if (release_worker) bongo_cat_preferences_import_dialog_release(dialog); + bongo_cat_preferences_import_dialog_release(dialog); } - static BongoCatImportJob *copy_job(const char *const *files) { if (!files || !files[0]) return NULL; size_t count = 0; @@ -115,66 +73,22 @@ static BongoCatImportJob *copy_job(const char *const *files) { job->count = count; for (size_t i = 0; i < count; ++i) { job->paths[i] = SDL_strdup(files[i]); - if (!job->paths[i]) { free_job(job); return NULL; } + if (!job->paths[i]) { + bongo_cat_preferences_import_job_free(job); + return NULL; + } } return job; } - static BongoCatImportJob *copy_path(const char *path) { const char *files[] = {path, NULL}; return path && path[0] ? copy_job(files) : NULL; } -static int SDLCALL import_worker(void *userdata) { - BongoCatImportJob *job = userdata; - job->result = BONGO_CAT_ERROR_FORMAT; - for (size_t i = 0; i < job->count; ++i) { - BongoCatImportReceipt receipt; - BongoCatError error = {0}; - BongoCatResult result = bongo_cat_import_install(job->paths[i], - job->data_root, &receipt, &error); - if (result == BONGO_CAT_OK) { - if (!job->first_id[0] && receipt.count) - snprintf(job->first_id, sizeof(job->first_id), "%s", receipt.ids[0]); - job->imported_count += receipt.count; - job->result = BONGO_CAT_OK; - } else if (!job->error.message[0]) { - job->result = result; - job->error = error; - } - } - if (!job->imported_count && !job->error.message[0]) - bongo_cat_error_set(&job->error, job->result, - "No model could be imported"); - SDL_Event event = {0}; - SDL_LockMutex(job->dialog->mutex); - bool notify = job->dialog->active; - if (notify) { - job->completion = true; - event.type = job->dialog->event_type; - event.user.windowID = job->dialog->window_id; - event.user.code = BONGO_CAT_IMPORT_COMPLETE_CODE; - event.user.data1 = job; - event.user.data2 = job->dialog; - notify = SDL_PushEvent(&event); - } - if (!notify) { - job->dialog->worker_job = NULL; - job->dialog->busy = false; - } - SDL_UnlockMutex(job->dialog->mutex); - if (!notify) { - BongoCatImportDialog *dialog = job->dialog; - free_job(job); - release_dialog(dialog); - } - return 0; -} - static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, SDL_WindowID window_id, BongoCatImportJob *job) { - if (!dialog || !app || !job || !app->data_root[0]) return false; - snprintf(job->data_root, sizeof(job->data_root), "%s", app->data_root); + if (!dialog || !app || !job || !app->models_root[0]) return false; + snprintf(job->models_root, sizeof(job->models_root), "%s", app->models_root); job->dialog = dialog; SDL_LockMutex(dialog->mutex); if (!dialog->active || dialog->busy) { @@ -184,17 +98,22 @@ static bool start_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->window_id = window_id; dialog->busy = true; dialog->worker_job = job; + dialog->started_ns = SDL_GetTicksNS(); + dialog->completed = 0; + dialog->total = job->count; ++dialog->references; - dialog->worker = SDL_CreateThread(import_worker, "bongo-cat-model-import", job); + dialog->worker = SDL_CreateThread(bongo_cat_preferences_import_worker, + BONGO_CAT_SLUG "-model-import", job); if (!dialog->worker) { dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; --dialog->references; } SDL_UnlockMutex(dialog->mutex); return dialog->worker != NULL; } - static void SDLCALL import_callback(void *userdata, const char *const *files, int filter) { (void)filter; @@ -209,10 +128,12 @@ static void SDLCALL import_callback(void *userdata, const char *const *files, event.user.data1 = job; event.user.data2 = dialog; if (dialog->active && !dialog->busy && job) pushed = SDL_PushEvent(&event); + if (!dialog->active) + SDL_Log("[runtime] Folder dialog result ignored during shutdown"); if (!pushed) dialog->open = false; SDL_UnlockMutex(dialog->mutex); - if (!pushed) free_job(job); - release_dialog(dialog); + if (!pushed) bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); } bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, @@ -227,7 +148,12 @@ bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, dialog->window_id = SDL_GetWindowID(window); ++dialog->references; SDL_UnlockMutex(dialog->mutex); +#ifdef _WIN32 + bongo_cat_windows_show_open_folder_dialog(import_callback, dialog, window, + NULL, true); +#else SDL_ShowOpenFolderDialog(import_callback, dialog, window, NULL, true); +#endif return true; } @@ -240,26 +166,28 @@ static void complete_job(BongoCatImportDialog *dialog, BongoCatApp *app, dialog->worker = NULL; dialog->worker_job = NULL; dialog->busy = false; + dialog->started_ns = 0; + dialog->completed = dialog->total = 0; } SDL_UnlockMutex(dialog->mutex); if (worker) SDL_WaitThread(worker, NULL); - if (job->result == BONGO_CAT_OK && job->first_id[0]) { - SDL_GL_MakeCurrent(app->window, app->gl_context); - bongo_cat_app_rescan_models(app); - if (!bongo_cat_app_select_model(app, job->first_id)) { - job->result = BONGO_CAT_ERROR_CUBISM; - bongo_cat_error_set(&job->error, job->result, - "Model imported but could not be loaded"); - } else { - const BongoCatModelEntry *entry = bongo_cat_models_find( - &app->models, job->first_id); - bongo_cat_preferences_model_cover_capture(app, entry); - } - } + size_t restored_count = job->restored_count; + if (job->result == BONGO_CAT_OK) + for (size_t i = 0; i < job->package_id_count; ++i) + if (bongo_cat_settings_restore_model_package(&app->settings, + job->package_ids[i])) restored_count++; + bool catalog_changed = job->installed_count > 0 || restored_count > 0; + if (job->result == BONGO_CAT_OK && catalog_changed) + for (size_t i = 0; i < job->package_id_count; ++i) + if (!job->package_refresh_requested[i]) + bongo_cat_app_request_model_package_refresh(app, + job->package_ids[i]); bongo_cat_preferences_import_complete(app, job->result, &job->error, - job->imported_count); - free_job(job); - release_dialog(dialog); + job->resolved_count, job->installed_count + restored_count, + job->succeeded_count, job->failed_count, job->failed_names, + job->failed_name_count); + bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); } bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, @@ -274,10 +202,13 @@ bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, bool accept = dialog->active && owner != NULL; SDL_UnlockMutex(dialog->mutex); if (accept && app && job && !start_job(dialog, app, - event->user.windowID, job)) free_job(job); - else if (!accept) free_job(job); + event->user.windowID, job)) + bongo_cat_preferences_import_job_free(job); + else if (!accept) bongo_cat_preferences_import_job_free(job); return true; } + if (bongo_cat_preferences_import_progress_event(dialog, app, event)) + return true; if (event->user.code == BONGO_CAT_IMPORT_COMPLETE_CODE && app && job) { complete_job(dialog, app, job); return true; @@ -290,6 +221,7 @@ void bongo_cat_preferences_import_path(BongoCatApp *app, if (!app || !app->preferences || !window || !path || !path[0]) return; BongoCatImportJob *job = copy_path(path); if (!job || !start_job(app->preferences->import_dialog, app, - SDL_GetWindowID(window), job)) free_job(job); + SDL_GetWindowID(window), job)) + bongo_cat_preferences_import_job_free(job); else app->preferences->render_dirty = true; } diff --git a/src/ui/preferences/preferences_import_dialog.c b/src/ui/preferences/preferences_import_dialog.c new file mode 100644 index 00000000..e2538d8a --- /dev/null +++ b/src/ui/preferences/preferences_import_dialog.c @@ -0,0 +1,36 @@ +#include "preferences_import_internal.h" + +BongoCatImportDialog *bongo_cat_preferences_import_create(void) { + BongoCatImportDialog *dialog = SDL_calloc(1, sizeof(*dialog)); + if (!dialog) return NULL; + dialog->event_type = SDL_RegisterEvents(1); + dialog->mutex = SDL_CreateMutex(); + if (dialog->event_type == (Uint32)-1 || !dialog->mutex) { + if (dialog->mutex) SDL_DestroyMutex(dialog->mutex); + SDL_free(dialog); + return NULL; + } + dialog->references = 1; + dialog->active = true; + return dialog; +} + +bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog) { + if (!dialog) return false; + SDL_LockMutex(dialog->mutex); + bool open = dialog->open || dialog->busy; + SDL_UnlockMutex(dialog->mutex); + return open; +} + +bool bongo_cat_preferences_import_status(const BongoCatImportDialog *dialog, + uint64_t *started_ns, size_t *completed, size_t *total) { + if (!dialog) return false; + SDL_LockMutex(dialog->mutex); + bool busy = dialog->busy; + if (started_ns) *started_ns = dialog->started_ns; + if (completed) *completed = dialog->completed; + if (total) *total = dialog->total; + SDL_UnlockMutex(dialog->mutex); + return busy; +} diff --git a/src/ui/preferences/preferences_import_internal.h b/src/ui/preferences/preferences_import_internal.h new file mode 100644 index 00000000..c2e4f242 --- /dev/null +++ b/src/ui/preferences/preferences_import_internal.h @@ -0,0 +1,75 @@ +#ifndef BONGO_CAT_PREFERENCES_IMPORT_INTERNAL_H +#define BONGO_CAT_PREFERENCES_IMPORT_INTERNAL_H + +#include "preferences_internal.h" +#include "model_import.h" + +typedef struct BongoCatImportJob { + size_t count; + char **paths; + char models_root[BONGO_CAT_PATH_CAP]; + char package_ids[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; + bool package_refresh_requested[BONGO_CAT_MODEL_CAP]; + size_t package_id_count; + size_t resolved_count; + size_t installed_count; + size_t restored_count; + size_t succeeded_count; + size_t failed_count; + size_t failed_name_count; + char failed_names[BONGO_CAT_IMPORT_FAILURE_NAME_CAP][BONGO_CAT_ID_CAP]; + BongoCatResult result; + BongoCatError error; + BongoCatImportDialog *dialog; +} BongoCatImportJob; + +typedef struct BongoCatImportProgressContext { + BongoCatImportJob *job; + size_t completed; +} BongoCatImportProgressContext; + +typedef struct BongoCatImportProgress { + BongoCatImportJob *job; + char package_id[BONGO_CAT_ID_CAP]; + size_t package_index; + size_t installed_count; +} BongoCatImportProgress; + +enum { + BONGO_CAT_IMPORT_EVENT_CODE = 0x42434e49, + BONGO_CAT_IMPORT_PROGRESS_CODE = 0x42434e4a, + BONGO_CAT_IMPORT_COMPLETE_CODE = 0x42434e4b +}; + +struct BongoCatImportDialog { + SDL_Mutex *mutex; + Uint32 event_type; + SDL_WindowID window_id; + SDL_Thread *worker; + BongoCatImportJob *worker_job; + uint64_t started_ns; + size_t completed; + size_t total; + int references; + bool active; + bool open; + bool busy; +}; + +void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, + const BongoCatImportReceipt *receipt, size_t package_index, + size_t completed); +void bongo_cat_preferences_import_receive(void *userdata, + const BongoCatImportReceipt *receipt); +void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, + const char *source); +void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, + const BongoCatImportBatchStats *stats); +bool bongo_cat_preferences_import_progress_event( + BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); +void bongo_cat_preferences_import_job_free(BongoCatImportJob *job); +void bongo_cat_preferences_import_dialog_release( + BongoCatImportDialog *dialog); +int SDLCALL bongo_cat_preferences_import_worker(void *userdata); + +#endif diff --git a/src/ui/preferences/preferences_import_progress.c b/src/ui/preferences/preferences_import_progress.c new file mode 100644 index 00000000..a11ebdb1 --- /dev/null +++ b/src/ui/preferences/preferences_import_progress.c @@ -0,0 +1,113 @@ +#include "preferences_import_internal.h" +#include "preferences_model_glyphs.h" +#include "model_import.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void bongo_cat_preferences_import_record_failure(BongoCatImportJob *job, + const char *source) { + if (!job || job->failed_name_count >= + BONGO_CAT_IMPORT_FAILURE_NAME_CAP) return; + const char *name = bongo_cat_path_name(source); + if (!name || !name[0]) return; + SDL_utf8strlcpy(job->failed_names[job->failed_name_count++], name, + BONGO_CAT_ID_CAP); +} + +void bongo_cat_preferences_import_merge_failures(BongoCatImportJob *job, + const BongoCatImportBatchStats *stats) { + if (!job || !stats) return; + for (size_t i = 0; i < stats->failure_name_count; ++i) + bongo_cat_preferences_import_record_failure(job, + stats->failure_names[i]); +} + +static size_t remember_package(BongoCatImportJob *job, const char *id) { + if (!job || !id || !id[0]) return BONGO_CAT_MODEL_CAP; + for (size_t i = 0; i < job->package_id_count; ++i) + if (!strcmp(job->package_ids[i], id)) return i; + if (job->package_id_count >= BONGO_CAT_MODEL_CAP) + return BONGO_CAT_MODEL_CAP; + size_t index = job->package_id_count++; + snprintf(job->package_ids[index], BONGO_CAT_ID_CAP, "%s", id); + return index; +} + +void bongo_cat_preferences_import_receive(void *userdata, + const BongoCatImportReceipt *receipt) { + BongoCatImportProgressContext *progress = userdata; + BongoCatImportJob *job = progress->job; + size_t package_index = receipt->count + ? remember_package(job, receipt->ids[0]) : BONGO_CAT_MODEL_CAP; + job->resolved_count += receipt->count; + job->installed_count += receipt->installed_count; + job->result = BONGO_CAT_OK; + bongo_cat_preferences_import_report_progress(job, receipt, package_index, + ++progress->completed); +} + +void bongo_cat_preferences_import_report_progress(BongoCatImportJob *job, + const BongoCatImportReceipt *receipt, size_t package_index, + size_t completed) { + BongoCatImportProgress *progress = receipt && receipt->count + ? SDL_calloc(1, sizeof(*progress)) : NULL; + if (progress) { + progress->job = job; + progress->package_index = package_index; + progress->installed_count = receipt->installed_count; + bongo_cat_import_package_base_id(progress->package_id, + sizeof(progress->package_id), receipt->ids[0]); + } + SDL_Event event = {0}; + SDL_LockMutex(job->dialog->mutex); + bool current = job->dialog->active && + job->dialog->worker_job == job; + if (current) { + job->dialog->completed = completed; + if (job->dialog->total < completed) + job->dialog->total = completed; + } + bool pushed = false; + if (current && progress) { + event.type = job->dialog->event_type; + event.user.windowID = job->dialog->window_id; + event.user.code = BONGO_CAT_IMPORT_PROGRESS_CODE; + event.user.data1 = progress; + event.user.data2 = job->dialog; + pushed = SDL_PushEvent(&event); + } + SDL_UnlockMutex(job->dialog->mutex); + if (!pushed) SDL_free(progress); +} + +bool bongo_cat_preferences_import_progress_event( + BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event) { + if (!dialog || !event || + event->user.code != BONGO_CAT_IMPORT_PROGRESS_CODE) return false; + BongoCatImportProgress *progress = + (BongoCatImportProgress *)event->user.data1; + SDL_LockMutex(dialog->mutex); + bool current = progress && dialog->active && + dialog->worker_job == progress->job; + SDL_UnlockMutex(dialog->mutex); + if (current && app) { + bool restored = bongo_cat_settings_restore_model_package( + &app->settings, progress->package_id); + if (restored) progress->job->restored_count++; + if (progress->installed_count || restored) + bongo_cat_preferences_model_glyphs_note(app->preferences, + progress->package_id); + if (progress->installed_count || restored) { + if (progress->package_index < BONGO_CAT_MODEL_CAP) + progress->job->package_refresh_requested[ + progress->package_index] = true; + bongo_cat_app_request_model_package_refresh(app, + progress->package_id); + } + } + SDL_free(progress); + return true; +} diff --git a/src/ui/preferences/preferences_import_worker.c b/src/ui/preferences/preferences_import_worker.c new file mode 100644 index 00000000..b954d538 --- /dev/null +++ b/src/ui/preferences/preferences_import_worker.c @@ -0,0 +1,92 @@ +#include "preferences_import_internal.h" + +#include + +int SDLCALL bongo_cat_preferences_import_worker(void *userdata) { + BongoCatImportJob *job = userdata; + if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_LOW)) { + SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, + "Cannot lower model import thread priority: %s", SDL_GetError()); + SDL_ClearError(); + } + job->result = BONGO_CAT_ERROR_FORMAT; + SDL_Log("[runtime] Model import started: sources=%llu", + (unsigned long long)job->count); + BongoCatImportSession *session = bongo_cat_import_session_create( + job->models_root, &job->error); + if (!session) { + job->result = job->error.code ? job->error.code : BONGO_CAT_ERROR_IO; + if (job->error.code == BONGO_CAT_OK) job->error.code = job->result; + job->failed_count = job->count; + for (size_t i = 0; i < job->count; ++i) + bongo_cat_preferences_import_record_failure(job, job->paths[i]); + } + BongoCatImportProgressContext progress = {job, 0}; + for (size_t i = 0; session && i < job->count; ++i) { + size_t before = progress.completed; + BongoCatImportBatchStats stats = {0}; + BongoCatError error = {0}; + SDL_Log("[runtime] Model import source started: index=%llu/%llu path=%s", + (unsigned long long)(i + 1), (unsigned long long)job->count, + job->paths[i]); + BongoCatResult result = bongo_cat_import_session_install_progressive( + session, job->paths[i], bongo_cat_preferences_import_receive, + &progress, &stats, &error); + SDL_Log("[runtime] Model import source completed: index=%llu/%llu " + "result=%d succeeded=%llu failed=%llu", + (unsigned long long)(i + 1), (unsigned long long)job->count, + (int)result, (unsigned long long)stats.succeeded_count, + (unsigned long long)stats.failed_count); + job->succeeded_count += stats.succeeded_count; + job->failed_count += stats.failed_count; + bongo_cat_preferences_import_merge_failures(job, &stats); + if (result != BONGO_CAT_OK && job->error.code == BONGO_CAT_OK) { + job->result = result; + job->error = error; + if (job->error.code == BONGO_CAT_OK) job->error.code = result; + } + if (stats.failed_count) { + progress.completed += stats.failed_count; + bongo_cat_preferences_import_report_progress(job, NULL, + BONGO_CAT_MODEL_CAP, progress.completed); + } else if (progress.completed == before) { + bongo_cat_preferences_import_report_progress(job, NULL, + BONGO_CAT_MODEL_CAP, ++progress.completed); + } + } + bongo_cat_import_session_destroy(session); + if (job->succeeded_count) job->result = BONGO_CAT_OK; + if (!job->succeeded_count && job->error.code == BONGO_CAT_OK) + bongo_cat_error_set(&job->error, BONGO_CAT_ERROR_FORMAT, + "No model could be imported"); + SDL_Log("[runtime] Model import finished: resolved=%llu installed=%llu " + "succeeded=%llu failed=%llu result=%d", + (unsigned long long)job->resolved_count, + (unsigned long long)job->installed_count, + (unsigned long long)job->succeeded_count, + (unsigned long long)job->failed_count, (int)job->result); + SDL_Event event = {0}; + SDL_LockMutex(job->dialog->mutex); + bool notify = job->dialog->active; + if (notify) { + event.type = job->dialog->event_type; + event.user.windowID = job->dialog->window_id; + event.user.code = BONGO_CAT_IMPORT_COMPLETE_CODE; + event.user.data1 = job; + event.user.data2 = job->dialog; + notify = SDL_PushEvent(&event); + } + if (!notify) { + job->dialog->worker_job = NULL; + job->dialog->busy = false; + job->dialog->started_ns = 0; + job->dialog->completed = job->dialog->total = 0; + } + SDL_UnlockMutex(job->dialog->mutex); + if (!notify) { + BongoCatImportDialog *dialog = job->dialog; + bongo_cat_preferences_import_job_free(job); + bongo_cat_preferences_import_dialog_release(dialog); + } + return 0; +} diff --git a/src/ui/preferences_internal.h b/src/ui/preferences/preferences_internal.h similarity index 73% rename from src/ui/preferences_internal.h rename to src/ui/preferences/preferences_internal.h index 5ea1adce..77bdc5b9 100644 --- a/src/ui/preferences_internal.h +++ b/src/ui/preferences/preferences_internal.h @@ -6,8 +6,8 @@ #include "ui_catime.h" #include -void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context); -void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context); +void bongo_cat_preferences_page_settings(BongoCatApp *app, + struct nk_context *context); void bongo_cat_preferences_page_model(BongoCatPreferences *value, struct nk_context *context); bool bongo_cat_preferences_behavior_dialog_active( @@ -25,6 +25,16 @@ void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, struct nk_rect bounds); void bongo_cat_preferences_behavior_rename_finish( BongoCatPreferences *value, bool save); +bool bongo_cat_preferences_model_rename_event( + BongoCatPreferences *value, const SDL_Event *event); +void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, + const BongoCatModelEntry *entry, struct nk_rect bounds); +void bongo_cat_preferences_model_rename_finish( + BongoCatPreferences *value, bool save); +bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + const BongoCatModelEntry *entry, struct nk_rect bounds, + BongoCatUIPalette palette); void bongo_cat_preferences_behavior_row_draw(BongoCatPreferences *value, struct nk_context *context, struct nk_command_buffer *canvas, struct nk_rect row, BongoCatBehaviorEntry *entry, BongoCatUIPalette palette, @@ -44,7 +54,9 @@ void bongo_cat_preferences_shortcut_smoke(BongoCatPreferences *value); void bongo_cat_preferences_import_path(BongoCatApp *app, SDL_Window *window, const char *path); void bongo_cat_preferences_import_complete(BongoCatApp *app, - BongoCatResult result, const BongoCatError *error, size_t imported_count); + BongoCatResult result, const BongoCatError *error, size_t resolved_count, + size_t installed_count, size_t succeeded_count, size_t failed_count, + const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count); void bongo_cat_preferences_model_cache_clear(BongoCatApp *app); bool bongo_cat_preferences_remove_dialog_active(const BongoCatApp *app); void bongo_cat_preferences_remove_dialog_open(BongoCatApp *app, const char *id); @@ -52,6 +64,8 @@ void bongo_cat_preferences_remove_dialog_draw(BongoCatApp *app, struct nk_context *context); void bongo_cat_preferences_remove_dialog_clear(const BongoCatApp *app); void bongo_cat_preferences_remove_dialog_close(BongoCatApp *app); +bool bongo_cat_preferences_chrome_drag_allowed( + const BongoCatPreferences *value); typedef struct BongoCatImportDialog BongoCatImportDialog; BongoCatImportDialog *bongo_cat_preferences_import_create(void); @@ -59,6 +73,8 @@ void bongo_cat_preferences_import_destroy(BongoCatImportDialog *dialog); bool bongo_cat_preferences_import_open(BongoCatImportDialog *dialog, SDL_Window *window); bool bongo_cat_preferences_import_is_open(const BongoCatImportDialog *dialog); +bool bongo_cat_preferences_import_status(const BongoCatImportDialog *dialog, + uint64_t *started_ns, size_t *completed, size_t *total); bool bongo_cat_preferences_import_event(BongoCatImportDialog *dialog, BongoCatApp *app, const SDL_Event *event); diff --git a/src/ui/preferences_language.c b/src/ui/preferences/preferences_language.c similarity index 71% rename from src/ui/preferences_language.c rename to src/ui/preferences/preferences_language.c index a6e29074..d2bcb610 100644 --- a/src/ui/preferences_language.c +++ b/src/ui/preferences/preferences_language.c @@ -5,21 +5,21 @@ #include bool bongo_cat_preferences_reload_language(BongoCatPreferences *value) { - if (!value || value->font_language == value->app->config.app.language) + if (!value || value->font_language == value->app->settings.app.language) return false; BongoCatLanguage previous = value->font_language; - BongoCatLanguage requested = value->app->config.app.language; + BongoCatLanguage requested = value->app->settings.app.language; BongoCatError error = {0}; if (!value->app->i18n || bongo_cat_i18n_reload(value->app->i18n, requested, &error) != BONGO_CAT_OK) { - value->app->config.app.language = previous; + value->app->settings.app.language = previous; SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); return false; } value->font_language = requested; if (!bongo_cat_preferences_reload_fonts(value)) { BongoCatError restore = {0}; - value->app->config.app.language = previous; + value->app->settings.app.language = previous; value->font_language = previous; bongo_cat_i18n_reload(value->app->i18n, previous, &restore); SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, @@ -27,6 +27,9 @@ bool bongo_cat_preferences_reload_language(BongoCatPreferences *value) { return false; } bongo_cat_ui_paint_destroy(&value->ui); + if (value->window) SDL_SetWindowTitle(value->window, + bongo_cat_i18n_get(value->app->i18n, + "native.preferencesWindowTitle", "BongoCat - Settings")); if (value->app->tray) bongo_cat_tray_sync(value->app->tray); value->render_dirty = true; return true; diff --git a/src/ui/preferences/preferences_lifecycle.c b/src/ui/preferences/preferences_lifecycle.c new file mode 100644 index 00000000..4ca433b0 --- /dev/null +++ b/src/ui/preferences/preferences_lifecycle.c @@ -0,0 +1,141 @@ +#include "preferences_controls.h" +#include "preferences_gl.h" +#include "preferences_model_cover.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "bongo_cat/memory.h" +#include "bongo_cat/platform.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include + +static void release_window(BongoCatPreferences *value) { + if (!value || !value->window) return; + bongo_cat_preferences_live_resize_uninstall(value); + bool context_ready = !value->gl_context || + SDL_GL_MakeCurrent(value->window, value->gl_context); + if (!context_ready) SDL_LogError(SDL_LOG_CATEGORY_VIDEO, + "Preferences GL cleanup skipped because its context could not be " + "activated: %s", SDL_GetError()); + if (value->ui_initialized && value->input_active) + bongo_cat_preferences_input_end(value); + SDL_StopTextInput(value->window); + if (context_ready) bongo_cat_preferences_model_cache_clear(value->app); + else bongo_cat_preferences_model_cache_abandon(value->app); + if (value->ui_initialized) { + bongo_cat_pref_controls_reset(&value->ui.context); + bongo_cat_ui_animations_reset(&value->ui.context); + } + bongo_cat_about_clear(value, context_ready); + if (context_ready) bongo_cat_preferences_assets_clear(value); + else bongo_cat_preferences_assets_abandon(value); + if (value->ui_initialized) { + bongo_cat_ui_cursor_destroy(&value->ui); + if (context_ready) bongo_cat_ui_destroy(&value->ui); + else bongo_cat_ui_abandon(&value->ui); + } + if (value->chrome_dragging) SDL_CaptureMouse(false); + bongo_cat_preferences_gl_destroy(value); + SDL_DestroyWindow(value->window); + value->window = NULL; + value->visible = false; + value->transparent_window = false; + value->ui_initialized = false; + value->font_reload_pending = false; + value->font_reload_defer_once = false; + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; + value->smoke_behavior_open_pending = false; + value->native_drag = false; + value->chrome_dragging = false; + value->pending_raster_scale = 0.0f; + value->raster_retry_ns = 0; + value->render_retry_ns = 0; + value->shown_ns = 0; + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + SDL_GL_SetSwapInterval(1); +} + +void bongo_cat_preferences_show(BongoCatPreferences *value) { + if (!value) return; +#ifdef __APPLE__ + bongo_cat_preferences_input_monitoring_refresh(value); +#endif + if (value->visible) { + bongo_cat_platform_raise_window(value->window); + bongo_cat_app_request_nearby_model_refresh(value->app); + return; + } + uint64_t requested_ns = SDL_GetTicksNS(); + bool opening = !value->window; + if (opening && !bongo_cat_preferences_open_window(value)) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Preferences failed: %s", SDL_GetError()); + release_window(value); + return; + } + if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); + value->shown_ns = requested_ns; + value->visible = true; + bongo_cat_about_refresh(value); + if (!opening) { + SDL_StartTextInput(value->window); + bongo_cat_preferences_live_resize_install(value); + } + value->render_dirty = true; + bongo_cat_preferences_render(value); + bongo_cat_platform_raise_window(value->window); + bongo_cat_app_request_nearby_model_refresh(value->app); +} + +void bongo_cat_preferences_close(BongoCatPreferences *value) { + if (!value || !value->window || !value->visible) return; + bongo_cat_preferences_live_resize_uninstall(value); + if (bongo_cat_preferences_behavior_dialog_active(value)) + bongo_cat_preferences_behavior_dialog_close(value); + bongo_cat_preferences_model_rename_finish(value, true); + bongo_cat_preferences_shortcut_cancel(value); + value->behavior_dialog = false; + value->behavior_dialog_input_armed = false; + value->behavior_dialog_opened_ns = 0; + value->behavior_dialog_closing_ns = 0; + value->import_requested = false; + value->import_drop_active = false; + if (value->input_active) bongo_cat_preferences_input_end(value); + if (value->ui_initialized) bongo_cat_ui_input_reset(&value->ui); + SDL_StopTextInput(value->window); + if (value->chrome_dragging) SDL_CaptureMouse(false); + value->chrome_dragging = false; + value->visible = false; + SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); + SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); + bool about_gl_ready = SDL_GL_MakeCurrent(value->window, value->gl_context); + bongo_cat_about_clear(value, about_gl_ready); + SDL_GL_MakeCurrent(previous_window, previous_context); + SDL_HideWindow(value->window); + bongo_cat_preferences_release_idle_window(value); + bongo_cat_config_store_flush(value->app); +} + +void bongo_cat_preferences_release_idle_window(BongoCatPreferences *value) { + if (!value || !value->window || value->visible) return; + /* A folder dialog still needs its owner; import completion can queue a + catalog refresh. Keep the window until both have finished. */ + if (bongo_cat_preferences_import_is_open(value->import_dialog) || + bongo_cat_app_model_refresh_busy(value->app) || value->model_loading || + value->model_selection_pending) return; + release_window(value); +} + +void bongo_cat_preferences_destroy(BongoCatPreferences *value) { + if (!value) return; + bongo_cat_preferences_import_destroy(value->import_dialog); + value->import_dialog = NULL; + bongo_cat_preferences_close(value); + release_window(value); + bongo_cat_about_clear(value, false); + bongo_cat_about_shutdown(value); + free(value); +} diff --git a/src/ui/preferences_live_resize.c b/src/ui/preferences/preferences_live_resize.c similarity index 100% rename from src/ui/preferences_live_resize.c rename to src/ui/preferences/preferences_live_resize.c diff --git a/src/ui/preferences/preferences_model.c b/src/ui/preferences/preferences_model.c new file mode 100644 index 00000000..50ecd479 --- /dev/null +++ b/src/ui/preferences/preferences_model.c @@ -0,0 +1,219 @@ +#include "preferences_state.h" +#include "preferences_model_card.h" +#include "preferences_model_cover.h" +#include "preferences_notice.h" +#include "preferences_widgets.h" +#include "model_import.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" + +#include +#include + +#define MODEL_CARD_HEIGHT 214 +#define MODEL_LOAD_RENDER_INTERVAL_NS 33333333ull + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static float visual_wait_progress(uint64_t elapsed) { + if (elapsed <= BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS) + return .8f * (float)((double)elapsed / + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS); + uint64_t slow_elapsed = elapsed - BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + uint64_t slow_duration = BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS - + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + float slow = slow_duration ? (float)((double)slow_elapsed / slow_duration) : 1.0f; + return .8f + (BONGO_CAT_MODEL_LOAD_VISUAL_WAIT_CAP - .8f) * + NK_CLAMP(0.0f, slow, 1.0f); +} + +void bongo_cat_preferences_model_visual_begin(BongoCatPreferences *value, + const char *model_id) { + if (!value || !model_id || !model_id[0]) return; + value->model_load_visual_active = true; + value->model_load_visual_started_ns = SDL_GetTicksNS(); + value->model_load_visual_completion_ns = 0; + value->model_load_progress = 0.0f; + snprintf(value->model_load_visual_id, + sizeof(value->model_load_visual_id), "%s", model_id); +} + +void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, + float progress) { + if (!value || !value->model_loading) return; + (void)progress; + uint64_t now = SDL_GetTicksNS(); + uint64_t elapsed = now - value->model_load_visual_started_ns; + value->model_load_progress = visual_wait_progress(elapsed); + value->render_dirty = true; + bool due = !value->model_load_render_ns || + now - value->model_load_render_ns >= MODEL_LOAD_RENDER_INTERVAL_NS; + if (bongo_cat_preferences_visible(value) && due) { + value->model_load_render_progress = progress; + value->model_load_render_ns = now; + bongo_cat_preferences_render(value); + } +} + +float bongo_cat_preferences_model_visual_progress(BongoCatPreferences *value, + const char *model_id) { + if (!value || !model_id || !value->model_load_visual_active || + strcmp(value->model_load_visual_id, model_id)) return 0.0f; + uint64_t now = SDL_GetTicksNS(); + uint64_t elapsed = now - value->model_load_visual_started_ns; + if (!value->model_loading && !value->model_selection_pending) { + if (!value->model_load_visual_completion_ns) + value->model_load_visual_completion_ns = now; + uint64_t completed = now - value->model_load_visual_completion_ns; + float amount = NK_CLAMP(0.0f, (float)((double)completed / + BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS), 1.0f); + float start = visual_wait_progress(elapsed); + value->model_load_progress = start + (1.0f - start) * amount; + if (amount >= 1.0f) { + value->model_load_visual_active = false; + value->model_load_visual_completion_ns = 0; + value->model_load_progress = 1.0f; + } + value->render_dirty = true; + return value->model_load_progress; + } + value->model_load_progress = visual_wait_progress(elapsed); + value->render_dirty = true; + return value->model_load_progress; +} + +static void finish_model_load_progress(BongoCatPreferences *value) { + if (!value) return; + if (value->model_load_visual_active) { + uint64_t now = SDL_GetTicksNS(); + value->model_load_visual_completion_ns = now; + value->model_load_progress = visual_wait_progress(now - + value->model_load_visual_started_ns); + } else value->model_load_progress = 1.0f; + value->render_dirty = true; +} + +void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value) { + if (!value || !value->model_selection_pending || value->model_loading) + return; + char id[BONGO_CAT_ID_CAP]; + snprintf(id, sizeof(id), "%s", value->pending_model_id); + bool multiple = value->pending_model_multiple; + bool active = value->pending_model_active; + value->pending_model_id[0] = '\0'; + value->model_selection_pending = false; + value->pending_model_multiple = false; + value->model_loading = true; + value->model_load_progress = 0.0f; + value->model_load_render_progress = 0.0f; + value->model_load_render_ns = 0; + if (!value->model_load_visual_active || strcmp(value->model_load_visual_id, id)) + bongo_cat_preferences_model_visual_begin(value, id); + snprintf(value->loading_model_id, sizeof(value->loading_model_id), "%s", id); + BongoCatError error = {0}; + bool selected = multiple ? bongo_cat_app_set_model_active( + value->app, id, active, &error) : + bongo_cat_app_select_model_with_error(value->app, id, &error); + if (selected) finish_model_load_progress(value); + else { + value->model_load_progress = 0.0f; + value->model_load_visual_active = false; + value->model_load_visual_id[0] = '\0'; + } + value->model_loading = false; + value->loading_model_id[0] = '\0'; + if (!selected) SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model selection failed: id=%s error=%s", id, + error.message[0] ? error.message : "Unable to display this model"); + bongo_cat_preferences_invalidate(value); + /* Render on the next input frame. A nested render here would replay the + card click that queued this selection and immediately toggle it back. */ +} + +static void smoke_model_behavior(BongoCatPreferences *value) { + BongoCatApp *app = value->app; + if (app->smoke_preference_model_select) { + for (size_t i = 0; i < app->models.count; ++i) { + const BongoCatModelEntry *entry = &app->models.entries[i]; + if (entry->preset || !strcmp(entry->id, + app->session.active_model_id)) continue; + app->smoke_preference_model_select = false; + value->smoke_behavior_open_pending = true; + SDL_Log("Preferences smoke selecting model %s", entry->id); + bongo_cat_preferences_model_select(value, entry); + return; + } + } + if (value->smoke_behavior_open_pending && !value->font_reload_pending && + !value->model_selection_pending && !value->model_loading) { + value->smoke_behavior_open_pending = false; + bongo_cat_preferences_behavior_dialog_open(value); + } +} + +static size_t model_count(const BongoCatPreferences *value, bool managed) { + size_t count = 0; + for (size_t i = 0; i < value->app->models.count; ++i) + if (value->app->models.entries[i].managed == managed) count++; + return count; +} + +static int preset_model_order(const BongoCatModelEntry *entry) { + if (!entry || !entry->preset) return 3; + if (!strcmp(entry->id, "standard")) return 0; + if (!strcmp(entry->id, "keyboard")) return 1; + if (!strcmp(entry->id, "gamepad")) return 2; + return 3; +} + +static void draw_models(BongoCatPreferences *value, + struct nk_context *context, bool managed, bool storage_busy) { + for (int order = 0; order <= 3; ++order) { + for (size_t i = 0; i < value->app->models.count; ++i) { + const BongoCatModelEntry *entry = &value->app->models.entries[i]; + if (entry->managed != managed || preset_model_order(entry) != order) + continue; + bongo_cat_preferences_model_card(value, context, entry, + storage_busy); + } + } +} + +void bongo_cat_preferences_page_model(BongoCatPreferences *value, + struct nk_context *context) { + BongoCatApp *app = value->app; + smoke_model_behavior(value); + bongo_cat_preferences_model_covers_begin(app); + bool multiple = app->settings.model.multiple_pets; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MULTIPLE_MODELS); + if (bongo_cat_pref_toggle(context, "multiple-pets", tr(app, + "native.multiplePets", + "Display multiple"), "", &multiple)) + bongo_cat_app_set_multiple_pets(app, multiple); + if (bongo_cat_preferences_model_section(value, context) && + !SDL_OpenURL("https://bongocat.pet/models")) + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "Cannot open model library: %s", SDL_GetError()); + float width = nk_window_get_content_region(context).w; + int columns = width >= 780 ? 4 : width >= 620 ? 3 : width >= 400 ? 2 : 1; + struct nk_vec2 old_spacing = context->style.window.spacing; + context->style.window.spacing = nk_vec2(14, 17); + nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); + if (bongo_cat_preferences_model_import_card(value, context)) + bongo_cat_preferences_request_model_import(app->preferences); + bool storage_busy = bongo_cat_preferences_import_status( + value->import_dialog, NULL, NULL, NULL) || + bongo_cat_app_model_refresh_busy(app); + draw_models(value, context, false, storage_busy); + if (model_count(value, true)) { + bongo_cat_pref_section(context, + tr(app, "pages.preference.model.nearbyTitle", "Nearby models")); + nk_layout_row_dynamic(context, MODEL_CARD_HEIGHT, columns); + draw_models(value, context, true, storage_busy); + } + context->style.window.spacing = old_spacing; + bongo_cat_preferences_model_covers_prune(app); +} diff --git a/src/ui/preferences/preferences_model_card.c b/src/ui/preferences/preferences_model_card.c new file mode 100644 index 00000000..ad8d1f02 --- /dev/null +++ b/src/ui/preferences/preferences_model_card.c @@ -0,0 +1,273 @@ +#include "preferences_model_card.h" +#include "preferences_model_card_paint.h" +#include "preferences_model_cover.h" +#include "preferences_notice.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_icons.h" +#include "ui_paint.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include "bongo_cat/platform.h" + +#include +#include +#include + +#define MODEL_SELECTION_FADE_MS 240.0f + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static void text(struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const char *value, struct nk_color color, + const struct nk_user_font *font) { + if (!font) font = context->style.font; + nk_draw_text(canvas, bounds, value, nk_strlen(value), font, + nk_rgba(0, 0, 0, 0), color); +} + +bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, + struct nk_context *context) { + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; + bounds.y += 5.0f; bounds.h += 1.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool pointer_hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + bool hover = pointer_hover || value->import_drop_active; + float lift = bongo_cat_ui_animate_eased(context, "model-import-hover", + hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_STANDARD); + bounds.y -= 2.0f * lift; + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, + 0, 8, 24, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); + nk_fill_rect(canvas, bounds, 14, hover ? p.hover_pink : p.hover); + bongo_cat_ui_paint_dashed_rounded(context, bounds, 14, 2, 5, 5, + hover ? p.pink : p.accent); + uint64_t import_started = 0; + bool importing = bongo_cat_preferences_import_status(value->import_dialog, + &import_started, NULL, NULL); + if (importing && import_started) { + const double lap_ns = 5000000000.0; + double elapsed = (double)(SDL_GetTicksNS() - import_started); + float progress = (float)fmod(elapsed / lap_ns, 1.0); + struct nk_rect outline = + bongo_cat_preferences_model_card_outline_bounds(bounds, 2.0f); + bongo_cat_preferences_model_card_draw_progress(canvas, outline, + 13.0f, 2.0f, progress, p.pink); + value->render_dirty = true; + } + float cx = bounds.x + bounds.w * .5f, cy = bounds.y + 91; + bongo_cat_preferences_icon_draw(value, canvas, + BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); + const char *label = tr(value->app, + "pages.preference.model.hints.clickOrDragToImport", + "Click or drag a model folder"); + float width = value->ui.caption_font->width(value->ui.caption_font->userdata, + value->ui.caption_font->height, label, nk_strlen(label)); + text(context, canvas, nk_rect(cx - width * .5f, cy + 25, + NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, + value->ui.caption_font); + const char *support = tr(value->app, + "pages.preference.model.hints.supportedImportFormats", + "Supports Mver and Tauri formats"); + width = value->ui.caption_font->width(value->ui.caption_font->userdata, + value->ui.caption_font->height, support, nk_strlen(support)); + text(context, canvas, nk_rect(cx - width * .5f, cy + 47, + NK_MIN(width + 1, bounds.w - 20), 24), support, p.pink, + value->ui.caption_font); + if (pointer_hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + return pointer_hover && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds); +} + +static void action_icon(BongoCatPreferences *value, + struct nk_command_buffer *canvas, struct nk_rect bounds, int icon, + struct nk_color color) { + bongo_cat_preferences_icon_draw(value, canvas, icon, + nk_rect(bounds.x + (bounds.w - 18) * .5f, + bounds.y + (bounds.h - 18) * .5f, 18, 18), color); +} + +void bongo_cat_preferences_model_select(BongoCatPreferences *value, + const BongoCatModelEntry *entry) { + if (!value || !entry) return; + if (value->model_loading && !strcmp(entry->id, value->loading_model_id)) + return; + bool multiple = value->app->settings.model.multiple_pets; + bool selected = bongo_cat_app_model_active(value->app, entry->id); + if (multiple && !selected && bongo_cat_app_active_model_count(value->app) >= + BONGO_CAT_ACTIVE_MODEL_CAP) { + bongo_cat_preferences_notice_show(value->app, tr(value->app, + "native.multiplePetsLimit", + "Up to 8 desktop pets can be displayed at once"), true); + return; + } + snprintf(value->pending_model_id, sizeof(value->pending_model_id), + "%s", entry->id); + value->pending_model_multiple = multiple; + value->pending_model_active = !selected; + if (!multiple && strcmp(entry->id, value->app->session.active_model_id)) + bongo_cat_preferences_model_visual_begin(value, entry->id); + value->model_selection_pending = true; + value->model_load_progress = 0.0f; + bongo_cat_preferences_invalidate(value); +} + +static void open_model_directory(BongoCatPreferences *value, + const BongoCatModelEntry *entry) { + const char *directory = entry->storage_directory[0] ? + entry->storage_directory : entry->directory; + if (bongo_cat_path_is_dir(directory) && + bongo_cat_platform_open_directory(directory)) return; + bongo_cat_preferences_notice_show(value->app, tr(value->app, + "pages.preference.model.hints.openDirectoryFailed", + "Unable to open model directory"), true); +} + +static void draw_cover(BongoCatPreferences *value, + struct nk_command_buffer *canvas, struct nk_rect preview, + const BongoCatModelEntry *entry, BongoCatUIPalette p) { + float raster_scale = value->ui.raster_scale > 0.0f ? + value->ui.raster_scale : 1.0f; + const BongoCatModelCover *cover = bongo_cat_preferences_model_cover( + value->app, entry, NK_MAX(1, (int)lroundf(preview.w * raster_scale)), + NK_MAX(1, (int)lroundf(preview.h * raster_scale))); + if (!cover) { + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CAT, + nk_rect(preview.x + (preview.w - 34) * .5f, + preview.y + (preview.h - 34) * .5f, 34, 34), p.muted); + return; + } + float scale = NK_MIN(preview.w / cover->width, preview.h / cover->height); + struct nk_rect image = nk_rect( + preview.x + (preview.w - cover->width * scale) * .5f, + preview.y + (preview.h - cover->height * scale) * .5f, + cover->width * scale, cover->height * scale); + struct nk_image texture = nk_image_id((int)cover->texture); + nk_draw_image(canvas, image, &texture, nk_rgb(255, 255, 255)); +} + +static void draw_actions(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect bounds, const BongoCatModelEntry *entry, + BongoCatUIPalette p, bool selected, bool primary, bool storage_busy, + bool *action_hover) { + struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, + bounds.w - 2, 38); + nk_fill_rect(canvas, actions, 0, p.surface); + bool deletable = !entry->preset && !entry->managed; + bool delete_enabled = deletable && !storage_busy; + float width = actions.w / (deletable ? 3.0f : 2.0f); + struct nk_rect items[3] = { + nk_rect(actions.x, actions.y, width, actions.h), + nk_rect(actions.x + width, actions.y, width, actions.h), + nk_rect(actions.x + width * 2, actions.y, width, actions.h)}; + bool behavior_enabled = primary || + !value->app->settings.model.multiple_pets; + bool region_hover[3] = { + nk_input_is_mouse_hovering_rect(&context->input, items[0]), + nk_input_is_mouse_hovering_rect(&context->input, items[1]), + deletable && nk_input_is_mouse_hovering_rect( + &context->input, items[2])}; + bool hover[3] = {behavior_enabled && region_hover[0], + region_hover[1], delete_enabled && region_hover[2]}; + for (int i = 1; i < (deletable ? 3 : 2); ++i) + nk_stroke_line(canvas, items[i].x, items[i].y + 10, + items[i].x, items[i].y + items[i].h - 10, 1, p.border_subtle); + for (int i = 0; i < 3; ++i) + if (hover[i]) nk_fill_rect(canvas, items[i], 0, p.hover_pink); + action_icon(value, canvas, items[0], BONGO_CAT_UI_ICON_SMILE, + hover[0] ? p.pink : behavior_enabled ? p.muted : p.border_subtle); + action_icon(value, canvas, items[1], BONGO_CAT_UI_ICON_FOLDER, + hover[1] ? p.pink : p.muted); + if (deletable) action_icon(value, canvas, items[2], BONGO_CAT_UI_ICON_TRASH, + hover[2] ? p.danger : delete_enabled ? p.muted : p.border_subtle); + *action_hover = region_hover[0] || region_hover[1] || region_hover[2]; + if (hover[0] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[0])) { + if (selected) bongo_cat_preferences_behavior_dialog_open(value); + else bongo_cat_preferences_model_select(value, entry); + } else if (hover[1] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[1])) open_model_directory(value, entry); + else if (hover[2] && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, items[2])) + bongo_cat_preferences_remove_dialog_open(value->app, entry->id); +} + +void bongo_cat_preferences_model_card(BongoCatPreferences *value, + struct nk_context *context, const BongoCatModelEntry *entry, + bool storage_busy) { + BongoCatApp *app = value->app; + bool selected = bongo_cat_app_model_active(app, entry->id); + bool primary = !strcmp(entry->id, app->session.active_model_id); + bool visual_target = value->model_load_visual_active && + !strcmp(entry->id, value->model_load_visual_id); + bool selected_visual = selected && !visual_target; + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + bounds.y += 5.0f; bounds.h += 1.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + char animation_id[BONGO_CAT_ID_CAP + 24]; + snprintf(animation_id, sizeof(animation_id), "model-hover-%s", entry->id); + float lift = bongo_cat_ui_animate_eased(context, animation_id, + hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); + char selection_animation_id[BONGO_CAT_ID_CAP + 28]; + snprintf(selection_animation_id, sizeof(selection_animation_id), + "model-selected-%s", entry->id); + float selection_amount = bongo_cat_ui_animate_eased(context, + selection_animation_id, selected_visual ? 1.0f : 0.0f, + MODEL_SELECTION_FADE_MS, BONGO_CAT_UI_EASE_STANDARD); + bounds.y -= 3.0f * lift; + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + if (selection_amount > .001f && p.effects) + bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 8, 20, 0, + bongo_cat_ui_color_alpha(nk_rgba(p.pink.r, p.pink.g, + p.pink.b, 89), selection_amount)); + else if (!selected_visual && hover && p.effects) + bongo_cat_ui_paint_shadow(context, bounds, 14, 0, 10, 28, 0, + nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); + nk_fill_rect(canvas, bounds, 14, p.surface); + float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); + struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, + bounds.w - 2, preview_height); + nk_fill_rect(canvas, preview, 12, p.surface); + draw_cover(value, canvas, preview, entry, p); + if (app->settings.model.multiple_pets) + bongo_cat_preferences_model_card_draw_selection_badge( + value, canvas, preview, p, selection_amount); + float info_y = preview.y + preview.h + 8; + struct nk_rect name_bounds = nk_rect(bounds.x + 9, info_y + 1, + bounds.w - 18, 30); + bool name_hover = bongo_cat_preferences_model_name_draw(value, + context, canvas, entry, name_bounds, p); + bool action_hover = false; + draw_actions(value, context, canvas, bounds, entry, p, selected, primary, + storage_busy, &action_hover); + float outline_width = 1.0f + selection_amount; + struct nk_rect outline = bongo_cat_preferences_model_card_outline_bounds( + bounds, outline_width); + float progress = visual_target ? + bongo_cat_preferences_model_visual_progress(value, entry->id) : + selected_visual ? 1.0f : 0.0f; + struct nk_color outline_color = progress >= .999f ? p.pink : + bongo_cat_ui_color_mix( + bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift), + p.pink, selection_amount); + nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, + outline_width, outline_color); + bongo_cat_preferences_model_card_draw_progress(canvas, outline, + 14.0f - outline_width * .5f, 2.0f, progress, p.pink); + if (!name_hover && (action_hover || hover)) + bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + if (!name_hover && !action_hover && hover && + nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds)) + bongo_cat_preferences_model_select(value, entry); +} diff --git a/src/ui/preferences/preferences_model_card.h b/src/ui/preferences/preferences_model_card.h new file mode 100644 index 00000000..d37b3ce8 --- /dev/null +++ b/src/ui/preferences/preferences_model_card.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_PREFERENCES_MODEL_CARD_H +#define BONGO_CAT_PREFERENCES_MODEL_CARD_H + +#include "bongo_cat/model.h" +#include "bongo_cat/preferences.h" + +struct nk_context; + +bool bongo_cat_preferences_model_import_card(BongoCatPreferences *value, + struct nk_context *context); +bool bongo_cat_preferences_model_section(BongoCatPreferences *value, + struct nk_context *context); +void bongo_cat_preferences_model_card(BongoCatPreferences *value, + struct nk_context *context, const BongoCatModelEntry *entry, + bool storage_busy); +void bongo_cat_preferences_model_select(BongoCatPreferences *value, + const BongoCatModelEntry *entry); + +#endif diff --git a/src/ui/preferences/preferences_model_card_paint.c b/src/ui/preferences/preferences_model_card_paint.c new file mode 100644 index 00000000..27f45f55 --- /dev/null +++ b/src/ui/preferences/preferences_model_card_paint.c @@ -0,0 +1,104 @@ +#include "preferences_model_card_paint.h" +#include "ui_icons.h" +#include "ui_paint.h" + +#include + +#define MODEL_PROGRESS_ARC_SEGMENTS 16 +#define MODEL_PROGRESS_POINT_CAP 80 + +struct nk_rect bongo_cat_preferences_model_card_outline_bounds( + struct nk_rect bounds, float thickness) { + float inset = thickness * .5f; + return nk_rect(bounds.x + inset, bounds.y + inset, + NK_MAX(1.0f, bounds.w - thickness), + NK_MAX(1.0f, bounds.h - thickness)); +} + +static int progress_point(float *points, int count, float x, float y) { + if (count >= MODEL_PROGRESS_POINT_CAP) return count; + points[count * 2] = x; + points[count * 2 + 1] = y; + return count + 1; +} + +static int rounded_outline_path(struct nk_rect bounds, float rounding, + float *points) { + const float pi = 3.14159265358979323846f; + float radius = NK_CLAMP(0.0f, rounding, + NK_MIN(bounds.w, bounds.h) * .5f); + float centers_x[] = {bounds.x + bounds.w - radius, + bounds.x + bounds.w - radius, bounds.x + radius, bounds.x + radius}; + float centers_y[] = {bounds.y + radius, bounds.y + bounds.h - radius, + bounds.y + bounds.h - radius, bounds.y + radius}; + float starts[] = {-pi * .5f, 0.0f, pi * .5f, pi}; + int count = 0; + count = progress_point(points, count, bounds.x + radius, bounds.y); + for (int corner = 0; corner < 4; ++corner) { + for (int i = 0; i <= MODEL_PROGRESS_ARC_SEGMENTS; ++i) { + float angle = starts[corner] + pi * .5f * i / + MODEL_PROGRESS_ARC_SEGMENTS; + count = progress_point(points, count, + centers_x[corner] + cosf(angle) * radius, + centers_y[corner] + sinf(angle) * radius); + } + } + count = progress_point(points, count, points[0], points[1]); + return count; +} + +void bongo_cat_preferences_model_card_draw_progress( + struct nk_command_buffer *canvas, struct nk_rect bounds, float rounding, + float thickness, float progress, struct nk_color color) { + progress = NK_CLAMP(0.0f, progress, 1.0f); + if (progress <= 0.001f) return; + if (progress >= .999f) { + nk_stroke_rect(canvas, bounds, rounding, thickness, color); + return; + } + float path[MODEL_PROGRESS_POINT_CAP * 2]; + int path_count = rounded_outline_path(bounds, rounding, path); + if (path_count < 2) return; + float lengths[MODEL_PROGRESS_POINT_CAP]; + lengths[0] = 0.0f; + for (int i = 1; i < path_count; ++i) { + float dx = path[i * 2] - path[(i - 1) * 2]; + float dy = path[i * 2 + 1] - path[(i - 1) * 2 + 1]; + lengths[i] = lengths[i - 1] + sqrtf(dx * dx + dy * dy); + } + float target = lengths[path_count - 1] * progress; + float partial[MODEL_PROGRESS_POINT_CAP * 2]; + int partial_count = progress_point(partial, 0, path[0], path[1]); + for (int i = 1; i < path_count; ++i) { + if (target >= lengths[i]) { + partial_count = progress_point(partial, partial_count, + path[i * 2], path[i * 2 + 1]); + continue; + } + float segment = lengths[i] - lengths[i - 1]; + float amount = segment > 0.0f ? + (target - lengths[i - 1]) / segment : 0.0f; + partial_count = progress_point(partial, partial_count, + path[(i - 1) * 2] + + (path[i * 2] - path[(i - 1) * 2]) * amount, + path[(i - 1) * 2 + 1] + + (path[i * 2 + 1] - path[(i - 1) * 2 + 1]) * amount); + break; + } + if (partial_count >= 2) + nk_stroke_polyline(canvas, partial, partial_count, thickness, color); +} + +void bongo_cat_preferences_model_card_draw_selection_badge( + BongoCatPreferences *value, struct nk_command_buffer *canvas, + struct nk_rect preview, BongoCatUIPalette palette, float amount) { + if (amount <= .001f) return; + float size = 25.0f * (.85f + .15f * amount); + struct nk_rect badge = nk_rect(preview.x + preview.w - size - 8, + preview.y + 8, size, size); + nk_fill_circle(canvas, badge, + bongo_cat_ui_color_alpha(palette.pink, amount)); + bongo_cat_preferences_icon_draw(value, canvas, BONGO_CAT_UI_ICON_CHECK, + nk_rect(badge.x + 5, badge.y + 5, badge.w - 10, badge.h - 10), + bongo_cat_ui_color_alpha(nk_rgb(255, 255, 255), amount)); +} diff --git a/src/ui/preferences/preferences_model_card_paint.h b/src/ui/preferences/preferences_model_card_paint.h new file mode 100644 index 00000000..8de273b6 --- /dev/null +++ b/src/ui/preferences/preferences_model_card_paint.h @@ -0,0 +1,15 @@ +#ifndef BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H +#define BONGO_CAT_PREFERENCES_MODEL_CARD_PAINT_H + +#include "preferences_state.h" + +struct nk_rect bongo_cat_preferences_model_card_outline_bounds( + struct nk_rect bounds, float thickness); +void bongo_cat_preferences_model_card_draw_progress( + struct nk_command_buffer *canvas, struct nk_rect bounds, float rounding, + float thickness, float progress, struct nk_color color); +void bongo_cat_preferences_model_card_draw_selection_badge( + BongoCatPreferences *value, struct nk_command_buffer *canvas, + struct nk_rect preview, BongoCatUIPalette palette, float amount); + +#endif diff --git a/src/ui/preferences/preferences_model_cover.c b/src/ui/preferences/preferences_model_cover.c new file mode 100644 index 00000000..3a528c09 --- /dev/null +++ b/src/ui/preferences/preferences_model_cover.c @@ -0,0 +1,146 @@ +#include "preferences_model_cover.h" +#include "preferences_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/path.h" +#include "bongo_cat/preferences.h" + +#include +#include +#include + +typedef struct ModelCoverSlot { + BongoCatModelCover image; + BongoCatApp *app; + char path[BONGO_CAT_PATH_CAP]; + int requested_width; + int requested_height; + uint64_t generation; +} ModelCoverSlot; + +static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; +static uint64_t cover_generation; +static unsigned cover_load_budget; +static bool cover_load_deferred; + +void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if ((!app || slot->app == app) && slot->image.texture) + glDeleteTextures(1, &slot->image.texture); + if (!app || slot->app == app) memset(slot, 0, sizeof(*slot)); + } +} + +bool bongo_cat_preferences_model_cover_reload(BongoCatApp *app, + const char *path) { + if (!app || !path || !path[0]) return false; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if (!slot->image.texture || slot->app != app || + strcmp(slot->path, path) != 0) continue; + int width = 0, height = 0; + BongoCatError ignored = {0}; + unsigned int texture = bongo_cat_image_texture_thumbnail(path, + slot->requested_width, slot->requested_height, &width, &height, + &ignored); + if (!texture) { + SDL_LogWarn(SDL_LOG_CATEGORY_RENDER, + "Model cover cache reload failed: %s", path); + return false; + } + unsigned int previous = slot->image.texture; + slot->image.texture = texture; + slot->image.width = width; + slot->image.height = height; + slot->generation = cover_generation; + glDeleteTextures(1, &previous); + if (app->preferences) + bongo_cat_preferences_invalidate(app->preferences); + SDL_Log("Model cover cache reloaded: %s (%dx%d)", path, + width, height); + return true; + } + if (app->preferences) + bongo_cat_preferences_invalidate(app->preferences); + return true; +} + +void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { + bongo_cat_preferences_remove_dialog_clear(app); + bongo_cat_preferences_model_cover_cache_clear(app); +} + +void bongo_cat_preferences_model_cache_abandon(BongoCatApp *app) { + bongo_cat_preferences_remove_dialog_clear(app); + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) + if (!app || cover_cache[i].app == app) + memset(&cover_cache[i], 0, sizeof(cover_cache[i])); +} + +void bongo_cat_preferences_model_covers_begin(BongoCatApp *app) { + cover_generation++; + if (!cover_generation) cover_generation++; + cover_load_budget = 1; + cover_load_deferred = false; + (void)app; +} + +const BongoCatModelCover *bongo_cat_preferences_model_cover( + BongoCatApp *app, const BongoCatModelEntry *entry, + int pixel_width, int pixel_height) { + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, + "resources/cover.png")) return NULL; + if (!bongo_cat_path_is_file(path)) { + static char last_missing[BONGO_CAT_PATH_CAP]; + if (strcmp(last_missing, path)) { + snprintf(last_missing, sizeof(last_missing), "%s", path); + SDL_Log("Model cover unavailable: id=%s path=%s", + entry->id, path); + } + return NULL; + } + ModelCoverSlot *empty = NULL; + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if (slot->image.texture && slot->app == app && !strcmp(slot->path, path)) { + if (slot->requested_width == pixel_width && + slot->requested_height == pixel_height) { + slot->generation = cover_generation; return &slot->image; + } + glDeleteTextures(1, &slot->image.texture); + memset(slot, 0, sizeof(*slot)); + empty = slot; + break; + } + if (!slot->image.texture && !empty) empty = slot; + } + if (!empty) return NULL; + if (!cover_load_budget) { + cover_load_deferred = true; + return NULL; + } + cover_load_budget--; + BongoCatError ignored = {0}; + empty->image.texture = bongo_cat_image_texture_thumbnail(path, + pixel_width, pixel_height, &empty->image.width, + &empty->image.height, &ignored); + if (!empty->image.texture) return NULL; + empty->app = app; empty->generation = cover_generation; + empty->requested_width = pixel_width; + empty->requested_height = pixel_height; + snprintf(empty->path, sizeof(empty->path), "%s", path); + return &empty->image; +} + +void bongo_cat_preferences_model_covers_prune(BongoCatApp *app) { + for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { + ModelCoverSlot *slot = &cover_cache[i]; + if (slot->app != app || !slot->image.texture || + slot->generation == cover_generation) continue; + glDeleteTextures(1, &slot->image.texture); + memset(slot, 0, sizeof(*slot)); + } + if (cover_load_deferred && app) + bongo_cat_preferences_invalidate(app->preferences); +} diff --git a/src/ui/preferences_model_cover.h b/src/ui/preferences/preferences_model_cover.h similarity index 72% rename from src/ui/preferences_model_cover.h rename to src/ui/preferences/preferences_model_cover.h index 0fdab386..c035901d 100644 --- a/src/ui/preferences_model_cover.h +++ b/src/ui/preferences/preferences_model_cover.h @@ -11,13 +11,14 @@ typedef struct BongoCatModelCover { int height; } BongoCatModelCover; -void bongo_cat_preferences_model_covers_begin(void); +void bongo_cat_preferences_model_covers_begin(BongoCatApp *app); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); +bool bongo_cat_preferences_model_cover_reload(BongoCatApp *app, + const char *path); +void bongo_cat_preferences_model_cache_abandon(BongoCatApp *app); const BongoCatModelCover *bongo_cat_preferences_model_cover( BongoCatApp *app, const BongoCatModelEntry *entry, int pixel_width, int pixel_height); -bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, - const BongoCatModelEntry *entry); void bongo_cat_preferences_model_covers_prune(BongoCatApp *app); #endif diff --git a/src/ui/preferences/preferences_model_glyphs.c b/src/ui/preferences/preferences_model_glyphs.c new file mode 100644 index 00000000..3ae3cbca --- /dev/null +++ b/src/ui/preferences/preferences_model_glyphs.c @@ -0,0 +1,102 @@ +#include "preferences_state.h" +#include "preferences_model_glyphs.h" + +#include +#include + +static bool glyph_covered(const uint32_t *ranges, size_t used, uint32_t rune) { + for (size_t i = 0; i + 1 < used; i += 2) + if (rune >= ranges[i] && rune <= ranges[i + 1]) return true; + return false; +} + +static void add_text(uint32_t *ranges, size_t capacity, + size_t *used, const char *text) { + const char *cursor = text; + size_t remaining = text ? strlen(text) : 0; + while (cursor && *cursor) { + nk_rune rune = 0; + int decoded = nk_utf_decode(cursor, &rune, (int)remaining); + if (decoded <= 0) { + cursor++; + if (remaining) remaining--; + continue; + } + cursor += decoded; remaining -= (size_t)decoded; + if (rune <= 0x7e || glyph_covered(ranges, *used, rune)) continue; + if (*used + 2 >= capacity) return; + ranges[(*used)++] = rune; + ranges[(*used)++] = rune; + ranges[*used] = 0; + } +} + +static bool text_covered(const uint32_t *ranges, size_t capacity, + const char *text) { + if (!ranges || !capacity || !text || !text[0]) return false; + size_t used = 0; + while (used < capacity && ranges[used]) used++; + const char *cursor = text; + size_t remaining = text ? strlen(text) : 0; + while (cursor && *cursor) { + nk_rune rune = 0; + int decoded = nk_utf_decode(cursor, &rune, (int)remaining); + if (decoded <= 0) return false; + if (rune > 0x7e && !glyph_covered(ranges, used, rune)) return false; + cursor += decoded; + remaining -= (size_t)decoded; + } + return true; +} + +void bongo_cat_preferences_model_glyphs_note( + BongoCatPreferences *preferences, const char *name) { + if (!preferences || !name || !name[0]) return; + for (size_t i = 0; i < preferences->pending_import_name_count; ++i) + if (!strcmp(preferences->pending_import_names[i], name)) return; + if (preferences->pending_import_name_count >= BONGO_CAT_MODEL_CAP) return; + snprintf(preferences->pending_import_names[ + preferences->pending_import_name_count++], BONGO_CAT_ID_CAP, "%s", name); +} + +bool bongo_cat_preferences_model_glyphs_ready( + const BongoCatPreferences *preferences, const char *name) { + return preferences && text_covered(preferences->glyph_ranges, + sizeof(preferences->glyph_ranges) / + sizeof(preferences->glyph_ranges[0]), name); +} + +void bongo_cat_preferences_model_glyphs_clear_pending( + BongoCatPreferences *preferences) { + if (preferences) preferences->pending_import_name_count = 0; +} + +void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, + uint32_t *ranges, size_t capacity) { + if (!app || !ranges) return; + size_t used = 0; + while (used < capacity && ranges[used]) used++; + if (used >= capacity || (used & 1)) return; + /* Symbols used by the HTML reference's contribution category badges. */ + add_text(ranges, capacity, &used, "〈/〉▤♧文"); + for (size_t i = 0; i < app->models.count; ++i) { + const BongoCatModelEntry *entry = &app->models.entries[i]; + add_text(ranges, capacity, &used, + bongo_cat_model_name(&app->settings, entry)); + } + for (size_t i = 0; i < app->behaviors.count; ++i) + add_text(ranges, capacity, &used, app->behaviors.entries[i].label); + for (size_t i = 0; + i < app->settings.behavior_shortcut_count; ++i) + add_text(ranges, capacity, &used, + app->settings.behavior_shortcuts[i].label); + if (app->preferences) { + const BongoCatPreferences *preferences = app->preferences; + for (size_t i = 0; i < sizeof(preferences->notices) / + sizeof(preferences->notices[0]); ++i) + add_text(ranges, capacity, &used, preferences->notices[i].message); + for (size_t i = 0; i < preferences->pending_import_name_count; ++i) + add_text(ranges, capacity, &used, + preferences->pending_import_names[i]); + } +} diff --git a/src/ui/preferences/preferences_model_glyphs.h b/src/ui/preferences/preferences_model_glyphs.h new file mode 100644 index 00000000..54621cc1 --- /dev/null +++ b/src/ui/preferences/preferences_model_glyphs.h @@ -0,0 +1,19 @@ +#ifndef BONGO_CAT_PREFERENCES_MODEL_GLYPHS_H +#define BONGO_CAT_PREFERENCES_MODEL_GLYPHS_H + +#include +#include + +typedef struct BongoCatApp BongoCatApp; +typedef struct BongoCatPreferences BongoCatPreferences; + +void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, + uint32_t *ranges, size_t capacity); +void bongo_cat_preferences_model_glyphs_note(BongoCatPreferences *preferences, + const char *name); +bool bongo_cat_preferences_model_glyphs_ready( + const BongoCatPreferences *preferences, const char *name); +void bongo_cat_preferences_model_glyphs_clear_pending( + BongoCatPreferences *preferences); + +#endif diff --git a/src/ui/preferences/preferences_model_import.c b/src/ui/preferences/preferences_model_import.c new file mode 100644 index 00000000..90716152 --- /dev/null +++ b/src/ui/preferences/preferences_model_import.c @@ -0,0 +1,92 @@ +#include "preferences_state.h" +#include "preferences_notice.h" +#include "model_import.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" + +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static const char *import_failure_message(BongoCatApp *app, + BongoCatResult result) { + switch (result) { + case BONGO_CAT_ERROR_ARGUMENT: + return tr(app, "pages.preference.model.hints.importInvalidSource", + "The selected source no longer exists or cannot be used"); + case BONGO_CAT_ERROR_FORMAT: + return tr(app, "pages.preference.model.hints.importInvalidFormat", + "No supported model was found, or the model package is incomplete"); + case BONGO_CAT_ERROR_IO: + return tr(app, "pages.preference.model.hints.importFileAccess", + "The model files could not be read or saved"); + case BONGO_CAT_ERROR_MEMORY: + return tr(app, "pages.preference.model.hints.importOutOfMemory", + "There is not enough memory to import this model"); + case BONGO_CAT_ERROR_CUBISM: + return tr(app, "pages.preference.model.hints.importLoadFailed", + "The model was imported, but it could not be displayed"); + default: + return tr(app, "pages.preference.model.hints.importFailed", + "Model import failed. Check the selected files and try again"); + } +} + +void bongo_cat_preferences_import_complete(BongoCatApp *app, + BongoCatResult result, const BongoCatError *error, size_t resolved_count, + size_t installed_count, size_t succeeded_count, size_t failed_count, + const char (*failed_names)[BONGO_CAT_ID_CAP], size_t failed_name_count) { + if (!app || !app->preferences) return; + bool failed = failed_count > 0 || result != BONGO_CAT_OK; + char batch_message[1024]; + char failure_list[BONGO_CAT_IMPORT_FAILURE_NAME_CAP * (BONGO_CAT_ID_CAP + 2)] = ""; + char more_failures[64] = ""; + size_t shown_names = failed_names && failed_name_count > + BONGO_CAT_IMPORT_FAILURE_NAME_CAP ? BONGO_CAT_IMPORT_FAILURE_NAME_CAP + : failed_names ? failed_name_count : 0; + for (size_t i = 0; i < shown_names; ++i) { + size_t used = strlen(failure_list); + snprintf(failure_list + used, sizeof(failure_list) - used, + "%s%s", i ? ", " : "", failed_names[i]); + } + size_t listed_count = shown_names; + if (!failure_list[0]) { + snprintf(failure_list, sizeof(failure_list), "%s", + tr(app, "pages.preference.model.hints.importUnknownSource", + "unknown source")); + listed_count = failed_count ? 1 : 0; + } + if (failed_count > listed_count) + snprintf(more_failures, sizeof(more_failures), tr(app, + "pages.preference.model.hints.importMoreFailures", + " and %zu more"), failed_count - listed_count); + const char *message = installed_count ? tr(app, + "pages.preference.model.hints.importSuccess", "Model imported") + : tr(app, "pages.preference.model.hints.importExists", + "Model already exists"); + if (failed) { + BongoCatResult failure = error && error->code != BONGO_CAT_OK + ? error->code : result; + snprintf(batch_message, sizeof(batch_message), tr(app, + "pages.preference.model.hints.importBatchResult", + "Import succeeded: %zu; failed: %zu.\nFailed models: %s%s.\nFirst failure: %s"), + succeeded_count, failed_count, failure_list, more_failures, + import_failure_message(app, failure)); + message = batch_message; + } + if (failed && error && error->message[0]) + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Model import failed: %s", error->message); + if (app->smoke) { + if (failed) app->exit_code = 1; + } else bongo_cat_preferences_notice_show(app, message, failed); + if (result == BONGO_CAT_OK) + SDL_Log("Resolved %zu model package(s); installed %zu new package(s)", + resolved_count, installed_count); + bongo_cat_preferences_invalidate(app->preferences); + bongo_cat_preferences_render(app->preferences); +} diff --git a/src/ui/preferences/preferences_model_rename.c b/src/ui/preferences/preferences_model_rename.c new file mode 100644 index 00000000..998278f9 --- /dev/null +++ b/src/ui/preferences/preferences_model_rename.c @@ -0,0 +1,95 @@ +#include "preferences_state.h" +#include "preferences_model_glyphs.h" +#include "preferences_text_edit.h" + +#include +#include + +bool bongo_cat_preferences_model_name_draw(BongoCatPreferences *value, + struct nk_context *context, struct nk_command_buffer *canvas, + const BongoCatModelEntry *entry, struct nk_rect bounds, + BongoCatUIPalette p) { + BongoCatPreferencesTextSession *session = &value->model_rename; + bool renaming = !strcmp(session->id, entry->id); + bool hover = !renaming && nk_input_is_mouse_hovering_rect( + &context->input, bounds); + bool clicked = hover && nk_input_is_mouse_click_in_rect( + &context->input, NK_BUTTON_LEFT, bounds); + if (renaming || hover) { + nk_fill_rect(canvas, bounds, 7, renaming ? p.hover_pink : p.hover); + nk_stroke_rect(canvas, bounds, 7, 1, renaming ? p.pink : p.accent); + } + const char *shown = renaming ? session->text : + bongo_cat_model_name(&value->app->settings, entry); + /* Page changes and catalog refreshes can draw before the atlas reload. */ + bool name_pending = !renaming && value->font_reload_pending && + !bongo_cat_preferences_model_glyphs_ready(value, shown); + if (name_pending) + shown = value->import_render_active ? + bongo_cat_i18n_get(value->app->i18n, "native.importing", + "Importing...") : "..."; + struct nk_rect text_bounds = nk_rect(bounds.x + 5, + bounds.y + 3, bounds.w - 10, 22); + if (renaming && session->select_all) + nk_fill_rect(canvas, text_bounds, 4, p.selection); + nk_draw_text(canvas, text_bounds, shown, nk_strlen(shown), + value->ui.label_font, nk_rgba(0, 0, 0, 0), + renaming ? p.pink : p.text); + if (renaming) { + session->bounds = bounds; + float caret = value->ui.label_font->width( + value->ui.label_font->userdata, value->ui.label_font->height, + shown, (int)session->cursor); + caret = NK_MIN(caret, bounds.w - 12); + if (!session->select_all) + nk_stroke_line(canvas, bounds.x + 5 + caret, bounds.y + 3, + bounds.x + 5 + caret, bounds.y + bounds.h - 3, 1, p.pink); + } else if (clicked && !name_pending) + bongo_cat_preferences_model_rename_begin(value, entry, bounds); + if (hover || renaming) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_TEXT); + return renaming || hover; +} + +void bongo_cat_preferences_model_rename_finish( + BongoCatPreferences *value, bool save) { + if (!value || !bongo_cat_preferences_text_session_active( + &value->model_rename)) return; + BongoCatPreferencesTextSession *session = &value->model_rename; + bool label_changed = false; + if (save) { + bongo_cat_text_edit_trim(session->text); + const BongoCatModelEntry *entry = bongo_cat_models_find( + &value->app->models, session->id); + const char *label = entry && !strcmp(session->text, + bongo_cat_model_default_name(entry)) ? "" : session->text; + label_changed = bongo_cat_settings_set_model_label(&value->app->settings, + session->id, label); + } + bongo_cat_preferences_text_session_reset(session); + SDL_StopTextInput(value->window); + if (label_changed) bongo_cat_preferences_reload_fonts(value); + value->render_dirty = true; +} + +void bongo_cat_preferences_model_rename_begin(BongoCatPreferences *value, + const BongoCatModelEntry *entry, struct nk_rect bounds) { + if (!value || !entry) return; + bongo_cat_preferences_shortcut_cancel(value); + bongo_cat_preferences_text_session_begin(&value->model_rename, entry->id, + bongo_cat_model_name(&value->app->settings, entry), bounds); + SDL_StartTextInput(value->window); + value->render_dirty = true; +} + +bool bongo_cat_preferences_model_rename_event( + BongoCatPreferences *value, const SDL_Event *event) { + if (!value) return false; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&value->model_rename, + event, value->ui.layout_scale, value->ui.label_font, 5.0f); + if (result.finish) + bongo_cat_preferences_model_rename_finish(value, result.save); + if (result.handled) value->render_dirty = true; + return result.handled; +} diff --git a/src/ui/preferences/preferences_model_section.c b/src/ui/preferences/preferences_model_section.c new file mode 100644 index 00000000..75d8cf64 --- /dev/null +++ b/src/ui/preferences/preferences_model_section.c @@ -0,0 +1,57 @@ +#include "preferences_model_card.h" +#include "preferences_state.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "ui_paint.h" +#include "bongo_cat/i18n.h" + +static const char *tr(BongoCatPreferences *value, const char *key, + const char *fallback) { + return bongo_cat_i18n_get(value->app->i18n, key, fallback); +} + +bool bongo_cat_preferences_model_section(BongoCatPreferences *value, + struct nk_context *context) { + struct nk_rect bounds; + nk_layout_row_dynamic(context, 22, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + const struct nk_user_font *action_font = bongo_cat_ui_caption_font(context); + const char *label = tr(value, + "pages.preference.model.tooltips.moreModels", "More Models"); + float label_width = action_font->width(action_font->userdata, + action_font->height, label, nk_strlen(label)); + float action_width = label_width + 22; + float action_x = bounds.x + bounds.w - action_width; + struct nk_rect action = nk_rect(action_x, + bounds.y, action_width, bounds.h); + bool action_hover = nk_input_is_mouse_hovering_rect(&context->input, action); + float hover_amount = bongo_cat_ui_animate_eased(context, + "model-more-link-hover", action_hover ? 1.0f : 0.0f, 180.0f, + BONGO_CAT_UI_EASE_STANDARD); + struct nk_color action_color = p.danger; + nk_draw_text(canvas, nk_rect(action.x, action.y + + (action.h - action_font->height) * .5f, label_width + 1, + action_font->height), label, nk_strlen(label), action_font, + nk_rgba(0, 0, 0, 0), action_color); + float arrow_y = action.y + action.h * .5f; + float arrow_offset = 2.0f * hover_amount; + float first_arrow_x = action.x + label_width + 11 + arrow_offset; + float second_arrow_x = action.x + label_width + 17 + arrow_offset; + struct nk_color first_arrow_color = bongo_cat_ui_color_alpha( + action_color, 0.5f); + nk_stroke_line(canvas, first_arrow_x - 4, arrow_y - 4, + first_arrow_x, arrow_y, 1.5f, first_arrow_color); + nk_stroke_line(canvas, first_arrow_x, arrow_y, + first_arrow_x - 4, arrow_y + 4, 1.5f, first_arrow_color); + nk_stroke_line(canvas, second_arrow_x - 4, arrow_y - 4, + second_arrow_x, arrow_y, 1.5f, action_color); + nk_stroke_line(canvas, second_arrow_x, arrow_y, + second_arrow_x - 4, arrow_y + 4, 1.5f, action_color); + if (action_hover) bongo_cat_ui_cursor_hover_rect(context, action, + BONGO_CAT_UI_CURSOR_POINTER); + return action_hover && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, action); +} diff --git a/src/ui/preferences/preferences_model_watch.c b/src/ui/preferences/preferences_model_watch.c new file mode 100644 index 00000000..b96a0032 --- /dev/null +++ b/src/ui/preferences/preferences_model_watch.c @@ -0,0 +1,90 @@ +#include "preferences_state.h" +#include "bongo_cat/path.h" + +#include + +#define MODEL_DIRECTORY_WATCH_INTERVAL_NS 750000000ull + +typedef struct ModelDirectorySnapshot { + uint64_t sum; + uint64_t xor_value; + size_t count; + bool available; +} ModelDirectorySnapshot; + +static uint64_t mix_hash(uint64_t value) { + value ^= value >> 30; + value *= 0xbf58476d1ce4e5b9ull; + value ^= value >> 27; + value *= 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +static uint64_t name_hash(const char *name) { + uint64_t value = 1469598103934665603ull; + for (const unsigned char *cursor = (const unsigned char *)name; + cursor && *cursor; ++cursor) { + value ^= *cursor; + value *= 1099511628211ull; + } + return value; +} + +static BongoCatPathVisit snapshot_item(void *userdata, + const char *directory, const char *name) { + ModelDirectorySnapshot *snapshot = userdata; + if (!name || name[0] == '.') return BONGO_CAT_PATH_CONTINUE; + char path[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, name) || + !bongo_cat_path_is_dir(path)) return BONGO_CAT_PATH_CONTINUE; + uint64_t hash = name_hash(name); + snapshot->sum += mix_hash(hash); + snapshot->xor_value ^= mix_hash(hash ^ 0x9e3779b97f4a7c15ull); + snapshot->count++; + return BONGO_CAT_PATH_CONTINUE; +} + +static ModelDirectorySnapshot directory_snapshot(const char *root) { + ModelDirectorySnapshot snapshot = {0}; + snapshot.available = root && bongo_cat_path_is_dir(root) && + bongo_cat_path_enumerate(root, snapshot_item, &snapshot); + return snapshot; +} + +void bongo_cat_preferences_model_watch(BongoCatPreferences *value, + uint64_t now) { + if (!value) return; + bool active = value->visible && value->page == 1 && value->window; + if (!active) { + value->model_directory_watch_active = false; + return; + } + if (!value->model_directory_watch_active) { + value->model_directory_watch_active = true; + if (!value->model_directory_watch_known) + value->model_directory_watch_refresh = true; + value->model_directory_watch_due_ns = 0; + } + if (value->model_directory_watch_due_ns > now) return; + value->model_directory_watch_due_ns = now + + MODEL_DIRECTORY_WATCH_INTERVAL_NS; + if (bongo_cat_preferences_import_status(value->import_dialog, + NULL, NULL, NULL)) return; + + ModelDirectorySnapshot current = directory_snapshot( + value->app->models_root); + bool changed = value->model_directory_watch_known && + (value->model_directory_watch_available != current.available || + value->model_directory_watch_count != current.count || + value->model_directory_watch_sum != current.sum || + value->model_directory_watch_xor != current.xor_value); + value->model_directory_watch_known = true; + value->model_directory_watch_available = current.available; + value->model_directory_watch_count = current.count; + value->model_directory_watch_sum = current.sum; + value->model_directory_watch_xor = current.xor_value; + if (changed || value->model_directory_watch_refresh) { + value->model_directory_watch_refresh = false; + bongo_cat_app_request_model_refresh(value->app); + } +} diff --git a/src/ui/preferences_notice.c b/src/ui/preferences/preferences_notice.c similarity index 51% rename from src/ui/preferences_notice.c rename to src/ui/preferences/preferences_notice.c index 01c5d8f5..62c382c0 100644 --- a/src/ui/preferences_notice.c +++ b/src/ui/preferences/preferences_notice.c @@ -1,5 +1,6 @@ #include "preferences_notice.h" #include "preferences_state.h" +#include "preferences_model_glyphs.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -11,6 +12,17 @@ enum { NOTICE_DURATION_MS = 2600, NOTICE_ENTER_MS = 250 }; +static void update_notice_timer(BongoCatPreferences *value, + BongoCatPreferenceNotice *notice, uint64_t now) { + if (!notice->message[0]) return; + bool hovered = SDL_GetMouseFocus() == value->window && + nk_input_is_mouse_hovering_rect(&value->ui.context.input, notice->bounds); + if (notice->hovered || hovered) + notice->until_ns += now - notice->timer_updated_ns; + notice->timer_updated_ns = now; + notice->hovered = hovered; +} + void bongo_cat_preferences_notice_show(BongoCatApp *app, const char *message, bool error) { if (!app || !app->preferences || !message || !message[0]) return; @@ -19,15 +31,24 @@ void bongo_cat_preferences_notice_show(BongoCatApp *app, BongoCatPreferenceNotice *target = &value->notices[0]; for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) { BongoCatPreferenceNotice *notice = &value->notices[i]; + update_notice_timer(value, notice, now); if (!notice->message[0] || notice->until_ns <= now) { target = notice; break; } - if (notice->started_ns < target->started_ns) target = notice; + if ((target->hovered && !notice->hovered) || + (notice->hovered == target->hovered && + notice->started_ns < target->started_ns)) target = notice; + } + memset(target, 0, sizeof(*target)); + SDL_utf8strlcpy(target->message, message, sizeof(target->message)); + if (!bongo_cat_preferences_model_glyphs_ready(value, target->message)) { + value->font_reload_pending = true; + value->font_reload_defer_once = false; } - snprintf(target->message, sizeof(target->message), "%s", message); target->error = error; target->started_ns = now; target->until_ns = now + NOTICE_DURATION_MS * 1000000ULL; + target->timer_updated_ns = now; value->render_dirty = true; } @@ -36,6 +57,7 @@ static size_t active_notices(BongoCatPreferences *value, uint64_t now, size_t count = 0; for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) { BongoCatPreferenceNotice *notice = &value->notices[i]; + update_notice_timer(value, notice, now); if (notice->message[0] && notice->until_ns <= now) memset(notice, 0, sizeof(*notice)); if (notice->message[0]) items[count++] = notice; @@ -51,37 +73,40 @@ static size_t active_notices(BongoCatPreferences *value, uint64_t now, return count; } -static float fit_notice_text(const struct nk_user_font *font, - const char *source, float maximum, char *output, size_t capacity) { - snprintf(output, capacity, "%s", source); - float width = font->width(font->userdata, font->height, - output, nk_strlen(output)); - if (width <= maximum) return width; - size_t prefix = strlen(source); - if (prefix > capacity - 4) prefix = capacity - 4; - while (prefix) { - prefix--; - while (prefix && ((unsigned char)source[prefix] & 0xc0) == 0x80) - prefix--; - memcpy(output, source, prefix); - memcpy(output + prefix, "...", 4); - width = font->width(font->userdata, font->height, - output, nk_strlen(output)); - if (width <= maximum) return width; +static int notice_line(const struct nk_user_font *font, const char *text, + float maximum, float *width) { + int length = 0, remaining = nk_strlen(text); + *width = 0; + while (length < remaining && text[length] != '\n') { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) bytes = 1; + float next_width = font->width(font->userdata, font->height, + text, length + bytes); + if (length && next_width > maximum) break; + length += bytes; + *width = next_width; } - snprintf(output, capacity, "..."); - return font->width(font->userdata, font->height, output, 3); + return length; } -static void draw_notice(BongoCatPreferences *value, +static float draw_notice(BongoCatPreferences *value, struct nk_context *context, BongoCatPreferenceNotice *notice, float width, float y, uint64_t now) { BongoCatUIPalette p = bongo_cat_ui_palette( bongo_cat_ui_dark(context)); const struct nk_user_font *font = bongo_cat_ui_label_font(context); - char label_text[sizeof(notice->message)]; - float text_width = fit_notice_text(font, notice->message, - NK_MAX(40.0f, width - 100.0f), label_text, sizeof(label_text)); + float maximum = NK_MAX(40.0f, width - 100.0f); + float text_width = 0; + size_t lines = 0; + for (const char *cursor = notice->message; *cursor; ++lines) { + float line_width; + cursor += notice_line(font, cursor, maximum, &line_width); + text_width = NK_MAX(text_width, line_width); + if (*cursor == '\n') cursor++; + } + float line_height = font->height + 5.0f; + float toast_height = NK_MAX(41.0f, lines * line_height + 15.0f); float toast_width = NK_MIN(text_width + 36.0f, width - 64.0f); float elapsed = (float)(now - notice->started_ns) / (NOTICE_ENTER_MS * 1000000.0f); @@ -90,7 +115,8 @@ static void draw_notice(BongoCatPreferences *value, float opacity = NK_CLAMP(0.0f, progress, 1.0f); float shown_width = toast_width * (.9f + .1f * progress); struct nk_rect bounds = nk_rect((width - shown_width) * .5f, - y - 12.0f * (1.0f - progress), shown_width, 41.0f); + y - 12.0f * (1.0f - progress), shown_width, toast_height); + notice->bounds = bounds; struct nk_color tone = notice->error ? p.danger : p.pink; if (p.effects) bongo_cat_ui_paint_shadow(context, bounds, 20, 0, 8, notice->error ? 25.0f : 22.0f, 0, nk_rgba(tone.r, tone.g, tone.b, @@ -98,12 +124,19 @@ static void draw_notice(BongoCatPreferences *value, struct nk_command_buffer *canvas = nk_window_get_canvas(context); nk_fill_rect(canvas, bounds, 20, nk_rgba(tone.r, tone.g, tone.b, (nk_byte)(255 * opacity))); - struct nk_rect label = nk_rect(bounds.x + (bounds.w - text_width) * .5f, - bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(text_width + 1, bounds.w - 20), font->height); - nk_draw_text(canvas, label, label_text, nk_strlen(label_text), font, - nk_rgba(0, 0, 0, 0), nk_rgba(255, 255, 255, (nk_byte)(255 * opacity))); + float label_y = bounds.y + (bounds.h - lines * line_height + 5.0f) * .5f; + for (const char *cursor = notice->message; *cursor; label_y += line_height) { + float line_width; + int length = notice_line(font, cursor, maximum, &line_width); + struct nk_rect label = nk_rect(bounds.x + (bounds.w - text_width) * .5f, + label_y, NK_MIN(text_width + 1, bounds.w - 20), font->height); + nk_draw_text(canvas, label, cursor, length, font, + nk_rgba(0, 0, 0, 0), nk_rgba(255, 255, 255, (nk_byte)(255 * opacity))); + cursor += length; + if (*cursor == '\n') cursor++; + } value->render_dirty = true; + return toast_height; } void bongo_cat_preferences_notice_draw(BongoCatPreferences *value, @@ -113,6 +146,7 @@ void bongo_cat_preferences_notice_draw(BongoCatPreferences *value, uint64_t now = SDL_GetTicksNS(); BongoCatPreferenceNotice *items[4]; size_t count = active_notices(value, now, items); + float y = 20.0f; for (size_t i = 0; i < count; ++i) - draw_notice(value, context, items[i], width, 20.0f + i * 51.0f, now); + y += draw_notice(value, context, items[i], width, y, now) + 10.0f; } diff --git a/src/ui/preferences_notice.h b/src/ui/preferences/preferences_notice.h similarity index 100% rename from src/ui/preferences_notice.h rename to src/ui/preferences/preferences_notice.h diff --git a/src/ui/preferences/preferences_number_edit.c b/src/ui/preferences/preferences_number_edit.c new file mode 100644 index 00000000..8a78c562 --- /dev/null +++ b/src/ui/preferences/preferences_number_edit.c @@ -0,0 +1,129 @@ +#include "preferences_number_edit.h" +#include "ui_backend.h" +#include "ui_catime.h" + +#include +#include +#include +#include + +static struct { + struct nk_context *context; + char id[64]; + char text[64]; + int length; + unsigned int frame; + bool integer; + bool negative; +} number_edit; + +void bongo_cat_pref_number_edit_reset(struct nk_context *context) { + if (number_edit.context == context) + memset(&number_edit, 0, sizeof(number_edit)); +} + +static nk_bool number_filter(const struct nk_text_edit *edit, nk_rune rune) { + (void)edit; + return (rune >= '0' && rune <= '9') || + (rune == '-' && number_edit.negative) || + (rune == '.' && !number_edit.integer); +} + +static bool valid_text(const char *text, bool integer, bool negative) { + if (*text == '-' && negative) ++text; + bool dot = false; + for (; *text; ++text) { + if (*text >= '0' && *text <= '9') continue; + if (*text == '.' && !integer && !dot) { dot = true; continue; } + return false; + } + return true; +} + +bool bongo_cat_pref_number_edit(struct nk_context *context, const char *id, + struct nk_rect bounds, const char *formatted, bool integer, + double minimum, double maximum, double *value) { + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + bool pressed = nk_input_is_mouse_pressed(&context->input, NK_BUTTON_LEFT); + bool active = number_edit.context == context && + strcmp(number_edit.id, id) == 0; + if (active && ((pressed && !hover) || + (unsigned int)(context->seq - number_edit.frame) > 1)) { + bongo_cat_pref_number_edit_reset(context); + nk_edit_unfocus(context); + active = false; + } + bool begin = !active && pressed && hover; + if (begin) { + number_edit.context = context; + snprintf(number_edit.id, sizeof(number_edit.id), "%s", id); + snprintf(number_edit.text, sizeof(number_edit.text), "%s", formatted); + number_edit.length = (int)strlen(number_edit.text); + number_edit.integer = integer; + number_edit.negative = minimum < 0; + active = true; + BongoCatUIBackend *backend = bongo_cat_ui_backend_for_context(context); + if (backend) SDL_StartTextInput(backend->window); + } + if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_TEXT); + if (!active) return false; + number_edit.frame = context->seq; + + char previous[sizeof(number_edit.text)]; + memcpy(previous, number_edit.text, sizeof(previous)); + int previous_length = number_edit.length; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_style_edit saved_style = context->style.edit; + const struct nk_user_font *saved_font = context->style.font; + context->style.font = bongo_cat_ui_body_font(context); + context->style.edit.normal = nk_style_item_color(p.field); + context->style.edit.hover = nk_style_item_color(p.field); + context->style.edit.active = nk_style_item_color(p.field); + context->style.edit.text_normal = p.text; + context->style.edit.text_hover = p.text; + context->style.edit.text_active = p.text; + context->style.edit.selected_normal = nk_rgb(83, 172, 252); + context->style.edit.selected_hover = nk_rgb(83, 172, 252); + context->style.edit.selected_text_normal = nk_rgb(255, 255, 255); + context->style.edit.selected_text_hover = nk_rgb(255, 255, 255); + context->style.edit.cursor_normal = p.text; + context->style.edit.cursor_hover = p.text; + context->style.edit.border = 0; + context->style.edit.rounding = 0; + context->style.edit.padding = nk_vec2(2, 0); + + /* Overlay the editor in an already allocated widget without advancing its row. */ + struct nk_panel saved_panel = *context->current->layout; + nk_layout_space_begin(context, NK_STATIC, bounds.h, 1); + struct nk_vec2 local = nk_layout_space_to_local(context, + nk_vec2(bounds.x, bounds.y)); + nk_layout_space_push(context, nk_rect(local.x, local.y, bounds.w, bounds.h)); + if (begin) nk_edit_unfocus(context); + nk_flags flags = nk_edit_string(context, + (nk_flags)NK_EDIT_FIELD | (nk_flags)NK_EDIT_AUTO_SELECT | + (nk_flags)NK_EDIT_SIG_ENTER, + number_edit.text, &number_edit.length, sizeof(number_edit.text) - 1, + number_filter); + nk_layout_space_end(context); + *context->current->layout = saved_panel; + context->style.edit = saved_style; + context->style.font = saved_font; + + number_edit.text[number_edit.length] = '\0'; + /* Clipboard insertion also needs whole-value validation. */ + if (!valid_text(number_edit.text, integer, minimum < 0)) { + memcpy(number_edit.text, previous, sizeof(previous)); + number_edit.length = previous_length; + } + char *end; + double parsed = strtod(number_edit.text, &end); + if (strcmp(previous, number_edit.text) != 0 && + end != number_edit.text && !*end && isfinite(parsed)) + *value = NK_CLAMP(minimum, parsed, maximum); + if (flags & (NK_EDIT_COMMITED | NK_EDIT_DEACTIVATED)) { + nk_edit_unfocus(context); + bongo_cat_pref_number_edit_reset(context); + } + return true; +} diff --git a/src/ui/preferences/preferences_number_edit.h b/src/ui/preferences/preferences_number_edit.h new file mode 100644 index 00000000..40a771d3 --- /dev/null +++ b/src/ui/preferences/preferences_number_edit.h @@ -0,0 +1,11 @@ +#ifndef BONGO_CAT_PREFERENCES_NUMBER_EDIT_H +#define BONGO_CAT_PREFERENCES_NUMBER_EDIT_H + +#include "preferences_controls.h" + +bool bongo_cat_pref_number_edit(struct nk_context *context, const char *id, + struct nk_rect bounds, const char *formatted, bool integer, + double minimum, double maximum, double *value); +void bongo_cat_pref_number_edit_reset(struct nk_context *context); + +#endif diff --git a/src/ui/preferences_overlay.c b/src/ui/preferences/preferences_overlay.c similarity index 97% rename from src/ui/preferences_overlay.c rename to src/ui/preferences/preferences_overlay.c index 9c94a07a..c7adbf5b 100644 --- a/src/ui/preferences_overlay.c +++ b/src/ui/preferences/preferences_overlay.c @@ -12,7 +12,7 @@ bool bongo_cat_preferences_overlay_input_ready(struct nk_context *context, if (*armed) return true; const struct nk_mouse_button *left = &context->input.mouse.buttons[NK_BUTTON_LEFT]; - if (!left->down && !left->clicked) *armed = true; + if (!left->down) *armed = true; return false; } diff --git a/src/ui/preferences_overlay.h b/src/ui/preferences/preferences_overlay.h similarity index 100% rename from src/ui/preferences_overlay.h rename to src/ui/preferences/preferences_overlay.h diff --git a/src/ui/preferences/preferences_page_dispatch.c b/src/ui/preferences/preferences_page_dispatch.c new file mode 100644 index 00000000..b50425d6 --- /dev/null +++ b/src/ui/preferences/preferences_page_dispatch.c @@ -0,0 +1,11 @@ +#include "preferences_page_dispatch.h" + +void bongo_cat_preferences_draw_page(BongoCatPreferences *value, + struct nk_context *context) { + switch (value->page) { + case 0: bongo_cat_preferences_page_settings(value->app, context); break; + case 1: bongo_cat_preferences_page_model(value, context); break; + case 2: bongo_cat_preferences_page_shortcuts(value, context); break; + default: bongo_cat_preferences_page_about(value, context); break; + } +} diff --git a/src/ui/preferences/preferences_page_dispatch.h b/src/ui/preferences/preferences_page_dispatch.h new file mode 100644 index 00000000..f69f3463 --- /dev/null +++ b/src/ui/preferences/preferences_page_dispatch.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_PREFERENCES_PAGE_DISPATCH_H +#define BONGO_CAT_PREFERENCES_PAGE_DISPATCH_H + +#include "preferences_state.h" + +void bongo_cat_preferences_draw_page(BongoCatPreferences *value, + struct nk_context *context); + +#endif diff --git a/src/ui/preferences/preferences_pages.c b/src/ui/preferences/preferences_pages.c new file mode 100644 index 00000000..a45b36da --- /dev/null +++ b/src/ui/preferences/preferences_pages.c @@ -0,0 +1,303 @@ +#include "preferences_internal.h" +#include "preferences_state.h" +#include "preferences_theme.h" +#include "preferences_widgets.h" +#include "preferences_notice.h" +#include "ui_tooltip.h" +#include "bongo_cat/audio.h" +#include "bongo_cat/i18n.h" +#include "bongo_cat/preferences.h" +#include "bongo_cat/tray.h" +#include "runtime.h" + +#include +#include + +static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { + return bongo_cat_i18n_get(app->i18n, key, fallback); +} + +static void section_gap(struct nk_context *context, float pixels) { + context->current->layout->at_y += pixels; +} + +static void page_display(BongoCatApp *app, struct nk_context *context) { + BongoCatModelPreferences *model = &app->settings.model; + BongoCatWindowPreferences *window = &app->settings.window; + BongoCatWindowState *window_state = &app->session.window; + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.cat.labels.windowSettings", + "Window"), + BONGO_CAT_PREF_ICON_SECTION_WINDOW); + bongo_cat_ui_question_tooltip(context, tr(app, + "pages.preference.cat.hints.gameInput", "Pet not responding in games?"), + tr(app, "pages.preference.cat.hints.gameInputHelp", + "Try running BongoCat as administrator and setting the game to windowed mode.")); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_PASS_THROUGH); + if (bongo_cat_pref_toggle(context, "pass-through", tr(app, + "composables.useAppMenu.labels.passThrough", "Pass Through"), tr(app, + "pages.preference.cat.hints.passThrough", + "You can also turn this off by right-clicking the tray icon"), + &window->pass_through)) { + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP); + if (bongo_cat_pref_toggle(context, "always-top", tr(app, + "composables.useAppMenu.labels.alwaysOnTop", "Always on Top"), "", + &window->always_on_top)) { + bongo_cat_platform_set_always_on_top(&app->platform, window->always_on_top); + bongo_cat_window_mark_hit_dirty(app); + bongo_cat_window_sync_click_through(app); + } + if (window->pass_through && window->always_on_top && + app->platform.hover_hide_unavailable) { + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + bongo_cat_pref_status(context, "hover-unavailable", tr(app, + "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), tr(app, + "pages.preference.cat.hints.hoverUnavailable", + "Hover hiding is unavailable in the current desktop environment.")); + } + if (window->pass_through && window->always_on_top && + !app->platform.hover_hide_unavailable) { + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); + if (bongo_cat_pref_toggle(context, "hide-on-hover", tr(app, + "pages.preference.cat.labels.hideOnHover", "Hide on Hover"), "", + &window->hide_on_hover)) + bongo_cat_app_update_hover(app, SDL_GetTicksNS()); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_HIDE_FADE); + bongo_cat_pref_float(context, "hide-fade", tr(app, + "pages.preference.cat.labels.hideFadeSeconds", "Fade Duration (s)"), + "", 0.0f, &window->hide_fade_seconds, + BONGO_CAT_MAX_HIDE_FADE_SECONDS, 0.1f, + BONGO_CAT_DEFAULT_HIDE_FADE_SECONDS); + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_KEEP_IN_SCREEN); + if (bongo_cat_pref_toggle(context, "keep-in-screen", tr(app, + "pages.preference.cat.labels.keepInScreen", "Keep on Screen"), "", + &window->keep_in_screen) && window->keep_in_screen) + bongo_cat_window_clamp_to_display(app); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_SOLID_BACKGROUND); + if (bongo_cat_pref_obs_background(context, "obs-background", tr(app, + "pages.preference.cat.labels.obsBackground", "Solid Background"), tr(app, + "pages.preference.cat.hints.obsBackground", "Window capture is black?"), tr(app, + "pages.preference.cat.hints.obsBackgroundHelp", "OBS: enable this option, use " + "the Windows 7 compatibility method, and remove the background with a " + "color key filter."), + &window->obs_background, &window->obs_background_color)) + app->dirty = true; + float old_scale = window_state->scale_percent; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_WINDOW_SIZE); + bongo_cat_pref_float(context, "window-size", tr(app, + "pages.preference.cat.labels.windowSize", "Window Size"), tr(app, + "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), + 10.0f, &window_state->scale_percent, 500.0f, 1.0f, + BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); + if (old_scale != window_state->scale_percent && old_scale > 0.0f) { + float requested_scale = window_state->scale_percent; + window_state->scale_percent = old_scale; + bongo_cat_window_cancel_wheel_animation(app); + bongo_cat_window_set_scale(app, requested_scale); + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_WINDOW_CORNERS); + /* Display the fraction of maximum rounding; keep saved radii in their + original units (percent of the short edge) for existing settings. */ + float corner_roundness = window->corner_radius_percent * 2.0f; + if (bongo_cat_pref_toggle_float(context, "window-corners", tr(app, + "pages.preference.cat.labels.windowCorners", "Window Corners (%)"), + &window->rounded_corners, 0.0f, &corner_roundness, + 100.0f, 1.0f, BONGO_CAT_DEFAULT_WINDOW_CORNER_PERCENT * 2.0f)) { + window->corner_radius_percent = corner_roundness * 0.5f; + app->dirty = true; + bongo_cat_window_mark_hit_dirty(app); + } + float old_opacity = window_state->opacity_percent; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_OPACITY); + bongo_cat_pref_slider(context, "opacity", tr(app, + "pages.preference.cat.labels.opacity", "Opacity"), tr(app, + "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), + 10.0f, &window_state->opacity_percent, 100.0f, 1.0f, + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + if (old_opacity != window_state->opacity_percent) + bongo_cat_window_cancel_wheel_animation(app); + if (old_opacity != window_state->opacity_percent && !app->hover_hidden) { + bongo_cat_app_cancel_hover_fade(app); + bongo_cat_platform_set_opacity(&app->platform, + window_state->opacity_percent / 100.0f); + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_RANDOM_EXPRESSION); + bongo_cat_pref_toggle_float(context, "random-expression", tr(app, + "pages.preference.cat.labels.randomExpression", "Random Expressions"), + &window->random_expression, 1.0f, + &window->random_expression_interval_seconds, 3600.0f, 1.0f, + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + + section_gap(context, 10); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.cat.labels.modelSettings", "Model"), + BONGO_CAT_PREF_ICON_SECTION_MODEL); + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MIRROR); + if (bongo_cat_pref_toggle(context, "mirror", tr(app, + "pages.preference.cat.labels.mirrorMode", "Mirror Mode"), "", + &model->mirror)) { + app->model_pointer_anchor_ready = false; + app->dirty = true; + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MOUSE_MIRROR); + if (bongo_cat_pref_toggle(context, "mouse-mirror", tr(app, + "pages.preference.cat.labels.mouseMirror", "Mouse Mirror"), "", + &model->mouse_mirror)) { + app->pointer_known = false; + app->dirty = true; + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MOUSE_CENTERED); + if (bongo_cat_pref_toggle(context, "mouse-centered", tr(app, + "pages.preference.cat.labels.mouseCentered", "Mouse Centered on Desktop Pet"), + "", &model->mouse_centered)) { + bongo_cat_app_reset_pointer_tracking(app); + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_IGNORE_MOUSE); + if (bongo_cat_pref_toggle(context, "ignore-mouse", tr(app, + "pages.preference.cat.labels.ignoreMouse", "Ignore Mouse Events"), "", + &model->ignore_mouse)) { + app->pointer_known = false; + app->dirty = true; + } + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_MAX_FPS); + model->max_fps = bongo_cat_pref_fps(context, "max-fps", tr(app, + "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), model->max_fps); +} + +static void update_autostart(BongoCatApp *app, bool old_value) { + BongoCatError error = {0}; + if (bongo_cat_platform_set_autostart(app->settings.app.autostart, &error) == BONGO_CAT_OK) return; + app->settings.app.autostart = old_value; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); + bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.general.hints.autostartFailed", + "Unable to update launch-on-startup settings"), true); +} + +#ifdef __APPLE__ +/* System Settings keeps Input Monitoring under Privacy & Security. */ +#define BONGO_CAT_INPUT_MONITORING_SETTINGS_URI \ + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + +void bongo_cat_preferences_input_monitoring_refresh(BongoCatPreferences *value) { + if (!value) return; + /* The refresh points are user actions, not frames: one read here. */ + bool authorized = bongo_cat_platform_input_monitoring_authorized(); + /* A button drawn from the old reading stays on screen until something + repaints, so a reading that changed - or arrives for the first time - + schedules the next frame; an unchanged one must not, or every refresh + point would keep the window rendering. */ + if (value->input_monitoring_valid && + value->input_monitoring_authorized == authorized) return; + value->input_monitoring_authorized = authorized; + value->input_monitoring_valid = true; + value->render_dirty = true; +} + +static void enable_input_monitoring(BongoCatApp *app) { + BongoCatPreferences *value = app->preferences; + if (!value) return; + /* The permission can change while the page is open, so the button reads it + once more before acting: an already granted permission hides the button + instead of asking again or sending the user to System Settings. */ + bongo_cat_preferences_input_monitoring_refresh(value); + if (value->input_monitoring_authorized) return; + /* The system prompt belongs to this button and to a missing permission; a + refusal is an answer, not a failure. */ + (void)bongo_cat_platform_input_monitoring_request(); + /* SDL_OpenURL is the whole entry point, so a failure is where the path has + to be spelled out: the item itself only says when to restart. */ + if (!SDL_OpenURL(BONGO_CAT_INPUT_MONITORING_SETTINGS_URI)) + bongo_cat_preferences_notice_show(app, tr(app, + "pages.preference.general.status.openSettingsFailed", + "Cannot open System Settings. Open Privacy & Security → Input " + "Monitoring."), true); + bongo_cat_preferences_input_monitoring_refresh(value); +} + +static void input_monitoring_section(BongoCatApp *app, + struct nk_context *context) { + /* The page is drawn through the preferences that hold the cached reading; a + page drawn before the first refresh initializes it once, never per frame. */ + BongoCatPreferences *value = app->preferences; + if (!value) return; + if (!value->input_monitoring_valid) + bongo_cat_preferences_input_monitoring_refresh(value); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.permissionsSettings", + "Permissions Settings"), BONGO_CAT_PREF_ICON_SECTION_APPLICATION); + const char *title = tr(app, + "pages.preference.general.labels.inputMonitoringPermission", + "Input Monitoring Permission"); + /* Granted is a reading without an action; only a missing permission offers + the button, and nothing here asks for authorization twice. */ + if (value->input_monitoring_authorized) { + bongo_cat_pref_status(context, "input-monitoring", title, tr(app, + "pages.preference.general.status.authorized", "Authorized")); + return; + } + if (bongo_cat_pref_button(context, "input-monitoring", title, tr(app, + "pages.preference.general.hints.inputMonitoringPermission", + "Restart after granting it."), tr(app, + "pages.preference.general.status.authorize", "Go to Enable"))) + enable_input_monitoring(app); +} +#endif + +static void page_general(BongoCatApp *app, struct nk_context *context) { + BongoCatApplicationPreferences *options = &app->settings.app; + // Keep each option in its own native language so the list is recognizable + // regardless of the language currently used by the settings window. + const char *ui_languages[] = {"简体中文", "繁體中文", "English", + "Français", "Deutsch", "日本語", "한국어", "Português", + "Русский", "Español"}; + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.appSettings", "Application"), + BONGO_CAT_PREF_ICON_SECTION_APPLICATION); + bool old_autostart = options->autostart; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_AUTOSTART); + if (bongo_cat_pref_toggle(context, "autostart", tr(app, + "pages.preference.general.labels.launchOnStartup", "Launch on Startup"), "", + &options->autostart)) update_autostart(app, old_autostart); + section_gap(context, 7); + bongo_cat_pref_section_icon(context, tr(app, + "pages.preference.general.labels.appearanceSettings", "Appearance"), + BONGO_CAT_PREF_ICON_SECTION_APPEARANCE); + section_gap(context, 6); + const int language_to_ui[] = {2, 0, 1, 3, 4, 5, 6, 7, 8, 9}; + const BongoCatLanguage ui_to_language[] = { + BONGO_CAT_LANG_ZH_CN, BONGO_CAT_LANG_ZH_HANT, + BONGO_CAT_LANG_EN_US, BONGO_CAT_LANG_FR_FR, + BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, + BONGO_CAT_LANG_KO_KR, BONGO_CAT_LANG_PT_BR, + BONGO_CAT_LANG_RU_RU, BONGO_CAT_LANG_ES_ES}; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_LANGUAGE); + int selected = bongo_cat_pref_combo(context, + "language", tr(app, "pages.preference.general.labels.language", + "Language"), "", ui_languages, BONGO_CAT_LANG_COUNT, + language_to_ui[options->language]); + options->language = ui_to_language[selected]; + const char *themes[] = { + tr(app, "pages.preference.general.options.auto", "System"), + tr(app, "pages.preference.general.options.lightMode", "Light"), + tr(app, "pages.preference.general.options.darkMode", "Dark")}; + bongo_cat_pref_row_icon(context, BONGO_CAT_PREF_ICON_THEME); + options->theme = (BongoCatTheme)bongo_cat_pref_theme(context, + "theme", tr(app, "pages.preference.general.labels.themeMode", + "Theme"), themes, options->theme); +#ifdef __APPLE__ + section_gap(context, 7); + input_monitoring_section(app, context); +#endif +} + +void bongo_cat_preferences_page_settings(BongoCatApp *app, + struct nk_context *context) { + page_display(app, context); + section_gap(context, 10); + page_general(app, context); +} diff --git a/src/ui/preferences/preferences_present.c b/src/ui/preferences/preferences_present.c new file mode 100644 index 00000000..6a0a37cf --- /dev/null +++ b/src/ui/preferences/preferences_present.c @@ -0,0 +1,110 @@ +#include "preferences_state.h" +#include "preferences_render_internal.h" +#include "preferences_controls.h" +#include "preferences_model_glyphs.h" +#include "ui_animation.h" +#include "ui_paint.h" +#include "bongo_cat/memory_policy.h" + +#include + +void bongo_cat_preferences_render(BongoCatPreferences *value) { + bongo_cat_preferences_release_idle_window(value); + if (!value || !value->window || !value->visible) return; + bongo_cat_preferences_drag_tick(value); + uint64_t now = SDL_GetTicksNS(); + bool raster_due = value->pending_raster_scale > 0.0f && + value->raster_retry_ns <= now; + if (value->render_retry_ns > now || + (!value->render_dirty && value->last_render_ns && !raster_due)) return; + value->render_dirty = false; + value->ui.context.delta_time_seconds = value->last_render_ns ? NK_CLAMP( + .001f, (float)(now - value->last_render_ns) / 1000000000.0f, + 1.0f / 60.0f) : 1.0f / 60.0f; + value->last_render_ns = now; + bongo_cat_preferences_input_end(value); + if (!SDL_GL_MakeCurrent(value->window, value->gl_context)) { + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Preferences GL context could not be activated: %s", SDL_GetError()); + return; + } + value->render_retry_ns = 0; + bool importing = bongo_cat_preferences_import_status( + value->import_dialog, NULL, NULL, NULL); + value->import_render_active = importing; + bool refreshing_models = bongo_cat_app_model_refresh_busy(value->app); + if (!importing && !refreshing_models) { + bongo_cat_preferences_refresh_raster(value); + bongo_cat_preferences_reload_language(value); + } + bool notice_font_reload = false; + for (size_t i = 0; i < sizeof(value->notices) / sizeof(value->notices[0]); ++i) + if (value->notices[i].message[0] && value->notices[i].until_ns > now && + !bongo_cat_preferences_model_glyphs_ready(value, value->notices[i].message)) + notice_font_reload = true; + if (value->font_reload_pending && + (notice_font_reload || (!importing && !refreshing_models))) { + if (value->font_reload_defer_once) { + value->font_reload_defer_once = false; + value->render_dirty = true; + } else if (!bongo_cat_preferences_reload_fonts(value)) { + value->font_reload_pending = false; + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "Deferred preferences font atlas rebuild failed"); + } + } + bongo_cat_preferences_apply_theme(value); + float width = 0.0f, height = 0.0f; + bongo_cat_ui_logical_size(&value->ui, &width, &height); + bongo_cat_ui_paint_begin_frame(&value->ui); + bongo_cat_ui_cursor_begin(&value->ui); + bool dark = bongo_cat_preferences_resolved_theme(value) != 0; + value->ui.frame_building = true; + bool close_requested = bongo_cat_preferences_draw_frame( + value, width, height, dark); + value->ui.frame_building = false; + bongo_cat_preferences_shortcut_smoke(value); + BongoCatUIPalette palette = bongo_cat_ui_palette(dark); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glClearColor( + value->transparent_window ? 0.0f : palette.background.r / 255.0f, + value->transparent_window ? 0.0f : palette.background.g / 255.0f, + value->transparent_window ? 0.0f : palette.background.b / 255.0f, + value->transparent_window ? 0.0f : 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + bool rendered = bongo_cat_ui_render(&value->ui); + bongo_cat_preferences_smoke_frame(value); + if (!rendered) { + /* Keep the last complete front buffer instead of presenting corruption. */ + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + } else if (!SDL_GL_SwapWindow(value->window)) { + value->render_dirty = true; + value->render_retry_ns = now + 1000000000ull; + SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, + "Preferences frame presentation failed: %s", SDL_GetError()); + } else bongo_cat_memory_policy_ui_frame_presented(); + bongo_cat_preferences_record_frame(value); + if (value->shortcut_recording || value->model_load_visual_active || + importing || bongo_cat_pref_controls_animating(&value->ui.context) || + bongo_cat_ui_animations_active(&value->ui.context)) + value->render_dirty = true; + else if (!value->chrome_dragging && !value->live_resize_active) + bongo_cat_ui_trim_idle(&value->ui); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); + bongo_cat_ui_cursor_apply(&value->ui); + if (close_requested) { + bongo_cat_preferences_close(value); + return; + } + if (value->import_requested && !bongo_cat_preferences_import_is_open( + value->import_dialog)) { + value->import_requested = false; + if (!bongo_cat_preferences_import_open(value->import_dialog, + value->window)) + value->render_dirty = true; + } +} diff --git a/src/ui/preferences_render.c b/src/ui/preferences/preferences_render.c similarity index 59% rename from src/ui/preferences_render.c rename to src/ui/preferences/preferences_render.c index 6759b789..0647a392 100644 --- a/src/ui/preferences_render.c +++ b/src/ui/preferences/preferences_render.c @@ -1,41 +1,26 @@ #include "preferences_state.h" +#include "preferences_render_internal.h" +#include "preferences_page_dispatch.h" #include "preferences_controls.h" #include "preferences_notice.h" #include "ui_catime.h" #include "ui_animation.h" +#include "ui_icons.h" #include "ui_paint.h" -#include "bongo_cat/file.h" #include "bongo_cat/memory_policy.h" -#include "bongo_cat/path.h" #include "bongo_cat/tray.h" +#include "update_service.h" #include #include #include -typedef struct RootStyle { - struct nk_vec2 padding; - struct nk_vec2 group_padding; - struct nk_vec2 spacing; - struct nk_style_item fixed; - struct nk_color background; - float group_border; -} RootStyle; -static const char *tr(const BongoCatPreferences *value, const char *key, - const char *fallback) { +typedef struct RootStyle { struct nk_vec2 padding, group_padding, spacing; + struct nk_style_item fixed; struct nk_color background; float group_border; } RootStyle; +static const char *tr(const BongoCatPreferences *value, const char *key, const char *fallback) { return bongo_cat_i18n_get(value->app->i18n, key, fallback); } -static void draw_icon(void *userdata, struct nk_command_buffer *canvas, - int icon, struct nk_rect bounds, struct nk_color color) { - bongo_cat_preferences_icon_draw(userdata, canvas, icon, bounds, color); -} -static void draw_page(BongoCatPreferences *value, struct nk_context *context) { - switch (value->page) { - case 0: bongo_cat_preferences_page_cat(value->app, context); break; - case 1: bongo_cat_preferences_page_general(value->app, context); break; - case 2: bongo_cat_preferences_page_model(value, context); break; - case 3: bongo_cat_preferences_page_shortcuts(value, context); break; - default: bongo_cat_preferences_page_about(value, context); break; - } -} +static void draw_icon(void *userdata, struct nk_command_buffer *canvas, int icon, + struct nk_rect bounds, struct nk_color color) { + bongo_cat_preferences_icon_draw(userdata, canvas, icon, bounds, color); } static RootStyle root_style_save(struct nk_context *context) { RootStyle saved; saved.padding = context->style.window.padding; @@ -70,16 +55,18 @@ static void root_style_restore(struct nk_context *context, static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, float width, float height, bool dark) { static const char *page_ids[] = { - "page-cat", "page-general", "page-model", "page-shortcuts", "page-about"}; + "page-settings", "page-model", "page-shortcuts", "page-about"}; + static const int menu_icons[] = { + BONGO_CAT_UI_ICON_CAT, BONGO_CAT_UI_ICON_WAND, + BONGO_CAT_UI_ICON_KEYBOARD, BONGO_CAT_UI_ICON_HEART}; const char *menus[] = { - tr(value, "pages.preference.cat.title", "Cat"), - tr(value, "pages.preference.general.title", "General"), + tr(value, "pages.preference.cat.title", "Preferences"), tr(value, "pages.preference.model.title", "Model"), tr(value, "pages.preference.shortcut.title", "Shortcuts"), - tr(value, "native.support.title", "Support the Dev Team")}; + tr(value, "native.support.title", "Support the Developer")}; bool modal = bongo_cat_preferences_remove_dialog_active(value->app) || bongo_cat_preferences_behavior_dialog_active(value); - BongoCatUIPalette p = bongo_cat_ui_palette(dark); + const bool native_chrome = bongo_cat_ui_native_chrome(); BongoCatUIPalette p = bongo_cat_ui_palette(dark); bongo_cat_ui_shell_draw(context, width, height, dark, !value->transparent_window); float sidebar = bongo_cat_ui_sidebar_width(width); @@ -93,18 +80,26 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool title_clicked = false; if (nk_group_begin(context, "preferences-sidebar", NK_WINDOW_NO_SCROLLBAR)) { bongo_cat_ui_header(context, "BongoCat", - value->ui.caption_font, value->logo_texture, &title_clicked, !modal, dark); + value->ui.heading_font, value->logo_texture, &title_clicked, !modal, dark, native_chrome); if (title_clicked && !SDL_OpenURL("https://bongocat.pet")) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Cannot open website: %s", SDL_GetError()); bongo_cat_ui_set_icons(draw_icon, value); - bongo_cat_ui_tabs(context, menus, 5, &value->page, !modal, dark, - interior_height, + BongoCatUpdateSnapshot update_snapshot; + bongo_cat_update_snapshot(value->app->update, &update_snapshot); + bool about_badge = update_snapshot.status == BONGO_CAT_UPDATE_AVAILABLE || + (update_snapshot.status == BONGO_CAT_UPDATE_ERROR && + update_snapshot.release.version[0]); + bongo_cat_ui_tabs(context, menus, menu_icons, 4, &value->page, + !modal, dark, interior_height, about_badge, draw_icon, value); if (!value->page_seen) { value->page_seen = true; value->last_page = value->page; } else if (value->last_page != value->page) { + bongo_cat_preferences_page_cache_clear(value, + value->last_page, value->page); value->last_page = value->page; value->page_transition_ns = SDL_GetTicksNS(); + value->render_dirty = true; } nk_group_end(context); } @@ -112,7 +107,14 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bool close_requested = false; if (nk_group_begin(context, "preferences-content", NK_WINDOW_NO_SCROLLBAR)) { close_requested = bongo_cat_ui_content_header(context, - menus[value->page], value->page, !modal, dark); + menus[value->page], menu_icons[value->page], !modal, dark, + native_chrome); + if (close_requested) { + /* Scrolled cards can overlap the header's hit area despite clipping. */ + nk_group_end(context); + nk_layout_row_end(context); + return true; + } float body_height = interior_height - BONGO_CAT_UI_HEADER_HEIGHT; nk_layout_row_dynamic(context, NK_MAX(120.0f, body_height), 1); struct nk_rect body_bounds = nk_widget_bounds(context); @@ -130,14 +132,11 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, context->style.window.group_padding = nk_vec2(24, 16 + 6.0f * (1.0f - page_progress)); context->style.window.spacing = nk_vec2(10, 10); - int scroll_page = NK_CLAMP(0, value->page, 4); - bool scroll_animating = fabsf(value->scroll_current[scroll_page] - - value->scroll_target[scroll_page]) > .5f; + int scroll_page = NK_CLAMP(0, value->page, 3); + bool scroll_animating = fabsf(value->scroll_current[scroll_page] - value->scroll_target[scroll_page]) > .5f; struct nk_style_scrollbar saved_scrollv = context->style.scrollv; - struct nk_rect scrollbar_hit = nk_rect(body_bounds.x + body_bounds.w - 14, - body_bounds.y, 14, body_bounds.h); - bool scrollbar_hover = !modal && nk_input_is_mouse_hovering_rect( - &context->input, scrollbar_hit); + struct nk_rect scrollbar_hit = nk_rect(body_bounds.x + body_bounds.w - 14, body_bounds.y, 14, body_bounds.h); + bool scrollbar_hover = !modal && nk_input_is_mouse_hovering_rect(&context->input, scrollbar_hit); context->style.scrollv.padding.x = scrollbar_hover ? 0.0f : 2.0f; context->style.scrollv.cursor_hover = nk_style_item_color(p.accent); context->style.scrollv.cursor_active = nk_style_item_color(p.accent); @@ -154,13 +153,12 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, context->input.mouse.scroll_delta = nk_vec2(0, 0); } if (nk_group_begin(context, page_ids[value->page], 0)) { - draw_page(value, context); + bongo_cat_preferences_draw_page(value, context); float wheel = context->input.mouse.scroll_delta.y; struct nk_panel *layout = context->current->layout; - float maximum = layout ? NK_MAX(0.0f, - layout->at_y + layout->row.height - layout->bounds.y - layout->bounds.h) : 0.0f; - if (wheel != 0.0f) - context->style.scrollv.cursor_normal = nk_style_item_color(p.accent); + float maximum = layout ? NK_MAX(0.0f, layout->at_y + layout->row.height - layout->bounds.y - layout->bounds.h) : 0.0f; + if (wheel != 0.0f) context->style.scrollv.cursor_normal = + nk_style_item_color(p.accent); context->input.mouse.scroll_delta.y = 0; nk_group_end(context); context->style.scrollv = saved_scrollv; @@ -172,17 +170,18 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, value->scroll_current[scroll_page] = actual; value->scroll_target[scroll_page] = actual; } - value->scroll_target[scroll_page] = NK_CLAMP(0.0f, - value->scroll_target[scroll_page], maximum); + value->scroll_target[scroll_page] = NK_CLAMP(0.0f, value->scroll_target[scroll_page], maximum); if (wheel != 0.0f) value->scroll_target[scroll_page] = NK_CLAMP(0.0f, - actual - wheel * 72.0f, maximum); + value->scroll_target[scroll_page] - wheel * 72.0f, maximum); else if (fabsf(actual - value->scroll_current[scroll_page]) > 2.0f) value->scroll_target[scroll_page] = NK_CLAMP(0.0f, actual, maximum); - float next = actual + (value->scroll_target[scroll_page] - actual) * .24f; - if (fabsf(next - value->scroll_target[scroll_page]) < .5f) - next = value->scroll_target[scroll_page]; + float blend = 1.0f - expf(-context->delta_time_seconds / .035f); + float next = actual + + (value->scroll_target[scroll_page] - actual) * blend; + if (fabsf(next - value->scroll_target[scroll_page]) < .5f) next = + value->scroll_target[scroll_page]; value->scroll_current[scroll_page] = NK_CLAMP(0.0f, next, maximum); bool still_animating = fabsf(value->scroll_current[scroll_page] - value->scroll_target[scroll_page]) > .5f; @@ -208,9 +207,11 @@ static bool draw_shell(BongoCatPreferences *value, struct nk_context *context, bongo_cat_preferences_notice_draw(value, context, width, height); bongo_cat_preferences_remove_dialog_draw(value->app, context); bongo_cat_preferences_behavior_dialog_draw(value, context); + bongo_cat_about_overlays(value, context); return close_requested; } -static bool draw_frame(BongoCatPreferences *value, float width, float height, +bool bongo_cat_preferences_draw_frame(BongoCatPreferences *value, + float width, float height, bool dark) { struct nk_context *context = &value->ui.context; RootStyle saved = root_style_save(context); @@ -224,86 +225,3 @@ static bool draw_frame(BongoCatPreferences *value, float width, float height, root_style_restore(context, &saved); return close_requested; } - -void bongo_cat_preferences_record_frame(BongoCatPreferences *value) { - if (!value->app->smoke_frame_series) return; - char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), value->app->data_root, - "preferences-frames.csv"); - FILE *file = bongo_cat_file_open(path, "ab"); - if (!file) return; - fprintf(file, "%llu\n", (unsigned long long)SDL_GetTicksNS()); - fclose(file); -} - -void bongo_cat_preferences_render(BongoCatPreferences *value) { - if (!value || !value->window) return; - bongo_cat_preferences_drag_tick(value); - uint64_t now = SDL_GetTicksNS(); - bool raster_due = value->pending_raster_scale > 0.0f && - value->raster_retry_ns <= now; - if (value->render_retry_ns > now || - (!value->render_dirty && value->last_render_ns && !raster_due)) return; - value->render_dirty = false; - value->last_render_ns = now; - bongo_cat_preferences_input_end(value); - if (!SDL_GL_MakeCurrent(value->window, value->gl_context)) { - value->render_dirty = true; - value->render_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences GL context could not be activated: %s", SDL_GetError()); - return; - } - value->render_retry_ns = 0; - bongo_cat_preferences_refresh_raster(value); - bongo_cat_preferences_reload_language(value); - if (value->font_reload_pending && - !bongo_cat_preferences_reload_fonts(value)) { - value->font_reload_pending = false; - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, - "Deferred preferences font atlas rebuild failed"); - } - bongo_cat_preferences_apply_theme(value); - float width = 0.0f, height = 0.0f; - bongo_cat_ui_logical_size(&value->ui, &width, &height); - bongo_cat_ui_paint_begin_frame(&value->ui); - bongo_cat_ui_cursor_begin(&value->ui); - bool dark = bongo_cat_preferences_resolved_theme(value) != 0; - value->ui.frame_building = true; - bool close_requested = draw_frame(value, width, height, dark); - value->ui.frame_building = false; - bongo_cat_preferences_shortcut_smoke(value); - BongoCatUIPalette palette = bongo_cat_ui_palette(dark); - glDisable(GL_SCISSOR_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glClearColor(value->transparent_window ? 0.0f : palette.background.r / 255.0f, - value->transparent_window ? 0.0f : palette.background.g / 255.0f, - value->transparent_window ? 0.0f : palette.background.b / 255.0f, - value->transparent_window ? 0.0f : 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - bongo_cat_ui_render(&value->ui); - bongo_cat_preferences_smoke_frame(value); - if (!SDL_GL_SwapWindow(value->window)) { - value->render_dirty = true; - value->render_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences frame presentation failed: %s", SDL_GetError()); - } else bongo_cat_memory_policy_ui_frame_presented(); - bongo_cat_preferences_record_frame(value); - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - bongo_cat_ui_cursor_apply(&value->ui); - if (close_requested) { - bongo_cat_preferences_close(value); - return; - } - if (value->import_requested && !bongo_cat_preferences_import_is_open( - value->import_dialog)) { - value->import_requested = false; - if (!bongo_cat_preferences_import_open(value->import_dialog, value->window)) - value->render_dirty = true; - } - if (value->shortcut_recording || - bongo_cat_pref_controls_animating(&value->ui.context) || - bongo_cat_ui_animations_active(&value->ui.context)) - value->render_dirty = true; -} diff --git a/src/ui/preferences/preferences_render_internal.h b/src/ui/preferences/preferences_render_internal.h new file mode 100644 index 00000000..bb471857 --- /dev/null +++ b/src/ui/preferences/preferences_render_internal.h @@ -0,0 +1,9 @@ +#ifndef BONGO_CAT_PREFERENCES_RENDER_INTERNAL_H +#define BONGO_CAT_PREFERENCES_RENDER_INTERNAL_H + +#include "bongo_cat/preferences.h" + +bool bongo_cat_preferences_draw_frame(BongoCatPreferences *value, + float width, float height, bool dark); + +#endif diff --git a/src/ui/preferences/preferences_row_icons.c b/src/ui/preferences/preferences_row_icons.c new file mode 100644 index 00000000..9aef286f --- /dev/null +++ b/src/ui/preferences/preferences_row_icons.c @@ -0,0 +1,188 @@ +#include "preferences_icons_internal.h" + +static void line(struct nk_command_buffer *canvas, struct nk_rect b, + float x1, float y1, float x2, float y2, struct nk_color color) { + nk_stroke_line(canvas, b.x + x1, b.y + y1, b.x + x2, b.y + y2, + 1.5f, color); +} + +static void dot(struct nk_command_buffer *canvas, struct nk_rect b, + float x, float y, float size, struct nk_color color) { + nk_fill_circle(canvas, nk_rect(b.x + x - size * .5f, + b.y + y - size * .5f, size, size), color); +} + +static void multiple_models(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 11, 11), 2, 1.5f, color); + nk_stroke_rect(c, nk_rect(b.x + 6, b.y + 6, 11, 11), 2, 1.5f, color); + dot(c, b, 4.5f, 4.5f, 2.0f, color); + dot(c, b, 9.5f, 9.5f, 2.0f, color); +} + +static void pass_through(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 3, 12, 12), 2, 1.5f, color); + line(c, b, 5, 9, 17, 9, color); + line(c, b, 14, 6, 17, 9, color); + line(c, b, 14, 12, 17, 9, color); +} + +static void always_on_top(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 3, 15, 15, 15, color); + line(c, b, 5, 12, 13, 12, color); + line(c, b, 9, 12, 9, 2, color); + line(c, b, 5, 6, 9, 2, color); + line(c, b, 13, 6, 9, 2, color); +} + +static void keep_in_screen(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 2, 7, 2, 2, color); line(c, b, 2, 2, 7, 2, color); + line(c, b, 11, 2, 16, 2, color); line(c, b, 16, 2, 16, 7, color); + line(c, b, 2, 11, 2, 16, color); line(c, b, 2, 16, 7, 16, color); + line(c, b, 11, 16, 16, 16, color); line(c, b, 16, 16, 16, 11, color); +} + +static void solid_background(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 2, 1.5f, color); + nk_fill_rect(c, nk_rect(b.x + 5, b.y + 5, 8, 8), 2, color); +} + +static void window_size(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 3, 15, 15, 3, color); + line(c, b, 3, 10, 3, 15, color); line(c, b, 3, 15, 8, 15, color); + line(c, b, 10, 3, 15, 3, color); line(c, b, 15, 3, 15, 8, color); +} + +static void window_corners(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 5, 1.5f, color); +} + +static void opacity(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 9, 1, 4, 9, color); line(c, b, 9, 1, 14, 9, color); + nk_stroke_circle(c, nk_rect(b.x + 4, b.y + 6, 10, 10), 1.5f, color); + line(c, b, 6, 12, 12, 12, color); +} + +static void random_expression(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 2, b.y + 2, 14, 14), 3, 1.5f, color); + dot(c, b, 6, 6, 2.5f, color); dot(c, b, 9, 9, 2.5f, color); + dot(c, b, 12, 12, 2.5f, color); +} + +static void mirror(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 9, 1, 9, 17, color); + nk_stroke_triangle(c, b.x + 2, b.y + 9, b.x + 7, b.y + 4, + b.x + 7, b.y + 14, 1.5f, color); + nk_stroke_triangle(c, b.x + 16, b.y + 9, b.x + 11, b.y + 4, + b.x + 11, b.y + 14, 1.5f, color); +} + +static void mouse_mirror(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 1, b.y + 2, b.x + 2, b.y + 14, + b.x + 7, b.y + 10, 1.5f, color); + nk_stroke_triangle(c, b.x + 17, b.y + 2, b.x + 16, b.y + 14, + b.x + 11, b.y + 10, 1.5f, color); + line(c, b, 9, 2, 9, 6, color); line(c, b, 9, 9, 9, 13, color); +} + +static void mouse_centered(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 3, b.y + 3, 12, 12), 1.5f, color); + line(c, b, 9, 0, 9, 6, color); line(c, b, 9, 12, 9, 18, color); + line(c, b, 0, 9, 6, 9, color); line(c, b, 12, 9, 18, 9, color); + dot(c, b, 9, 9, 2.5f, color); +} + +static void ignore_mouse(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 2, b.y + 1, b.x + 3, b.y + 15, + b.x + 8, b.y + 11, 1.5f, color); + line(c, b, 7, 10, 11, 16, color); + line(c, b, 1, 17, 17, 1, color); +} + +static void max_fps(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 1, b.y + 2, 16, 14), 2, 1.5f, color); + line(c, b, 4, 13, 4, 10, color); + line(c, b, 8, 13, 8, 7, color); + line(c, b, 12, 13, 12, 4, color); + line(c, b, 3, 4, 15, 4, color); +} + +static void autostart(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_triangle(c, b.x + 9, b.y + 1, b.x + 4, b.y + 9, + b.x + 14, b.y + 9, 1.5f, color); + line(c, b, 6, 9, 6, 14, color); line(c, b, 12, 9, 12, 14, color); + line(c, b, 6, 14, 12, 14, color); + line(c, b, 8, 15, 8, 18, color); line(c, b, 10, 15, 10, 18, color); +} + +static void language(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 1, b.y + 1, 16, 16), 1.5f, color); + line(c, b, 1, 9, 17, 9, color); line(c, b, 9, 1, 9, 17, color); + line(c, b, 4, 4, 14, 4, color); line(c, b, 4, 14, 14, 14, color); +} + +static void theme(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_rect(c, nk_rect(b.x + 2, b.y + 10, 7, 6), 2, 1.5f, color); + line(c, b, 7, 11, 14, 2, color); line(c, b, 10, 12, 16, 4, color); + line(c, b, 14, 2, 16, 4, color); line(c, b, 3, 17, 2, 18, color); +} + +static void shortcut_visibility(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 1, 9, 5, 5, color); line(c, b, 5, 5, 9, 4, color); + line(c, b, 9, 4, 13, 5, color); line(c, b, 13, 5, 17, 9, color); + line(c, b, 1, 9, 5, 13, color); line(c, b, 5, 13, 9, 14, color); + line(c, b, 9, 14, 13, 13, color); line(c, b, 13, 13, 17, 9, color); + dot(c, b, 9, 9, 4, color); +} + +static void shortcut_preferences(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + line(c, b, 2, 4, 16, 4, color); dot(c, b, 6, 4, 4, color); + line(c, b, 2, 9, 16, 9, color); dot(c, b, 12, 9, 4, color); + line(c, b, 2, 14, 16, 14, color); dot(c, b, 8, 14, 4, color); +} + +static void hide_fade(struct nk_command_buffer *c, struct nk_rect b, + struct nk_color color) { + nk_stroke_circle(c, nk_rect(b.x + 1, b.y + 3, 12, 12), 1.5f, color); + line(c, b, 7, 6, 7, 9, color); + line(c, b, 7, 9, 9, 10, color); + struct nk_color trail = color; + trail.a = (nk_byte)((unsigned int)color.a * 3 / 5); + line(c, b, 15, 5, 15, 13, trail); + trail.a = (nk_byte)((unsigned int)color.a * 3 / 10); + line(c, b, 18, 7, 18, 11, trail); +} + +bool bongo_cat_pref_row_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, struct nk_color color) { + typedef void (*Draw)(struct nk_command_buffer *, struct nk_rect, + struct nk_color); + static const Draw draws[] = {multiple_models, pass_through, always_on_top, + keep_in_screen, solid_background, window_size, window_corners, opacity, + random_expression, mirror, mouse_mirror, mouse_centered, ignore_mouse, + max_fps, autostart, language, theme, shortcut_visibility, + shortcut_preferences, hide_fade}; + int index = icon - BONGO_CAT_PREF_ICON_MULTIPLE_MODELS; + int count = (int)(sizeof(draws) / sizeof(draws[0])); + if (index < 0 || index >= count) return false; + draws[index](canvas, bounds, color); + return true; +} diff --git a/src/ui/preferences_scale.c b/src/ui/preferences/preferences_scale.c similarity index 66% rename from src/ui/preferences_scale.c rename to src/ui/preferences/preferences_scale.c index a3e85814..0c5ee396 100644 --- a/src/ui/preferences_scale.c +++ b/src/ui/preferences/preferences_scale.c @@ -1,14 +1,10 @@ #include "preferences_state.h" +#include "preferences_fonts.h" +#include "preferences_gl.h" #include "ui_catime.h" -#include "ui_font.h" -#include "ui_font_atlas.h" -#include "preferences_model_glyphs.h" -#include "ui_paint.h" -#include "bongo_cat/i18n.h" #include "bongo_cat/memory_policy.h" #include #include -#include #define PREF_WIDTH 900.0f #define PREF_HEIGHT 680.0f @@ -16,22 +12,6 @@ #define PREF_MIN_HEIGHT 560.0f #define PREF_SCREEN_MARGIN 48.0f -typedef struct PreferenceFonts { - char body_path[BONGO_CAT_PATH_CAP]; - char body_fallback_path[BONGO_CAT_PATH_CAP]; - char body_korean_fallback_path[BONGO_CAT_PATH_CAP]; - char heading_path[BONGO_CAT_PATH_CAP]; - char heading_fallback_path[BONGO_CAT_PATH_CAP]; - char heading_korean_fallback_path[BONGO_CAT_PATH_CAP]; - const char *body; - const char *body_fallback; - const char *body_korean_fallback; - const char *heading; - const char *heading_fallback; - const char *heading_korean_fallback; - const nk_rune *ranges; -} PreferenceFonts; - static SDL_DisplayID window_display(SDL_Window *window) { SDL_DisplayID display = window ? SDL_GetDisplayForWindow(window) : 0; return display ? display : SDL_GetPrimaryDisplay(); @@ -73,33 +53,6 @@ static void fit_position(SDL_Window *window, SDL_DisplayID display, SDL_SetWindowPosition(window, next_x, next_y); } -static void font_paths(BongoCatPreferences *value, PreferenceFonts *fonts) { - memset(fonts, 0, sizeof(*fonts)); - fonts->body = bongo_cat_ui_system_font(fonts->body_path, - sizeof(fonts->body_path), false); - fonts->body_fallback = bongo_cat_ui_system_font(fonts->body_fallback_path, - sizeof(fonts->body_fallback_path), true); - fonts->body_korean_fallback = bongo_cat_ui_system_korean_font( - fonts->body_korean_fallback_path, - sizeof(fonts->body_korean_fallback_path)); - fonts->heading = bongo_cat_ui_system_heading_font(fonts->heading_path, - sizeof(fonts->heading_path), false); - fonts->heading_fallback = bongo_cat_ui_system_heading_font( - fonts->heading_fallback_path, - sizeof(fonts->heading_fallback_path), true); - fonts->heading_korean_fallback = bongo_cat_ui_system_korean_heading_font( - fonts->heading_korean_fallback_path, - sizeof(fonts->heading_korean_fallback_path)); - if (!fonts->heading) fonts->heading = fonts->body; - if (!fonts->heading_fallback) fonts->heading_fallback = fonts->body_fallback; - if (!value->app->i18n) return; - bongo_cat_i18n_glyph_ranges(value->app->i18n, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - bongo_cat_preferences_model_glyphs(value->app, value->glyph_ranges, - sizeof(value->glyph_ranges) / sizeof(value->glyph_ranges[0])); - fonts->ranges = value->glyph_ranges; -} - static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, const SDL_Point *point, void *data) { BongoCatPreferences *value = data; @@ -109,6 +62,13 @@ static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, int width = 0, height = 0; SDL_GetWindowSize(window, &width, &height); int edge = scaled(7.0f, scale); +#ifdef __APPLE__ + /* AppKit owns the traffic-light controls in the native titlebar. Do not + classify their hit area as a resize corner from SDL's borderless hit + test callback. */ + if (point->x < scaled(100.0f, scale) && point->y < scaled(40.0f, scale)) + return SDL_HITTEST_NORMAL; +#endif bool left = point->x < edge, right = point->x >= width - edge; bool top = point->y < edge, bottom = point->y >= height - edge; if (top && left) return SDL_HITTEST_RESIZE_TOPLEFT; @@ -124,32 +84,21 @@ static SDL_HitTestResult SDLCALL preference_hit_test(SDL_Window *window, return SDL_HITTEST_NORMAL; } -static bool attach_gl_context(BongoCatPreferences *value) { - value->gl_context = value->app->gl_context; - if (SDL_GL_MakeCurrent(value->window, value->gl_context)) return true; - SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); - value->gl_context = SDL_GL_CreateContext(value->window); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); - value->owns_gl_context = value->gl_context != NULL; - return value->gl_context && - SDL_GL_MakeCurrent(value->window, value->gl_context); -} - static void discard_window(BongoCatPreferences *value) { - if (value->owns_gl_context && value->gl_context) - SDL_GL_DestroyContext(value->gl_context); + bongo_cat_preferences_gl_destroy(value); if (value->window) SDL_DestroyWindow(value->window); value->window = NULL; - value->gl_context = NULL; - value->owns_gl_context = false; value->transparent_window = false; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); } static bool create_window(BongoCatPreferences *value, int width, int height, SDL_WindowFlags flags, bool transparent) { - value->window = SDL_CreateWindow(BONGO_CAT_SETTINGS_WINDOW_TITLE, + if (SDL_getenv("BONGO_CAT_TEST_DISABLE_PREFERENCES_TRANSPARENCY")) + transparent = false; + const char *title = bongo_cat_i18n_get(value->app->i18n, + "native.preferencesWindowTitle", "BongoCat - Settings"); + value->window = SDL_CreateWindow(title, width, height, flags | (transparent ? SDL_WINDOW_TRANSPARENT : 0)); value->transparent_window = value->window && transparent && @@ -173,9 +122,11 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { "Transparent preferences window unavailable: %s", SDL_GetError()); if (!create_window(value, width, height, flags, false)) return false; } + bongo_cat_platform_configure_preferences_window(value->window); SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); display = window_display(value->window); float raster_scale = 1.0f; bongo_cat_ui_query_window_scale(value->window, &layout_scale, &raster_scale); @@ -186,7 +137,8 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_SetWindowPosition(value->window, SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); - bool context_ready = attach_gl_context(value); + bongo_cat_platform_configure_preferences_window(value->window); + bool context_ready = bongo_cat_preferences_gl_create(value); if (!context_ready && value->transparent_window) { SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Transparent preferences OpenGL context unavailable: %s", @@ -198,11 +150,12 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL_WINDOWPOS_CENTERED_DISPLAY(display)); SDL_SyncWindow(value->window); - context_ready = attach_gl_context(value); + bongo_cat_platform_configure_preferences_window(value->window); + context_ready = bongo_cat_preferences_gl_create(value); } if (!context_ready) return false; - PreferenceFonts fonts; - font_paths(value, &fonts); + BongoCatPreferenceFonts fonts; + bongo_cat_preferences_fonts_resolve(value, &fonts); BongoCatError error = {0}; if (!bongo_cat_ui_init(&value->ui, value->window, fonts.body, fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, @@ -217,7 +170,7 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { bongo_cat_preferences_assets_load(value); bongo_cat_memory_policy_ui_loaded(); value->style_theme = -1; - value->font_language = value->app->config.app.language; + value->font_language = value->app->settings.app.language; int pixel_width = 0, pixel_height = 0; SDL_GetWindowSizeInPixels(value->window, &pixel_width, &pixel_height); SDL_Log("Preferences GL ready: dedicated=%d transparent=%d pixels=%dx%d layout=%.2f raster=%.2f", @@ -233,48 +186,6 @@ bool bongo_cat_preferences_open_window(BongoCatPreferences *value) { return true; } -bool bongo_cat_preferences_reload_fonts(BongoCatPreferences *value) { - if (!value || !value->ui_initialized) return false; - if (value->ui.frame_building) { - value->font_reload_pending = true; - value->render_dirty = true; - return true; - } - PreferenceFonts fonts; - font_paths(value, &fonts); - bool reloaded = bongo_cat_ui_font_atlas_reload(&value->ui, fonts.body, - fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, - fonts.heading_fallback, fonts.heading_korean_fallback, fonts.ranges, - value->ui.raster_scale); - if (reloaded) value->font_reload_pending = false; - return reloaded; -} - -bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value) { - if (!value || value->pending_raster_scale <= 0.0f) return true; - uint64_t now = SDL_GetTicksNS(); - if (value->raster_retry_ns > now) return false; - float raster_scale = value->pending_raster_scale; - PreferenceFonts fonts; - font_paths(value, &fonts); - if (!bongo_cat_ui_font_atlas_reload(&value->ui, fonts.body, - fonts.body_fallback, fonts.body_korean_fallback, fonts.heading, - fonts.heading_fallback, fonts.heading_korean_fallback, fonts.ranges, - raster_scale)) { - value->raster_retry_ns = now + 1000000000ull; - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, - "Preferences font atlas could not be rebuilt for %.2fx", raster_scale); - return false; - } - value->pending_raster_scale = 0.0f; - value->raster_retry_ns = 0; - bongo_cat_ui_paint_destroy(&value->ui); - bongo_cat_preferences_model_cover_cache_clear(value->app); - bongo_cat_preferences_assets_clear(value); - bongo_cat_preferences_assets_load(value); - return true; -} - bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, const SDL_Event *event) { if (!value || !value->window || !event || @@ -312,6 +223,7 @@ bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, SDL_SetWindowSize(value->window, width, height); fit_position(value->window, display, width, height); SDL_SyncWindow(value->window); + bongo_cat_platform_configure_preferences_window(value->window); value->live_resize_rendering = was_rendering; SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); value->render_dirty = true; diff --git a/src/ui/preferences/preferences_scrollbar.c b/src/ui/preferences/preferences_scrollbar.c new file mode 100644 index 00000000..be1c9933 --- /dev/null +++ b/src/ui/preferences/preferences_scrollbar.c @@ -0,0 +1,130 @@ +#include "preferences_scrollbar.h" + +#include "ui_backend.h" + +enum { SCROLLBAR_WIDTH = 10, SCROLLBAR_RESERVE = 14 }; +static const float SCROLLBAR_WHEEL_STEP = 48.0f; + +static struct nk_color with_opacity(struct nk_color color, float opacity) { + color.a = (nk_byte)(color.a * NK_CLAMP(0.0f, opacity, 1.0f) + .5f); + return color; +} + +static void draw_track(struct nk_command_buffer *canvas, struct nk_rect bounds, + const struct nk_style_item *item, const struct nk_style_scrollbar *style, + float opacity) { + struct nk_color tint = with_opacity(nk_rgba(255, 255, 255, 255), opacity); + switch (item->type) { + case NK_STYLE_ITEM_IMAGE: + nk_draw_image(canvas, bounds, &item->data.image, tint); + break; + case NK_STYLE_ITEM_NINE_SLICE: + nk_draw_nine_slice(canvas, bounds, &item->data.slice, tint); + break; + case NK_STYLE_ITEM_COLOR: + nk_fill_rect(canvas, bounds, style->rounding, + with_opacity(item->data.color, opacity)); + if (style->border > 0.0f) + nk_stroke_rect(canvas, bounds, style->rounding, style->border, + with_opacity(style->border_color, opacity)); + break; + } +} + +bool bongo_cat_preferences_scrollbar_needed( + struct nk_rect viewport, float content_height) { + return content_height > viewport.h; +} + +float bongo_cat_preferences_scrollbar_content_width( + struct nk_rect viewport, float content_height) { + return viewport.w - (bongo_cat_preferences_scrollbar_needed( + viewport, content_height) ? SCROLLBAR_RESERVE : 0.0f); +} + +void bongo_cat_preferences_scrollbar_reset( + BongoCatPreferencesScrollbar *scrollbar) { + if (!scrollbar) return; + scrollbar->grab_y = 0.0f; + scrollbar->dragging = false; +} + +BongoCatPreferencesScrollbarResult bongo_cat_preferences_scrollbar_draw( + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect viewport, float content_height, float offset, + BongoCatPreferencesScrollbar *scrollbar, struct nk_color cursor_color, + float opacity, bool enabled) { + BongoCatPreferencesScrollbarResult result = { + .offset = offset, + .scrollable = bongo_cat_preferences_scrollbar_needed( + viewport, content_height), + .changed = false}; + if (!context || !canvas || !scrollbar) return result; + if (!result.scrollable) { + bongo_cat_preferences_scrollbar_reset(scrollbar); + result.changed = offset != 0.0f; + result.offset = 0.0f; + return result; + } + + const struct nk_style_scrollbar *style = &context->style.scrollv; + struct nk_rect track = nk_rect(viewport.x + viewport.w - SCROLLBAR_WIDTH, + viewport.y, SCROLLBAR_WIDTH, viewport.h); + float initial = offset; + float maximum = content_height - viewport.h; + offset = NK_CLAMP(0.0f, offset, maximum); + bool hover = enabled && nk_input_is_mouse_hovering_rect( + &context->input, track); + if (!enabled) scrollbar->dragging = false; + float padding_x = hover || scrollbar->dragging ? 0.0f : style->padding.x; + float inset_y = style->border + style->padding.y; + float cursor_height = viewport.h / content_height * viewport.h - + inset_y * 2.0f; + struct nk_rect cursor = nk_rect(track.x + style->border + padding_x, + track.y + offset / content_height * track.h + inset_y, + track.w - (style->border + padding_x) * 2.0f, + NK_MAX(0.0f, cursor_height)); + + struct nk_mouse_button *left = &context->input.mouse.buttons[NK_BUTTON_LEFT]; + if (!left->down) scrollbar->dragging = false; + if (enabled && left->clicked && NK_INBOX(left->clicked_pos.x, + left->clicked_pos.y, cursor.x, cursor.y, cursor.w, cursor.h)) { + scrollbar->dragging = true; + scrollbar->grab_y = left->clicked_pos.y - cursor.y; + } else if (enabled && left->clicked && hover) { + offset += left->clicked_pos.y < cursor.y ? -viewport.h : viewport.h; + offset = NK_CLAMP(0.0f, offset, maximum); + } + if (enabled && scrollbar->dragging && left->down) { + float travel = NK_MAX(1.0f, track.h - inset_y * 2.0f - cursor.h); + float cursor_y = context->input.mouse.pos.y - track.y - inset_y - + scrollbar->grab_y; + offset = NK_CLAMP(0.0f, cursor_y / travel * maximum, maximum); + } + bool viewport_hover = enabled && nk_input_is_mouse_hovering_rect( + &context->input, viewport); + if (viewport_hover && context->input.mouse.scroll_delta.y != 0.0f) + offset = NK_CLAMP(0.0f, offset - context->input.mouse.scroll_delta.y * + SCROLLBAR_WHEEL_STEP, maximum); + + bool active = hover || scrollbar->dragging; + const struct nk_style_item *track_style = scrollbar->dragging ? + &style->active : hover ? &style->hover : &style->normal; + draw_track(canvas, track, track_style, style, opacity); + cursor.x = track.x + style->border + + (active ? 0.0f : style->padding.x); + cursor.w = track.w - (cursor.x - track.x) * 2.0f; + cursor.y = track.y + offset / content_height * track.h + inset_y; + nk_fill_rect(canvas, cursor, style->rounding_cursor, + with_opacity(cursor_color, opacity)); + if (style->border_cursor > 0.0f) + nk_stroke_rect(canvas, cursor, style->rounding_cursor, + style->border_cursor, + with_opacity(style->cursor_border_color, opacity)); + if (hover) bongo_cat_ui_cursor_hover_rect(context, track, + BONGO_CAT_UI_CURSOR_POINTER); + + result.offset = offset; + result.changed = offset != initial; + return result; +} diff --git a/src/ui/preferences/preferences_scrollbar.h b/src/ui/preferences/preferences_scrollbar.h new file mode 100644 index 00000000..c42fe63b --- /dev/null +++ b/src/ui/preferences/preferences_scrollbar.h @@ -0,0 +1,31 @@ +#ifndef BONGO_CAT_PREFERENCES_SCROLLBAR_H +#define BONGO_CAT_PREFERENCES_SCROLLBAR_H + +#include "nuklear_config.h" + +#include + +typedef struct BongoCatPreferencesScrollbar { + float grab_y; + bool dragging; +} BongoCatPreferencesScrollbar; + +typedef struct BongoCatPreferencesScrollbarResult { + float offset; + bool scrollable; + bool changed; +} BongoCatPreferencesScrollbarResult; + +bool bongo_cat_preferences_scrollbar_needed( + struct nk_rect viewport, float content_height); +float bongo_cat_preferences_scrollbar_content_width( + struct nk_rect viewport, float content_height); +void bongo_cat_preferences_scrollbar_reset( + BongoCatPreferencesScrollbar *scrollbar); +BongoCatPreferencesScrollbarResult bongo_cat_preferences_scrollbar_draw( + struct nk_context *context, struct nk_command_buffer *canvas, + struct nk_rect viewport, float content_height, float offset, + BongoCatPreferencesScrollbar *scrollbar, struct nk_color cursor_color, + float opacity, bool enabled); + +#endif diff --git a/src/ui/preferences/preferences_section_icons.c b/src/ui/preferences/preferences_section_icons.c new file mode 100644 index 00000000..de1f5778 --- /dev/null +++ b/src/ui/preferences/preferences_section_icons.c @@ -0,0 +1,66 @@ +#include "preferences_icons_internal.h" + +static void window_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + nk_stroke_rect(canvas, nk_rect(bounds.x + 1, bounds.y + 2, + bounds.w - 2, bounds.h - 4), 2, 1.5f, color); + nk_stroke_line(canvas, bounds.x + 1, bounds.y + 6, + bounds.x + bounds.w - 1, bounds.y + 6, 1.5f, color); + nk_fill_circle(canvas, nk_rect(bounds.x + 3, bounds.y + 3, 2, 2), color); + nk_fill_circle(canvas, nk_rect(bounds.x + 6, bounds.y + 3, 2, 2), color); +} + +static void model_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + float left = bounds.x + 2, right = bounds.x + bounds.w - 2; + float center = bounds.x + bounds.w * .5f; + float top = bounds.y + 1, middle = bounds.y + 6; + float joint = bounds.y + 9, bottom = bounds.y + bounds.h - 1; + nk_stroke_line(canvas, center, top, left, middle, 1.5f, color); + nk_stroke_line(canvas, center, top, right, middle, 1.5f, color); + nk_stroke_line(canvas, left, middle, center, joint, 1.5f, color); + nk_stroke_line(canvas, right, middle, center, joint, 1.5f, color); + nk_stroke_line(canvas, left, middle, left, bounds.y + 13, 1.5f, color); + nk_stroke_line(canvas, right, middle, right, bounds.y + 13, 1.5f, color); + nk_stroke_line(canvas, left, bounds.y + 13, center, bottom, 1.5f, color); + nk_stroke_line(canvas, right, bounds.y + 13, center, bottom, 1.5f, color); + nk_stroke_line(canvas, center, joint, center, bottom, 1.5f, color); +} + +static void application_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + nk_stroke_rect(canvas, nk_rect(bounds.x + 1, bounds.y + 1, + bounds.w - 2, bounds.h - 2), 3, 1.5f, color); + for (int row = 0; row < 2; ++row) + for (int column = 0; column < 2; ++column) + nk_fill_rect(canvas, nk_rect(bounds.x + 4 + column * 6, + bounds.y + 4 + row * 6, 4, 4), 1, color); +} + +static void appearance_icon(struct nk_command_buffer *canvas, + struct nk_rect bounds, struct nk_color color) { + float x = bounds.x + bounds.w * .5f, y = bounds.y + bounds.h * .5f; + nk_stroke_circle(canvas, nk_rect(x - 4, y - 4, 8, 8), 1.5f, color); + const float rays[][4] = { + {0, -8, 0, -6}, {0, 6, 0, 8}, {-8, 0, -6, 0}, {6, 0, 8, 0}, + {-6, -6, -4.5f, -4.5f}, {4.5f, 4.5f, 6, 6}, + {4.5f, -4.5f, 6, -6}, {-6, 6, -4.5f, 4.5f}}; + for (int i = 0; i < 8; ++i) + nk_stroke_line(canvas, x + rays[i][0], y + rays[i][1], + x + rays[i][2], y + rays[i][3], 1.5f, color); +} + +bool bongo_cat_pref_section_icon_draw(struct nk_command_buffer *canvas, + struct nk_rect bounds, BongoCatPrefIcon icon, + struct nk_color color) { + if (icon == BONGO_CAT_PREF_ICON_SECTION_WINDOW) + window_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_MODEL) + model_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_APPLICATION) + application_icon(canvas, bounds, color); + else if (icon == BONGO_CAT_PREF_ICON_SECTION_APPEARANCE) + appearance_icon(canvas, bounds, color); + else return false; + return true; +} diff --git a/src/ui/preferences_shortcut_clear.c b/src/ui/preferences/preferences_shortcut_clear.c similarity index 100% rename from src/ui/preferences_shortcut_clear.c rename to src/ui/preferences/preferences_shortcut_clear.c diff --git a/src/ui/preferences_shortcut_clear.h b/src/ui/preferences/preferences_shortcut_clear.h similarity index 100% rename from src/ui/preferences_shortcut_clear.h rename to src/ui/preferences/preferences_shortcut_clear.h diff --git a/src/ui/preferences_shortcuts.c b/src/ui/preferences/preferences_shortcuts.c similarity index 82% rename from src/ui/preferences_shortcuts.c rename to src/ui/preferences/preferences_shortcuts.c index cdf01bad..0a4b2b8a 100644 --- a/src/ui/preferences_shortcuts.c +++ b/src/ui/preferences/preferences_shortcuts.c @@ -20,6 +20,12 @@ bool bongo_cat_preferences_shortcut_active(const BongoCatPreferences *value, } static void finish(BongoCatPreferences *value) { + if (value->shortcut_target && strcmp(value->shortcut_target, value->shortcut_original)) + for (size_t i = 0; i < value->app->settings.behavior_shortcut_count; ++i) { + BongoCatBehaviorShortcut *binding = &value->app->settings.behavior_shortcuts[i]; + if (binding->shortcut == value->shortcut_target) + binding->shortcut_disabled = !binding->shortcut[0]; + } value->shortcut_recording = false; value->shortcut_id[0] = '\0'; value->shortcut_target = NULL; @@ -27,6 +33,8 @@ static void finish(BongoCatPreferences *value) { value->shortcut_key = SDLK_UNKNOWN; value->shortcut_suppress_until_ns = SDL_GetTicksNS() + 250000000ULL; bongo_cat_shortcut_init(&value->app->shortcut_state); + memset(&value->app->sound_shortcut_state, 0, sizeof(value->app->sound_shortcut_state)); + memset(value->app->sound_shortcut_active, 0, sizeof(value->app->sound_shortcut_active)); value->render_dirty = true; } @@ -50,6 +58,8 @@ void bongo_cat_preferences_shortcut_begin(BongoCatPreferences *value, value->shortcut_key = SDLK_UNKNOWN; value->shortcut_recording = true; bongo_cat_shortcut_init(&value->app->shortcut_state); + memset(&value->app->sound_shortcut_state, 0, sizeof(value->app->sound_shortcut_state)); + memset(value->app->sound_shortcut_active, 0, sizeof(value->app->sound_shortcut_active)); value->render_dirty = true; } @@ -67,9 +77,13 @@ static const char *primary_name(SDL_Keycode key, char output[24]) { if (key >= SDLK_0 && key <= SDLK_9) { output[0] = (char)('0' + key - SDLK_0); output[1] = '\0'; return output; } - if (key >= SDLK_F1 && key <= SDLK_F24) { + /* SDL places navigation and keypad keys between F12 and F13. */ + if (key >= SDLK_F1 && key <= SDLK_F12) { snprintf(output, 24, "F%d", (int)(key - SDLK_F1 + 1)); return output; } + if (key >= SDLK_F13 && key <= SDLK_F24) { + snprintf(output, 24, "F%d", (int)(key - SDLK_F13 + 13)); return output; + } switch (key) { case SDLK_RETURN: case SDLK_KP_ENTER: return "Enter"; case SDLK_TAB: return "Tab"; case SDLK_SPACE: return "Space"; @@ -127,7 +141,7 @@ static bool capture_key(BongoCatPreferences *value, if (event->mod & SDL_KMOD_ALT) append(shortcut, sizeof(shortcut), "Alt"); if (event->mod & SDL_KMOD_GUI) append(shortcut, sizeof(shortcut), "Meta"); append(shortcut, sizeof(shortcut), key); - if (bongo_cat_config_shortcut_conflicts(&value->app->config, shortcut, + if (bongo_cat_settings_shortcut_conflicts(&value->app->settings, shortcut, value->shortcut_target)) { snprintf(value->shortcut_target, (size_t)value->shortcut_capacity, "%s", value->shortcut_original); @@ -162,8 +176,9 @@ bool bongo_cat_preferences_shortcuts_blocked( static void shortcut_row(BongoCatPreferences *value, struct nk_context *context, const char *id, const char *label_key, const char *label_fallback, - char *target, int capacity) { + char *target, int capacity, BongoCatPrefIcon icon) { bool active = bongo_cat_preferences_shortcut_active(value, id); + bongo_cat_pref_row_icon(context, icon); int action = bongo_cat_pref_edit(context, id, tr(value, label_key, label_fallback), "", target, active, tr(value, "components.shortcut.hints.clickRecordShortcut", @@ -178,22 +193,25 @@ static void shortcut_row(BongoCatPreferences *value, struct nk_context *context, void bongo_cat_preferences_page_shortcuts(BongoCatPreferences *value, struct nk_context *context) { - BongoCatShortcutOptions *keys = &value->app->config.shortcuts; - bongo_cat_pref_section(context, tr(value, - "pages.preference.shortcut.title", "Shortcuts")); + BongoCatShortcutPreferences *keys = &value->app->settings.shortcuts; shortcut_row(value, context, "shortcut-cat", "native.shortcut.toggleCat", "Show/Hide", - keys->visible_cat, sizeof(keys->visible_cat)); + keys->toggle_pet_visibility, sizeof(keys->toggle_pet_visibility), + BONGO_CAT_PREF_ICON_SHORTCUT_VISIBILITY); shortcut_row(value, context, "shortcut-pref", "native.shortcut.preferences", "Preferences", - keys->visible_preferences, sizeof(keys->visible_preferences)); - shortcut_row(value, context, "shortcut-mirror", "native.shortcut.mirror", - "Mirror", - keys->mirror, sizeof(keys->mirror)); + keys->visible_preferences, sizeof(keys->visible_preferences), + BONGO_CAT_PREF_ICON_SHORTCUT_PREFERENCES); shortcut_row(value, context, "shortcut-pass", "native.shortcut.passThrough", "Pass Through", - keys->pass_through, sizeof(keys->pass_through)); + keys->pass_through, sizeof(keys->pass_through), + BONGO_CAT_PREF_ICON_PASS_THROUGH); shortcut_row(value, context, "shortcut-top", "native.shortcut.alwaysOnTop", "Always on Top", - keys->always_on_top, sizeof(keys->always_on_top)); + keys->always_on_top, sizeof(keys->always_on_top), + BONGO_CAT_PREF_ICON_ALWAYS_ON_TOP); + shortcut_row(value, context, "shortcut-mirror", "native.shortcut.mirror", + "Mirror", + keys->mirror, sizeof(keys->mirror), + BONGO_CAT_PREF_ICON_MIRROR); } diff --git a/src/ui/preferences_smoke.c b/src/ui/preferences/preferences_smoke.c similarity index 82% rename from src/ui/preferences_smoke.c rename to src/ui/preferences/preferences_smoke.c index 7271cf24..2a4c44b7 100644 --- a/src/ui/preferences_smoke.c +++ b/src/ui/preferences/preferences_smoke.c @@ -41,7 +41,7 @@ static void write_window_handle(BongoCatPreferences *value) { SDL_PROP_WINDOW_WIN32_HWND_POINTER, NULL); #endif char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), value->app->data_root, + if (!bongo_cat_path_join(path, sizeof(path), value->app->state_root, "preferences-window.txt")) return; FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; @@ -52,14 +52,28 @@ static void write_window_handle(BongoCatPreferences *value) { fclose(file); } +void bongo_cat_preferences_record_frame(BongoCatPreferences *value) { + if (!value->app->smoke_frame_series) return; + char path[BONGO_CAT_PATH_CAP]; + bongo_cat_path_join(path, sizeof(path), value->app->state_root, + "preferences-frames.csv"); + FILE *file = bongo_cat_file_open(path, "ab"); + if (!file) return; + fprintf(file, "%llu\n", (unsigned long long)SDL_GetTicksNS()); + fclose(file); +} + void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { if (!value) return; write_window_handle(value); if (!value->app->smoke) return; bool valid = bongo_cat_ui_frame_valid(&value->ui); + bool assets_valid = value->logo_texture && value->icon_texture && + (value->page != 3 || + (value->catime_texture && value->vlaina_texture)); if (!value->frame_checked) { value->frame_checked = true; - if (!valid) value->app->exit_code = 1; + if (!valid || !assets_valid) value->app->exit_code = 1; } int window_width = 0, window_height = 0; int pixel_width = 0, pixel_height = 0; @@ -78,7 +92,7 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { for (int i = 0; i < 4; ++i) if (corners[i][3] > corner_alpha) corner_alpha = corners[i][3]; char path[BONGO_CAT_PATH_CAP]; - bongo_cat_path_join(path, sizeof(path), value->app->data_root, + bongo_cat_path_join(path, sizeof(path), value->app->state_root, "ui-frame.txt"); FILE *file = bongo_cat_file_open(path, "wb"); if (!file) return; @@ -92,7 +106,8 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { "logical=%.2fx%.2f atlas=%dx%d transparent=%d corner_alpha=%u " "resize_cached=%u resize_failures=%u resize_layout=%u " "fonts=%.1f,%.1f,%.1f,%.1f,%.1f " - "paint_textures=%zu paint_bytes=%zu\n", + "paint_textures=%zu paint_bytes=%zu " + "assets=%u,%u,%u,%u,%u valid_assets=%d\n", valid, value->page, value->ui.last_convert_result, value->ui.last_vertex_bytes, value->ui.last_element_bytes, value->ui.last_draw_commands, value->ui.last_draw_elements, @@ -108,6 +123,9 @@ void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value) { value->live_resize_layout_frames, font_height(value->ui.caption_font), font_height(value->ui.body_font), font_height(value->ui.label_font), font_height(value->ui.heading_font), - font_height(value->ui.hero_font), paint_count, paint_bytes); + font_height(value->ui.hero_font), paint_count, paint_bytes, + value->logo_texture, value->icon_texture, + value->icon_texture_hidpi, value->catime_texture, + value->vlaina_texture, assets_valid); fclose(file); } diff --git a/src/ui/preferences_state.h b/src/ui/preferences/preferences_state.h similarity index 52% rename from src/ui/preferences_state.h rename to src/ui/preferences/preferences_state.h index 143a0ccc..86b0c37c 100644 --- a/src/ui/preferences_state.h +++ b/src/ui/preferences/preferences_state.h @@ -2,15 +2,26 @@ #define BONGO_CAT_PREFERENCES_STATE_H #include "preferences_internal.h" +#include "preferences_scrollbar.h" +#include "preferences_text_session.h" +#include "about/preferences_about.h" #include "ui_backend.h" + +#define BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS 5000000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS 3000000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS 160000000ull +#define BONGO_CAT_MODEL_LOAD_VISUAL_WAIT_CAP 0.95f #include "bongo_cat/i18n.h" #include "bongo_cat/preferences.h" -#include "../runtime/modal_frame.h" +#include "modal_frame.h" typedef struct BongoCatPreferenceNotice { - char message[384]; + char message[1024]; uint64_t started_ns; uint64_t until_ns; + uint64_t timer_updated_ns; + struct nk_rect bounds; + bool hovered; bool error; } BongoCatPreferenceNotice; @@ -20,11 +31,13 @@ struct BongoCatPreferences { SDL_GLContext gl_context; bool owns_gl_context; bool transparent_window; + bool visible; bool ui_initialized; BongoCatUIBackend ui; unsigned int logo_texture; unsigned int icon_texture; unsigned int icon_texture_hidpi; + bool icon_hidpi_attempted; int logo_width; int logo_height; unsigned int catime_texture; @@ -32,24 +45,53 @@ struct BongoCatPreferences { int catime_width, catime_height; int vlaina_width, vlaina_height; bool support_assets_loaded; + BongoCatAboutState about; + struct nk_user_font support_logs_font; int page; int style_theme; BongoCatLanguage font_language; nk_rune glyph_ranges[2048]; bool input_active; bool import_requested; + bool import_drop_active; + bool import_render_active; BongoCatImportDialog *import_dialog; bool frame_checked; bool render_dirty; bool font_reload_pending; + bool font_reload_defer_once; + size_t pending_import_name_count; + char pending_import_names[BONGO_CAT_MODEL_CAP][BONGO_CAT_ID_CAP]; + bool model_directory_watch_active; + bool model_directory_watch_known; + bool model_directory_watch_refresh; + bool model_directory_watch_available; + size_t model_directory_watch_count; + uint64_t model_directory_watch_sum; + uint64_t model_directory_watch_xor; + uint64_t model_directory_watch_due_ns; bool smoke_behavior_open_pending; + bool model_selection_pending; + bool pending_model_multiple; + bool pending_model_active; + bool model_loading; + bool model_load_visual_active; + float model_load_progress; + float model_load_render_progress; + uint64_t model_load_render_ns; + uint64_t model_load_visual_started_ns; + uint64_t model_load_visual_completion_ns; + char pending_model_id[BONGO_CAT_ID_CAP]; + char loading_model_id[BONGO_CAT_ID_CAP]; + char model_load_visual_id[BONGO_CAT_ID_CAP]; uint64_t last_render_ns; + uint64_t shown_ns; float pending_raster_scale; uint64_t raster_retry_ns; uint64_t render_retry_ns; - float scroll_current[5]; - float scroll_target[5]; - bool scroll_ready[5]; + float scroll_current[4]; + float scroll_target[4]; + bool scroll_ready[4]; bool page_seen; int last_page; uint64_t page_transition_ns; @@ -60,11 +102,11 @@ struct BongoCatPreferences { uint64_t behavior_dialog_opened_ns; uint64_t behavior_dialog_closing_ns; uint64_t behavior_tab_transition_ns; - float behavior_scroll[2]; - char behavior_rename_id[BONGO_CAT_PATH_CAP]; - char behavior_rename_text[BONGO_CAT_ID_CAP]; - struct nk_rect behavior_rename_bounds; - bool behavior_rename_select_all; + float behavior_scroll[3]; + bool behavior_audio_playing[BONGO_CAT_BEHAVIOR_CAP]; + BongoCatPreferencesScrollbar behavior_scrollbar; + BongoCatPreferencesTextSession behavior_rename; + BongoCatPreferencesTextSession model_rename; bool native_drag; bool chrome_dragging; bool live_resize_active; @@ -85,11 +127,23 @@ struct BongoCatPreferences { SDL_Keycode shortcut_key; bool shortcut_recording; uint64_t shortcut_suppress_until_ns; +#ifdef __APPLE__ + /* The permission changes while the user is in System Settings, so the page + reads it again when it becomes visible and when the window regains focus + instead of querying macOS per frame. */ + bool input_monitoring_authorized; + bool input_monitoring_valid; +#endif }; +#ifdef __APPLE__ +void bongo_cat_preferences_input_monitoring_refresh(BongoCatPreferences *value); +#endif + int bongo_cat_preferences_resolved_theme(const BongoCatPreferences *value); void bongo_cat_preferences_apply_theme(BongoCatPreferences *value); bool bongo_cat_preferences_open_window(BongoCatPreferences *value); +void bongo_cat_preferences_release_idle_window(BongoCatPreferences *value); bool bongo_cat_preferences_scale_event(BongoCatPreferences *value, const SDL_Event *event); bool bongo_cat_preferences_refresh_raster(BongoCatPreferences *value); @@ -101,10 +155,21 @@ void bongo_cat_preferences_live_resize_uninstall(BongoCatPreferences *value); void bongo_cat_preferences_record_frame(BongoCatPreferences *value); void bongo_cat_preferences_assets_load(BongoCatPreferences *value); void bongo_cat_preferences_support_assets_load(BongoCatPreferences *value); +void bongo_cat_preferences_support_assets_clear(BongoCatPreferences *value); +void bongo_cat_preferences_process_model_selection(BongoCatPreferences *value); +void bongo_cat_preferences_model_visual_begin(BongoCatPreferences *value, + const char *model_id); +void bongo_cat_preferences_model_load_progress(BongoCatPreferences *value, + float progress); +float bongo_cat_preferences_model_visual_progress(BongoCatPreferences *value, + const char *model_id); void bongo_cat_preferences_assets_clear(BongoCatPreferences *value); +void bongo_cat_preferences_assets_abandon(BongoCatPreferences *value); void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app); +void bongo_cat_preferences_page_cache_clear(BongoCatPreferences *value, + int previous_page, int next_page); void bongo_cat_preferences_smoke_frame(BongoCatPreferences *value); -void bongo_cat_preferences_icon_draw(const BongoCatPreferences *value, +void bongo_cat_preferences_icon_draw(BongoCatPreferences *value, struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color); diff --git a/src/ui/preferences/preferences_text_edit.c b/src/ui/preferences/preferences_text_edit.c new file mode 100644 index 00000000..f482c417 --- /dev/null +++ b/src/ui/preferences/preferences_text_edit.c @@ -0,0 +1,153 @@ +#include "preferences_text_edit.h" + +#include + +static bool continuation(unsigned char value) { + return (value & 0xc0) == 0x80; +} + +static size_t boundary(const char *text, size_t cursor) { + size_t length = text ? strlen(text) : 0; + if (cursor > length) cursor = length; + while (cursor && cursor < length && continuation((unsigned char)text[cursor])) + cursor--; + return cursor; +} + +static size_t previous(const char *text, size_t cursor) { + cursor = boundary(text, cursor); + if (!cursor) return 0; + do cursor--; while (cursor && continuation((unsigned char)text[cursor])); + return cursor; +} + +static size_t next(const char *text, size_t cursor) { + size_t length = text ? strlen(text) : 0; + cursor = boundary(text, cursor); + if (cursor >= length) return length; + do cursor++; while (cursor < length && + continuation((unsigned char)text[cursor])); + return cursor; +} + +static size_t rune_bytes(const unsigned char *value, size_t remaining) { + if (!remaining || !value[0]) return 0; + if (value[0] < 0x80) return 1; + size_t count = value[0] >= 0xc2 && value[0] <= 0xdf ? 2 : + value[0] >= 0xe0 && value[0] <= 0xef ? 3 : + value[0] >= 0xf0 && value[0] <= 0xf4 ? 4 : 0; + if (!count || count > remaining) return 0; + for (size_t i = 1; i < count; ++i) + if (!continuation(value[i])) return 0; + if ((count == 3 && value[0] == 0xe0 && value[1] < 0xa0) || + (count == 3 && value[0] == 0xed && value[1] >= 0xa0) || + (count == 4 && value[0] == 0xf0 && value[1] < 0x90) || + (count == 4 && value[0] == 0xf4 && value[1] >= 0x90)) return 0; + return count; +} + +void bongo_cat_text_edit_begin(const char *text, size_t *cursor, + bool *select_all) { + if (cursor) *cursor = text ? strlen(text) : 0; + if (select_all) *select_all = false; +} + +bool bongo_cat_text_edit_insert(char *text, size_t capacity, size_t *cursor, + bool *select_all, const char *inserted) { + if (!text || !capacity || !cursor || !select_all || !inserted) return false; + bool changed = false; + if (*select_all) { + changed = text[0] != '\0'; + text[0] = '\0'; *cursor = 0; *select_all = false; + } + size_t length = strlen(text); + *cursor = boundary(text, *cursor); + size_t remaining = strlen(inserted); + for (const unsigned char *source = (const unsigned char *)inserted; + remaining && *source;) { + size_t bytes = rune_bytes(source, remaining); + if (!bytes) { source++; remaining--; continue; } + if (bytes == 1 && source[0] < 32) { + source++; remaining--; continue; + } + if (length + bytes >= capacity) break; + memmove(text + *cursor + bytes, text + *cursor, + length - *cursor + 1); + memcpy(text + *cursor, source, bytes); + *cursor += bytes; length += bytes; + source += bytes; remaining -= bytes; changed = true; + } + return changed; +} + +bool bongo_cat_text_edit_erase(char *text, size_t *cursor, + bool *select_all, bool forward) { + if (!text || !cursor || !select_all) return false; + size_t length = strlen(text); + if (*select_all) { + bool changed = length != 0; + text[0] = '\0'; *cursor = 0; *select_all = false; + return changed; + } + *cursor = boundary(text, *cursor); + size_t start = forward ? *cursor : previous(text, *cursor); + size_t end = forward ? next(text, *cursor) : *cursor; + if (start == end) return false; + memmove(text + start, text + end, length - end + 1); + *cursor = start; + return true; +} + +bool bongo_cat_text_edit_move(const char *text, size_t *cursor, + bool *select_all, BongoCatTextEditMove move) { + if (!text || !cursor || !select_all) return false; + size_t length = strlen(text), old = *cursor; + bool selected = *select_all; + *cursor = boundary(text, *cursor); + if (move == BONGO_CAT_TEXT_EDIT_HOME || (selected && + move == BONGO_CAT_TEXT_EDIT_LEFT)) *cursor = 0; + else if (move == BONGO_CAT_TEXT_EDIT_END || (selected && + move == BONGO_CAT_TEXT_EDIT_RIGHT)) *cursor = length; + else if (move == BONGO_CAT_TEXT_EDIT_LEFT) *cursor = previous(text, *cursor); + else if (move == BONGO_CAT_TEXT_EDIT_RIGHT) *cursor = next(text, *cursor); + *select_all = false; + return selected || old != *cursor; +} + +void bongo_cat_text_edit_select_all(const char *text, size_t *cursor, + bool *select_all) { + if (cursor) *cursor = text ? strlen(text) : 0; + if (select_all) *select_all = true; +} + +void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all) { + if (text) text[0] = '\0'; + if (cursor) *cursor = 0; + if (select_all) *select_all = false; +} + +void bongo_cat_text_edit_trim(char *text) { + if (!text) return; + size_t start = 0, end = strlen(text); + while (start < end && (text[start] == ' ' || text[start] == '\t')) start++; + while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\t')) + end--; + if (start) memmove(text, text + start, end - start); + text[end - start] = '\0'; +} + +size_t bongo_cat_text_edit_nearest(const char *text, float target, + float (*measure)(const void *userdata, const char *text, size_t length), + const void *userdata) { + if (!text || !measure || target <= 0.0f) return 0; + size_t length = strlen(text), cursor = 0; + float previous_width = 0.0f; + while (cursor < length) { + size_t after = next(text, cursor); + float width = measure(userdata, text, after); + if (target < previous_width + (width - previous_width) * 0.5f) + return cursor; + previous_width = width; cursor = after; + } + return length; +} diff --git a/src/ui/preferences/preferences_text_edit.h b/src/ui/preferences/preferences_text_edit.h new file mode 100644 index 00000000..eeb35cf2 --- /dev/null +++ b/src/ui/preferences/preferences_text_edit.h @@ -0,0 +1,30 @@ +#ifndef BONGO_CAT_PREFERENCES_TEXT_EDIT_H +#define BONGO_CAT_PREFERENCES_TEXT_EDIT_H + +#include +#include + +typedef enum BongoCatTextEditMove { + BONGO_CAT_TEXT_EDIT_LEFT, + BONGO_CAT_TEXT_EDIT_RIGHT, + BONGO_CAT_TEXT_EDIT_HOME, + BONGO_CAT_TEXT_EDIT_END +} BongoCatTextEditMove; + +void bongo_cat_text_edit_begin(const char *text, size_t *cursor, + bool *select_all); +bool bongo_cat_text_edit_insert(char *text, size_t capacity, size_t *cursor, + bool *select_all, const char *inserted); +bool bongo_cat_text_edit_erase(char *text, size_t *cursor, + bool *select_all, bool forward); +bool bongo_cat_text_edit_move(const char *text, size_t *cursor, + bool *select_all, BongoCatTextEditMove move); +void bongo_cat_text_edit_select_all(const char *text, size_t *cursor, + bool *select_all); +void bongo_cat_text_edit_clear(char *text, size_t *cursor, bool *select_all); +void bongo_cat_text_edit_trim(char *text); +size_t bongo_cat_text_edit_nearest(const char *text, float target, + float (*measure)(const void *userdata, const char *text, size_t length), + const void *userdata); + +#endif diff --git a/src/ui/preferences/preferences_text_session.c b/src/ui/preferences/preferences_text_session.c new file mode 100644 index 00000000..a4f4bcd0 --- /dev/null +++ b/src/ui/preferences/preferences_text_session.c @@ -0,0 +1,112 @@ +#include "preferences_text_session.h" + +#include "preferences_text_edit.h" + +#include +#include +#include + +static float measure(const void *userdata, const char *text, size_t length) { + const struct nk_user_font *font = userdata; + return font->width(font->userdata, font->height, text, (int)length); +} + +static void clipboard_event(BongoCatPreferencesTextSession *session, + SDL_Keycode key) { + if (key == SDLK_V) { + char *clipboard = SDL_GetClipboardText(); + bongo_cat_text_edit_insert(session->text, sizeof(session->text), + &session->cursor, &session->select_all, clipboard); + SDL_free(clipboard); + return; + } + if (session->select_all) SDL_SetClipboardText(session->text); + if (key == SDLK_X && session->select_all) + bongo_cat_text_edit_clear(session->text, &session->cursor, + &session->select_all); +} + +bool bongo_cat_preferences_text_session_active( + const BongoCatPreferencesTextSession *session) { + return session && session->id[0]; +} + +void bongo_cat_preferences_text_session_begin( + BongoCatPreferencesTextSession *session, const char *id, + const char *text, struct nk_rect bounds) { + if (!session) return; + snprintf(session->id, sizeof(session->id), "%s", id ? id : ""); + snprintf(session->text, sizeof(session->text), "%s", text ? text : ""); + session->bounds = bounds; + bongo_cat_text_edit_begin(session->text, &session->cursor, + &session->select_all); +} + +void bongo_cat_preferences_text_session_reset( + BongoCatPreferencesTextSession *session) { + if (session) memset(session, 0, sizeof(*session)); +} + +BongoCatPreferencesTextSessionEvent bongo_cat_preferences_text_session_event( + BongoCatPreferencesTextSession *session, const SDL_Event *event, + float layout_scale, const struct nk_user_font *font, float text_inset) { + BongoCatPreferencesTextSessionEvent result = {0}; + if (!bongo_cat_preferences_text_session_active(session) || !event) + return result; + if (event->type == SDL_EVENT_TEXT_INPUT) { + bongo_cat_text_edit_insert(session->text, sizeof(session->text), + &session->cursor, &session->select_all, event->text.text); + result.handled = true; + return result; + } + if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { + result.finish = result.save = true; + return result; + } + if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && + event->button.button == SDL_BUTTON_LEFT) { + float scale = layout_scale > 0.0f ? layout_scale : 1.0f; + float x = event->button.x / scale, y = event->button.y / scale; + if (!NK_INBOX(x, y, session->bounds.x, session->bounds.y, + session->bounds.w, session->bounds.h)) { + result.finish = result.save = true; + return result; + } + if (font) session->cursor = bongo_cat_text_edit_nearest(session->text, + x - session->bounds.x - text_inset, measure, font); + session->select_all = false; + result.handled = true; + return result; + } + if (event->type != SDL_EVENT_KEY_DOWN && + event->type != SDL_EVENT_KEY_UP) return result; + result.handled = true; + if (event->type == SDL_EVENT_KEY_UP) return result; + SDL_Keycode key = event->key.key; + bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; + if (key == SDLK_ESCAPE) { + result.finish = true; + return result; + } + if (key == SDLK_RETURN || key == SDLK_KP_ENTER) { + result.finish = result.save = true; + return result; + } + if (control && key == SDLK_A) + bongo_cat_text_edit_select_all(session->text, &session->cursor, + &session->select_all); + else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) + clipboard_event(session, key); + else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) + bongo_cat_text_edit_erase(session->text, &session->cursor, + &session->select_all, key == SDLK_DELETE); + else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || + key == SDLK_END) + bongo_cat_text_edit_move(session->text, &session->cursor, + &session->select_all, + key == SDLK_LEFT ? BONGO_CAT_TEXT_EDIT_LEFT : + key == SDLK_RIGHT ? BONGO_CAT_TEXT_EDIT_RIGHT : + key == SDLK_HOME ? BONGO_CAT_TEXT_EDIT_HOME : + BONGO_CAT_TEXT_EDIT_END); + return result; +} diff --git a/src/ui/preferences/preferences_text_session.h b/src/ui/preferences/preferences_text_session.h new file mode 100644 index 00000000..f8a2fa8c --- /dev/null +++ b/src/ui/preferences/preferences_text_session.h @@ -0,0 +1,35 @@ +#ifndef BONGO_CAT_PREFERENCES_TEXT_SESSION_H +#define BONGO_CAT_PREFERENCES_TEXT_SESSION_H + +#include "nuklear_config.h" +#include "bongo_cat/common.h" + +#include +#include + +typedef struct BongoCatPreferencesTextSession { + char id[BONGO_CAT_BEHAVIOR_ID_CAP]; + char text[BONGO_CAT_ID_CAP]; + struct nk_rect bounds; + size_t cursor; + bool select_all; +} BongoCatPreferencesTextSession; + +typedef struct BongoCatPreferencesTextSessionEvent { + bool handled; + bool finish; + bool save; +} BongoCatPreferencesTextSessionEvent; + +bool bongo_cat_preferences_text_session_active( + const BongoCatPreferencesTextSession *session); +void bongo_cat_preferences_text_session_begin( + BongoCatPreferencesTextSession *session, const char *id, + const char *text, struct nk_rect bounds); +void bongo_cat_preferences_text_session_reset( + BongoCatPreferencesTextSession *session); +BongoCatPreferencesTextSessionEvent bongo_cat_preferences_text_session_event( + BongoCatPreferencesTextSession *session, const SDL_Event *event, + float layout_scale, const struct nk_user_font *font, float text_inset); + +#endif diff --git a/src/ui/preferences_theme.c b/src/ui/preferences/preferences_theme.c similarity index 82% rename from src/ui/preferences_theme.c rename to src/ui/preferences/preferences_theme.c index a22f17ed..2b29e1ee 100644 --- a/src/ui/preferences_theme.c +++ b/src/ui/preferences/preferences_theme.c @@ -1,4 +1,5 @@ #include "preferences_theme.h" +#include "preferences_widgets_internal.h" #include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" @@ -39,10 +40,12 @@ static bool form_begin(struct nk_context *context, const char *id, context->style.window.group_padding = saved->padding; context->style.window.spacing = saved->spacing; context->style.window.group_border = saved->border; + bongo_cat_pref_row_icon_clear(context); return false; } static void form_end(struct nk_context *context, const ThemeFormStyle *saved) { + bongo_cat_pref_row_icon_clear(context); nk_group_end(context); context->style.window.fixed_background = saved->background; context->style.window.background = saved->window_color; @@ -93,18 +96,17 @@ static void draw_icon(struct nk_command_buffer *canvas, int icon, } static int capsule(struct nk_context *context, const char *id, - const char *const *labels, int selected) { - (void)labels; + const char *const *labels, int count, bool icons, int selected) { struct nk_rect widget; if (nk_widget(&widget, context) == NK_WIDGET_INVALID) return selected; - selected = NK_CLAMP(0, selected, 2); + selected = NK_CLAMP(0, selected, count - 1); const float width = 156.0f; struct nk_rect bounds = nk_rect(widget.x + NK_MAX(0.0f, widget.w - width - 6.0f), widget.y - 1, NK_MIN(width, widget.w), 38); - float segment = bounds.w / 3.0f; + float segment = bounds.w / count; bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); int hovered = hover ? NK_CLAMP(0, (int)((context->input.mouse.pos.x - - bounds.x) / segment), 2) : -1; + bounds.x) / segment), count - 1) : -1; if (hover && nk_input_is_mouse_click_in_rect(&context->input, NK_BUTTON_LEFT, bounds)) selected = hovered; @@ -118,7 +120,7 @@ static int capsule(struct nk_context *context, const char *id, BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); struct nk_command_buffer *canvas = nk_window_get_canvas(context); nk_fill_rect(canvas, bounds, 14, p.field); - for (int i = 0; i < 3; ++i) { + for (int i = 0; i < count; ++i) { snprintf(animation_id, sizeof(animation_id), "theme-hover-%s-%d", id, i); float amount = bongo_cat_ui_animate_eased(context, animation_id, hovered == i ? 1.0f : 0.0f, 160, BONGO_CAT_UI_EASE_STANDARD); @@ -134,7 +136,7 @@ static int capsule(struct nk_context *context, const char *id, nk_fill_rect(canvas, active, 11, p.accent); nk_stroke_rect(canvas, bounds, 14, 1, bongo_cat_ui_color_mix(p.border_subtle, p.accent, outer_hover)); - for (int i = 0; i < 3; ++i) { + for (int i = 0; i < count; ++i) { snprintf(animation_id, sizeof(animation_id), "theme-hover-%s-%d", id, i); float amount = bongo_cat_ui_animate_eased(context, animation_id, hovered == i ? 1.0f : 0.0f, 160, BONGO_CAT_UI_EASE_STANDARD); @@ -151,7 +153,17 @@ static int capsule(struct nk_context *context, const char *id, float size = 20.0f + amount; struct nk_rect icon = nk_rect(bounds.x + segment * (i + .5f) - size * .5f, bounds.y + (bounds.h - size) * .5f, size, size); - draw_icon(canvas, i, icon, color, segment_background); + if (icons) draw_icon(canvas, i, icon, color, segment_background); + else { + const struct nk_user_font *font = context->style.font; + int length = nk_strlen(labels[i]); + float text_width = font->width(font->userdata, font->height, + labels[i], length); + nk_draw_text(canvas, nk_rect(bounds.x + segment * (i + .5f) - + text_width * .5f, bounds.y + (bounds.h - font->height) * .5f, + text_width, font->height), labels[i], length, font, + nk_rgba(0, 0, 0, 0), color); + } } if (hover) { bongo_cat_ui_cursor_hover_rect(context, bounds, @@ -160,23 +172,31 @@ static int capsule(struct nk_context *context, const char *id, return selected; } -int bongo_cat_pref_theme(struct nk_context *context, const char *id, - const char *title, const char *const *labels, int selected) { +static int choice_row(struct nk_context *context, const char *id, + const char *title, const char *const *labels, int count, bool icons, + int selected) { ThemeFormStyle saved; if (!form_begin(context, id, &saved)) return selected; float available = nk_window_get_content_region(context).w; float left = NK_MAX(220.0f, available - 184.0f); nk_layout_row_begin(context, NK_STATIC, 36, 2); nk_layout_row_push(context, left); - nk_style_push_font(context, bongo_cat_ui_label_font(context)); - struct nk_vec2 padding = context->style.text.padding; - context->style.text.padding.x += 5.0f; - nk_label(context, title, NK_TEXT_LEFT); - context->style.text.padding = padding; - nk_style_pop_font(context); + bongo_cat_pref_form_label(context, title); nk_layout_row_push(context, NK_MAX(176.0f, available - left - 8.0f)); - selected = capsule(context, id, labels, selected); + selected = capsule(context, id, labels, count, icons, selected); nk_layout_row_end(context); form_end(context, &saved); return selected; } + +int bongo_cat_pref_theme(struct nk_context *context, const char *id, + const char *title, const char *const *labels, int selected) { + return choice_row(context, id, title, labels, 3, true, selected); +} + +int bongo_cat_pref_fps(struct nk_context *context, const char *id, + const char *title, int fps) { + const char *labels[] = {"30 FPS", "60 FPS"}; + return choice_row(context, id, title, labels, 2, false, + fps == 30 ? 0 : 1) == 0 ? 30 : 60; +} diff --git a/src/ui/preferences_theme.h b/src/ui/preferences/preferences_theme.h similarity index 71% rename from src/ui/preferences_theme.h rename to src/ui/preferences/preferences_theme.h index f4cfc518..5c8f3c0b 100644 --- a/src/ui/preferences_theme.h +++ b/src/ui/preferences/preferences_theme.h @@ -5,5 +5,7 @@ int bongo_cat_pref_theme(struct nk_context *context, const char *id, const char *title, const char *const *labels, int selected); +int bongo_cat_pref_fps(struct nk_context *context, const char *id, + const char *title, int fps); #endif diff --git a/src/ui/preferences_toggle.c b/src/ui/preferences/preferences_toggle.c similarity index 50% rename from src/ui/preferences_toggle.c rename to src/ui/preferences/preferences_toggle.c index 709e4e8d..18169be8 100644 --- a/src/ui/preferences_toggle.c +++ b/src/ui/preferences/preferences_toggle.c @@ -3,13 +3,12 @@ #include "ui_backend.h" #include "ui_catime.h" #include "ui_paint.h" +#include "bongo_cat/config.h" #include -bool bongo_cat_pref_control_toggle(struct nk_context *context, - const char *id, bool *value) { - struct nk_rect cell; - if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; +static bool toggle_at(struct nk_context *context, const char *id, + bool *value, struct nk_rect cell) { const float effect_margin = 18.0f; struct nk_rect track = nk_rect(cell.x + cell.w - 46 - effect_margin, cell.y + (cell.h - 24) * .5f, 46, 24); @@ -53,3 +52,58 @@ bool bongo_cat_pref_control_toggle(struct nk_context *context, BONGO_CAT_UI_CURSOR_POINTER); return changed; } + +bool bongo_cat_pref_control_toggle(struct nk_context *context, + const char *id, bool *value) { + struct nk_rect cell; + if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; + return toggle_at(context, id, value, cell); +} + +static bool draw_swatches(struct nk_context *context, const char *id, + struct nk_rect cell, BongoCatObsBackgroundColor *selected) { + const float slot = 25.0f, switch_width = 64.0f; + float left = cell.x + cell.w - switch_width - + BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT * slot - 7.0f; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + bool changed = false; + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) { + BongoCatObsBackgroundColor color = (BongoCatObsBackgroundColor)i; + struct nk_rect hit = nk_rect(left + i * slot, + cell.y + (cell.h - 24.0f) * .5f, 24.0f, 24.0f); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, hit); + if (nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, hit)) { + *selected = color; + changed = true; + } + char animation_id[96]; + snprintf(animation_id, sizeof(animation_id), + "obs-background-%s-%d", id, i); + float amount = bongo_cat_ui_animate_eased(context, animation_id, + *selected == color ? 1.0f : 0.0f, 180.0f, + BONGO_CAT_UI_EASE_STANDARD); + float size = 14.0f + 6.0f * amount; + struct nk_rect circle = nk_rect(hit.x + (hit.w - size) * .5f, + hit.y + (hit.h - size) * .5f, size, size); + uint32_t rgb = bongo_cat_obs_background_color_rgb(color); + if (amount > 0.0f && p.effects) + bongo_cat_ui_paint_shadow(context, circle, size * .5f, + 0, 1, 4, 0, nk_rgba(0, 0, 0, 51)); + nk_fill_circle(canvas, circle, nk_rgb((rgb >> 16) & 255, + (rgb >> 8) & 255, rgb & 255)); + nk_stroke_circle(canvas, circle, 1.0f, p.border); + if (hover) bongo_cat_ui_cursor_hover_rect(context, hit, + BONGO_CAT_UI_CURSOR_POINTER); + } + return changed; +} + +bool bongo_cat_pref_control_obs_background(struct nk_context *context, + const char *id, bool *enabled, BongoCatObsBackgroundColor *color) { + struct nk_rect cell; + if (nk_widget(&cell, context) == NK_WIDGET_INVALID) return false; + bool changed = toggle_at(context, id, enabled, cell); + return (*enabled && draw_swatches(context, id, cell, color)) || changed; +} diff --git a/src/ui/preferences/preferences_widget_shortcut.c b/src/ui/preferences/preferences_widget_shortcut.c new file mode 100644 index 00000000..f1139f58 --- /dev/null +++ b/src/ui/preferences/preferences_widget_shortcut.c @@ -0,0 +1,105 @@ +#include "preferences_widgets.h" +#include "bongo_cat/shortcut.h" +#include "preferences_widgets_internal.h" +#include "preferences_shortcut_clear.h" +#include "ui_animation.h" +#include "ui_backend.h" +#include "ui_catime.h" +#include "ui_icons.h" +#include "ui_paint.h" + +#include +#include +#include + +int bongo_cat_pref_edit(struct nk_context *context, const char *id, + const char *title, const char *detail, const char *value, + bool recording, const char *idle_hint, const char *record_hint) { + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; + if (!bongo_cat_pref_form_begin(context, id, lines, &saved)) return false; + char shortcut_label[BONGO_CAT_SHORTCUT_CAP * 2]; + bongo_cat_shortcut_format(value, shortcut_label, sizeof(shortcut_label)); + const char *shown = recording ? record_hint : + (value && value[0] ? shortcut_label : idle_hint); + const struct nk_user_font *font = bongo_cat_ui_body_font(context); + float width = font->width(font->userdata, font->height, shown, + nk_strlen(shown)); + float control_width = NK_CLAMP(180.0f, width + 64.0f, 260.0f); + bongo_cat_pref_form_title_sized(context, title, control_width); + struct nk_rect bounds; + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) { + nk_layout_row_end(context); + bongo_cat_pref_form_end(context, &saved); + return false; + } + const float effect_margin = 6.0f; + bounds = nk_rect(bounds.x + bounds.w - control_width, bounds.y, + control_width - effect_margin, bounds.h); + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); + char hover_id[80]; + snprintf(hover_id, sizeof(hover_id), "shortcut-hover-%s", id); + float hover_amount = bongo_cat_ui_animate_eased(context, hover_id, + hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, bounds, 10, recording ? p.hover_pink : + bongo_cat_ui_color_mix(p.field, p.hover, hover_amount)); + nk_stroke_rect(canvas, bounds, 10, recording ? 2.0f : 1.0f, + recording ? p.pink : bongo_cat_ui_color_mix( + p.border_subtle, p.accent, hover_amount)); + if (recording) { + float phase = (float)(SDL_GetTicksNS() % 1500000000ULL) / + 1500000000.0f; + float pulse = .5f - .5f * cosf(phase * 6.2831853f); + struct nk_rect glow = nk_rect(bounds.x - pulse * 5, + bounds.y - pulse * 5, bounds.w + pulse * 10, + bounds.h + pulse * 10); + nk_stroke_rect(canvas, glow, 12, 2, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, + (nk_byte)(150 * (1.0f - pulse)))); + } + bool has_shortcut = value && value[0]; + bool can_clear = !recording && has_shortcut; + bool show_keyboard = recording || !has_shortcut; + float icon_width = show_keyboard ? 26.0f : 0.0f; + float text_width = NK_MIN(width, bounds.w - icon_width - + (can_clear ? 34.0f : 16.0f)); + float group_width = icon_width + text_width + (can_clear ? 20.0f : 0.0f); + float group_x = bounds.x + NK_MAX(14.0f, (bounds.w - group_width) * .5f); + float text_x = group_x + icon_width; + struct nk_color icon_color = recording ? p.pink : + bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount); + if (show_keyboard) { + struct nk_rect keyboard = nk_rect(group_x, bounds.y + 10, 18, 18); + if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_KEYBOARD, + keyboard, icon_color)) { + nk_stroke_rect(canvas, keyboard, 3, 1.5f, icon_color); + for (int i = 0; i < 3; ++i) + nk_stroke_line(canvas, keyboard.x + 3 + i * 3, + keyboard.y + 4, keyboard.x + 4 + i * 3, + keyboard.y + 4, 1, icon_color); + nk_stroke_line(canvas, keyboard.x + 3, keyboard.y + 8, + keyboard.x + 10, keyboard.y + 8, 1, icon_color); + } + } + struct nk_rect text = nk_rect(text_x, + bounds.y + (bounds.h - font->height) * .5f, + NK_MIN(width + 1, bounds.x + bounds.w - text_x - + (can_clear ? 24.0f : 8.0f)), font->height); + nk_draw_text(canvas, text, shown, nk_strlen(shown), font, + nk_rgba(0, 0, 0, 0), recording ? p.pink : + (has_shortcut ? p.text : + bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount))); + struct nk_rect clear_bounds = nk_rect(bounds.x + bounds.w - 25, + bounds.y + 8, 17, 20); + bool clear = bongo_cat_pref_shortcut_clear(context, canvas, id, + clear_bounds, p, 1.0f, can_clear); + if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, + BONGO_CAT_UI_CURSOR_POINTER); + bool clicked = !clear && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds) != 0; + nk_layout_row_end(context); + bongo_cat_pref_description(context, detail, lines); + bongo_cat_pref_form_end(context, &saved); + return clear ? -1 : clicked; +} diff --git a/src/ui/preferences_widgets.c b/src/ui/preferences/preferences_widgets.c similarity index 52% rename from src/ui/preferences_widgets.c rename to src/ui/preferences/preferences_widgets.c index 3b15ee96..c8e4f7ad 100644 --- a/src/ui/preferences_widgets.c +++ b/src/ui/preferences/preferences_widgets.c @@ -1,33 +1,42 @@ #include "preferences_widgets.h" +#include "preferences_widgets_internal.h" #include "preferences_controls.h" -#include "preferences_shortcut_clear.h" -#include "ui_animation.h" #include "ui_backend.h" #include "ui_catime.h" -#include "ui_icons.h" #include "ui_paint.h" - -#include -#include -#include -typedef struct FormStyle { - struct nk_style_item background; - struct nk_color window_color; - struct nk_color border_color; - struct nk_vec2 padding; - struct nk_vec2 spacing; - float border; -} FormStyle; +#include "ui_tooltip.h" static struct nk_context *section_context; static bool section_first; -static int detail_lines(const struct nk_context *context, const char *text) { +static const float description_line_height = 19.0f; +static bool has_line_break(const char *text) { + if (!text) return false; + while (*text) { + if (*text++ == '\n') return true; + } + return false; +} +static int wrapped_line_count(const struct nk_user_font *font, + const char *text, int length, float width) { + if (length <= 0) return 1; + float measured = font->width(font->userdata, font->height, text, length); + return (int)(measured / width) + 1; +} +int bongo_cat_pref_detail_lines(const struct nk_context *context, + const char *text) { if (!text || !text[0]) return 0; const struct nk_user_font *font = bongo_cat_ui_caption_font(context); - float width = nk_window_get_content_region(context).w - 310.0f; + float width = nk_window_get_content_region(context).w - + (has_line_break(text) ? 30.0f : 310.0f); if (width < 220.0f) width = 220.0f; - float measured = font->width(font->userdata, font->height, - text, nk_strlen(text)); - int lines = (int)(measured / width) + 1; + int lines = 0; + const char *segment = text; + for (;;) { + const char *end = segment; + while (*end && *end != '\n') ++end; + lines += wrapped_line_count(font, segment, (int)(end - segment), width); + if (!*end) break; + segment = end + 1; + } return NK_CLAMP(1, lines, 3); } static void restore_style(struct nk_context *context, const FormStyle *saved) { @@ -38,7 +47,7 @@ static void restore_style(struct nk_context *context, const FormStyle *saved) { context->style.window.spacing = saved->spacing; context->style.window.group_border = saved->border; } -static bool form_begin(struct nk_context *context, const char *id, +bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, int lines, FormStyle *saved) { saved->background = context->style.window.fixed_background; saved->window_color = context->style.window.background; @@ -53,57 +62,67 @@ static bool form_begin(struct nk_context *context, const char *id, context->style.window.group_padding = nk_vec2(13, 11); context->style.window.spacing = nk_vec2(8, 3); context->style.window.group_border = 0; - float height = lines ? 92.0f : 76.0f; + float height = lines ? 70.0f + description_line_height * lines : 76.0f; if (section_context == context && section_first && !lines) height = 69.0f; if (section_context == context) section_first = false; nk_layout_row_dynamic(context, height, 1); if (!nk_group_begin(context, id, NK_WINDOW_NO_SCROLLBAR)) { + bongo_cat_pref_row_icon_clear(context); restore_style(context, saved); return false; } return true; } -static void form_end(struct nk_context *context, const FormStyle *saved) { - nk_group_end(context); - restore_style(context, saved); -} +void bongo_cat_pref_form_end(struct nk_context *context, + const FormStyle *saved) { + bongo_cat_pref_row_icon_clear(context); + nk_group_end(context); restore_style(context, saved); } +#define form_begin bongo_cat_pref_form_begin +#define form_end bongo_cat_pref_form_end static float left_width(struct nk_context *context) { return NK_MAX(220.0f, nk_window_get_content_region(context).w - 228.0f); } -static void form_title_sized(struct nk_context *context, const char *title, - float control_width) { - float available = nk_window_get_content_region(context).w; - float left = NK_MAX(220.0f, available - control_width - 8.0f); - nk_layout_row_begin(context, NK_STATIC, 36, 2); - nk_layout_row_push(context, left); - nk_style_push_font(context, bongo_cat_ui_label_font(context)); - struct nk_vec2 text_padding = context->style.text.padding; - context->style.text.padding.x += 5.0f; - nk_label(context, title, NK_TEXT_LEFT); - context->style.text.padding = text_padding; - nk_style_pop_font(context); - nk_layout_row_push(context, NK_MAX(control_width, available - left - 8.0f)); -} static void form_title(struct nk_context *context, const char *title) { - form_title_sized(context, title, 220.0f); -} -static void description(struct nk_context *context, const char *text, + bongo_cat_pref_form_title_sized(context, title, 220.0f); } +void bongo_cat_pref_description(struct nk_context *context, const char *text, int lines) { if (!lines) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - float left = left_width(context); - nk_layout_row_begin(context, NK_STATIC, 22.0f * lines, 2); - nk_layout_row_push(context, left); + float available = nk_window_get_content_region(context).w; + bool multiline = has_line_break(text); + float left = multiline ? available : left_width(context); + float wrap_width = available - (multiline ? 10.0f : 310.0f); + if (wrap_width < 220.0f) wrap_width = 220.0f; + const struct nk_user_font *font = bongo_cat_ui_caption_font(context); nk_style_push_font(context, bongo_cat_ui_caption_font(context)); struct nk_vec2 text_padding = context->style.text.padding; + float row_spacing = context->style.window.spacing.y; context->style.text.padding.x += 5.0f; - nk_label_colored_wrap(context, text, p.muted); + context->style.window.spacing.y = 0; + const char *segment = text; + int remaining = lines; + while (remaining > 0) { + const char *end = segment; + while (*end && *end != '\n') ++end; + int segment_lines = wrapped_line_count(font, segment, + (int)(end - segment), wrap_width); + segment_lines = NK_MIN(segment_lines, remaining); + nk_layout_row_begin(context, NK_STATIC, + description_line_height * segment_lines, multiline ? 1 : 2); + nk_layout_row_push(context, left); + nk_text_wrap_colored(context, segment, (int)(end - segment), p.muted); + if (!multiline) { + nk_layout_row_push(context, NK_MAX(1.0f, available - left - 8.0f)); + nk_spacing(context, 1); + } + nk_layout_row_end(context); + remaining -= segment_lines; + if (!*end) break; + segment = end + 1; + } + context->style.window.spacing.y = row_spacing; context->style.text.padding = text_padding; nk_style_pop_font(context); - nk_layout_row_push(context, NK_MAX(1.0f, - nk_window_get_content_region(context).w - left - 8.0f)); - nk_spacing(context, 1); - nk_layout_row_end(context); } static bool secondary_button(struct nk_context *context, const char *label) { BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); @@ -136,155 +155,131 @@ void bongo_cat_pref_section(struct nk_context *context, const char *title) { nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.text); } - +void bongo_cat_pref_section_icon(struct nk_context *context, + const char *title, BongoCatPrefIcon icon) { + section_context = context; section_first = true; + const struct nk_user_font *font = bongo_cat_ui_label_font(context); + float width = nk_window_get_content_region(context).w - 42; + float measured = font->width(font->userdata, font->height, + title, nk_strlen(title)); + if (width > 0 && measured > width) { + /* Leave room for word wrapping in longer translations. */ + float height = (float)((int)(measured / width) + 2) * + NK_MAX(22.0f, font->height + 2.0f); + struct nk_vec2 spacing = context->style.window.spacing; + struct nk_vec2 padding = context->style.text.padding; + context->style.window.spacing.x = 0; + context->style.text.padding = nk_vec2(0, 0); + nk_style_push_font(context, font); + nk_layout_row_begin(context, NK_STATIC, height, 2); + nk_layout_row_push(context, 42); + struct nk_rect bounds; + if (nk_widget(&bounds, context) != NK_WIDGET_INVALID) { + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); + bongo_cat_pref_icon_draw(canvas, + nk_rect(bounds.x + 14, bounds.y + 2, 18, 18), icon, p.accent); + } + nk_layout_row_push(context, width); + nk_text_wrap_colored(context, title, nk_strlen(title), + bongo_cat_ui_palette(bongo_cat_ui_dark(context)).accent); + nk_layout_row_end(context); + nk_style_pop_font(context); + context->style.window.spacing = spacing; + context->style.text.padding = padding; + return; + } + struct nk_rect bounds; + nk_layout_row_dynamic(context, 22, 1); + if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; + BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); + struct nk_command_buffer *canvas = nk_window_get_canvas(context); + nk_fill_rect(canvas, nk_rect(bounds.x, bounds.y + 2, 4, 18), 2, p.pink); + struct nk_rect icon_bounds = nk_rect(bounds.x + 14, + bounds.y + 2, 18, 18); + bongo_cat_pref_icon_draw(canvas, icon_bounds, icon, p.accent); + struct nk_rect text = nk_rect(bounds.x + 42, + bounds.y + (bounds.h - font->height) * .5f, + bounds.w - 42, font->height); + nk_draw_text(canvas, text, title, nk_strlen(title), font, + nk_rgba(0, 0, 0, 0), p.accent); +} bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *detail, bool *value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; - form_title_sized(context, title, 80.0f); + bongo_cat_pref_form_title_sized(context, title, 80.0f); bool changed = bongo_cat_pref_control_toggle(context, id, value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); + form_end(context, &saved); return changed; +} +bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, + const char *title, const char *question, const char *reply, bool *enabled, + BongoCatObsBackgroundColor *color) { + int lines = question && question[0] ? 1 : 0; FormStyle saved; + if (!form_begin(context, id, lines, &saved)) return false; + bongo_cat_pref_form_title_sized(context, title, 230.0f); + bool changed = bongo_cat_pref_control_obs_background(context, id, enabled, color); + nk_layout_row_end(context); + bongo_cat_ui_question_tooltip(context, question, reply); form_end(context, &saved); return changed; } bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *detail, float minimum, float *value, float maximum, float step, float default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_float(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } bool bongo_cat_pref_int(struct nk_context *context, const char *id, const char *title, const char *detail, int minimum, int *value, int maximum, int step, int default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_int(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } bool bongo_cat_pref_slider(struct nk_context *context, const char *id, const char *title, const char *detail, float minimum, float *value, float maximum, float step, float default_value) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool changed = bongo_cat_pref_control_slider(context, id, minimum, value, maximum, step, default_value); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return changed; } - int bongo_cat_pref_combo(struct nk_context *context, const char *id, const char *title, const char *detail, const char *const *items, int count, int selected) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return selected; - form_title_sized(context, title, 176.0f); + bongo_cat_pref_form_title_sized(context, title, 176.0f); selected = bongo_cat_pref_control_combo(context, id, items, count, selected); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return selected; } - -int bongo_cat_pref_edit(struct nk_context *context, const char *id, - const char *title, const char *detail, const char *value, - bool recording, const char *idle_hint, const char *record_hint) { - int lines = detail_lines(context, detail); FormStyle saved; - if (!form_begin(context, id, lines, &saved)) return false; - const char *shown = recording ? record_hint : (value && value[0] ? value : idle_hint); - const struct nk_user_font *font = bongo_cat_ui_body_font(context); - float width = font->width(font->userdata, font->height, shown, nk_strlen(shown)); - float control_width = NK_CLAMP(180.0f, width + 64.0f, 260.0f); - form_title_sized(context, title, control_width); - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) { - nk_layout_row_end(context); form_end(context, &saved); return false; - } - const float effect_margin = 6.0f; - bounds = nk_rect(bounds.x + bounds.w - control_width, bounds.y, - control_width - effect_margin, bounds.h); - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - char hover_id[80]; snprintf(hover_id, sizeof(hover_id), "shortcut-hover-%s", id); - float hover_amount = bongo_cat_ui_animate_eased(context, hover_id, - hover ? 1.0f : 0.0f, 200, BONGO_CAT_UI_EASE_STANDARD); - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - nk_fill_rect(canvas, bounds, 10, recording ? p.hover_pink : - bongo_cat_ui_color_mix(p.field, p.hover, hover_amount)); - nk_stroke_rect(canvas, bounds, 10, recording ? 2.0f : 1.0f, - recording ? p.pink : bongo_cat_ui_color_mix( - p.border_subtle, p.accent, hover_amount)); - if (recording) { - float phase = (float)(SDL_GetTicksNS() % 1500000000ULL) / - 1500000000.0f; - float pulse = .5f - .5f * cosf(phase * 6.2831853f); - struct nk_rect glow = nk_rect(bounds.x - pulse * 5, - bounds.y - pulse * 5, bounds.w + pulse * 10, - bounds.h + pulse * 10); - nk_stroke_rect(canvas, glow, 12, 2, - nk_rgba(p.pink.r, p.pink.g, p.pink.b, - (nk_byte)(150 * (1.0f - pulse)))); - } - bool has_shortcut = value && value[0]; - bool can_clear = !recording && has_shortcut; - bool show_keyboard = recording || !has_shortcut; - float icon_width = show_keyboard ? 26.0f : 0.0f; - float text_width = NK_MIN(width, bounds.w - icon_width - - (can_clear ? 34.0f : 16.0f)); - float group_width = icon_width + text_width + (can_clear ? 20.0f : 0.0f); - float group_x = bounds.x + NK_MAX(14.0f, (bounds.w - group_width) * .5f); - float text_x = group_x + icon_width; - struct nk_color icon_color = recording ? p.pink : - bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount); - if (show_keyboard) { - struct nk_rect keyboard = nk_rect(group_x, bounds.y + 10, 18, 18); - if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_KEYBOARD, - keyboard, icon_color)) { - nk_stroke_rect(canvas, keyboard, 3, 1.5f, icon_color); - for (int i = 0; i < 3; ++i) - nk_stroke_line(canvas, keyboard.x + 3 + i * 3, keyboard.y + 4, - keyboard.x + 4 + i * 3, keyboard.y + 4, 1, icon_color); - nk_stroke_line(canvas, keyboard.x + 3, keyboard.y + 8, - keyboard.x + 10, keyboard.y + 8, 1, icon_color); - } - } - struct nk_rect text = nk_rect(text_x, bounds.y + (bounds.h - font->height) * .5f, - NK_MIN(width + 1, bounds.x + bounds.w - text_x - - (can_clear ? 24.0f : 8.0f)), font->height); - nk_draw_text(canvas, text, shown, nk_strlen(shown), font, nk_rgba(0, 0, 0, 0), - recording ? p.pink : (has_shortcut ? p.text : - bongo_cat_ui_color_mix(p.muted, p.accent, hover_amount))); - struct nk_rect clear_bounds = nk_rect(bounds.x + bounds.w - 25, - bounds.y + 8, 17, 20); - bool clear = bongo_cat_pref_shortcut_clear(context, canvas, id, - clear_bounds, p, 1.0f, can_clear); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - bool clicked = !clear && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds) != 0; - nk_layout_row_end(context); description(context, detail, lines); - form_end(context, &saved); - return clear ? -1 : clicked; -} - bool bongo_cat_pref_button(struct nk_context *context, const char *id, const char *title, const char *detail, const char *button) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return false; form_title(context, title); bool result = secondary_button(context, button); - nk_layout_row_end(context); description(context, detail, lines); + nk_layout_row_end(context); bongo_cat_pref_description(context, detail, lines); form_end(context, &saved); return result; } - void bongo_cat_pref_status(struct nk_context *context, const char *id, const char *title, const char *detail) { - int lines = detail_lines(context, detail); FormStyle saved; + int lines = bongo_cat_pref_detail_lines(context, detail); FormStyle saved; if (!form_begin(context, id, lines, &saved)) return; BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); nk_layout_row_dynamic(context, 44, 1); @@ -296,5 +291,6 @@ void bongo_cat_pref_status(struct nk_context *context, const char *id, row.y + (row.h - font->height) * .5f, row.w - 20, font->height); nk_draw_text(nk_window_get_canvas(context), text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.text); - description(context, detail, lines); form_end(context, &saved); + bongo_cat_pref_description(context, detail, lines); + form_end(context, &saved); } diff --git a/src/ui/preferences_widgets.h b/src/ui/preferences/preferences_widgets.h similarity index 70% rename from src/ui/preferences_widgets.h rename to src/ui/preferences/preferences_widgets.h index 550da408..c8dd453e 100644 --- a/src/ui/preferences_widgets.h +++ b/src/ui/preferences/preferences_widgets.h @@ -2,11 +2,23 @@ #define BONGO_CAT_PREFERENCES_WIDGETS_H #include +#include "bongo_cat/config.h" #include "nuklear_config.h" +#include "preferences_icons.h" void bongo_cat_pref_section(struct nk_context *context, const char *title); +void bongo_cat_pref_section_icon(struct nk_context *context, + const char *title, BongoCatPrefIcon icon); +void bongo_cat_pref_row_icon(struct nk_context *context, + BongoCatPrefIcon icon); bool bongo_cat_pref_toggle(struct nk_context *context, const char *id, const char *title, const char *description, bool *value); +bool bongo_cat_pref_toggle_float(struct nk_context *context, const char *id, + const char *title, bool *enabled, float minimum, float *value, + float maximum, float step, float default_value); +bool bongo_cat_pref_obs_background(struct nk_context *context, const char *id, + const char *title, const char *question, const char *reply, bool *enabled, + BongoCatObsBackgroundColor *color); bool bongo_cat_pref_float(struct nk_context *context, const char *id, const char *title, const char *description, float minimum, float *value, float maximum, float step, float default_value); diff --git a/src/ui/preferences/preferences_widgets_compound.c b/src/ui/preferences/preferences_widgets_compound.c new file mode 100644 index 00000000..efacc3ef --- /dev/null +++ b/src/ui/preferences/preferences_widgets_compound.c @@ -0,0 +1,31 @@ +#include "preferences_widgets.h" +#include "preferences_widgets_internal.h" +#include "preferences_controls.h" + +bool bongo_cat_pref_toggle_float(struct nk_context *context, const char *id, + const char *title, bool *enabled, float minimum, float *value, + float maximum, float step, float default_value) { + FormStyle saved; + if (!bongo_cat_pref_form_begin(context, id, 0, &saved)) return false; + float available = nk_window_get_content_region(context).w; + int columns = *enabled ? 3 : 2; + float input_width = *enabled ? 122.0f : 0.0f; + float spacing = 8.0f * (columns - 1); + float left = NK_MAX(220.0f, + available - input_width - 80.0f - spacing); + nk_layout_row_begin(context, NK_STATIC, 36, columns); + nk_layout_row_push(context, left); + bongo_cat_pref_form_label(context, title); + bool changed = false; + if (*enabled) { + nk_layout_row_push(context, input_width); + changed = bongo_cat_pref_control_float(context, id, + minimum, value, maximum, step, default_value); + } + nk_layout_row_push(context, NK_MAX(80.0f, + available - left - input_width - spacing)); + changed = bongo_cat_pref_control_toggle(context, id, enabled) || changed; + nk_layout_row_end(context); + bongo_cat_pref_form_end(context, &saved); + return changed; +} diff --git a/src/ui/preferences/preferences_widgets_internal.h b/src/ui/preferences/preferences_widgets_internal.h new file mode 100644 index 00000000..b904ee07 --- /dev/null +++ b/src/ui/preferences/preferences_widgets_internal.h @@ -0,0 +1,29 @@ +#ifndef BONGO_CAT_PREFERENCES_WIDGETS_INTERNAL_H +#define BONGO_CAT_PREFERENCES_WIDGETS_INTERNAL_H + +#include +#include "nuklear_config.h" + +typedef struct FormStyle { + struct nk_style_item background; + struct nk_color window_color; + struct nk_color border_color; + struct nk_vec2 padding; + struct nk_vec2 spacing; + float border; +} FormStyle; + +bool bongo_cat_pref_form_begin(struct nk_context *context, const char *id, + int lines, FormStyle *saved); +void bongo_cat_pref_form_end(struct nk_context *context, + const FormStyle *saved); +void bongo_cat_pref_form_label(struct nk_context *context, const char *title); +void bongo_cat_pref_row_icon_clear(struct nk_context *context); +int bongo_cat_pref_detail_lines(const struct nk_context *context, + const char *text); +void bongo_cat_pref_form_title_sized(struct nk_context *context, + const char *title, float control_width); +void bongo_cat_pref_description(struct nk_context *context, + const char *text, int lines); + +#endif diff --git a/src/ui/preferences_behavior_rename.c b/src/ui/preferences_behavior_rename.c deleted file mode 100644 index cd2893af..00000000 --- a/src/ui/preferences_behavior_rename.c +++ /dev/null @@ -1,159 +0,0 @@ -#include "preferences_state.h" - -#include -#include -#include - -static BongoCatBehaviorShortcut *binding_for(BongoCatConfig *config, - const char *id) { - for (size_t i = 0; i < config->behavior_shortcut_count; ++i) - if (!strcmp(config->behavior_shortcuts[i].id, id)) - return &config->behavior_shortcuts[i]; - if (config->behavior_shortcut_count >= BONGO_CAT_BEHAVIOR_CAP) return NULL; - BongoCatBehaviorShortcut *binding = - &config->behavior_shortcuts[config->behavior_shortcut_count++]; - memset(binding, 0, sizeof(*binding)); - snprintf(binding->id, sizeof(binding->id), "%s", id); - return binding; -} - -static const char *display_label(const BongoCatBehaviorEntry *entry, - const BongoCatBehaviorShortcut *binding) { - return binding && binding->label[0] ? binding->label : entry->label; -} - -void bongo_cat_preferences_behavior_rename_finish( - BongoCatPreferences *value, bool save) { - if (!value || !value->behavior_rename_id[0]) return; - bool label_changed = false; - if (save) { - size_t start = 0, end = strlen(value->behavior_rename_text); - while (start < end && (value->behavior_rename_text[start] == ' ' || - value->behavior_rename_text[start] == '\t')) start++; - while (end > start && (value->behavior_rename_text[end - 1] == ' ' || - value->behavior_rename_text[end - 1] == '\t')) end--; - if (start) memmove(value->behavior_rename_text, - value->behavior_rename_text + start, end - start); - value->behavior_rename_text[end - start] = '\0'; - BongoCatBehaviorEntry *entry = NULL; - for (size_t i = 0; i < value->app->behaviors.count; ++i) - if (!strcmp(value->app->behaviors.entries[i].id, - value->behavior_rename_id)) { - entry = &value->app->behaviors.entries[i]; break; - } - BongoCatBehaviorShortcut *binding = binding_for(&value->app->config, - value->behavior_rename_id); - if (binding) { - char label[BONGO_CAT_ID_CAP]; - snprintf(label, sizeof(label), "%s", - entry && !strcmp(value->behavior_rename_text, entry->label) ? "" : - value->behavior_rename_text); - label_changed = strcmp(binding->label, label) != 0; - snprintf(binding->label, sizeof(binding->label), "%s", label); - } - } - value->behavior_rename_id[0] = '\0'; - value->behavior_rename_text[0] = '\0'; - value->behavior_rename_select_all = false; - SDL_StopTextInput(value->window); - if (label_changed) bongo_cat_preferences_reload_fonts(value); - value->render_dirty = true; -} - -void bongo_cat_preferences_behavior_rename_begin(BongoCatPreferences *value, - const BongoCatBehaviorEntry *entry, BongoCatBehaviorShortcut *binding, - struct nk_rect bounds) { - bongo_cat_preferences_shortcut_cancel(value); - snprintf(value->behavior_rename_id, sizeof(value->behavior_rename_id), - "%s", entry->id); - snprintf(value->behavior_rename_text, sizeof(value->behavior_rename_text), - "%s", display_label(entry, binding)); - value->behavior_rename_bounds = bounds; - value->behavior_rename_select_all = true; - SDL_StartTextInput(value->window); - value->render_dirty = true; -} - -static void append_text(BongoCatPreferences *value, const char *text) { - if (!text) return; - if (value->behavior_rename_select_all) { - value->behavior_rename_text[0] = '\0'; - value->behavior_rename_select_all = false; - } - size_t used = strlen(value->behavior_rename_text); - for (const char *source = text; *source;) { - nk_rune rune = 0; - int decoded = nk_utf_decode(source, &rune, (int)strlen(source)); - if (decoded <= 0) { source++; continue; } - size_t bytes = (size_t)decoded; - if (used + bytes >= sizeof(value->behavior_rename_text)) break; - if (rune < 32) { source += bytes; continue; } - memcpy(value->behavior_rename_text + used, source, bytes); - used += bytes; source += bytes; - } - value->behavior_rename_text[used] = '\0'; -} - -static void erase_text(BongoCatPreferences *value, SDL_Keycode key) { - size_t length = strlen(value->behavior_rename_text); - if (value->behavior_rename_select_all) length = 0; - else if (length && key == SDLK_BACKSPACE) { - do length--; while (length && - ((unsigned char)value->behavior_rename_text[length] & 0xc0) == 0x80); - } - value->behavior_rename_text[length] = '\0'; - value->behavior_rename_select_all = false; -} - -static void clipboard_event(BongoCatPreferences *value, SDL_Keycode key) { - if (key == SDLK_V) { - char *clipboard = SDL_GetClipboardText(); - append_text(value, clipboard); - SDL_free(clipboard); - return; - } - if (value->behavior_rename_select_all) - SDL_SetClipboardText(value->behavior_rename_text); - if (key == SDLK_X && value->behavior_rename_select_all) - value->behavior_rename_text[0] = '\0'; -} - -bool bongo_cat_preferences_behavior_rename_event( - BongoCatPreferences *value, const SDL_Event *event) { - if (!value || !event || !value->behavior_rename_id[0]) return false; - if (event->type == SDL_EVENT_TEXT_INPUT) { - append_text(value, event->text.text); - value->render_dirty = true; return true; - } - if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST) { - bongo_cat_preferences_behavior_rename_finish(value, true); return false; - } - if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && - event->button.button == SDL_BUTTON_LEFT) { - float scale = value->ui.layout_scale > 0.0f ? - value->ui.layout_scale : 1.0f; - float x = event->button.x / scale, y = event->button.y / scale; - if (!NK_INBOX(x, y, value->behavior_rename_bounds.x, - value->behavior_rename_bounds.y, value->behavior_rename_bounds.w, - value->behavior_rename_bounds.h)) - bongo_cat_preferences_behavior_rename_finish(value, true); - return false; - } - if (event->type != SDL_EVENT_KEY_DOWN && - event->type != SDL_EVENT_KEY_UP) return false; - if (event->type == SDL_EVENT_KEY_UP) return true; - SDL_Keycode key = event->key.key; - bool control = (event->key.mod & SDL_KMOD_CTRL) != 0; - if (key == SDLK_ESCAPE) - bongo_cat_preferences_behavior_rename_finish(value, false); - else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) - bongo_cat_preferences_behavior_rename_finish(value, true); - else if (control && key == SDLK_A) value->behavior_rename_select_all = true; - else if (control && (key == SDLK_C || key == SDLK_X || key == SDLK_V)) - clipboard_event(value, key); - else if (key == SDLK_BACKSPACE || key == SDLK_DELETE) erase_text(value, key); - else if (key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_HOME || - key == SDLK_END) value->behavior_rename_select_all = false; - value->render_dirty = true; - return true; -} diff --git a/src/ui/preferences_model.c b/src/ui/preferences_model.c deleted file mode 100644 index 6c63369a..00000000 --- a/src/ui/preferences_model.c +++ /dev/null @@ -1,303 +0,0 @@ -#include "preferences_state.h" -#include "preferences_model_cover.h" -#include "preferences_widgets.h" -#include "preferences_notice.h" -#include "ui_backend.h" -#include "ui_catime.h" -#include "ui_animation.h" -#include "ui_paint.h" -#include "ui_icons.h" -#include "bongo_cat/i18n.h" -#include "bongo_cat/path.h" -#include "bongo_cat/platform.h" -#include "bongo_cat/preferences.h" - -#include -#include -#include - -static const char *tr(BongoCatApp *app, const char *key, - const char *fallback) { - return bongo_cat_i18n_get(app->i18n, key, fallback); -} - -void bongo_cat_preferences_import_complete(BongoCatApp *app, - BongoCatResult result, const BongoCatError *error, size_t imported_count) { - if (!app || !app->preferences) return; - const char *message = result == BONGO_CAT_OK ? tr(app, - "pages.preference.model.hints.importSuccess", "Model imported") : - (error && error->message[0] ? error->message : "Model import failed"); - if (app->smoke) { - if (result != BONGO_CAT_OK) app->exit_code = 1; - } else bongo_cat_preferences_notice_show(app, message, result != BONGO_CAT_OK); - if (result == BONGO_CAT_OK) { - bongo_cat_preferences_reload_fonts(app->preferences); - SDL_Log("Imported %zu model variant(s)", imported_count); - } - bongo_cat_preferences_invalidate(app->preferences); - bongo_cat_preferences_render(app->preferences); -} - -static const char *mode_label(BongoCatApp *app, BongoCatModelMode mode) { - if (mode == BONGO_CAT_MODE_KEYBOARD) - return tr(app, "native.modeKeyboard", "Keyboard"); - if (mode == BONGO_CAT_MODE_GAMEPAD) - return tr(app, "native.modeGamepad", "Gamepad"); - return tr(app, "native.modeStandard", "Standard"); -} - -static const char *model_name(const BongoCatModelEntry *entry) { - if (!strcmp(entry->id, "standard")) return "Standard"; - if (!strcmp(entry->id, "keyboard")) return "Keyboard"; - if (!strcmp(entry->id, "gamepad")) return "Gamepad"; - if (entry->display_name[0]) return entry->display_name; - return entry->id; -} - -static void text(struct nk_context *context, struct nk_command_buffer *canvas, - struct nk_rect bounds, const char *value, struct nk_color color, - const struct nk_user_font *font) { - if (!font) font = context->style.font; - nk_draw_text(canvas, bounds, value, nk_strlen(value), font, - nk_rgba(0, 0, 0, 0), color); -} - -static struct nk_rect outline_bounds(struct nk_rect bounds, float thickness) { - float inset = thickness * .5f; - return nk_rect(bounds.x + inset, bounds.y + inset, - NK_MAX(1.0f, bounds.w - thickness), - NK_MAX(1.0f, bounds.h - thickness)); -} - -static bool import_card(BongoCatPreferences *value, - struct nk_context *context) { - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; - bounds.y += 5.0f; bounds.h += 1.0f; - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - float lift = bongo_cat_ui_animate_eased(context, "model-import-hover", - hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_STANDARD); - bounds.y -= 2.0f * lift; - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, 14, - 0, 8, 24, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - nk_fill_rect(canvas, bounds, 14, hover ? p.hover_pink : p.hover); - bongo_cat_ui_paint_dashed_rounded(context, bounds, 14, 2, 5, 5, - hover ? p.pink : p.accent); - float cx = bounds.x + bounds.w * .5f, cy = bounds.y + 94; - bongo_cat_preferences_icon_draw(value, canvas, - BONGO_CAT_UI_ICON_UPLOAD, nk_rect(cx - 20, cy - 20, 40, 40), p.pink); - const char *label = tr(value->app, - "pages.preference.model.hints.clickOrDragToImport", "Import model directory"); - float width = value->ui.caption_font->width(value->ui.caption_font->userdata, - value->ui.caption_font->height, label, nk_strlen(label)); - text(context, canvas, nk_rect(cx - width * .5f, cy + 30, - NK_MIN(width + 1, bounds.w - 20), 24), label, p.accent, - value->ui.caption_font); - if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - return hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds); -} - -static void action_icon(BongoCatPreferences *value, - struct nk_command_buffer *canvas, struct nk_rect r, int icon, - struct nk_color color) { - bongo_cat_preferences_icon_draw(value, canvas, icon, - nk_rect(r.x + (r.w - 18) * .5f, r.y + (r.h - 18) * .5f, 18, 18), color); -} - -static void select_model(BongoCatPreferences *value, - const BongoCatModelEntry *entry) { - BongoCatError error = {0}; - if (bongo_cat_app_select_model_with_error(value->app, entry->id, &error)) { - bongo_cat_preferences_model_cover_capture(value->app, entry); - bongo_cat_preferences_reload_fonts(value); - bongo_cat_preferences_invalidate(value); - return; - } - const char *message = tr(value->app, "native.modelLoadFailed", - "Unable to display this model"); - bongo_cat_preferences_notice_show(value->app, message, true); - bongo_cat_preferences_invalidate(value); -} - -static void open_model_directory(BongoCatPreferences *value, - const BongoCatModelEntry *entry) { - const char *directory = entry->storage_directory[0] ? - entry->storage_directory : entry->directory; - if (bongo_cat_path_is_dir(directory) && - bongo_cat_platform_open_directory(directory)) return; - bongo_cat_preferences_notice_show(value->app, tr(value->app, - "pages.preference.model.hints.openDirectoryFailed", - "Unable to open model directory"), true); -} - -static void model_card(BongoCatPreferences *value, struct nk_context *context, - BongoCatModelEntry *entry) { - BongoCatApp *app = value->app; - bool selected = !strcmp(entry->id, app->config.current_model); - struct nk_rect bounds; - if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return; - bounds.y += 5.0f; bounds.h += 1.0f; - BongoCatUIPalette p = bongo_cat_ui_palette(bongo_cat_ui_dark(context)); - bool hover = nk_input_is_mouse_hovering_rect(&context->input, bounds); - char animation_id[BONGO_CAT_ID_CAP + 24]; - snprintf(animation_id, sizeof(animation_id), "model-hover-%s", entry->id); - float lift = bongo_cat_ui_animate_eased(context, animation_id, - hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SWIFT); - bounds.y -= 3.0f * lift; - struct nk_command_buffer *canvas = nk_window_get_canvas(context); - if (selected && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 8, 20, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - else if (hover && p.effects) bongo_cat_ui_paint_shadow(context, bounds, - 14, 0, 10, 28, 0, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 46)); - nk_fill_rect(canvas, bounds, 14, p.surface); - float preview_height = NK_MIN(128.0f, bounds.w * 354.0f / 612.0f); - struct nk_rect preview = nk_rect(bounds.x + 1, bounds.y + 1, - bounds.w - 2, preview_height); - nk_fill_rect(canvas, preview, 12, p.field); - float raster_scale = value->ui.raster_scale > 0.0f ? - value->ui.raster_scale : 1.0f; - const BongoCatModelCover *cover = bongo_cat_preferences_model_cover( - app, entry, NK_MAX(1, (int)lroundf(preview.w * raster_scale)), - NK_MAX(1, (int)lroundf(preview.h * raster_scale))); - if (cover) { - float scale = NK_MIN(preview.w / cover->width, preview.h / cover->height); - struct nk_rect image = nk_rect(preview.x + (preview.w - cover->width * scale) * .5f, - preview.y + (preview.h - cover->height * scale) * .5f, - cover->width * scale, cover->height * scale); - struct nk_image texture = nk_image_id((int)cover->texture); - nk_draw_image(canvas, image, &texture, nk_rgb(255, 255, 255)); - } - float info_y = preview.y + preview.h + 8; - text(context, canvas, nk_rect(bounds.x + 13, info_y, bounds.w - 26, 22), - model_name(entry), p.text, value->ui.label_font); - text(context, canvas, nk_rect(bounds.x + 13, info_y + 24, bounds.w - 26, 20), - mode_label(app, entry->mode), p.muted, value->ui.caption_font); - struct nk_rect actions = nk_rect(bounds.x + 1, bounds.y + bounds.h - 39, - bounds.w - 2, 38); - nk_fill_rect(canvas, actions, 0, p.field); - bool deletable = !entry->preset && !entry->managed; - float action_width = actions.w / (deletable ? 3.0f : 2.0f); - struct nk_rect first = nk_rect(actions.x, actions.y, action_width, actions.h); - struct nk_rect second = nk_rect(first.x + first.w, actions.y, - action_width, actions.h); - struct nk_rect third = nk_rect(second.x + second.w, actions.y, - action_width, actions.h); - nk_stroke_line(canvas, second.x, second.y + 10, second.x, - second.y + second.h - 10, 1, p.border_subtle); - bool first_hover = nk_input_is_mouse_hovering_rect(&context->input, first); - bool second_hover = nk_input_is_mouse_hovering_rect(&context->input, second); - bool third_hover = deletable && - nk_input_is_mouse_hovering_rect(&context->input, third); - if (first_hover) nk_fill_rect(canvas, first, 0, p.hover_pink); - if (second_hover) nk_fill_rect(canvas, second, 0, p.hover_pink); - if (third_hover) nk_fill_rect(canvas, third, 0, p.hover_pink); - action_icon(value, canvas, first, BONGO_CAT_UI_ICON_SMILE, - first_hover ? p.pink : p.muted); - action_icon(value, canvas, second, BONGO_CAT_UI_ICON_FOLDER, - second_hover ? p.pink : p.muted); - if (deletable) { - nk_stroke_line(canvas, third.x, third.y + 10, third.x, - third.y + third.h - 10, 1, p.border_subtle); - action_icon(value, canvas, third, BONGO_CAT_UI_ICON_TRASH, - third_hover ? p.danger : p.muted); - } - float outline_width = selected ? 2.0f : 1.0f; - struct nk_rect outline = outline_bounds(bounds, outline_width); - struct nk_color outline_color = selected ? p.pink : - bongo_cat_ui_color_mix(p.border_subtle, p.accent, lift); - nk_stroke_rect(canvas, outline, 14.0f - outline_width * .5f, - outline_width, outline_color); - if (first_hover || second_hover || third_hover || hover) - bongo_cat_ui_cursor_hover_rect(context, bounds, - BONGO_CAT_UI_CURSOR_POINTER); - if (first_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, first)) { - if (selected) - bongo_cat_preferences_behavior_dialog_open(value); - else select_model(value, entry); - } else if (second_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, second)) open_model_directory(value, entry); - else if (third_hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, third)) - bongo_cat_preferences_remove_dialog_open(app, entry->id); - else if (hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, bounds)) select_model(value, entry); -} - -static bool is_builtin(const char *id) { - return !strcmp(id, "standard") || !strcmp(id, "keyboard") || - !strcmp(id, "gamepad"); -} - -static bool has_nearby(const BongoCatApp *app) { - for (size_t i = 0; i < app->models.count; ++i) - if (app->models.entries[i].managed) return true; - return false; -} - -static void smoke_model_behavior(BongoCatPreferences *value) { - BongoCatApp *app = value->app; - if (app->smoke_preference_model_select) { - for (size_t i = 0; i < app->models.count; ++i) { - BongoCatModelEntry *entry = &app->models.entries[i]; - if (!entry->managed || !strcmp(entry->id, - app->config.current_model)) continue; - app->smoke_preference_model_select = false; - value->smoke_behavior_open_pending = true; - SDL_Log("Preferences smoke selecting nearby model %s", entry->id); - select_model(value, entry); - return; - } - } - if (value->smoke_behavior_open_pending && !value->font_reload_pending) { - value->smoke_behavior_open_pending = false; - bongo_cat_preferences_behavior_dialog_open(value); - } -} - -static void draw_named(BongoCatPreferences *value, struct nk_context *context, - const char *id) { - for (size_t i = 0; i < value->app->models.count; ++i) - if (!strcmp(value->app->models.entries[i].id, id)) { - model_card(value, context, &value->app->models.entries[i]); return; - } -} - -void bongo_cat_preferences_page_model(BongoCatPreferences *value, - struct nk_context *context) { - BongoCatApp *app = value->app; - smoke_model_behavior(value); - bongo_cat_preferences_model_covers_begin(); - bongo_cat_pref_section(context, - tr(app, "pages.preference.model.title", "Installed models")); - float width = nk_window_get_content_region(context).w; - int columns = width >= 780 ? 4 : width >= 620 ? 3 : width >= 400 ? 2 : 1; - struct nk_vec2 old_spacing = context->style.window.spacing; - context->style.window.spacing = nk_vec2(14, 17); - nk_layout_row_dynamic(context, 222, columns); - if (import_card(value, context)) - bongo_cat_preferences_request_model_import(app->preferences); - draw_named(value, context, "standard"); - draw_named(value, context, "keyboard"); - draw_named(value, context, "gamepad"); - for (size_t i = 0; i < app->models.count; ++i) - if (!is_builtin(app->models.entries[i].id) && - !app->models.entries[i].managed) - model_card(value, context, &app->models.entries[i]); - context->style.window.spacing = old_spacing; - if (has_nearby(app)) { - bongo_cat_pref_section(context, tr(app, - "pages.preference.model.labels.nearbyModels", "Nearby folders")); - context->style.window.spacing = nk_vec2(14, 17); - nk_layout_row_dynamic(context, 222, columns); - for (size_t i = 0; i < app->models.count; ++i) - if (app->models.entries[i].managed) - model_card(value, context, &app->models.entries[i]); - context->style.window.spacing = old_spacing; - } - bongo_cat_preferences_model_covers_prune(app); -} diff --git a/src/ui/preferences_model_cover.c b/src/ui/preferences_model_cover.c deleted file mode 100644 index 23391969..00000000 --- a/src/ui/preferences_model_cover.c +++ /dev/null @@ -1,114 +0,0 @@ -#include "preferences_model_cover.h" -#include "preferences_internal.h" -#include "../runtime/runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/image.h" -#include "bongo_cat/path.h" - -#include -#include -#include - -typedef struct ModelCoverSlot { - BongoCatModelCover image; - BongoCatApp *app; - char path[BONGO_CAT_PATH_CAP]; - uint64_t generation; -} ModelCoverSlot; - -static ModelCoverSlot cover_cache[BONGO_CAT_MODEL_CAP]; -static uint64_t cover_generation; - -void bongo_cat_preferences_model_cover_cache_clear(BongoCatApp *app) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverSlot *slot = &cover_cache[i]; - if ((!app || slot->app == app) && slot->image.texture) - glDeleteTextures(1, &slot->image.texture); - if (!app || slot->app == app) memset(slot, 0, sizeof(*slot)); - } -} - -void bongo_cat_preferences_model_cache_clear(BongoCatApp *app) { - bongo_cat_preferences_remove_dialog_clear(app); - bongo_cat_preferences_model_cover_cache_clear(app); -} - -void bongo_cat_preferences_model_covers_begin(void) { - cover_generation++; - if (!cover_generation) cover_generation++; -} - -const BongoCatModelCover *bongo_cat_preferences_model_cover( - BongoCatApp *app, const BongoCatModelEntry *entry, - int pixel_width, int pixel_height) { - char path[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(path, sizeof(path), entry->adapter_directory, - "resources/cover.png") || !bongo_cat_path_is_file(path)) return NULL; - ModelCoverSlot *empty = NULL; - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverSlot *slot = &cover_cache[i]; - if (slot->image.texture && slot->app == app && !strcmp(slot->path, path)) { - if (slot->image.width == pixel_width || - slot->image.height == pixel_height) { - slot->generation = cover_generation; return &slot->image; - } - glDeleteTextures(1, &slot->image.texture); - memset(slot, 0, sizeof(*slot)); - empty = slot; - break; - } - if (!slot->image.texture && !empty) empty = slot; - } - if (!empty) return NULL; - BongoCatError ignored = {0}; - empty->image.texture = bongo_cat_image_texture_resampled(path, - pixel_width, pixel_height, 0, &empty->image.width, - &empty->image.height, &ignored); - if (!empty->image.texture) return NULL; - empty->app = app; empty->generation = cover_generation; - snprintf(empty->path, sizeof(empty->path), "%s", path); - return &empty->image; -} - -bool bongo_cat_preferences_model_cover_capture(BongoCatApp *app, - const BongoCatModelEntry *entry) { - if (!app || !entry || !app->window || !app->gl_context || - !app->config.window.visible) return false; - char path[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(resources, sizeof(resources), - entry->adapter_directory, "resources") || - !bongo_cat_path_join(path, sizeof(path), resources, "cover.png")) return false; - char marker[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(marker, sizeof(marker), resources, - ".bongo-cat-cover-fallback")) return false; - bool fallback = bongo_cat_path_is_file(marker); - if (bongo_cat_path_is_file(path) && !fallback) return true; - if (!bongo_cat_path_create_directory(resources)) return false; - SDL_Window *previous_window = SDL_GL_GetCurrentWindow(); - SDL_GLContext previous_context = SDL_GL_GetCurrentContext(); - char temporary[BONGO_CAT_PATH_CAP]; - if (!bongo_cat_path_join(temporary, sizeof(temporary), resources, - ".bongo-cat-runtime-cover.png")) return false; - bongo_cat_file_remove(temporary); - snprintf(app->pending_model_cover_path, - sizeof(app->pending_model_cover_path), "%s", temporary); - bongo_cat_app_render_now(app); - if (previous_window && previous_context && - (previous_window != SDL_GL_GetCurrentWindow() || - previous_context != SDL_GL_GetCurrentContext())) - SDL_GL_MakeCurrent(previous_window, previous_context); - if (!bongo_cat_path_is_file(temporary) || - !bongo_cat_file_replace(temporary, path, true)) return false; - if (fallback) bongo_cat_file_remove(marker); - return true; -} - -void bongo_cat_preferences_model_covers_prune(BongoCatApp *app) { - for (size_t i = 0; i < BONGO_CAT_MODEL_CAP; ++i) { - ModelCoverSlot *slot = &cover_cache[i]; - if (slot->app != app || !slot->image.texture || - slot->generation == cover_generation) continue; - glDeleteTextures(1, &slot->image.texture); - memset(slot, 0, sizeof(*slot)); - } -} diff --git a/src/ui/preferences_model_glyphs.c b/src/ui/preferences_model_glyphs.c deleted file mode 100644 index d96fdf32..00000000 --- a/src/ui/preferences_model_glyphs.c +++ /dev/null @@ -1,45 +0,0 @@ -#include "preferences_state.h" -#include "preferences_model_glyphs.h" - -#include - -static bool glyph_covered(const uint32_t *ranges, size_t used, uint32_t rune) { - for (size_t i = 0; i + 1 < used; i += 2) - if (rune >= ranges[i] && rune <= ranges[i + 1]) return true; - return false; -} - -static void add_text(uint32_t *ranges, size_t capacity, - size_t *used, const char *text) { - const char *cursor = text; - while (cursor && *cursor) { - nk_rune rune = 0; - int decoded = nk_utf_decode(cursor, &rune, (int)strlen(cursor)); - if (decoded <= 0) { cursor++; continue; } - cursor += decoded; - if (rune <= 0x7e || glyph_covered(ranges, *used, rune)) continue; - if (*used + 2 >= capacity) return; - ranges[(*used)++] = rune; - ranges[(*used)++] = rune; - ranges[*used] = 0; - } -} - -void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, - uint32_t *ranges, size_t capacity) { - if (!app || !ranges) return; - size_t used = 0; - while (used < capacity && ranges[used]) used++; - if (used >= capacity || (used & 1)) return; - for (size_t i = 0; i < app->models.count; ++i) { - const BongoCatModelEntry *entry = &app->models.entries[i]; - add_text(ranges, capacity, &used, - entry->display_name[0] ? entry->display_name : entry->id); - } - for (size_t i = 0; i < app->behaviors.count; ++i) - add_text(ranges, capacity, &used, app->behaviors.entries[i].label); - for (size_t i = 0; - i < app->config.behavior_shortcut_count; ++i) - add_text(ranges, capacity, &used, - app->config.behavior_shortcuts[i].label); -} diff --git a/src/ui/preferences_model_glyphs.h b/src/ui/preferences_model_glyphs.h deleted file mode 100644 index 273e0966..00000000 --- a/src/ui/preferences_model_glyphs.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef BONGO_CAT_PREFERENCES_MODEL_GLYPHS_H -#define BONGO_CAT_PREFERENCES_MODEL_GLYPHS_H - -#include -#include - -typedef struct BongoCatApp BongoCatApp; - -void bongo_cat_preferences_model_glyphs(const BongoCatApp *app, - uint32_t *ranges, size_t capacity); - -#endif diff --git a/src/ui/preferences_pages.c b/src/ui/preferences_pages.c deleted file mode 100644 index 19f264f3..00000000 --- a/src/ui/preferences_pages.c +++ /dev/null @@ -1,146 +0,0 @@ -#include "preferences_internal.h" -#include "preferences_theme.h" -#include "preferences_widgets.h" -#include "preferences_notice.h" -#include "bongo_cat/audio.h" -#include "bongo_cat/i18n.h" -#include "bongo_cat/preferences.h" -#include "bongo_cat/tray.h" -#include "../runtime/runtime.h" - -#include -#include - -static const char *tr(BongoCatApp *app, const char *key, const char *fallback) { - return bongo_cat_i18n_get(app->i18n, key, fallback); -} - -static void section_gap(struct nk_context *context, float pixels) { - context->current->layout->at_y += pixels; -} - -void bongo_cat_preferences_page_cat(BongoCatApp *app, struct nk_context *context) { - BongoCatModelOptions *model = &app->config.model; - BongoCatWindowOptions *window = &app->config.window; - bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.windowSettings", - "Window Settings")); - if (bongo_cat_pref_toggle(context, "pass-through", tr(app, - "composables.useAppMenu.labels.passThrough", "Pass Through"), "", - &window->pass_through)) { - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } - if (bongo_cat_pref_toggle(context, "always-top", tr(app, - "composables.useAppMenu.labels.alwaysOnTop", "Always on Top"), "", - &window->always_on_top)) { - bongo_cat_platform_set_always_on_top(&app->platform, window->always_on_top); - bongo_cat_window_mark_hit_dirty(app); - bongo_cat_window_sync_click_through(app); - } - if (bongo_cat_pref_toggle(context, "keep-in-screen", tr(app, - "pages.preference.cat.labels.keepInScreen", "Keep on Screen"), "", - &window->keep_in_screen) && window->keep_in_screen) - bongo_cat_window_clamp_to_display(app); - float old_scale = window->scale_percent; - bongo_cat_pref_float(context, "window-size", tr(app, - "pages.preference.cat.labels.windowSize", "Window Size"), tr(app, - "composables.useAppMenu.labels.wheelSizeHint", "Wheel: resize"), - 10.0f, &window->scale_percent, 500.0f, 1.0f, - BONGO_CAT_DEFAULT_WINDOW_SCALE_PERCENT); - if (old_scale != window->scale_percent && old_scale > 0.0f) { - float requested_scale = window->scale_percent; - window->scale_percent = old_scale; - bongo_cat_window_cancel_wheel_animation(app); - bongo_cat_window_set_scale(app, requested_scale); - } - float old_opacity = window->opacity_percent; - bongo_cat_pref_slider(context, "opacity", tr(app, - "pages.preference.cat.labels.opacity", "Opacity"), tr(app, - "composables.useAppMenu.labels.wheelOpacityHint", "Ctrl+Wheel: opacity"), - 10.0f, &window->opacity_percent, 100.0f, 1.0f, - BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); - if (old_opacity != window->opacity_percent) bongo_cat_window_cancel_wheel_animation(app); - if (old_opacity != window->opacity_percent && !app->hover_hidden) - bongo_cat_platform_set_opacity(&app->platform, - window->opacity_percent / 100.0f); - - section_gap(context, 10); - bongo_cat_pref_section(context, tr(app, "pages.preference.cat.labels.modelSettings", - "Model Settings")); - if (bongo_cat_pref_toggle(context, "mirror", tr(app, - "pages.preference.cat.labels.mirrorMode", "Mirror Mode"), "", - &model->mirror)) { - app->model_pointer_anchor_ready = false; - app->dirty = true; - } - if (bongo_cat_pref_toggle(context, "mouse-mirror", tr(app, - "pages.preference.cat.labels.mouseMirror", "Mouse Mirror"), "", - &model->mouse_mirror)) { - app->pointer_known = false; - app->dirty = true; - } - if (bongo_cat_pref_toggle(context, "mouse-centered", tr(app, - "pages.preference.cat.labels.mouseCentered", "Mouse Centered on Desktop Pet"), - "", &model->mouse_centered)) { - bongo_cat_app_reset_pointer_tracking(app); - } - if (bongo_cat_pref_toggle(context, "ignore-mouse", tr(app, - "pages.preference.cat.labels.ignoreMouse", "Ignore Mouse Events"), "", - &model->ignore_mouse)) { - app->pointer_known = false; - app->dirty = true; - } - bongo_cat_pref_float(context, "release-delay", tr(app, - "pages.preference.cat.labels.autoReleaseDelay", "Auto Release Delay"), "", - .05f, &model->auto_release_seconds, 30.0f, .05f, - BONGO_CAT_DEFAULT_AUTO_RELEASE_SECONDS); - bongo_cat_pref_int(context, "max-fps", tr(app, - "pages.preference.cat.labels.maxFPS", "Max Frame Rate"), "", - 1, &model->max_fps, 240, 1, BONGO_CAT_DEFAULT_MAX_FPS); -} - -static void update_autostart(BongoCatApp *app, bool old_value) { - BongoCatError error = {0}; - if (bongo_cat_platform_set_autostart(app->config.app.autostart, &error) == BONGO_CAT_OK) return; - app->config.app.autostart = old_value; - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", error.message); - bongo_cat_preferences_notice_show(app, error.message, true); -} - -void bongo_cat_preferences_page_general(BongoCatApp *app, struct nk_context *context) { - BongoCatAppOptions *options = &app->config.app; - // Keep each option in its own native language so the list is recognizable - // regardless of the language currently used by the settings window. - const char *ui_languages[] = {"简体中文", "繁體中文", "English", - "Français", "Deutsch", "日本語", "한국어", "Português", - "Русский", "Español"}; - bongo_cat_pref_section(context, tr(app, "pages.preference.general.labels.appSettings", - "Application Settings")); - bool old_autostart = options->autostart; - if (bongo_cat_pref_toggle(context, "autostart", tr(app, - "pages.preference.general.labels.launchOnStartup", "Launch on Startup"), "", - &options->autostart)) update_autostart(app, old_autostart); - section_gap(context, 7); - bongo_cat_pref_section(context, tr(app, - "pages.preference.general.labels.appearanceSettings", "Appearance Settings")); - section_gap(context, 6); - const int language_to_ui[] = {2, 0, 1, 3, 4, 5, 6, 7, 8, 9}; - const BongoCatLanguage ui_to_language[] = { - BONGO_CAT_LANG_ZH_CN, BONGO_CAT_LANG_ZH_TW, - BONGO_CAT_LANG_EN_US, BONGO_CAT_LANG_FR_FR, - BONGO_CAT_LANG_DE_DE, BONGO_CAT_LANG_JA_JP, - BONGO_CAT_LANG_KO_KR, BONGO_CAT_LANG_PT_BR, - BONGO_CAT_LANG_RU_RU, BONGO_CAT_LANG_ES_ES}; - int selected = bongo_cat_pref_combo(context, - "language", tr(app, "pages.preference.general.labels.language", - "Language"), "", ui_languages, BONGO_CAT_LANG_COUNT, - language_to_ui[options->language]); - options->language = ui_to_language[selected]; - const char *themes[] = { - tr(app, "pages.preference.general.options.auto", "System"), - tr(app, "pages.preference.general.options.lightMode", "Light"), - tr(app, "pages.preference.general.options.darkMode", "Dark")}; - options->theme = (BongoCatTheme)bongo_cat_pref_theme(context, - "theme", tr(app, "pages.preference.general.labels.themeMode", - "Theme"), themes, options->theme); -} diff --git a/src/ui/ui_animation.c b/src/ui/rendering/ui_animation.c similarity index 100% rename from src/ui/ui_animation.c rename to src/ui/rendering/ui_animation.c diff --git a/src/ui/ui_animation.h b/src/ui/rendering/ui_animation.h similarity index 100% rename from src/ui/ui_animation.h rename to src/ui/rendering/ui_animation.h diff --git a/src/ui/rendering/ui_font.c b/src/ui/rendering/ui_font.c new file mode 100644 index 00000000..d895401c --- /dev/null +++ b/src/ui/rendering/ui_font.c @@ -0,0 +1,231 @@ +#include "ui_font.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +static bool readable(const char *path) { + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + fclose(file); + return true; +} + +const char *bongo_cat_ui_system_font(char *path, size_t capacity, bool multilingual) { +#ifdef _WIN32 + const char *windows = SDL_getenv("WINDIR"); + if (!windows) windows = SDL_getenv("SystemRoot"); + if (windows) { + const char *multi[] = {"Fonts/msyh.ttc", "Fonts/msyhl.ttc"}; + const char *latin[] = {"Fonts/segoeui.ttf", "Fonts/msyhl.ttc"}; + const char **candidates = multilingual ? multi : latin; + for (size_t i = 0; i < 2; ++i) { + bongo_cat_path_join(path, capacity, windows, candidates[i]); + if (readable(path)) return path; + } + } +#elif defined(__APPLE__) + const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : + "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } +#else + /* Every Linux distribution ships fonts to different directories, so the + candidate lists must cover the common layouts. CJK-capable fonts come + first for multilingual fallbacks; the CJK-less DejaVu remains last as a + final resort so Chinese text never silently falls back to it. */ + static const char *const cjk[] = { + /* Arch/Manjaro and openSUSE: noto-fonts-cjk */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + /* Fedora/RHEL: google-noto-sans-cjk-fonts */ + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", + /* Debian/Ubuntu: fonts-noto-cjk (and language-specific extras) */ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", + "/usr/share/fonts/opentype/noto/NotoSansCJKtc-Regular.otf", + "/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.otf", + /* WenQuanYi micro hei/zen hei (Debian, Arch, openSUSE) */ + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + /* Older Ubuntu: droid fallback */ + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"}; + static const char *const latin[] = { + /* Debian/Ubuntu, Arch, Fedora ttf-dejavu layouts */ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/freefont/FreeSans.ttf"}; + const char *const *candidates = multilingual ? cjk : latin; + size_t count = multilingual ? + sizeof(cjk) / sizeof(cjk[0]) : sizeof(latin) / sizeof(latin[0]); + for (size_t i = 0; i < count; ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } + if (multilingual) + for (size_t i = 0; i < sizeof(latin) / sizeof(latin[0]); ++i) { + if (!readable(latin[i])) continue; + snprintf(path, capacity, "%s", latin[i]); + return path; + } +#endif + return NULL; +} + +const char *bongo_cat_ui_system_heading_font(char *path, size_t capacity, + bool multilingual) { +#ifdef _WIN32 + const char *windows = SDL_getenv("WINDIR"); + if (!windows) windows = SDL_getenv("SystemRoot"); + if (windows) { + const char *multi[] = {"Fonts/msyhbd.ttc", "Fonts/msyhl.ttc"}; + const char *latin[] = {"Fonts/seguisb.ttf", "Fonts/segoeui.ttf"}; + const char **candidates = multilingual ? multi : latin; + for (size_t i = 0; i < 2; ++i) { + bongo_cat_path_join(path, capacity, windows, candidates[i]); + if (readable(path)) return path; + } + } +#elif defined(__APPLE__) + const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : + "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } +#else + static const char *const cjk[] = { + "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Bold.otf", + "/usr/share/fonts/opentype/noto/NotoSansCJKtc-Bold.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.otf", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"}; + static const char *const latin[] = { + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans-Bold.ttf", + "/usr/share/fonts/freefont/FreeSans-Bold.ttf"}; + const char *const *candidates = multilingual ? cjk : latin; + size_t count = multilingual ? + sizeof(cjk) / sizeof(cjk[0]) : sizeof(latin) / sizeof(latin[0]); + for (size_t i = 0; i < count; ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } + if (multilingual) + for (size_t i = 0; i < sizeof(latin) / sizeof(latin[0]); ++i) { + if (!readable(latin[i])) continue; + snprintf(path, capacity, "%s", latin[i]); + return path; + } +#endif + return NULL; +} + +const char *bongo_cat_ui_system_korean_font(char *path, size_t capacity) { +#ifdef _WIN32 + const char *windows = SDL_getenv("WINDIR"); + if (!windows) windows = SDL_getenv("SystemRoot"); + if (windows) { + const char *candidates[] = {"Fonts/malgun.ttf", "Fonts/malgunsl.ttf"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + bongo_cat_path_join(path, capacity, windows, candidates[i]); + if (readable(path)) return path; + } + } +#elif defined(__APPLE__) + const char *candidates[] = { + "/System/Library/Fonts/AppleSDGothicNeo.ttc", + "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; +#else + const char *candidates[] = { + /* NotoSansCJK covers Korean on every major distribution */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + /* Language-specific Noto Sans KR (Debian/Ubuntu, Fedora) */ + "/usr/share/fonts/opentype/noto/NotoSansKR-Regular.otf", + "/usr/share/fonts/google-noto-sans-kr-fonts/NotoSansKR-Regular.otf", + /* Nanum Gothic (Debian/Ubuntu, Arch, Fedora) */ + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/nanum/NanumGothic.ttf", + "/usr/share/fonts/nanum-gothic-fonts/NanumGothic.ttf"}; +#endif +#ifndef _WIN32 + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } +#endif + return bongo_cat_ui_system_font(path, capacity, true); +} + +const char *bongo_cat_ui_system_korean_heading_font(char *path, + size_t capacity) { +#ifdef _WIN32 + const char *windows = SDL_getenv("WINDIR"); + if (!windows) windows = SDL_getenv("SystemRoot"); + if (windows) { + const char *candidates[] = {"Fonts/malgunbd.ttf", "Fonts/malgun.ttf"}; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + bongo_cat_path_join(path, capacity, windows, candidates[i]); + if (readable(path)) return path; + } + } +#elif defined(__APPLE__) + const char *candidates[] = { + "/System/Library/Fonts/AppleSDGothicNeo.ttc", + "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; +#else + const char *candidates[] = { + "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/opentype/noto/NotoSansKR-Bold.otf", + "/usr/share/fonts/google-noto-sans-kr-fonts/NotoSansKR-Bold.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", + "/usr/share/fonts/nanum/NanumGothicBold.ttf", + "/usr/share/fonts/nanum-gothic-fonts/NanumGothicBold.ttf", + /* Regular weight as a last resort for headings with no bold face */ + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"}; +#endif +#ifndef _WIN32 + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { + if (!readable(candidates[i])) continue; + snprintf(path, capacity, "%s", candidates[i]); + return path; + } +#endif + return bongo_cat_ui_system_heading_font(path, capacity, true); +} diff --git a/src/ui/ui_font.h b/src/ui/rendering/ui_font.h similarity index 100% rename from src/ui/ui_font.h rename to src/ui/rendering/ui_font.h diff --git a/src/ui/ui_font_atlas.c b/src/ui/rendering/ui_font_atlas.c similarity index 57% rename from src/ui/ui_font_atlas.c rename to src/ui/rendering/ui_font_atlas.c index 09191b24..e57d48fa 100644 --- a/src/ui/ui_font_atlas.c +++ b/src/ui/rendering/ui_font_atlas.c @@ -4,74 +4,77 @@ #include #include +/* Reuse mappings only within this bake. Owners remain in sources[] and are + detached/released once, including on failure. */ +static const UIFontSource *load_source(UIFontSource *sources, + const char *const *paths, int index) { + if (!paths[index]) return NULL; + for (int i = 0; i < index; ++i) + if (sources[i].data && paths[i] && strcmp(paths[i], paths[index]) == 0) + return &sources[i]; + return bongo_cat_ui_font_source_load(&sources[index], paths[index]) ? + &sources[index] : NULL; +} + bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, const char *body_path, const char *body_fallback_path, const char *body_korean_fallback_path, const char *heading_path, const char *heading_fallback_path, const char *heading_korean_fallback_path, const nk_rune *glyph_ranges, float raster_scale) { - UIFontSource body = {0}, body_fallback = {0}, body_korean_fallback = {0}; - UIFontSource heading = {0}, heading_fallback = {0}; - UIFontSource heading_korean_fallback = {0}; - bool body_loaded = body_path && - bongo_cat_ui_font_source_load(&body, body_path); - bool body_fallback_loaded = body_fallback_path && - (!body_path || strcmp(body_path, body_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&body_fallback, body_fallback_path); - bool body_korean_fallback_loaded = body_korean_fallback_path && + UIFontSource sources[6] = {0}; + const char *paths[] = {body_path, body_fallback_path, + body_korean_fallback_path, heading_path, heading_fallback_path, + heading_korean_fallback_path}; + const UIFontSource *body = load_source(sources, paths, 0); + const UIFontSource *body_fallback = body_fallback_path && + (!body_path || strcmp(body_path, body_fallback_path) != 0) ? + load_source(sources, paths, 1) : NULL; + const UIFontSource *body_korean_fallback = body_korean_fallback_path && (!body_path || strcmp(body_path, body_korean_fallback_path) != 0) && (!body_fallback_path || strcmp(body_fallback_path, - body_korean_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&body_korean_fallback, - body_korean_fallback_path); - bool heading_loaded = heading_path && - bongo_cat_ui_font_source_load(&heading, heading_path); - bool heading_fallback_loaded = heading_fallback_path && - (!heading_path || strcmp(heading_path, heading_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&heading_fallback, - heading_fallback_path); - bool heading_korean_fallback_loaded = heading_korean_fallback_path && - (!heading_path || strcmp(heading_path, - heading_korean_fallback_path) != 0) && + body_korean_fallback_path) != 0) ? + load_source(sources, paths, 2) : NULL; + const UIFontSource *heading = load_source(sources, paths, 3); + const UIFontSource *heading_fallback = heading_fallback_path && + (!heading_path || strcmp(heading_path, heading_fallback_path) != 0) ? + load_source(sources, paths, 4) : NULL; + const UIFontSource *heading_korean_fallback = heading_korean_fallback_path && + (!heading_path || strcmp(heading_path, heading_korean_fallback_path) != 0) && (!heading_fallback_path || strcmp(heading_fallback_path, - heading_korean_fallback_path) != 0) && - bongo_cat_ui_font_source_load(&heading_korean_fallback, - heading_korean_fallback_path); - const UIFontSource *heading_source = heading_loaded ? &heading : - (body_loaded ? &body : NULL); - const UIFontSource *heading_fallback_source = heading_fallback_loaded ? - &heading_fallback : (body_fallback_loaded ? &body_fallback : NULL); + heading_korean_fallback_path) != 0) ? + load_source(sources, paths, 5) : NULL; + const UIFontSource *heading_source = heading ? heading : body; + const UIFontSource *heading_fallback_source = heading_fallback ? + heading_fallback : body_fallback; const UIFontSource *heading_korean_fallback_source = - heading_korean_fallback_loaded ? &heading_korean_fallback : - (body_korean_fallback_loaded ? &body_korean_fallback : - heading_fallback_source); + heading_korean_fallback ? heading_korean_fallback : + (body_korean_fallback ? body_korean_fallback : heading_fallback_source); nk_rune *primary_ranges = NULL, *cjk_ranges = NULL; nk_rune *korean_ranges = NULL; if (!bongo_cat_ui_font_split_ranges(glyph_ranges, &primary_ranges, &cjk_ranges, &korean_ranges)) { - bongo_cat_ui_font_source_release(&heading_korean_fallback); - bongo_cat_ui_font_source_release(&heading_fallback); - bongo_cat_ui_font_source_release(&heading); - bongo_cat_ui_font_source_release(&body_korean_fallback); - bongo_cat_ui_font_source_release(&body_fallback); - bongo_cat_ui_font_source_release(&body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_source_release(&sources[i]); return false; } nk_font_atlas_init_default(&ui->atlas); nk_font_atlas_begin(&ui->atlas); GLint maximum_texture = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum_texture); - float scale_limit = maximum_texture >= 16384 ? 2.0f : - (maximum_texture >= 8192 ? 1.25f : 1.0f); + /* Keep the atlas bounded on high-DPI displays. The UI is laid out in + logical pixels, so a 1.5x raster is enough for crisp text while a 2x + atlas can quadruple both the texture and bake working memory. */ + float scale_limit = maximum_texture >= 8192 ? 1.5f : 1.0f; float font_scale = NK_CLAMP(1.0f, raster_scale, scale_limit); if (font_scale + .01f < raster_scale) SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, - "Preferences font raster scale limited to %.2fx by GPU texture size %d", + "Preferences font raster scale limited to %.2fx by the UI memory " + "budget (GPU texture limit %d)", font_scale, maximum_texture); - const UIFontSource *body_source = body_loaded ? &body : NULL; - const UIFontSource *fallback_source = body_fallback_loaded ? - &body_fallback : NULL; + const UIFontSource *body_source = body; + const UIFontSource *fallback_source = body_fallback; const UIFontSource *body_korean_fallback_source = - body_korean_fallback_loaded ? &body_korean_fallback : fallback_source; + body_korean_fallback ? body_korean_fallback : fallback_source; struct nk_font *caption_font = bongo_cat_ui_font_add_family(&ui->atlas, body_source, fallback_source, body_korean_fallback_source, 18.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, @@ -80,7 +83,12 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, body_source, fallback_source, body_korean_fallback_source, 20.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, korean_ranges); - struct nk_font *label_font = bongo_cat_ui_font_add_family(&ui->atlas, + /* Identical families at 20 px can share glyphs and the font handle. */ + bool shared_label = body_source == heading_source && + fallback_source == heading_fallback_source && + body_korean_fallback_source == heading_korean_fallback_source; + struct nk_font *label_font = shared_label ? body_font : + bongo_cat_ui_font_add_family(&ui->atlas, heading_source, heading_fallback_source, heading_korean_fallback_source, 20.0f * font_scale, glyph_ranges, primary_ranges, cjk_ranges, korean_ranges); @@ -116,22 +124,13 @@ bool bongo_cat_ui_font_atlas_create(BongoCatUIBackend *ui, } ui->font_path_found = body_path != NULL || body_fallback_path != NULL || body_korean_fallback_path != NULL; - ui->font_file_loaded = body_loaded || body_fallback_loaded || - body_korean_fallback_loaded; + ui->font_file_loaded = body || body_fallback || body_korean_fallback; ui->custom_font_loaded = ui->font_file_loaded && body_font != NULL; - bongo_cat_ui_font_detach_source(&ui->atlas, &heading_korean_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &heading_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &heading); - bongo_cat_ui_font_detach_source(&ui->atlas, &body_korean_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &body_fallback); - bongo_cat_ui_font_detach_source(&ui->atlas, &body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_detach_source(&ui->atlas, &sources[i]); nk_font_atlas_cleanup(&ui->atlas); - bongo_cat_ui_font_source_release(&heading_korean_fallback); - bongo_cat_ui_font_source_release(&heading_fallback); - bongo_cat_ui_font_source_release(&heading); - bongo_cat_ui_font_source_release(&body_korean_fallback); - bongo_cat_ui_font_source_release(&body_fallback); - bongo_cat_ui_font_source_release(&body); + for (int i = 0; i < 6; ++i) + bongo_cat_ui_font_source_release(&sources[i]); return uploaded; } diff --git a/src/ui/ui_font_atlas.h b/src/ui/rendering/ui_font_atlas.h similarity index 100% rename from src/ui/ui_font_atlas.h rename to src/ui/rendering/ui_font_atlas.h diff --git a/src/ui/ui_font_atlas_internal.h b/src/ui/rendering/ui_font_atlas_internal.h similarity index 100% rename from src/ui/ui_font_atlas_internal.h rename to src/ui/rendering/ui_font_atlas_internal.h diff --git a/src/ui/ui_font_atlas_ranges.c b/src/ui/rendering/ui_font_atlas_ranges.c similarity index 100% rename from src/ui/ui_font_atlas_ranges.c rename to src/ui/rendering/ui_font_atlas_ranges.c diff --git a/src/ui/ui_font_atlas_sources.c b/src/ui/rendering/ui_font_atlas_sources.c similarity index 91% rename from src/ui/ui_font_atlas_sources.c rename to src/ui/rendering/ui_font_atlas_sources.c index 71bdb654..007b02b4 100644 --- a/src/ui/ui_font_atlas_sources.c +++ b/src/ui/rendering/ui_font_atlas_sources.c @@ -48,8 +48,12 @@ void bongo_cat_ui_font_source_release(UIFontSource *source) { static struct nk_font_config font_config(float size, const nk_rune *ranges) { struct nk_font_config config = nk_font_config(size); config.range = ranges; - config.oversample_h = 2; - config.oversample_v = 2; + /* CJK glyphs dominate the atlas and gain little from subpixel + oversampling. Keep the sharper 2x bake for Latin and symbols while + using the native raster for the large fallback ranges. */ + bool fallback_range = ranges && ranges[0] >= 0x1100; + config.oversample_h = fallback_range ? 1 : 2; + config.oversample_v = fallback_range ? 1 : 2; return config; } diff --git a/src/ui/ui_font_atlas_upload.c b/src/ui/rendering/ui_font_atlas_upload.c similarity index 83% rename from src/ui/ui_font_atlas_upload.c rename to src/ui/rendering/ui_font_atlas_upload.c index 092ee64d..05294d6f 100644 --- a/src/ui/ui_font_atlas_upload.c +++ b/src/ui/rendering/ui_font_atlas_upload.c @@ -7,6 +7,11 @@ bool bongo_cat_ui_font_upload_atlas(BongoCatUIBackend *ui) { const void *pixels = nk_font_atlas_bake(&ui->atlas, &width, &height, NK_FONT_ATLAS_ALPHA8); if (!pixels || width < 1 || height < 1) return false; + SDL_Window *window = SDL_GL_GetCurrentWindow(); + SDL_GLContext context = SDL_GL_GetCurrentContext(); + SDL_Log("Preferences font atlas upload starting: %dx%d window=%u " + "context=%p", width, height, + window ? (unsigned)SDL_GetWindowID(window) : 0, (void *)context); GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum); if (width > maximum || height > maximum) { @@ -44,5 +49,7 @@ bool bongo_cat_ui_font_upload_atlas(BongoCatUIBackend *ui) { } nk_font_atlas_end(&ui->atlas, nk_handle_id((int)ui->font_texture), &ui->null_texture); + SDL_Log("Preferences font atlas upload completed: texture=%u size=%dx%d", + ui->font_texture, width, height); return true; } diff --git a/src/ui/ui_font_reload.c b/src/ui/rendering/ui_font_reload.c similarity index 100% rename from src/ui/ui_font_reload.c rename to src/ui/rendering/ui_font_reload.c diff --git a/src/ui/ui_icons.h b/src/ui/rendering/ui_icons.h similarity index 100% rename from src/ui/ui_icons.h rename to src/ui/rendering/ui_icons.h diff --git a/src/ui/ui_native_theme.c b/src/ui/rendering/ui_native_theme.c similarity index 88% rename from src/ui/ui_native_theme.c rename to src/ui/rendering/ui_native_theme.c index 8fe15349..5b646500 100644 --- a/src/ui/ui_native_theme.c +++ b/src/ui/rendering/ui_native_theme.c @@ -56,7 +56,8 @@ static HWND native_window(SDL_Window *window) { static void initialize(void) { if (initialized) return; initialized = true; - HMODULE theme = LoadLibraryW(L"uxtheme.dll"); + HMODULE theme = LoadLibraryExW(L"uxtheme.dll", NULL, + LOAD_LIBRARY_SEARCH_SYSTEM32); load_named(theme, "SetWindowTheme", &set_window_theme, sizeof(set_window_theme)); if (windows_build() < 18362) return; @@ -68,6 +69,12 @@ static void initialize(void) { void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { initialize(); HWND handle = native_window(window); + bongo_cat_ui_native_menu_prepare_native(handle, dark); +} + +void bongo_cat_ui_native_menu_prepare_native(void *native_handle, bool dark) { + initialize(); + HWND handle = (HWND)native_handle; bool contrast = high_contrast(); int mode = contrast ? 0 : (dark ? 2 : 3); if (set_preferred_mode && mode != applied_mode) { @@ -76,6 +83,9 @@ void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { if (flush_menu_themes) flush_menu_themes(); } if (handle && allow_dark_window) allow_dark_window(handle, !contrast); + if (handle && set_window_theme) + set_window_theme(handle, dark && !contrast ? L"DarkMode_Explorer" : NULL, + NULL); } void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark) { @@ -107,4 +117,8 @@ void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark) { void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark) { (void)window; (void)dark; } + +void bongo_cat_ui_native_menu_prepare_native(void *handle, bool dark) { + (void)handle; (void)dark; +} #endif diff --git a/src/ui/ui_native_theme.h b/src/ui/rendering/ui_native_theme.h similarity index 79% rename from src/ui/ui_native_theme.h rename to src/ui/rendering/ui_native_theme.h index ea68fbe2..8b577a2b 100644 --- a/src/ui/ui_native_theme.h +++ b/src/ui/rendering/ui_native_theme.h @@ -7,5 +7,6 @@ typedef struct SDL_Window SDL_Window; void bongo_cat_ui_native_theme_apply(SDL_Window *window, bool dark); void bongo_cat_ui_native_menu_prepare(SDL_Window *window, bool dark); +void bongo_cat_ui_native_menu_prepare_native(void *handle, bool dark); #endif diff --git a/src/ui/ui_paint.c b/src/ui/rendering/ui_paint.c similarity index 90% rename from src/ui/ui_paint.c rename to src/ui/rendering/ui_paint.c index f15f6e8c..7c1901b3 100644 --- a/src/ui/ui_paint.c +++ b/src/ui/rendering/ui_paint.c @@ -12,7 +12,6 @@ static float clamp01(float value) { return NK_CLAMP(0.0f, value, 1.0f); } - struct nk_color bongo_cat_ui_color_mix(struct nk_color first, struct nk_color second, float amount) { amount = clamp01(amount); @@ -34,8 +33,12 @@ static uint32_t pack(struct nk_color color) { } static void pixel(unsigned char *target, struct nk_color color, float alpha) { - target[0] = color.r; target[1] = color.g; target[2] = color.b; - target[3] = (unsigned char)(color.a * clamp01(alpha) + .5f); + unsigned char coverage = (unsigned char)(color.a * clamp01(alpha) + .5f); + /* GL_LINEAR interpolates RGB and alpha independently. Keeping a colored + RGB value in a fully transparent texel can therefore create a visible + fringe on DWM-composited windows when a neighbouring sample is mixed. */ + target[0] = coverage ? color.r : 0; target[1] = coverage ? color.g : 0; target[2] = coverage ? color.b : 0; + target[3] = coverage; } static float rounded_distance(float x, float y, float width, float height, @@ -51,6 +54,11 @@ static float rounded_distance(float x, float y, float width, float height, static bool texture_dimensions(struct nk_context *context, struct nk_rect bounds, int *width, int *height, float *scale_x, float *scale_y, BongoCatUIBackend **backend) { + /* Nuklear clips the eventual image, but rasterization/upload happens here. + Skip effects whose complete padded target is outside the visible clip. */ + struct nk_rect clip = nk_window_get_canvas(context)->clip; + if (bounds.x + bounds.w <= clip.x || bounds.y + bounds.h <= clip.y || + bounds.x >= clip.x + clip.w || bounds.y >= clip.y + clip.h) return false; *backend = bongo_cat_ui_backend_for_context(context); if (!*backend || !(*backend)->window) return false; float logical_width = 0.0f, logical_height = 0.0f; @@ -100,7 +108,6 @@ void bongo_cat_ui_paint_gradient(struct nk_context *context, bongo_cat_ui_paint_cache_draw(context, bounds, item, nk_rgb(255, 255, 255)); } - static void paint_radial(struct nk_context *context, int kind, struct nk_rect bounds, struct nk_color center, struct nk_color edge, float midpoint, float outer) { @@ -163,7 +170,7 @@ void bongo_cat_ui_paint_sidebar_glow(struct nk_context *context, int width, height; float sx, sy; BongoCatUIBackend *backend; if (!texture_dimensions(context, surface, &width, &height, &sx, &sy, &backend)) return; - float scale = (sx + sy) * .5f; + float scale = NK_MIN(sx, sy); BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_SIDEBAR_GLOW, width, height, (int)lroundf(rounding * scale), (int)lroundf(sidebar * sx), 0, pack(color), 0}; @@ -222,14 +229,15 @@ void bongo_cat_ui_paint_shadow(struct nk_context *context, float center_x = pad_x + bounds.w * sx * .5f; float center_y = pad_y + bounds.h * sy * .5f; float sigma = NK_MAX(1.0f, (float)key.first_parameter * .5f); + float inverse_sigma_squared = 1.0f / (sigma * sigma); + float radius = NK_CLAMP(0.0f, (float)(key.radius + key.second_parameter), + NK_MIN(shape_w, shape_h) * .5f); for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { float local_x = x + .5f - center_x + shape_w * .5f; float local_y = y + .5f - center_y + shape_h * .5f; - float distance = rounded_distance(local_x, local_y, - shape_w, shape_h, - (float)(key.radius + key.second_parameter)); + float distance = rounded_distance(local_x, local_y, shape_w, shape_h, radius); float alpha = .55f * - expf(-.5f * distance * distance / (sigma * sigma)); + expf(-.5f * distance * distance * inverse_sigma_squared); pixels[(size_t)y * width + x] = (unsigned char)(255.0f * clamp01(alpha) + .5f); } @@ -276,11 +284,3 @@ void bongo_cat_ui_paint_dashed_rounded(struct nk_context *context, bongo_cat_ui_paint_cache_draw(context, bounds, item, nk_rgb(255, 255, 255)); } - -void bongo_cat_ui_paint_begin_frame(BongoCatUIBackend *backend) { - bongo_cat_ui_paint_cache_begin_frame(backend); -} - -void bongo_cat_ui_paint_destroy(BongoCatUIBackend *backend) { - bongo_cat_ui_paint_cache_destroy(backend); -} diff --git a/src/ui/ui_paint.h b/src/ui/rendering/ui_paint.h similarity index 100% rename from src/ui/ui_paint.h rename to src/ui/rendering/ui_paint.h diff --git a/src/ui/ui_paint_border.c b/src/ui/rendering/ui_paint_border.c similarity index 92% rename from src/ui/ui_paint_border.c rename to src/ui/rendering/ui_paint_border.c index fe2d34b2..23616241 100644 --- a/src/ui/ui_paint_border.c +++ b/src/ui/rendering/ui_paint_border.c @@ -72,7 +72,10 @@ void bongo_cat_ui_raster_dashed_rounded(unsigned char *pixels, clamp01(NK_MIN(phase + .5f, dash - phase + .5f)) : 0.0f; float alpha = clamp01(thickness * .5f + .75f - distance) * dash_alpha; unsigned char *target = pixels + ((size_t)y * width + x) * 4; - target[0] = color.r; target[1] = color.g; target[2] = color.b; - target[3] = (unsigned char)(color.a * alpha + .5f); + unsigned char coverage = (unsigned char)(color.a * alpha + .5f); + target[0] = coverage ? color.r : 0; + target[1] = coverage ? color.g : 0; + target[2] = coverage ? color.b : 0; + target[3] = coverage; } } diff --git a/src/ui/ui_paint_border.h b/src/ui/rendering/ui_paint_border.h similarity index 100% rename from src/ui/ui_paint_border.h rename to src/ui/rendering/ui_paint_border.h diff --git a/src/ui/ui_paint_cache.c b/src/ui/rendering/ui_paint_cache.c similarity index 81% rename from src/ui/ui_paint_cache.c rename to src/ui/rendering/ui_paint_cache.c index 62e19c99..a706f65c 100644 --- a/src/ui/ui_paint_cache.c +++ b/src/ui/rendering/ui_paint_cache.c @@ -13,7 +13,9 @@ struct BongoCatUIPaintTexture { }; #define PAINT_CACHE_COUNT 48 -#define PAINT_CACHE_BYTE_LIMIT (32u * 1024u * 1024u) +/* Effects are regenerated when evicted, so keep the cache bounded instead + of allowing a long settings session to retain a large GPU allocation. */ +#define PAINT_CACHE_BYTE_LIMIT (16u * 1024u * 1024u) static BongoCatUIPaintTexture textures[PAINT_CACHE_COUNT]; static uint64_t use_counter; @@ -130,15 +132,28 @@ void bongo_cat_ui_paint_cache_draw(struct nk_context *context, void bongo_cat_ui_paint_cache_begin_frame(BongoCatUIBackend *backend) { if (!backend) return; - uint64_t marker = backend->paint_frame_marker; - if (marker) for (size_t i = 0; i < PAINT_CACHE_COUNT; ++i) { - BongoCatUIPaintTexture *item = &textures[i]; - if (item->backend == backend && item->texture && item->used < marker) - release_texture(item); - } + /* Reuse hover/animation variants across frames. LRU allocation still caps + this cache at 48 entries / 16 MiB and protects current-frame commands. */ backend->paint_frame_marker = use_counter + 1; } +void bongo_cat_ui_paint_cache_trim_idle(BongoCatUIBackend *backend) { + /* Keep small reusable effects, evict older variants first. No timer or + extra redraw is needed; closing the window releases everything. */ + size_t bytes = bongo_cat_ui_paint_cache_usage(backend, NULL); + while (bytes > 4u * 1024u * 1024u) { + BongoCatUIPaintTexture *oldest = NULL; + for (size_t i = 0; i < PAINT_CACHE_COUNT; i++) { + BongoCatUIPaintTexture *item = &textures[i]; + if (item->backend == backend && item->texture && + (!oldest || item->used < oldest->used)) oldest = item; + } + if (!oldest) break; + bytes -= oldest->bytes; + release_texture(oldest); + } +} + size_t bongo_cat_ui_paint_cache_usage(BongoCatUIBackend *backend, size_t *texture_count) { size_t bytes = 0, count = 0; @@ -160,3 +175,11 @@ void bongo_cat_ui_paint_cache_destroy(BongoCatUIBackend *backend) { } if (backend) backend->paint_frame_marker = 0; } + +void bongo_cat_ui_paint_cache_abandon(BongoCatUIBackend *backend) { + for (size_t i = 0; i < sizeof(textures) / sizeof(textures[0]); ++i) { + BongoCatUIPaintTexture *item = &textures[i]; + if (item->backend == backend) memset(item, 0, sizeof(*item)); + } + if (backend) backend->paint_frame_marker = 0; +} diff --git a/src/ui/ui_paint_cache.h b/src/ui/rendering/ui_paint_cache.h similarity index 91% rename from src/ui/ui_paint_cache.h rename to src/ui/rendering/ui_paint_cache.h index df4475ad..26c04f6b 100644 --- a/src/ui/ui_paint_cache.h +++ b/src/ui/rendering/ui_paint_cache.h @@ -35,8 +35,10 @@ void bongo_cat_ui_paint_cache_draw(struct nk_context *context, struct nk_rect bounds, const BongoCatUIPaintTexture *item, struct nk_color tint); void bongo_cat_ui_paint_cache_begin_frame(BongoCatUIBackend *backend); +void bongo_cat_ui_paint_cache_trim_idle(BongoCatUIBackend *backend); size_t bongo_cat_ui_paint_cache_usage(BongoCatUIBackend *backend, size_t *texture_count); void bongo_cat_ui_paint_cache_destroy(BongoCatUIBackend *backend); +void bongo_cat_ui_paint_cache_abandon(BongoCatUIBackend *backend); #endif diff --git a/src/ui/rendering/ui_paint_lifecycle.c b/src/ui/rendering/ui_paint_lifecycle.c new file mode 100644 index 00000000..49b254e7 --- /dev/null +++ b/src/ui/rendering/ui_paint_lifecycle.c @@ -0,0 +1,10 @@ +#include "ui_paint.h" +#include "ui_paint_cache.h" + +void bongo_cat_ui_paint_begin_frame(BongoCatUIBackend *backend) { + bongo_cat_ui_paint_cache_begin_frame(backend); +} + +void bongo_cat_ui_paint_destroy(BongoCatUIBackend *backend) { + bongo_cat_ui_paint_cache_destroy(backend); +} diff --git a/src/ui/ui_paint_shell.c b/src/ui/rendering/ui_paint_shell.c similarity index 96% rename from src/ui/ui_paint_shell.c rename to src/ui/rendering/ui_paint_shell.c index b240de0a..13a446ad 100644 --- a/src/ui/ui_paint_shell.c +++ b/src/ui/rendering/ui_paint_shell.c @@ -35,7 +35,8 @@ void bongo_cat_ui_paint_rounded_surface(struct nk_context *context, BONGO_CAT_UI_SHELL_SCALE_MAX); int width = NK_MAX(1, (int)ceilf(bounds.w * sx)); int height = NK_MAX(1, (int)ceilf(bounds.h * sy)); - int radius = (int)lroundf(rounding * (sx + sy) * .5f); + float raster_scale = NK_MIN(sx, sy); + int radius = (int)lroundf(rounding * raster_scale); BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_ROUNDED_SURFACE, width, height, radius, 0, 0, 0, 0}; BongoCatUIPaintTexture *item = diff --git a/src/ui/ui_catime.c b/src/ui/theme/ui_catime.c similarity index 63% rename from src/ui/ui_catime.c rename to src/ui/theme/ui_catime.c index 9b993460..6ea0ef1d 100644 --- a/src/ui/ui_catime.c +++ b/src/ui/theme/ui_catime.c @@ -4,7 +4,6 @@ #include "ui_icons.h" #include "ui_paint.h" -#include static BongoCatUIIconDraw external_icon_draw; static void *external_icon_userdata; @@ -43,6 +42,11 @@ float bongo_cat_ui_sidebar_width(float window_width) { BONGO_CAT_UI_SIDEBAR_WIDTH; } +bool bongo_cat_ui_native_chrome(void) { + /* Keep SDL borderless input handling consistent on macOS. */ + return false; +} + void bongo_cat_ui_shell_draw(struct nk_context *context, float width, float height, bool dark, bool native_frame) { BongoCatUIPalette p = bongo_cat_ui_palette(dark); @@ -57,20 +61,13 @@ void bongo_cat_ui_shell_draw(struct nk_context *context, float width, nk_fill_rect(canvas, surface, 0, p.surface_glass); nk_fill_rect(canvas, nk_rect(0, 0, sidebar_right, height), 0, p.surface); - } else if (fast) { + } else { nk_fill_rect(canvas, surface, rounding, p.surface_glass); nk_push_scissor(canvas, nk_rect(0, 0, sidebar_right, height)); nk_fill_rect(canvas, surface, rounding, p.surface); nk_push_scissor(canvas, surface); - } else { - bongo_cat_ui_paint_rounded_surface(context, surface, rounding, - p.surface_glass); - nk_push_scissor(canvas, nk_rect(0, 0, sidebar_right, height)); - bongo_cat_ui_paint_rounded_surface(context, surface, rounding, - p.surface); - nk_push_scissor(canvas, surface); } - if (p.effects && !fast) + if (p.effects && !fast && !dark) bongo_cat_ui_paint_sidebar_glow(context, surface, sidebar_right, rounding, nk_rgba(p.accent.r, p.accent.g, p.accent.b, 56)); @@ -79,67 +76,46 @@ void bongo_cat_ui_shell_draw(struct nk_context *context, float width, p.border_subtle); } -static void signature(struct nk_command_buffer *canvas, struct nk_rect bounds, - BongoCatUIPalette p) { - float x = bounds.x + (bounds.w - 90) * .5f, y = bounds.y + 124; - struct nk_color glow = p.effects ? - nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89) : p.pink; - nk_stroke_curve(canvas, x + 3, y + 8, x + 25, y + 12, - x + 55, y + 10, x + 87, y + 6, 6, glow); - float previous_x = x + 3, previous_y = y + 8; - for (int i = 1; i <= 24; ++i) { - float t = i / 24.0f, u = 1.0f - t; - float next_x = x + u * u * u * 3 + 3 * u * u * t * 25 + - 3 * u * t * t * 55 + t * t * t * 87; - float next_y = y + u * u * u * 8 + 3 * u * u * t * 12 + - 3 * u * t * t * 10 + t * t * t * 6; - nk_stroke_line(canvas, previous_x, previous_y, next_x, next_y, 3, - bongo_cat_ui_color_mix(p.accent, p.pink, t)); - previous_x = next_x; previous_y = next_y; - } -} - bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, - bool *title_clicked, bool interactive, bool dark) { + bool *title_clicked, bool interactive, bool dark, bool native_chrome) { struct nk_rect bounds; - float height = nk_window_get_content_region(context).w < 100 ? 118.0f : 148.0f; + bool compact = nk_window_get_content_region(context).w < 100; + float height = compact ? (native_chrome ? 148.0f : 118.0f) : + (native_chrome ? 176.0f : 148.0f); nk_layout_row_dynamic(context, height, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; BongoCatUIPalette p = bongo_cat_ui_palette(dark); struct nk_command_buffer *canvas = nk_window_get_canvas(context); - float size = bounds.w < 100 ? 54.0f : 72.0f; + /* The current logo asset already contains its own rounded brand mark. + Give it room to read at a glance instead of framing it a second time. */ + float base_size = compact ? 68.0f : 84.0f; + float top = native_chrome ? 34.0f : (compact ? 6.0f : 8.0f); + struct nk_rect base_logo = nk_rect(bounds.x + (bounds.w - base_size) * .5f, + bounds.y + top, base_size, base_size); + bool logo_hover = interactive && nk_input_is_mouse_hovering_rect( + &context->input, base_logo); bool hover = interactive && nk_input_is_mouse_hovering_rect(&context->input, nk_rect(bounds.x, bounds.y, bounds.w, bounds.h)); float hover_amount = bongo_cat_ui_animate_eased(context, "brand-logo-hover", hover ? 1.0f : 0.0f, 250.0f, BONGO_CAT_UI_EASE_SPRING); - size *= 1.0f + .08f * hover_amount; - struct nk_rect frame = nk_rect(bounds.x + (bounds.w - size) * .5f, - bounds.y + (bounds.w < 100 ? 10.0f : 13.0f) - - (size - (bounds.w < 100 ? 54.0f : 72.0f)) * .5f, size, size); - float rounding = bounds.w < 100 ? 14.0f : 18.0f; - if (p.effects) bongo_cat_ui_paint_shadow(context, frame, rounding, - 0, 5, 16, 0, nk_rgba(p.pink.r, p.pink.g, p.pink.b, 89)); - if (p.effects) bongo_cat_ui_paint_gradient(context, frame, rounding, - p.accent, hover ? p.pink_hover : p.pink); - else nk_fill_rect(canvas, frame, rounding, p.accent); - struct nk_rect inner = nk_rect(frame.x + 3, frame.y + 3, - frame.w - 6, frame.h - 6); - nk_fill_rect(canvas, inner, bounds.w < 100 ? 11.0f : 15.0f, p.surface); + float size = base_size * (1.0f + .08f * hover_amount); + struct nk_rect logo = nk_rect(bounds.x + (bounds.w - size) * .5f, + bounds.y + top - (size - base_size) * .5f, size, size); + if (p.effects) bongo_cat_ui_paint_shadow(context, logo, + compact ? 17.0f : 21.0f, 0, 4, 14, 0, + nk_rgba(p.pink.r, p.pink.g, p.pink.b, 64)); if (logo_texture) { struct nk_image image = nk_image_id((int)logo_texture); - float image_size = (bounds.w < 100 ? 42.0f : 56.0f) * - (1.0f + .08f * hover_amount); - struct nk_rect image_bounds = nk_rect(frame.x + (size - image_size) * .5f, - frame.y + (size - image_size) * .5f, image_size, image_size); - nk_draw_image(canvas, image_bounds, &image, nk_rgb(255, 255, 255)); + nk_draw_image(canvas, logo, &image, nk_rgb(255, 255, 255)); } if (bounds.w >= 100) { if (!font) font = bongo_cat_ui_caption_font(context); - centered(canvas, nk_rect(bounds.x, bounds.y + 88, bounds.w, 28), title, - font, p.pink); - signature(canvas, bounds, p); + float title_y = native_chrome ? 124.0f : 94.0f; + struct nk_color title_color = logo_hover ? nk_rgb(84, 174, 255) : p.pink; + centered(canvas, nk_rect(bounds.x, bounds.y + title_y, bounds.w, 28), title, + font, title_color); } if (hover) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); @@ -148,7 +124,8 @@ bool bongo_cat_ui_header(struct nk_context *context, const char *title, return false; } bool bongo_cat_ui_content_header(struct nk_context *context, - const char *title, int icon, bool interactive, bool dark) { + const char *title, int icon, bool interactive, bool dark, + bool native_chrome) { struct nk_rect bounds; nk_layout_row_dynamic(context, BONGO_CAT_UI_HEADER_HEIGHT, 1); if (nk_widget(&bounds, context) == NK_WIDGET_INVALID) return false; @@ -165,20 +142,32 @@ bool bongo_cat_ui_content_header(struct nk_context *context, bounds.y + (bounds.h - font->height) * .5f, bounds.w - 110, font->height); nk_draw_text(canvas, text, title, nk_strlen(title), font, nk_rgba(0, 0, 0, 0), p.accent); + if (native_chrome) return false; struct nk_rect close = nk_rect(bounds.x + bounds.w - 48, bounds.y + 11, 34, 34); - bool hover = interactive && nk_input_is_mouse_hovering_rect(&context->input, close); - struct nk_color color = hover ? p.accent : p.muted; - if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_CLOSE, - nk_rect(close.x + 7, close.y + 7, 20, 20), color)) { - nk_stroke_line(canvas, close.x + 10, close.y + 10, - close.x + 24, close.y + 24, 2, color); - nk_stroke_line(canvas, close.x + 24, close.y + 10, - close.x + 10, close.y + 24, 2, color); + return bongo_cat_ui_close_button(context, canvas, close, p.muted, + p.accent, interactive); +} + +bool bongo_cat_ui_close_button(struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + struct nk_color normal, struct nk_color hover, bool interactive) { + bool is_hovered = interactive && nk_input_is_mouse_hovering_rect( + &context->input, bounds); + struct nk_color color = is_hovered ? hover : normal; + float icon_size = NK_MAX(0.0f, NK_MIN(bounds.w, bounds.h) - 14.0f); + struct nk_rect icon = nk_rect(bounds.x + (bounds.w - icon_size) * .5f, + bounds.y + (bounds.h - icon_size) * .5f, icon_size, icon_size); + if (!bongo_cat_ui_draw_icon(canvas, BONGO_CAT_UI_ICON_CLOSE, icon, color)) { + float inset = icon_size * .22f; + nk_stroke_line(canvas, icon.x + inset, icon.y + inset, + icon.x + icon.w - inset, icon.y + icon.h - inset, 2, color); + nk_stroke_line(canvas, icon.x + icon.w - inset, icon.y + inset, + icon.x + inset, icon.y + icon.h - inset, 2, color); } - if (hover) bongo_cat_ui_cursor_hover_rect(context, close, + if (is_hovered) bongo_cat_ui_cursor_hover_rect(context, bounds, BONGO_CAT_UI_CURSOR_POINTER); - return hover && nk_input_is_mouse_click_in_rect(&context->input, - NK_BUTTON_LEFT, close); + return is_hovered && nk_input_is_mouse_click_in_rect(&context->input, + NK_BUTTON_LEFT, bounds); } bool bongo_cat_ui_close_hit(float x, float y, float width) { diff --git a/src/ui/ui_catime.h b/src/ui/theme/ui_catime.h similarity index 81% rename from src/ui/ui_catime.h rename to src/ui/theme/ui_catime.h index a941f0a3..e76c6e92 100644 --- a/src/ui/ui_catime.h +++ b/src/ui/theme/ui_catime.h @@ -41,12 +41,16 @@ typedef void (*BongoCatUIIconDraw)(void *userdata, struct nk_color color); bool bongo_cat_ui_header(struct nk_context *context, const char *title, const struct nk_user_font *font, unsigned int logo_texture, - bool *title_clicked, bool interactive, bool dark); + bool *title_clicked, bool interactive, bool dark, bool native_chrome); bool bongo_cat_ui_content_header(struct nk_context *context, - const char *title, int icon, bool interactive, bool dark); + const char *title, int icon, bool interactive, bool dark, + bool native_chrome); +bool bongo_cat_ui_close_button(struct nk_context *context, + struct nk_command_buffer *canvas, struct nk_rect bounds, + struct nk_color normal, struct nk_color hover, bool interactive); void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, - int count, int *active, bool interactive, bool dark, - float available_height, + const int *icons, int count, int *active, bool interactive, bool dark, + float available_height, bool about_badge, BongoCatUIIconDraw draw_icon, void *icon_userdata); void bongo_cat_ui_set_icons(BongoCatUIIconDraw draw_icon, void *icon_userdata); @@ -55,6 +59,7 @@ bool bongo_cat_ui_draw_icon(struct nk_command_buffer *canvas, int icon, void bongo_cat_ui_fallback_icon(struct nk_command_buffer *canvas, int icon, struct nk_rect bounds, struct nk_color color); float bongo_cat_ui_sidebar_width(float window_width); +bool bongo_cat_ui_native_chrome(void); bool bongo_cat_ui_close_hit(float x, float y, float width); bool bongo_cat_ui_title_link_hit(float x, float y, float width); bool bongo_cat_ui_title_drag_hit(float x, float y, float width); diff --git a/src/ui/ui_catime_icons.c b/src/ui/theme/ui_catime_icons.c similarity index 100% rename from src/ui/ui_catime_icons.c rename to src/ui/theme/ui_catime_icons.c diff --git a/src/ui/ui_catime_tabs.c b/src/ui/theme/ui_catime_tabs.c similarity index 89% rename from src/ui/ui_catime_tabs.c rename to src/ui/theme/ui_catime_tabs.c index b4303126..fab38ab3 100644 --- a/src/ui/ui_catime_tabs.c +++ b/src/ui/theme/ui_catime_tabs.c @@ -70,20 +70,18 @@ static void tab_content(struct nk_command_buffer *canvas, } void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, - int count, int *active, bool interactive, bool dark, - float available_height, + const int *icons, int count, int *active, bool interactive, bool dark, + float available_height, bool about_badge, BongoCatUIIconDraw draw_icon, void *icon_userdata) { BongoCatUIPalette p = bongo_cat_ui_palette(dark); struct nk_command_buffer *canvas = nk_window_get_canvas(context); float header_height = nk_window_get_content_region(context).w < 100.0f ? 118.0f : 148.0f; float room = NK_MAX(1.0f, available_height - header_height); - float top_padding = NK_MIN(16.0f, NK_MAX(4.0f, room * .05f)); - float row_height = 68.0f; - float gap = count > 1 ? NK_CLAMP(0.0f, - (room - top_padding - row_height * count) / (count - 1), 8.0f) : 0.0f; - if (top_padding + row_height * count > room) - row_height = NK_MAX(40.0f, (room - top_padding) / count); + float slot_height = room / count; + float row_height = NK_CLAMP(40.0f, slot_height - 8.0f, 68.0f); + float gap = NK_MAX(0.0f, slot_height - row_height); + float top_padding = gap * .5f; for (int i = 0; i < count; ++i) { struct nk_rect bounds; nk_layout_row_dynamic(context, row_height, 1); @@ -111,8 +109,12 @@ void bongo_cat_ui_tabs(struct nk_context *context, const char *const *labels, nk_rgba(p.pink.r, p.pink.g, p.pink.b, (nk_byte)(255 * weight))); struct nk_color color = selected ? nk_rgb(255, 255, 255) : (hover ? p.accent : p.muted); - tab_content(canvas, context, tile, labels[i], i, color, + tab_content(canvas, context, tile, labels[i], icons ? icons[i] : i, color, draw_icon, icon_userdata); + if (about_badge && i == count - 1) { + nk_fill_circle(canvas, nk_rect(tile.x + tile.w - 11.0f, + tile.y + 7.0f, 7.0f, 7.0f), p.danger); + } if (hover) bongo_cat_ui_cursor_hover_rect(context, hit, BONGO_CAT_UI_CURSOR_POINTER); if (hover && nk_input_is_mouse_click_in_rect(&context->input, diff --git a/src/ui/ui_catime_theme.c b/src/ui/theme/ui_catime_theme.c similarity index 97% rename from src/ui/ui_catime_theme.c rename to src/ui/theme/ui_catime_theme.c index a4c1d1b7..5e2ee8b2 100644 --- a/src/ui/ui_catime_theme.c +++ b/src/ui/theme/ui_catime_theme.c @@ -39,9 +39,9 @@ BongoCatUIPalette bongo_cat_ui_palette(bool dark) { #ifdef _WIN32 if (high_contrast(&value)) return value; #endif - value.background = rgb(dark ? 0x16181D : 0xF0F4F9); - value.surface = rgb(dark ? 0x21242B : 0xFFFFFF); - value.surface_glass = rgb(dark ? 0x20232A : 0xFBFCFE); + value.background = rgb(dark ? 0x010409 : 0xF0F4F9); + value.surface = rgb(dark ? 0x010409 : 0xFFFFFF); + value.surface_glass = rgb(dark ? 0x010409 : 0xFBFCFE); value.field = rgb(dark ? 0x2A2E37 : 0xF5F8FC); value.border = rgb(dark ? 0x345473 : 0xDAEDFE); value.border_subtle = rgb(dark ? 0x343A45 : 0xE8EBF1); diff --git a/src/ui/ui_font.c b/src/ui/ui_font.c deleted file mode 100644 index 0405dc09..00000000 --- a/src/ui/ui_font.c +++ /dev/null @@ -1,148 +0,0 @@ -#include "ui_font.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include - -static bool readable(const char *path) { - FILE *file = bongo_cat_file_open(path, "rb"); - if (!file) return false; - fclose(file); - return true; -} - -const char *bongo_cat_ui_system_font(char *path, size_t capacity, bool multilingual) { -#ifdef _WIN32 - const char *windows = SDL_getenv("WINDIR"); - if (!windows) windows = SDL_getenv("SystemRoot"); - if (windows) { - const char *multi[] = {"Fonts/msyh.ttc", "Fonts/msyhl.ttc"}; - const char *latin[] = {"Fonts/segoeui.ttf", "Fonts/msyhl.ttc"}; - const char **candidates = multilingual ? multi : latin; - for (size_t i = 0; i < 2; ++i) { - bongo_cat_path_join(path, capacity, windows, candidates[i]); - if (readable(path)) return path; - } - } -#elif defined(__APPLE__) - const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : - "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", - "/System/Library/Fonts/Hiragino Sans GB.ttc"}; -#else - const char *candidates[] = {multilingual ? - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" : - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.otf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - if (!readable(candidates[i])) continue; - snprintf(path, capacity, "%s", candidates[i]); - return path; - } -#endif - return NULL; -} - -const char *bongo_cat_ui_system_heading_font(char *path, size_t capacity, - bool multilingual) { -#ifdef _WIN32 - const char *windows = SDL_getenv("WINDIR"); - if (!windows) windows = SDL_getenv("SystemRoot"); - if (windows) { - const char *multi[] = {"Fonts/msyhbd.ttc", "Fonts/msyhl.ttc"}; - const char *latin[] = {"Fonts/seguisb.ttf", "Fonts/segoeui.ttf"}; - const char **candidates = multilingual ? multi : latin; - for (size_t i = 0; i < 2; ++i) { - bongo_cat_path_join(path, capacity, windows, candidates[i]); - if (readable(path)) return path; - } - } -#elif defined(__APPLE__) - const char *candidates[] = {multilingual ? "/System/Library/Fonts/PingFang.ttc" : - "/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/PingFang.ttc", - "/System/Library/Fonts/Hiragino Sans GB.ttc"}; -#else - const char *candidates[] = {multilingual ? - "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" : - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - if (!readable(candidates[i])) continue; - snprintf(path, capacity, "%s", candidates[i]); - return path; - } -#endif - return NULL; -} - -const char *bongo_cat_ui_system_korean_font(char *path, size_t capacity) { -#ifdef _WIN32 - const char *windows = SDL_getenv("WINDIR"); - if (!windows) windows = SDL_getenv("SystemRoot"); - if (windows) { - const char *candidates[] = {"Fonts/malgun.ttf", "Fonts/malgunsl.ttf"}; - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - bongo_cat_path_join(path, capacity, windows, candidates[i]); - if (readable(path)) return path; - } - } -#elif defined(__APPLE__) - const char *candidates[] = { - "/System/Library/Fonts/AppleSDGothicNeo.ttc", - "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; -#else - const char *candidates[] = { - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/opentype/noto/NotoSansKR-Regular.otf", - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - if (!readable(candidates[i])) continue; - snprintf(path, capacity, "%s", candidates[i]); - return path; - } -#endif - return bongo_cat_ui_system_font(path, capacity, true); -} - -const char *bongo_cat_ui_system_korean_heading_font(char *path, - size_t capacity) { -#ifdef _WIN32 - const char *windows = SDL_getenv("WINDIR"); - if (!windows) windows = SDL_getenv("SystemRoot"); - if (windows) { - const char *candidates[] = {"Fonts/malgunbd.ttf", "Fonts/malgun.ttf"}; - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - bongo_cat_path_join(path, capacity, windows, candidates[i]); - if (readable(path)) return path; - } - } -#elif defined(__APPLE__) - const char *candidates[] = { - "/System/Library/Fonts/AppleSDGothicNeo.ttc", - "/System/Library/Fonts/Supplemental/AppleGothic.ttf"}; -#else - const char *candidates[] = { - "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", - "/usr/share/fonts/opentype/noto/NotoSansKR-Bold.otf", - "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"}; -#endif -#ifndef _WIN32 - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); ++i) { - if (!readable(candidates[i])) continue; - snprintf(path, capacity, "%s", candidates[i]); - return path; - } -#endif - return bongo_cat_ui_system_heading_font(path, capacity, true); -} diff --git a/tests/test_app_state.c b/tests/core/test_app_state.c similarity index 53% rename from tests/test_app_state.c rename to tests/core/test_app_state.c index ec3b6dec..5f3f2dc1 100644 --- a/tests/test_app_state.c +++ b/tests/core/test_app_state.c @@ -13,6 +13,9 @@ typedef struct ParameterValue { static ParameterValue parameters[128]; static size_t parameter_count; static bool left_hand, right_hand, left_trigger, right_trigger, left_thumb; +static bool active_motions[8]; +static int active_expression = -1; +static int restored_motion_count; int bongo_cat_test_failures; static float parameter(const char *id) { @@ -38,6 +41,63 @@ bool bongo_cat_live2d_parameter(BongoCatLive2D *live2d, const char *id, return true; } +bool bongo_cat_live2d_start_motion(BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + if (index < 0 || index >= (int)(sizeof(active_motions) / + sizeof(active_motions[0]))) return false; + active_motions[index] = true; + return true; +} + +bool bongo_cat_live2d_restore_motion_state(BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + if (index < 0 || index >= (int)(sizeof(active_motions) / + sizeof(active_motions[0]))) return false; + active_motions[index] = true; + restored_motion_count++; + return true; +} + +bool bongo_cat_live2d_motion_selected(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + return index >= 0 && index < (int)(sizeof(active_motions) / + sizeof(active_motions[0])) && active_motions[index]; +} + +bool bongo_cat_live2d_motion_persistent(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; + return index == 1; +} + +bool bongo_cat_live2d_motion_visible(const BongoCatLive2D *live2d, + const char *group, int index) { + (void)live2d; (void)group; (void)index; + return true; +} + +bool bongo_cat_live2d_motion_same_toggle(const BongoCatLive2D *live2d, + const char *left_group, int left_index, + const char *right_group, int right_index) { + (void)live2d; (void)left_group; (void)left_index; + (void)right_group; (void)right_index; + return false; +} + +bool bongo_cat_live2d_set_expression(BongoCatLive2D *live2d, int index) { + (void)live2d; + active_expression = index; + return true; +} + +int bongo_cat_live2d_expression(const BongoCatLive2D *live2d) { + (void)live2d; + return active_expression; +} + int bongo_cat_overlay_key(BongoCatOverlay *overlay, const char *name, bool pressed) { (void)overlay; if (strcmp(name, "KeyA") == 0 || strcmp(name, "DPadLeft") == 0) @@ -60,6 +120,63 @@ static BongoCatInputEvent input(BongoCatInputKind kind, const char *name, float return event; } +static void check_behavior_state(BongoCatApp *app) { + snprintf(app->loaded_model, sizeof(app->loaded_model), "model-a"); + app->behaviors.count = 3; + app->behaviors.entries[0] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_MOTION, .index = 1}; + snprintf(app->behaviors.entries[0].id, + sizeof(app->behaviors.entries[0].id), "model-a:motion:Tap:1"); + snprintf(app->behaviors.entries[0].group, + sizeof(app->behaviors.entries[0].group), "Tap"); + app->behaviors.entries[1] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_MOTION, .index = 2}; + snprintf(app->behaviors.entries[1].id, + sizeof(app->behaviors.entries[1].id), "model-a:motion:Tap:2"); + snprintf(app->behaviors.entries[1].group, + sizeof(app->behaviors.entries[1].group), "Tap"); + app->behaviors.entries[2] = (BongoCatBehaviorEntry){ + .kind = BONGO_CAT_BEHAVIOR_EXPRESSION, .index = 3}; + snprintf(app->behaviors.entries[2].id, + sizeof(app->behaviors.entries[2].id), "model-a:expression:3"); + + app->session.active_behavior_count = 1; + snprintf(app->session.active_behaviors[0].model_id, + sizeof(app->session.active_behaviors[0].model_id), "model-b"); + snprintf(app->session.active_behaviors[0].behavior_id, + sizeof(app->session.active_behaviors[0].behavior_id), + "model-b:expression:1"); + active_motions[1] = true; + active_motions[2] = true; + active_expression = 3; + CHECK(bongo_cat_app_selected_motion_count(app) == 2); + bongo_cat_app_capture_behavior_state(app); + CHECK(app->session.active_behavior_count == 3); + CHECK(strcmp(app->session.active_behaviors[0].model_id, + "model-b") == 0); + CHECK(strcmp(app->session.active_behaviors[1].behavior_id, + "model-a:motion:Tap:1") == 0); + CHECK(strcmp(app->session.active_behaviors[2].behavior_id, + "model-a:expression:3") == 0); + + active_motions[1] = false; + active_motions[2] = false; + active_expression = -1; + bongo_cat_app_restore_behavior_state(app, "model-a"); + CHECK(active_motions[1]); + CHECK(!active_motions[2]); + CHECK(bongo_cat_app_selected_motion_count(app) == 1); + CHECK(active_expression == 3); + CHECK(restored_motion_count == 1); + + active_motions[1] = false; + active_expression = -1; + bongo_cat_app_capture_behavior_state(app); + CHECK(app->session.active_behavior_count == 1); + CHECK(strcmp(app->session.active_behaviors[0].model_id, + "model-b") == 0); +} + int main(void) { /* BongoCatApp contains the input queue and model catalogs and is too large for the default 1 MiB Windows test-thread stack. */ @@ -83,6 +200,18 @@ int main(void) { CHECK(!app.right_mouse_down && app.pointer_hit_dirty); CHECK(parameter("ParamMouseRightDown") == 0.0f); + event = input(BONGO_CAT_INPUT_MOUSE_DOWN, "Back", 1.0f); + bongo_cat_app_apply_input(&app, &event); + event = input(BONGO_CAT_INPUT_MOUSE_DOWN, "Forward", 1.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(app.side_mouse_down); + event = input(BONGO_CAT_INPUT_MOUSE_UP, "Back", 0.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(app.side_mouse_down && app.forward_mouse_down); + event = input(BONGO_CAT_INPUT_MOUSE_UP, "Forward", 0.0f); + bongo_cat_app_apply_input(&app, &event); + CHECK(!app.side_mouse_down); + event = input(BONGO_CAT_INPUT_KEY_DOWN, "KeyA", 1.0f); bongo_cat_app_apply_input(&app, &event); CHECK(parameter("CatParamLeftHandDown") == 1.0f); @@ -131,5 +260,6 @@ int main(void) { CHECK(parameter("CatParamStickShowLeftHand") == 0.0f); CHECK(parameter("CatParamLeftHandDown") == 0.0f); CHECK(!right_hand && !left_trigger && !right_trigger && !left_thumb); + check_behavior_state(&app); return bongo_cat_test_failures ? 1 : 0; } diff --git a/tests/core/test_config.c b/tests/core/test_config.c new file mode 100644 index 00000000..c3038e99 --- /dev/null +++ b/tests/core/test_config.c @@ -0,0 +1,234 @@ +#include "test.h" +#include "test_config_validation.h" +#include "bongo_cat/config.h" +#include "bongo_cat/file.h" +#include "bongo_cat/model.h" +#include "bongo_cat/path.h" + +#include +#include + +static void write_text(const char *path, const char *text) { + FILE *file = bongo_cat_file_open(path, "wb"); + CHECK(file != NULL); + if (file) { CHECK(fputs(text, file) >= 0); CHECK(fclose(file) == 0); } +} + +static bool contains_text(const char *path, const char *needle) { + char content[8192] = {0}; + FILE *file = bongo_cat_file_open(path, "rb"); + if (!file) return false; + size_t length = fread(content, 1, sizeof(content) - 1, file); + fclose(file); + content[length] = '\0'; + return strstr(content, needle) != NULL; +} + +void test_config(void) { + test_config_validation(); + BongoCatLanguage language; + CHECK(!strcmp(bongo_cat_language_name(BONGO_CAT_LANG_ZH_HANT), + "zh-Hant")); + CHECK(bongo_cat_language_parse("zh-Hant", &language) && + language == BONGO_CAT_LANG_ZH_HANT); + CHECK(bongo_cat_language_parse("zh-TW", &language) && + language == BONGO_CAT_LANG_ZH_HANT); + CHECK(!bongo_cat_language_parse("zh-Hans", &language)); + const uint32_t colors[] = {0x00ff00, 0x0000ff, 0xff0000, 0xff00ff}; + for (int i = 0; i < BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; ++i) + CHECK(bongo_cat_obs_background_color_rgb(i) == colors[i]); + + static BongoCatSettings settings; + static BongoCatSessionState session; + bongo_cat_settings_defaults(&settings); + bongo_cat_session_defaults(&session); + CHECK(!settings.window.pass_through); + settings.model.max_fps = 30; + settings.model.multiple_pets = true; + settings.model.mirror = true; + settings.model.mouse_centered = false; + settings.window.pass_through = true; + settings.window.obs_background = true; + settings.window.random_expression = true; + settings.window.random_expression_interval_seconds = 12.0f; + settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_BLUE; + settings.app.language = BONGO_CAT_LANG_ZH_CN; + memcpy(settings.extensions_json, "{\"example\":{\"enabled\":true}}", + sizeof("{\"example\":{\"enabled\":true}}")); + settings.behavior_shortcut_count = 1; + memcpy(settings.behavior_shortcuts[0].id, "model:motion:Tap:0", + sizeof("model:motion:Tap:0")); + memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", + sizeof("Control+1")); + memcpy(settings.behavior_shortcuts[0].label, "Happy tap", + sizeof("Happy tap")); + CHECK(bongo_cat_settings_set_model_label(&settings, "model", "Display")); + CHECK(bongo_cat_settings_set_model_removed(&settings, "model~2", true)); + CHECK(bongo_cat_settings_set_model_removed(&settings, "model-extra", true)); + session.window.x = -321; + session.window.position_known = true; + session.window.opacity_percent = 75.0f; + session.window.width = 700; + session.window.height = 500; + session.window.content_width = 612; + session.window.content_height = 354; + memcpy(session.active_model_id, "model", sizeof("model")); + session.last_update_check_day = 20260827; + memcpy(session.last_update_check_version, "0.1.0", sizeof("0.1.0")); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "gamepad")); + session.active_behavior_count = 2; + memcpy(session.active_behaviors[0].model_id, "model", + sizeof("model")); + memcpy(session.active_behaviors[0].behavior_id, + "model:motion:Tap:0", sizeof("model:motion:Tap:0")); + memcpy(session.active_behaviors[1].model_id, "model", + sizeof("model")); + memcpy(session.active_behaviors[1].behavior_id, + "model:expression:2", sizeof("model:expression:2")); + + const char *settings_path = "bongocat-settings.json"; + const char *session_path = "bongocat-session.json"; + BongoCatError error = {0}; + CHECK(bongo_cat_settings_save(settings_path, &settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_save(session_path, &session, &error) == + BONGO_CAT_OK); + CHECK(contains_text(settings_path, "\"format\": \"bongocat/settings\"")); + CHECK(contains_text(settings_path, "\"captureBackground\": true")); + CHECK(contains_text(settings_path, "\"randomExpression\": true")); + CHECK(contains_text(settings_path, + "\"randomExpressionIntervalSeconds\": 12.0")); + CHECK(contains_text(settings_path, "\"multiplePets\": true") && !contains_text(settings_path, "inputReleaseDelaySeconds")); + CHECK(contains_text(settings_path, "\"removedModels\"") && + contains_text(settings_path, "\"model~2\"")); + CHECK(contains_text(settings_path, "\"example\"")); + CHECK(!contains_text(settings_path, "activeModelId")); + CHECK(contains_text(session_path, "\"format\": \"bongocat/session\"")); + CHECK(contains_text(session_path, "\"contentWidth\": 612") && + contains_text(session_path, "\"contentHeight\": 354")); + CHECK(contains_text(session_path, "\"activeModelId\": \"model\"")); + CHECK(contains_text(session_path, "\"lastUpdateCheckDay\": 20260827")); + CHECK(contains_text(session_path, "\"additionalModelIds\"")); + CHECK(contains_text(session_path, "\"activeBehaviors\"")); + CHECK(contains_text(session_path, + "\"behaviorId\": \"model:expression:2\"")); + CHECK(!contains_text(session_path, "clickThrough")); + + static BongoCatSettings loaded_settings; + static BongoCatSessionState loaded_session; + bongo_cat_settings_defaults(&loaded_settings); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_settings_load(settings_path, &loaded_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == + BONGO_CAT_OK); + CHECK(loaded_settings.model.max_fps == 30 && loaded_settings.model.mirror && + loaded_settings.model.multiple_pets); + CHECK(loaded_settings.window.pass_through && + loaded_settings.window.obs_background && + loaded_settings.window.random_expression && + loaded_settings.window.random_expression_interval_seconds == 12.0f); + CHECK(loaded_settings.app.language == BONGO_CAT_LANG_ZH_CN); + CHECK(strstr(loaded_settings.extensions_json, + "\"enabled\":true") != NULL); + CHECK(strcmp(bongo_cat_model_name(&loaded_settings, + &(BongoCatModelEntry){.id = "model", .display_name = "Imported"}), + "Display") == 0); + CHECK(bongo_cat_settings_model_removed(&loaded_settings, "model~2") && + bongo_cat_settings_model_removed(&loaded_settings, "model-extra")); + CHECK(bongo_cat_settings_restore_model_package(&loaded_settings, "model")); + CHECK(!bongo_cat_settings_model_removed(&loaded_settings, "model~2") && + bongo_cat_settings_model_removed(&loaded_settings, "model-extra")); + CHECK(loaded_session.window.x == -321 && + loaded_session.window.opacity_percent == 75.0f && + loaded_session.window.width == 700 && + loaded_session.window.height == 500 && + loaded_session.window.content_width == 612 && + loaded_session.window.content_height == 354); + CHECK(strcmp(loaded_session.active_model_id, "model") == 0); + CHECK(loaded_session.last_update_check_day == 20260827 && + strcmp(loaded_session.last_update_check_version, "0.1.0") == 0); + CHECK(loaded_session.additional_model_count == 2 && + bongo_cat_session_model_active(&loaded_session, "keyboard") && + bongo_cat_session_model_active(&loaded_session, "gamepad")); + CHECK(loaded_session.active_behavior_count == 2); + CHECK(strcmp(loaded_session.active_behaviors[0].behavior_id, + "model:motion:Tap:0") == 0); + CHECK(strcmp(loaded_session.active_behaviors[1].behavior_id, + "model:expression:2") == 0); + + const char *unsupported = "bongocat-unsupported.json"; + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":2}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/session\",\"schemaVersion\":2}"); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == + BONGO_CAT_ERROR_FORMAT); + + write_text(unsupported, "{\"format\":\"bongocat/settings\",\"schemaVersion\":1,\"rendering\":{\"inputReleaseDelaySeconds\":3,\"maximumFps\":30}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == BONGO_CAT_OK && loaded_settings.model.max_fps == 30); + + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":\"fast\"}}"); + loaded_settings.model.max_fps = 73; + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + CHECK(loaded_settings.model.max_fps == 73); + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":1.5}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/settings\",\"schemaVersion\":1," + "\"rendering\":{\"maximumFps\":30,\"maximumFps\":60}}"); + CHECK(bongo_cat_settings_load(unsupported, &loaded_settings, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/session\",\"schemaVersion\":1," + "\"window\":{\"visible\":123}}"); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == + BONGO_CAT_ERROR_FORMAT); + write_text(unsupported, + "{\"format\":\"bongocat/session\",\"schemaVersion\":1," + "\"window\":{\"size\":{\"width\":700,\"height\":500}}}"); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_session_load(unsupported, &loaded_session, &error) == + BONGO_CAT_OK && loaded_session.window.content_width == 700 && + loaded_session.window.content_height == 500); + + static BongoCatSettings canonical_settings; + static BongoCatSessionState canonical_session; + bongo_cat_settings_defaults(&canonical_settings); + bongo_cat_session_defaults(&canonical_session); + canonical_settings.model.max_fps = 900; + canonical_session.window.scale_percent = -1.0f; + CHECK(bongo_cat_settings_save(settings_path, &canonical_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_save(session_path, &canonical_session, &error) == + BONGO_CAT_OK); + bongo_cat_settings_defaults(&loaded_settings); + bongo_cat_session_defaults(&loaded_session); + CHECK(bongo_cat_settings_load(settings_path, &loaded_settings, &error) == + BONGO_CAT_OK); + CHECK(bongo_cat_session_load(session_path, &loaded_session, &error) == + BONGO_CAT_OK); + CHECK(loaded_settings.model.max_fps == 60); + CHECK(loaded_session.window.scale_percent == 10.0f); + + const char *append_path = "bongocat-append.log"; + if (bongo_cat_path_is_file(append_path)) + CHECK(bongo_cat_file_remove(append_path)); + CHECK(bongo_cat_file_append(append_path, "first\n", 6)); + CHECK(bongo_cat_file_append(append_path, "second\n", 7)); + CHECK(contains_text(append_path, "first\nsecond\n")); + + CHECK(bongo_cat_file_remove(settings_path)); + CHECK(bongo_cat_file_remove(session_path)); + CHECK(bongo_cat_file_remove(unsupported)); + CHECK(bongo_cat_file_remove(append_path)); +} diff --git a/tests/core/test_config_validation.c b/tests/core/test_config_validation.c new file mode 100644 index 00000000..3dc5ebbe --- /dev/null +++ b/tests/core/test_config_validation.c @@ -0,0 +1,166 @@ +#include "test.h" +#include "test_config_validation.h" +#include "bongo_cat/config.h" +#include "bongo_cat/file.h" +#include "bongo_cat/model.h" + +#include +#include + +static void check_defaults_and_validation(void) { + static BongoCatSettings settings; + static BongoCatSessionState session; + bongo_cat_settings_defaults(&settings); + bongo_cat_session_defaults(&session); + CHECK(settings.model.max_fps == 60 && settings.model.mouse_centered && + !settings.model.multiple_pets); + CHECK(settings.window.always_on_top && !settings.window.keep_in_screen); + CHECK(!settings.window.obs_background); + CHECK(!settings.window.random_expression && + settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.visible && session.window.width == 612 && + session.window.height == 354 && session.window.content_width == 612 && + session.window.content_height == 354); + CHECK(strcmp(session.active_model_id, "standard") == 0); + CHECK(session.additional_model_count == 0); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "keyboard")); + CHECK(bongo_cat_session_add_model(&session, "gamepad")); + CHECK(session.additional_model_count == 2 && + bongo_cat_session_model_active(&session, "standard") && + bongo_cat_session_model_active(&session, "keyboard")); + CHECK(bongo_cat_session_remove_model(&session, "keyboard")); + CHECK(session.additional_model_count == 1 && + !bongo_cat_session_model_active(&session, "keyboard")); + bongo_cat_session_clear_additional_models(&session); + settings.model.max_fps = 900; + settings.window.obs_background_color = BONGO_CAT_OBS_BACKGROUND_COLOR_COUNT; + settings.window.hide_delay_seconds = NAN; + settings.window.random_expression_interval_seconds = NAN; + session.window.scale_percent = -2.0f; + session.window.opacity_percent = NAN; + session.active_behavior_count = 3; + memcpy(session.active_behaviors[0].model_id, "model", sizeof("model")); + memcpy(session.active_behaviors[0].behavior_id, "model:motion:Tap:0", + sizeof("model:motion:Tap:0")); + session.active_behaviors[1] = session.active_behaviors[0]; + memcpy(session.active_behaviors[2].model_id, "other", sizeof("other")); + bongo_cat_settings_validate(&settings); + bongo_cat_session_validate(&session); + CHECK(settings.model.max_fps == 60); + const int old_fps[] = {-1, 0, 1, 24, 30, 31, 60, 120, 240}; + const int new_fps[] = {60, 60, 30, 30, 30, 60, 60, 60, 60}; + for (size_t i = 0; i < sizeof(old_fps) / sizeof(old_fps[0]); ++i) { + settings.model.max_fps = old_fps[i]; + bongo_cat_settings_validate(&settings); + CHECK(settings.model.max_fps == new_fps[i]); + } + CHECK(settings.window.hide_delay_seconds == 0.0f); + CHECK(settings.window.random_expression_interval_seconds == + BONGO_CAT_DEFAULT_RANDOM_EXPRESSION_SECONDS); + CHECK(settings.window.obs_background_color == + BONGO_CAT_OBS_BACKGROUND_GREEN); + CHECK(session.window.scale_percent == 10.0f); + CHECK(session.window.opacity_percent == + BONGO_CAT_DEFAULT_WINDOW_OPACITY_PERCENT); + CHECK(session.active_behavior_count == 1); + CHECK(strcmp(session.active_behaviors[0].model_id, "model") == 0); +} + +static void check_override_canonicalization(void) { + static BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + settings.behavior_shortcut_count = 4; + memcpy(settings.behavior_shortcuts[0].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[0].shortcut, "Control+1", + sizeof("Control+1")); + memcpy(settings.behavior_shortcuts[0].label, "First", sizeof("First")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+2", + sizeof("Control+2")); + memcpy(settings.behavior_shortcuts[2].id, "empty", sizeof("empty")); + memcpy(settings.behavior_shortcuts[3].id, "motion:2", sizeof("motion:2")); + memcpy(settings.behavior_shortcuts[3].label, "Second", sizeof("Second")); + settings.model_label_count = 2; + memcpy(settings.model_labels[0].id, "model", sizeof("model")); + memcpy(settings.model_labels[0].label, "Old", sizeof("Old")); + memcpy(settings.model_labels[1].id, "model", sizeof("model")); + memcpy(settings.model_labels[1].label, "New", sizeof("New")); + bongo_cat_settings_validate(&settings); + CHECK(settings.behavior_shortcut_count == 2); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+2") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].label, "First") == 0); + CHECK(strcmp(settings.behavior_shortcuts[1].id, "motion:2") == 0); + CHECK(settings.model_label_count == 1); + CHECK(strcmp(settings.model_labels[0].label, "New") == 0); +} + +static void check_shortcuts(void) { + static BongoCatSettings settings; + bongo_cat_settings_defaults(&settings); + memcpy(settings.shortcuts.toggle_pet_visibility, "Control+B", + sizeof("Control+B")); + memcpy(settings.shortcuts.visible_preferences, "control+b", + sizeof("control+b")); + settings.behavior_shortcut_count = 2; + memcpy(settings.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); + memcpy(settings.behavior_shortcuts[0].shortcut, "CONTROL+B", + sizeof("CONTROL+B")); + memcpy(settings.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); + memcpy(settings.behavior_shortcuts[1].shortcut, "Control+M", + sizeof("Control+M")); + bongo_cat_settings_validate(&settings); + CHECK(strcmp(settings.shortcuts.toggle_pet_visibility, "Control+B") == 0); + CHECK(!settings.shortcuts.visible_preferences[0]); + CHECK(settings.behavior_shortcut_count == 1); + CHECK(strcmp(settings.behavior_shortcuts[0].id, "motion:1") == 0); + CHECK(strcmp(settings.behavior_shortcuts[0].shortcut, "Control+M") == 0); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "control+b", NULL)); + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "control+b", + settings.shortcuts.toggle_pet_visibility)); +} + +static void check_behavior_companions(void) { + static BongoCatSettings settings, loaded; + bongo_cat_settings_defaults(&settings); + const char *ids[] = {"lucia:motion:CAT_motion_lock:14", "lucia:sound:0", + "lucia:expression:0", "lucia:motion:Tap:0", "lucia:sound:3"}; + settings.behavior_shortcut_count = sizeof(ids) / sizeof(ids[0]); + for (size_t i = 0; i < settings.behavior_shortcut_count; ++i) + snprintf(settings.behavior_shortcuts[i].id, sizeof(settings.behavior_shortcuts[i].id), "%s", ids[i]); + for (size_t i = 0; i < 4; ++i) + snprintf(settings.behavior_shortcuts[i].shortcut, BONGO_CAT_SHORTCUT_CAP, "Alt+O"); + settings.behavior_shortcuts[4].shortcut_disabled = true; + snprintf(settings.shortcuts.mirror, BONGO_CAT_SHORTCUT_CAP, "Control+M"); + for (size_t i = 0; i < 4; ++i) { + CHECK(!bongo_cat_settings_shortcut_conflicts(&settings, "alt+o", + settings.behavior_shortcuts[i].shortcut)); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "Control+M", + settings.behavior_shortcuts[i].shortcut)); + } + bongo_cat_settings_validate(&settings); + CHECK(settings.behavior_shortcut_count == 5); + for (size_t i = 0; i < 4; ++i) + CHECK(!strcmp(settings.behavior_shortcuts[i].shortcut, "Alt+O")); + CHECK(bongo_cat_settings_shortcut_conflicts(&settings, "Alt+O", settings.shortcuts.mirror)); + CHECK(bongo_cat_settings_save("bongocat-audio-config-test.json", &settings, NULL) == BONGO_CAT_OK); + bongo_cat_settings_defaults(&loaded); + CHECK(bongo_cat_settings_load("bongocat-audio-config-test.json", &loaded, NULL) == BONGO_CAT_OK); + CHECK(loaded.behavior_shortcut_count == 5); + for (size_t i = 0; i < 4; ++i) { + CHECK(!strcmp(loaded.behavior_shortcuts[i].id, ids[i])); + CHECK(!strcmp(loaded.behavior_shortcuts[i].shortcut, "Alt+O")); + } + CHECK(loaded.behavior_shortcuts[4].shortcut_disabled); + CHECK(bongo_cat_file_remove("bongocat-audio-config-test.json")); +} + +void test_config_validation(void) { + check_defaults_and_validation(); + check_behavior_companions(); + check_shortcuts(); + check_override_canonicalization(); +} diff --git a/tests/core/test_config_validation.h b/tests/core/test_config_validation.h new file mode 100644 index 00000000..a71457d0 --- /dev/null +++ b/tests/core/test_config_validation.h @@ -0,0 +1,6 @@ +#ifndef BONGO_CAT_TEST_CONFIG_VALIDATION_H +#define BONGO_CAT_TEST_CONFIG_VALIDATION_H + +void test_config_validation(void); + +#endif diff --git a/tests/core/test_hover_fade.c b/tests/core/test_hover_fade.c new file mode 100644 index 00000000..5ccebb1d --- /dev/null +++ b/tests/core/test_hover_fade.c @@ -0,0 +1,171 @@ +#include "test.h" +#include "runtime.h" +#include "modal_frame.h" + +#include +#include + +int bongo_cat_test_failures; +static unsigned opacity_calls, hit_calls; +static bool fail_opacity, hit = true; + +bool bongo_cat_platform_set_opacity(BongoCatPlatform *platform, float opacity) { + opacity_calls++; + if (fail_opacity) { + fail_opacity = false; + return SDL_SetError("injected opacity failure"); + } + platform->window_opacity = opacity; + return true; +} +float bongo_cat_platform_get_opacity(const BongoCatPlatform *platform) { + return platform->window_opacity; +} +bool bongo_cat_platform_pointer_local(BongoCatPlatform *platform, + double x, double y, float *local_x, float *local_y) { + (void)platform; + *local_x = (float)x; *local_y = (float)y; + return x >= 0 && x < 100 && y >= 0 && y < 100; +} +bool bongo_cat_window_visible_at_pointer(BongoCatApp *app, float x, float y) { + (void)app; (void)x; (void)y; + hit_calls++; + return hit; +} +void bongo_cat_window_schedule_pointer_hit(BongoCatApp *app) { (void)app; } +void bongo_cat_window_sync_click_through(BongoCatApp *app) { (void)app; } +void bongo_cat_window_snapshot_end(BongoCatApp *app) { (void)app; } +void bongo_cat_window_snapshot_update(BongoCatApp *app, uint64_t now) { + (void)app; (void)now; +} +void bongo_cat_app_drain_input(BongoCatApp *app, bool shortcuts) { + (void)app; (void)shortcuts; +} +bool bongo_cat_app_step_live2d(BongoCatApp *app, float elapsed) { + (void)app; (void)elapsed; + return true; +} +void bongo_cat_app_render_now(BongoCatApp *app) { (void)app; } + +static void reset(BongoCatApp *app) { + memset(app, 0, sizeof(*app)); + app->window = app->platform.window = (SDL_Window *)app; + app->platform.window_opacity = 0.8f; + app->session.window.opacity_percent = 80.0f; + app->session.window.visible = true; + app->settings.window.hide_on_hover = true; + app->settings.window.always_on_top = true; + app->settings.window.pass_through = true; + app->settings.window.hide_fade_seconds = 0.3f; + app->pointer_known = true; + app->pointer_x = app->pointer_y = 50; + opacity_calls = hit_calls = 0; + fail_opacity = false; + hit = true; +} + +static void tick(BongoCatApp *app, uint64_t now, bool inside) { + app->pointer_x = inside ? 50 : -10; + bongo_cat_app_update_hover(app, now); + bongo_cat_app_update_hover_fade(app, now); +} + +int main(void) { + BongoCatApp *app = calloc(1, sizeof(*app)); + if (!app) return 1; + const uint64_t start = 1000000000ull; + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + float partial = app->platform.window_opacity; + CHECK(partial > 0.0f && partial < 0.8f); + tick(app, start + 100000000ull, false); + CHECK(app->platform.window_opacity == partial); + tick(app, start + 201000000ull, false); + CHECK(!app->hover_fade_active && app->platform.window_opacity == 0.8f); + + /* Repeated reversal stays bounded and finishes at the user's opacity. */ + reset(app); + uint64_t now = start; + for (int i = 0; i < 100; ++i) { + bool inside = i % 2 == 0; + tick(app, now, inside); + now += 24000000ull; + tick(app, now, inside); + CHECK(app->platform.window_opacity >= 0.0f && + app->platform.window_opacity <= 0.8f); + } + tick(app, now + 1000000000ull, false); + CHECK(!app->hover_hidden && !app->hover_fade_active && + app->platform.window_opacity == 0.8f); + + /* 1000 Hz input must not generate 1000 opacity writes or hit reads. */ + reset(app); + app->settings.window.hide_fade_seconds = 3.0f; + for (uint64_t i = 0; i <= 1000; ++i) + tick(app, start + i * 1000000ull, true); + CHECK(opacity_calls <= 125 && hit_calls <= 126); + CHECK(opacity_calls > 0 && app->hover_fade_active); + partial = app->platform.window_opacity; + app->settings.window.hide_fade_seconds = 1.0f; + tick(app, start + 1008000000ull, true); + CHECK(app->platform.window_opacity < partial && + partial - app->platform.window_opacity < 0.03f); + app->settings.window.hide_fade_seconds = 0.0f; + tick(app, start + 1009000000ull, true); + CHECK(!app->hover_fade_active && app->platform.window_opacity == 0.0f); + tick(app, start + 1010000000ull, false); + CHECK(app->platform.window_opacity == 0.8f); + + reset(app); + for (uint64_t i = 0; i <= 1000; ++i) + tick(app, start + i * 1000000ull, i % 2 == 0); + CHECK(hit_calls <= 126 && opacity_calls <= 125); + + /* A lower base opacity chosen during fade-out is the new restore target. */ + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + app->session.window.opacity_percent = 20.0f; + tick(app, start + 101000000ull, false); + tick(app, start + 1000000000ull, false); + CHECK(SDL_fabsf(app->platform.window_opacity - 0.2f) < 0.00001f && + !app->hover_fade_active); + + /* Disabled capability performs no sampling or opacity work. */ + reset(app); + app->platform.hover_hide_unavailable = true; + tick(app, start, true); + CHECK(!app->hover_hidden && !app->hover_fade_active && + opacity_calls == 0 && hit_calls == 0); + reset(app); + tick(app, start, true); + fail_opacity = true; + tick(app, start + 10000000ull, true); + CHECK(app->platform.hover_hide_unavailable && !app->hover_hidden && + !app->hover_fade_active && app->platform.window_opacity == 0.8f); + unsigned calls = opacity_calls; + tick(app, start + 1000000000ull, true); + CHECK(opacity_calls == calls); + + /* Model alpha changes while the pointer is stationary are still sampled. */ + reset(app); + tick(app, start, true); + tick(app, start + 100000000ull, true); + hit = false; + tick(app, start + 108000000ull, true); + CHECK(!app->hover_hidden); + tick(app, start + 500000000ull, true); + CHECK(app->platform.window_opacity == 0.8f); + + /* The menu/modal loop must advance an existing fade without the app loop. */ + reset(app); + tick(app, SDL_GetTicksNS(), true); + app->settings.window.hide_fade_seconds = 0.0f; + BongoCatModalFrame modal; + bongo_cat_modal_frame_init(&modal, app); + bongo_cat_modal_frame_tick(&modal); + CHECK(app->platform.window_opacity == 0.0f && !app->hover_fade_active); + free(app); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/test_input.c b/tests/core/test_input.c similarity index 65% rename from tests/test_input.c rename to tests/core/test_input.c index 82a64d87..12d2747c 100644 --- a/tests/test_input.c +++ b/tests/core/test_input.c @@ -4,22 +4,38 @@ #include #ifdef _WIN32 -#include "../src/platform/windows_keys.h" +#include "windows_keys.h" + #endif void test_input(void) { #ifdef _WIN32 char key_name[16]; - KBDLLHOOKSTRUCT key = {.vkCode = VK_NUMPAD1}; + RAWKEYBOARD key = {.VKey = VK_NUMPAD1}; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Kp1") == 0); - key.vkCode = VK_END; + key.VKey = VK_END; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Kp1") == 0); - key.flags = LLKHF_EXTENDED; + key.Flags = RI_KEY_E0; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "End") == 0); - key.vkCode = VK_SNAPSHOT; + key.VKey = VK_SNAPSHOT; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "PrintScreen") == 0); - key.vkCode = VK_APPS; + key.VKey = VK_APPS; CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "Apps") == 0); + + key = (RAWKEYBOARD){.VKey = VK_SHIFT, .MakeCode = 0x36}; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ShiftRight") == 0); + key = (RAWKEYBOARD){.VKey = VK_CONTROL, .MakeCode = 0x1d}; + unsigned left_control = bongo_cat_windows_key_index(&key); + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ControlLeft") == 0); + key.Flags = RI_KEY_E0; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "ControlRight") == 0); + CHECK(left_control != bongo_cat_windows_key_index(&key)); + key = (RAWKEYBOARD){.VKey = VK_MENU, .Flags = RI_KEY_E0}; + CHECK(strcmp(bongo_cat_windows_key_name(&key, key_name), "AltGr") == 0); + key.VKey = 255; + CHECK(bongo_cat_windows_key_name(&key, key_name) == NULL); + key = (RAWKEYBOARD){.VKey = 'A', .MakeCode = 0xff}; + CHECK(bongo_cat_windows_key_name(&key, key_name) == NULL); #endif BongoCatInputState state; bongo_cat_input_init(&state); @@ -70,6 +86,13 @@ void test_input(void) { CHECK(bongo_cat_input_take_mouse(&state, &x, &y)); CHECK(x == 10.0 && y == 11.0); + CHECK(!bongo_cat_mouse_sample_fresh(1000000000ull, 0)); + CHECK(bongo_cat_mouse_sample_fresh(1000000000ull, 1000000000ull)); + CHECK(bongo_cat_mouse_sample_fresh(1099999999ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(1100000000ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(31000000000ull, 1000000000ull)); + CHECK(!bongo_cat_mouse_sample_fresh(999999999ull, 1000000000ull)); + BongoCatMouseTracking tracking = {0}; bongo_cat_mouse_target(&tracking, 0.0, 0.0); CHECK(bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); @@ -81,16 +104,30 @@ void test_input(void) { CHECK(bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); CHECK(tracking.settled && x == 100.0 && y == 40.0); CHECK(!bongo_cat_mouse_step(&tracking, 1.0f / 60.0f, &x, &y)); + float drag_x = 0.0f, drag_y = 0.0f; + bongo_cat_mouse_drag_coordinates(0.0f, 0.0f, false, &drag_x, &drag_y); + CHECK(drag_x == -1.0f && drag_y == 1.0f); + bongo_cat_mouse_drag_coordinates(1.0f, 1.0f, false, &drag_x, &drag_y); + CHECK(drag_x == 1.0f && drag_y == -1.0f); + bongo_cat_mouse_drag_coordinates(1.0f, 0.0f, true, &drag_x, &drag_y); + CHECK(drag_x == -1.0f && drag_y == 1.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.5f, 'X', false) == 30.0f); + 0.0f, 0.5f, 'X', false) == -30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 1.0f, 0.5f, 'X', false) == -30.0f); + 1.0f, 0.5f, 'X', false) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.5f, 'X', true) == -30.0f); + 0.0f, 0.5f, 'X', true) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, 0.5f, 0.0f, 'Y', true) == 30.0f); CHECK(bongo_cat_mouse_parameter_value(-30.0f, 30.0f, - 0.0f, 0.0f, 'Z', false) == -30.0f); + 0.0f, 0.0f, 'Z', false) == 30.0f); + CHECK(bongo_cat_mouse_parameter_value(-10.0f, 30.0f, + 0.5f, 0.5f, 'X', false) == 0.0f); + CHECK(bongo_cat_mouse_centered_ratio(80.0, 80.0, 0.0, 100.0) == 0.5f); + CHECK(bongo_cat_mouse_centered_ratio(0.0, 80.0, 0.0, 100.0) == 0.0f); + CHECK(bongo_cat_mouse_centered_ratio(100.0, 80.0, 0.0, 100.0) == 1.0f); + CHECK(bongo_cat_mouse_centered_ratio(40.0, 80.0, 0.0, 100.0) == 0.25f); + CHECK(bongo_cat_mouse_centered_ratio(90.0, 80.0, 0.0, 100.0) == 0.75f); bongo_cat_input_init(&state); event.kind = BONGO_CAT_INPUT_KEY_DOWN; @@ -129,29 +166,14 @@ void test_input(void) { bongo_cat_input_init(&state); event.kind = BONGO_CAT_INPUT_KEY_DOWN; event.timestamp_ms = 100; - memcpy(event.name, "KeyA", sizeof("KeyA")); - bongo_cat_input_auto_release(&state, &event, 3000); - CHECK(!bongo_cat_input_take_release(&state, 3099, &output)); - event.timestamp_ms = 200; - bongo_cat_input_auto_release(&state, &event, 3000); - CHECK(!bongo_cat_input_take_release(&state, 3199, &output)); - CHECK(bongo_cat_input_take_release(&state, 3200, &output)); + memcpy(event.name, "CapsLock", sizeof("CapsLock")); + bongo_cat_input_schedule_release(&state, &event, 100); + CHECK(!bongo_cat_input_take_scheduled_release(&state, 199, &output)); + CHECK(bongo_cat_input_take_scheduled_release(&state, 200, &output)); CHECK(output.kind == BONGO_CAT_INPUT_KEY_UP); - CHECK(strcmp(output.name, "KeyA") == 0); - bongo_cat_input_auto_release(&state, &event, 3000); + CHECK(strcmp(output.name, "CapsLock") == 0); + bongo_cat_input_schedule_release(&state, &event, 100); event.kind = BONGO_CAT_INPUT_KEY_UP; - bongo_cat_input_auto_release(&state, &event, 0); - CHECK(!bongo_cat_input_take_release(&state, 9999, &output)); - - bongo_cat_input_init(&state); - event.kind = BONGO_CAT_INPUT_KEY_DOWN; - event.timestamp_ms = 1000; - memcpy(event.name, "KeyB", sizeof("KeyB")); - bongo_cat_input_auto_release(&state, &event, 50); - CHECK(!bongo_cat_input_take_release(&state, 1049, &output)); - CHECK(bongo_cat_input_take_release(&state, 1050, &output)); - event.timestamp_ms = 2000; - bongo_cat_input_auto_release(&state, &event, 500); - CHECK(!bongo_cat_input_take_release(&state, 2499, &output)); - CHECK(bongo_cat_input_take_release(&state, 2500, &output)); + bongo_cat_input_schedule_release(&state, &event, 100); + CHECK(!bongo_cat_input_take_scheduled_release(&state, 9999, &output)); } diff --git a/tests/core/test_input_concurrent.c b/tests/core/test_input_concurrent.c new file mode 100644 index 00000000..498e83a4 --- /dev/null +++ b/tests/core/test_input_concurrent.c @@ -0,0 +1,74 @@ +#include "bongo_cat/input.h" +#include "test.h" +#include +#include +#include + +#define PRODUCERS 4 +#define EVENTS 20000 +int bongo_cat_test_failures; +static BongoCatInputState input; +static atomic_int finished; +static atomic_uint accepted; + +static int SDLCALL produce(void *userdata) { + unsigned id = *(unsigned *)userdata; + for (unsigned i = 0; i < EVENTS; ++i) { + BongoCatInputEvent event = { + .kind = i % 2 ? BONGO_CAT_INPUT_KEY_UP : BONGO_CAT_INPUT_KEY_DOWN, + .timestamp_ms = i, .x = id, .y = i, .value = (float)id + }; + snprintf(event.name, sizeof(event.name), "producer%u", id); + if (bongo_cat_input_push(&input, &event)) atomic_fetch_add(&accepted, 1); + if (i % 128 == 0) SDL_Delay(1); + } + atomic_fetch_add(&finished, 1); + return 0; +} + +int main(void) { + bongo_cat_input_init(&input); + atomic_init(&finished, 0); + atomic_init(&accepted, 0); + SDL_Thread *threads[PRODUCERS]; + unsigned ids[PRODUCERS], last[PRODUCERS] = {0}; + bool seen[PRODUCERS] = {0}; + for (unsigned i = 0; i < PRODUCERS; ++i) { + ids[i] = i; + threads[i] = SDL_CreateThread(produce, "input-producer", &ids[i]); + CHECK(threads[i] != NULL); + if (!threads[i]) return 1; + } + unsigned received = 0; + uint64_t sequence = 0; + for (;;) { + BongoCatInputEvent event; + bool done = atomic_load(&finished) == PRODUCERS; + if (!bongo_cat_input_pop(&input, &event)) { + if (done) break; + SDL_Delay(1); + continue; + } + unsigned id = (unsigned)event.x; + CHECK(id < PRODUCERS); + if (id >= PRODUCERS) continue; + char expected[32]; + snprintf(expected, sizeof(expected), "producer%u", id); + CHECK(strcmp(expected, event.name) == 0); + CHECK(event.value == (float)id); + CHECK(event.y == (double)event.timestamp_ms); + CHECK(event.kind == (event.timestamp_ms % 2 ? + BONGO_CAT_INPUT_KEY_UP : BONGO_CAT_INPUT_KEY_DOWN)); + CHECK(!seen[id] || event.timestamp_ms > last[id]); + CHECK(!received || event.sequence > sequence); + seen[id] = true; + last[id] = (unsigned)event.timestamp_ms; + sequence = event.sequence; + ++received; + } + for (unsigned i = 0; i < PRODUCERS; ++i) SDL_WaitThread(threads[i], NULL); + CHECK(received == atomic_load(&accepted)); + CHECK(received + atomic_load(&input.dropped) == PRODUCERS * EVENTS); + for (unsigned i = 0; i < PRODUCERS; ++i) CHECK(seen[i]); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/core/test_language.c b/tests/core/test_language.c new file mode 100644 index 00000000..d2215b2e --- /dev/null +++ b/tests/core/test_language.c @@ -0,0 +1,31 @@ +#include "test.h" +#include "bongo_cat/config.h" + +static void check_locale(const char *language, const char *country, + BongoCatLanguage expected) { + BongoCatLanguage actual = BONGO_CAT_LANG_COUNT; + CHECK(bongo_cat_language_from_locale(language, country, &actual)); + CHECK(actual == expected); +} + +void test_language(void) { + check_locale("en", "GB", BONGO_CAT_LANG_EN_US); + check_locale("fr-CA", NULL, BONGO_CAT_LANG_FR_FR); + check_locale("de", "AT", BONGO_CAT_LANG_DE_DE); + check_locale("ja", "JP", BONGO_CAT_LANG_JA_JP); + check_locale("ko", "KR", BONGO_CAT_LANG_KO_KR); + check_locale("pt", "PT", BONGO_CAT_LANG_PT_BR); + check_locale("ru", "RU", BONGO_CAT_LANG_RU_RU); + check_locale("es", "MX", BONGO_CAT_LANG_ES_ES); + check_locale("zh", "CN", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "SG", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "TW", BONGO_CAT_LANG_ZH_HANT); + check_locale("zh", "HK", BONGO_CAT_LANG_ZH_HANT); + check_locale("ZH-hAnT", "CN", BONGO_CAT_LANG_ZH_HANT); + check_locale("zh-Hans", "TW", BONGO_CAT_LANG_ZH_CN); + check_locale("zh", "Hant", BONGO_CAT_LANG_ZH_HANT); + BongoCatLanguage unchanged = BONGO_CAT_LANG_DE_DE; + CHECK(!bongo_cat_language_from_locale("it", "IT", &unchanged)); + CHECK(unchanged == BONGO_CAT_LANG_DE_DE); + CHECK(!bongo_cat_language_from_locale(NULL, NULL, &unchanged)); +} diff --git a/tests/core/test_log_policy.c b/tests/core/test_log_policy.c new file mode 100644 index 00000000..3bdeec05 --- /dev/null +++ b/tests/core/test_log_policy.c @@ -0,0 +1,29 @@ +#include "bongo_cat/log.h" + +#include + +int main(void) { + int failures = 0; + const int categories[] = {SDL_LOG_CATEGORY_APPLICATION, + SDL_LOG_CATEGORY_INPUT, SDL_LOG_CATEGORY_VIDEO, + BONGO_CAT_LOG_LIFECYCLE, BONGO_CAT_LOG_UPDATE, + BONGO_CAT_LOG_INPUT, BONGO_CAT_LOG_INPUT + 1}; + for (unsigned i = 0; i < sizeof(categories) / sizeof(categories[0]); ++i) { + int category = categories[i]; + for (int value = SDL_LOG_PRIORITY_TRACE; + value <= SDL_LOG_PRIORITY_CRITICAL; ++value) { + SDL_LogPriority priority = (SDL_LogPriority)value; + bool expected = priority >= SDL_LOG_PRIORITY_WARN || + (priority == SDL_LOG_PRIORITY_INFO && + (category == BONGO_CAT_LOG_LIFECYCLE || + category == BONGO_CAT_LOG_UPDATE || + category == BONGO_CAT_LOG_INPUT)); + if (bongo_cat_log_enabled(category, priority) != expected) { + fprintf(stderr, "Unexpected log policy: category=%d priority=%d\n", + category, value); + ++failures; + } + } + } + return failures != 0; +} diff --git a/tests/test_main.c b/tests/core/test_main.c similarity index 83% rename from tests/test_main.c rename to tests/core/test_main.c index 14ffdee0..24fd9dbb 100644 --- a/tests/test_main.c +++ b/tests/core/test_main.c @@ -4,10 +4,13 @@ int bongo_cat_test_failures; int main(void) { test_config(); + test_language(); test_input(); test_models(); test_mver_pointer(); test_shortcut(); + test_sound_shortcut(); + test_update(); if (bongo_cat_test_failures) { fprintf(stderr, "%d checks failed\n", bongo_cat_test_failures); return 1; diff --git a/tests/test_models.c b/tests/core/test_models.c similarity index 82% rename from tests/test_models.c rename to tests/core/test_models.c index e7731595..d4f225e5 100644 --- a/tests/test_models.c +++ b/tests/core/test_models.c @@ -4,6 +4,7 @@ #include "bongo_cat/path.h" #include +#include #include typedef struct ParameterSet { @@ -121,28 +122,37 @@ static bool validate_model(const BongoCatModelEntry *model) { } void test_models(void) { - BongoCatModelCatalog catalog; + BongoCatModelCatalog *catalog = calloc(1, sizeof(*catalog)); + BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); BongoCatError error = {0}; - bongo_cat_models_init(&catalog); - CHECK(bongo_cat_models_scan(&catalog, + CHECK(catalog && behaviors); + if (!catalog || !behaviors) { + free(catalog); + free(behaviors); + return; + } + bongo_cat_models_init(catalog); + CHECK(bongo_cat_models_scan(catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", false, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 0); - bongo_cat_models_init(&catalog); - CHECK(bongo_cat_models_scan(&catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", + CHECK(catalog->count == 0); + bongo_cat_models_init(catalog); + CHECK(bongo_cat_models_scan(catalog, BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models", true, &error) == BONGO_CAT_OK); - CHECK(catalog.count == 3); - const BongoCatModelEntry *standard = bongo_cat_models_find(&catalog, "standard"); - const BongoCatModelEntry *keyboard = bongo_cat_models_find(&catalog, "keyboard"); - const BongoCatModelEntry *gamepad = bongo_cat_models_find(&catalog, "gamepad"); + CHECK(catalog->count == 3); + const BongoCatModelEntry *standard = bongo_cat_models_find(catalog, "standard"); + const BongoCatModelEntry *keyboard = bongo_cat_models_find(catalog, "keyboard"); + const BongoCatModelEntry *gamepad = bongo_cat_models_find(catalog, "gamepad"); CHECK(standard && standard->mode == BONGO_CAT_MODE_STANDARD && validate_model(standard)); CHECK(keyboard && keyboard->mode == BONGO_CAT_MODE_KEYBOARD && validate_model(keyboard)); CHECK(gamepad && gamepad->mode == BONGO_CAT_MODE_GAMEPAD && validate_model(gamepad)); - BongoCatBehaviorCatalog behaviors; - CHECK(bongo_cat_behaviors_load(&behaviors, standard, &error) == BONGO_CAT_OK); - CHECK(behaviors.count == 7); - CHECK(behaviors.entries[0].kind == BONGO_CAT_BEHAVIOR_MOTION); - CHECK(strcmp(behaviors.entries[0].group, "CAT_motion") == 0); - CHECK(strstr(behaviors.entries[0].sound, "live2d_motion1.flac") != NULL); - CHECK(behaviors.entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); + CHECK(bongo_cat_behaviors_load(behaviors, standard, &error) == BONGO_CAT_OK); + CHECK(behaviors->count == 7); + CHECK(behaviors->entries[0].kind == BONGO_CAT_BEHAVIOR_MOTION); + CHECK(strcmp(behaviors->entries[0].group, "CAT_motion") == 0); + CHECK(strstr(behaviors->entries[0].sound, "live2d_motion1.flac") != NULL); + CHECK(behaviors->entries[6].kind == BONGO_CAT_BEHAVIOR_EXPRESSION); + + free(behaviors); + free(catalog); } diff --git a/tests/test_mver_pointer.c b/tests/core/test_mver_pointer.c similarity index 76% rename from tests/test_mver_pointer.c rename to tests/core/test_mver_pointer.c index 38f74078..1a62943b 100644 --- a/tests/test_mver_pointer.c +++ b/tests/core/test_mver_pointer.c @@ -69,4 +69,22 @@ void test_mver_pointer(void) { CHECK(bongo_cat_mver_pointer_update(&state, 800.0, 700.0, 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); CHECK(pointer_x == 800.0 && pointer_y == 700.0); + + state = (BongoCatMverPointerState){0}; + bounds = (BongoCatMverPointerBounds){0.0, 0.0, 3440.0, 1440.0}; + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); + for (int index = 0; index < 10; ++index) + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + -1000.0, -1000.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 0.0 && pointer_y == 0.0); + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 5.0, 7.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 5.0 && pointer_y == 7.0); + CHECK(bongo_cat_mver_pointer_update(&state, 1720.0, 720.0, + 0.0, 0.0, true, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 5.0 && pointer_y == 7.0); + CHECK(bongo_cat_mver_pointer_update(&state, 2700.0, 900.0, + 0.0, 0.0, false, &bounds, &pointer_x, &pointer_y)); + CHECK(pointer_x == 2700.0 && pointer_y == 900.0); } diff --git a/tests/test_shortcut.c b/tests/core/test_shortcut.c similarity index 59% rename from tests/test_shortcut.c rename to tests/core/test_shortcut.c index 6a0f9115..44ce49c6 100644 --- a/tests/test_shortcut.c +++ b/tests/core/test_shortcut.c @@ -10,6 +10,43 @@ static BongoCatInputEvent key(BongoCatInputKind kind, const char *name) { } void test_shortcut(void) { + static const struct { const char *stored; const char *shown; } labels[] = { + {"Control+A", "Ctrl+A"}, {"Ctrl+Shift+A", "Ctrl+Shift+A"}, + {"control+Comma", "Ctrl+,"}, {"Alt+PageUp", "Alt+PgUp"}, + {"Control+PageDown", "Ctrl+PgDn"}, {"PrintScreen", "PrtSc"}, + {"Escape", "Esc"}, {"Delete", "Del"}, {"Insert", "Ins"}, + {"Control+BracketLeft", "Ctrl+["}, {"Alt+Backslash", "Alt+\\"}, + {"Control+ArrowLeft", "Ctrl+\xE2\x86\x90"}, + {"ArrowUp", "\xE2\x86\x91"}, {"ArrowDown", "\xE2\x86\x93"}, + {"ArrowRight", "\xE2\x86\x92"}, + {"UpArrow", "\xE2\x86\x91"}, {"DownArrow", "\xE2\x86\x93"}, + {"LeftArrow", "\xE2\x86\x90"}, {"RightArrow", "\xE2\x86\x92"}, + {"Shift+KpPlus", "Shift+Num +"}, + {"F12", "F12"}, {"UnknownKey", "UnknownKey"}, + {"Gamepad:South", "Gamepad:South"}, {"", ""}, {NULL, ""}, +#if defined(_WIN32) + {"Meta+A", "Win+A"}, {"Super+A", "Win+A"}, +#elif defined(__APPLE__) + {"Meta+A", "Cmd+A"}, {"Command+A", "Cmd+A"}, +#else + {"Meta+A", "Super+A"}, +#endif + }; + char shown[BONGO_CAT_SHORTCUT_CAP * 2]; + for (size_t i = 0; i < sizeof(labels) / sizeof(labels[0]); ++i) { + bongo_cat_shortcut_format(labels[i].stored, shown, sizeof(shown)); + CHECK(strcmp(shown, labels[i].shown) == 0); + } + char small[5]; + bongo_cat_shortcut_format("Control+A", small, sizeof(small)); + CHECK(strcmp(small, "Ctrl") == 0); + bongo_cat_shortcut_format("Control+A", small, 1); + CHECK(small[0] == '\0'); + small[0] = 'x'; + bongo_cat_shortcut_format("Control+A", small, 0); + CHECK(small[0] == 'x'); + bongo_cat_shortcut_format("Control+A", NULL, 0); + BongoCatShortcutState state; bongo_cat_shortcut_init(&state); BongoCatInputEvent control = key(BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); diff --git a/tests/core/test_sound_shortcut.c b/tests/core/test_sound_shortcut.c new file mode 100644 index 00000000..510d2543 --- /dev/null +++ b/tests/core/test_sound_shortcut.c @@ -0,0 +1,37 @@ +#include "bongo_cat/sound_shortcut.h" +#include "test.h" + +#include + +static bool edge(BongoCatSoundShortcutState *state, BongoCatInputKind kind, + const char *name) { + BongoCatInputEvent event = {.kind = kind}; + snprintf(event.name, sizeof(event.name), "%s", name); + return bongo_cat_sound_shortcut_update(state, &event); +} + +void test_sound_shortcut(void) { + BongoCatSoundShortcutState state = {0}; + CHECK(!bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyA")); + CHECK(!edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyA")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Control+A")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "ControlRight")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_UP, "ControlLeft")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Ctrl+A")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_DOWN, "KeyB")); + CHECK(bongo_cat_sound_shortcut_down(&state, "A+B+Control")); + CHECK(edge(&state, BONGO_CAT_INPUT_KEY_UP, "KeyA")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "A+B+Control")); + CHECK(edge(&state, BONGO_CAT_INPUT_MOUSE_DOWN, "Left")); + CHECK(bongo_cat_sound_shortcut_down(&state, "Control+Left")); + CHECK(edge(&state, BONGO_CAT_INPUT_MOUSE_UP, "Left")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+Left")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "Control+")); + CHECK(!bongo_cat_sound_shortcut_down(&state, "+Control")); +} diff --git a/tests/core/test_update.c b/tests/core/test_update.c new file mode 100644 index 00000000..67dacca0 --- /dev/null +++ b/tests/core/test_update.c @@ -0,0 +1,72 @@ +#include "test.h" +#include "bongo_cat/update.h" + +#include + +static const char release_json[] = + "{" + "\"tag_name\":\"v1.2.3\"," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/releases/tag/v1.2.3\"," + "\"draft\":false,\"prerelease\":false," + "\"body\":\"Fixed input and rendering.\"," + "\"assets\":[" + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.exe.sha256\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.exe.sha256\"}," + "{\"name\":\"BongoCat-1.2.3-windows-x64-setup.exe\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-setup.exe\"}," + "{\"name\":\"BongoCat-1.2.3-windows-x64-portable.exe\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v1.2.3/BongoCat-1.2.3-windows-x64-portable.exe\"}]}"; + +static const char unix_release_json[] = + "{\"tag_name\":\"v2.0.0\"," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/releases/tag/v2.0.0\"," + "\"draft\":false,\"prerelease\":false,\"assets\":[" + "{\"name\":\"BongoCat-2.0.0-linux-x64.tar.gz\"," + "\"browser_download_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/download/v2.0.0/BongoCat-2.0.0-linux-x64.tar.gz\"}]}"; + +void test_update(void) { + CHECK(bongo_cat_update_version_valid("1.2.3-rc.1+build.4")); + CHECK(!bongo_cat_update_version_valid("1")); + CHECK(!bongo_cat_update_version_valid("1.")); + CHECK(!bongo_cat_update_version_valid("1.2")); + CHECK(bongo_cat_update_compare_versions("1.2.3", "1.2.2") > 0); + CHECK(bongo_cat_update_compare_versions("v2.0.0", "1.99.99") > 0); + CHECK(bongo_cat_update_compare_versions("1.0.0", "1.0.0-rc.1") > 0); + CHECK(bongo_cat_update_compare_versions("1.0.0-beta.2", + "1.0.0-beta.11") < 0); + CHECK(bongo_cat_update_compare_versions("1.0.0+build.2", + "1.0.0+build.1") == 0); + + BongoCatUpdateRelease release; + BongoCatError error = {0}; + CHECK(bongo_cat_update_parse_release(release_json, "windows-x64", + &release, &error)); + CHECK(strcmp(release.version, "1.2.3") == 0); + CHECK(strstr(release.installer_url, "windows-x64-setup.exe") != NULL); + CHECK(strstr(release.portable_url, "windows-x64-portable.exe") != NULL); + CHECK(strcmp(release.notes, "Fixed input and rendering.") == 0); + + CHECK(bongo_cat_update_parse_release(unix_release_json, "linux-x64", + &release, &error)); + CHECK(release.installer_url[0] == '\0'); + CHECK(strstr(release.portable_url, "linux-x64.tar.gz") != NULL); + + const char unsafe[] = + "{\"tag_name\":\"v2.0.0\",\"draft\":false," + "\"prerelease\":false,\"html_url\":\"https://example.com/v2\"," + "\"assets\":[]}"; + CHECK(!bongo_cat_update_parse_release(unsafe, "windows-x64", + &release, &error)); + + const char oversized_tag[] = + "{\"tag_name\":\"v1234567890123456789012345678901234567890\"," + "\"draft\":false,\"prerelease\":false," + "\"html_url\":\"https://github.com/vladelaina/BongoCat/" + "releases/tag/oversized\",\"assets\":[]}"; + CHECK(!bongo_cat_update_parse_release(oversized_tag, "windows-x64", + &release, &error)); +} diff --git a/tests/test_i18n.c b/tests/i18n/test_i18n.c similarity index 52% rename from tests/test_i18n.c rename to tests/i18n/test_i18n.c index 9efc71ca..51121d0d 100644 --- a/tests/test_i18n.c +++ b/tests/i18n/test_i18n.c @@ -1,12 +1,55 @@ #include "bongo_cat/i18n.h" +#include "bongo_cat/path.h" +#include "bongo_cat/utf8.h" +#include #include #include #include +static bool locale_path(char *output, size_t capacity, const char *root, + const char *name) { + char filename[32]; + size_t length = strlen(name); + if (length > sizeof(filename) - sizeof(".json")) return false; + memcpy(filename, name, length); + memcpy(filename + length, ".json", sizeof(".json")); + return bongo_cat_path_join(output, capacity, root, filename); +} + +static bool locale_encoding_valid(const char *root, const char *name) { + char path[BONGO_CAT_PATH_CAP]; + if (!locale_path(path, sizeof(path), root, name)) return false; + FILE *file = fopen(path, "rb"); + if (!file || fseek(file, 0, SEEK_END) != 0) { + if (file) fclose(file); + return false; + } + long end = ftell(file); + if (end < 0 || fseek(file, 0, SEEK_SET) != 0) { + fclose(file); return false; + } + size_t length = (size_t)end; + char *contents = malloc(length + 1); + bool valid = contents && fread(contents, 1, length, file) == length; + fclose(file); + if (!valid) { free(contents); return false; } + contents[length] = '\0'; + if (length >= 3 && (unsigned char)contents[0] == 0xef && + (unsigned char)contents[1] == 0xbb && + (unsigned char)contents[2] == 0xbf) valid = false; + for (size_t i = 0; valid && i + 2 < length; ++i) + if ((unsigned char)contents[i] == 0xef && + (unsigned char)contents[i + 1] == 0xbf && + (unsigned char)contents[i + 2] == 0xbd) valid = false; + valid = valid && bongo_cat_utf8_valid(contents); + free(contents); + return valid; +} + static yyjson_doc *load(const char *root, const char *name) { char path[BONGO_CAT_PATH_CAP]; - snprintf(path, sizeof(path), "%s/%s.json", root, name); + if (!locale_path(path, sizeof(path), root, name)) return NULL; return yyjson_read_file(path, 0, NULL, NULL); } @@ -27,7 +70,17 @@ static bool same_shape(yyjson_val *reference, yyjson_val *candidate, const char *name = yyjson_get_str(key); yyjson_val *next = yyjson_obj_get(candidate, name); char child[BONGO_CAT_PATH_CAP]; - snprintf(child, sizeof(child), "%s%s%s", path, path[0] ? "." : "", name); + size_t path_length = strlen(path), name_length = strlen(name); + size_t separator = path_length ? 1 : 0; + size_t available = path_length < sizeof(child) + ? sizeof(child) - path_length : 0; + if (separator >= available || name_length >= available - separator) { + fprintf(stderr, "Locale key path is too long\n"); + return false; + } + memcpy(child, path, path_length); + if (separator) child[path_length] = '.'; + memcpy(child + path_length + separator, name, name_length + 1); if (!same_shape(value, next, child)) return false; } return true; @@ -75,18 +128,55 @@ static bool covers_value(const uint32_t *ranges, yyjson_val *value) { return true; } +static bool contains_replacement(yyjson_val *value) { + if (yyjson_is_str(value)) { + const unsigned char *text = (const unsigned char *)yyjson_get_str(value); + while (*text) if (next_utf8(&text) == 0xfffd) return true; + } else if (yyjson_is_arr(value)) { + size_t index, count; yyjson_val *item; + yyjson_arr_foreach(value, index, count, item) + if (contains_replacement(item)) return true; + } else if (yyjson_is_obj(value)) { + size_t index, count; yyjson_val *key, *item; + yyjson_obj_foreach(value, index, count, key, item) + if (contains_replacement(item)) return true; + } + return false; +} + int main(void) { char root[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/resources/assets/locales", BONGO_CAT_NATIVE_SOURCE_DIR); + if (!bongo_cat_path_join(root, sizeof(root), BONGO_CAT_NATIVE_SOURCE_DIR, + "resources/assets/locales")) return 1; yyjson_doc *reference = load(root, "zh-CN"); if (!reference) return 1; + const char *required_ui_keys[] = { + "pages.preference.general.hints.autostartFailed", + "pages.preference.model.hints.deleteFailed", + "pages.preference.model.hints.importFailed", + "native.preferencesWindowTitle", + "native.startup.failed", + "native.startup.detail", + "native.startup.diagnosticLog", + "native.support.website", + "native.support.openLogs", + "native.support.starOnGitHub", + "native.support.by"}; const uint32_t expected[] = {'A', 0x4e2d, 0x8a2d, 0x00e7, 0x00fc, 0x65e5, 0xd55c, 0x00ea, 0x0420, 0x00f1}; for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { const char *name = bongo_cat_language_name((BongoCatLanguage)language); + if (!locale_encoding_valid(root, name)) { + fprintf(stderr, "Invalid UTF-8 locale encoding: %s\n", name); + return 8; + } yyjson_doc *document = load(root, name); if (!document || !same_shape(yyjson_doc_get_root(reference), - yyjson_doc_get_root(document), "")) return 2; + yyjson_doc_get_root(document), "") || + contains_replacement(yyjson_doc_get_root(document))) { + fprintf(stderr, "Invalid replacement character in locale: %s\n", name); + return 2; + } BongoCatError error = {0}; BongoCatI18n *i18n = bongo_cat_i18n_create(root, (BongoCatLanguage)language, &error); uint32_t ranges[2048]; @@ -96,6 +186,16 @@ int main(void) { fprintf(stderr, "Missing U+%04X for %s\n", expected[language], name); return 3; } + for (size_t i = 0; i < sizeof(required_ui_keys) / + sizeof(required_ui_keys[0]); ++i) { + const char *missing = "__missing_translation__"; + if (!strcmp(bongo_cat_i18n_get(i18n, required_ui_keys[i], missing), + missing)) { + fprintf(stderr, "Missing required translation %s for %s\n", + required_ui_keys[i], name); + return 7; + } + } bongo_cat_i18n_destroy(i18n); yyjson_doc_free(document); } diff --git a/tests/live2d/test_core_profile.cpp b/tests/live2d/test_core_profile.cpp new file mode 100644 index 00000000..a381bcfb --- /dev/null +++ b/tests/live2d/test_core_profile.cpp @@ -0,0 +1,139 @@ +#include "cubism_core_profile.hpp" +#include "cubism_runtime.hpp" +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + std::fprintf(stderr, "Failed at line %d: %s (%s)\n", __LINE__, \ + #condition, SDL_GetError()); return 1; } } while (0) + +static GLuint shader(GLenum type, const char *source) { + GLuint result = glCreateShader(type); + glShaderSource(result, 1, &source, nullptr); + glCompileShader(result); + return result; +} + +int main() { + CHECK(SDL_Init(SDL_INIT_VIDEO)); +#ifdef __APPLE__ + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1); +#else + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); +#endif + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Core profile test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + CHECK(window); + SDL_GLContext context = SDL_GL_CreateContext(window); + CHECK(context); + glewExperimental = GL_TRUE; + CHECK(glewInit() == GLEW_OK); + while (glGetError() != GL_NO_ERROR) {} + GLuint vertex = shader(GL_VERTEX_SHADER, + "#version 330 core\nlayout(location=3) in vec2 p;" + "layout(location=7) in vec2 uv; out vec2 v;" + "void main(){gl_Position=vec4(p,0,1);v=uv;}"); + GLuint fragment = shader(GL_FRAGMENT_SHADER, + "#version 330 core\nin vec2 v; out vec4 color;" + "void main(){color=vec4(v,0,1);}"); + GLuint program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + CHECK(linked); + glUseProgram(program); + glViewport(0, 0, 32, 32); + const GLfloat triangle[] = {-1,-1, 3,-1, -1,3}; + const GLfloat quad[] = {-1,-1, 1,-1, 1,1, -1,1}; + const GLushort triangle_indices[] = {0,1,2}; + const GLushort quad_indices[] = {0,1,2, 2,3,0}; + const GLfloat triangle_uv[] = {1,0, 1,0, 1,0}; + const GLfloat quad_uv[] = {1,0, 1,0, 1,0, 1,0}; + GLuint host_vao, host_buffers[2]; + glGenVertexArrays(1, &host_vao); + glGenBuffers(2, host_buffers); + glBindVertexArray(host_vao); + glBindBuffer(GL_ARRAY_BUFFER, host_buffers[0]); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, host_buffers[1]); + for (int iteration = 0; iteration < 32; ++iteration) { + bongo_cat::CoreProfileBuffers outer, inner; + GLuint retained_vao = 0, retained_buffer = 0; + for (int frame = 0; frame < 8; ++frame) { + bongo_cat::CoreProfileBinding binding(outer); + if (frame) { + CHECK(outer.vao == retained_vao); + CHECK(outer.buffers[0] == retained_buffer); + } + retained_vao = outer.vao; + retained_buffer = outer.buffers[0]; + glEnableVertexAttribArray(3); + glEnableVertexAttribArray(7); + bongo_cat::CoreProfileBinding::attribute(0, 3, triangle, sizeof(triangle)); + bongo_cat::CoreProfileBinding::attribute(1, 7, triangle_uv, sizeof(triangle_uv)); + bongo_cat::CoreProfileBinding::draw(3, triangle_indices); + { + bongo_cat::CoreProfileBinding nested(inner); + glEnableVertexAttribArray(3); + glEnableVertexAttribArray(7); + bongo_cat::CoreProfileBinding::attribute(0, 3, quad, sizeof(quad)); + bongo_cat::CoreProfileBinding::attribute(1, 7, quad_uv, sizeof(quad_uv)); + glClearColor(0, 0, 0, 0); + glClear(GL_COLOR_BUFFER_BIT); + // Cubism PreDraw clears the element binding before some passes. + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + bongo_cat::CoreProfileBinding::draw(6, quad_indices); + unsigned char pixel[4] = {}; + glReadPixels(16, 16, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(pixel[0] == 255 && pixel[1] == 0 && pixel[2] == 0); + } + GLint current = 0; + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, ¤t); + CHECK((GLuint)current == outer.vao); + bongo_cat::CoreProfileBinding::draw(3, triangle_indices); + unsigned char pixel[4] = {}; + glReadPixels(16, 16, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(pixel[0] == 255 && pixel[1] == 0 && pixel[2] == 0); + } + GLint current = -1; + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, ¤t); + CHECK((GLuint)current == host_vao); + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, ¤t); + CHECK((GLuint)current == host_buffers[0]); + glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, ¤t); + CHECK((GLuint)current == host_buffers[1]); + GLuint vao = outer.vao; + GLuint buffers[] = {outer.buffers[0], outer.buffers[1], outer.buffers[2], + inner.buffers[0], inner.buffers[1], inner.buffers[2]}; + for (GLuint buffer : buffers) CHECK(glIsBuffer(buffer)); + outer.release(); + inner.release(); + CHECK(!glIsVertexArray(vao)); + for (GLuint buffer : buffers) CHECK(!glIsBuffer(buffer)); + outer.release(); + CHECK(outer.vao == 0 && outer.buffers[0] == 0); + CHECK(glGetError() == GL_NO_ERROR); + } + BongoCatLive2D runtime{}; + runtime.retired_count = 1; + runtime.retired[0].frames_remaining = 3; + for (int frame = 0; frame < 3; ++frame) { + CHECK(bongo_cat_live2d_update(&runtime, 1.0f / 60.0f)); + bongo_cat_live2d_draw(&runtime); + } + CHECK(runtime.retired_count == 0); + CHECK(!bongo_cat_live2d_update(&runtime, 1.0f / 60.0f)); + glDeleteVertexArrays(1, &host_vao); + glDeleteBuffers(2, host_buffers); + glDeleteProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return 0; +} diff --git a/tests/live2d/test_motion_state.cpp b/tests/live2d/test_motion_state.cpp new file mode 100644 index 00000000..206064f1 --- /dev/null +++ b/tests/live2d/test_motion_state.cpp @@ -0,0 +1,102 @@ +#include "cubism_model.hpp" +#include "test.h" + +#include + +int bongo_cat_test_failures; + +using MotionState = bongo_cat::NativeModel::MotionState; +using MotionStateCurve = bongo_cat::NativeModel::MotionStateCurve; + +static MotionStateCurve parameter(const char *id, float start, float end, + float normal, int index) { + MotionStateCurve curve; + curve.target = "Parameter"; + curve.id = id; + curve.start = start; + curve.end = end; + curve.normal = normal; + curve.parameter = index; + return curve; +} + +static MotionStateCurve part(const char *id, float start, float end, + float normal, int index) { + MotionStateCurve curve; + curve.target = "PartOpacity"; + curve.id = id; + curve.start = start; + curve.end = end; + curve.normal = normal; + curve.part = index; + return curve; +} + +static MotionState motion(const char *group, int index, + std::initializer_list curves) { + MotionState state; + state.group = group; + state.index = index; + state.curves = curves; + return state; +} + +int main() { + MotionState show = motion("toggle", 6, + {parameter("Visible", 0.0f, 1.0f, 0.0f, 4)}); + MotionState hide = motion("toggle", 9, + {parameter("Visible", 1.0f, 0.0f, 0.0f, 4)}); + bool enabled = false; + CHECK(bongo_cat::motion_toggle_pair(show, hide, &enabled)); + CHECK(enabled); + + MotionState part_show = motion("parts", 1, + {part("Accessory", 0.0f, 1.0f, 0.0f, 3)}); + MotionState part_hide = motion("parts", 8, + {part("Accessory", 1.0f, 0.0f, 0.0f, 3)}); + CHECK(bongo_cat::motion_toggle_pair(part_show, part_hide, &enabled)); + CHECK(enabled); + CHECK(bongo_cat::motion_enables_state(part_show)); + CHECK(!bongo_cat::motion_enables_state(part_hide)); + CHECK(bongo_cat::motion_enables_state(show)); + CHECK(!bongo_cat::motion_enables_state(hide)); + MotionState one_shot = motion("action", 16, + {parameter("Rotation", 360.0f, 45.0f, 0.0f, 4)}); + one_shot.self_contained = true; + CHECK(!bongo_cat::motion_enables_state(one_shot)); + + CHECK(bongo_cat::motion_run_clears_selection(true, true, false)); + CHECK(!bongo_cat::motion_run_clears_selection(false, true, false)); + CHECK(!bongo_cat::motion_run_clears_selection(true, false, false)); + CHECK(!bongo_cat::motion_run_clears_selection(true, true, true)); + + enabled = true; + CHECK(bongo_cat::motion_toggle_pair(hide, show, &enabled)); + CHECK(!enabled); + + MotionState multi_show = motion("other", 3, { + parameter("Left", 0.0f, 1.0f, 0.0f, 1), + parameter("Right", 0.0f, 1.0f, 0.0f, 2)}); + MotionState multi_hide = motion("other", 20, { + parameter("Left", 1.0f, 0.0f, 0.0f, 1), + parameter("Right", 1.0f, 0.0f, 0.0f, 2)}); + CHECK(bongo_cat::motion_toggle_pair(multi_show, multi_hide, &enabled)); + CHECK(enabled); + + MotionState wrong_group = hide; + wrong_group.group = "different"; + CHECK(!bongo_cat::motion_toggle_pair(show, wrong_group, &enabled)); + + MotionState contradictory = motion("other", 21, { + parameter("Left", 1.0f, 0.0f, 0.0f, 1), + parameter("Right", 1.0f, 0.0f, 1.0f, 2)}); + CHECK(!bongo_cat::motion_toggle_pair(multi_show, contradictory, &enabled)); + + MotionState missing_parameter = hide; + missing_parameter.curves[0].parameter = -1; + CHECK(!bongo_cat::motion_toggle_pair(show, missing_parameter, &enabled)); + + if (bongo_cat_test_failures) return 1; + puts("motion state checks passed"); + return 0; +} diff --git a/tests/media/test_audio.c b/tests/media/test_audio.c new file mode 100644 index 00000000..3b6d74a5 --- /dev/null +++ b/tests/media/test_audio.c @@ -0,0 +1,131 @@ +#include "audio_internal.h" +#include "bongo_cat/file.h" +#include "test.h" + +#include +#include +#include +#include + +int bongo_cat_test_failures; + +static void little(FILE *file, unsigned value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) fputc((int)((value >> (i * 8)) & 255), file); +} + +static bool wave(const char *path) { + FILE *file = bongo_cat_file_open(path, "wb"); + if (!file) return false; + unsigned frames = 96000; + fwrite("RIFF", 1, 4, file); little(file, 36 + frames * 2, 4); + fwrite("WAVEfmt ", 1, 8, file); little(file, 16, 4); + little(file, 1, 2); little(file, 1, 2); little(file, 48000, 4); + little(file, 96000, 4); little(file, 2, 2); little(file, 16, 2); + fwrite("data", 1, 4, file); little(file, frames * 2, 4); + for (unsigned i = 0; i < frames; ++i) little(file, (i / 100) % 2 ? 4000 : 61536, 2); + bool ok = !ferror(file); + return fclose(file) == 0 && ok; +} + +static bool offline(BongoCatAudio *audio) { + ma_engine_config config = ma_engine_config_init(); + config.noDevice = MA_TRUE; + config.noAutoStart = MA_TRUE; + config.channels = 2; + config.sampleRate = 48000; + audio->initialized = ma_engine_init(&config, &audio->engine) == MA_SUCCESS; + return audio->initialized; +} + +static size_t count(const BongoCatAudio *audio) { + size_t total = 0; + for (size_t i = 0; i < AUDIO_VOICES; ++i) total += audio->voices[i].ready; + return total; +} + +static bool audible(BongoCatAudio *audio) { + uint64_t deadline = SDL_GetTicks() + 3000; + do { + float samples[512] = {0}; + if (ma_engine_read_pcm_frames(&audio->engine, samples, 256, NULL) != MA_SUCCESS) return false; + for (size_t i = 0; i < 512; ++i) if (fabsf(samples[i]) > 0.001f) return true; + SDL_Delay(1); + } while (SDL_GetTicks() < deadline); + return false; +} + +int main(void) { + char first[BONGO_CAT_PATH_CAP], second[BONGO_CAT_PATH_CAP]; + snprintf(first, sizeof(first), "bongocat-audio-%llu-\xE9\x9F\xB3.wav", + (unsigned long long)SDL_GetTicksNS()); + snprintf(second, sizeof(second), "bongocat-audio-%llu.wav", + (unsigned long long)SDL_GetTicksNS()); + CHECK(wave(first)); CHECK(wave(second)); + BongoCatError error = {0}; + BongoCatAudio *audio = bongo_cat_audio_create(&error); + CHECK(audio != NULL); + if (!audio) return 1; + CHECK(!audio->initialized); + CHECK(!bongo_cat_audio_any_playing(audio)); + CHECK(offline(audio)); + if (!audio->initialized) { bongo_cat_audio_destroy(audio); return 1; } + CHECK(bongo_cat_audio_play_sound(audio, first, false, &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + CHECK(bongo_cat_audio_play_sound(audio, second, false, &error) == BONGO_CAT_OK); + CHECK(count(audio) == 2); + CHECK(bongo_cat_audio_is_playing(audio, first)); + bongo_cat_audio_stop_path(audio, first); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_is_playing(audio, second)); + CHECK(bongo_cat_audio_any_playing(audio)); + CHECK(bongo_cat_audio_play_sound(audio, first, false, &error) == BONGO_CAT_OK); + CHECK(count(audio) == 2); /* Restarting first must not discard second. */ + CHECK(audible(audio)); + for (size_t i = 0; i < AUDIO_VOICES + 10; ++i) + CHECK(bongo_cat_audio_play_sound(audio, first, true, &error) == BONGO_CAT_OK); + CHECK(count(audio) == AUDIO_VOICES); + /* Stop/reset while asynchronous file loads are still pending. */ + bongo_cat_audio_stop(audio); CHECK(count(audio) == 0); + CHECK(!bongo_cat_audio_any_playing(audio)); + CHECK(bongo_cat_audio_play(audio, + BONGO_CAT_NATIVE_SOURCE_DIR "/resources/assets/models/standard/live2d_motion1.flac", + &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + bongo_cat_audio_set_enabled(audio, false); + CHECK(!audio->initialized && count(audio) == 0); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + CHECK(!audio->initialized); + CHECK(!bongo_cat_audio_any_playing(audio)); + bongo_cat_audio_set_enabled(audio, true); + CHECK(offline(audio)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + uint64_t end_deadline = SDL_GetTicks() + 4000; + while (bongo_cat_audio_is_playing(audio, first) && SDL_GetTicks() < end_deadline) { + float samples[2048]; + CHECK(ma_engine_read_pcm_frames(&audio->engine, samples, 1024, NULL) == MA_SUCCESS); + SDL_Delay(1); + } + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(bongo_cat_audio_play(audio, first, &error) == BONGO_CAT_OK); + CHECK(audible(audio)); + for (size_t i = 0; i < AUDIO_VOICES; ++i) + if (audio->voices[i].ready) ma_sound_stop(&audio->voices[i].sound); + bongo_cat_audio_collect(audio, audio->last_play + AUDIO_IDLE_MS + 1); + CHECK(!audio->initialized && count(audio) == 0); + CHECK(!bongo_cat_audio_is_playing(audio, first)); + CHECK(offline(audio)); + BongoCatResult invalid = bongo_cat_audio_play(audio, "missing-audio-test-file.wav", &error); + CHECK(invalid == BONGO_CAT_OK || invalid == BONGO_CAT_ERROR_IO); + uint64_t deadline = SDL_GetTicks() + 3000; + while (count(audio) && SDL_GetTicks() < deadline) { + audio->next_collect = 0; + bongo_cat_audio_collect(audio, SDL_GetTicks()); + SDL_Delay(1); + } + CHECK(count(audio) == 0); + bongo_cat_audio_reset(audio); bongo_cat_audio_reset(audio); + bongo_cat_audio_destroy(audio); + CHECK(SDL_RemovePath(first)); CHECK(SDL_RemovePath(second)); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/media/test_image_filter.c b/tests/media/test_image_filter.c new file mode 100644 index 00000000..5a1dce66 --- /dev/null +++ b/tests/media/test_image_filter.c @@ -0,0 +1,163 @@ +#include "image_internal.h" +#include "bongo_cat/gl_api.h" +#include "test.h" +#include +#include +#include +#include + +int bongo_cat_test_failures; +static bool straight_alpha; + +static void check_sampling(GLuint texture) { + BongoCatGL gl; + BongoCatError error = {0}; + CHECK(bongo_cat_gl_load(&gl, &error)); + const char *vertex = "#version 330 core\n" + "void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);" + "gl_Position=vec4(p*2.0-1.0,0,1);}"; + const char *fragment = "#version 330 core\n" + "uniform sampler2D atlas; uniform int straightAlpha; out vec4 result;" + "void main(){vec4 c=textureLod(atlas,vec2(0.25,0.5),0.0);" + "if(straightAlpha!=0)c.rgb*=c.a;" + "result=vec4(c.rgb+vec3(1.0-c.a),1.0);}"; + GLuint program = bongo_cat_gl_program(&gl, vertex, fragment, &error), vao = 0; + CHECK(program != 0); + if (!program) return; + gl.gen_vertex_arrays(1, &vao); + gl.bind_vertex_array(vao); + gl.use_program(program); + gl.uniform_1i(gl.uniform_location(program, "straightAlpha"), straight_alpha); + gl.active_texture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glViewport(0, 0, 1, 1); + glDisable(GL_BLEND); + glDisable(GL_DITHER); + glDrawArrays(GL_TRIANGLES, 0, 3); + unsigned char pixel[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + printf(" filtered white edge on white: %u %u %u (expected 255 255 255)\n", + pixel[0], pixel[1], pixel[2]); + CHECK(pixel[0] >= 254 && pixel[1] >= 254 && pixel[2] >= 254); + CHECK(glGetError() == GL_NO_ERROR); + gl.use_program(0); + gl.bind_vertex_array(0); + gl.delete_vertex_arrays(1, &vao); + gl.delete_program(program); +} + +static void fixture(const char *path, int width, int height, int kind) { + unsigned char *pixels = malloc((size_t)width * height * 4); + CHECK(pixels != NULL); + if (!pixels) return; + for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { + unsigned char *p = pixels + ((size_t)y * width + x) * 4; + bool visible = kind == 3 ? (x % 2) == 0 : (x % 4) < 2; + p[0] = p[1] = p[2] = visible ? 255 : 0; + p[3] = visible ? (kind == 2 ? 64 : 255) : 0; + if (!visible && (kind == 1 || kind == 3)) p[0] = 255; + } + CHECK(stbi_write_png(path, width, height, 4, pixels, width * 4)); + free(pixels); +} + +static void check_levels(GLuint texture, int width, int height) { + glBindTexture(GL_TEXTURE_2D, texture); + GLint filter = 0; + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, &filter); + CHECK(filter == GL_LINEAR_MIPMAP_LINEAR); + int level = 0; + do { + unsigned char *pixels = malloc((size_t)width * height * 4); + CHECK(pixels != NULL); + if (!pixels) return; + GLint actual_width = 0, actual_height = 0, format = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_WIDTH, &actual_width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_HEIGHT, &actual_height); + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_INTERNAL_FORMAT, &format); + CHECK(actual_width == width && actual_height == height && format == GL_RGBA8); + glGetTexImage(GL_TEXTURE_2D, level, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(glGetError() == GL_NO_ERROR); + int max_error = 0; + for (int i = 0; i < width * height; ++i) { + const unsigned char *p = pixels + i * 4; + /* White at any coverage must remain white over a white desktop, + * and contribute only its coverage over a black desktop. */ + for (int c = 0; c < 3; ++c) + max_error = SDL_max(max_error, abs((int)p[c] - p[3])); + } + printf(" mip %d (%dx%d): maximum white-composite error = %d/255\n", + level, width, height, max_error); + CHECK(max_error <= 1); + free(pixels); + if (width == 1 && height == 1) break; + width = SDL_max(1, width / 2); + height = SDL_max(1, height / 2); + ++level; + } while (true); +} + +static void model_case(const char *path, int source_width, int kind, bool direct) { + fixture(path, source_width, 8, kind); + BongoCatError error = {0}; + int width = 0, height = 0; + BongoCatImageAlphaMask alpha; + GLuint texture = bongo_cat_image_texture_model(path, direct, + &width, &height, &alpha, NULL, NULL, &error); + printf("case width=%d kind=%d direct=%d: %s\n", source_width, kind, + direct, texture ? "loaded" : error.message); + CHECK(texture != 0); + if (texture) { +#ifdef _WIN32 + CHECK(width == SDL_min(source_width, 2048)); + CHECK(height == (source_width > 2048 ? 4 : 8)); +#endif + CHECK(alpha.width > 0 && alpha.height > 0); + if (!straight_alpha) check_levels(texture, width, height); + if (source_width == 8) check_sampling(texture); + glDeleteTextures(1, &texture); + } + /* Ordinary image users retain straight-alpha pixels. */ + BongoCatImage original; + CHECK(bongo_cat_image_load(path, &original, &error) == BONGO_CAT_OK); + if (original.pixels) { + CHECK(original.width == source_width); + CHECK(original.pixels[0] == 255); + CHECK(original.pixels[3] == (kind == 2 ? 64 : 255)); + bongo_cat_image_free(&original); + } +} + +int main(int argc, char **argv) { + straight_alpha = argc > 1 && SDL_strcmp(argv[1], "--straight-alpha") == 0; + CHECK(SDL_Init(SDL_INIT_VIDEO)); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Image filter test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + CHECK(window != NULL); + SDL_GLContext context = window ? SDL_GL_CreateContext(window) : NULL; + CHECK(context != NULL); + if (!context) { SDL_Quit(); return 1; } + char *path = NULL; + SDL_asprintf(&path, "%sbongocat-filter-%llu.png", SDL_GetBasePath(), + (unsigned long long)SDL_GetTicksNS()); + CHECK(path != NULL); + if (path) { + for (int kind = 0; kind < 4; ++kind) { + model_case(path, 8, kind, true); + model_case(path, 8, kind, false); +#ifdef _WIN32 + model_case(path, 4096, kind, false); + model_case(path, 4096, kind, true); +#endif + } + CHECK(SDL_RemovePath(path)); + SDL_free(path); + } + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/media/test_overlay_layout.c b/tests/media/test_overlay_layout.c new file mode 100644 index 00000000..162f6b7a --- /dev/null +++ b/tests/media/test_overlay_layout.c @@ -0,0 +1,84 @@ +#include "bongo_cat/overlay.h" +#include "bongo_cat/path.h" +#include "test.h" +#include +#include +#include +#include +#include + +int bongo_cat_test_failures; + +static void check_pixel(bool filled) { + unsigned char pixel[4] = {0}; + glReadPixels(28, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + CHECK(filled ? pixel[0] > 250 : pixel[0] == 0); + CHECK(glGetError() == GL_NO_ERROR); +} + +static void layout_case(BongoCatOverlay *overlay, const char *root, + const char *background, int width, int height, int reference_width, + int reference_height, bool mver, bool filled) { + size_t bytes = (size_t)width * height * 4; + unsigned char *pixels = malloc(bytes); + CHECK(pixels != NULL); + if (!pixels) return; + memset(pixels, 255, bytes); + CHECK(stbi_write_png(background, width, height, 4, pixels, width * 4)); + free(pixels); + BongoCatLive2DRenderOptions options = {0}; + options.mver_projection = mver; + options.reference_width = reference_width; + options.reference_height = reference_height; + BongoCatError error = {0}; + CHECK(bongo_cat_overlay_load(overlay, root, true, &options, &error) == BONGO_CAT_OK); + glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_overlay_draw_background(overlay, false); + check_pixel(filled); + /* Effects must use the same logical canvas as the background. */ + CHECK(bongo_cat_overlay_effect(overlay, background)); + glClear(GL_COLOR_BUFFER_BIT); + bongo_cat_overlay_draw_effect(overlay, false); + check_pixel(filled); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_VIDEO)); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_Window *window = SDL_CreateWindow("Overlay layout test", 32, 32, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + SDL_GLContext context = window ? SDL_GL_CreateContext(window) : NULL; + CHECK(context != NULL); + if (!context) { SDL_Quit(); return 1; } + char root[BONGO_CAT_PATH_CAP], resources[BONGO_CAT_PATH_CAP]; + char background[BONGO_CAT_PATH_CAP]; + SDL_snprintf(root, sizeof(root), "%soverlay-layout-%llu", SDL_GetBasePath(), + (unsigned long long)SDL_GetTicksNS()); + CHECK(bongo_cat_path_join(resources, sizeof(resources), root, "resources")); + CHECK(bongo_cat_path_join(background, sizeof(background), resources, "background.png")); + CHECK(SDL_CreateDirectory(resources)); + BongoCatError error = {0}; + BongoCatOverlay *overlay = bongo_cat_overlay_create(&error); + CHECK(overlay != NULL); + glViewport(0, 0, 32, 32); + glClearColor(0, 0, 0, 0); + if (overlay) { + layout_case(overlay, root, background, 612, 354, 1224, 708, true, true); + layout_case(overlay, root, background, 612, 352, 1836, 1056, true, true); + layout_case(overlay, root, background, 612, 354, 612, 354, true, true); + layout_case(overlay, root, background, 612, 354, 1400, 1400, true, false); + layout_case(overlay, root, background, 306, 177, 612, 354, true, false); + layout_case(overlay, root, background, 612, 354, 1224, 354, true, false); + layout_case(overlay, root, background, 612, 354, 1224, 708, false, true); + bongo_cat_overlay_destroy(overlay); + } + CHECK(SDL_RemovePath(background)); + CHECK(SDL_RemovePath(resources)); + CHECK(SDL_RemovePath(root)); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/model_import/test_model_import_archive.c b/tests/model_import/test_model_import_archive.c new file mode 100644 index 00000000..8e165524 --- /dev/null +++ b/tests/model_import/test_model_import_archive.c @@ -0,0 +1,115 @@ +#include "model_import_archive.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include + +static bool save_zip(mz_zip_archive *zip, const char *path) { + void *data = NULL; + size_t size = 0; + bool ok = mz_zip_writer_finalize_heap_archive(zip, &data, &size) != 0; + mz_zip_writer_end(zip); + if (ok) ok = SDL_SaveFile(path, data, size); + mz_free(data); + return ok; +} + +typedef struct ZipTree { + mz_zip_archive *zip; + const char *root; + const char *prefix; +} ZipTree; + +static BongoCatPathVisit zip_item(void *userdata, const char *directory, + const char *name) { + ZipTree *tree = userdata; + char path[BONGO_CAT_PATH_CAP], relative[BONGO_CAT_PATH_CAP]; + if (!bongo_cat_path_join(path, sizeof(path), directory, name)) + return BONGO_CAT_PATH_FAILURE; + if (bongo_cat_path_is_dir(path)) + return bongo_cat_path_enumerate(path, zip_item, tree) + ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; + int written = snprintf(relative, sizeof(relative), "%s%s", tree->prefix, + path + strlen(tree->root) + 1); + if (written < 0 || (size_t)written >= sizeof(relative)) + return BONGO_CAT_PATH_FAILURE; + size_t size = 0; + void *data = SDL_LoadFile(path, &size); + bool ok = data && mz_zip_writer_add_mem(tree->zip, relative, data, + size, MZ_BEST_SPEED); + SDL_free(data); + return ok ? BONGO_CAT_PATH_CONTINUE : BONGO_CAT_PATH_FAILURE; +} + +void test_model_import_archive(void) { + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char archive[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP], temporary[BONGO_CAT_PATH_CAP]; + char *cwd = SDL_GetCurrentDirectory(); + CHECK(cwd != NULL); + if (!cwd) return; + snprintf(root, sizeof(root), "%s/archive-test-%llu", cwd, + (unsigned long long)SDL_GetTicksNS()); + SDL_free(cwd); + CHECK(child(package, sizeof(package), root, "fixture", true)); + CHECK(mver_fixture(package)); + CHECK(child(models, sizeof(models), root, "installed", true)); + CHECK(child(archive, sizeof(archive), root, "example.ZIP", false)); + CHECK(bongo_cat_import_is_archive(archive)); + CHECK(!bongo_cat_import_is_archive(package)); + BongoCatError error = {0}; + for (int nested = 0; nested < 2; ++nested) { + mz_zip_archive zip = {0}; + CHECK(mz_zip_writer_init_heap(&zip, 0, 0)); + ZipTree tree = {&zip, package, nested ? "outer/model/" : ""}; + CHECK(bongo_cat_path_enumerate(package, zip_item, &tree)); + CHECK(save_zip(&zip, archive)); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_OK); + CHECK(strcmp(bongo_cat_path_name(directory), "example") == 0); + bongo_cat_import_archive_cleanup(temporary); + CHECK(!bongo_cat_path_is_dir(temporary)); + BongoCatImportSession *session = bongo_cat_import_session_create(models, &error); + CHECK(session != NULL); + BongoCatImportBatchStats stats; + CHECK(bongo_cat_import_session_install_progressive(session, archive, + NULL, NULL, &stats, &error) == BONGO_CAT_OK); + CHECK(stats.succeeded_count > 0 && stats.failed_count == 0); + BongoCatImportReceipt receipt; + CHECK(bongo_cat_import_session_install(session, archive, &receipt, + &error) == BONGO_CAT_OK); + CHECK(receipt.count > 0 && receipt.installed_count == 0); + bongo_cat_import_session_destroy(session); + } + const char *unsafe[] = {"../escape", "safe/../../escape", "\\absolute", + "C:/escape", "safe\\..\\escape", "file:stream", "NUL.txt", "dir./file"}; + for (size_t i = 0; i < sizeof(unsafe) / sizeof(unsafe[0]); ++i) { + mz_zip_archive zip = {0}; + CHECK(mz_zip_writer_init_heap(&zip, 0, 0)); + CHECK(mz_zip_writer_add_mem(&zip, unsafe[i], "x", 1, 0)); + CHECK(save_zip(&zip, archive)); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(!directory[0] && !temporary[0]); + } + CHECK(write_text(archive, "not a zip")); + CHECK(bongo_cat_import_archive_extract(archive, directory, temporary, + &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(!directory[0] && !temporary[0]); + mz_zip_archive empty_model = {0}; + CHECK(mz_zip_writer_init_heap(&empty_model, 0, 0)); + CHECK(mz_zip_writer_add_mem(&empty_model, "readme.txt", "text", 4, 0)); + CHECK(save_zip(&empty_model, archive)); + BongoCatImportSession *session = bongo_cat_import_session_create(models, &error); + CHECK(session != NULL); + BongoCatImportBatchStats stats; + CHECK(bongo_cat_import_session_install_progressive(session, archive, + NULL, NULL, &stats, &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(stats.succeeded_count == 0 && stats.failed_count == 1); + bongo_cat_import_session_destroy(session); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} diff --git a/tests/model_import/test_model_import_identity.c b/tests/model_import/test_model_import_identity.c new file mode 100644 index 00000000..c596619e --- /dev/null +++ b/tests/model_import/test_model_import_identity.c @@ -0,0 +1,205 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_import_mver.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static void disabled_mver_live2d_is_not_imported(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-disabled-mver-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(config, sizeof(config), root, "config.json", false)); + CHECK(write_text(config, "{\"standard\":{\"l2d\":false," + "\"keyboard\":[[65]],\"hand\":[[65]]}}")); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, &discovery, &error) < 0); + CHECK(discovery.count == 0); + CHECK(strstr(error.message, "no supported model modes") != NULL); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + +static bool ends_with(const char *value, const char *suffix) { + size_t value_length = strlen(value), suffix_length = strlen(suffix); + return value_length >= suffix_length && + strcmp(value + value_length - suffix_length, suffix) == 0; +} + +static void package_identity_rules(const BongoCatImportDiscovery *fixture) { + static const char unicode_name[] = + "\xE9\x9C\xB2\xE8\xA5\xBF\xE4\xBA\x9A-" + "\xE8\xAA\x93\xE7\x84\xB0\xE7\x89\x88"; + BongoCatImportDiscovery discovery = *fixture; + BongoCatPackageMetadata metadata[BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + BongoCatError error = {0}; + + snprintf(discovery.source_name, sizeof(discovery.source_name), "%s", + unicode_name); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 1 && + strcmp(metadata[0].package_id, unicode_name) == 0); + + discovery = *fixture; + memset(metadata, 0, sizeof(metadata)); + snprintf(discovery.source_name, sizeof(discovery.source_name), + "bad:name. "); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(strcmp(metadata[0].package_id, "bad-name") == 0 && + strcmp(metadata[0].display_name, "bad:name. ") == 0); + + discovery = *fixture; + memset(metadata, 0, sizeof(metadata)); + snprintf(discovery.source_name, sizeof(discovery.source_name), "CON.txt"); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(strcmp(metadata[0].package_id, "_CON.txt") == 0); + + discovery = *fixture; + discovery.count = 2; + discovery.candidates[1] = discovery.candidates[0]; + discovery.candidates[1].mode = BONGO_CAT_MODE_GAMEPAD; + discovery.candidates[1].gamepad_buttons = true; + memset(discovery.source_name, 'x', sizeof(discovery.source_name) - 1); + discovery.source_name[sizeof(discovery.source_name) - 1] = '\0'; + memset(metadata, 0, sizeof(metadata)); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 2 && + strlen(metadata[0].package_id) < BONGO_CAT_ID_CAP && + strlen(metadata[1].package_id) < BONGO_CAT_ID_CAP && + ends_with(metadata[1].package_id, "~2")); + + discovery = *fixture; + discovery.count = 2; + discovery.candidates[1] = discovery.candidates[0]; + snprintf(discovery.source_name, sizeof(discovery.source_name), "duplicate"); + memset(metadata, 0, sizeof(metadata)); + CHECK(bongo_cat_import_prepare_package_metadata(&discovery, metadata, + &error)); + CHECK(discovery.count == 1 && strcmp(metadata[0].package_id, + "duplicate") == 0); +} + +int test_model_import_identity(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + disabled_mver_live2d_is_not_imported(temporary); + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + char manifest[BONGO_CAT_PATH_CAP], missing[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongocat-import-identity-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-import-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), data, "models", true)); + CHECK(child(source, sizeof(source), root, "source", false)); + CHECK(mver_fixture(source)); + CHECK(child(config, sizeof(config), source, "config.json", false)); + CHECK(child(manifest, sizeof(manifest), source, + "img/standard/cat_model/cat.model3.json", false)); + CHECK(child(missing, sizeof(missing), root, "missing", false)); + BongoCatError error = {0}; + BongoCatImportDiscovery discovered = {0}; + CHECK(bongo_cat_import_mver_discover(source, &discovered, &error) == 1); + char digest[65]; + bool placeholder = true; + CHECK(bongo_cat_import_candidate_inspect(&discovered.candidates[0], + digest, &placeholder, &error) && !placeholder); + package_identity_rules(&discovered); + BongoCatImportReceipt first = {0}, repeated = {0}, wrapped = {0}; + BongoCatImportReceipt changed = {0}; + BongoCatImportSession *session = bongo_cat_import_session_create(models_root, + &error); + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install(session, config, &first, + &error) == BONGO_CAT_OK); + CHECK(first.count == 1 && first.installed_count == 1 && first.installed[0]); + CHECK(strcmp(first.ids[0], "source") == 0); + CHECK(strncmp(first.ids[0], "model-", 6) != 0); + CHECK(session && bongo_cat_import_session_install(session, missing, NULL, + &error) != BONGO_CAT_OK); + error = (BongoCatError){0}; + CHECK(session && bongo_cat_import_session_install(session, manifest, + &repeated, &error) == BONGO_CAT_OK); + CHECK(repeated.count == 1 && repeated.installed_count == 0 && + !repeated.installed[0] && strcmp(first.ids[0], repeated.ids[0]) == 0); + bongo_cat_import_session_destroy(session); + CHECK(bongo_cat_import_install(manifest, models_root, &wrapped, &error) == + BONGO_CAT_OK); + CHECK(wrapped.count == 1 && wrapped.installed_count == 0 && + strcmp(first.ids[0], wrapped.ids[0]) == 0); + char extra[BONGO_CAT_PATH_CAP]; + CHECK(child(extra, sizeof(extra), source, + "img/standard/cat_model/changed.txt", false)); + CHECK(write_text(extra, "changed")); + CHECK(bongo_cat_import_install(source, models_root, &changed, &error) == + BONGO_CAT_OK); + CHECK(changed.count == 1 && changed.installed_count == 1 && + changed.installed[0] && strcmp(changed.ids[0], "source-2") == 0); + char adapter_metadata[BONGO_CAT_PATH_CAP]; + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + CHECK(child(app->cache_root, sizeof(app->cache_root), data, "cache", + true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + const BongoCatModelEntry *original = bongo_cat_models_find(&app->models, + "source"); + const BongoCatModelEntry *modified = bongo_cat_models_find(&app->models, + "source-2"); + CHECK(app->models.count == 2 && original && modified && + original->source_format == BONGO_CAT_MODEL_SOURCE_MVER && + original->content_digest[0] != '\0' && + strcmp(original->content_digest, modified->content_digest) != 0 && + (original->capabilities & + BONGO_CAT_MODEL_CAPABILITY_MVER_PROJECTION)); + if (original) { + CHECK(child(adapter_metadata, sizeof(adapter_metadata), + original->adapter_directory, + ".bongo-cat-adapter.json", false)); + CHECK(bongo_cat_path_is_file(adapter_metadata)); + char imported_config[BONGO_CAT_PATH_CAP]; + CHECK(child(imported_config, sizeof(imported_config), + original->storage_directory, "config.json", false)); + CHECK(bongo_cat_path_is_file(imported_config)); + char internal[BONGO_CAT_PATH_CAP]; + CHECK(child(internal, sizeof(internal), + original->storage_directory, ".bongo-cat-adapter", false)); + CHECK(!bongo_cat_path_is_dir(internal)); + CHECK(child(internal, sizeof(internal), + original->storage_directory, ".bongo-cat-package.json", + false)); + CHECK(!bongo_cat_path_is_file(internal)); + } + free(app); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/model_import/test_model_import_source.c b/tests/model_import/test_model_import_source.c new file mode 100644 index 00000000..b7484587 --- /dev/null +++ b/tests/model_import/test_model_import_source.c @@ -0,0 +1,86 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +void test_model_import_source(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char moc[BONGO_CAT_PATH_CAP], config[BONGO_CAT_PATH_CAP]; + char directory[BONGO_CAT_PATH_CAP], normalized[BONGO_CAT_PATH_CAP]; + BongoCatError error = {0}; + snprintf(root, sizeof(root), "%s/bongocat-source-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "mver", true)); + CHECK(mver_fixture(package)); + CHECK(child(moc, sizeof(moc), package, + "img/standard/cat_model/cat.moc3", false)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(bongo_cat_import_source_directory(moc, normalized, 2, + &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(bongo_cat_import_source_directory(NULL, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(bongo_cat_import_source_directory(moc, NULL, 0, + &error) == BONGO_CAT_ERROR_ARGUMENT); + CHECK(child(config, sizeof(config), package, "config.json", false)); + CHECK(child(directory, sizeof(directory), package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK(SDL_RenamePath(config, directory)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(write_text(directory, "invalid json")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(strstr(error.message, "Mver configuration") != NULL); + + CHECK(child(package, sizeof(package), root, "tauri", true)); + CHECK(child(directory, sizeof(directory), package, "nested", true)); + CHECK(child(moc, sizeof(moc), directory, "CAT.MOC3", false)); + CHECK(write_text(moc, "MOC3")); + CHECK(child(config, sizeof(config), package, "cat.model3.json", false)); + CHECK(write_text(config, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"nested/CAT.MOC3\",\"Textures\":[\"texture.png\"]}}")); + CHECK(child(directory, sizeof(directory), package, "texture.png", false)); + char texture[BONGO_CAT_PATH_CAP]; + snprintf(texture, sizeof(texture), "%s/resources/assets/bongocat.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + CHECK(SDL_CopyFile(texture, directory)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0 && error.code == BONGO_CAT_OK); + CHECK(bongo_cat_import_source_directory(config, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, package) == 0); + CHECK(bongo_cat_path_remove(directory)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_path_remove(moc)); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_ARGUMENT); + /* An orphan must not discover models in sibling directories. */ + CHECK(child(moc, sizeof(moc), root, "orphan.moc3", false)); + CHECK(write_text(moc, "MOC3")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(child(moc, sizeof(moc), root, "unsupported.txt", false)); + CHECK(write_text(moc, "text")); + CHECK(bongo_cat_import_source_directory(moc, normalized, + sizeof(normalized), &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_import_source_directory(root, normalized, + sizeof(normalized), &error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, root) == 0); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_mver_audio.c b/tests/model_import/test_mver_audio.c new file mode 100644 index 00000000..4cf468e4 --- /dev/null +++ b/tests/model_import/test_mver_audio.c @@ -0,0 +1,102 @@ +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "runtime.h" +#include "window_menu.h" +#include "test_mver_support.h" +#include "test_mver_import_internal.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static void sound_chord(const char *text, const char *expected) { + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + char value[BONGO_CAT_SHORTCUT_CAP]; + bool ok = bongo_cat_mver_sound_chord(yyjson_doc_get_root(doc), value, sizeof(value)); + CHECK(expected ? ok && strcmp(value, expected) == 0 : !ok); + yyjson_doc_free(doc); +} + +void test_mver_audio(void) { + sound_chord("[90]", "Z"); + sound_chord("[17,65,66]", "Control+A+B"); + sound_chord("[17]", "Control"); + sound_chord("[17,1]", "Control+Left"); + sound_chord("[]", NULL); + sound_chord("[256]", NULL); + char root[BONGO_CAT_PATH_CAP], adapter[BONGO_CAT_PATH_CAP]; + char sound_dir[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "bongocat-sound-import-%llu", (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + BongoCatImportCandidate candidate = {.format = BONGO_CAT_IMPORT_MVER, + .mode = BONGO_CAT_MODE_STANDARD}; + CHECK(child(candidate.directory, sizeof(candidate.directory), root, "img/standard/cat_model", false)); + snprintf(candidate.setting, sizeof(candidate.setting), "cat.model3.json"); + CHECK(child(candidate.assets, sizeof(candidate.assets), root, "img/standard", false)); + CHECK(child(candidate.config, sizeof(candidate.config), root, "config.json", false)); + CHECK(child(adapter, sizeof(adapter), root, "adapter", true)); + CHECK(child(sound_dir, sizeof(sound_dir), candidate.assets, "sounds", true)); + CHECK(child(path, sizeof(path), sound_dir, "0.flac", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/models/standard/live2d_motion1.flac", path)); + BongoCatModelEntry *model = calloc(1, sizeof(*model)); + BongoCatBehaviorCatalog *catalog = calloc(1, sizeof(*catalog)); + CHECK(model && catalog); + if (!model || !catalog) { free(model); free(catalog); return; } + snprintf(model->id, sizeof(model->id), "test-audio"); + snprintf(model->directory, sizeof(model->directory), "%s", candidate.directory); + snprintf(model->setting_file, sizeof(model->setting_file), "%s", candidate.setting); + snprintf(model->adapter_directory, sizeof(model->adapter_directory), "%s", adapter); + for (int keep = 0; keep <= 1; ++keep) { + char config[256]; + snprintf(config, sizeof(config), "{\"decoration\":{\"soundKeep\":%s," + "\"soundClear\":[17,222]},\"standard\":{\"sounds\":[[90],[88]]}}", + keep ? "true" : "false"); + CHECK(write_text(candidate.config, config)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_adapter_metadata(&candidate, adapter, &error)); + CHECK(bongo_cat_behaviors_load(catalog, model, &error) == BONGO_CAT_OK); + CHECK(catalog->count == 2); /* Missing 1.flac skipped; clear always imported. */ + CHECK(catalog->entries[0].sound[0] && !catalog->entries[0].momentary); + CHECK(catalog->entries[0].sound_overlap == (keep != 0)); + CHECK(!catalog->entries[0].sound_clear && catalog->entries[1].sound_clear); + } + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + bongo_cat_import_apply_metadata(app, "test-audio", adapter); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(!strcmp(app->settings.behavior_shortcuts[0].id, "test-audio:sound:0")); + CHECK(!strcmp(app->settings.behavior_shortcuts[0].shortcut, "Z")); + app->settings.behavior_shortcuts[0].shortcut[0] = '\0'; + bongo_cat_import_apply_metadata(app, "test-audio", adapter); + CHECK(!app->settings.behavior_shortcuts[0].shortcut[0]); + char names[BONGO_CAT_BEHAVIOR_CAP][BONGO_CAT_MENU_LABEL_CAP]; + bool checked[BONGO_CAT_BEHAVIOR_CAP] = {false}; + size_t count = 99; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 0); + app->behaviors = *catalog; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 2 && !checked[0] && !checked[1]); + app->behaviors.entries[0].sound[0] = '\0'; + bongo_cat_window_audio_labels(app, names, checked, &count); + CHECK(count == 0); /* A clear command alone does not expose Audio. */ + free(app); + } + CHECK(child(path, sizeof(path), adapter, BONGO_CAT_MODEL_ADAPTER_FILE, false)); + CHECK(write_text(path, "{\"bindings\":[" + "{\"kind\":\"sound\",\"sound\":\"resources/sounds/0.flac\",\"overlap\":false}," + "{\"kind\":\"sound\",\"sound\":\"missing.flac\"}," + "{\"kind\":\"sound-clear\"}]}")); + CHECK(bongo_cat_behaviors_load(catalog, model, NULL) == BONGO_CAT_OK); + CHECK(catalog->count == 3); + CHECK(!catalog->entries[0].momentary && !catalog->entries[0].sound_overlap); + CHECK(!catalog->entries[1].sound[0] && !catalog->entries[1].sound_clear); + CHECK(catalog->entries[2].sound_clear); + free(catalog); free(model); + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} diff --git a/tests/model_import/test_mver_container.c b/tests/model_import/test_mver_container.c new file mode 100644 index 00000000..1b5142e6 --- /dev/null +++ b/tests/model_import/test_mver_container.c @@ -0,0 +1,341 @@ +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +typedef struct ProgressiveImportLog { + size_t callbacks; + size_t resolved; + size_t installed; +} ProgressiveImportLog; + +static void progressive_receipt(void *userdata, + const BongoCatImportReceipt *receipt) { + ProgressiveImportLog *log = userdata; + log->callbacks++; + log->resolved += receipt->count; + log->installed += receipt->installed_count; +} + +static void import_without_hand_images(void) { + char *temporary = SDL_GetCurrentDirectory(); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(temporary && discovery); + if (!temporary || !discovery) { + SDL_free(temporary); + free(discovery); + return; + } + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP], copied[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-no-hand-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + BongoCatError error = {0}; + CHECK(child(path, sizeof(path), package, "img/standard/hand/0.png", false)); + CHECK(SDL_RemovePath(path)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(path, sizeof(path), package, "img/standard/hand", false)); + CHECK(bongo_cat_model_remove_tree(path, NULL)); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(stored, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(path, sizeof(path), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], path, &error)); + CHECK(child(path, sizeof(path), root, "adapter/resources/left-keys", false)); + CHECK(!bongo_cat_path_find_suffix(path, ".png", copied, sizeof(copied))); + CHECK(child(path, sizeof(path), package, "img/standard/hand", false)); + CHECK(!bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), stored, "img/standard/hand", false)); + CHECK(!bongo_cat_path_is_dir(path)); + static const char *const preserved[] = { + "config.json", "img/standard/cat_model/cat.model3.json", + "img/standard/cat_model/cat.moc3", "img/standard/cat_model/texture.png" + }; + for (size_t i = 0; i < sizeof(preserved) / sizeof(preserved[0]); ++i) { + CHECK(child(path, sizeof(path), package, preserved[i], false)); + CHECK(child(copied, sizeof(copied), stored, preserved[i], false)); + size_t source_size = 0, copied_size = 0; + void *source_data = SDL_LoadFile(path, &source_size); + void *copied_data = SDL_LoadFile(copied, &copied_size); + CHECK(source_data && copied_data && source_size == copied_size && + memcmp(source_data, copied_data, source_size) == 0); + SDL_free(source_data); + SDL_free(copied_data); + } + CHECK(child(path, sizeof(path), package, "img/standard/cat_model/texture.png", false)); + CHECK(SDL_RemovePath(path)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(error.code == BONGO_CAT_ERROR_FORMAT); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); + free(discovery); +} + +void test_mver_container_discovery(void) { + import_without_hand_images(); + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char backup[BONGO_CAT_PATH_CAP]; + char mode[BONGO_CAT_PATH_CAP]; + char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; + char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; + char variant_img[BONGO_CAT_PATH_CAP], variant_standard[BONGO_CAT_PATH_CAP]; + char variant_hand[BONGO_CAT_PATH_CAP], source_hand[BONGO_CAT_PATH_CAP]; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongocat-container-%llu", temporary, + stamp); + bongo_cat_model_remove_tree(root, NULL); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "app", true)); + CHECK(mver_fixture(package)); + CHECK(child(backup, sizeof(backup), package, + "config.json.before_console_bak", false)); + CHECK(write_text(backup, "backup")); + char runtime_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(runtime_resources, sizeof(runtime_resources), package, + "Resources", true)); + CHECK(child(backup, sizeof(backup), runtime_resources, "cat.ttf", false)); + CHECK(write_text(backup, "runtime font")); + CHECK(child(backup, sizeof(backup), runtime_resources, + "l2dlogo.png", false)); + CHECK(write_text(backup, "runtime logo")); + char authored_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(authored_resources, sizeof(authored_resources), package, + "img/standard/cat_model/resources", true)); + CHECK(child(backup, sizeof(backup), authored_resources, "keep.bin", false)); + CHECK(write_text(backup, "model resource")); + CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); + CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); + CHECK(mver_fixture(nested_package)); + char nested_config[BONGO_CAT_PATH_CAP], slim_config[BONGO_CAT_PATH_CAP]; + CHECK(child(nested_config, sizeof(nested_config), nested_package, + "config.json", false)); + CHECK(child(slim_config, sizeof(slim_config), nested_package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK(SDL_RenamePath(nested_config, slim_config)); + CHECK(child(variant, sizeof(variant), bundle, "variant", true)); + CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); + CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); + CHECK(child(variant_standard, sizeof(variant_standard), variant_img, + "standard", true)); + CHECK(child(variant_hand, sizeof(variant_hand), variant_standard, + "hand", true)); + CHECK(child(source_hand, sizeof(source_hand), nested_package, + "img/standard/hand/0.png", false)); + CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && + SDL_CopyFile(source_hand, mode)); + CHECK(child(backup, sizeof(backup), variant_standard, + "override_bak", false)); + CHECK(write_text(backup, "backup")); + char normalized[BONGO_CAT_PATH_CAP]; + BongoCatError source_error = {0}; + CHECK(bongo_cat_import_source_directory(mode, normalized, + sizeof(normalized), &source_error) == BONGO_CAT_OK); + CHECK(strcmp(normalized, variant_model) == 0); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(package, &discovery, &error) == 1); + CHECK(discovery.count == 1); + CHECK(child(mode, sizeof(mode), package, "img/standard", false)); + BongoCatImportDiscovery nested = {0}; + CHECK(bongo_cat_import_mver_discover_exact(mode, &nested, &error) == 0); + CHECK(bongo_cat_import_mver_discover(mode, &nested, &error) == 1); + BongoCatImportDiscovery selected_container = {0}; + CHECK(bongo_cat_import_discover(root, &selected_container, &error)); + CHECK(selected_container.count == 3); + size_t patch_count = 0; + const BongoCatImportCandidate *contained_patch = NULL; + for (size_t i = 0; i < selected_container.count; ++i) { + if (selected_container.candidates[i].format == + BONGO_CAT_IMPORT_TAURI) continue; + /* Each recursive candidate owns its package tree. The selected + container is only a discovery scope, never a copy source. */ + CHECK(strcmp(selected_container.candidates[i].package_root, + root) != 0); + } + for (size_t i = 0; i < selected_container.count; ++i) + if (selected_container.candidates[i].format == + BONGO_CAT_IMPORT_MVER_PATCH) { + patch_count++; + contained_patch = &selected_container.candidates[i]; + } + CHECK(patch_count == 1); + BongoCatImportDiscovery filtered = selected_container; + BongoCatPackageMetadata filtered_metadata[ + BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + CHECK(bongo_cat_import_prepare_package_metadata(&filtered, + filtered_metadata, &error)); + CHECK(filtered.count == 2); + for (size_t i = 0; i < filtered.count; ++i) + CHECK(filtered.candidates[i].format == BONGO_CAT_IMPORT_MVER); + char installed_root[BONGO_CAT_PATH_CAP]; + snprintf(installed_root, sizeof(installed_root), + "%s/bongocat-container-installed-%llu", temporary, stamp); + BongoCatImportCandidate installed = {0}; + CHECK(contained_patch && bongo_cat_import_prepare_package(contained_patch, + installed_root, &installed, &error)); + CHECK(strncmp(installed.overrides, installed_root, + strlen(installed_root)) == 0); + CHECK(child(mode, sizeof(mode), installed.overrides, "hand/0.png", false) && + bongo_cat_path_is_file(mode)); + CHECK(child(backup, sizeof(backup), installed_root, + "app/config.json.before_console_bak", false) && + !bongo_cat_path_is_file(backup)); + + BongoCatImportDiscovery external_patch = {0}; + CHECK(bongo_cat_import_mver_patch_discover(variant, &external_patch, + &error) == 1); + BongoCatImportDiscovery standalone_patch = external_patch; + BongoCatPackageMetadata standalone_metadata[ + BONGO_CAT_IMPORT_CANDIDATE_CAP] = {0}; + CHECK(bongo_cat_import_prepare_package_metadata(&standalone_patch, + standalone_metadata, &error)); + CHECK(standalone_patch.count == 1 && + standalone_patch.candidates[0].format == BONGO_CAT_IMPORT_MVER_PATCH); + char external_root[BONGO_CAT_PATH_CAP]; + snprintf(external_root, sizeof(external_root), + "%s/bongocat-container-external-%llu", temporary, stamp); + installed = (BongoCatImportCandidate){0}; + CHECK(external_patch.count == 1 && + bongo_cat_import_prepare_package(&external_patch.candidates[0], + external_root, &installed, &error)); + CHECK(strncmp(installed.overrides, external_root, + strlen(external_root)) == 0); + CHECK(child(mode, sizeof(mode), installed.overrides, "hand/0.png", false) && + bongo_cat_path_is_file(mode)); + CHECK(child(backup, sizeof(backup), installed.overrides, + "override_bak", false) && !bongo_cat_path_is_file(backup)); + + char progressive_root[BONGO_CAT_PATH_CAP]; + snprintf(progressive_root, sizeof(progressive_root), + "%s/bongocat-container-progressive-%llu", temporary, stamp); + CHECK(SDL_CreateDirectory(progressive_root)); + ProgressiveImportLog progressive = {0}; + BongoCatImportSession *session = bongo_cat_import_session_create( + progressive_root, &error); + BongoCatImportBatchStats stats = {0}; + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install_progressive(session, + root, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_OK); + CHECK(progressive.callbacks == 2 && progressive.resolved == 2 && + progressive.installed == 2); + CHECK(stats.succeeded_count == 2 && stats.failed_count == 0); + CHECK(child(mode, sizeof(mode), package, + "img/standard/cat_model/cat.moc3", false)); + progressive = (ProgressiveImportLog){0}; + CHECK(session && bongo_cat_import_session_install_progressive(session, + mode, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_OK); + CHECK(progressive.callbacks == 1 && progressive.resolved == 1 && + progressive.installed == 0); + CHECK(stats.succeeded_count == 1 && stats.failed_count == 0); + char missing_source[BONGO_CAT_PATH_CAP]; + CHECK(child(missing_source, sizeof(missing_source), progressive_root, + "missing", false)); + CHECK(bongo_cat_import_session_install_progressive(session, + missing_source, progressive_receipt, &progressive, &stats, &error) == + BONGO_CAT_ERROR_ARGUMENT); + CHECK(stats.succeeded_count == 0 && stats.failed_count == 1 && + stats.failure_name_count == 1 && strcmp(stats.failure_names[0], + "missing") == 0); + bongo_cat_import_session_destroy(session); + + char models_root[BONGO_CAT_PATH_CAP], cache_root[BONGO_CAT_PATH_CAP]; + snprintf(models_root, sizeof(models_root), + "%s/bongocat-container-models-%llu", temporary, stamp); + snprintf(cache_root, sizeof(cache_root), + "%s/bongocat-container-cache-%llu", temporary, stamp); + CHECK(SDL_CreateDirectory(models_root)); + CHECK(SDL_CreateDirectory(cache_root)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(root, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 2 && receipt.installed_count == 2); + BongoCatImportReceipt subset_receipt = {0}; + CHECK(bongo_cat_import_install(package, models_root, &subset_receipt, + &error) == BONGO_CAT_OK); + CHECK(subset_receipt.count == 1 && subset_receipt.installed_count == 0 && + strcmp(subset_receipt.ids[0], receipt.ids[0]) == 0); + CHECK(child(mode, sizeof(mode), package, + "img/standard/cat_model/cat.moc3", false)); + BongoCatImportReceipt moc_receipt = {0}; + CHECK(bongo_cat_import_install(mode, models_root, &moc_receipt, + &error) == BONGO_CAT_OK); + CHECK(moc_receipt.count == subset_receipt.count && + moc_receipt.installed_count == 0 && + strcmp(moc_receipt.ids[0], subset_receipt.ids[0]) == 0); + char stored[BONGO_CAT_PATH_CAP], duplicate_directory[BONGO_CAT_PATH_CAP]; + CHECK(child(stored, sizeof(stored), models_root, receipt.ids[0], false) && + bongo_cat_path_is_dir(stored)); + CHECK(child(duplicate_directory, sizeof(duplicate_directory), models_root, + receipt.ids[1], false) && !bongo_cat_path_is_dir(duplicate_directory)); + CHECK(child(backup, sizeof(backup), stored, + ".bongo-cat-package.json", false) && !bongo_cat_path_is_file(backup)); + CHECK(child(backup, sizeof(backup), stored, + ".bongo-cat-adapter", false) && !bongo_cat_path_is_dir(backup)); + CHECK(child(backup, sizeof(backup), stored, + BONGO_CAT_MODEL_ADAPTER_FILE, false) && + !bongo_cat_path_is_file(backup)); + CHECK(child(backup, sizeof(backup), stored, "app/Resources", false) && + !bongo_cat_path_is_dir(backup)); + CHECK(child(backup, sizeof(backup), stored, + "app/img/standard/cat_model/resources/keep.bin", false) && + bongo_cat_path_is_file(backup)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", cache_root); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == receipt.count); + for (size_t i = 0; i < app->models.count; ++i) + CHECK(strcmp(app->models.entries[i].storage_directory, stored) == 0); + CHECK(bongo_cat_settings_set_model_removed(&app->settings, + receipt.ids[0], true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count + 1 == receipt.count); + CHECK(!bongo_cat_models_find(&app->models, receipt.ids[0])); + CHECK(bongo_cat_models_find(&app->models, receipt.ids[1])); + CHECK(bongo_cat_path_is_dir(stored)); + CHECK(bongo_cat_settings_restore_model_package(&app->settings, + receipt.ids[0])); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == receipt.count && + bongo_cat_models_find(&app->models, receipt.ids[0])); + free(app); + } + CHECK(bongo_cat_model_remove_tree(models_root, NULL)); + CHECK(bongo_cat_model_remove_tree(cache_root, NULL)); + CHECK(bongo_cat_model_remove_tree(progressive_root, NULL)); + CHECK(bongo_cat_model_remove_tree(installed_root, NULL)); + CHECK(bongo_cat_model_remove_tree(external_root, NULL)); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_mver_import.c b/tests/model_import/test_mver_import.c new file mode 100644 index 00000000..4d592273 --- /dev/null +++ b/tests/model_import/test_mver_import.c @@ -0,0 +1,245 @@ +#include "model_import_mver_internal.h" +#include "model_storage.h" +#include "preferences_model_glyphs.h" +#include "preferences_state.h" +#include "ui_font_atlas.h" +#include "runtime.h" +#include "test_mver_support.h" +#include "test_mver_import_internal.h" +#include "bongo_cat/image.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" +#include +#include +#include +#include +#include +int failures; +int test_preferences_text(void); int test_mver_nearby_identity(void); +int test_mver_nearby_refresh(void); +int test_mver_missing_motion_groups(void); +int test_mver_policy(void); +int test_model_import_identity(void); +int test_slim_package(void); +static bool chord(const char *json, bool gamepad, const char *expected) { + yyjson_doc *document = yyjson_read(json, strlen(json), 0); + BongoCatImportCandidate candidate = {0}; + candidate.gamepad_buttons = gamepad; + char output[BONGO_CAT_SHORTCUT_CAP]; + bool ok = document && bongo_cat_mver_chord(&candidate, + yyjson_doc_get_root(document), output, sizeof(output)); + bool matches = ok && expected && strcmp(output, expected) == 0; + yyjson_doc_free(document); + return expected ? matches : !ok; +} + +static void labels_from_shortcut_rows(void) { + static const char default_expression[] = + "\xE9\xBB\x98\xE8\xAE\xA4\xE8\xA1\xA8\xE6\x83\x85"; + static const char keyboard_hidden[] = + "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-labels-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(path, sizeof(path), root, "config.json", false)); + CHECK(write_text(path, + "{/* misleading: \\\"standard\\\" */standard:{" + "l2d_expression:[[18,//\xE9\xBB\x98\xE8\xAE\xA4\xE8\xA1\xA8\xE6\x83\x85\n76]," + "[18,77]],l2d_motion:[],l2d_motion_lockhand:" + "[[18,//\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1\n49]],sounds:[]}," + "keyboard:{l2d_expression:[]}}")); + BongoCatMverLabels labels = {0}; + CHECK(bongo_cat_mver_labels_load(path, "standard", &labels)); + CHECK(labels.count == 2); + CHECK(strcmp(bongo_cat_mver_label(&labels, "l2d_expression", 0), + default_expression) == 0); + CHECK(bongo_cat_mver_label(&labels, "l2d_expression", 1) == NULL); + CHECK(strcmp(bongo_cat_mver_label(&labels, "l2d_motion_lockhand", 0), + keyboard_hidden) == 0); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} + +static void metadata_backfills_labels(void) { + static const char keyboard_hidden[] = + "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-metadata-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(path, sizeof(path), root, ".bongo-cat-adapter.json", false)); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-runtime-adapter\",\"bindings\":[" + "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":0," + "\"shortcut\":\"Alt+1\",\"label\":\"\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1\"}," + "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":1," + "\"shortcut\":\"Alt+2\",\"label\":\"Imported\"}," + "{\"kind\":\"expression\",\"index\":2,\"shortcut\":\"Alt+3\"," + "\"label\":\"Expression label\"}]}")); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + app->settings.behavior_shortcut_count = 2; + snprintf(app->settings.behavior_shortcuts[0].id, + sizeof(app->settings.behavior_shortcuts[0].id), + "model:motion:CAT_motion_lock:0"); + snprintf(app->settings.behavior_shortcuts[0].shortcut, + sizeof(app->settings.behavior_shortcuts[0].shortcut), "Control+1"); + snprintf(app->settings.behavior_shortcuts[1].id, + sizeof(app->settings.behavior_shortcuts[1].id), + "model:motion:CAT_motion_lock:1"); + snprintf(app->settings.behavior_shortcuts[1].label, + sizeof(app->settings.behavior_shortcuts[1].label), "Custom name"); + bongo_cat_import_apply_metadata(app, "model", root); + CHECK(app->settings.behavior_shortcut_count == 3); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Control+1") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[0].label, keyboard_hidden) == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[1].label, "Custom name") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[2].id, + "model:expression:2") == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[2].label, + "Expression label") == 0); + free(app); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} + +static bool range_has(const uint32_t *ranges, uint32_t rune) { + for (size_t i = 0; ranges[i]; i += 2) + if (rune >= ranges[i] && rune <= ranges[i + 1]) return true; + return false; +} + +static void behavior_labels_add_font_glyphs(void) { + static const unsigned char custom_label[] = { + 0xe7, 0x8c, 0xab, 0xe5, 0x92, 0xaa, 0xe5, 0xbd, + 0xa2, 0xe6, 0x80, 0x81, 0 + }; + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) return; + snprintf(app->behaviors.entries[0].label, + sizeof(app->behaviors.entries[0].label), + "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"); + app->behaviors.count = 1; + memcpy(app->settings.behavior_shortcuts[0].label, custom_label, + sizeof(custom_label)); + app->settings.behavior_shortcut_count = 1; + uint32_t ranges[64] = {0x20, 0x7e, 0}; + bongo_cat_preferences_model_glyphs(app, ranges, + sizeof(ranges) / sizeof(ranges[0])); + CHECK(range_has(ranges, 0x952e)); + CHECK(range_has(ranges, 0x76d8)); + CHECK(range_has(ranges, 0x6d88)); + CHECK(range_has(ranges, 0x5931)); + CHECK(range_has(ranges, 0x732b)); + CHECK(range_has(ranges, 0x54aa)); + CHECK(range_has(ranges, 0x5f62)); + CHECK(range_has(ranges, 0x6001)); + free(app); +} + +static void font_reload_defers_during_frame(void) { + BongoCatPreferences value = {0}; + value.ui_initialized = true; + value.ui.frame_building = true; + CHECK(bongo_cat_preferences_reload_fonts(&value)); + CHECK(value.font_reload_pending); + CHECK(value.render_dirty); + BongoCatUIBackend backend = {0}; + backend.frame_building = true; + CHECK(!bongo_cat_ui_font_atlas_reload(&backend, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, 1.0f)); +} + +static void model_change_reloads_glyphs_before_drawing(void) { + for (int page = 0; page < 4; ++page) { + BongoCatPreferences value = {0}; + value.page = page; + value.ui_initialized = true; + value.font_reload_defer_once = true; + bongo_cat_preferences_models_changed(&value); + CHECK(value.render_dirty); + CHECK(value.font_reload_pending && !value.font_reload_defer_once); + } + BongoCatPreferences unopened = {0}; + bongo_cat_preferences_models_changed(&unopened); + CHECK(unopened.render_dirty && !unopened.font_reload_pending); +} + +static void model_visual_expires_without_window(void) { + BongoCatPreferences value = {0}; + value.model_load_visual_active = true; + value.model_load_visual_started_ns = SDL_GetTicksNS() - + BONGO_CAT_MODEL_LOAD_VISUAL_DURATION_NS; + CHECK(!bongo_cat_preferences_needs_frame(&value)); + CHECK(!value.model_load_visual_active && value.model_load_progress == 1.0f); +} + +static void model_visual_curve(void) { + BongoCatPreferences value = {0}; + bongo_cat_preferences_model_visual_begin(&value, "curve"); + value.model_loading = true; + uint64_t now = SDL_GetTicksNS(); + value.model_load_visual_started_ns = now - + BONGO_CAT_MODEL_LOAD_VISUAL_RAMP_NS; + float ramp = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(ramp > .79f && ramp < .81f); + value.model_load_visual_started_ns = now - 4000000000ull; + float waiting = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(waiting > .86f && waiting < .89f); + value.model_loading = false; + value.model_load_visual_completion_ns = SDL_GetTicksNS() - + BONGO_CAT_MODEL_LOAD_VISUAL_COMPLETE_NS; + float complete = bongo_cat_preferences_model_visual_progress(&value, "curve"); + CHECK(complete == 1.0f && !value.model_load_visual_active); +} + +int main(void) { + test_mver_pointer_modes(); + test_mver_audio(); + failures += test_preferences_text(); + CHECK(chord("[17,65]", true, "Control+A")); + CHECK(chord("[0]", true, "Gamepad:South")); + CHECK(chord("[15]", true, "Gamepad:Select")); + CHECK(chord("[8]", false, "Backspace")); + CHECK(chord("[24]", false, "24")); + CHECK(chord("[16,65]", false, "Shift+A")); + CHECK(chord("[17,18,90]", false, "Control+Alt+Z")); + CHECK(chord("[0]", false, NULL)); + CHECK(chord("[16]", false, NULL)); + CHECK(chord("[17,65,66]", true, NULL)); + BongoCatMverKeyNames modifier = bongo_cat_mver_device_names(16, 1, 2); + CHECK(modifier.count == 1 && strcmp(modifier.items[0], "ShiftRight") == 0); + BongoCatMverKeyNames dpad = bongo_cat_mver_gamepad_names(12); + CHECK(dpad.count == 1 && strcmp(dpad.items[0], "DPadUp") == 0); + BongoCatMverKeyNames numeric = bongo_cat_mver_device_names(24, 0, 1); + CHECK(numeric.count == 1 && strcmp(numeric.generated, "24") == 0); + labels_from_shortcut_rows(); + metadata_backfills_labels(); + failures += test_mver_missing_motion_groups(); + behavior_labels_add_font_glyphs(); + font_reload_defers_during_frame(); + model_change_reloads_glyphs_before_drawing(); + model_visual_expires_without_window(); + model_visual_curve(); + failures += test_mver_nearby_identity(); + failures += test_mver_nearby_refresh(); + failures += test_mver_policy(); + failures += test_model_import_identity(); + test_mver_container_discovery(); + test_model_import_source(); + test_model_import_archive(); + test_mver_manifest(); + test_tauri_portable(); + failures += test_slim_package(); + return failures ? 1 : 0; +} diff --git a/tests/model_import/test_mver_import_internal.h b/tests/model_import/test_mver_import_internal.h new file mode 100644 index 00000000..ab619d5a --- /dev/null +++ b/tests/model_import/test_mver_import_internal.h @@ -0,0 +1,22 @@ +#ifndef BONGO_CAT_TEST_MVER_IMPORT_INTERNAL_H +#define BONGO_CAT_TEST_MVER_IMPORT_INTERNAL_H + +#include + +extern int failures; + +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", \ + __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +void test_mver_container_discovery(void); +void test_mver_audio(void); +void test_mver_pointer_modes(void); +void test_model_import_source(void); +void test_model_import_archive(void); +void test_mver_manifest(void); +void test_tauri_portable(void); + +#endif diff --git a/tests/model_import/test_mver_manifest.c b/tests/model_import/test_mver_manifest.c new file mode 100644 index 00000000..0ed257f7 --- /dev/null +++ b/tests/model_import/test_mver_manifest.c @@ -0,0 +1,195 @@ +#include "model_import.h" +#include "model_import_manifest.h" +#include "mver/model_import_mver.h" +#include "mver/model_import_mver_manifest.h" +#include "model_storage.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static const char *broken = + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"]\n]\n}\n}," + "\"Groups\":[{\"Target\":\"Parameter\",\"Name\":\"EyeBlink\"," + "\"Ids\":[\"ParamEyeLOpen\"]}]}"; + +static void multiple_mver_manifests(void) { + char *temporary = SDL_GetCurrentDirectory(); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(temporary && discovery); + if (!temporary || !discovery) { + SDL_free(temporary); + free(discovery); + return; + } + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char model[BONGO_CAT_PATH_CAP], canonical[BONGO_CAT_PATH_CAP]; + char exported[BONGO_CAT_PATH_CAP], extra[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-multiple-manifests-%llu", + temporary, (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(child(canonical, sizeof(canonical), model, "cat.model3.json", false)); + CHECK(child(exported, sizeof(exported), model, "ys.model3.json", false)); + /* The export can carry different behavior; Mver still uses cat.model3.json. */ + static const char export_json[] = + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"],\"Expressions\":[" + "{\"Name\":\"unused\",\"File\":\"unused.exp3.json\"}]}}"; + CHECK(write_text(exported, export_json)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_discover(root, discovery, &error)); + CHECK(discovery->count == 1); + CHECK(strcmp(discovery->candidates[0].setting, "cat.model3.json") == 0); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + CHECK(bongo_cat_import_discover(stored, discovery, &error)); + CHECK(discovery->count == 1); + CHECK(strcmp(discovery->candidates[0].setting, "cat.model3.json") == 0); + CHECK(child(extra, sizeof(extra), stored, + "img/standard/cat_model/ys.model3.json", false)); + size_t length = 0; + char *copied = SDL_LoadFile(extra, &length); + CHECK(copied && length == strlen(export_json) && + memcmp(copied, export_json, length) == 0); + SDL_free(copied); + CHECK(bongo_cat_path_is_file(canonical) && bongo_cat_path_is_file(exported)); + + /* Never hide a broken canonical entry by selecting a different export. */ + CHECK(write_text(canonical, "{}")); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(SDL_RemovePath(canonical)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == 1); + CHECK(strcmp(discovery->candidates[0].setting, "ys.model3.json") == 0); + CHECK(child(extra, sizeof(extra), model, "other.model3.json", false)); + CHECK(write_text(extra, export_json)); + memset(discovery, 0, sizeof(*discovery)); + CHECK(bongo_cat_import_mver_discover_exact(package, discovery, &error) == -1); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); + free(discovery); +} + +void test_mver_manifest(void) { + multiple_mver_manifests(); + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; + char model[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char models[BONGO_CAT_PATH_CAP], stored[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-manifest-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(package, sizeof(package), root, "source", true)); + CHECK(mver_fixture(package)); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, broken)); + bool repaired = false; + yyjson_doc *document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && repaired); + CHECK(yyjson_arr_size(yyjson_obj_get(yyjson_doc_get_root(document), + "Groups")) == 1); + yyjson_doc_free(document); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(child(models, sizeof(models), root, "models", true)); + BongoCatError error = {0}; + BongoCatImportReceipt receipt = {0}, duplicate = {0}; + CHECK(bongo_cat_import_install(package, models, &receipt, &error) == BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(child(stored, sizeof(stored), models, receipt.ids[0], false)); + CHECK(child(model, sizeof(model), stored, "img/standard/cat_model", false)); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + char installed_manifest[BONGO_CAT_PATH_CAP]; + CHECK(child(installed_manifest, sizeof(installed_manifest), model, + "cat.model3.json", false)); + size_t installed_length = 0; + char *installed_json = SDL_LoadFile(installed_manifest, &installed_length); + CHECK(installed_json && installed_length == strlen(broken) && + memcmp(installed_json, broken, installed_length) == 0); + SDL_free(installed_json); + BongoCatBehaviorCatalog *behaviors = calloc(1, sizeof(*behaviors)); + BongoCatModelEntry *entry = calloc(1, sizeof(*entry)); + CHECK(behaviors && entry); + if (behaviors && entry) { + snprintf(entry->directory, sizeof(entry->directory), "%s", model); + snprintf(entry->setting_file, sizeof(entry->setting_file), "cat.model3.json"); + CHECK(bongo_cat_behaviors_load(behaviors, entry, &error) == BONGO_CAT_OK); + } + free(behaviors); + free(entry); + CHECK(bongo_cat_import_install(package, models, &duplicate, &error) == BONGO_CAT_OK); + CHECK(duplicate.count == 1 && duplicate.installed_count == 0 && + strcmp(receipt.ids[0], duplicate.ids[0]) == 0); + size_t length = 0; + char *original = SDL_LoadFile(path, &length); + CHECK(original && length == strlen(broken) && strcmp(original, broken) == 0); + SDL_free(original); + + CHECK(write_text(path, "{/* comment */\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\",],},}")); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && repaired); + yyjson_doc_free(document); + static const char bom_manifest[] = "\xEF\xBB\xBF" + "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"]}}"; + document = bongo_cat_model_json_parse(bom_manifest, + sizeof(bom_manifest) - 1, &repaired); + CHECK(document && repaired); + yyjson_doc_free(document); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"missing.png\"]]}}}")); + CHECK(child(model, sizeof(model), package, "img/standard/cat_model", false)); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + static const char *const rejected[] = { + "{\"Version\":3,\"FileReferences\":{", + "{\"Version\":3 \"FileReferences\":{}}", + "{\"Version\":3,\"FileReferences\":{}} trailing garbage" + }; + for (size_t i = 0; i < sizeof(rejected) / sizeof(rejected[0]); ++i) { + CHECK(write_text(path, rejected[i])); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(!document && !repaired); + yyjson_doc_free(document); + } + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}")); + document = bongo_cat_import_mver_manifest_read(path, &repaired); + CHECK(document && !repaired); + yyjson_doc_free(document); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"Physics\":\"missing.physics3.json\",\"Pose\":\"\"," + "\"DisplayInfo\":\"missing.cdi3.json\",\"UserData\":\"missing.userdata3.json\"," + "\"Expressions\":[{\"Name\":\"missing\",\"File\":\"missing.exp3.json\"}]," + "\"Motions\":{\"Idle\":[{\"File\":\"missing.motion3.json\",\"Sound\":\"\"}]}}}")); + CHECK(!bongo_cat_import_manifest_valid(model, "cat.model3.json", NULL)); + CHECK(bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"UserData\":\"../outside.json\"}}")); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"missing.moc3\",\"Textures\":[\"texture.png\"]}}")); + CHECK(!bongo_cat_import_mver_manifest_valid(model, "cat.model3.json")); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/model_import/test_mver_missing_motion.c b/tests/model_import/test_mver_missing_motion.c new file mode 100644 index 00000000..b0e85fc5 --- /dev/null +++ b/tests/model_import/test_mver_missing_motion.c @@ -0,0 +1,168 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_import_mver.h" +#include "model_storage.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static void optional_mode_inputs(const char *temporary) { + const char *names[] = {"standard", "keyboard", "gamepad"}; + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(discovery != NULL); + if (!discovery) return; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); ++i) { + char root[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; + char mode[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char adapter[BONGO_CAT_PATH_CAP], config[512]; + snprintf(root, sizeof(root), "%s/bongocat-optional-%s-%llu", temporary, + names[i], (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(standard, sizeof(standard), root, "img/standard", false)); + CHECK(child(mode, sizeof(mode), root, "img", false)); + CHECK(child(path, sizeof(path), mode, names[i], false)); + if (i) CHECK(SDL_RenamePath(standard, path)); + snprintf(mode, sizeof(mode), "%s", path); + CHECK(child(path, sizeof(path), mode, "hand", false)); + CHECK(bongo_cat_model_remove_tree(path, NULL)); + const char *disabled = i ? "standard" : "keyboard"; + CHECK(child(path, sizeof(path), root, "img", false)); + CHECK(child(standard, sizeof(standard), path, disabled, true)); + snprintf(config, sizeof(config), "{\"%s\":{\"l2d\":true," + "\"hand\":null,\"lefthand\":null,\"sounds\":null," + "\"l2d_expression\":null,\"l2d_motion\":null}," + "\"%s\":{\"l2d\":false,\"hand\":42,\"lefthand\":42}}", names[i], disabled); + CHECK(child(path, sizeof(path), root, "config.json", false)); + CHECK(write_text(path, config)); + memset(discovery, 0, sizeof(*discovery)); + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, discovery, &error) == 1); + CHECK(discovery->count == 1); + CHECK(child(adapter, sizeof(adapter), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + char report_path[BONGO_CAT_PATH_CAP]; + CHECK(child(report_path, sizeof(report_path), adapter, + ".bongo-cat-import-report.json", false)); + yyjson_doc *report = bongo_cat_json_read_file(report_path, 0, NULL); + yyjson_val *assets = yyjson_obj_get(yyjson_doc_get_root(report), "assets"); + CHECK(report && yyjson_is_obj(assets)); + CHECK(!yyjson_obj_get(assets, "hand") && + !yyjson_obj_get(assets, "lefthand") && + !yyjson_obj_get(assets, "sounds")); + yyjson_doc_free(report); + size_t length = 0; + char *original = SDL_LoadFile(path, &length); + CHECK(original && length == strlen(config) && memcmp(original, config, length) == 0); + SDL_free(original); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + } + free(discovery); +} + +static void empty_optional_bindings(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + char adapter[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-empty-bindings-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(mver_fixture(root)); + CHECK(child(path, sizeof(path), root, "config.json", false)); + CHECK(write_text(path, "{\"decoration\":{\"emoticonKeep\":true,\"emoticonClear\":[]}," + "\"standard\":{\"l2d\":true,\"hand\":[[65]],\"keyboard\":[[65]]," + "\"l2d_expression\":[[],[999999],[65]]," + "\"l2d_motion\":[null,[999999],[66]],\"face\":[[],null,[67],[999999]]," + "\"sounds\":[[],null,[68],[999999]]}}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," + "\"Textures\":[\"texture.png\"],\"Expressions\":[" + "{\"Name\":\"a\",\"File\":\"a.exp3.json\"}," + "{\"Name\":\"b\",\"File\":\"b.exp3.json\"}," + "{\"Name\":\"c\",\"File\":\"c.exp3.json\"}]," + "\"Motions\":{\"CAT_motion\":[{\"File\":\"a.motion3.json\"}," + "{\"File\":\"b.motion3.json\"},{\"File\":\"c.motion3.json\"}]}}}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/c.exp3.json", false)); + CHECK(write_text(path, "{\"Type\":\"Live2D Expression\",\"Parameters\":[]}")); + CHECK(child(path, sizeof(path), root, "img/standard/cat_model/c.motion3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"Meta\":{\"Duration\":1},\"Curves\":[]}")); + CHECK(child(path, sizeof(path), root, "img/standard/keyboard", true)); + CHECK(child(path, sizeof(path), root, "img/standard/keyboard/0.png", false)); + CHECK(write_text(path, "unreadable optional image")); + CHECK(child(path, sizeof(path), root, "img/standard/face", true)); + CHECK(child(path, sizeof(path), root, "img/standard/face/2.png", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/bongocat.png", path)); + CHECK(child(path, sizeof(path), root, "img/standard/face/3.png", false)); + CHECK(write_text(path, "unreadable optional image")); + CHECK(child(path, sizeof(path), root, "img/standard/sounds", true)); + CHECK(child(path, sizeof(path), root, "img/standard/sounds/2.flac", false)); + CHECK(bongo_cat_path_copy_file(BONGO_CAT_NATIVE_SOURCE_DIR + "/resources/assets/models/standard/live2d_motion1.flac", path)); + BongoCatImportDiscovery *discovery = calloc(1, sizeof(*discovery)); + CHECK(discovery != NULL); + if (discovery) { + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover_exact(root, discovery, &error) == 1); + CHECK(child(adapter, sizeof(adapter), root, "adapter", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + CHECK(child(path, sizeof(path), adapter, BONGO_CAT_MODEL_ADAPTER_FILE, false)); + yyjson_doc *document = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *bindings = yyjson_obj_get(yyjson_doc_get_root(document), "bindings"); + CHECK(document && yyjson_arr_size(bindings) == 4); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_arr_get(bindings, 0), "index")) == 2); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_arr_get(bindings, 1), "index")) == 2); + yyjson_doc_free(document); + CHECK(child(path, sizeof(path), adapter, "resources/left-keys/KeyA.png", false)); + CHECK(bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), root, "img/standard/hand/0.png", false)); + CHECK(write_text(path, "unreadable optional hand")); + CHECK(child(adapter, sizeof(adapter), root, "adapter-without-hand", false)); + CHECK(bongo_cat_import_prepare_adapter(&discovery->candidates[0], adapter, &error)); + CHECK(child(path, sizeof(path), adapter, "resources/left-keys/KeyA.png", false)); + CHECK(!bongo_cat_path_is_file(path)); + free(discovery); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + +int test_mver_missing_motion_groups(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + optional_mode_inputs(temporary); + empty_optional_bindings(temporary); + unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); + char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP]; + snprintf(source, sizeof(source), "%s/bongocat-stale-motion-%llu", + temporary, stamp); + snprintf(data, sizeof(data), "%s/bongocat-stale-motion-data-%llu", + temporary, stamp); + CHECK(mver_fixture(source)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(path, sizeof(path), source, "config.json", false)); + CHECK(write_text(path, "{\"standard\":{\"hand\":[[65]]," + "\"keyboard\":[[65]],\"l2d_motion\":[[67]]," + "\"l2d_motion_lockhand\":[[68]]}}")); + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), data, "models", true)); + CHECK(bongo_cat_import_install(source, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1); + CHECK(bongo_cat_model_remove_tree(source, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/model_import/test_mver_nearby_identity.c b/tests/model_import/test_mver_nearby_identity.c new file mode 100644 index 00000000..dc857bee --- /dev/null +++ b/tests/model_import/test_mver_nearby_identity.c @@ -0,0 +1,348 @@ +#include "test_mver_support.h" + +#include "model_import.h" +#include "model_storage.h" +#include "runtime.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static bool model_displayed(const BongoCatApp *app, const char *name) { + for (size_t i = 0; app && i < app->models.count; ++i) + if (strcmp(app->models.entries[i].display_name, name) == 0) + return true; + return false; +} + +static bool wait_for_model(BongoCatApp *app, const char *name, bool visible) { + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (model_displayed(app, name) != visible && + SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } + return model_displayed(app, name) == visible; +} + +static bool broken_expression_fixture(const char *root) { + char path[BONGO_CAT_PATH_CAP]; + return mver_fixture(root) && + child(path, sizeof(path), root, "config.json", false) && + write_text(path, "{\"standard\":{\"l2d\":true,\"keyboard\":[[65]]," + "\"hand\":[[65]],\"l2d_expression\":[[999999]]}}") && + child(path, sizeof(path), root, + "img/standard/cat_model/cat.model3.json", false) && + write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]," + "\"Expressions\":[{\"Name\":\"bad\",\"File\":\"bad.exp3.json\"}]}}") && + child(path, sizeof(path), root, + "img/standard/cat_model/bad.exp3.json", false) && + write_text(path, "{\"Type\":\"Live2D Expression\",\"Parameters\":[]}"); +} + +static void catalog_failure_isolation(const char *temporary) { + char root[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char nearby[BONGO_CAT_PATH_CAP], bad[BONGO_CAT_PATH_CAP]; + char good[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-scan-failure-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(models, sizeof(models), root, "models", true)); + CHECK(child(nearby, sizeof(nearby), root, "nearby", true)); + CHECK(child(bad, sizeof(bad), models, "a-broken", false)); + CHECK(broken_expression_fixture(bad)); + CHECK(child(good, sizeof(good), models, "z-valid", false)); + CHECK(mver_fixture(good)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", true)); + snprintf(app->nearby_root, sizeof(app->nearby_root), "%s", nearby); + + /* Verify that this fixture actually exercises the reported failure. */ + BongoCatError error = {0}; + CHECK(bongo_cat_import_nearby_root(app, bad, &error) == BONGO_CAT_ERROR_FORMAT); + CHECK(strstr(error.message, "not a supported input chord") != NULL); + snprintf(app->session.active_model_id, sizeof(app->session.active_model_id), + "a-broken"); + app->settings.behavior_shortcut_count = 1; + snprintf(app->settings.behavior_shortcuts[0].id, + sizeof(app->settings.behavior_shortcuts[0].id), "a-broken:expression:0"); + snprintf(app->settings.behavior_shortcuts[0].shortcut, + sizeof(app->settings.behavior_shortcuts[0].shortcut), "Alt+1"); + bongo_cat_app_rescan_models(app); + CHECK(app->models.count == 4); + CHECK(bongo_cat_models_find(&app->models, "standard") != NULL); + CHECK(model_displayed(app, "z-valid")); + CHECK(!model_displayed(app, "a-broken")); + CHECK(strcmp(app->session.active_model_id, "standard") == 0); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Alt+1") == 0); + + CHECK(child(bad, sizeof(bad), nearby, "a-broken-nearby", false)); + CHECK(broken_expression_fixture(bad)); + CHECK(child(good, sizeof(good), nearby, "z-valid-nearby", false)); + CHECK(mver_fixture(good)); + CHECK(child(path, sizeof(path), good, "img/standard/cat_model/cat.moc3", false)); + CHECK(write_text(path, "MOC3-distinct-nearby")); + bongo_cat_app_refresh_nearby_models(app); + CHECK(app->models.count == 5); + CHECK(model_displayed(app, "z-valid-nearby")); + CHECK(!model_displayed(app, "a-broken-nearby")); + bongo_cat_app_request_nearby_model_refresh(app); + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (bongo_cat_app_model_refresh_busy(app) && SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } + CHECK(!bongo_cat_app_model_refresh_busy(app)); + CHECK(app->models.count == 5); + CHECK(model_displayed(app, "z-valid")); + CHECK(model_displayed(app, "z-valid-nearby")); + bongo_cat_model_refresh_shutdown(app); + CHECK(app->settings.behavior_shortcut_count == 1); + + /* A file at the cache/storage path deterministically simulates an + unavailable directory without depending on platform ACLs. */ + CHECK(child(path, sizeof(path), root, "blocked", false)); + CHECK(write_text(path, "blocked")); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", path); + CHECK(bongo_cat_model_catalog_scan(app, false, nearby) == BONGO_CAT_OK); + CHECK(app->models.count == 3); + snprintf(app->models_root, sizeof(app->models_root), "%s", path); + CHECK(bongo_cat_model_catalog_scan(app, true, nearby) == BONGO_CAT_OK); + CHECK(app->models.count == 3); + const BongoCatModelEntry *entry = bongo_cat_models_find(&app->models, "standard"); + CHECK(entry && strstr(entry->directory, "resources/assets/models") != NULL); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + CHECK(bongo_cat_model_catalog_scan(app, false, NULL) == BONGO_CAT_OK); + CHECK(bongo_cat_model_catalog_add_bundled(app, true)); + entry = bongo_cat_models_find(&app->models, "standard"); + CHECK(entry && strstr(entry->directory, "resources/assets/models") != NULL); + CHECK(app->models.count == 3); + free(app); +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + +static void background_installed_refresh(const char *temporary) { + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char cache[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-owned-source-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-owned-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(data)); + CHECK(mver_fixture(root)); + CHECK(child(source, sizeof(source), root, "config.json", false)); + CHECK(child(models, sizeof(models), data, "models", true)); + CHECK(child(cache, sizeof(cache), data, "cache", true)); + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_install(source, models, &receipt, &error) == + BONGO_CAT_OK && receipt.count == 1); + + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->models_root, sizeof(app->models_root), "%s", models); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", cache); + snprintf(app->models.entries[0].id, + sizeof(app->models.entries[0].id), "unrelated-model"); + app->models.entries[0].managed = true; + app->models.count = 1; + bongo_cat_app_request_model_package_refresh(app, receipt.ids[0]); + CHECK(wait_for_model(app, bongo_cat_path_name(root), true)); + CHECK(bongo_cat_models_find(&app->models, "unrelated-model") != NULL); + CHECK(bongo_cat_settings_set_model_removed(&app->settings, + receipt.ids[0], true)); + bongo_cat_app_request_model_package_refresh(app, receipt.ids[0]); + CHECK(wait_for_model(app, bongo_cat_path_name(root), false)); + CHECK(bongo_cat_models_find(&app->models, "unrelated-model") != NULL); + bongo_cat_model_refresh_shutdown(app); + free(app); + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); +} + +int test_mver_nearby_identity(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char source[BONGO_CAT_PATH_CAP], moved[BONGO_CAT_PATH_CAP]; + char again[BONGO_CAT_PATH_CAP], duplicate[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(root, sizeof(root), "%s/bongocat-nearby-move-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-nearby-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + CHECK(child(source, sizeof(source), root, "source", false)); + CHECK(child(moved, sizeof(moved), root, "moved", false)); + CHECK(child(again, sizeof(again), root, "again", false)); + CHECK(child(duplicate, sizeof(duplicate), root, "duplicate", false)); + CHECK(mver_fixture(source)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + bongo_cat_models_init(&app->models); + snprintf(app->models_root, sizeof(app->models_root), "%s", data); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); + BongoCatError error = {0}; + CHECK(bongo_cat_import_nearby_root(app, source, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && app->models.entries[0].managed); + char old_id[BONGO_CAT_ID_CAP]; + snprintf(old_id, sizeof(old_id), "%s", app->models.entries[0].id); + CHECK(bongo_cat_settings_set_model_label(&app->settings, old_id, + "Nearby custom name")); + BongoCatBehaviorShortcut *binding = + &app->settings.behavior_shortcuts[app->settings.behavior_shortcut_count++]; + snprintf(binding->id, sizeof(binding->id), "%s:expression:2", old_id); + snprintf(binding->shortcut, sizeof(binding->shortcut), "Alt+3"); + snprintf(binding->label, sizeof(binding->label), "Custom expression"); + CHECK(bongo_cat_path_rename(source, moved)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_nearby_root(app, moved, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && app->models.entries[0].managed); + const char *new_id = app->models.entries[0].id; + CHECK(strcmp(old_id, new_id) != 0); + CHECK(strcmp(app->session.active_model_id, old_id) == 0); + CHECK(bongo_cat_settings_model_label(&app->settings, new_id) == NULL); + CHECK(strcmp(bongo_cat_settings_model_label(&app->settings, old_id), + "Nearby custom name") == 0); + CHECK(app->settings.behavior_shortcut_count == 1); + CHECK(strncmp(app->settings.behavior_shortcuts[0].id, old_id, + strlen(old_id)) == 0); + CHECK(strcmp(app->settings.behavior_shortcuts[0].shortcut, "Alt+3") == 0); + char ambiguous_id[BONGO_CAT_ID_CAP]; + snprintf(ambiguous_id, sizeof(ambiguous_id), "%s", new_id); + CHECK(bongo_cat_settings_set_model_label(&app->settings, ambiguous_id, + "Must remain here")); + CHECK(bongo_cat_path_rename(moved, again)); + CHECK(mver_fixture(duplicate)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_nearby_root(app, root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && app->models.entries[0].managed); + CHECK(strcmp(app->session.active_model_id, old_id) == 0); + CHECK(strcmp(bongo_cat_settings_model_label(&app->settings, ambiguous_id), + "Must remain here") == 0); + free(app); +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} + +int test_mver_nearby_refresh(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) { + SDL_free(temporary); + return failures; + } + background_installed_refresh(temporary); + catalog_failure_isolation(temporary); + + BongoCatApp *parsed = calloc(1, sizeof(*parsed)); + CHECK(parsed != NULL); + if (parsed) { + char nearby_argument[BONGO_CAT_PATH_CAP + 20]; + snprintf(nearby_argument, sizeof(nearby_argument), + "--nearby-root=%s", temporary); + char *arguments[] = {"BongoCat", nearby_argument}; + BongoCatError argument_error = {0}; + CHECK(bongo_cat_startup_arguments(parsed, 2, arguments, + &argument_error)); + CHECK(strcmp(parsed->nearby_root, temporary) == 0); + free(parsed); + } + + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + char root[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char startup_source[BONGO_CAT_PATH_CAP], sync_source[BONGO_CAT_PATH_CAP]; + char background_source[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-nearby-root-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-nearby-refresh-data-%llu", + temporary, nonce); + CHECK(SDL_CreateDirectory(root)); + CHECK(SDL_CreateDirectory(data)); + char name[96]; + snprintf(name, sizeof(name), "startup-source-%llu", nonce); + CHECK(child(startup_source, sizeof(startup_source), root, name, false)); + snprintf(name, sizeof(name), "sync-source-%llu", nonce); + CHECK(child(sync_source, sizeof(sync_source), root, name, false)); + snprintf(name, sizeof(name), "background-source-%llu", nonce); + CHECK(child(background_source, sizeof(background_source), root, name, false)); + CHECK(mver_fixture(startup_source)); + + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) goto cleanup; + bongo_cat_settings_defaults(&app->settings); + bongo_cat_session_defaults(&app->session); + bongo_cat_models_init(&app->models); + snprintf(app->asset_root, sizeof(app->asset_root), + "%s/resources/assets", BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(app->models_root, sizeof(app->models_root), "%s", data); + snprintf(app->cache_root, sizeof(app->cache_root), "%s", data); + snprintf(app->nearby_root, sizeof(app->nearby_root), "%s", root); + + bongo_cat_app_rescan_models(app); + CHECK(model_displayed(app, bongo_cat_path_name(startup_source))); + CHECK(mver_fixture(sync_source)); + bongo_cat_app_refresh_nearby_models(app); + CHECK(model_displayed(app, bongo_cat_path_name(startup_source)) || + model_displayed(app, bongo_cat_path_name(sync_source))); + CHECK(mver_fixture(background_source)); + bongo_cat_app_request_nearby_model_refresh(app); + /* A synchronous catalog mutation invalidates the in-flight snapshot. The + refresh worker must discard it, rerun, and commit only the newer scan. */ + bongo_cat_app_refresh_installed_models(app); + uint64_t deadline = SDL_GetTicksNS() + 5000000000ull; + while (!model_displayed(app, bongo_cat_path_name(background_source)) && + SDL_GetTicksNS() < deadline) { + bongo_cat_model_refresh_update(app); + SDL_Delay(2); + } + CHECK(model_displayed(app, bongo_cat_path_name(background_source))); + bongo_cat_model_refresh_shutdown(app); + free(app); + +cleanup: + CHECK(bongo_cat_model_remove_tree(root, NULL)); + CHECK(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/model_import/test_mver_pointer_import.c b/tests/model_import/test_mver_pointer_import.c new file mode 100644 index 00000000..1e4d56c2 --- /dev/null +++ b/tests/model_import/test_mver_pointer_import.c @@ -0,0 +1,162 @@ +#include "model_import.h" +#include "model_import_mver.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "model_storage.h" +#include "preferences_internal.h" +#include "preferences_overlay.h" +#include "preferences_state.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include + +static bool overlay_input_self_test(void); +static bool overlay_chrome_self_test(void); +static bool ui_input_reset_self_test(void); + +const char *test_mver_pointer_config(bool live2d) { + const char *prefix = "{\"decoration\":{\"l2d_correct\":1.987," + "\"l2d_offset\":[0,-0.005],\"l2d_horizontal_flip\":true," + "\"window_size\":[1400,1400],\"offsetX\":[10,11]," + "\"offsetY\":[-10,-65],\"scalar\":[1,1]," + "\"armLineColor\":[1,2,3],\"hand_offset\":[4,5]," + "\"leftHanded\":true,\"mouse_force_move\":true," + "\"mouse_speed\":1.25},\"workarea\":{\"workarea\":true," + "\"top_left\":[100,200],\"right_bottom\":[2100,1400]},"; + const char *suffix = ",\"mouse\":false," + "\"hand_offset\":[4,5]," + "\"keyboard\":[[65]],\"hand\":[[65]]," + "\"l2d_expression\":[[65],[66]]}}"; + static char config[1024]; + SDL_snprintf(config, sizeof(config), "%s\"standard\":{\"l2d\":%s%s", + prefix, live2d ? "true" : "false", suffix); + return config; +} + +bool test_mver_pointer_fixture_assets(const char *standard, + const char *source) { + static const char *const names[] = { + "arm.png", "tablet.png", "tablet_left.png", "tablet_right.png" + }; + char path[BONGO_CAT_PATH_CAP]; + for (size_t index = 0; index < sizeof(names) / sizeof(names[0]); ++index) + if (!bongo_cat_path_join(path, sizeof(path), standard, names[index]) || + !SDL_CopyFile(source, path)) return false; + return true; +} + +bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { + if (!overlay_input_self_test() || !overlay_chrome_self_test() || + !ui_input_reset_self_test()) return false; + char path[BONGO_CAT_PATH_CAP]; + bool files = bongo_cat_path_join(path, sizeof(path), adapter, + "resources/mver-pointer/arm.png") && bongo_cat_path_is_file(path) && + bongo_cat_path_join(path, sizeof(path), adapter, + "resources/mver-pointer/tablet.png") && bongo_cat_path_is_file(path) && + bongo_cat_path_join(path, sizeof(path), adapter, + ".bongo-cat-adapter.json"); + yyjson_doc *metadata = files ? bongo_cat_json_read_file(path, 0, NULL) : NULL; + yyjson_val *pointer = metadata ? yyjson_obj_get( + yyjson_doc_get_root(metadata), "standardPointer") : NULL; + bool valid = yyjson_is_obj(pointer) && + yyjson_get_bool(yyjson_obj_get(pointer, "enabled")) == expected_enabled && + !yyjson_get_bool(yyjson_obj_get(pointer, "mouse")) && + yyjson_get_num(yyjson_obj_get(pointer, "offsetY")) == -65.0 && + yyjson_get_num(yyjson_obj_get(pointer, "handOffsetX")) == 4.0 && + yyjson_get_int(yyjson_obj_get(pointer, "lineBlue")) == 3; + yyjson_doc_free(metadata); + BongoCatLive2DRenderOptions render = {0}; + return valid && bongo_cat_import_render_options(adapter, &render) && + !render.auto_frame && + render.pointer_left_handed && render.mouse_force_move && + render.mouse_speed > 1.249f && render.mouse_speed < 1.251f; +} + +void test_mver_pointer_modes(void) { + char *current = SDL_GetCurrentDirectory(); + CHECK(current != NULL); + if (!current) return; + char root[BONGO_CAT_PATH_CAP], adapter[BONGO_CAT_PATH_CAP]; + char config_path[BONGO_CAT_PATH_CAP], config[2048]; + SDL_snprintf(root, sizeof(root), "%spointer-modes-%llu", current, + (unsigned long long)SDL_GetTicksNS()); + SDL_free(current); + CHECK(mver_fixture(root)); + CHECK(child(adapter, sizeof(adapter), root, "adapter", true)); + CHECK(child(config_path, sizeof(config_path), root, "config.json", false)); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + CHECK(bongo_cat_import_mver_discover(root, &discovery, &error) == 1); + CHECK(discovery.count == 1); + if (discovery.count == 1) { + const struct { int mode; bool live2d; bool enabled; } cases[] = { + {1, true, false}, {1, false, true}, {98, true, true} + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + SDL_snprintf(config, sizeof(config), "{\"mode\":%d,%s", + cases[i].mode, test_mver_pointer_config(cases[i].live2d) + 1); + CHECK(write_text(config_path, config)); + CHECK(bongo_cat_import_prepare_adapter(&discovery.candidates[0], + adapter, &error)); + CHECK(test_mver_pointer_adapter(adapter, cases[i].enabled)); + } + } + CHECK(bongo_cat_model_remove_tree(root, NULL)); +} + +static bool overlay_input_self_test(void) { + struct nk_context context = {0}; + struct nk_mouse_button *left = + &context.input.mouse.buttons[NK_BUTTON_LEFT]; + bool armed = false; + left->down = nk_true; left->clicked = 1; + if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || armed) + return false; + left->clicked = 0; + if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || armed) + return false; + left->down = nk_false; left->clicked = 1; + if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || !armed) + return false; + left->clicked = 2; + return bongo_cat_preferences_overlay_input_ready(&context, &armed); +} + +static bool overlay_chrome_self_test(void) { + BongoCatApp *app = SDL_calloc(1, sizeof(*app)); + BongoCatPreferences *preferences = SDL_calloc(1, sizeof(*preferences)); + bool passed = app && preferences; + if (passed) { + preferences->app = app; + passed = bongo_cat_preferences_chrome_drag_allowed(preferences); + preferences->behavior_dialog = true; + passed = passed && + !bongo_cat_preferences_chrome_drag_allowed(preferences); + } + SDL_free(preferences); + SDL_free(app); + return passed; +} + +static bool ui_input_reset_self_test(void) { + BongoCatUIBackend ui = {0}; + struct nk_mouse *mouse = &ui.context.input.mouse; + mouse->pos = nk_vec2(42.0f, 24.0f); + mouse->scroll_delta = nk_vec2(1.0f, -2.0f); + mouse->buttons[NK_BUTTON_LEFT].down = nk_true; + mouse->buttons[NK_BUTTON_LEFT].clicked = 2; + mouse->buttons[NK_BUTTON_RIGHT].down = nk_true; + mouse->buttons[NK_BUTTON_RIGHT].clicked = 1; + ui.last_left_click_ns = 99; + ui.double_click_down = true; + bongo_cat_ui_input_reset(&ui); + return mouse->pos.x == -1.0f && mouse->pos.y == -1.0f && + mouse->scroll_delta.x == 0.0f && mouse->scroll_delta.y == 0.0f && + !mouse->buttons[NK_BUTTON_LEFT].down && + !mouse->buttons[NK_BUTTON_LEFT].clicked && + !mouse->buttons[NK_BUTTON_RIGHT].down && + !mouse->buttons[NK_BUTTON_RIGHT].clicked && + !ui.last_left_click_ns && !ui.double_click_down; +} diff --git a/tests/model_import/test_mver_policy.c b/tests/model_import/test_mver_policy.c new file mode 100644 index 00000000..7211add0 --- /dev/null +++ b/tests/model_import/test_mver_policy.c @@ -0,0 +1,75 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_support.h" + +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +int test_mver_policy(void) { + failures = 0; + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return failures; + static const struct { + const char *name; + BongoCatModelMode mode; + } fixtures[] = { + {"standard", BONGO_CAT_MODE_STANDARD}, + {"keyboard", BONGO_CAT_MODE_KEYBOARD}, + {"gamepad", BONGO_CAT_MODE_GAMEPAD} + }; + for (size_t i = 0; i < sizeof(fixtures) / sizeof(fixtures[0]); ++i) { + BongoCatImportCandidate candidate = {0}; + snprintf(candidate.directory, sizeof(candidate.directory), + "%s/resources/assets/models/%s", BONGO_CAT_NATIVE_SOURCE_DIR, + fixtures[i].name); + snprintf(candidate.assets, sizeof(candidate.assets), "%s", + candidate.directory); + snprintf(candidate.package_root, sizeof(candidate.package_root), "%s", + candidate.directory); + snprintf(candidate.setting, sizeof(candidate.setting), + "cat.model3.json"); + candidate.mode = fixtures[i].mode; + candidate.format = BONGO_CAT_IMPORT_MVER; + char digest[65]; + bool placeholder = false; + BongoCatError error = {0}; + CHECK(bongo_cat_import_candidate_inspect(&candidate, digest, + &placeholder, &error) && placeholder); + } + + char source[BONGO_CAT_PATH_CAP], custom[BONGO_CAT_PATH_CAP]; + char texture[BONGO_CAT_PATH_CAP], replacement[BONGO_CAT_PATH_CAP]; + snprintf(source, sizeof(source), "%s/resources/assets/models/keyboard", + BONGO_CAT_NATIVE_SOURCE_DIR); + snprintf(custom, sizeof(custom), "%s/bongocat-retextured-stock-%llu", + temporary, (unsigned long long)SDL_GetTicksNS()); + BongoCatError error = {0}; + CHECK(bongo_cat_model_copy_directory(source, custom, &error) == BONGO_CAT_OK); + CHECK(child(texture, sizeof(texture), custom, + "demomodel2.1024/texture_00.png", false)); + snprintf(replacement, sizeof(replacement), + "%s/resources/assets/bongocat.png", BONGO_CAT_NATIVE_SOURCE_DIR); + CHECK(SDL_CopyFile(replacement, texture)); + BongoCatImportCandidate candidate = {0}; + snprintf(candidate.directory, sizeof(candidate.directory), "%s", custom); + snprintf(candidate.assets, sizeof(candidate.assets), "%s", custom); + snprintf(candidate.package_root, sizeof(candidate.package_root), "%s", + custom); + snprintf(candidate.setting, sizeof(candidate.setting), "cat.model3.json"); + candidate.mode = BONGO_CAT_MODE_KEYBOARD; + candidate.format = BONGO_CAT_IMPORT_MVER; + char digest[65]; + bool placeholder = true; + CHECK(bongo_cat_import_candidate_inspect(&candidate, digest, &placeholder, + &error) && !placeholder); + CHECK(bongo_cat_model_remove_tree(custom, NULL)); + SDL_free(temporary); + return failures; +} diff --git a/tests/model_import/test_mver_support.c b/tests/model_import/test_mver_support.c new file mode 100644 index 00000000..107816e6 --- /dev/null +++ b/tests/model_import/test_mver_support.c @@ -0,0 +1,54 @@ +#include "test_mver_support.h" + +#include "bongo_cat/common.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" + +#include +#include +#include + +const char *test_mver_pointer_config(bool live2d); +bool test_mver_pointer_fixture_assets(const char *standard, + const char *source); + +bool write_text(const char *path, const char *text) { + FILE *file = bongo_cat_file_open(path, "wb"); + bool ok = file && fwrite(text, 1, strlen(text), file) == strlen(text); + if (file && fclose(file) != 0) ok = false; + return ok; +} + +bool child(char *output, size_t capacity, const char *root, + const char *name, bool directory) { + return bongo_cat_path_join(output, capacity, root, name) && + (!directory || SDL_CreateDirectory(output)); +} + +bool mver_fixture(const char *root) { + char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; + char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; + if (!SDL_CreateDirectory(root) || + !child(image, sizeof(image), root, "img", true) || + !child(standard, sizeof(standard), image, "standard", true) || + !child(hand, sizeof(hand), standard, "hand", true) || + !child(model, sizeof(model), standard, "cat_model", true)) + return false; + if (!child(path, sizeof(path), root, "config.json", false) || + !write_text(path, test_mver_pointer_config(true))) return false; + if (!child(path, sizeof(path), model, "cat.model3.json", false) || + !write_text(path, "{\"Version\":3,\"FileReferences\":" + "{\"Moc\":\"cat.moc3\",\"Textures\":[\"texture.png\"]}}") || + !child(path, sizeof(path), model, "cat.moc3", false) || + !write_text(path, "MOC3")) return false; + snprintf(source, sizeof(source), "%s/resources/assets/bongocat.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + if (!child(path, sizeof(path), model, "texture.png", false) || + !SDL_CopyFile(source, path) || + !child(path, sizeof(path), hand, "0.png", false) || + !SDL_CopyFile(source, path) || + !child(path, sizeof(path), standard, "bg.png", false) || + !SDL_CopyFile(source, path)) return false; + return test_mver_pointer_fixture_assets(standard, source); +} diff --git a/tests/model_import/test_mver_support.h b/tests/model_import/test_mver_support.h new file mode 100644 index 00000000..015e6c4b --- /dev/null +++ b/tests/model_import/test_mver_support.h @@ -0,0 +1,12 @@ +#ifndef BONGO_CAT_TEST_MVER_SUPPORT_H +#define BONGO_CAT_TEST_MVER_SUPPORT_H + +#include +#include + +bool write_text(const char *path, const char *text); +bool child(char *output, size_t capacity, const char *root, + const char *name, bool directory); +bool mver_fixture(const char *root); + +#endif diff --git a/tests/model_import/test_slim_package.c b/tests/model_import/test_slim_package.c new file mode 100644 index 00000000..e2a69913 --- /dev/null +++ b/tests/model_import/test_slim_package.c @@ -0,0 +1,60 @@ +#include "model_import.h" +#include "model_storage.h" +#include "test_mver_support.h" +#include "bongo_cat/path.h" + +#include +#include + +const char *test_mver_pointer_config(bool live2d); + +int test_slim_package(void) { + int failures = 0; +#define CHECK_SLIM(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", \ + __FILE__, __LINE__, #value); failures++; \ +} } while (0) + char *temporary = SDL_GetCurrentDirectory(); + CHECK_SLIM(temporary != NULL); + if (!temporary) return failures; + char source[BONGO_CAT_PATH_CAP], data[BONGO_CAT_PATH_CAP]; + char config[BONGO_CAT_PATH_CAP], slim[BONGO_CAT_PATH_CAP]; + unsigned long long nonce = (unsigned long long)SDL_GetTicksNS(); + snprintf(source, sizeof(source), "%s/bongocat-slim-%llu", + temporary, nonce); + snprintf(data, sizeof(data), "%s/bongocat-slim-data-%llu", + temporary, nonce); + CHECK_SLIM(mver_fixture(source)); + CHECK_SLIM(SDL_CreateDirectory(data)); + CHECK_SLIM(child(config, sizeof(config), source, "config.json", false)); + CHECK_SLIM(child(slim, sizeof(slim), source, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK_SLIM(SDL_RenamePath(config, slim)); + char compact[1400]; + snprintf(compact, sizeof(compact), + "{\"schemaVersion\":1,\"kind\":\"bongocat-skin\"," + "\"sourceFormat\":\"bongo-cat-mver\",%s", + test_mver_pointer_config(true) + 1); + CHECK_SLIM(write_text(slim, compact)); + + BongoCatImportReceipt receipt = {0}; + BongoCatError error = {0}; + char models_root[BONGO_CAT_PATH_CAP]; + CHECK_SLIM(child(models_root, sizeof(models_root), data, "models", true)); + CHECK_SLIM(bongo_cat_import_install(source, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK_SLIM(receipt.count == 1 && receipt.installed_count == 1); + char installed[BONGO_CAT_PATH_CAP], models[BONGO_CAT_PATH_CAP]; + char package[BONGO_CAT_PATH_CAP]; + snprintf(models, sizeof(models), "%s", models_root); + CHECK_SLIM(bongo_cat_path_join(package, sizeof(package), models, + receipt.ids[0])); + CHECK_SLIM(child(installed, sizeof(installed), package, + BONGO_CAT_SKIN_CONFIG_FILE, false)); + CHECK_SLIM(bongo_cat_path_is_file(installed)); + CHECK_SLIM(bongo_cat_model_remove_tree(source, NULL)); + CHECK_SLIM(bongo_cat_model_remove_tree(data, NULL)); + SDL_free(temporary); +#undef CHECK_SLIM + return failures; +} diff --git a/tests/model_import/test_tauri_portable.c b/tests/model_import/test_tauri_portable.c new file mode 100644 index 00000000..90831cbe --- /dev/null +++ b/tests/model_import/test_tauri_portable.c @@ -0,0 +1,346 @@ +#include "model_import.h" +#include "model_import_mver_internal.h" +#include "tauri/model_import_tauri.h" +#include "tauri/model_import_tauri_internal.h" +#include "model_storage.h" +#include "runtime.h" +#include "test_mver_import_internal.h" +#include "test_mver_support.h" +#include "bongo_cat/image.h" +#include "bongo_cat/file.h" +#include "bongo_cat/json.h" +#include "bongo_cat/path.h" + +#include +#include +#include +#include +#include +#include + +static void write_canvas_bytes(void *context, void *data, int size) { + CHECK(fwrite(data, 1, (size_t)size, context) == (size_t)size); +} + +static bool write_input_canvas(const char *path, int width, int height) { + unsigned char *pixels = calloc((size_t)width * height, 4); + FILE *file = pixels ? bongo_cat_file_open(path, "wb") : NULL; + bool ok = file && stbi_write_png_to_func(write_canvas_bytes, file, + width, height, 4, pixels, width * 4); + if (file && fclose(file) != 0) ok = false; + free(pixels); + return ok; +} + +static void check_portable_images(const char *root, + const char *const *relative, size_t count) { + char path[BONGO_CAT_PATH_CAP]; + for (size_t i = 0; i < count; ++i) { + CHECK(child(path, sizeof(path), root, relative[i], false)); + CHECK(bongo_cat_path_is_file(path)); + CHECK(bongo_cat_image_info(path, NULL, NULL)); + } +} + +void test_tauri_portable(void) { + char *temporary = SDL_GetCurrentDirectory(); + CHECK(temporary != NULL); + if (!temporary) return; + char root[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; + char path[BONGO_CAT_PATH_CAP]; + snprintf(root, sizeof(root), "%s/bongocat-tauri-%llu", temporary, + (unsigned long long)SDL_GetTicksNS()); + CHECK(SDL_CreateDirectory(root)); + CHECK(child(model, sizeof(model), root, "keyboard", true)); + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"cat.moc3\",\"Textures\":[" + "\"resources/model-texture.png\"]}}")); + CHECK(child(path, sizeof(path), model, "cat.moc3", false)); + CHECK(write_text(path, "MOC3")); + char tray[BONGO_CAT_PATH_CAP]; + snprintf(tray, sizeof(tray), "%s/resources/assets/bongocat.png", + BONGO_CAT_NATIVE_SOURCE_DIR); + char resources[BONGO_CAT_PATH_CAP], left_keys[BONGO_CAT_PATH_CAP]; + char right_keys[BONGO_CAT_PATH_CAP], east[BONGO_CAT_PATH_CAP]; + char numeric[BONGO_CAT_PATH_CAP], model_texture[BONGO_CAT_PATH_CAP]; + CHECK(child(resources, sizeof(resources), model, "resources", true)); + CHECK(child(model_texture, sizeof(model_texture), resources, + "model-texture.png", false)); + CHECK(SDL_CopyFile(tray, model_texture)); + CHECK(child(path, sizeof(path), resources, "background.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(path, sizeof(path), resources, "cover.png", false)); + CHECK(SDL_CopyFile(tray, path)); + CHECK(child(left_keys, sizeof(left_keys), resources, "left-keys", true)); + CHECK(child(numeric, sizeof(numeric), left_keys, "24.png", false)); + CHECK(SDL_CopyFile(tray, numeric)); + CHECK(child(right_keys, sizeof(right_keys), resources, "right-keys", true)); + CHECK(child(east, sizeof(east), right_keys, "East.png", false)); + CHECK(SDL_CopyFile(tray, east)); + char nested[BONGO_CAT_PATH_CAP], nested_resources[BONGO_CAT_PATH_CAP]; + CHECK(child(nested, sizeof(nested), model, "nested", true)); + CHECK(child(nested_resources, sizeof(nested_resources), nested, + "resources", true)); + CHECK(child(path, sizeof(path), nested_resources, "keep.bin", false)); + CHECK(write_text(path, "keep")); + BongoCatImportDiscovery discovery = {0}; + BongoCatError error = {0}; + /* A referenced Moc may live below the manifest directory. */ + CHECK(child(path, sizeof(path), model, "cat.model3.json", false)); + CHECK(write_text(path, "{\"Version\":3,\"FileReferences\":{" + "\"Moc\":\"nested/CAT.MOC3\",\"Textures\":[" + "\"resources/model-texture.png\"]}}")); + CHECK(child(path, sizeof(path), nested, "CAT.MOC3", false)); + CHECK(write_text(path, "MOC3")); + CHECK(bongo_cat_import_tauri_discover_exact(model, &discovery, &error) == 1); + CHECK(discovery.count == 1 && + discovery.candidates[0].format == BONGO_CAT_IMPORT_TAURI && + discovery.candidates[0].mode == BONGO_CAT_MODE_GAMEPAD && + discovery.candidates[0].gamepad_buttons); + CHECK(bongo_cat_path_remove(east)); + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(SDL_CopyFile(tray, path)); + BongoCatImportDiscovery keyboard = {0}; + CHECK(bongo_cat_import_tauri_discover_exact(model, &keyboard, &error) == 1); + CHECK(keyboard.count == 1 && + keyboard.candidates[0].mode == BONGO_CAT_MODE_KEYBOARD && + !keyboard.candidates[0].gamepad_buttons); + char models_root[BONGO_CAT_PATH_CAP]; + CHECK(child(models_root, sizeof(models_root), root, "models", true)); + BongoCatImportReceipt receipt = {0}; + CHECK(child(path, sizeof(path), nested, "CAT.MOC3", false)); + CHECK(bongo_cat_import_install(path, models_root, &receipt, &error) == + BONGO_CAT_OK); + CHECK(receipt.count == 1 && receipt.installed_count == 1 && + strcmp(receipt.ids[0], "keyboard") == 0); + BongoCatImportReceipt folder_receipt = {0}; + CHECK(bongo_cat_import_install(model, models_root, &folder_receipt, + &error) == BONGO_CAT_OK); + CHECK(folder_receipt.count == receipt.count && + folder_receipt.installed_count == 0 && + strcmp(folder_receipt.ids[0], receipt.ids[0]) == 0); + BongoCatImportSession *session = bongo_cat_import_session_create( + models_root, &error); + BongoCatImportBatchStats stats = {0}; + CHECK(session != NULL); + CHECK(session && bongo_cat_import_session_install_progressive(session, + path, NULL, NULL, &stats, &error) == BONGO_CAT_OK); + CHECK(stats.succeeded_count == 1 && stats.failed_count == 0); + bongo_cat_import_session_destroy(session); + char package[BONGO_CAT_PATH_CAP]; + CHECK(child(package, sizeof(package), models_root, receipt.ids[0], false)); + CHECK(child(path, sizeof(path), package, "config.json", false) && + bongo_cat_path_is_file(path)); + yyjson_doc *normalized = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *normalized_root = normalized + ? yyjson_doc_get_root(normalized) : NULL; + yyjson_val *normalized_mode = yyjson_obj_get(normalized_root, "keyboard"); + yyjson_val *normalized_left = yyjson_obj_get(normalized_mode, "lefthand"); + yyjson_val *window_size = yyjson_obj_get( + yyjson_obj_get(normalized_root, "decoration"), "window_size"); + yyjson_val *l2d_correct = yyjson_obj_get( + yyjson_obj_get(normalized_root, "decoration"), "l2d_correct"); + int expected_width = 0, expected_height = 0; + CHECK(bongo_cat_image_info(tray, &expected_width, &expected_height)); + CHECK(yyjson_get_int(yyjson_obj_get(normalized_root, "mode")) == 2 && + yyjson_is_arr(normalized_left) && + yyjson_is_arr(yyjson_obj_get(normalized_mode, "righthand")) && + yyjson_get_int(yyjson_arr_get(yyjson_arr_get(normalized_left, 0), 0)) == + 24 && + yyjson_get_int(yyjson_arr_get(window_size, 0)) == expected_width && + yyjson_get_int(yyjson_arr_get(window_size, 1)) == expected_height && + yyjson_is_num(l2d_correct) && yyjson_get_num(l2d_correct) == 2.0); + yyjson_doc_free(normalized); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/cat.model3.json", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/model-texture.png", false) && + bongo_cat_path_is_file(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/resources/right-keys", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, + "img/keyboard/cat_model/nested/resources/keep.bin", false) && + bongo_cat_path_is_file(path)); + static const char *const keyboard_runtime[] = { + "img/keyboard/lefthand/leftup.png", + "img/keyboard/righthand/rightup.png" + }; + check_portable_images(package, keyboard_runtime, + sizeof(keyboard_runtime) / sizeof(keyboard_runtime[0])); + CHECK(child(path, sizeof(path), package, ".bongo-cat-adapter", false) && + !bongo_cat_path_is_dir(path)); + CHECK(child(path, sizeof(path), package, ".bongo-cat-package.json", false) && + !bongo_cat_path_is_file(path)); + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (app) { + snprintf(app->models_root, sizeof(app->models_root), "%s", models_root); + CHECK(child(app->cache_root, sizeof(app->cache_root), root, "cache", + true)); + bongo_cat_models_init(&app->models); + CHECK(bongo_cat_import_installed_models(app, models_root, &error) == + BONGO_CAT_OK); + CHECK(app->models.count == 1 && + app->models.entries[0].source_format == BONGO_CAT_MODEL_SOURCE_MVER && + strcmp(app->models.entries[0].storage_directory, package) == 0 && + strcmp(app->models.entries[0].adapter_directory, package) != 0); + free(app); + } + BongoCatImportCandidate portable = keyboard.candidates[0], installed = {0}; + char portable_root[BONGO_CAT_PATH_CAP]; + portable.mode = BONGO_CAT_MODE_STANDARD; + portable.gamepad_buttons = false; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-standard", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const standard_runtime[] = { + "img/standard/arm.png", "img/standard/up.png", + "img/standard/mousebg.png", "img/standard/l2dmousebg.png", + "img/standard/mouse.png", "img/standard/mouse_left.png", + "img/standard/mouse_right.png", "img/standard/mouse_side.png", + "img/standard/tabletbg.png", "img/standard/l2dtabletbg.png", + "img/standard/tablet.png", "img/standard/tablet_left.png", + "img/standard/tablet_right.png" + }; + check_portable_images(portable_root, standard_runtime, + sizeof(standard_runtime) / sizeof(standard_runtime[0])); + CHECK(child(path, sizeof(path), portable_root, "config.json", false)); + yyjson_doc *standard_config = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *standard_root = standard_config + ? yyjson_doc_get_root(standard_config) : NULL; + yyjson_val *standard_decoration = yyjson_obj_get(standard_root, + "decoration"); + yyjson_val *standard_l2d_correct = yyjson_obj_get(standard_decoration, + "l2d_correct"); + CHECK(yyjson_get_int(yyjson_obj_get(standard_root, "mode")) == 1 && + yyjson_is_arr(yyjson_obj_get(yyjson_obj_get(standard_root, + "standard"), "hand")) && yyjson_is_num(standard_l2d_correct) && + yyjson_get_num(standard_l2d_correct) == 2.0); + yyjson_doc_free(standard_config); + CHECK(child(path, sizeof(path), portable_root, "img/standard/bg.png", + false) && !bongo_cat_path_is_file(path)); + char adapter_root[BONGO_CAT_PATH_CAP]; + CHECK(child(adapter_root, sizeof(adapter_root), root, + "portable-standard-adapter", true)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); + CHECK(child(path, sizeof(path), adapter_root, + BONGO_CAT_MODEL_ADAPTER_FILE, false)); + yyjson_doc *adapter = bongo_cat_json_read_file(path, 0, NULL); + yyjson_val *pointer = adapter ? yyjson_obj_get( + yyjson_doc_get_root(adapter), "standardPointer") : NULL; + CHECK(yyjson_is_obj(pointer) && + !yyjson_get_bool(yyjson_obj_get(pointer, "enabled"))); + yyjson_doc_free(adapter); + BongoCatLive2DRenderOptions render = {0}; + CHECK(bongo_cat_import_render_options(adapter_root, &render) && + render.mver_projection && render.auto_frame); + CHECK(child(path, sizeof(path), adapter_root, + "resources/mver-pointer/arm.png", false) && + !bongo_cat_path_is_file(path)); + portable.mode = BONGO_CAT_MODE_GAMEPAD; + portable.gamepad_buttons = true; + installed = (BongoCatImportCandidate){0}; + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-gamepad", false)); + error = (BongoCatError){0}; + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + static const char *const gamepad_runtime[] = { + "img/gamepad/arm_L.png", "img/gamepad/arm_R.png", + "img/gamepad/left_stick.png", "img/gamepad/left_stick_down.png", + "img/gamepad/right_stick.png", "img/gamepad/right_stick_down.png", + "img/gamepad/lefthand/leftup.png", + "img/gamepad/righthand/rightup.png" + }; + check_portable_images(portable_root, gamepad_runtime, + sizeof(gamepad_runtime) / sizeof(gamepad_runtime[0])); + /* Legacy backgrounds can disagree with the retained input canvas. */ + portable.mode = BONGO_CAT_MODE_STANDARD; + portable.gamepad_buttons = false; + CHECK(write_input_canvas(numeric, 500, 405)); + TauriMverCalibration calibration = {0}; + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(write_input_canvas(path, 500, 405)); + CHECK(child(path, sizeof(path), left_keys, "instructions.png", false)); + CHECK(write_input_canvas(path, 800, 600)); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.window_width == 500 && calibration.window_height == 405); + CHECK(bongo_cat_tauri_key_code("F1.png") == 112 && + bongo_cat_tauri_key_code("F12.png") == 123 && + bongo_cat_tauri_key_code("F1x.png") == -1); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.window_height == 405 && + calibration.l2d_correct > 1.619 && calibration.l2d_correct < 1.621); + /* Disagreeing image canvases do not supply a reliable replacement. */ + CHECK(child(path, sizeof(path), right_keys, "KeyA.png", false)); + CHECK(write_input_canvas(path, 600, 405)); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.window_width == expected_width && + calibration.window_height == expected_height); + CHECK(write_input_canvas(path, 500, 405)); + /* A real source calibration must override the recovered projection. */ + CHECK(child(path, sizeof(path), model, BONGO_CAT_TAURI_SOURCE_FILE, false)); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"window_size\":[500,405],\"l2d_correct\":1.6," + "\"l2d_offset\":[0,-0.005]}}")); + CHECK(child(portable_root, sizeof(portable_root), root, + "portable-calibrated", false)); + CHECK(bongo_cat_import_tauri_convert_to_mver(&portable, portable_root, + &installed, &error)); + CHECK(child(adapter_root, sizeof(adapter_root), root, + "portable-calibrated-adapter", true)); + CHECK(bongo_cat_import_prepare_adapter(&installed, adapter_root, &error)); + CHECK(bongo_cat_import_render_options(adapter_root, &render) && + render.mver_projection && !render.auto_frame && + render.reference_width == 500 && render.reference_height == 405 && + render.projection_scale > 1.599f && render.projection_scale < 1.601f && + render.offset_y > -0.0051f && render.offset_y < -0.0049f); + CHECK(child(path, sizeof(path), model, BONGO_CAT_TAURI_SOURCE_FILE, false)); + CHECK(write_text(path, "{\"schemaVersion\":0}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"l2d_correct\":1.6}}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.window_height == 405 && calibration.l2d_correct == 1.6); + CHECK(write_text(path, "{\"schemaVersion\":1," + "\"kind\":\"bongo-cat-mver-source\",\"mode\":\"standard\"," + "\"decoration\":{\"window_size\":[500.5,405],\"l2d_correct\":-1}}")); + CHECK(bongo_cat_tauri_read_calibration(&portable, &calibration) && + calibration.auto_frame && calibration.window_width == 500 && + calibration.l2d_correct > 1.619 && calibration.l2d_correct < 1.621); + /* Resources can belong to a package root above the model directory. */ + CHECK(bongo_cat_path_remove(path)); + BongoCatImportCandidate package_resources = portable; + CHECK(child(package_resources.directory, sizeof(package_resources.directory), + root, "separate-model", true)); + snprintf(package_resources.assets, sizeof(package_resources.assets), "%s", + package_resources.directory); + snprintf(package_resources.package_root, sizeof(package_resources.package_root), + "%s", model); + CHECK(child(path, sizeof(path), root, "separate-inputs", true)); + TauriKeyFiles *copied_left = calloc(1, sizeof(*copied_left)); + TauriKeyFiles *copied_right = calloc(1, sizeof(*copied_right)); + CHECK(copied_left && copied_right); + if (copied_left && copied_right) { + CHECK(bongo_cat_tauri_copy_input_images(&package_resources, path, + copied_left, copied_right, &error)); + CHECK(copied_left->count == 1 && copied_left->values[0].code == 24); + CHECK(bongo_cat_tauri_read_calibration(&package_resources, &calibration) && + calibration.window_width == 500 && calibration.window_height == 405); + } + free(copied_left); + free(copied_right); + CHECK(bongo_cat_model_remove_tree(root, NULL)); + SDL_free(temporary); +} diff --git a/tests/platform/test_linux_evdev.c b/tests/platform/test_linux_evdev.c new file mode 100644 index 00000000..50f1685d --- /dev/null +++ b/tests/platform/test_linux_evdev.c @@ -0,0 +1,173 @@ +#include "linux_evdev_internal.h" +#include "test.h" +#include +#include +#include +#include +#include + +int bongo_cat_test_failures; +static BongoCatInputState input; +static LinuxPlatformState native; +static BongoCatPlatform platform = {.input = &input, .native = &native}; +static LinuxEvdevState state; + +static void send_event(EvdevDevice *device, unsigned type, unsigned code, int value) { + struct input_event event = {.type = type, .code = code, .value = value}; + bongo_cat_evdev_event(&state, device, &event); +} + +static void expect(BongoCatInputKind kind, const char *name) { + BongoCatInputEvent event = {0}; + CHECK(bongo_cat_input_pop(&input, &event)); + CHECK(event.kind == kind); + CHECK(strcmp(event.name, name) == 0); +} + +static void expect_empty(void) { + BongoCatInputEvent event; + CHECK(!bongo_cat_input_pop(&input, &event)); +} + +static void test_policy_and_masks(void) { + CHECK(!bongo_cat_linux_evdev_requested(NULL, true)); + CHECK(!bongo_cat_linux_evdev_requested("", true)); + CHECK(!bongo_cat_linux_evdev_requested("0", true)); + CHECK(!bongo_cat_linux_evdev_requested("true", true)); + CHECK(!bongo_cat_linux_evdev_requested("1", false)); + CHECK(bongo_cat_linux_evdev_requested("1", true)); + BongoCatError error = {0}; + CHECK(!bongo_cat_linux_evdev_start(&platform, &error)); + CHECK(native.evdev == NULL); + CHECK(!bongo_cat_linux_evdev_pointer_active(&platform)); + CHECK(bongo_cat_evdev_node_valid("event0")); + CHECK(bongo_cat_evdev_node_valid("event123")); + CHECK(!bongo_cat_evdev_node_valid(NULL)); + CHECK(!bongo_cat_evdev_node_valid("event")); + CHECK(!bongo_cat_evdev_node_valid("event1/../event2")); + CHECK(!bongo_cat_evdev_node_valid("event1x")); + CHECK(!bongo_cat_evdev_node_valid("event12345678901234567890123456789")); + CHECK(bongo_cat_evdev_mask_bit("1 0", 64, 64)); + CHECK(!bongo_cat_evdev_mask_bit("1 0", 64, 0)); + CHECK(bongo_cat_evdev_mask_bit("1 0 0", 32, 64)); + CHECK(bongo_cat_evdev_mask_bit("8000000000000000\n", 64, 63)); + CHECK(bongo_cat_evdev_mask_bit("80000000", 32, 31)); + CHECK(!bongo_cat_evdev_mask_bit("100000000", 32, 32)); + CHECK(!bongo_cat_evdev_mask_bit("-1", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("1x", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("10000000000000000", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("", 64, 0)); + CHECK(!bongo_cat_evdev_mask_bit("1", 64, KEY_CNT)); +} + +static void test_multiple_devices(void) { + EvdevDevice a = {.keyboard = true}, b = {.keyboard = true}; + send_event(&a, EV_KEY, KEY_A, 1); + send_event(&a, EV_KEY, KEY_A, 1); + send_event(&a, EV_KEY, KEY_A, 2); + send_event(&b, EV_KEY, KEY_A, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + bongo_cat_evdev_release(&state, &a); + expect_empty(); + bongo_cat_evdev_release(&state, &b); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(); + send_event(&a, EV_KEY, KEY_LEFTMETA, 1); + send_event(&a, EV_KEY, KEY_RIGHTMETA, 1); + send_event(&a, EV_KEY, KEY_LEFTMETA, 0); + expect(BONGO_CAT_INPUT_KEY_DOWN, "Meta"); + expect_empty(); + send_event(&a, EV_KEY, KEY_RIGHTMETA, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "Meta"); + send_event(&a, EV_KEY, KEY_ENTER, 1); + send_event(&b, EV_KEY, KEY_KPENTER, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "Return"); + bongo_cat_evdev_release(&state, &a); + expect_empty(); + bongo_cat_evdev_release(&state, &b); + expect(BONGO_CAT_INPUT_KEY_UP, "Return"); + send_event(&a, EV_KEY, KEY_CNT, 1); + send_event(&a, EV_KEY, KEY_A, -1); + expect_empty(); +} + +static void test_overflow_and_motion(void) { + EvdevDevice device = {.keyboard = true, .pointer = true, .relative = true}; + send_event(&device, EV_KEY, KEY_LEFTCTRL, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + CHECK(bongo_cat_input_control_down(&input)); + send_event(&device, EV_SYN, SYN_DROPPED, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); + CHECK(!bongo_cat_input_control_down(&input)); + send_event(&device, EV_KEY, KEY_B, 1); + send_event(&device, EV_REL, REL_X, 10); + expect_empty(); + CHECK(state.relative_x == 0); + send_event(&device, EV_SYN, SYN_REPORT, 0); + send_event(&device, EV_KEY, KEY_B, 1); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyB"); + bongo_cat_evdev_release(&state, &device); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyB"); + send_event(&device, EV_KEY, BTN_SIDE, 1); + send_event(&device, EV_KEY, BTN_SIDE, 0); + expect(BONGO_CAT_INPUT_MOUSE_DOWN, "Back"); + expect(BONGO_CAT_INPUT_MOUSE_UP, "Back"); + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + send_event(&device, EV_REL, REL_X, 12); + send_event(&device, EV_REL, REL_Y, -7); + CHECK(SDL_HasEvent(platform.wake_event_type)); + native.evdev = &state; + double x = 0, y = 0; + CHECK(bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + CHECK(x == 12 && y == -7); + CHECK(!bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + send_event(&device, EV_REL, REL_X, INT_MAX); + send_event(&device, EV_REL, REL_X, INT_MAX); + CHECK(bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + CHECK(x == 32768 && y == 0); + device.relative = false; + send_event(&device, EV_REL, REL_X, 10); + CHECK(!bongo_cat_linux_evdev_relative_pointer(&platform, &x, &y)); + native.evdev = NULL; +} + +static void test_read_and_disconnect(void) { + int pipes[2]; + CHECK(pipe(pipes) == 0); + CHECK(fcntl(pipes[0], F_SETFL, O_NONBLOCK) == 0); + state.epoll_fd = epoll_create1(EPOLL_CLOEXEC); + CHECK(state.epoll_fd >= 0); + state.device_count = 1; + EvdevDevice *device = &state.devices[0]; + *device = (EvdevDevice){.fd = pipes[0], .keyboard = true, .relative = true}; + CHECK(bongo_cat_evdev_read(&state, device)); + struct input_event event = {.type = EV_KEY, .code = KEY_A, .value = 1}; + CHECK(write(pipes[1], &event, sizeof(event)) == sizeof(event)); + CHECK(bongo_cat_evdev_read(&state, device)); + expect(BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + close(pipes[1]); + CHECK(!bongo_cat_evdev_read(&state, device)); + bongo_cat_evdev_remove(&state, 0); + expect(BONGO_CAT_INPUT_KEY_UP, "KeyA"); + CHECK(state.device_count == 0); + CHECK(!atomic_load(&state.pointer_active)); + close(state.epoll_fd); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_EVENTS)); + bongo_cat_input_init(&input); + platform.wake_event_type = SDL_RegisterEvents(1); + state.platform = &platform; + state.motion_lock = SDL_CreateMutex(); + CHECK(state.motion_lock != NULL); + atomic_init(&state.pointer_active, false); + test_policy_and_masks(); + test_multiple_devices(); + test_overflow_and_motion(); + test_read_and_disconnect(); + expect_empty(); + SDL_DestroyMutex(state.motion_lock); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_linux_window.c b/tests/platform/test_linux_window.c new file mode 100644 index 00000000..e933c963 --- /dev/null +++ b/tests/platform/test_linux_window.c @@ -0,0 +1,53 @@ +#include "runtime.h" +#include "test.h" +#include + +int bongo_cat_test_failures; + +int main(int argc, char **argv) { + BongoCatApp *app = calloc(1, sizeof(*app)); + BongoCatError error = {0}; + if (!app) return 1; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + fprintf(stderr, "Initialization failed: %s\n", error.message); + bongo_cat_app_shutdown(app, "test:failed", 1); + free(app); + return 1; + } + app->startup_visibility_pending = false; + app->settings.window.keep_in_screen = true; + app->settings.window.always_on_top = true; + bongo_cat_platform_set_always_on_top(&app->platform, true); + bongo_cat_window_set_visible(app, true); + SDL_SyncWindow(app->window); + int x, y; + CHECK(SDL_GetWindowPosition(app->window, &x, &y)); + bongo_cat_window_set_visible(app, false); + CHECK(app->session.window.position_known); + CHECK(app->session.window.x == x && app->session.window.y == y); + CHECK(SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN); + SDL_Rect bounds = {0}; + CHECK(SDL_GetDisplayUsableBounds(SDL_GetDisplayForWindow(app->window), &bounds)); + app->session.window.x = bounds.x - 10000; + app->session.window.y = bounds.y - 10000; + bongo_cat_window_set_visible(app, true); + SDL_SyncWindow(app->window); + CHECK(SDL_GetWindowPosition(app->window, &x, &y)); + CHECK(x >= bounds.x && y >= bounds.y); + CHECK(!(SDL_GetWindowFlags(app->window) & SDL_WINDOW_HIDDEN)); + CHECK(SDL_GetWindowFlags(app->window) & SDL_WINDOW_ALWAYS_ON_TOP); + SDL_Event exposed = {.type = SDL_EVENT_WINDOW_EXPOSED}; + exposed.window.windowID = SDL_GetWindowID(app->window); + app->window_minimized = true; + CHECK(bongo_cat_window_event(app, &exposed)); + CHECK(app->window_minimized == + ((SDL_GetWindowFlags(app->window) & SDL_WINDOW_MINIMIZED) != 0)); + for (int i = 0; i < 3; ++i) { + bongo_cat_window_set_visible(app, false); + bongo_cat_window_set_visible(app, true); + } + CHECK(!app->startup_visibility_pending && app->session.window.visible); + bongo_cat_app_shutdown(app, "test:complete", bongo_cat_test_failures ? 1 : 0); + free(app); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_macos_click_through.m b/tests/platform/test_macos_click_through.m new file mode 100644 index 00000000..8711e006 --- /dev/null +++ b/tests/platform/test_macos_click_through.m @@ -0,0 +1,207 @@ +// Regression check for macOS pass-through: once the platform is told the pet window must ignore the +// mouse, SDL's own mouse-motion handling must not take that back. SDL derives +// NSWindow.ignoresMouseEvents from the window shape on every mouse move, so a click-through window +// has to describe itself to SDL through that shape as well; otherwise the next move makes the pet +// clickable again and the window server keeps routing clicks to it. +// +// It drives the real platform implementation over a real SDL window created like the pet window and +// asserts the user-visible outcome: the window server's own hit test at the window centre. The move +// event is posted to this process only (no event tap, no other application, no cursor movement). +// +// Needs a window server and the Cocoa driver. Only when those are unavailable does it exit 77, which +// ctest reports as a skip (SKIP_RETURN_CODE in cmake/Tests.cmake); a window that cannot be created or +// that is not a Cocoa window fails instead. + +#import +#include +#include +#include "bongo_cat/platform.h" + +#include +#include +#include + +enum { CHECK_SKIP = 77, PET_SIZE = 280 }; + +static int failures; + +static void expect(int condition, const char *what) { + printf("%s %s\n", condition ? "ok " : "FAIL", what); + if (!condition) failures++; +} + +static void skip(const char *reason) { + printf("SKIP %s\n", reason); + exit(CHECK_SKIP); +} + +static NSWindow *cocoa_window(SDL_Window *window) { + return (__bridge NSWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window), + SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL); +} + +// Window number of the topmost window at a screen point, i.e. the window the window server would +// hand the mouse to there. +static NSInteger mouse_target_at(NSPoint point) { + return [NSWindow windowNumberAtPoint:point belowWindowWithWindowNumber:0]; +} + +static void pump_runloop(double seconds) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:seconds]]; +} + +static void pump_sdl(int milliseconds) { + uint64_t deadline = SDL_GetTicksNS() + (uint64_t)milliseconds * 1000000ull; + SDL_Event event; + while (SDL_GetTicksNS() < deadline) { + if (!SDL_WaitEventTimeout(&event, 10)) continue; + while (SDL_PollEvent(&event)) { } + } +} + +// A window move AppKit would deliver to this window while the pointer is over it. +static void post_mouse_move(NSWindow *window) { + NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeMouseMoved + location:NSMakePoint(PET_SIZE / 2, PET_SIZE / 2) modifierFlags:0 + timestamp:[[NSProcessInfo processInfo] systemUptime] + windowNumber:window.windowNumber context:nil eventNumber:0 clickCount:0 pressure:0]; + [NSApp postEvent:event atStart:NO]; + pump_sdl(300); + pump_runloop(0.3); +} + +// Before anything is concluded about the pet window, confirm the hit test reflects the flag here, +// with two throwaway plain windows of the same shape. +static void require_working_instrument(void) { + NSRect frame = NSMakeRect(140, NSScreen.screens.firstObject.frame.size.height - 70, 80, 80); + NSWindow *under = [[NSWindow alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + NSWindow *upper = [[NSWindow alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + for (NSWindow *window in @[under, upper]) { + window.opaque = NO; + window.backgroundColor = [NSColor colorWithCalibratedWhite:0 alpha:0.01]; + } + upper.level = NSFloatingWindowLevel; + [under orderFront:nil]; + [upper orderFront:nil]; + pump_runloop(0.4); + NSPoint point = NSMakePoint(NSMidX(upper.frame), NSMidY(upper.frame)); + if (mouse_target_at(point) != upper.windowNumber) { + [upper orderOut:nil]; [under orderOut:nil]; + skip("the hit test query does not report our own floating window here"); + } + upper.ignoresMouseEvents = YES; + pump_runloop(0.2); + NSInteger skipped = mouse_target_at(point); + [upper orderOut:nil]; [under orderOut:nil]; + pump_runloop(0.2); + if (skipped == upper.windowNumber) skip("the hit test query cannot observe ignoresMouseEvents here"); +} + +// A missing window or a missing Cocoa window is a regression of what this check covers, not an +// environment limitation: those are only excused when SDL cannot use the Cocoa driver at all. +static void fail(const char *what) { + expect(0, what); + printf("click-through check failed\n"); + exit(1); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + [NSApplication.sharedApplication setActivationPolicy:NSApplicationActivationPolicyAccessory]; + require_working_instrument(); + + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); + SDL_SetHint(SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, "0"); // a check must not steal focus + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { + skip(SDL_GetError()); + } + const char *driver = SDL_GetCurrentVideoDriver(); + if (!driver || strcmp(driver, "cocoa") != 0) { + char reason[96]; + snprintf(reason, sizeof(reason), "SDL video driver is %s, not cocoa", driver ? driver : "absent"); + skip(reason); + } + SDL_Window *window = SDL_CreateWindow("bongo-cat-click-through-check", PET_SIZE, PET_SIZE, + SDL_WINDOW_OPENGL | SDL_WINDOW_BORDERLESS | SDL_WINDOW_HIGH_PIXEL_DENSITY | + SDL_WINDOW_HIDDEN | SDL_WINDOW_TRANSPARENT); + if (!window) fail("SDL creates a transparent borderless window like the pet window"); + SDL_SetWindowPosition(window, 40, 40); + SDL_GLContext context = SDL_GL_CreateContext(window); + if (context) { + if (!SDL_GL_MakeCurrent(window, context)) printf("note: no current GL context: %s\n", + SDL_GetError()); + } else { + printf("note: no GL context (%s); the window is still the one the pet uses\n", SDL_GetError()); + } + + NSWindow *native = cocoa_window(window); + if (!native) fail("SDL hands the platform a Cocoa window"); + NSPoint centre = NSMakePoint(NSMidX(native.frame), NSMidY(native.frame)); + BongoCatPlatform platform; + memset(&platform, 0, sizeof(platform)); + platform.window = window; + + // bongo_cat_window_apply() order: the pet is configured while hidden, then revealed. + bongo_cat_platform_set_visible(&platform, false); + bongo_cat_platform_set_click_through(&platform, true, false); + bongo_cat_platform_set_always_on_top(&platform, true); + bongo_cat_platform_set_visible(&platform, true); + pump_runloop(0.5); + expect(native.ignoresMouseEvents, "pass-through reaches the native window"); + expect(mouse_target_at(centre) != native.windowNumber, + "the window server passes the mouse through the pet before any motion"); + + post_mouse_move(native); + expect(native.ignoresMouseEvents, "pass-through survives SDL mouse-motion handling"); + expect(mouse_target_at(centre) != native.windowNumber, + "the window server still passes the mouse through the pet after motion"); + + // Dynamic transparency (transparent pixel under the pointer) uses the same platform call. + bongo_cat_platform_set_click_through(&platform, false, true); + pump_runloop(0.2); + expect(native.ignoresMouseEvents, "dynamic transparency reaches the native window"); + post_mouse_move(native); + expect(native.ignoresMouseEvents, "dynamic transparency survives SDL mouse-motion handling"); + + // Turning pass-through off must give the pet its window interaction back, and it must stay that + // way: SDL rewrites ignoresMouseEvents from the window shape on the next mouse move. + bongo_cat_platform_set_click_through(&platform, false, false); + pump_runloop(0.2); + expect(!native.ignoresMouseEvents, "pass-through off reaches the native window"); + expect(mouse_target_at(centre) == native.windowNumber, + "the window server targets the pet again once pass-through is off"); + post_mouse_move(native); + expect(!native.ignoresMouseEvents, "pass-through off survives SDL mouse-motion handling"); + expect(mouse_target_at(centre) == native.windowNumber, + "the window server still targets the pet after motion once pass-through is off"); + + // The same has to hold when the state is toggled repeatedly, not only on the first switch off. + char label[128]; + for (int cycle = 1; cycle <= 2; cycle++) { + bongo_cat_platform_set_click_through(&platform, true, false); + pump_runloop(0.2); + post_mouse_move(native); + snprintf(label, sizeof(label), "pass-through on survives motion in cycle %d", cycle); + expect(native.ignoresMouseEvents, label); + snprintf(label, sizeof(label), "the window server passes the mouse through in cycle %d", + cycle); + expect(mouse_target_at(centre) != native.windowNumber, label); + + bongo_cat_platform_set_click_through(&platform, false, false); + pump_runloop(0.2); + post_mouse_move(native); + snprintf(label, sizeof(label), "pass-through off survives motion in cycle %d", cycle); + expect(!native.ignoresMouseEvents, label); + snprintf(label, sizeof(label), "the window server targets the pet in cycle %d", cycle); + expect(mouse_target_at(centre) == native.windowNumber, label); + } + + if (context) SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + pump_runloop(0.2); + printf("%s\n", failures ? "click-through check failed" : "click-through check passed"); + return failures ? 1 : 0; +} diff --git a/tests/platform/test_windows_capture.c b/tests/platform/test_windows_capture.c new file mode 100644 index 00000000..9d481f20 --- /dev/null +++ b/tests/platform/test_windows_capture.c @@ -0,0 +1,188 @@ +#include "windows_capture.h" +#include "windows_borderless.h" +#include "windows_diagnostics.h" +#include "windows_package.h" +#include "windows_popup.h" +#include "windows_tray.h" + +#include +#include + +static int failures; +static int window_position_messages; +static int nonclient_size_messages; + +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", \ + __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static void test_package_storage_root(void) { + char path[BONGO_CAT_PATH_CAP]; + CHECK(bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"publisher.BongoCat_abcd1234", + path, sizeof(path))); + CHECK(strcmp(path, "C:\\Users\\Alice\\AppData\\Local\\Packages\\" + "publisher.BongoCat_abcd1234\\LocalCache\\Local\\BongoCat") == 0); + CHECK(!bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"publisher.BongoCat_abcd1234", path, 8)); + CHECK(path[0] == '\0'); + CHECK(!bongo_cat_windows_package_storage_root_for( + L"C:\\Users\\Alice", L"", path, sizeof(path))); +} + +static LRESULT CALLBACK click_through_test_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (message == WM_WINDOWPOSCHANGED) window_position_messages++; + if (message == WM_NCCALCSIZE) nonclient_size_messages++; + return DefWindowProcW(window, message, wparam, lparam); +} + +static void test_click_through_does_not_refresh_frame(void) { + CHECK(!bongo_cat_windows_borderless_hit_transparent(false, false, false)); + CHECK(bongo_cat_windows_borderless_hit_transparent(false, true, false)); + CHECK(!bongo_cat_windows_borderless_hit_transparent(false, true, true)); + CHECK(bongo_cat_windows_borderless_hit_transparent(true, false, true)); + const wchar_t class_name[] = L"BongoCat click-through test"; + WNDCLASSW type = {.lpfnWndProc = click_through_test_proc, + .hInstance = GetModuleHandleW(NULL), .lpszClassName = class_name}; + ATOM registered = RegisterClassW(&type); + CHECK(registered != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS); + HWND window = CreateWindowExW(WS_EX_LAYERED, class_name, + L"BongoCat click-through test", WS_POPUP, 0, 0, 32, 32, + NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (!window) return; + bongo_cat_windows_borderless_install(window); + window_position_messages = 0; + nonclient_size_messages = 0; + bongo_cat_windows_borderless_set_click_through(window, false, true); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) == 0); + CHECK(SendMessageW(window, WM_NCHITTEST, 0, 0) == HTTRANSPARENT); + SendMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, 0); + CHECK(GetCapture() == window); + SendMessageW(window, WM_RBUTTONUP, 0, 0); + CHECK(GetCapture() != window); + bongo_cat_windows_borderless_set_click_through(window, true, false); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) != 0); + CHECK(SendMessageW(window, WM_NCHITTEST, 0, 0) == HTTRANSPARENT); + bongo_cat_windows_borderless_set_click_through(window, false, false); + CHECK((GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TRANSPARENT) == 0); + CHECK(window_position_messages == 0); + CHECK(nonclient_size_messages == 0); + bongo_cat_windows_borderless_uninstall(window); + DestroyWindow(window); + if (registered) UnregisterClassW(class_name, GetModuleHandleW(NULL)); +} + +static void test_popup_completion(void) { + HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat popup test", + 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (!window) return; + bongo_cat_windows_popup_complete(window); + MSG message = {0}; + bool found = false; + while (PeekMessageW(&message, window, 0, 0, PM_REMOVE)) + if (message.message == WM_NULL) found = true; + CHECK(found); + DestroyWindow(window); +} + +static void count_tray_restore(void *userdata) { + int *count = userdata; + (*count)++; +} + +static void test_tray_restart_notification(void) { + int restores = 0; + void *tray = &restores; + bongo_cat_platform_set_tray_callbacks( + tray, NULL, NULL, count_tray_restore, &restores); + bongo_cat_windows_tray_handle_message(WM_NULL); + CHECK(restores == 0); + UINT taskbar_created = RegisterWindowMessageW(L"TaskbarCreated"); + CHECK(taskbar_created != 0); + bongo_cat_windows_tray_handle_message(taskbar_created); + CHECK(restores == 1); + bongo_cat_platform_set_tray_callbacks(tray, NULL, NULL, NULL, NULL); + bongo_cat_windows_tray_handle_message(taskbar_created); + CHECK(restores == 1); +} + +static void test_capture_styles(void) { + HWND window = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_LAYERED | + WS_EX_TRANSPARENT, L"STATIC", + L"BongoCat capture test", WS_POPUP, 0, 0, 32, 32, + NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (window) { + CHECK(bongo_cat_windows_diagnostics_probe_capture(window, "unit-test")); + CHECK(bongo_cat_windows_capture_configure(window)); + LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK((style & WS_EX_APPWINDOW) == 0); + CHECK((style & WS_EX_TOOLWINDOW) == 0); + CHECK((style & WS_EX_NOREDIRECTIONBITMAP) == 0); + CHECK((style & WS_EX_LAYERED) != 0); + CHECK((style & WS_EX_TRANSPARENT) != 0); + CHECK(bongo_cat_windows_capture_configure(window)); + CHECK(GetWindowLongPtrW(window, GWL_EXSTYLE) == style); + CHECK(!bongo_cat_windows_capture_handle_message(window, WM_NULL, 0)); + CHECK(!bongo_cat_windows_capture_handle_message(window, WM_TIMER, 1)); + DestroyWindow(window); + } + window = CreateWindowExW(WS_EX_NOREDIRECTIONBITMAP, L"STATIC", + L"BongoCat redirection test", WS_POPUP, + 0, 0, 32, 32, NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (window) { + CHECK(!bongo_cat_windows_capture_configure(window)); + LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK((style & WS_EX_TOOLWINDOW) == 0); + CHECK((style & WS_EX_NOREDIRECTIONBITMAP) != 0); + DestroyWindow(window); + } + window = CreateWindowExW(WS_EX_APPWINDOW | WS_EX_LAYERED, L"STATIC", + L"BongoCat capture app-window test", WS_POPUP, 0, 0, 32, 32, + NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (window) { + LONG_PTR before = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK(bongo_cat_windows_capture_configure(window)); + LONG_PTR after = GetWindowLongPtrW(window, GWL_EXSTYLE); + CHECK((after & WS_EX_APPWINDOW) != 0); + CHECK((after & WS_EX_LAYERED) != 0); + CHECK(after == before); + DestroyWindow(window); + } +} + +static void test_transparency_lifecycle(void) { + HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat transparency test", + WS_POPUP, 0, 0, 32, 32, NULL, NULL, GetModuleHandleW(NULL), NULL); + CHECK(window != NULL); + if (!window) return; + CHECK(!bongo_cat_windows_capture_is_configured(window)); + bongo_cat_windows_capture_mark_transparent(window, true); + CHECK(bongo_cat_windows_capture_handle_transparency_message( + window, WM_ERASEBKGND, 0)); + bongo_cat_windows_capture_install_transparency_handler(window); + CHECK(SendMessageW(window, WM_ERASEBKGND, 0, 0) == 0); + CHECK(bongo_cat_windows_capture_configure(window)); + CHECK(bongo_cat_windows_capture_is_configured(window)); + bongo_cat_windows_capture_mark_transparent(window, false); + CHECK(!bongo_cat_windows_capture_handle_transparency_message( + window, WM_ERASEBKGND, 0)); + DestroyWindow(window); +} + +int main(void) { + test_package_storage_root(); + test_click_through_does_not_refresh_frame(); + test_capture_styles(); + test_transparency_lifecycle(); + test_tray_restart_notification(); + test_popup_completion(); + return failures ? 1 : 0; +} diff --git a/tests/platform/test_windows_input.c b/tests/platform/test_windows_input.c new file mode 100644 index 00000000..6ec700df --- /dev/null +++ b/tests/platform/test_windows_input.c @@ -0,0 +1,233 @@ +#include "test.h" +#include "windows_input_internal.h" + +#include +#include +#include + +int bongo_cat_test_failures; +void test_windows_relative_sources(void); +void test_windows_raw_receiver(void); +void test_windows_pointer_detection(void); +void test_windows_mouse_mapping(void); + +typedef struct RawFixture { + WindowsInputState state; + BongoCatInputState input; + BongoCatPlatform platform; +} RawFixture; + +static void initialize(RawFixture *test) { + bongo_cat_input_init(&test->input); + test->platform.input = &test->input; + test->platform.native = &test->state; + test->state.platform = &test->platform; + InitializeSRWLock(&test->state.platform_lock); + InitializeSRWLock(&test->state.relative_lock); +} + +static void key_packet(RawFixture *test, uintptr_t device, USHORT code, + USHORT scan, USHORT flags) { + RAWINPUT packet = {0}; + packet.header.dwSize = offsetof(RAWINPUT, data) + sizeof(RAWKEYBOARD); + packet.header.dwType = RIM_TYPEKEYBOARD; + packet.header.hDevice = (HANDLE)device; + packet.header.wParam = RIM_INPUTSINK; + packet.data.keyboard = (RAWKEYBOARD){.VKey = code, .MakeCode = scan, + .Flags = flags}; + bongo_cat_windows_input_packet(&test->state, &packet, packet.header.dwSize); +} + +static void expect(RawFixture *test, BongoCatInputKind kind, const char *name) { + BongoCatInputEvent event = {0}; + CHECK(bongo_cat_input_pop(&test->input, &event)); + CHECK(event.kind == kind && strcmp(event.name, name) == 0); + CHECK(event.timestamp_ms <= SDL_GetTicks()); +} + +static void expect_empty(RawFixture *test) { + BongoCatInputEvent event; + CHECK(!bongo_cat_input_pop(&test->input, &event)); +} + +static void test_key_devices(void) { + RawFixture test = {0}; + initialize(&test); + key_packet(&test, 1, 'A', 0x1e, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + key_packet(&test, 1, 'A', 0x1e, 0); + key_packet(&test, 2, 'A', 0x1e, 0); + key_packet(&test, 1, 'A', 0x1e, RI_KEY_BREAK); + expect_empty(&test); + bongo_cat_windows_input_remove_device(&test.state, (HANDLE)(uintptr_t)2); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + key_packet(&test, 1, VK_CONTROL, 0x1d, 0); + key_packet(&test, 1, VK_CONTROL, 0x1d, RI_KEY_E0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "ControlLeft"); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "ControlRight"); + key_packet(&test, 1, VK_CONTROL, 0x1d, RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "ControlLeft"); + CHECK(bongo_cat_input_control_down(&test.input)); + bongo_cat_windows_input_clear_devices(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "ControlRight"); + CHECK(!bongo_cat_input_control_down(&test.input)); + CHECK(test.state.device_count == 0); +} + +static void test_key_sequences(void) { + RawFixture test = {0}; + initialize(&test); + key_packet(&test, 0, VK_NUMPAD1, 0x4f, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Kp1"); + key_packet(&test, 0, VK_END, 0x4f, RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Kp1"); + key_packet(&test, 0, VK_RETURN, 0x1c, 0); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_E0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Return"); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_BREAK); + expect_empty(&test); + key_packet(&test, 0, VK_RETURN, 0x1c, RI_KEY_E0 | RI_KEY_BREAK); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Return"); + key_packet(&test, 0, VK_SHIFT, 0x2a, RI_KEY_E0); + key_packet(&test, 0, 255, 0, 0); + key_packet(&test, 0, 'A', 0xff, 0); + expect_empty(&test); + key_packet(&test, 0, VK_CONTROL, 0x1d, RI_KEY_E1); + key_packet(&test, 0, VK_NUMLOCK, 0x45, 0); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "Pause"); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "Pause"); + CHECK(!bongo_cat_input_control_down(&test.input)); + bongo_cat_windows_input_clear_devices(&test.state); + expect_empty(&test); +} + +static void test_buttons(void) { + RawFixture test = {0}; + initialize(&test); + WindowsRawDevice *a = bongo_cat_windows_input_device(&test.state, NULL); + WindowsRawDevice *b = bongo_cat_windows_input_device(&test.state, + (HANDLE)(uintptr_t)1); + CHECK(a && b); + if (!a || !b) { bongo_cat_windows_input_clear_devices(&test.state); return; } + bongo_cat_windows_input_buttons(&test.state, a, + RI_MOUSE_LEFT_BUTTON_DOWN | RI_MOUSE_BUTTON_4_DOWN); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Left"); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Back"); + bongo_cat_windows_input_buttons(&test.state, b, RI_MOUSE_LEFT_BUTTON_DOWN); + bongo_cat_windows_input_remove_device(&test.state, NULL); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Back"); + expect_empty(&test); + bongo_cat_windows_input_buttons(&test.state, b, RI_MOUSE_LEFT_BUTTON_UP); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Left"); + bongo_cat_windows_input_buttons(&test.state, b, + RI_MOUSE_RIGHT_BUTTON_DOWN | RI_MOUSE_RIGHT_BUTTON_UP); + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Right"); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Right"); + bongo_cat_windows_input_clear_devices(&test.state); +} + +static void fill_queue(RawFixture *test, bool recovery) { + BongoCatInputEvent event = {.kind = BONGO_CAT_INPUT_KEY_DOWN}; + for (unsigned i = 1; i < BONGO_CAT_INPUT_QUEUE_CAP; ++i) + CHECK(bongo_cat_input_push(&test->input, &event)); + if (!recovery) return; + event.kind = BONGO_CAT_INPUT_KEY_UP; + for (unsigned i = 1; i < BONGO_CAT_INPUT_RECOVERY_CAP; ++i) + CHECK(bongo_cat_input_push(&test->input, &event)); +} + +static void drain(RawFixture *test) { + BongoCatInputEvent event; + while (bongo_cat_input_pop(&test->input, &event)) {} +} + +static void test_backpressure(void) { + RawFixture test = {0}; + initialize(&test); + fill_queue(&test, false); + key_packet(&test, 1, 'A', 0x1e, 0); + CHECK(test.state.retry_events); + drain(&test); + bongo_cat_windows_input_flush(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + fill_queue(&test, true); + bongo_cat_windows_input_clear_devices(&test.state); + CHECK(test.state.retry_events); + drain(&test); + bongo_cat_windows_input_flush(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(&test); +} + +static void test_packets(void) { + RawFixture test = {0}; + initialize(&test); + RAWINPUT packet = {0}; + packet.header.dwSize = sizeof(packet); + packet.header.dwType = RIM_TYPEMOUSE; + packet.header.wParam = RIM_INPUTSINK; + packet.data.mouse.lLastX = 8; + bongo_cat_windows_input_reset_relative(&test.platform); + CHECK(!test.state.receiving && test.state.ownership == 0); + bongo_cat_windows_input_packet(&test.state, &packet, sizeof(packet)); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); + CHECK(x == 8.0 && y == 0.0); + CHECK(!bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); + bongo_cat_windows_input_packet(&test.state, &packet, 1); + packet.header.dwSize = sizeof(RAWINPUTHEADER); + bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); + CHECK(!bongo_cat_windows_input_take_relative(&test.platform, &x, &y, NULL)); + packet.header.dwType = RIM_TYPEHID; + bongo_cat_windows_input_packet(&test.state, &packet, packet.header.dwSize); + expect_empty(&test); + bongo_cat_windows_input_clear_devices(&test.state); + test.state.desktop_unavailable = true; + key_packet(&test, 1, 'A', 0x1e, 0); + CHECK(test.state.device_count == 1); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + bongo_cat_windows_input_clear_devices(&test.state); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); +} + +static void test_motion_keeps_key_and_button_edges(void) { + RawFixture test = {0}; + initialize(&test); + RAWINPUT packet = {0}; + packet.header.dwSize = sizeof(packet); + packet.header.dwType = RIM_TYPEMOUSE; + packet.header.wParam = RIM_INPUTSINK; + packet.data.mouse.lLastX = 1; + for (unsigned i = 0; i < 8000; ++i) { + packet.data.mouse.usButtonFlags = i == 100 ? RI_MOUSE_LEFT_BUTTON_DOWN : + i == 101 ? RI_MOUSE_LEFT_BUTTON_UP : 0; + bongo_cat_windows_input_packet(&test.state, &packet, sizeof(packet)); + if (i == 100) key_packet(&test, 1, 'A', 0x1e, 0); + if (i == 101) key_packet(&test, 1, 'A', 0x1e, RI_KEY_BREAK); + bongo_cat_windows_input_flush(&test.state); + } + expect(&test, BONGO_CAT_INPUT_MOUSE_DOWN, "Left"); + expect(&test, BONGO_CAT_INPUT_KEY_DOWN, "KeyA"); + expect(&test, BONGO_CAT_INPUT_MOUSE_UP, "Left"); + expect(&test, BONGO_CAT_INPUT_KEY_UP, "KeyA"); + expect_empty(&test); + CHECK(test.state.observed_x == 8000); + CHECK(!test.state.retry_events); + bongo_cat_windows_input_clear_devices(&test.state); +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_EVENTS)); + test_key_devices(); + test_key_sequences(); + test_buttons(); + test_backpressure(); + test_packets(); + test_motion_keeps_key_and_button_edges(); + test_windows_relative_sources(); + test_windows_pointer_detection(); + test_windows_mouse_mapping(); + test_windows_raw_receiver(); + SDL_Quit(); + return bongo_cat_test_failures ? 1 : 0; +} diff --git a/tests/platform/test_windows_mouse_mapping.c b/tests/platform/test_windows_mouse_mapping.c new file mode 100644 index 00000000..ac86628e --- /dev/null +++ b/tests/platform/test_windows_mouse_mapping.c @@ -0,0 +1,90 @@ +#include "test.h" +#include "mouse_internal.h" +#include "windows_input_internal.h" + +#include +#include + +static void expect_position(BongoCatApp *app, const BongoCatMouseProjection *projection, + bool relative, double seed_x, double seed_y, float expected_x, float expected_y, + bool expected_change) { + double x, y; + bool changed; + bool mapped = bongo_cat_app_map_pointer(app, projection, relative, + seed_x, seed_y, &x, &y, &changed); + CHECK(mapped); + if (!mapped) return; + float ratio_x, ratio_y; + bool normalized = bongo_cat_mver_pointer_ratios(x, y, &projection->bounds, + &ratio_x, &ratio_y); + CHECK(normalized); + if (normalized) { + CHECK(fabsf(ratio_x - expected_x) < 0.0001f); + CHECK(fabsf(ratio_y - expected_y) < 0.0001f); + } + CHECK(changed == expected_change); +} + +static void test_display_projection(BongoCatApp *app, + BongoCatMverPointerBounds bounds) { + WindowsInputState input = {0}; + WindowsRawDevice device = {0}; + InitializeSRWLock(&input.relative_lock); + app->platform.native = &input; + app->mver_pointer = (BongoCatMverPointerState){0}; + bongo_cat_windows_input_reset_relative(&app->platform); + BongoCatMouseProjection projection = {.bounds = bounds, .centered = true, + .center_x = bounds.left + bounds.width * 0.5, + .center_y = bounds.top + bounds.height * 0.5}; + /* The pet display differs from the model's authored workarea. Tracking + must use the same per-update projection as model output. */ + app->settings.model.mouse_centered = true; + RAWMOUSE mouse = {.lLastX = (LONG)(bounds.width * 0.1), + .lLastY = (LONG)(bounds.height * 0.1)}; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, projection.center_x, projection.center_y, + 0.6f, 0.6f, true); + /* An unavailable source cannot replace the virtual point with a cursor + position from another monitor. A delivered packet remains usable. */ + expect_position(app, &projection, true, 9000, 9000, 0.6f, 0.6f, false); + mouse.lLastX = -mouse.lLastX; + mouse.lLastY = -mouse.lLastY; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 0.5f, 0.5f, true); + expect_position(app, &projection, true, 9000, 9000, 0.5f, 0.5f, false); + mouse.lLastX = mouse.lLastY = 10000; + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 1.0f, 1.0f, true); + mouse.lLastX = (LONG)(-bounds.width * 0.1); + mouse.lLastY = (LONG)(-bounds.height * 0.1); + bongo_cat_windows_input_motion(&input, &device, &mouse, NULL); + expect_position(app, &projection, true, 9000, 9000, 0.9f, 0.9f, true); + expect_position(app, &projection, false, bounds.left + bounds.width * 0.25, + bounds.top + bounds.height * 0.25, 0.25f, 0.25f, true); + app->platform.native = NULL; +} + +void test_windows_mouse_mapping(void) { + BongoCatApp *app = calloc(1, sizeof(*app)); + CHECK(app != NULL); + if (!app) return; + app->model_render_options.mver_projection = true; + app->model_render_options.custom_pointer_bounds = true; + app->model_render_options.pointer_left = 100; + app->model_render_options.pointer_top = 200; + app->model_render_options.pointer_right = 900; + app->model_render_options.pointer_bottom = 800; + BongoCatMouseProjection custom; + bool resolved = bongo_cat_app_mouse_projection(app, 500, 500, &custom); + CHECK(resolved); + if (resolved) { + CHECK(!custom.centered); + CHECK(custom.bounds.left == 100 && custom.bounds.top == 200); + CHECK(custom.bounds.width == 800 && custom.bounds.height == 600); + expect_position(app, &custom, false, 500, 500, 0.5f, 0.5f, true); + } + test_display_projection(app, (BongoCatMverPointerBounds){0, 0, 1920, 1080}); + test_display_projection(app, (BongoCatMverPointerBounds){-1920, -200, 1920, 1080}); + test_display_projection(app, (BongoCatMverPointerBounds){2560, 0, 1080, 1920}); + free(app); +} diff --git a/tests/platform/test_windows_pointer_detection.c b/tests/platform/test_windows_pointer_detection.c new file mode 100644 index 00000000..25554870 --- /dev/null +++ b/tests/platform/test_windows_pointer_detection.c @@ -0,0 +1,302 @@ +#include "test.h" +#include "windows_input_detection.h" +#include + +static void test_clip_bounds(void) { + static const struct { + RECT clip; + bool locked; + } cases[] = { + {{-1920, -1080, 0, 0}, false}, + {{-1920, -1080, -1, -1}, false}, + {{-1920, -1080, 1920, 1080}, false}, + {{0, 0, 1920, 1080}, false}, + {{-2, -2, 0, 0}, true}, + {{-1, -1, 1, 1}, true}, + {{960, 540, 961, 541}, true}, + {{0, 0, 0, 0}, true}, + {{-3, -2, 0, 0}, false}, + {{-2, -3, 0, 0}, false}, + {{1, 0, 0, 1}, false}, + {{0, 1, 1, 0}, false}, + {{LONG_MIN, LONG_MIN, LONG_MAX, LONG_MAX}, false}, + {{LONG_MIN, LONG_MIN, LONG_MIN + 2, LONG_MIN + 2}, true}, + {{LONG_MAX - 2, LONG_MAX - 2, LONG_MAX, LONG_MAX}, true} + }; + CHECK(!bongo_cat_windows_pointer_clip_locked(NULL)); + for (unsigned i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + bool locked = bongo_cat_windows_pointer_clip_locked(&cases[i].clip); + if (locked != cases[i].locked) + fprintf(stderr, "clip bounds case=%u locked=%d\n", i, (int)locked); + CHECK(locked == cases[i].locked); + } +} + +static WindowsPointerObservation observation(void) { + return (WindowsPointerObservation){.foreground = (HWND)(uintptr_t)1, + .pid = 2, .foreign = true, .position_known = true, .monitor_known = true, + .clip_known = true, .cursor_known = true, .cursor_flags = CURSOR_SHOWING, + .position = {960, 540}, .monitor = {0, 0, 1920, 1080}, + .clip = {0, 0, 1920, 1080}}; +} + +static void test_stalled_and_resume(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 12; + sample.motion_packets = 128; + for (unsigned t = 16; t < 128; t += 16) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 128)); + CHECK(state.reason == WINDOWS_POINTER_STALLED); + sample.raw_x = 0; + sample.motion_packets = 0; + for (unsigned t = 144; t <= 1024; t += 16) + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, t)); + sample.raw_x = 10; + sample.motion_packets = 1; + for (unsigned t = 1040; t <= 1280; t += 16) { + sample.position.x += 10; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(!state.relative && state.reason == WINDOWS_POINTER_DESKTOP); +} + +static void test_recenter_and_real_travel(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 10; + sample.motion_packets = 1; + for (unsigned i = 1; i <= 8; ++i) { + sample.position.x = i % 2 ? 974 : 960; + bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + } + CHECK(state.relative && state.reason == WINDOWS_POINTER_RECENTERED); + state = (WindowsPointerDetection){0}; + sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 64; ++i) { + sample.raw_x = i % 2 ? 14.0 : -14.0; + sample.motion_packets = 1; + sample.position.x = i % 2 ? 974 : 960; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } +} + +static void test_desktop_edges_and_slow_motion(void) { + static const char *const scenarios[] = { + "monitor edge", "negative monitor edge", "clip edge", + "unknown monitor", "slow cursor travel", "small raw movement", + "alternating raw movement" + }; + for (unsigned scenario = 0; + scenario < sizeof(scenarios) / sizeof(scenarios[0]); ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + if (scenario == 0) sample.position.x = 1919; + if (scenario == 1) { + sample.monitor = (RECT){-1920, -1080, 0, 0}; + sample.clip = sample.monitor; + sample.position = (POINT){-1920, -500}; + } + if (scenario == 2) sample.clip.right = 961; + if (scenario == 3) sample.monitor_known = false; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 64; ++i) { + sample.motion_packets = 128; + sample.raw_x = 32; + if (scenario == 4) sample.position.x += 1; + if (scenario == 5) sample.raw_x = 1; + if (scenario == 6) sample.raw_x = i % 2 ? 4.0 : -4.0; + bool relative = bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + if (relative) { + fprintf(stderr, "desktop scenario=%s sample=%u reason=%d raw_x=%.1f\n", + scenarios[scenario], i, (int)state.reason, sample.raw_x); + fprintf(stderr, "clip=(%ld,%ld,%ld,%ld)\n", sample.clip.left, + sample.clip.top, sample.clip.right, sample.clip.bottom); + } + CHECK(!relative); + } + } +} + +static void test_hidden_and_clipped(void) { + for (unsigned scenario = 0; scenario < 3; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + if (scenario == 0) sample.cursor_flags = 0; + if (scenario == 1) sample.clip = (RECT){960, 540, 961, 541}; + if (scenario == 2) sample.cursor_known = false; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + /* A hidden cursor without mouse input must not activate tracking. */ + for (unsigned t = 16; t <= 256; t += 16) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + sample.raw_y = 5; + sample.motion_packets = 1; + for (unsigned t = 272; t <= 304; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative == (scenario == 1)); + for (unsigned t = 320; t <= 384; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); + CHECK(state.reason == (scenario == 0 ? WINDOWS_POINTER_HIDDEN : + scenario == 1 ? WINDOWS_POINTER_CLIPPED : WINDOWS_POINTER_STALLED)); + if (scenario == 2) continue; + sample.cursor_flags = CURSOR_SHOWING; + sample.clip = sample.monitor; + sample.raw_y = 0; + sample.motion_packets = 0; + for (unsigned t = 400; t <= 768; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(!state.relative); + } +} + +static void test_focus_suspend_and_missing_position(void) { + for (unsigned scenario = 0; scenario < 6; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 8; + sample.motion_packets = 1; + for (unsigned t = 16; t <= 128; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); + if (scenario == 0) sample.foreground = (HWND)(uintptr_t)3; + if (scenario == 1) sample.pid++; + if (scenario == 2) sample.foreign = false; + if (scenario == 3) sample.position_known = false; + ULONGLONG now = scenario == 4 ? 1000 : scenario == 5 ? 1 : 144; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, now)); + CHECK(!state.relative); + } +} + +static void test_transition_flicker(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 8; + sample.motion_packets = 1; + for (unsigned i = 1; i <= 32; ++i) { + sample.position.x += 8; + sample.cursor_flags = i % 2 ? 0 : CURSOR_SHOWING; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + sample.cursor_flags = 0; + for (unsigned t = 528; t <= 768; t += 16) + bongo_cat_windows_pointer_detect(&state, &sample, t); + CHECK(state.relative); + for (unsigned i = 1; i <= 16; ++i) { + sample.cursor_flags = i % 2 ? CURSOR_SHOWING : 0; + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 768 + i * 16)); + } +} + +static void test_slow_sampling_and_edge_returns(void) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 20; + sample.motion_packets = 1; + for (unsigned t = 60; t < 240; t += 60) + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, t)); + CHECK(bongo_cat_windows_pointer_detect(&state, &sample, 240)); + state = (WindowsPointerDetection){0}; + sample = observation(); + sample.position.x = 1910; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 32; ++i) { + sample.position.x = i % 2 ? 1919 : 1910; + sample.raw_x = 20; + sample.motion_packets = 1; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + state = (WindowsPointerDetection){0}; + sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + for (unsigned i = 1; i <= 16; ++i) { + /* One large packet is not sustained movement over the time window. */ + sample.raw_x = i == 1 ? 10000 : 0; + sample.motion_packets = i == 1 ? 8000 : 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } +} + +static void test_hidden_desktop_and_resume(void) { + for (unsigned scenario = 0; scenario < 4; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + if (scenario == 2) { + sample.monitor = sample.clip = (RECT){-1920, -1080, 0, 0}; + sample.position = (POINT){-1920, -500}; + } + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.motion_packets = 1; + for (unsigned i = 1; i <= 64; ++i) { + sample.raw_x = 8; + if (scenario == 0) sample.position.x += 8; + if (scenario == 1) sample.position.x += 1; + if (scenario == 3) { + sample.raw_x = i % 2 ? 14 : -14; + sample.position.x = i % 2 ? 974 : 960; + } + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, i * 16)); + } + } + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + sample.cursor_flags = 0; + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.raw_x = 12; + sample.motion_packets = 1; + for (unsigned t = 16; t <= 256; t += 16) { + sample.position.x = t % 32 ? 974 : 960; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(state.relative && state.reason == WINDOWS_POINTER_HIDDEN); + /* Normal coordinate travel must release even while the cursor is hidden. */ + for (unsigned t = 272; t <= 640; t += 16) { + sample.position.x += 12; + bongo_cat_windows_pointer_detect(&state, &sample, t); + } + CHECK(!state.relative); +} + +static void test_absolute_observation(void) { + for (unsigned scenario = 0; scenario < 4; ++scenario) { + WindowsPointerDetection state = {0}; + WindowsPointerObservation sample = observation(); + CHECK(!bongo_cat_windows_pointer_detect(&state, &sample, 0)); + sample.motion_packets = 1; + for (unsigned i = 1; i <= 32; ++i) { + sample.absolute_x = 8; + if (scenario == 1) sample.position.x += 8; + if (scenario == 2) sample.absolute_x = i % 2 ? 8 : -8; + if (scenario == 3) { + /* Individually subthreshold signals must not be added. */ + sample.absolute_x = 2; + sample.raw_x = 2; + } + bool relative = bongo_cat_windows_pointer_detect(&state, &sample, i * 16); + CHECK(relative == (scenario == 0 && i >= 8)); + } + } +} + +void test_windows_pointer_detection(void) { + test_hidden_desktop_and_resume(); + test_absolute_observation(); + test_clip_bounds(); + test_stalled_and_resume(); + test_recenter_and_real_travel(); + test_desktop_edges_and_slow_motion(); + test_hidden_and_clipped(); + test_focus_suspend_and_missing_position(); + test_transition_flicker(); + test_slow_sampling_and_edge_returns(); +} diff --git a/tests/platform/test_windows_raw_receiver.c b/tests/platform/test_windows_raw_receiver.c new file mode 100644 index 00000000..3b0f8d1a --- /dev/null +++ b/tests/platform/test_windows_raw_receiver.c @@ -0,0 +1,224 @@ +#include "test.h" +#include "windows_input_internal.h" + +#include + +static unsigned registered_to(HWND window) { + WindowsInputState state = {.window = window}; + return bongo_cat_windows_input_ownership(&state); +} + +static HANDLE blocked_receiver_entered, blocked_receiver_release; +static WNDPROC original_receiver_proc; +#define TEST_BLOCK_RECEIVER (WM_APP + 271) + +static LRESULT CALLBACK blocked_receiver_proc(HWND window, UINT message, + WPARAM wparam, LPARAM lparam) { + if (message == TEST_BLOCK_RECEIVER) { + SetEvent(blocked_receiver_entered); + WaitForSingleObject(blocked_receiver_release, INFINITE); + return 0; + } + return CallWindowProcW(original_receiver_proc, window, message, wparam, lparam); +} + +static void test_deferred_cleanup(BongoCatPlatform *platform) { + DWORD before = 0, after = 0; + CHECK(GetProcessHandleCount(GetCurrentProcess(), &before)); + CHECK(bongo_cat_windows_input_start(platform)); + WindowsInputState *state = platform->native; + if (!state) return; + HANDLE worker = NULL; + blocked_receiver_entered = CreateEventW(NULL, TRUE, FALSE, NULL); + blocked_receiver_release = CreateEventW(NULL, TRUE, FALSE, NULL); + CHECK(DuplicateHandle(GetCurrentProcess(), state->thread, GetCurrentProcess(), + &worker, SYNCHRONIZE, FALSE, 0)); + CHECK(blocked_receiver_entered && blocked_receiver_release); + if (worker && blocked_receiver_entered && blocked_receiver_release) { + original_receiver_proc = (WNDPROC)GetWindowLongPtrW(state->window, GWLP_WNDPROC); + CHECK(SetWindowLongPtrW(state->window, GWLP_WNDPROC, + (LONG_PTR)blocked_receiver_proc) != 0); + CHECK(PostMessageW(state->window, TEST_BLOCK_RECEIVER, 0, 0)); + DWORD entered = WaitForSingleObject(blocked_receiver_entered, 3000); + CHECK(entered == WAIT_OBJECT_0); + if (entered == WAIT_OBJECT_0) { + bongo_cat_windows_input_stop(platform); + CHECK(platform->native == NULL); + CHECK(WaitForSingleObject(worker, 0) == WAIT_TIMEOUT); + BongoCatPlatform duplicate = {.input = platform->input}; + CHECK(!bongo_cat_windows_input_start(&duplicate)); + bongo_cat_windows_input_stop(&duplicate); + } + } + if (blocked_receiver_release) SetEvent(blocked_receiver_release); + bongo_cat_windows_input_stop(platform); + if (worker) { + CHECK(WaitForSingleObject(worker, INFINITE) == WAIT_OBJECT_0); + CloseHandle(worker); + } + if (blocked_receiver_entered) CloseHandle(blocked_receiver_entered); + if (blocked_receiver_release) CloseHandle(blocked_receiver_release); + CHECK(GetProcessHandleCount(GetCurrentProcess(), &after)); + CHECK(after == before); + CHECK(bongo_cat_windows_input_start(platform)); + bongo_cat_windows_input_stop(platform); +} + +static void test_registration_flags(void) { + HWND receiver = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(receiver != NULL); + if (!receiver) return; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK, receiver}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + WindowsInputState state = {.window = receiver}; + CHECK(bongo_cat_windows_input_ownership(&state) == 1); + CHECK(state.registration_error == ERROR_SUCCESS); + CHECK((state.mouse_registration_flags & RIDEV_INPUTSINK) != 0); + CHECK(registered_to(NULL) == 0); + mouse.dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 1); + mouse.dwFlags = 0; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 0); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(bongo_cat_windows_input_ownership(&state) == 0); + DestroyWindow(receiver); +} + +static void test_registration_recovery(void) { + HWND receiver = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + HWND other = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(receiver && other); + if (!receiver || !other) { + if (receiver) DestroyWindow(receiver); + if (other) DestroyWindow(other); + return; + } + WindowsInputState state = {.window = receiver}; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK, receiver}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + /* Restore only the missing keyboard; keep the healthy mouse unchanged. */ + CHECK(bongo_cat_windows_input_restore(&state, registered_to(receiver)) == 3); + CHECK(registered_to(receiver) == 3); + CHECK(bongo_cat_windows_input_ownership(&state) == 3); + CHECK(state.mouse_registration_flags == RIDEV_INPUTSINK); + CHECK(state.keyboard_registration_flags & RIDEV_INPUTSINK); + CHECK(bongo_cat_windows_input_restore(&state, 3) == 3); + + RAWINPUTDEVICE keyboard = {1, 6, 0, receiver}; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(registered_to(receiver) == 1); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 3); + CHECK(registered_to(receiver) == 3); + + /* A competing receiver must not be overwritten, even without INPUTSINK. */ + keyboard.hwndTarget = other; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, registered_to(receiver)) == 1); + keyboard.dwFlags = RIDEV_INPUTSINK; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 1); + CHECK(registered_to(other) == 2); + keyboard.dwFlags = 0; + keyboard.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 1); + + keyboard.dwFlags = RIDEV_REMOVE; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + CHECK(bongo_cat_windows_input_restore(&state, 1) == 3); + bongo_cat_windows_input_unregister(&state); + CHECK(registered_to(receiver) == 0); + CHECK(bongo_cat_windows_input_restore(&state, 0) == 3); + CHECK(registered_to(receiver) == 3); + bongo_cat_windows_input_unregister(&state); + DestroyWindow(other); + DestroyWindow(receiver); +} + +static void test_worker_recovery(HWND receiver) { + RAWINPUTDEVICE keyboard = {1, 6, RIDEV_REMOVE, NULL}; + CHECK(RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard))); + ULONGLONG deadline = GetTickCount64() + 3000; + while (registered_to(receiver) != 3 && GetTickCount64() < deadline) + SDL_Delay(10); + CHECK(registered_to(receiver) == 3); +} + +void test_windows_raw_receiver(void) { + test_registration_flags(); + test_registration_recovery(); + BongoCatInputState input; + bongo_cat_input_init(&input); + BongoCatPlatform platform = {.input = &input}; + SDL_setenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_FAILURE", "1", 1); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(platform.native == NULL); + SDL_unsetenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_FAILURE"); + SDL_setenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS", "5000", 1); + ULONGLONG started = GetTickCount64(); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(GetTickCount64() - started < 4000); + CHECK(platform.native == NULL); + SDL_unsetenv_unsafe("BONGO_CAT_TEST_RAW_INPUT_DELAY_MS"); + + SDL_SetHintWithPriority(SDL_HINT_WINDOWS_RAW_KEYBOARD, "0", SDL_HINT_OVERRIDE); + CHECK(SDL_InitSubSystem(SDL_INIT_VIDEO)); + SDL_Window *window = SDL_CreateWindow("Raw Input test", 64, 64, SDL_WINDOW_HIDDEN); + CHECK(window != NULL); + CHECK(bongo_cat_windows_input_start(&platform)); + if (platform.native) { + WindowsInputState *state = platform.native; + HWND receiver = state->window; + CHECK(registered_to(receiver) == 3); + CHECK(!IsWindowVisible(receiver)); + CHECK(GetWindowThreadProcessId(receiver, NULL) != GetCurrentThreadId()); + BongoCatPlatform duplicate = {.input = &input}; + CHECK(!bongo_cat_windows_input_start(&duplicate)); + SDL_Window *preferences = SDL_CreateWindow("Raw Input preferences test", + 64, 64, SDL_WINDOW_HIDDEN); + CHECK(preferences != NULL); + SDL_PumpEvents(); + CHECK(registered_to(receiver) == 3); + test_worker_recovery(receiver); + SDL_DestroyWindow(preferences); + bongo_cat_windows_input_stop(&platform); + CHECK(platform.native == NULL && registered_to(receiver) == 0); + CHECK(!IsWindow(receiver)); + bongo_cat_windows_input_stop(&platform); + } + test_deferred_cleanup(&platform); + SDL_DestroyWindow(window); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + + HWND other = CreateWindowExW(0, L"STATIC", L"", 0, + 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); + CHECK(other != NULL); + if (!other) return; + RAWINPUTDEVICE mouse = {1, 2, RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, other}; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + CHECK(!bongo_cat_windows_input_start(&platform)); + CHECK(registered_to(other) == 1); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + + CHECK(bongo_cat_windows_input_start(&platform)); + if (platform.native) { + mouse.dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; + mouse.hwndTarget = other; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + bongo_cat_windows_input_stop(&platform); + CHECK(registered_to(other) == 1); + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + CHECK(RegisterRawInputDevices(&mouse, 1, sizeof(mouse))); + } + DestroyWindow(other); +} diff --git a/tests/platform/test_windows_relative.c b/tests/platform/test_windows_relative.c new file mode 100644 index 00000000..7b6fed91 --- /dev/null +++ b/tests/platform/test_windows_relative.c @@ -0,0 +1,187 @@ +#include "test.h" +#include "windows_input_internal.h" + +static void test_relative_motion(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + RAWMOUSE mouse = {.lLastX = 17, .lLastY = -9}; + double x, y; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + bongo_cat_windows_input_reset_relative(&platform); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + mouse.lLastX = -5; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 12.0 && y == -18.0 && samples == 2); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 0.0 && y == 0.0 && samples == 0); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + bongo_cat_windows_input_reset_relative(&platform); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bongo_cat_windows_input_release_relative(&platform); + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + bongo_cat_windows_input_reset_relative(&platform); + state.receiving = false; + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); +} + +static void test_absolute_devices(void) { + WindowsInputState state = {0}; + WindowsRawDevice a = {0}, b = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + bongo_cat_windows_input_reset_relative(&platform); + RECT bounds = {-1920, -200, 3440, 1440}; + RAWMOUSE mouse = {.usFlags = MOUSE_MOVE_ABSOLUTE | MOUSE_VIRTUAL_DESKTOP}; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(a.absolute_x == -1920.0 && a.absolute_y == -200.0); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &b, &mouse, &bounds); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(state.observed_absolute_x == 5359.0); + CHECK(state.observed_absolute_y == 1639.0); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 5359.0 && y == 1639.0); + CHECK(a.absolute_x == 3439.0 && a.absolute_y == 1439.0); + bongo_cat_windows_input_reset_relative(&platform); + mouse.lLastX = mouse.lLastY = 32768; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + bounds.right = 2560; + mouse.lLastX = 30000; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); + mouse.lLastX = -1; + bongo_cat_windows_input_motion(&state, &a, &mouse, &bounds); + CHECK(!a.absolute_known); + CHECK(state.observed_absolute_x == 5359.0); + CHECK(state.observed_absolute_y == 1639.0); + bongo_cat_windows_input_clear_motion(&state); + CHECK(state.observed_absolute_x == 0.0 && state.observed_absolute_y == 0.0); +} + +static void test_motion_units(void) { + WindowsInputState state = {0}; + WindowsRawDevice absolute = {0}, relative = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + state.receiving = true; + bongo_cat_windows_input_reset_relative(&platform); + RECT bounds = {0, 0, 2560, 1440}; + RAWMOUSE mouse = {.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse.lLastX = 32768; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse = (RAWMOUSE){.lLastX = 3, .lLastY = -4}; + bongo_cat_windows_input_motion(&state, &relative, &mouse, NULL); + double x, y; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 3.0 && y == -4.0); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + CHECK(x == 0.0 && y == 0.0); +} + +static void test_delivered_motion_without_ownership(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + bongo_cat_windows_input_reset_relative(&platform); + RAWMOUSE mouse = {.lLastX = 12, .lLastY = -3}; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + mouse.lLastX = -12; + mouse.lLastY = 3; + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + double x, y; + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 0.0 && y == 0.0 && samples == 2); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + RECT bounds = {0, 0, 1920, 1080}; + mouse = (RAWMOUSE){.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &device, &mouse, &bounds); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &device, &mouse, &bounds); + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 1919.0 && y == 1079.0 && samples == 1); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, NULL)); +} + +static void test_observation_does_not_consume_motion(void) { + WindowsInputState state = {0}; + WindowsRawDevice relative = {0}, absolute = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + bongo_cat_windows_input_reset_relative(&platform); + RAWMOUSE mouse = {.lLastX = 12, .lLastY = -3}; + bongo_cat_windows_input_motion(&state, &relative, &mouse, NULL); + RECT bounds = {0, 0, 1920, 1080}; + mouse = (RAWMOUSE){.usFlags = MOUSE_MOVE_ABSOLUTE}; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + mouse.lLastX = mouse.lLastY = 65535; + bongo_cat_windows_input_motion(&state, &absolute, &mouse, &bounds); + WindowsPointerObservation sample = {.position_known = true, .position = {42, 24}}; + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 0); + CHECK(sample.raw_x == 12 && sample.raw_y == -3); + CHECK(sample.absolute_x == 1919 && sample.absolute_y == 1079); + CHECK(sample.motion_packets == 2); + CHECK(sample.position_known && sample.position.x == 42 && sample.position.y == 24); + double x, y; + unsigned long long count; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &count)); + CHECK(x == 12 && y == -3 && count == 1); + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 0); + CHECK(sample.raw_x == 0 && sample.absolute_x == 0 && sample.motion_packets == 0); + bongo_cat_windows_input_clear_motion(&state); + CHECK(bongo_cat_windows_input_take_observation(&state, &sample) == 1); + CHECK(sample.raw_y == 0 && sample.absolute_y == 0 && sample.motion_packets == 0); +} + +static void test_high_rate_observation(void) { + WindowsInputState state = {0}; + WindowsRawDevice device = {0}; + BongoCatPlatform platform = {.native = &state}; + InitializeSRWLock(&state.relative_lock); + RAWMOUSE mouse = {.lLastX = 3, .lLastY = -2}; + for (unsigned i = 0; i < 8000; ++i) + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + CHECK(state.observed_x == 24000 && state.observed_y == -16000); + CHECK(state.observed_motion == 8000 && state.relative_samples == 0); + bongo_cat_windows_input_reset_relative(&platform); + CHECK(state.observed_motion == 8000); + CHECK(state.observed_generation == 0); + for (unsigned i = 0; i < 8000; ++i) + bongo_cat_windows_input_motion(&state, &device, &mouse, NULL); + double x, y; + unsigned long long samples; + CHECK(bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + CHECK(x == 24000 && y == -16000 && samples == 8000); + CHECK(state.observed_motion == 16000); + CHECK(!bongo_cat_windows_input_take_relative(&platform, &x, &y, &samples)); + bongo_cat_windows_input_clear_motion(&state); + CHECK(state.observed_x == 0 && state.observed_y == 0); + CHECK(state.observed_motion == 0 && state.observed_generation == 1); +} + +void test_windows_relative_sources(void) { + test_relative_motion(); + test_absolute_devices(); + test_motion_units(); + test_delivered_motion_without_ownership(); + test_observation_does_not_consume_motion(); + test_high_rate_observation(); +} diff --git a/tests/test.h b/tests/support/test.h similarity index 83% rename from tests/test.h rename to tests/support/test.h index 58bf3f19..72a807e0 100644 --- a/tests/test.h +++ b/tests/support/test.h @@ -10,9 +10,12 @@ extern int bongo_cat_test_failures; } } while (0) void test_config(void); +void test_language(void); void test_input(void); void test_models(void); void test_mver_pointer(void); void test_shortcut(void); +void test_sound_shortcut(void); +void test_update(void); #endif diff --git a/tests/test_config.c b/tests/test_config.c deleted file mode 100644 index 317fc98d..00000000 --- a/tests/test_config.c +++ /dev/null @@ -1,168 +0,0 @@ -#include "test.h" -#include "bongo_cat/config.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" -#include "bongo_cat/utf8.h" - -#include -#include - -static void write_text(const char *path, const char *text) { - FILE *file = bongo_cat_file_open(path, "wb"); - CHECK(file != NULL); - if (file) { CHECK(fputs(text, file) >= 0); CHECK(fclose(file) == 0); } -} - -static bool contains_text(const char *path, const char *needle) { - char content[8192] = {0}; - FILE *file = bongo_cat_file_open(path, "rb"); - if (!file) return false; - size_t length = fread(content, 1, sizeof(content) - 1, file); - fclose(file); - content[length] = '\0'; - return strstr(content, needle) != NULL; -} - -void test_config(void) { - BongoCatConfig value; - bongo_cat_config_defaults(&value); - CHECK(value.model.max_fps == 60); - CHECK(value.window.width == 612 && value.window.height == 354); - CHECK(value.window.visible && value.window.always_on_top); - CHECK(!value.window.keep_in_screen); - CHECK(value.model.mouse_centered); - - value.model.max_fps = 900; - value.window.scale_percent = -2.0f; - value.window.opacity_percent = 123.0f; - bongo_cat_config_validate(&value); - CHECK(value.model.max_fps == 240); - CHECK(value.window.scale_percent == 10.0f); - CHECK(value.window.opacity_percent == 100.0f); - - BongoCatConfig duplicates; - bongo_cat_config_defaults(&duplicates); - memcpy(duplicates.shortcuts.visible_cat, "Control+B", sizeof("Control+B")); - memcpy(duplicates.shortcuts.visible_preferences, "control+b", sizeof("control+b")); - duplicates.behavior_shortcut_count = 2; - memcpy(duplicates.behavior_shortcuts[0].id, "motion:0", sizeof("motion:0")); - memcpy(duplicates.behavior_shortcuts[0].shortcut, "CONTROL+B", sizeof("CONTROL+B")); - memcpy(duplicates.behavior_shortcuts[1].id, "motion:1", sizeof("motion:1")); - memcpy(duplicates.behavior_shortcuts[1].shortcut, "Control+M", sizeof("Control+M")); - bongo_cat_config_validate(&duplicates); - CHECK(strcmp(duplicates.shortcuts.visible_cat, "Control+B") == 0); - CHECK(!duplicates.shortcuts.visible_preferences[0]); - CHECK(!duplicates.behavior_shortcuts[0].shortcut[0]); - CHECK(strcmp(duplicates.behavior_shortcuts[1].shortcut, "Control+M") == 0); - CHECK(bongo_cat_config_shortcut_conflicts(&duplicates, "control+b", NULL)); - CHECK(!bongo_cat_config_shortcut_conflicts(&duplicates, "control+b", - duplicates.shortcuts.visible_cat)); - memcpy(duplicates.behavior_shortcuts[1].label, "\xc4\xe3", - sizeof("\xc4\xe3")); - bongo_cat_config_validate(&duplicates); - CHECK(bongo_cat_utf8_valid(duplicates.behavior_shortcuts[1].label)); - - value.model.max_fps = 30; - value.model.mirror = true; - value.model.mouse_centered = false; - value.window.pass_through = true; - value.window.x = -321; - value.window.opacity_percent = 75.0f; - value.app.language = BONGO_CAT_LANG_ZH_CN; - memcpy(value.current_model, "keyboard", sizeof("keyboard")); - value.current_mode = BONGO_CAT_MODE_KEYBOARD; - value.behavior_shortcut_count = 1; - memcpy(value.behavior_shortcuts[0].id, "keyboard:motion:Tap:0", - sizeof("keyboard:motion:Tap:0")); - memcpy(value.behavior_shortcuts[0].shortcut, "Control+1", sizeof("Control+1")); - memcpy(value.behavior_shortcuts[0].label, "Happy tap", sizeof("Happy tap")); - - const char *preferences = "bongo-cat-\xE5\x81\x8F\xE5\xA5\xBD.json"; - const char *session = "bongo-cat-\xE4\xBC\x9A\xE8\xAF\x9D.json"; - BongoCatError error = {0}; - CHECK(bongo_cat_preferences_save(preferences, &value, &error) == BONGO_CAT_OK); - CHECK(!contains_text(preferences, "\"motionSound\"")); - CHECK(!contains_text(preferences, "\"behavior\"")); - CHECK(!contains_text(preferences, "\"taskbarVisible\"")); - CHECK(bongo_cat_session_save(session, &value, &error) == BONGO_CAT_OK); - CHECK(bongo_cat_path_is_file(preferences)); - CHECK(bongo_cat_path_is_file(session)); - - BongoCatConfig loaded; - bongo_cat_config_defaults(&loaded); - CHECK(bongo_cat_preferences_load(preferences, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.model.max_fps == 30 && loaded.model.mirror); - CHECK(!loaded.model.mouse_centered); - CHECK(loaded.window.pass_through); - CHECK(loaded.window.x == 0); - CHECK(loaded.window.opacity_percent == 100.0f); - CHECK(loaded.app.language == BONGO_CAT_LANG_ZH_CN); - CHECK(strcmp(loaded.current_model, "standard") == 0); - CHECK(loaded.behavior_shortcut_count == 1); - CHECK(strcmp(loaded.behavior_shortcuts[0].shortcut, "Control+1") == 0); - CHECK(strcmp(loaded.behavior_shortcuts[0].label, "Happy tap") == 0); - - for (int language = 0; language < BONGO_CAT_LANG_COUNT; ++language) { - value.app.language = (BongoCatLanguage)language; - CHECK(bongo_cat_preferences_save(preferences, &value, &error) == BONGO_CAT_OK); - BongoCatConfig language_loaded; - bongo_cat_config_defaults(&language_loaded); - CHECK(bongo_cat_preferences_load(preferences, &language_loaded, - &error) == BONGO_CAT_OK); - CHECK(language_loaded.app.language == (BongoCatLanguage)language); - } - - CHECK(bongo_cat_session_load(session, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.window.x == -321); - CHECK(loaded.window.opacity_percent == 75.0f); - CHECK(strcmp(loaded.current_model, "keyboard") == 0); - CHECK(loaded.current_mode == BONGO_CAT_MODE_GAMEPAD); - - const char *legacy_switches = "bongo-cat-legacy-switches.json"; - write_text(legacy_switches, - "{\"format\":\"bongo-cat/preferences\",\"version\":2," - "\"model\":{\"maxFPS\":42,\"motionSound\":false,\"behavior\":false}}"); - CHECK(bongo_cat_preferences_load(legacy_switches, &loaded, &error) == BONGO_CAT_OK); - CHECK(loaded.model.max_fps == 42); - CHECK(bongo_cat_preferences_save(legacy_switches, &loaded, &error) == BONGO_CAT_OK); - CHECK(!contains_text(legacy_switches, "\"motionSound\"")); - CHECK(!contains_text(legacy_switches, "\"behavior\"")); - - const char *unsupported = "bongo-cat-unsupported.json"; - write_text(unsupported, "{\"schemaVersion\":2,\"model\":{\"maxFPS\":1}}"); - int preserved_fps = loaded.model.max_fps; - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.max_fps == preserved_fps); - write_text(unsupported, - "{\"format\":\"bongo-cat/preferences\",\"version\":1,\"model\":{\"maxFPS\":1}}"); - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.max_fps == preserved_fps); - write_text(unsupported, - "{\"format\":\"bongo-cat/preferences\",\"version\":3,\"model\":{\"maxFPS\":1}}"); - CHECK(bongo_cat_preferences_load(unsupported, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - - const char *broken_session = "bongo-cat-broken-session.json"; - write_text(broken_session, "{ invalid"); - bool preserved_mirror = loaded.model.mirror; - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.mirror == preserved_mirror); - write_text(broken_session, - "{\"format\":\"bongo-cat/session\",\"version\":1,\"window\":{\"visible\":false}}"); - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - CHECK(loaded.model.mirror == preserved_mirror); - write_text(broken_session, - "{\"format\":\"bongo-cat/session\",\"version\":3,\"window\":{\"visible\":false}}"); - CHECK(bongo_cat_session_load(broken_session, &loaded, &error) == - BONGO_CAT_ERROR_FORMAT); - - CHECK(bongo_cat_file_remove(preferences)); - CHECK(bongo_cat_file_remove(session)); - CHECK(bongo_cat_file_remove(unsupported)); - CHECK(bongo_cat_file_remove(broken_session)); - CHECK(bongo_cat_file_remove(legacy_switches)); -} diff --git a/tests/test_mver_import.c b/tests/test_mver_import.c deleted file mode 100644 index 4d9dc95f..00000000 --- a/tests/test_mver_import.c +++ /dev/null @@ -1,334 +0,0 @@ -#include "model_import_mver_internal.h" -#include "model_storage.h" -#include "preferences_model_glyphs.h" -#include "preferences_state.h" -#include "ui_font_atlas.h" -#include "runtime.h" -#include "bongo_cat/file.h" -#include "bongo_cat/path.h" - -#include -#include -#include -#include -#include - -static int failures; -#define CHECK(value) do { if (!(value)) { \ - fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ - failures++; \ -} } while (0) - -const char *test_mver_pointer_config(bool live2d); -bool test_mver_pointer_fixture_assets(const char *standard, const char *source); -bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled); - -static bool chord(const char *json, bool gamepad, const char *expected) { - yyjson_doc *document = yyjson_read(json, strlen(json), 0); - BongoCatImportCandidate candidate = {0}; - candidate.gamepad_buttons = gamepad; - char output[BONGO_CAT_SHORTCUT_CAP]; - bool ok = document && bongo_cat_mver_chord(&candidate, - yyjson_doc_get_root(document), output, sizeof(output)); - bool matches = ok && expected && strcmp(output, expected) == 0; - yyjson_doc_free(document); - return expected ? matches : !ok; -} - -static bool write_text(const char *path, const char *text) { - FILE *file = bongo_cat_file_open(path, "wb"); - bool ok = file && fwrite(text, 1, strlen(text), file) == strlen(text); - if (file && fclose(file) != 0) ok = false; - return ok; -} - -static bool child(char *output, size_t capacity, const char *root, - const char *name, bool directory) { - return bongo_cat_path_join(output, capacity, root, name) && - (!directory || SDL_CreateDirectory(output)); -} - -static void labels_from_shortcut_rows(void) { - static const char default_expression[] = - "\xE9\xBB\x98\xE8\xAE\xA4\xE8\xA1\xA8\xE6\x83\x85"; - static const char keyboard_hidden[] = - "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"; - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-labels-%llu", temporary, - (unsigned long long)SDL_GetTicksNS()); - CHECK(SDL_CreateDirectory(root)); - CHECK(child(path, sizeof(path), root, "config.json", false)); - CHECK(write_text(path, - "{/* misleading: \\\"standard\\\" */standard:{" - "l2d_expression:[[18,//\xE9\xBB\x98\xE8\xAE\xA4\xE8\xA1\xA8\xE6\x83\x85\n76]," - "[18,77]],l2d_motion:[],l2d_motion_lockhand:" - "[[18,//\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1\n49]],sounds:[]}," - "keyboard:{l2d_expression:[]}}")); - BongoCatMverLabels labels = {0}; - CHECK(bongo_cat_mver_labels_load(path, "standard", &labels)); - CHECK(labels.count == 2); - CHECK(strcmp(bongo_cat_mver_label(&labels, "l2d_expression", 0), - default_expression) == 0); - CHECK(bongo_cat_mver_label(&labels, "l2d_expression", 1) == NULL); - CHECK(strcmp(bongo_cat_mver_label(&labels, "l2d_motion_lockhand", 0), - keyboard_hidden) == 0); - CHECK(bongo_cat_model_remove_tree(root, NULL)); - SDL_free(temporary); -} - -static void metadata_backfills_labels(void) { - static const char keyboard_hidden[] = - "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"; - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - char root[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; - snprintf(root, sizeof(root), "%s/bongo-cat-metadata-%llu", temporary, - (unsigned long long)SDL_GetTicksNS()); - CHECK(SDL_CreateDirectory(root)); - CHECK(child(path, sizeof(path), root, ".bongo-cat-mver.json", false)); - CHECK(write_text(path, "{\"version\":1,\"bindings\":[" - "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":0," - "\"shortcut\":\"Alt+1\",\"label\":\"\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1\"}," - "{\"kind\":\"motion\",\"group\":\"CAT_motion_lock\",\"index\":1," - "\"shortcut\":\"Alt+2\",\"label\":\"Imported\"}," - "{\"kind\":\"expression\",\"index\":2,\"shortcut\":\"Alt+3\"," - "\"label\":\"Expression label\"}]}")); - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (app) { - app->config.behavior_shortcut_count = 2; - snprintf(app->config.behavior_shortcuts[0].id, - sizeof(app->config.behavior_shortcuts[0].id), - "model:motion:CAT_motion_lock:0"); - snprintf(app->config.behavior_shortcuts[0].shortcut, - sizeof(app->config.behavior_shortcuts[0].shortcut), "Control+1"); - snprintf(app->config.behavior_shortcuts[1].id, - sizeof(app->config.behavior_shortcuts[1].id), - "model:motion:CAT_motion_lock:1"); - snprintf(app->config.behavior_shortcuts[1].label, - sizeof(app->config.behavior_shortcuts[1].label), "Custom name"); - bongo_cat_import_apply_metadata(app, "model", root); - CHECK(app->config.behavior_shortcut_count == 3); - CHECK(strcmp(app->config.behavior_shortcuts[0].shortcut, "Control+1") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[0].label, keyboard_hidden) == 0); - CHECK(strcmp(app->config.behavior_shortcuts[1].label, "Custom name") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[2].id, - "model:expression:2") == 0); - CHECK(strcmp(app->config.behavior_shortcuts[2].label, - "Expression label") == 0); - free(app); - } - CHECK(bongo_cat_model_remove_tree(root, NULL)); - SDL_free(temporary); -} - -static bool range_has(const uint32_t *ranges, uint32_t rune) { - for (size_t i = 0; ranges[i]; i += 2) - if (rune >= ranges[i] && rune <= ranges[i + 1]) return true; - return false; -} - -static void behavior_labels_add_font_glyphs(void) { - static const unsigned char custom_label[] = { - 0xe7, 0x8c, 0xab, 0xe5, 0x92, 0xaa, 0xe5, 0xbd, - 0xa2, 0xe6, 0x80, 0x81, 0 - }; - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (!app) return; - snprintf(app->behaviors.entries[0].label, - sizeof(app->behaviors.entries[0].label), - "\xE9\x94\xAE\xE7\x9B\x98\xE6\xB6\x88\xE5\xA4\xB1"); - app->behaviors.count = 1; - memcpy(app->config.behavior_shortcuts[0].label, custom_label, - sizeof(custom_label)); - app->config.behavior_shortcut_count = 1; - uint32_t ranges[64] = {0x20, 0x7e, 0}; - bongo_cat_preferences_model_glyphs(app, ranges, - sizeof(ranges) / sizeof(ranges[0])); - CHECK(range_has(ranges, 0x952e)); - CHECK(range_has(ranges, 0x76d8)); - CHECK(range_has(ranges, 0x6d88)); - CHECK(range_has(ranges, 0x5931)); - CHECK(range_has(ranges, 0x732b)); - CHECK(range_has(ranges, 0x54aa)); - CHECK(range_has(ranges, 0x5f62)); - CHECK(range_has(ranges, 0x6001)); - free(app); -} - -static void font_reload_defers_during_frame(void) { - BongoCatPreferences value = {0}; - value.ui_initialized = true; - value.ui.frame_building = true; - CHECK(bongo_cat_preferences_reload_fonts(&value)); - CHECK(value.font_reload_pending); - CHECK(value.render_dirty); - BongoCatUIBackend backend = {0}; - backend.frame_building = true; - CHECK(!bongo_cat_ui_font_atlas_reload(&backend, NULL, NULL, - NULL, NULL, NULL, NULL, NULL, 1.0f)); -} - -static bool portable_fixture(const char *root) { - char image[BONGO_CAT_PATH_CAP], standard[BONGO_CAT_PATH_CAP]; - char hand[BONGO_CAT_PATH_CAP], model[BONGO_CAT_PATH_CAP]; - char path[BONGO_CAT_PATH_CAP], source[BONGO_CAT_PATH_CAP]; - if (!SDL_CreateDirectory(root) || !child(image, sizeof(image), root, "img", true) || - !child(standard, sizeof(standard), image, "standard", true) || - !child(hand, sizeof(hand), standard, "hand", true) || - !child(model, sizeof(model), standard, "cat_model", true)) return false; - if (!child(path, sizeof(path), root, "config.json", false) || - !write_text(path, test_mver_pointer_config(true))) - return false; - if (!child(path, sizeof(path), model, "cat.model3.json", false) || - !write_text(path, "{\"Version\":3,\"FileReferences\":{\"Moc\":\"cat.moc3\"," - "\"Textures\":[\"texture.png\"]}}") || - !child(path, sizeof(path), model, "cat.moc3", false) || - !write_text(path, "MOC3")) return false; - snprintf(source, sizeof(source), "%s/resources/assets/tray.png", - BONGO_CAT_NATIVE_SOURCE_DIR); - if (!child(path, sizeof(path), model, "texture.png", false) || - !SDL_CopyFile(source, path) || !child(path, sizeof(path), hand, "0.png", false) || - !SDL_CopyFile(source, path) || !child(path, sizeof(path), standard, "bg.png", false) || - !SDL_CopyFile(source, path)) return false; - return test_mver_pointer_fixture_assets(standard, source); -} - -static void portable_model(void) { - char root[BONGO_CAT_PATH_CAP], package[BONGO_CAT_PATH_CAP]; - char data[BONGO_CAT_PATH_CAP], mode[BONGO_CAT_PATH_CAP]; - char bundle[BONGO_CAT_PATH_CAP], nested_package[BONGO_CAT_PATH_CAP]; - char launch[BONGO_CAT_PATH_CAP]; - char variant[BONGO_CAT_PATH_CAP], variant_model[BONGO_CAT_PATH_CAP]; - char variant_img[BONGO_CAT_PATH_CAP], variant_standard[BONGO_CAT_PATH_CAP]; - char variant_hand[BONGO_CAT_PATH_CAP], source_hand[BONGO_CAT_PATH_CAP]; - char *temporary = SDL_GetCurrentDirectory(); - CHECK(temporary != NULL); - if (!temporary) return; - unsigned long long stamp = (unsigned long long)SDL_GetTicksNS(); - snprintf(root, sizeof(root), "%s/bongo-cat-portable-%llu", temporary, - stamp); - snprintf(data, sizeof(data), "%s/bongo-cat-portable-data-%llu", temporary, - stamp); - bongo_cat_model_remove_tree(root, NULL); - bongo_cat_model_remove_tree(data, NULL); - CHECK(SDL_CreateDirectory(root)); - CHECK(SDL_CreateDirectory(data)); - CHECK(child(package, sizeof(package), root, "app", true)); - CHECK(portable_fixture(package)); - CHECK(child(bundle, sizeof(bundle), root, "bundle", true)); - CHECK(child(launch, sizeof(launch), root, "launch", true)); - CHECK(child(nested_package, sizeof(nested_package), bundle, "model", true)); - CHECK(portable_fixture(nested_package)); - CHECK(child(variant, sizeof(variant), bundle, "variant", true)); - CHECK(child(variant_model, sizeof(variant_model), variant, "model", true)); - CHECK(child(variant_img, sizeof(variant_img), variant_model, "img", true)); - CHECK(child(variant_standard, sizeof(variant_standard), variant_img, "standard", true)); - CHECK(child(variant_hand, sizeof(variant_hand), variant_standard, "hand", true)); - CHECK(child(source_hand, sizeof(source_hand), nested_package, - "img/standard/hand/0.png", false)); - CHECK(child(mode, sizeof(mode), variant_hand, "0.png", false) && - SDL_CopyFile(source_hand, mode)); - BongoCatImportDiscovery discovery = {0}; - BongoCatError error = {0}; - CHECK(bongo_cat_import_mver_discover_exact(package, &discovery, &error) == 1); - CHECK(discovery.count == 1); - CHECK(child(mode, sizeof(mode), package, "img/standard", false)); - BongoCatImportDiscovery nested = {0}; - CHECK(bongo_cat_import_mver_discover_exact(mode, &nested, &error) == 0); - CHECK(bongo_cat_import_mver_discover(mode, &nested, &error) == 1); - BongoCatImportDiscovery selected_container = {0}; - CHECK(bongo_cat_import_discover(root, &selected_container, &error)); - CHECK(selected_container.count == 3); - size_t patch_count = 0; - for (size_t i = 0; i < selected_container.count; ++i) - if (selected_container.candidates[i].format == BONGO_CAT_IMPORT_MVER_PATCH) - patch_count++; - CHECK(patch_count == 1); - BongoCatApp *app = calloc(1, sizeof(*app)); - CHECK(app != NULL); - if (!app) { bongo_cat_model_remove_tree(root, NULL); SDL_free(temporary); return; } - bongo_cat_config_defaults(&app->config); - bongo_cat_models_init(&app->models); - snprintf(app->data_root, sizeof(app->data_root), "%s", data); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(app->models.entries[0].managed && !app->models.entries[0].preset); - CHECK(strcmp(app->models.entries[0].display_name, "app") == 0); - CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); - CHECK(strcmp(app->config.current_model, app->models.entries[0].id) == 0); - char adapter_file[BONGO_CAT_PATH_CAP]; - CHECK(child(adapter_file, sizeof(adapter_file), - app->models.entries[0].adapter_directory, "resources/left-keys/KeyA.png", false)); - CHECK(bongo_cat_path_is_file(adapter_file)); - CHECK(test_mver_pointer_adapter( - app->models.entries[0].adapter_directory, true)); - BongoCatLive2DRenderOptions render = {0}; - CHECK(bongo_cat_import_mver_render_options( - app->models.entries[0].adapter_directory, &render)); - CHECK(render.mver_projection && render.source_mirror); - CHECK(render.projection_scale > 1.986f && render.projection_scale < 1.988f); - CHECK(render.offset_y < -0.004f && render.offset_y > -0.006f); - CHECK(render.reference_width == 1400 && render.reference_height == 1400); - CHECK(render.custom_pointer_bounds && render.pointer_left == 100 && - render.pointer_top == 200 && render.pointer_right == 2100 && - render.pointer_bottom == 1400); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, mode, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(strcmp(app->models.entries[0].storage_directory, package) == 0); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver_scan(app, root, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_nearby_mver(app, launch, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 3); - for (size_t i = 0; i < app->models.count; ++i) { - CHECK(app->models.entries[i].display_name[0] != '\0'); - CHECK(app->models.entries[i].managed); - } - CHECK(child(mode, sizeof(mode), package, "config.json", false)); - CHECK(write_text(mode, test_mver_pointer_config(false))); - bongo_cat_models_init(&app->models); - CHECK(bongo_cat_import_portable_mver(app, package, &error) == BONGO_CAT_OK); - CHECK(app->models.count == 1); - CHECK(test_mver_pointer_adapter( - app->models.entries[0].adapter_directory, true)); - free(app); - CHECK(bongo_cat_model_remove_tree(root, NULL)); - CHECK(bongo_cat_model_remove_tree(data, NULL)); - SDL_free(temporary); -} - -int main(void) { - CHECK(chord("[17,65]", true, "Control+A")); - CHECK(chord("[0]", true, "Gamepad:South")); - CHECK(chord("[15]", true, "Gamepad:Select")); - CHECK(chord("[8]", false, "Backspace")); - CHECK(chord("[16,65]", false, "Shift+A")); - CHECK(chord("[17,18,90]", false, "Control+Alt+Z")); - CHECK(chord("[0]", false, NULL)); - CHECK(chord("[16]", false, NULL)); - CHECK(chord("[17,65,66]", true, NULL)); - BongoCatMverKeyNames modifier = bongo_cat_mver_device_names(16, 1, 2); - CHECK(modifier.count == 1 && strcmp(modifier.items[0], "ShiftRight") == 0); - BongoCatMverKeyNames dpad = bongo_cat_mver_gamepad_names(12); - CHECK(dpad.count == 1 && strcmp(dpad.items[0], "DPadUp") == 0); - labels_from_shortcut_rows(); - metadata_backfills_labels(); - behavior_labels_add_font_glyphs(); - font_reload_defers_during_frame(); - portable_model(); - return failures ? 1 : 0; -} diff --git a/tests/test_mver_pointer_import.c b/tests/test_mver_pointer_import.c deleted file mode 100644 index 696931f5..00000000 --- a/tests/test_mver_pointer_import.c +++ /dev/null @@ -1,82 +0,0 @@ -#include "model_import.h" -#include "preferences_overlay.h" -#include "bongo_cat/json.h" -#include "bongo_cat/path.h" - -#include -#include - -static bool overlay_input_self_test(void); - -const char *test_mver_pointer_config(bool live2d) { - const char *prefix = "{\"decoration\":{\"l2d_correct\":1.987," - "\"l2d_offset\":[0,-0.005],\"l2d_horizontal_flip\":true," - "\"window_size\":[1400,1400],\"offsetX\":[10,11]," - "\"offsetY\":[-10,-65],\"scalar\":[1,1]," - "\"armLineColor\":[1,2,3],\"hand_offset\":[4,5]," - "\"leftHanded\":true,\"mouse_force_move\":true," - "\"mouse_speed\":1.25},\"workarea\":{\"workarea\":true," - "\"top_left\":[100,200],\"right_bottom\":[2100,1400]},"; - const char *suffix = ",\"mouse\":false," - "\"hand_offset\":[4,5]," - "\"keyboard\":[[65]],\"hand\":[[65]]," - "\"l2d_expression\":[[65],[66]]}}"; - static char config[1024]; - SDL_snprintf(config, sizeof(config), "%s\"standard\":{\"l2d\":%s%s", - prefix, live2d ? "true" : "false", suffix); - return config; -} - -bool test_mver_pointer_fixture_assets(const char *standard, - const char *source) { - static const char *const names[] = { - "arm.png", "tablet.png", "tablet_left.png", "tablet_right.png" - }; - char path[BONGO_CAT_PATH_CAP]; - for (size_t index = 0; index < sizeof(names) / sizeof(names[0]); ++index) - if (!bongo_cat_path_join(path, sizeof(path), standard, names[index]) || - !SDL_CopyFile(source, path)) return false; - return true; -} - -bool test_mver_pointer_adapter(const char *adapter, bool expected_enabled) { - if (!overlay_input_self_test()) return false; - char path[BONGO_CAT_PATH_CAP]; - bool files = bongo_cat_path_join(path, sizeof(path), adapter, - "resources/mver-pointer/arm.png") && bongo_cat_path_is_file(path) && - bongo_cat_path_join(path, sizeof(path), adapter, - "resources/mver-pointer/tablet.png") && bongo_cat_path_is_file(path) && - bongo_cat_path_join(path, sizeof(path), adapter, ".bongo-cat-mver.json"); - yyjson_doc *metadata = files ? bongo_cat_json_read_file(path, 0, NULL) : NULL; - yyjson_val *pointer = metadata ? yyjson_obj_get( - yyjson_doc_get_root(metadata), "standardPointer") : NULL; - bool valid = yyjson_is_obj(pointer) && - yyjson_get_bool(yyjson_obj_get(pointer, "enabled")) == expected_enabled && - !yyjson_get_bool(yyjson_obj_get(pointer, "mouse")) && - yyjson_get_num(yyjson_obj_get(pointer, "offsetY")) == -65.0 && - yyjson_get_num(yyjson_obj_get(pointer, "handOffsetX")) == 4.0 && - yyjson_get_int(yyjson_obj_get(pointer, "lineBlue")) == 3; - yyjson_doc_free(metadata); - BongoCatLive2DRenderOptions render = {0}; - return valid && bongo_cat_import_mver_render_options(adapter, &render) && - render.pointer_left_handed && render.mouse_force_move && - render.mouse_speed > 1.249f && render.mouse_speed < 1.251f; -} - -static bool overlay_input_self_test(void) { - struct nk_context context = {0}; - struct nk_mouse_button *left = - &context.input.mouse.buttons[NK_BUTTON_LEFT]; - bool armed = false; - left->down = nk_true; left->clicked = 1; - if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || armed) - return false; - left->clicked = 0; - if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || armed) - return false; - left->down = nk_false; - if (bongo_cat_preferences_overlay_input_ready(&context, &armed) || !armed) - return false; - left->down = nk_true; left->clicked = 1; - return bongo_cat_preferences_overlay_input_ready(&context, &armed); -} diff --git a/tests/test_windows_capture.c b/tests/test_windows_capture.c deleted file mode 100644 index 4f498d82..00000000 --- a/tests/test_windows_capture.c +++ /dev/null @@ -1,84 +0,0 @@ -#include "windows_capture.h" -#include "windows_direct_input.h" -#include "windows_input.h" -#include "windows_popup.h" - -#include -#include - -static int failures; - -#define CHECK(value) do { if (!(value)) { \ - fprintf(stderr, "%s:%d: check failed: %s\n", \ - __FILE__, __LINE__, #value); \ - failures++; \ -} } while (0) - -static void test_popup_completion(void) { - HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat popup test", - 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); - CHECK(window != NULL); - if (!window) return; - bongo_cat_windows_popup_complete(window); - MSG message = {0}; - bool found = false; - while (PeekMessageW(&message, window, 0, 0, PM_REMOVE)) - if (message.message == WM_NULL) found = true; - CHECK(found); - DestroyWindow(window); -} - -static void test_input_startup(void) { - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE"); - SDL_setenv_unsafe("BONGO_CAT_TEST_HOOK_DELAY_MS", "2000", 1); - BongoCatInputState input; - bongo_cat_input_init(&input); - BongoCatPlatform platform = {.input = &input}; - ULONGLONG started = GetTickCount64(); - CHECK(bongo_cat_windows_input_start(&platform)); - CHECK(GetTickCount64() - started < 500); - bongo_cat_windows_input_stop(&platform); - CHECK(platform.native == NULL); - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_DELAY_MS"); - SDL_setenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE", "1", 1); - CHECK(!bongo_cat_windows_input_start(&platform)); - CHECK(platform.native == NULL); - SDL_unsetenv_unsafe("BONGO_CAT_TEST_HOOK_FAILURE"); -} - -static void test_direct_input_rebase(void) { - HWND window = CreateWindowExW(0, L"STATIC", L"BongoCat input test", - 0, 0, 0, 0, 0, HWND_MESSAGE, NULL, GetModuleHandleW(NULL), NULL); - CHECK(window != NULL); - if (!window) return; - BongoCatPlatform platform = {0}; - if (bongo_cat_windows_direct_input_create(&platform, window)) { - double x = 0.0, y = 0.0; - CHECK(!bongo_cat_platform_relative_pointer(&platform, &x, &y)); - bongo_cat_windows_direct_input_reset(&platform); - CHECK(!bongo_cat_platform_relative_pointer(&platform, &x, &y)); - bongo_cat_windows_direct_input_destroy(&platform); - CHECK(platform.relative_pointer == NULL); - } - DestroyWindow(window); -} - -int main(void) { - HWND window = CreateWindowExW(WS_EX_TOOLWINDOW, L"STATIC", - L"BongoCat capture test", WS_POPUP, 0, 0, 32, 32, - NULL, NULL, GetModuleHandleW(NULL), NULL); - CHECK(window != NULL); - if (window) { - CHECK(bongo_cat_windows_capture_configure(window)); - LONG_PTR style = GetWindowLongPtrW(window, GWL_EXSTYLE); - CHECK((style & WS_EX_APPWINDOW) != 0); - CHECK((style & WS_EX_TOOLWINDOW) == 0); - CHECK(!bongo_cat_windows_capture_handle_message(window, WM_NULL, 0)); - CHECK(!bongo_cat_windows_capture_handle_message(window, WM_TIMER, 1)); - DestroyWindow(window); - } - test_popup_completion(); - test_input_startup(); - test_direct_input_rebase(); - return failures ? 1 : 0; -} diff --git a/tests/ui/test_input_monitoring_refresh.c b/tests/ui/test_input_monitoring_refresh.c new file mode 100644 index 00000000..1ae421a3 --- /dev/null +++ b/tests/ui/test_input_monitoring_refresh.c @@ -0,0 +1,75 @@ +#include "preferences_state.h" + +#include +#include + +/* The target compiles the real preferences_pages.c with this getter substituted + for the platform one, so the refresh path runs without touching TCC. */ +static bool probe_authorized; +static unsigned probe_reads; + +bool bongo_cat_test_input_monitoring_authorized(void) { + probe_reads++; + return probe_authorized; +} + +static int failures; + +static void check(bool condition, const char *what) { + if (!condition) { + fprintf(stderr, "failed: %s\n", what); + failures++; + } +} + +int main(void) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + if (!value) { + fprintf(stderr, "cannot allocate preferences\n"); + return 2; + } + + /* First reading: the page has never queried the platform, so the frame it is + drawing now needs another one to show what was read. */ + probe_authorized = false; + probe_reads = 0; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(value->input_monitoring_valid, "first refresh marks the reading valid"); + check(!value->input_monitoring_authorized, + "first refresh stores the missing permission"); + check(value->render_dirty, "first refresh repaints the page"); + check(probe_reads == 1, "first refresh reads the platform once"); + + /* The user granted the permission while the page was open: the button the + current frame drew is stale, so the next frame has to draw again. */ + probe_authorized = true; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(value->input_monitoring_authorized, + "granted refresh stores the permission"); + check(value->render_dirty, "granted refresh repaints the page"); + + /* Revoked again: the same kind of change, in the other direction. */ + probe_authorized = false; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(!value->input_monitoring_authorized, + "revoked refresh stores the missing permission"); + check(value->render_dirty, "revoked refresh repaints the page"); + + /* An unchanged reading must not schedule a repaint, or every refresh point + would keep the page rendering. */ + probe_reads = 0; + value->render_dirty = false; + bongo_cat_preferences_input_monitoring_refresh(value); + check(!value->input_monitoring_authorized, + "unchanged refresh keeps the reading"); + check(!value->render_dirty, "unchanged refresh does not repaint"); + check(probe_reads == 1, "unchanged refresh still reads the platform once"); + + free(value); + if (failures) return 1; + puts("input monitoring refresh repaint checks passed"); + return 0; +} diff --git a/tests/test_nuklear.c b/tests/ui/test_nuklear.c similarity index 96% rename from tests/test_nuklear.c rename to tests/ui/test_nuklear.c index 736719c1..5bf94b37 100644 --- a/tests/test_nuklear.c +++ b/tests/ui/test_nuklear.c @@ -1,5 +1,5 @@ -#include "../src/ui/nuklear_config.h" -#include "../src/ui/ui_paint_border.h" +#include "nuklear_config.h" +#include "ui_paint_border.h" #include #include diff --git a/tests/ui/test_preferences_lifecycle.c b/tests/ui/test_preferences_lifecycle.c new file mode 100644 index 00000000..9458a634 --- /dev/null +++ b/tests/ui/test_preferences_lifecycle.c @@ -0,0 +1,348 @@ +#include "preferences_state.h" +#include "preferences_import_internal.h" +#include "preferences_render_internal.h" +#include "ui_catime.h" +#include "runtime.h" +#include "bongo_cat/file.h" +#include "bongo_cat/path.h" +#include "about/preferences_about_internal.h" +#include "ui_paint_cache.h" +#include "ui_paint.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#else +#include +#include +#endif + +static int failures; +#define CHECK(condition) do { if (!(condition)) { \ + fprintf(stderr, "%d: %s\n", __LINE__, #condition); failures++; \ +} } while (0) + +static void busy(BongoCatImportDialog *dialog, bool open, bool importing) { + SDL_LockMutex(dialog->mutex); + dialog->open = open; + dialog->busy = importing; + SDL_UnlockMutex(dialog->mutex); +} + +static void about_disk_cache(BongoCatApp *app) { + char root[BONGO_CAT_PATH_CAP], directory[BONGO_CAT_PATH_CAP], path[BONGO_CAT_PATH_CAP]; + CHECK(bongo_cat_path_join(root, sizeof(root), app->cache_root, "about-cache-test")); + CHECK(bongo_cat_path_join(directory, sizeof(directory), root, "about")); + CHECK(bongo_cat_path_create_directory(directory)); + CHECK(bongo_cat_path_join(path, sizeof(path), directory, "wechat-v1.svg")); + /* Larger than the network's initial buffer, but well below the QR limit. */ + char svg[9000]; + snprintf(svg, sizeof(svg), + "" + "", 0); + size_t svg_size = strlen(svg); + Uint32 event_type = SDL_RegisterEvents(1); + for (int stale = 0; stale < 2; stale++) { + FILE *file = bongo_cat_file_open(path, "wb"); + CHECK(file != NULL); + if (!file) return; + CHECK(fwrite(svg, 1, svg_size, file) == svg_size); + CHECK(fflush(file) == 0); + if (stale) { + time_t old = time(NULL) - 2 * 86400; +#ifdef _WIN32 + struct __utimbuf64 times = {old, old}; + CHECK(_futime64(_fileno(file), ×) == 0); +#else + struct timespec times[2] = {{old, 0}, {old, 0}}; + CHECK(futimens(fileno(file), times) == 0); +#endif + } + CHECK(fclose(file) == 0); + SDL_PathInfo before = {0}, after = {0}; + CHECK(SDL_GetPathInfo(path, &before)); + BongoCatAboutRequest *job = bongo_cat_about_request(BONGO_ABOUT_WECHAT, + event_type, 0, root, false); + CHECK(job != NULL); + if (!job) return; + SDL_WaitThread(job->thread, NULL); + job->thread = NULL; + CHECK(SDL_GetAtomicInt(&job->done)); + CHECK(job->status == 200 && job->qr_pixels); + CHECK(job->refresh_needed == (stale != 0)); + CHECK(!job->response && !job->capacity); + bongo_cat_about_request_free(job); + CHECK(SDL_GetPathInfo(path, &after)); + CHECK(before.modify_time == after.modify_time); + file = bongo_cat_file_open(path, "rb"); + CHECK(file != NULL); + if (file) { + char bytes[sizeof(svg)] = {0}; + CHECK(fread(bytes, 1, svg_size, file) == svg_size); + CHECK(strcmp(bytes, svg) == 0); + CHECK(fclose(file) == 0); + } + SDL_FlushEvent(event_type); + } + CHECK(bongo_cat_file_remove(path)); + CHECK(bongo_cat_path_remove(directory)); + CHECK(bongo_cat_path_remove(root)); +} + +static void about_refresh_failure(BongoCatApp *app) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + CHECK(value != NULL); + if (!value) return; + value->app = app; + value->visible = true; + value->about.event_type = SDL_RegisterEvents(1); + value->about.qr_attempted = value->about.contributors_attempted = true; + value->about.qr_pixels = calloc(240 * 240, 4); + value->about.contributors = calloc(1, sizeof(*value->about.contributors)); + unsigned char *pixels = value->about.qr_pixels; + BongoCatAboutFeed *feed = value->about.contributors; + CHECK(pixels && feed); + for (int cancelled = 0; cancelled < 2; cancelled++) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + CHECK(job != NULL); + if (!job) break; + job->kind = BONGO_ABOUT_WECHAT; + /* A failed refresh and a cancelled successful refresh must both + preserve the old cache without automatically retrying. */ + if (cancelled) { + job->status = 200; + job->qr_pixels = calloc(240 * 240, 4); + job->refresh_needed = true; + SDL_SetAtomicInt(&job->cancel, 1); + } + SDL_SetAtomicInt(&job->done, 1); + value->about.qr_request = job; + bongo_cat_about_refresh(value); + CHECK(value->about.qr_pixels == pixels && value->about.contributors == feed); + CHECK(!value->about.qr_request && !value->about.contributors_request); + CHECK(!value->about.qr_texture_dirty); + } + bongo_cat_about_clear(value, false); + free(value); +} + +static void about_unchanged_contributors(BongoCatApp *app) { + BongoCatPreferences *value = calloc(1, sizeof(*value)); + CHECK(value != NULL); + if (!value) return; + value->app = app; + value->visible = true; + value->about.event_type = SDL_RegisterEvents(1); + value->about.qr_attempted = value->about.contributors_attempted = true; + for (int update = 0; update < 4; update++) { + BongoCatAboutRequest *job = calloc(1, sizeof(*job)); + CHECK(job != NULL); + if (!job) break; + job->feed = calloc(1, sizeof(*job->feed)); + CHECK(job->feed != NULL); + if (!job->feed) { free(job); break; } + job->feed->count = 1; + job->feed->people[0].pixels = calloc(BONGO_ABOUT_AVATAR_SIZE * BONGO_ABOUT_AVATAR_SIZE, 4); + CHECK(job->feed->people[0].pixels != NULL); + if (!job->feed->people[0].pixels) { bongo_cat_about_request_free(job); break; } + if (update >= 2) job->feed->people[0].pixels[0] = 255; + if (update == 3) strcpy(job->feed->people[0].name, "Renamed contributor"); + job->kind = BONGO_ABOUT_CONTRIBUTORS; + job->status = 200; + SDL_SetAtomicInt(&job->done, 1); + BongoCatAboutFeed *previous = value->about.contributors; + value->about.contributors_request = job; + value->about.portraits_loaded = true; + value->about.portraits_next = 1; + bongo_cat_about_refresh(value); + CHECK(!value->about.contributors_request); + if (update == 1) { + CHECK(value->about.contributors == previous); + CHECK(value->about.portraits_loaded && value->about.portraits_next == 1); + } else { + CHECK(value->about.contributors != previous); + CHECK(!value->about.portraits_loaded && value->about.portraits_next == 0); + } + } + bongo_cat_about_clear(value, false); + free(value); +} + +static void about_session_cache(BongoCatPreferences *value) { + BongoCatAboutState *s = &value->about; + CHECK(s->contributors_attempted || (s->contributors_request && + SDL_GetAtomicInt(&s->contributors_request->cancel))); + CHECK(s->qr_attempted || (s->qr_request && SDL_GetAtomicInt(&s->qr_request->cancel))); + BongoCatAboutRequest *contributors_request = s->contributors_request; + BongoCatAboutRequest *qr_request = s->qr_request; + /* A second show only raises the already open window. */ + bongo_cat_preferences_show(value); + CHECK(s->contributors_request == contributors_request); + CHECK(s->qr_request == qr_request); + if (!s->contributors) s->contributors = calloc(1, sizeof(*s->contributors)); + if (!s->qr_pixels) s->qr_pixels = calloc(240 * 240, 4); + BongoCatAboutFeed *feed = s->contributors; + unsigned char *pixels = s->qr_pixels; + CHECK(feed && pixels); + s->qr_open = true; + SDL_GL_MakeCurrent(value->window, value->gl_context); + bongo_cat_preferences_page_cache_clear(value, 3, 0); + bongo_cat_preferences_page_cache_clear(value, 0, 3); + CHECK(!s->qr_open); + CHECK(s->contributors == feed && s->qr_pixels == pixels); + CHECK(s->contributors_request == contributors_request && s->qr_request == qr_request); + /* A DPI/theme asset rebuild must retain downloaded data and attempt flags. */ + bool qr_attempted = s->qr_attempted; + bongo_cat_about_assets_clear(value, true); + CHECK(s->qr_attempted == qr_attempted); + CHECK(s->contributors == feed && s->qr_pixels == pixels); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); +} + +static float original_paragraph_height(const char *text, + const struct nk_user_font *font, float width, float leading) { + float height = 0; + while (*text) { + int length = 0, remaining = (int)strlen(text); + while (length < remaining) { + nk_rune rune; + int bytes = nk_utf_decode(text + length, &rune, remaining - length); + if (bytes <= 0) break; + float next = font->width(font->userdata, font->height, text, length + bytes); + if (length && next > width) break; + length += bytes; + if (rune == '\n') break; + } + if (!length) break; + height += leading; + text += length; + while (*text == ' ' || *text == '\n') text++; + } + return height; +} + +static void about_render_cost_regressions(BongoCatPreferences *value) { + const char *texts[] = {"Open source & community contributions", + "Every step of BongoCat comes from open source. Thank you to all our contributors.", + "\xe6\x84\x9f\xe8\xb0\xa2\xe6\x89\x80\xe6\x9c\x89\xe8\xb4\xa1\xe7\x8c\xae\xe8\x80\x85 BongoCat", + "First line\n Second line", "", "A"}; + const struct nk_user_font *font = value->ui.caption_font; + for (size_t i = 0; i < sizeof(texts) / sizeof(texts[0]); i++) + for (int width = 1; width <= 400; width += 3) { + float actual = bongo_cat_about_paragraph(NULL, nk_rect(0, 0, (float)width, 0), + texts[i], font, nk_rgb(0, 0, 0), false, 24); + CHECK(actual == original_paragraph_height(texts[i], font, (float)width, 24)); + } + SDL_GL_MakeCurrent(value->window, value->gl_context); + BongoCatUIPaintKey key = {BONGO_CAT_UI_PAINT_SHADOW, 8, 8, 4, 2, 0, 0, 0}; + unsigned char pixels[64] = {0}; + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + BongoCatUIPaintTexture *item = bongo_cat_ui_paint_cache_get(&value->ui, &key); + CHECK(item && bongo_cat_ui_paint_cache_upload(item, pixels, true)); + /* One unused frame and idle trimming must not discard a small hover effect. */ + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + bongo_cat_ui_paint_cache_begin_frame(&value->ui); + bongo_cat_ui_trim_idle(&value->ui); + item = bongo_cat_ui_paint_cache_get(&value->ui, &key); + CHECK(bongo_cat_ui_paint_cache_ready(item)); + CHECK(bongo_cat_ui_paint_cache_usage(&value->ui, NULL) <= 4u * 1024u * 1024u); + bongo_cat_ui_paint_destroy(&value->ui); + CHECK(bongo_cat_ui_paint_cache_usage(&value->ui, NULL) == 0); + SDL_GL_MakeCurrent(value->app->window, value->app->gl_context); +} + +static void close_over_scrolled_models(BongoCatPreferences *value) { + if (bongo_cat_ui_native_chrome()) return; + struct nk_context *context = &value->ui.context; + value->page = 1; + value->page_seen = true; + value->last_page = 1; + value->page_transition_ns = 0; + for (int scroll = 0; scroll <= 800; scroll += 40) { + nk_input_begin(context); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_false); + nk_input_end(context); + bongo_cat_preferences_draw_frame(value, 800, 500, false); + nk_clear(context); + value->scroll_ready[1] = true; + value->scroll_current[1] = (float)scroll; + value->scroll_target[1] = (float)scroll; + nk_input_begin(context); + nk_input_motion(context, 746, 36); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_true); + nk_input_button(context, NK_BUTTON_LEFT, 746, 36, nk_false); + nk_input_end(context); + CHECK(bongo_cat_preferences_draw_frame(value, 800, 500, false)); + CHECK(!value->model_selection_pending); + value->model_selection_pending = false; + nk_clear(context); + } +} + +int main(int argc, char **argv) { + BongoCatApp *app = calloc(1, sizeof(*app)); + BongoCatError error = {0}; + if (!app) return 1; + if (!bongo_cat_app_initialize(app, argc, argv, &error)) { + fprintf(stderr, "Initialization failed: %s\n", error.message); + bongo_cat_app_shutdown(app, "test:failed", 1); + free(app); + return 1; + } + BongoCatPreferences *value = app->preferences; + about_disk_cache(app); + about_refresh_failure(app); + about_unchanged_contributors(app); + CHECK(value != NULL); + if (value) { + for (int cycle = 0; cycle < 3; ++cycle) { + bongo_cat_preferences_show(value); + CHECK(value->window && value->gl_context && value->ui_initialized); + if (!cycle) about_render_cost_regressions(value); + about_session_cache(value); + bongo_cat_preferences_close(value); + CHECK(!value->about.contributors && !value->about.qr_pixels); + CHECK(!value->about.contributors_attempted && !value->about.qr_attempted); + CHECK(!value->about.contributors_request || + SDL_GetAtomicInt(&value->about.contributors_request->cancel)); + CHECK(!value->about.qr_request || + SDL_GetAtomicInt(&value->about.qr_request->cancel)); + CHECK(!value->window && !value->gl_context && !value->ui_initialized); + CHECK(SDL_GL_GetCurrentContext() == app->gl_context); + } + bongo_cat_preferences_show(value); + SDL_Window *window = value->window; + busy(value->import_dialog, true, false); + bongo_cat_preferences_close(value); + bongo_cat_preferences_render(value); + CHECK(value->window == window && !value->visible); + CHECK(!value->about.contributors_attempted && !value->about.qr_attempted); + busy(value->import_dialog, false, true); + bongo_cat_preferences_render(value); + CHECK(value->window == window); + bongo_cat_preferences_show(value); + CHECK(value->window == window && value->visible); + about_session_cache(value); + busy(value->import_dialog, false, false); + bongo_cat_preferences_render(value); + CHECK(value->window == window && value->visible); + busy(value->import_dialog, false, true); + bongo_cat_preferences_close(value); + CHECK(value->window == window && !value->visible); + busy(value->import_dialog, false, false); + bongo_cat_preferences_render(value); + CHECK(!value->window && !value->gl_context && !value->ui_initialized); + bongo_cat_preferences_show(value); + CHECK(value->visible && value->ui_initialized); + close_over_scrolled_models(value); + bongo_cat_preferences_close(value); + } + bongo_cat_app_shutdown(app, "test:complete", failures != 0); + free(app); + printf("Preferences lifecycle: %d failures\n", failures); + return failures != 0; +} diff --git a/tests/ui/test_preferences_text.c b/tests/ui/test_preferences_text.c new file mode 100644 index 00000000..d67902fd --- /dev/null +++ b/tests/ui/test_preferences_text.c @@ -0,0 +1,136 @@ +#include "preferences_text_edit.h" +#include "preferences_text_session.h" +#include "ui_backend.h" + +#include +#include +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { \ + fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #value); \ + failures++; \ +} } while (0) + +static float fixed_width(const void *userdata, const char *text, size_t length) { + (void)userdata; + (void)text; + return (float)length; +} + +static void text_edit(void) { + char text[32] = "A\xE4\xB8\xAD" "B"; + size_t cursor = 0; + bool selected = false; + bongo_cat_text_edit_begin(text, &cursor, &selected); + CHECK(cursor == 5 && !selected); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_LEFT)); + CHECK(cursor == 4); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x96\xB0")); + CHECK(strcmp(text, "A\xE4\xB8\xAD\xE6\x96\xB0" "B") == 0 && + cursor == 7); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(strcmp(text, "A\xE4\xB8\xAD" "B") == 0 && cursor == 4); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_HOME)); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, true)); + CHECK(strcmp(text, "\xE4\xB8\xAD" "B") == 0 && cursor == 0); + CHECK(bongo_cat_text_edit_move(text, &cursor, &selected, + BONGO_CAT_TEXT_EDIT_RIGHT)); + CHECK(cursor == 3); + CHECK(bongo_cat_text_edit_nearest(text, 1.4f, fixed_width, NULL) == 0); + CHECK(bongo_cat_text_edit_nearest(text, 1.6f, fixed_width, NULL) == 3); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(bongo_cat_text_edit_insert(text, sizeof(text), &cursor, &selected, + "\xE6\x9B\xBF\xE6\x8D\xA2")); + CHECK(strcmp(text, "\xE6\x9B\xBF\xE6\x8D\xA2") == 0 && + cursor == 6 && !selected); + bongo_cat_text_edit_select_all(text, &cursor, &selected); + CHECK(bongo_cat_text_edit_erase(text, &cursor, &selected, false)); + CHECK(text[0] == '\0' && cursor == 0 && !selected); + snprintf(text, sizeof(text), " label\t "); + bongo_cat_text_edit_trim(text); + CHECK(strcmp(text, "label") == 0); +} + +static void text_session(void) { + BongoCatPreferencesTextSession session = {0}; + bongo_cat_preferences_text_session_begin(&session, "model", "Name", + nk_rect(10, 20, 100, 30)); + CHECK(bongo_cat_preferences_text_session_active(&session)); + SDL_Event event = {.type = SDL_EVENT_KEY_DOWN}; + event.key.key = SDLK_A; + event.key.mod = SDL_KMOD_CTRL; + BongoCatPreferencesTextSessionEvent result = + bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(result.handled && !result.finish && session.select_all); + event.key.key = SDLK_DELETE; + event.key.mod = SDL_KMOD_NONE; + result = bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(result.handled && session.text[0] == '\0' && !session.select_all); + event.type = SDL_EVENT_MOUSE_BUTTON_DOWN; + event.button.button = SDL_BUTTON_LEFT; + event.button.x = 200; + event.button.y = 200; + result = bongo_cat_preferences_text_session_event(&session, &event, + 1.0f, NULL, 5.0f); + CHECK(!result.handled && result.finish && result.save); + bongo_cat_preferences_text_session_reset(&session); + CHECK(!bongo_cat_preferences_text_session_active(&session)); +} + +static void input_release_order(void) { + BongoCatUIBackend ui = {0}; + const SDL_Keycode keys[] = {SDLK_A, SDLK_C, SDLK_V, SDLK_X, SDLK_Z, + SDLK_LEFT, SDLK_RIGHT}; + const enum nk_keys actions[] = {NK_KEY_TEXT_SELECT_ALL, NK_KEY_COPY, + NK_KEY_PASTE, NK_KEY_CUT, NK_KEY_TEXT_UNDO, + NK_KEY_TEXT_WORD_LEFT, NK_KEY_TEXT_WORD_RIGHT}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) { + SDL_Event event = {.type = SDL_EVENT_KEY_DOWN}; + event.key.key = keys[i]; + event.key.down = true; + event.key.mod = SDL_KMOD_CTRL; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[actions[i]].down); + event.type = SDL_EVENT_KEY_UP; + event.key.key = SDLK_LCTRL; + event.key.down = false; + event.key.mod = SDL_KMOD_NONE; + bongo_cat_ui_event(&ui, &event); + event.key.key = keys[i]; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[actions[i]].down); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_LEFT].down); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_RIGHT].down); + } + SDL_Event event = {.type = SDL_EVENT_KEY_UP}; + event.key.key = SDLK_LSHIFT; + event.key.mod = SDL_KMOD_RSHIFT; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[NK_KEY_SHIFT].down); + event.key.key = SDLK_RSHIFT; + event.key.mod = SDL_KMOD_NONE; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_SHIFT].down); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_LEFT; + event.key.down = true; + bongo_cat_ui_event(&ui, &event); + CHECK(ui.context.input.keyboard.keys[NK_KEY_LEFT].down); + event.type = SDL_EVENT_WINDOW_FOCUS_LOST; + bongo_cat_ui_event(&ui, &event); + CHECK(!ui.context.input.keyboard.keys[NK_KEY_LEFT].down); +} + +int test_preferences_text(void) { + failures = 0; + text_edit(); + text_session(); + input_release_order(); + return failures; +} diff --git a/vendor/nanosvg/LICENSE.txt b/vendor/nanosvg/LICENSE.txt new file mode 100644 index 00000000..f896f2eb --- /dev/null +++ b/vendor/nanosvg/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (c) 2013-14 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/vendor/nanosvg/nanosvg.h b/vendor/nanosvg/nanosvg.h new file mode 100644 index 00000000..291ecbf8 --- /dev/null +++ b/vendor/nanosvg/nanosvg.h @@ -0,0 +1,3278 @@ +/* + * Copyright (c) 2013-14 Mikko Mononen memon@inside.org + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * 3. This notice may not be removed or altered from any source distribution. + * + * The SVG parser is based on Anti-Grain Geometry 2.4 SVG example + * Copyright (C) 2002-2004 Maxim Shemanarev (McSeem) (http://www.antigrain.com/) + * + * Arc calculation code based on canvg (https://code.google.com/p/canvg/) + * + * Bounding box calculation based on http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html + * + */ + +#ifndef NANOSVG_H +#define NANOSVG_H + +#ifndef NANOSVG_CPLUSPLUS +#ifdef __cplusplus +extern "C" { +#endif +#endif + +// NanoSVG is a simple stupid single-header-file SVG parse. The output of the parser is a list of cubic bezier shapes. +// +// The library suits well for anything from rendering scalable icons in your editor application to prototyping a game. +// +// NanoSVG supports a wide range of SVG features, but something may be missing, feel free to create a pull request! +// +// The shapes in the SVG images are transformed by the viewBox and converted to specified units. +// That is, you should get the same looking data as your designed in your favorite app. +// +// NanoSVG can return the paths in few different units. For example if you want to render an image, you may choose +// to get the paths in pixels, or if you are feeding the data into a CNC-cutter, you may want to use millimeters. +// +// The units passed to NanoSVG should be one of: 'px', 'pt', 'pc' 'mm', 'cm', or 'in'. +// DPI (dots-per-inch) controls how the unit conversion is done. +// +// If you don't know or care about the units stuff, "px" and 96 should get you going. + + +/* Example Usage: + // Load SVG + NSVGimage* image; + image = nsvgParseFromFile("test.svg", "px", 96); + printf("size: %f x %f\n", image->width, image->height); + // Use... + for (NSVGshape *shape = image->shapes; shape != NULL; shape = shape->next) { + for (NSVGpath *path = shape->paths; path != NULL; path = path->next) { + for (int i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + drawCubicBez(p[0],p[1], p[2],p[3], p[4],p[5], p[6],p[7]); + } + } + } + // Delete + nsvgDelete(image); +*/ + +enum NSVGpaintType { + NSVG_PAINT_UNDEF = -1, + NSVG_PAINT_NONE = 0, + NSVG_PAINT_COLOR = 1, + NSVG_PAINT_LINEAR_GRADIENT = 2, + NSVG_PAINT_RADIAL_GRADIENT = 3 +}; + +enum NSVGspreadType { + NSVG_SPREAD_PAD = 0, + NSVG_SPREAD_REFLECT = 1, + NSVG_SPREAD_REPEAT = 2 +}; + +enum NSVGlineJoin { + NSVG_JOIN_MITER = 0, + NSVG_JOIN_ROUND = 1, + NSVG_JOIN_BEVEL = 2 +}; + +enum NSVGlineCap { + NSVG_CAP_BUTT = 0, + NSVG_CAP_ROUND = 1, + NSVG_CAP_SQUARE = 2 +}; + +enum NSVGfillRule { + NSVG_FILLRULE_NONZERO = 0, + NSVG_FILLRULE_EVENODD = 1 +}; + +enum NSVGflags { + NSVG_FLAGS_VISIBLE = 0x01 +}; + +enum NSVGpaintOrder { + NSVG_PAINT_FILL = 0x00, + NSVG_PAINT_MARKERS = 0x01, + NSVG_PAINT_STROKE = 0x02, +}; + +typedef struct NSVGgradientStop { + unsigned int color; + float offset; +} NSVGgradientStop; + +typedef struct NSVGgradient { + float xform[6]; + char spread; + float fx, fy; + int nstops; + NSVGgradientStop stops[1]; +} NSVGgradient; + +typedef struct NSVGpaint { + signed char type; + union { + unsigned int color; + NSVGgradient* gradient; + }; +} NSVGpaint; + +typedef struct NSVGpath +{ + float* pts; // Cubic bezier points: x0,y0, [cpx1,cpx1,cpx2,cpy2,x1,y1], ... + int npts; // Total number of bezier points. + char closed; // Flag indicating if shapes should be treated as closed. + float bounds[4]; // Tight bounding box of the shape [minx,miny,maxx,maxy]. + struct NSVGpath* next; // Pointer to next path, or NULL if last element. +} NSVGpath; + +typedef struct NSVGshape +{ + char id[64]; // Optional 'id' attr of the shape or its group + NSVGpaint fill; // Fill paint + NSVGpaint stroke; // Stroke paint + float opacity; // Opacity of the shape. + float strokeWidth; // Stroke width (scaled). + float strokeDashOffset; // Stroke dash offset (scaled). + float strokeDashArray[8]; // Stroke dash array (scaled). + char strokeDashCount; // Number of dash values in dash array. + char strokeLineJoin; // Stroke join type. + char strokeLineCap; // Stroke cap type. + float miterLimit; // Miter limit + char fillRule; // Fill rule, see NSVGfillRule. + unsigned char paintOrder; // Encoded paint order (3×2-bit fields) see NSVGpaintOrder + unsigned char flags; // Logical or of NSVG_FLAGS_* flags + float bounds[4]; // Tight bounding box of the shape [minx,miny,maxx,maxy]. + char fillGradient[64]; // Optional 'id' of fill gradient + char strokeGradient[64]; // Optional 'id' of stroke gradient + float xform[6]; // Root transformation for fill/stroke gradient + NSVGpath* paths; // Linked list of paths in the image. + struct NSVGshape* next; // Pointer to next shape, or NULL if last element. +} NSVGshape; + +typedef struct NSVGimage +{ + float width; // Width of the image. + float height; // Height of the image. + NSVGshape* shapes; // Linked list of shapes in the image. +} NSVGimage; + +// Parses SVG file from a file, returns SVG image as paths. +NSVGimage* nsvgParseFromFile(const char* filename, const char* units, float dpi); + +// Parses SVG file from a null terminated string, returns SVG image as paths. +// Important note: changes the string. +NSVGimage* nsvgParse(char* input, const char* units, float dpi); + +// Duplicates a path. +NSVGpath* nsvgDuplicatePath(NSVGpath* p); + +// Deletes an image. +void nsvgDelete(NSVGimage* image); + +#ifndef NANOSVG_CPLUSPLUS +#ifdef __cplusplus +} +#endif +#endif + +#ifdef NANOSVG_IMPLEMENTATION + +#include +#include +#include +#include + +#define NSVG_PI (3.14159265358979323846264338327f) +#define NSVG_KAPPA90 (0.5522847493f) // Length proportional to radius of a cubic bezier handle for 90deg arcs. + +#define NSVG_ALIGN_MIN 0 +#define NSVG_ALIGN_MID 1 +#define NSVG_ALIGN_MAX 2 +#define NSVG_ALIGN_NONE 0 +#define NSVG_ALIGN_MEET 1 +#define NSVG_ALIGN_SLICE 2 + +#define NSVG_NOTUSED(v) do { (void)(1 ? (void)0 : ( (void)(v) ) ); } while(0) +#define NSVG_RGB(r, g, b) (((unsigned int)r) | ((unsigned int)g << 8) | ((unsigned int)b << 16)) + +#ifdef _MSC_VER + #pragma warning (disable: 4996) // Switch off security warnings + #pragma warning (disable: 4100) // Switch off unreferenced formal parameter warnings + #ifdef __cplusplus + #define NSVG_INLINE inline + #else + #define NSVG_INLINE + #endif +#else + #define NSVG_INLINE inline +#endif + + +static int nsvg__isspace(char c) +{ + return strchr(" \t\n\v\f\r", c) != 0; +} + +static int nsvg__isdigit(char c) +{ + return c >= '0' && c <= '9'; +} + +static NSVG_INLINE float nsvg__minf(float a, float b) { return a < b ? a : b; } +static NSVG_INLINE float nsvg__maxf(float a, float b) { return a > b ? a : b; } + + +// Simple XML parser + +#define NSVG_XML_TAG 1 +#define NSVG_XML_CONTENT 2 +#define NSVG_XML_MAX_ATTRIBS 256 + +static void nsvg__parseContent(char* s, + void (*contentCb)(void* ud, const char* s), + void* ud) +{ + // Trim start white spaces + while (*s && nsvg__isspace(*s)) s++; + if (!*s) return; + + if (contentCb) + (*contentCb)(ud, s); +} + +static void nsvg__parseElement(char* s, + void (*startelCb)(void* ud, const char* el, const char** attr), + void (*endelCb)(void* ud, const char* el), + void* ud) +{ + const char* attr[NSVG_XML_MAX_ATTRIBS]; + int nattr = 0; + char* name; + int start = 0; + int end = 0; + char quote; + + // Skip white space after the '<' + while (*s && nsvg__isspace(*s)) s++; + + // Check if the tag is end tag + if (*s == '/') { + s++; + end = 1; + } else { + start = 1; + } + + // Skip comments, data and preprocessor stuff. + if (!*s || *s == '?' || *s == '!') + return; + + // Get tag name + name = s; + while (*s && !nsvg__isspace(*s)) s++; + if (*s) { *s++ = '\0'; } + + // Get attribs + while (!end && *s && nattr < NSVG_XML_MAX_ATTRIBS-3) { + char* name = NULL; + char* value = NULL; + + // Skip white space before the attrib name + while (*s && nsvg__isspace(*s)) s++; + if (!*s) break; + if (*s == '/') { + end = 1; + break; + } + name = s; + // Find end of the attrib name. + while (*s && !nsvg__isspace(*s) && *s != '=') s++; + if (*s) { *s++ = '\0'; } + // Skip until the beginning of the value. + while (*s && *s != '\"' && *s != '\'') s++; + if (!*s) break; + quote = *s; + s++; + // Store value and find the end of it. + value = s; + while (*s && *s != quote) s++; + if (*s) { *s++ = '\0'; } + + // Store only well formed attributes + if (name && value) { + attr[nattr++] = name; + attr[nattr++] = value; + } + } + + // List terminator + attr[nattr++] = 0; + attr[nattr++] = 0; + + // Call callbacks. + if (start && startelCb) + (*startelCb)(ud, name, attr); + if (end && endelCb) + (*endelCb)(ud, name); +} + +int nsvg__parseXML(char* input, + void (*startelCb)(void* ud, const char* el, const char** attr), + void (*endelCb)(void* ud, const char* el), + void (*contentCb)(void* ud, const char* s), + void* ud) +{ + char* s = input; + char* mark = s; + int state = NSVG_XML_CONTENT; + while (*s) { + if (*s == '<' && state == NSVG_XML_CONTENT) { + // Start of a tag + *s++ = '\0'; + nsvg__parseContent(mark, contentCb, ud); + mark = s; + state = NSVG_XML_TAG; + } else if (*s == '>' && state == NSVG_XML_TAG) { + // Start of a content or new tag. + *s++ = '\0'; + nsvg__parseElement(mark, startelCb, endelCb, ud); + mark = s; + state = NSVG_XML_CONTENT; + } else { + s++; + } + } + + return 1; +} + + +/* Simple SVG parser. */ + +#define NSVG_MAX_ATTR 128 + +enum NSVGgradientUnits { + NSVG_USER_SPACE = 0, + NSVG_OBJECT_SPACE = 1 +}; + +#define NSVG_MAX_DASHES 8 +#define NSVG_MAX_CLASSES 32 + +enum NSVGunits { + NSVG_UNITS_USER, + NSVG_UNITS_PX, + NSVG_UNITS_PT, + NSVG_UNITS_PC, + NSVG_UNITS_MM, + NSVG_UNITS_CM, + NSVG_UNITS_IN, + NSVG_UNITS_PERCENT, + NSVG_UNITS_EM, + NSVG_UNITS_EX +}; + +typedef struct NSVGcoordinate { + float value; + int units; +} NSVGcoordinate; + +typedef struct NSVGlinearData { + NSVGcoordinate x1, y1, x2, y2; +} NSVGlinearData; + +typedef struct NSVGradialData { + NSVGcoordinate cx, cy, r, fx, fy; +} NSVGradialData; + +typedef struct NSVGgradientData +{ + char id[64]; + char ref[64]; + signed char type; + union { + NSVGlinearData linear; + NSVGradialData radial; + }; + char spread; + char units; + float xform[6]; + int nstops; + NSVGgradientStop* stops; + struct NSVGgradientData* next; +} NSVGgradientData; + +typedef struct NSVGattrib +{ + char id[64]; + float xform[6]; + unsigned int fillColor; + unsigned int strokeColor; + float opacity; + float fillOpacity; + float strokeOpacity; + char fillGradient[64]; + char strokeGradient[64]; + float strokeWidth; + float strokeDashOffset; + float strokeDashArray[NSVG_MAX_DASHES]; + int strokeDashCount; + char strokeLineJoin; + char strokeLineCap; + float miterLimit; + char fillRule; + float fontSize; + unsigned int stopColor; + float stopOpacity; + float stopOffset; + char hasFill; + char hasStroke; + char visible; + unsigned char paintOrder; +} NSVGattrib; + +typedef struct NSVGstyleDeclaration +{ + char* className; + char* propertiesText; + struct NSVGstyleDeclaration* next; +} NSVGstyleDeclaration; + +typedef struct NSVGparser +{ + NSVGattrib attr[NSVG_MAX_ATTR]; + int attrHead; + float* pts; + int npts; + int cpts; + NSVGpath* plist; + NSVGimage* image; + NSVGstyleDeclaration* styles; + NSVGgradientData* gradients; + NSVGshape* shapesTail; + float viewMinx, viewMiny, viewWidth, viewHeight; + int alignX, alignY, alignType; + float dpi; + char pathFlag; + char defsFlag; + char styleFlag; +} NSVGparser; + +static void nsvg__xformIdentity(float* t) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = 0.0f; t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetTranslation(float* t, float tx, float ty) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = 0.0f; t[3] = 1.0f; + t[4] = tx; t[5] = ty; +} + +static void nsvg__xformSetScale(float* t, float sx, float sy) +{ + t[0] = sx; t[1] = 0.0f; + t[2] = 0.0f; t[3] = sy; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetSkewX(float* t, float a) +{ + t[0] = 1.0f; t[1] = 0.0f; + t[2] = tanf(a); t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetSkewY(float* t, float a) +{ + t[0] = 1.0f; t[1] = tanf(a); + t[2] = 0.0f; t[3] = 1.0f; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformSetRotation(float* t, float a) +{ + float cs = cosf(a), sn = sinf(a); + t[0] = cs; t[1] = sn; + t[2] = -sn; t[3] = cs; + t[4] = 0.0f; t[5] = 0.0f; +} + +static void nsvg__xformMultiply(float* t, float* s) +{ + float t0 = t[0] * s[0] + t[1] * s[2]; + float t2 = t[2] * s[0] + t[3] * s[2]; + float t4 = t[4] * s[0] + t[5] * s[2] + s[4]; + t[1] = t[0] * s[1] + t[1] * s[3]; + t[3] = t[2] * s[1] + t[3] * s[3]; + t[5] = t[4] * s[1] + t[5] * s[3] + s[5]; + t[0] = t0; + t[2] = t2; + t[4] = t4; +} + +static void nsvg__xformInverse(float* inv, float* t) +{ + double invdet, det = (double)t[0] * t[3] - (double)t[2] * t[1]; + if (det > -1e-6 && det < 1e-6) { + nsvg__xformIdentity(t); + return; + } + invdet = 1.0 / det; + inv[0] = (float)(t[3] * invdet); + inv[2] = (float)(-t[2] * invdet); + inv[4] = (float)(((double)t[2] * t[5] - (double)t[3] * t[4]) * invdet); + inv[1] = (float)(-t[1] * invdet); + inv[3] = (float)(t[0] * invdet); + inv[5] = (float)(((double)t[1] * t[4] - (double)t[0] * t[5]) * invdet); +} + +static void nsvg__xformPremultiply(float* t, float* s) +{ + float s2[6]; + memcpy(s2, s, sizeof(float)*6); + nsvg__xformMultiply(s2, t); + memcpy(t, s2, sizeof(float)*6); +} + +static void nsvg__xformPoint(float* dx, float* dy, float x, float y, float* t) +{ + *dx = x*t[0] + y*t[2] + t[4]; + *dy = x*t[1] + y*t[3] + t[5]; +} + +static void nsvg__xformVec(float* dx, float* dy, float x, float y, float* t) +{ + *dx = x*t[0] + y*t[2]; + *dy = x*t[1] + y*t[3]; +} + +#define NSVG_EPSILON (1e-12) + +static int nsvg__ptInBounds(float* pt, float* bounds) +{ + return pt[0] >= bounds[0] && pt[0] <= bounds[2] && pt[1] >= bounds[1] && pt[1] <= bounds[3]; +} + + +static double nsvg__evalBezier(double t, double p0, double p1, double p2, double p3) +{ + double it = 1.0-t; + return it*it*it*p0 + 3.0*it*it*t*p1 + 3.0*it*t*t*p2 + t*t*t*p3; +} + +static void nsvg__curveBounds(float* bounds, float* curve) +{ + int i, j, count; + double roots[2], a, b, c, b2ac, t, v; + float* v0 = &curve[0]; + float* v1 = &curve[2]; + float* v2 = &curve[4]; + float* v3 = &curve[6]; + + // Start the bounding box by end points + bounds[0] = nsvg__minf(v0[0], v3[0]); + bounds[1] = nsvg__minf(v0[1], v3[1]); + bounds[2] = nsvg__maxf(v0[0], v3[0]); + bounds[3] = nsvg__maxf(v0[1], v3[1]); + + // Bezier curve fits inside the convex hull of it's control points. + // If control points are inside the bounds, we're done. + if (nsvg__ptInBounds(v1, bounds) && nsvg__ptInBounds(v2, bounds)) + return; + + // Add bezier curve inflection points in X and Y. + for (i = 0; i < 2; i++) { + a = -3.0 * v0[i] + 9.0 * v1[i] - 9.0 * v2[i] + 3.0 * v3[i]; + b = 6.0 * v0[i] - 12.0 * v1[i] + 6.0 * v2[i]; + c = 3.0 * v1[i] - 3.0 * v0[i]; + count = 0; + if (fabs(a) < NSVG_EPSILON) { + if (fabs(b) > NSVG_EPSILON) { + t = -c / b; + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + } + } else { + b2ac = b*b - 4.0*c*a; + if (b2ac > NSVG_EPSILON) { + t = (-b + sqrt(b2ac)) / (2.0 * a); + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + t = (-b - sqrt(b2ac)) / (2.0 * a); + if (t > NSVG_EPSILON && t < 1.0-NSVG_EPSILON) + roots[count++] = t; + } + } + for (j = 0; j < count; j++) { + v = nsvg__evalBezier(roots[j], v0[i], v1[i], v2[i], v3[i]); + bounds[0+i] = nsvg__minf(bounds[0+i], (float)v); + bounds[2+i] = nsvg__maxf(bounds[2+i], (float)v); + } + } +} + +static unsigned char nsvg__encodePaintOrder(enum NSVGpaintOrder a, enum NSVGpaintOrder b, enum NSVGpaintOrder c) { + return (a & 0x03) | ((b & 0x03) << 2) | ((c & 0x03) << 4); +} + +static NSVGparser* nsvg__createParser(void) +{ + NSVGparser* p; + p = (NSVGparser*)malloc(sizeof(NSVGparser)); + if (p == NULL) goto error; + memset(p, 0, sizeof(NSVGparser)); + + p->image = (NSVGimage*)malloc(sizeof(NSVGimage)); + if (p->image == NULL) goto error; + memset(p->image, 0, sizeof(NSVGimage)); + + // Init style + nsvg__xformIdentity(p->attr[0].xform); + memset(p->attr[0].id, 0, sizeof p->attr[0].id); + p->attr[0].fillColor = NSVG_RGB(0,0,0); + p->attr[0].strokeColor = NSVG_RGB(0,0,0); + p->attr[0].opacity = 1; + p->attr[0].fillOpacity = 1; + p->attr[0].strokeOpacity = 1; + p->attr[0].stopOpacity = 1; + p->attr[0].strokeWidth = 1; + p->attr[0].strokeLineJoin = NSVG_JOIN_MITER; + p->attr[0].strokeLineCap = NSVG_CAP_BUTT; + p->attr[0].miterLimit = 4; + p->attr[0].fillRule = NSVG_FILLRULE_NONZERO; + p->attr[0].hasFill = 1; + p->attr[0].visible = 1; + p->attr[0].paintOrder = nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); + + return p; + +error: + if (p) { + if (p->image) free(p->image); + free(p); + } + return NULL; +} +static void nsvg__deleteStyles(NSVGstyleDeclaration* style) { + while (style) { + NSVGstyleDeclaration* next = style->next; + free(style->className); + free(style->propertiesText); + free(style); + style = next; + } +} + +static void nsvg__deletePaths(NSVGpath* path) +{ + while (path) { + NSVGpath *next = path->next; + if (path->pts != NULL) + free(path->pts); + free(path); + path = next; + } +} + +static void nsvg__deletePaint(NSVGpaint* paint) +{ + if (paint->type == NSVG_PAINT_LINEAR_GRADIENT || paint->type == NSVG_PAINT_RADIAL_GRADIENT) + free(paint->gradient); +} + +static void nsvg__deleteGradientData(NSVGgradientData* grad) +{ + NSVGgradientData* next; + while (grad != NULL) { + next = grad->next; + free(grad->stops); + free(grad); + grad = next; + } +} + +static void nsvg__deleteParser(NSVGparser* p) +{ + if (p != NULL) { + nsvg__deleteStyles(p->styles); + nsvg__deletePaths(p->plist); + nsvg__deleteGradientData(p->gradients); + nsvgDelete(p->image); + free(p->pts); + free(p); + } +} + +static void nsvg__resetPath(NSVGparser* p) +{ + p->npts = 0; +} + +static void nsvg__addPoint(NSVGparser* p, float x, float y) +{ + if (p->npts+1 > p->cpts) { + p->cpts = p->cpts ? p->cpts*2 : 8; + p->pts = (float*)realloc(p->pts, p->cpts*2*sizeof(float)); + if (!p->pts) return; + } + p->pts[p->npts*2+0] = x; + p->pts[p->npts*2+1] = y; + p->npts++; +} + +static void nsvg__moveTo(NSVGparser* p, float x, float y) +{ + if (p->npts > 0) { + p->pts[(p->npts-1)*2+0] = x; + p->pts[(p->npts-1)*2+1] = y; + } else { + nsvg__addPoint(p, x, y); + } +} + +static void nsvg__lineTo(NSVGparser* p, float x, float y) +{ + float px,py, dx,dy; + if (p->npts > 0) { + px = p->pts[(p->npts-1)*2+0]; + py = p->pts[(p->npts-1)*2+1]; + dx = x - px; + dy = y - py; + nsvg__addPoint(p, px + dx/3.0f, py + dy/3.0f); + nsvg__addPoint(p, x - dx/3.0f, y - dy/3.0f); + nsvg__addPoint(p, x, y); + } +} + +static void nsvg__cubicBezTo(NSVGparser* p, float cpx1, float cpy1, float cpx2, float cpy2, float x, float y) +{ + if (p->npts > 0) { + nsvg__addPoint(p, cpx1, cpy1); + nsvg__addPoint(p, cpx2, cpy2); + nsvg__addPoint(p, x, y); + } +} + +static NSVGattrib* nsvg__getAttr(NSVGparser* p) +{ + return &p->attr[p->attrHead]; +} + +static void nsvg__pushAttr(NSVGparser* p) +{ + if (p->attrHead < NSVG_MAX_ATTR-1) { + p->attrHead++; + memcpy(&p->attr[p->attrHead], &p->attr[p->attrHead-1], sizeof(NSVGattrib)); + } +} + +static void nsvg__popAttr(NSVGparser* p) +{ + if (p->attrHead > 0) + p->attrHead--; +} + +static float nsvg__actualOrigX(NSVGparser* p) +{ + return p->viewMinx; +} + +static float nsvg__actualOrigY(NSVGparser* p) +{ + return p->viewMiny; +} + +static float nsvg__actualWidth(NSVGparser* p) +{ + return p->viewWidth; +} + +static float nsvg__actualHeight(NSVGparser* p) +{ + return p->viewHeight; +} + +static float nsvg__actualLength(NSVGparser* p) +{ + float w = nsvg__actualWidth(p), h = nsvg__actualHeight(p); + return sqrtf(w*w + h*h) / sqrtf(2.0f); +} + +static float nsvg__convertToPixels(NSVGparser* p, NSVGcoordinate c, float orig, float length) +{ + NSVGattrib* attr = nsvg__getAttr(p); + switch (c.units) { + case NSVG_UNITS_USER: return c.value; + case NSVG_UNITS_PX: return c.value; + case NSVG_UNITS_PT: return c.value / 72.0f * p->dpi; + case NSVG_UNITS_PC: return c.value / 6.0f * p->dpi; + case NSVG_UNITS_MM: return c.value / 25.4f * p->dpi; + case NSVG_UNITS_CM: return c.value / 2.54f * p->dpi; + case NSVG_UNITS_IN: return c.value * p->dpi; + case NSVG_UNITS_EM: return c.value * attr->fontSize; + case NSVG_UNITS_EX: return c.value * attr->fontSize * 0.52f; // x-height of Helvetica. + case NSVG_UNITS_PERCENT: return orig + c.value / 100.0f * length; + default: return c.value; + } + return c.value; +} + +static NSVGgradientData* nsvg__findGradientData(NSVGparser* p, const char* id) +{ + NSVGgradientData* grad = p->gradients; + if (id == NULL || *id == '\0') + return NULL; + while (grad != NULL) { + if (strcmp(grad->id, id) == 0) + return grad; + grad = grad->next; + } + return NULL; +} + +static NSVGgradient* nsvg__createGradient(NSVGparser* p, const char* id, const float* localBounds, float *xform, signed char* paintType) +{ + NSVGgradientData* data = NULL; + NSVGgradientData* ref = NULL; + NSVGgradientStop* stops = NULL; + NSVGgradient* grad; + float ox, oy, sw, sh, sl; + int nstops = 0; + int refIter; + + data = nsvg__findGradientData(p, id); + if (data == NULL) return NULL; + + // TODO: use ref to fill in all unset values too. + ref = data; + refIter = 0; + while (ref != NULL) { + NSVGgradientData* nextRef = NULL; + if (stops == NULL && ref->stops != NULL) { + stops = ref->stops; + nstops = ref->nstops; + break; + } + nextRef = nsvg__findGradientData(p, ref->ref); + if (nextRef == ref) break; // prevent infite loops on malformed data + ref = nextRef; + refIter++; + if (refIter > 32) break; // prevent infite loops on malformed data + } + if (stops == NULL) return NULL; + + grad = (NSVGgradient*)malloc(sizeof(NSVGgradient) + sizeof(NSVGgradientStop)*(nstops-1)); + if (grad == NULL) return NULL; + + // The shape width and height. + if (data->units == NSVG_OBJECT_SPACE) { + ox = localBounds[0]; + oy = localBounds[1]; + sw = localBounds[2] - localBounds[0]; + sh = localBounds[3] - localBounds[1]; + } else { + ox = nsvg__actualOrigX(p); + oy = nsvg__actualOrigY(p); + sw = nsvg__actualWidth(p); + sh = nsvg__actualHeight(p); + } + sl = sqrtf(sw*sw + sh*sh) / sqrtf(2.0f); + + if (data->type == NSVG_PAINT_LINEAR_GRADIENT) { + float x1, y1, x2, y2, dx, dy; + x1 = nsvg__convertToPixels(p, data->linear.x1, ox, sw); + y1 = nsvg__convertToPixels(p, data->linear.y1, oy, sh); + x2 = nsvg__convertToPixels(p, data->linear.x2, ox, sw); + y2 = nsvg__convertToPixels(p, data->linear.y2, oy, sh); + // Calculate transform aligned to the line + dx = x2 - x1; + dy = y2 - y1; + grad->xform[0] = dy; grad->xform[1] = -dx; + grad->xform[2] = dx; grad->xform[3] = dy; + grad->xform[4] = x1; grad->xform[5] = y1; + } else { + float cx, cy, fx, fy, r; + cx = nsvg__convertToPixels(p, data->radial.cx, ox, sw); + cy = nsvg__convertToPixels(p, data->radial.cy, oy, sh); + fx = nsvg__convertToPixels(p, data->radial.fx, ox, sw); + fy = nsvg__convertToPixels(p, data->radial.fy, oy, sh); + r = nsvg__convertToPixels(p, data->radial.r, 0, sl); + // Calculate transform aligned to the circle + grad->xform[0] = r; grad->xform[1] = 0; + grad->xform[2] = 0; grad->xform[3] = r; + grad->xform[4] = cx; grad->xform[5] = cy; + grad->fx = fx / r; + grad->fy = fy / r; + } + + nsvg__xformMultiply(grad->xform, data->xform); + nsvg__xformMultiply(grad->xform, xform); + + grad->spread = data->spread; + memcpy(grad->stops, stops, nstops*sizeof(NSVGgradientStop)); + grad->nstops = nstops; + + *paintType = data->type; + + return grad; +} + +static float nsvg__getAverageScale(float* t) +{ + float sx = sqrtf(t[0]*t[0] + t[2]*t[2]); + float sy = sqrtf(t[1]*t[1] + t[3]*t[3]); + return (sx + sy) * 0.5f; +} + +static void nsvg__getLocalBounds(float* bounds, NSVGshape *shape, float* xform) +{ + NSVGpath* path; + float curve[4*2], curveBounds[4]; + int i, first = 1; + for (path = shape->paths; path != NULL; path = path->next) { + nsvg__xformPoint(&curve[0], &curve[1], path->pts[0], path->pts[1], xform); + for (i = 0; i < path->npts-1; i += 3) { + nsvg__xformPoint(&curve[2], &curve[3], path->pts[(i+1)*2], path->pts[(i+1)*2+1], xform); + nsvg__xformPoint(&curve[4], &curve[5], path->pts[(i+2)*2], path->pts[(i+2)*2+1], xform); + nsvg__xformPoint(&curve[6], &curve[7], path->pts[(i+3)*2], path->pts[(i+3)*2+1], xform); + nsvg__curveBounds(curveBounds, curve); + if (first) { + bounds[0] = curveBounds[0]; + bounds[1] = curveBounds[1]; + bounds[2] = curveBounds[2]; + bounds[3] = curveBounds[3]; + first = 0; + } else { + bounds[0] = nsvg__minf(bounds[0], curveBounds[0]); + bounds[1] = nsvg__minf(bounds[1], curveBounds[1]); + bounds[2] = nsvg__maxf(bounds[2], curveBounds[2]); + bounds[3] = nsvg__maxf(bounds[3], curveBounds[3]); + } + curve[0] = curve[6]; + curve[1] = curve[7]; + } + } +} + +static void nsvg__addShape(NSVGparser* p) +{ + NSVGattrib* attr = nsvg__getAttr(p); + float scale = 1.0f; + NSVGshape* shape; + NSVGpath* path; + int i; + + if (p->plist == NULL) + return; + + shape = (NSVGshape*)malloc(sizeof(NSVGshape)); + if (shape == NULL) goto error; + memset(shape, 0, sizeof(NSVGshape)); + + memcpy(shape->id, attr->id, sizeof shape->id); + memcpy(shape->fillGradient, attr->fillGradient, sizeof shape->fillGradient); + memcpy(shape->strokeGradient, attr->strokeGradient, sizeof shape->strokeGradient); + memcpy(shape->xform, attr->xform, sizeof shape->xform); + scale = nsvg__getAverageScale(attr->xform); + shape->strokeWidth = attr->strokeWidth * scale; + shape->strokeDashOffset = attr->strokeDashOffset * scale; + shape->strokeDashCount = (char)attr->strokeDashCount; + for (i = 0; i < attr->strokeDashCount; i++) + shape->strokeDashArray[i] = attr->strokeDashArray[i] * scale; + shape->strokeLineJoin = attr->strokeLineJoin; + shape->strokeLineCap = attr->strokeLineCap; + shape->miterLimit = attr->miterLimit; + shape->fillRule = attr->fillRule; + shape->opacity = attr->opacity; + shape->paintOrder = attr->paintOrder; + + shape->paths = p->plist; + p->plist = NULL; + + // Calculate shape bounds + shape->bounds[0] = shape->paths->bounds[0]; + shape->bounds[1] = shape->paths->bounds[1]; + shape->bounds[2] = shape->paths->bounds[2]; + shape->bounds[3] = shape->paths->bounds[3]; + for (path = shape->paths->next; path != NULL; path = path->next) { + shape->bounds[0] = nsvg__minf(shape->bounds[0], path->bounds[0]); + shape->bounds[1] = nsvg__minf(shape->bounds[1], path->bounds[1]); + shape->bounds[2] = nsvg__maxf(shape->bounds[2], path->bounds[2]); + shape->bounds[3] = nsvg__maxf(shape->bounds[3], path->bounds[3]); + } + + // Set fill + if (attr->hasFill == 0) { + shape->fill.type = NSVG_PAINT_NONE; + } else if (attr->hasFill == 1) { + shape->fill.type = NSVG_PAINT_COLOR; + shape->fill.color = attr->fillColor; + shape->fill.color |= (unsigned int)(attr->fillOpacity*255) << 24; + } else if (attr->hasFill == 2) { + shape->fill.type = NSVG_PAINT_UNDEF; + } + + // Set stroke + if (attr->hasStroke == 0) { + shape->stroke.type = NSVG_PAINT_NONE; + } else if (attr->hasStroke == 1) { + shape->stroke.type = NSVG_PAINT_COLOR; + shape->stroke.color = attr->strokeColor; + shape->stroke.color |= (unsigned int)(attr->strokeOpacity*255) << 24; + } else if (attr->hasStroke == 2) { + shape->stroke.type = NSVG_PAINT_UNDEF; + } + + // Set flags + shape->flags = (attr->visible ? NSVG_FLAGS_VISIBLE : 0x00); + + // Add to tail + if (p->image->shapes == NULL) + p->image->shapes = shape; + else + p->shapesTail->next = shape; + p->shapesTail = shape; + + return; + +error: + if (shape) free(shape); +} + +static void nsvg__addPath(NSVGparser* p, char closed) +{ + NSVGattrib* attr = nsvg__getAttr(p); + NSVGpath* path = NULL; + float bounds[4]; + float* curve; + int i; + + if (p->npts < 4) + return; + + if (closed) + nsvg__lineTo(p, p->pts[0], p->pts[1]); + + // Expect 1 + N*3 points (N = number of cubic bezier segments). + if ((p->npts % 3) != 1) + return; + + path = (NSVGpath*)malloc(sizeof(NSVGpath)); + if (path == NULL) goto error; + memset(path, 0, sizeof(NSVGpath)); + + path->pts = (float*)malloc(p->npts*2*sizeof(float)); + if (path->pts == NULL) goto error; + path->closed = closed; + path->npts = p->npts; + + // Transform path. + for (i = 0; i < p->npts; ++i) + nsvg__xformPoint(&path->pts[i*2], &path->pts[i*2+1], p->pts[i*2], p->pts[i*2+1], attr->xform); + + // Find bounds + for (i = 0; i < path->npts-1; i += 3) { + curve = &path->pts[i*2]; + nsvg__curveBounds(bounds, curve); + if (i == 0) { + path->bounds[0] = bounds[0]; + path->bounds[1] = bounds[1]; + path->bounds[2] = bounds[2]; + path->bounds[3] = bounds[3]; + } else { + path->bounds[0] = nsvg__minf(path->bounds[0], bounds[0]); + path->bounds[1] = nsvg__minf(path->bounds[1], bounds[1]); + path->bounds[2] = nsvg__maxf(path->bounds[2], bounds[2]); + path->bounds[3] = nsvg__maxf(path->bounds[3], bounds[3]); + } + } + + path->next = p->plist; + p->plist = path; + + return; + +error: + if (path != NULL) { + if (path->pts != NULL) free(path->pts); + free(path); + } +} + +// We roll our own string to float because the std library one uses locale and messes things up. +static double nsvg__atof(const char* s) +{ + char* cur = (char*)s; + char* end = NULL; + double res = 0.0, sign = 1.0; + long long intPart = 0, fracPart = 0; + char hasIntPart = 0, hasFracPart = 0; + + // Parse optional sign + if (*cur == '+') { + cur++; + } else if (*cur == '-') { + sign = -1; + cur++; + } + + // Parse integer part + if (nsvg__isdigit(*cur)) { + // Parse digit sequence + intPart = strtoll(cur, &end, 10); + if (cur != end) { + res = (double)intPart; + hasIntPart = 1; + cur = end; + } + } + + // Parse fractional part. + if (*cur == '.') { + cur++; // Skip '.' + if (nsvg__isdigit(*cur)) { + // Parse digit sequence + fracPart = strtoll(cur, &end, 10); + if (cur != end) { + res += (double)fracPart / pow(10.0, (double)(end - cur)); + hasFracPart = 1; + cur = end; + } + } + } + + // A valid number should have integer or fractional part. + if (!hasIntPart && !hasFracPart) + return 0.0; + + // Parse optional exponent + if (*cur == 'e' || *cur == 'E') { + long expPart = 0; + cur++; // skip 'E' + expPart = strtol(cur, &end, 10); // Parse digit sequence with sign + if (cur != end) { + res *= pow(10.0, (double)expPart); + } + } + + return res * sign; +} + + +static const char* nsvg__parseNumber(const char* s, char* it, const int size) +{ + const int last = size-1; + int i = 0; + + // sign + if (*s == '-' || *s == '+') { + if (i < last) it[i++] = *s; + s++; + } + // integer part + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + if (*s == '.') { + // decimal point + if (i < last) it[i++] = *s; + s++; + // fraction part + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + } + // exponent + if ((*s == 'e' || *s == 'E') && (s[1] != 'm' && s[1] != 'x')) { + if (i < last) it[i++] = *s; + s++; + if (*s == '-' || *s == '+') { + if (i < last) it[i++] = *s; + s++; + } + while (*s && nsvg__isdigit(*s)) { + if (i < last) it[i++] = *s; + s++; + } + } + it[i] = '\0'; + + return s; +} + +static const char* nsvg__getNextPathItemWhenArcFlag(const char* s, char* it) +{ + it[0] = '\0'; + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + if (!*s) return s; + if (*s == '0' || *s == '1') { + it[0] = *s++; + it[1] = '\0'; + return s; + } + return s; +} + +static const char* nsvg__getNextPathItem(const char* s, char* it) +{ + it[0] = '\0'; + // Skip white spaces and commas + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + if (!*s) return s; + if (*s == '-' || *s == '+' || *s == '.' || nsvg__isdigit(*s)) { + s = nsvg__parseNumber(s, it, 64); + } else { + // Parse command + it[0] = *s++; + it[1] = '\0'; + return s; + } + + return s; +} + +static unsigned int nsvg__parseColorHex(const char* str) +{ + unsigned int r=0, g=0, b=0; + if (sscanf(str, "#%2x%2x%2x", &r, &g, &b) == 3 ) // 2 digit hex + return NSVG_RGB(r, g, b); + if (sscanf(str, "#%1x%1x%1x", &r, &g, &b) == 3 ) // 1 digit hex, e.g. #abc -> 0xccbbaa + return NSVG_RGB(r*17, g*17, b*17); // same effect as (r<<4|r), (g<<4|g), .. + return NSVG_RGB(128, 128, 128); +} + +// Parse rgb color. The pointer 'str' must point at "rgb(" (4+ characters). +// This function returns gray (rgb(128, 128, 128) == '#808080') on parse errors +// for backwards compatibility. Note: other image viewers return black instead. + +static unsigned int nsvg__parseColorRGB(const char* str) +{ + int i; + unsigned int rgbi[3]; + float rgbf[3]; + // try decimal integers first + if (sscanf(str, "rgb(%u, %u, %u)", &rgbi[0], &rgbi[1], &rgbi[2]) != 3) { + // integers failed, try percent values (float, locale independent) + const char delimiter[3] = {',', ',', ')'}; + str += 4; // skip "rgb(" + for (i = 0; i < 3; i++) { + while (*str && (nsvg__isspace(*str))) str++; // skip leading spaces + if (*str == '+') str++; // skip '+' (don't allow '-') + if (!*str) break; + rgbf[i] = nsvg__atof(str); + + // Note 1: it would be great if nsvg__atof() returned how many + // bytes it consumed but it doesn't. We need to skip the number, + // the '%' character, spaces, and the delimiter ',' or ')'. + + // Note 2: The following code does not allow values like "33.%", + // i.e. a decimal point w/o fractional part, but this is consistent + // with other image viewers, e.g. firefox, chrome, eog, gimp. + + while (*str && nsvg__isdigit(*str)) str++; // skip integer part + if (*str == '.') { + str++; + if (!nsvg__isdigit(*str)) break; // error: no digit after '.' + while (*str && nsvg__isdigit(*str)) str++; // skip fractional part + } + if (*str == '%') str++; else break; + while (*str && nsvg__isspace(*str)) str++; + if (*str == delimiter[i]) str++; + else break; + } + if (i == 3) { + rgbi[0] = roundf(rgbf[0] * 2.55f); + rgbi[1] = roundf(rgbf[1] * 2.55f); + rgbi[2] = roundf(rgbf[2] * 2.55f); + } else { + rgbi[0] = rgbi[1] = rgbi[2] = 128; + } + } + // clip values as the CSS spec requires + for (i = 0; i < 3; i++) { + if (rgbi[i] > 255) rgbi[i] = 255; + } + return NSVG_RGB(rgbi[0], rgbi[1], rgbi[2]); +} + +typedef struct NSVGNamedColor { + const char* name; + unsigned int color; +} NSVGNamedColor; + +NSVGNamedColor nsvg__colors[] = { + + { "red", NSVG_RGB(255, 0, 0) }, + { "green", NSVG_RGB( 0, 128, 0) }, + { "blue", NSVG_RGB( 0, 0, 255) }, + { "yellow", NSVG_RGB(255, 255, 0) }, + { "cyan", NSVG_RGB( 0, 255, 255) }, + { "magenta", NSVG_RGB(255, 0, 255) }, + { "black", NSVG_RGB( 0, 0, 0) }, + { "grey", NSVG_RGB(128, 128, 128) }, + { "gray", NSVG_RGB(128, 128, 128) }, + { "white", NSVG_RGB(255, 255, 255) }, + +#ifdef NANOSVG_ALL_COLOR_KEYWORDS + { "aliceblue", NSVG_RGB(240, 248, 255) }, + { "antiquewhite", NSVG_RGB(250, 235, 215) }, + { "aqua", NSVG_RGB( 0, 255, 255) }, + { "aquamarine", NSVG_RGB(127, 255, 212) }, + { "azure", NSVG_RGB(240, 255, 255) }, + { "beige", NSVG_RGB(245, 245, 220) }, + { "bisque", NSVG_RGB(255, 228, 196) }, + { "blanchedalmond", NSVG_RGB(255, 235, 205) }, + { "blueviolet", NSVG_RGB(138, 43, 226) }, + { "brown", NSVG_RGB(165, 42, 42) }, + { "burlywood", NSVG_RGB(222, 184, 135) }, + { "cadetblue", NSVG_RGB( 95, 158, 160) }, + { "chartreuse", NSVG_RGB(127, 255, 0) }, + { "chocolate", NSVG_RGB(210, 105, 30) }, + { "coral", NSVG_RGB(255, 127, 80) }, + { "cornflowerblue", NSVG_RGB(100, 149, 237) }, + { "cornsilk", NSVG_RGB(255, 248, 220) }, + { "crimson", NSVG_RGB(220, 20, 60) }, + { "darkblue", NSVG_RGB( 0, 0, 139) }, + { "darkcyan", NSVG_RGB( 0, 139, 139) }, + { "darkgoldenrod", NSVG_RGB(184, 134, 11) }, + { "darkgray", NSVG_RGB(169, 169, 169) }, + { "darkgreen", NSVG_RGB( 0, 100, 0) }, + { "darkgrey", NSVG_RGB(169, 169, 169) }, + { "darkkhaki", NSVG_RGB(189, 183, 107) }, + { "darkmagenta", NSVG_RGB(139, 0, 139) }, + { "darkolivegreen", NSVG_RGB( 85, 107, 47) }, + { "darkorange", NSVG_RGB(255, 140, 0) }, + { "darkorchid", NSVG_RGB(153, 50, 204) }, + { "darkred", NSVG_RGB(139, 0, 0) }, + { "darksalmon", NSVG_RGB(233, 150, 122) }, + { "darkseagreen", NSVG_RGB(143, 188, 143) }, + { "darkslateblue", NSVG_RGB( 72, 61, 139) }, + { "darkslategray", NSVG_RGB( 47, 79, 79) }, + { "darkslategrey", NSVG_RGB( 47, 79, 79) }, + { "darkturquoise", NSVG_RGB( 0, 206, 209) }, + { "darkviolet", NSVG_RGB(148, 0, 211) }, + { "deeppink", NSVG_RGB(255, 20, 147) }, + { "deepskyblue", NSVG_RGB( 0, 191, 255) }, + { "dimgray", NSVG_RGB(105, 105, 105) }, + { "dimgrey", NSVG_RGB(105, 105, 105) }, + { "dodgerblue", NSVG_RGB( 30, 144, 255) }, + { "firebrick", NSVG_RGB(178, 34, 34) }, + { "floralwhite", NSVG_RGB(255, 250, 240) }, + { "forestgreen", NSVG_RGB( 34, 139, 34) }, + { "fuchsia", NSVG_RGB(255, 0, 255) }, + { "gainsboro", NSVG_RGB(220, 220, 220) }, + { "ghostwhite", NSVG_RGB(248, 248, 255) }, + { "gold", NSVG_RGB(255, 215, 0) }, + { "goldenrod", NSVG_RGB(218, 165, 32) }, + { "greenyellow", NSVG_RGB(173, 255, 47) }, + { "honeydew", NSVG_RGB(240, 255, 240) }, + { "hotpink", NSVG_RGB(255, 105, 180) }, + { "indianred", NSVG_RGB(205, 92, 92) }, + { "indigo", NSVG_RGB( 75, 0, 130) }, + { "ivory", NSVG_RGB(255, 255, 240) }, + { "khaki", NSVG_RGB(240, 230, 140) }, + { "lavender", NSVG_RGB(230, 230, 250) }, + { "lavenderblush", NSVG_RGB(255, 240, 245) }, + { "lawngreen", NSVG_RGB(124, 252, 0) }, + { "lemonchiffon", NSVG_RGB(255, 250, 205) }, + { "lightblue", NSVG_RGB(173, 216, 230) }, + { "lightcoral", NSVG_RGB(240, 128, 128) }, + { "lightcyan", NSVG_RGB(224, 255, 255) }, + { "lightgoldenrodyellow", NSVG_RGB(250, 250, 210) }, + { "lightgray", NSVG_RGB(211, 211, 211) }, + { "lightgreen", NSVG_RGB(144, 238, 144) }, + { "lightgrey", NSVG_RGB(211, 211, 211) }, + { "lightpink", NSVG_RGB(255, 182, 193) }, + { "lightsalmon", NSVG_RGB(255, 160, 122) }, + { "lightseagreen", NSVG_RGB( 32, 178, 170) }, + { "lightskyblue", NSVG_RGB(135, 206, 250) }, + { "lightslategray", NSVG_RGB(119, 136, 153) }, + { "lightslategrey", NSVG_RGB(119, 136, 153) }, + { "lightsteelblue", NSVG_RGB(176, 196, 222) }, + { "lightyellow", NSVG_RGB(255, 255, 224) }, + { "lime", NSVG_RGB( 0, 255, 0) }, + { "limegreen", NSVG_RGB( 50, 205, 50) }, + { "linen", NSVG_RGB(250, 240, 230) }, + { "maroon", NSVG_RGB(128, 0, 0) }, + { "mediumaquamarine", NSVG_RGB(102, 205, 170) }, + { "mediumblue", NSVG_RGB( 0, 0, 205) }, + { "mediumorchid", NSVG_RGB(186, 85, 211) }, + { "mediumpurple", NSVG_RGB(147, 112, 219) }, + { "mediumseagreen", NSVG_RGB( 60, 179, 113) }, + { "mediumslateblue", NSVG_RGB(123, 104, 238) }, + { "mediumspringgreen", NSVG_RGB( 0, 250, 154) }, + { "mediumturquoise", NSVG_RGB( 72, 209, 204) }, + { "mediumvioletred", NSVG_RGB(199, 21, 133) }, + { "midnightblue", NSVG_RGB( 25, 25, 112) }, + { "mintcream", NSVG_RGB(245, 255, 250) }, + { "mistyrose", NSVG_RGB(255, 228, 225) }, + { "moccasin", NSVG_RGB(255, 228, 181) }, + { "navajowhite", NSVG_RGB(255, 222, 173) }, + { "navy", NSVG_RGB( 0, 0, 128) }, + { "oldlace", NSVG_RGB(253, 245, 230) }, + { "olive", NSVG_RGB(128, 128, 0) }, + { "olivedrab", NSVG_RGB(107, 142, 35) }, + { "orange", NSVG_RGB(255, 165, 0) }, + { "orangered", NSVG_RGB(255, 69, 0) }, + { "orchid", NSVG_RGB(218, 112, 214) }, + { "palegoldenrod", NSVG_RGB(238, 232, 170) }, + { "palegreen", NSVG_RGB(152, 251, 152) }, + { "paleturquoise", NSVG_RGB(175, 238, 238) }, + { "palevioletred", NSVG_RGB(219, 112, 147) }, + { "papayawhip", NSVG_RGB(255, 239, 213) }, + { "peachpuff", NSVG_RGB(255, 218, 185) }, + { "peru", NSVG_RGB(205, 133, 63) }, + { "pink", NSVG_RGB(255, 192, 203) }, + { "plum", NSVG_RGB(221, 160, 221) }, + { "powderblue", NSVG_RGB(176, 224, 230) }, + { "purple", NSVG_RGB(128, 0, 128) }, + { "rosybrown", NSVG_RGB(188, 143, 143) }, + { "royalblue", NSVG_RGB( 65, 105, 225) }, + { "saddlebrown", NSVG_RGB(139, 69, 19) }, + { "salmon", NSVG_RGB(250, 128, 114) }, + { "sandybrown", NSVG_RGB(244, 164, 96) }, + { "seagreen", NSVG_RGB( 46, 139, 87) }, + { "seashell", NSVG_RGB(255, 245, 238) }, + { "sienna", NSVG_RGB(160, 82, 45) }, + { "silver", NSVG_RGB(192, 192, 192) }, + { "skyblue", NSVG_RGB(135, 206, 235) }, + { "slateblue", NSVG_RGB(106, 90, 205) }, + { "slategray", NSVG_RGB(112, 128, 144) }, + { "slategrey", NSVG_RGB(112, 128, 144) }, + { "snow", NSVG_RGB(255, 250, 250) }, + { "springgreen", NSVG_RGB( 0, 255, 127) }, + { "steelblue", NSVG_RGB( 70, 130, 180) }, + { "tan", NSVG_RGB(210, 180, 140) }, + { "teal", NSVG_RGB( 0, 128, 128) }, + { "thistle", NSVG_RGB(216, 191, 216) }, + { "tomato", NSVG_RGB(255, 99, 71) }, + { "turquoise", NSVG_RGB( 64, 224, 208) }, + { "violet", NSVG_RGB(238, 130, 238) }, + { "wheat", NSVG_RGB(245, 222, 179) }, + { "whitesmoke", NSVG_RGB(245, 245, 245) }, + { "yellowgreen", NSVG_RGB(154, 205, 50) }, +#endif +}; + +static unsigned int nsvg__parseColorName(const char* str) +{ + int i, ncolors = sizeof(nsvg__colors) / sizeof(NSVGNamedColor); + + for (i = 0; i < ncolors; i++) { + if (strcmp(nsvg__colors[i].name, str) == 0) { + return nsvg__colors[i].color; + } + } + + return NSVG_RGB(128, 128, 128); +} + +static unsigned int nsvg__parseColor(const char* str) +{ + size_t len = 0; + while(*str == ' ') ++str; + len = strlen(str); + if (len >= 1 && *str == '#') + return nsvg__parseColorHex(str); + else if (len >= 4 && str[0] == 'r' && str[1] == 'g' && str[2] == 'b' && str[3] == '(') + return nsvg__parseColorRGB(str); + return nsvg__parseColorName(str); +} + +static float nsvg__parseOpacity(const char* str) +{ + float val = nsvg__atof(str); + if (val < 0.0f) val = 0.0f; + if (val > 1.0f) val = 1.0f; + return val; +} + +static float nsvg__parseMiterLimit(const char* str) +{ + float val = nsvg__atof(str); + if (val < 0.0f) val = 0.0f; + return val; +} + +static int nsvg__parseUnits(const char* units) +{ + if (units[0] == 'p' && units[1] == 'x') + return NSVG_UNITS_PX; + else if (units[0] == 'p' && units[1] == 't') + return NSVG_UNITS_PT; + else if (units[0] == 'p' && units[1] == 'c') + return NSVG_UNITS_PC; + else if (units[0] == 'm' && units[1] == 'm') + return NSVG_UNITS_MM; + else if (units[0] == 'c' && units[1] == 'm') + return NSVG_UNITS_CM; + else if (units[0] == 'i' && units[1] == 'n') + return NSVG_UNITS_IN; + else if (units[0] == '%') + return NSVG_UNITS_PERCENT; + else if (units[0] == 'e' && units[1] == 'm') + return NSVG_UNITS_EM; + else if (units[0] == 'e' && units[1] == 'x') + return NSVG_UNITS_EX; + return NSVG_UNITS_USER; +} + +static int nsvg__isCoordinate(const char* s) +{ + // optional sign + if (*s == '-' || *s == '+') + s++; + // must have at least one digit, or start by a dot + return (nsvg__isdigit(*s) || *s == '.'); +} + +static NSVGcoordinate nsvg__parseCoordinateRaw(const char* str) +{ + NSVGcoordinate coord = {0, NSVG_UNITS_USER}; + char buf[64]; + coord.units = nsvg__parseUnits(nsvg__parseNumber(str, buf, 64)); + coord.value = nsvg__atof(buf); + return coord; +} + +static NSVGcoordinate nsvg__coord(float v, int units) +{ + NSVGcoordinate coord = {v, units}; + return coord; +} + +static float nsvg__parseCoordinate(NSVGparser* p, const char* str, float orig, float length) +{ + NSVGcoordinate coord = nsvg__parseCoordinateRaw(str); + return nsvg__convertToPixels(p, coord, orig, length); +} + +static int nsvg__parseTransformArgs(const char* str, float* args, int maxNa, int* na) +{ + const char* end; + const char* ptr; + char it[64]; + + *na = 0; + ptr = str; + while (*ptr && *ptr != '(') ++ptr; + if (*ptr == 0) + return 1; + end = ptr; + while (*end && *end != ')') ++end; + if (*end == 0) + return 1; + + while (ptr < end) { + if (*ptr == '-' || *ptr == '+' || *ptr == '.' || nsvg__isdigit(*ptr)) { + if (*na >= maxNa) return 0; + ptr = nsvg__parseNumber(ptr, it, 64); + args[(*na)++] = (float)nsvg__atof(it); + } else { + ++ptr; + } + } + return (int)(end - str); +} + + +static int nsvg__parseMatrix(float* xform, const char* str) +{ + float t[6]; + int na = 0; + int len = nsvg__parseTransformArgs(str, t, 6, &na); + if (na != 6) return len; + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseTranslate(float* xform, const char* str) +{ + float args[2]; + float t[6]; + int na = 0; + int len = nsvg__parseTransformArgs(str, args, 2, &na); + if (na == 1) args[1] = 0.0; + + nsvg__xformSetTranslation(t, args[0], args[1]); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseScale(float* xform, const char* str) +{ + float args[2]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 2, &na); + if (na == 1) args[1] = args[0]; + nsvg__xformSetScale(t, args[0], args[1]); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseSkewX(float* xform, const char* str) +{ + float args[1]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 1, &na); + nsvg__xformSetSkewX(t, args[0]/180.0f*NSVG_PI); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseSkewY(float* xform, const char* str) +{ + float args[1]; + int na = 0; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 1, &na); + nsvg__xformSetSkewY(t, args[0]/180.0f*NSVG_PI); + memcpy(xform, t, sizeof(float)*6); + return len; +} + +static int nsvg__parseRotate(float* xform, const char* str) +{ + float args[3]; + int na = 0; + float m[6]; + float t[6]; + int len = nsvg__parseTransformArgs(str, args, 3, &na); + if (na == 1) + args[1] = args[2] = 0.0f; + nsvg__xformIdentity(m); + + if (na > 1) { + nsvg__xformSetTranslation(t, -args[1], -args[2]); + nsvg__xformMultiply(m, t); + } + + nsvg__xformSetRotation(t, args[0]/180.0f*NSVG_PI); + nsvg__xformMultiply(m, t); + + if (na > 1) { + nsvg__xformSetTranslation(t, args[1], args[2]); + nsvg__xformMultiply(m, t); + } + + memcpy(xform, m, sizeof(float)*6); + + return len; +} + +static void nsvg__parseTransform(float* xform, const char* str) +{ + float t[6]; + int len; + nsvg__xformIdentity(xform); + while (*str) + { + if (strncmp(str, "matrix", 6) == 0) + len = nsvg__parseMatrix(t, str); + else if (strncmp(str, "translate", 9) == 0) + len = nsvg__parseTranslate(t, str); + else if (strncmp(str, "scale", 5) == 0) + len = nsvg__parseScale(t, str); + else if (strncmp(str, "rotate", 6) == 0) + len = nsvg__parseRotate(t, str); + else if (strncmp(str, "skewX", 5) == 0) + len = nsvg__parseSkewX(t, str); + else if (strncmp(str, "skewY", 5) == 0) + len = nsvg__parseSkewY(t, str); + else{ + ++str; + continue; + } + if (len != 0) { + str += len; + } else { + ++str; + continue; + } + + nsvg__xformPremultiply(xform, t); + } +} + +static void nsvg__parseUrl(char* id, const char* str) +{ + int i = 0; + str += 4; // "url("; + if (*str && *str == '#') + str++; + while (i < 63 && *str && *str != ')') { + id[i] = *str++; + i++; + } + id[i] = '\0'; +} + +static char nsvg__parseLineCap(const char* str) +{ + if (strcmp(str, "butt") == 0) + return NSVG_CAP_BUTT; + else if (strcmp(str, "round") == 0) + return NSVG_CAP_ROUND; + else if (strcmp(str, "square") == 0) + return NSVG_CAP_SQUARE; + // TODO: handle inherit. + return NSVG_CAP_BUTT; +} + +static char nsvg__parseLineJoin(const char* str) +{ + if (strcmp(str, "miter") == 0) + return NSVG_JOIN_MITER; + else if (strcmp(str, "round") == 0) + return NSVG_JOIN_ROUND; + else if (strcmp(str, "bevel") == 0) + return NSVG_JOIN_BEVEL; + // TODO: handle inherit. + return NSVG_JOIN_MITER; +} + +static char nsvg__parseFillRule(const char* str) +{ + if (strcmp(str, "nonzero") == 0) + return NSVG_FILLRULE_NONZERO; + else if (strcmp(str, "evenodd") == 0) + return NSVG_FILLRULE_EVENODD; + // TODO: handle inherit. + return NSVG_FILLRULE_NONZERO; +} + +static unsigned char nsvg__parsePaintOrder(const char* str) +{ + if (strcmp(str, "normal") == 0 || strcmp(str, "fill stroke markers") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); + else if (strcmp(str, "fill markers stroke") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_MARKERS, NSVG_PAINT_STROKE); + else if (strcmp(str, "markers fill stroke") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_MARKERS, NSVG_PAINT_FILL, NSVG_PAINT_STROKE); + else if (strcmp(str, "markers stroke fill") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_MARKERS, NSVG_PAINT_STROKE, NSVG_PAINT_FILL); + else if (strcmp(str, "stroke fill markers") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_STROKE, NSVG_PAINT_FILL, NSVG_PAINT_MARKERS); + else if (strcmp(str, "stroke markers fill") == 0) + return nsvg__encodePaintOrder(NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS, NSVG_PAINT_FILL); + // TODO: handle inherit. + return nsvg__encodePaintOrder(NSVG_PAINT_FILL, NSVG_PAINT_STROKE, NSVG_PAINT_MARKERS); +} + +static const char* nsvg__getNextDashItem(const char* s, char* it) +{ + int n = 0; + it[0] = '\0'; + // Skip white spaces and commas + while (*s && (nsvg__isspace(*s) || *s == ',')) s++; + // Advance until whitespace, comma or end. + while (*s && (!nsvg__isspace(*s) && *s != ',')) { + if (n < 63) + it[n++] = *s; + s++; + } + it[n++] = '\0'; + return s; +} + +static int nsvg__parseStrokeDashArray(NSVGparser* p, const char* str, float* strokeDashArray) +{ + char item[64]; + int count = 0, i; + float sum = 0.0f; + + // Handle "none" + if (str[0] == 'n') + return 0; + + // Parse dashes + while (*str) { + str = nsvg__getNextDashItem(str, item); + if (!*item) break; + if (count < NSVG_MAX_DASHES) + strokeDashArray[count++] = fabsf(nsvg__parseCoordinate(p, item, 0.0f, nsvg__actualLength(p))); + } + + for (i = 0; i < count; i++) + sum += strokeDashArray[i]; + if (sum <= 1e-6f) + count = 0; + + return count; +} + +static void nsvg__parseStyle(NSVGparser* p, const char* str); + +// Apply any matching class styles for a "class" attribute value. We support only simple class +// selectors. The class attribute may contain multiple space-separated class names. +static void nsvg__applyClassStyles(NSVGparser* p, const char* value) +{ + const char* cur = value; + while (*cur) { + const char* classStart; + size_t classLen; + NSVGstyleDeclaration* style; + + while (*cur && nsvg__isspace(*cur)) + cur++; + if (!*cur) + break; + + classStart = cur; + while (*cur && !nsvg__isspace(*cur)) + cur++; + classLen = (size_t)(cur - classStart); + + for (style = p->styles; style != NULL; style = style->next) { + if (strncmp(style->className, classStart, classLen) == 0 && style->className[classLen] == '\0') { + nsvg__parseStyle(p, style->propertiesText); + } + } + } +} + +static int nsvg__parseAttr(NSVGparser* p, const char* name, const char* value) +{ + float xform[6]; + NSVGattrib* attr = nsvg__getAttr(p); + if (!attr) return 0; + + if (strcmp(name, "style") == 0) { + nsvg__parseStyle(p, value); + } else if (strcmp(name, "display") == 0) { + if (strcmp(value, "none") == 0) + attr->visible = 0; + // Don't reset ->visible on display:inline, one display:none hides the whole subtree + + } else if (strcmp(name, "fill") == 0) { + if (strcmp(value, "none") == 0) { + attr->hasFill = 0; + } else if (strncmp(value, "url(", 4) == 0) { + attr->hasFill = 2; + nsvg__parseUrl(attr->fillGradient, value); + } else { + attr->hasFill = 1; + attr->fillColor = nsvg__parseColor(value); + } + } else if (strcmp(name, "opacity") == 0) { + attr->opacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "fill-opacity") == 0) { + attr->fillOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "stroke") == 0) { + if (strcmp(value, "none") == 0) { + attr->hasStroke = 0; + } else if (strncmp(value, "url(", 4) == 0) { + attr->hasStroke = 2; + nsvg__parseUrl(attr->strokeGradient, value); + } else { + attr->hasStroke = 1; + attr->strokeColor = nsvg__parseColor(value); + } + } else if (strcmp(name, "stroke-width") == 0) { + attr->strokeWidth = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "stroke-dasharray") == 0) { + attr->strokeDashCount = nsvg__parseStrokeDashArray(p, value, attr->strokeDashArray); + } else if (strcmp(name, "stroke-dashoffset") == 0) { + attr->strokeDashOffset = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "stroke-opacity") == 0) { + attr->strokeOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "stroke-linecap") == 0) { + attr->strokeLineCap = nsvg__parseLineCap(value); + } else if (strcmp(name, "stroke-linejoin") == 0) { + attr->strokeLineJoin = nsvg__parseLineJoin(value); + } else if (strcmp(name, "stroke-miterlimit") == 0) { + attr->miterLimit = nsvg__parseMiterLimit(value); + } else if (strcmp(name, "fill-rule") == 0) { + attr->fillRule = nsvg__parseFillRule(value); + } else if (strcmp(name, "font-size") == 0) { + attr->fontSize = nsvg__parseCoordinate(p, value, 0.0f, nsvg__actualLength(p)); + } else if (strcmp(name, "transform") == 0) { + nsvg__parseTransform(xform, value); + nsvg__xformPremultiply(attr->xform, xform); + } else if (strcmp(name, "stop-color") == 0) { + attr->stopColor = nsvg__parseColor(value); + } else if (strcmp(name, "stop-opacity") == 0) { + attr->stopOpacity = nsvg__parseOpacity(value); + } else if (strcmp(name, "offset") == 0) { + attr->stopOffset = nsvg__parseCoordinate(p, value, 0.0f, 1.0f); + } else if (strcmp(name, "paint-order") == 0) { + attr->paintOrder = nsvg__parsePaintOrder(value); + } else if (strcmp(name, "id") == 0) { + strncpy(attr->id, value, 63); + attr->id[63] = '\0'; + } else if (strcmp(name, "class") == 0) { + nsvg__applyClassStyles(p, value); + } + else { + return 0; + } + return 1; +} + +static int nsvg__parseNameValue(NSVGparser* p, const char* start, const char* end) +{ + const char* str; + const char* val; + char name[512]; + char value[512]; + int n; + + str = start; + while (str < end && *str != ':') ++str; + + val = str; + + // Right Trim + while (str > start && (*str == ':' || nsvg__isspace(*str))) --str; + ++str; + + n = (int)(str - start); + if (n > 511) n = 511; + if (n) memcpy(name, start, n); + name[n] = 0; + + while (val < end && (*val == ':' || nsvg__isspace(*val))) ++val; + + n = (int)(end - val); + if (n > 511) n = 511; + if (n) memcpy(value, val, n); + value[n] = 0; + + return nsvg__parseAttr(p, name, value); +} + +static void nsvg__parseStyle(NSVGparser* p, const char* str) +{ + const char* start; + const char* end; + + while (*str) { + // Left Trim + while(*str && nsvg__isspace(*str)) ++str; + start = str; + while(*str && *str != ';') ++str; + end = str; + + // Right Trim + while (end > start && (*end == ';' || nsvg__isspace(*end))) --end; + ++end; + + nsvg__parseNameValue(p, start, end); + if (*str) ++str; + } +} + +static void nsvg__parseAttribs(NSVGparser* p, const char** attr) +{ + int i; + for (i = 0; attr[i]; i += 2) + { + if (strcmp(attr[i], "style") == 0) + nsvg__parseStyle(p, attr[i + 1]); + else + nsvg__parseAttr(p, attr[i], attr[i + 1]); + } +} + +static int nsvg__getArgsPerElement(char cmd) +{ + switch (cmd) { + case 'v': + case 'V': + case 'h': + case 'H': + return 1; + case 'm': + case 'M': + case 'l': + case 'L': + case 't': + case 'T': + return 2; + case 'q': + case 'Q': + case 's': + case 'S': + return 4; + case 'c': + case 'C': + return 6; + case 'a': + case 'A': + return 7; + case 'z': + case 'Z': + return 0; + } + return -1; +} + +static void nsvg__pathMoveTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) { + *cpx += args[0]; + *cpy += args[1]; + } else { + *cpx = args[0]; + *cpy = args[1]; + } + nsvg__moveTo(p, *cpx, *cpy); +} + +static void nsvg__pathLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) { + *cpx += args[0]; + *cpy += args[1]; + } else { + *cpx = args[0]; + *cpy = args[1]; + } + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathHLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) + *cpx += args[0]; + else + *cpx = args[0]; + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathVLineTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + if (rel) + *cpy += args[0]; + else + *cpy = args[0]; + nsvg__lineTo(p, *cpx, *cpy); +} + +static void nsvg__pathCubicBezTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x2, y2, cx1, cy1, cx2, cy2; + + if (rel) { + cx1 = *cpx + args[0]; + cy1 = *cpy + args[1]; + cx2 = *cpx + args[2]; + cy2 = *cpy + args[3]; + x2 = *cpx + args[4]; + y2 = *cpy + args[5]; + } else { + cx1 = args[0]; + cy1 = args[1]; + cx2 = args[2]; + cy2 = args[3]; + x2 = args[4]; + y2 = args[5]; + } + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx2; + *cpy2 = cy2; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathCubicBezShortTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + cx2 = *cpx + args[0]; + cy2 = *cpy + args[1]; + x2 = *cpx + args[2]; + y2 = *cpy + args[3]; + } else { + cx2 = args[0]; + cy2 = args[1]; + x2 = args[2]; + y2 = args[3]; + } + + cx1 = 2*x1 - *cpx2; + cy1 = 2*y1 - *cpy2; + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx2; + *cpy2 = cy2; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathQuadBezTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx, cy; + float cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + cx = *cpx + args[0]; + cy = *cpy + args[1]; + x2 = *cpx + args[2]; + y2 = *cpy + args[3]; + } else { + cx = args[0]; + cy = args[1]; + x2 = args[2]; + y2 = args[3]; + } + + // Convert to cubic bezier + cx1 = x1 + 2.0f/3.0f*(cx - x1); + cy1 = y1 + 2.0f/3.0f*(cy - y1); + cx2 = x2 + 2.0f/3.0f*(cx - x2); + cy2 = y2 + 2.0f/3.0f*(cy - y2); + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx; + *cpy2 = cy; + *cpx = x2; + *cpy = y2; +} + +static void nsvg__pathQuadBezShortTo(NSVGparser* p, float* cpx, float* cpy, + float* cpx2, float* cpy2, float* args, int rel) +{ + float x1, y1, x2, y2, cx, cy; + float cx1, cy1, cx2, cy2; + + x1 = *cpx; + y1 = *cpy; + if (rel) { + x2 = *cpx + args[0]; + y2 = *cpy + args[1]; + } else { + x2 = args[0]; + y2 = args[1]; + } + + cx = 2*x1 - *cpx2; + cy = 2*y1 - *cpy2; + + // Convert to cubix bezier + cx1 = x1 + 2.0f/3.0f*(cx - x1); + cy1 = y1 + 2.0f/3.0f*(cy - y1); + cx2 = x2 + 2.0f/3.0f*(cx - x2); + cy2 = y2 + 2.0f/3.0f*(cy - y2); + + nsvg__cubicBezTo(p, cx1,cy1, cx2,cy2, x2,y2); + + *cpx2 = cx; + *cpy2 = cy; + *cpx = x2; + *cpy = y2; +} + +static float nsvg__sqr(float x) { return x*x; } +static float nsvg__vmag(float x, float y) { return sqrtf(x*x + y*y); } + +static float nsvg__vecrat(float ux, float uy, float vx, float vy) +{ + return (ux*vx + uy*vy) / (nsvg__vmag(ux,uy) * nsvg__vmag(vx,vy)); +} + +static float nsvg__vecang(float ux, float uy, float vx, float vy) +{ + float r = nsvg__vecrat(ux,uy, vx,vy); + if (r < -1.0f) r = -1.0f; + if (r > 1.0f) r = 1.0f; + return ((ux*vy < uy*vx) ? -1.0f : 1.0f) * acosf(r); +} + +static void nsvg__pathArcTo(NSVGparser* p, float* cpx, float* cpy, float* args, int rel) +{ + // Ported from canvg (https://code.google.com/p/canvg/) + float rx, ry, rotx; + float x1, y1, x2, y2, cx, cy, dx, dy, d; + float x1p, y1p, cxp, cyp, s, sa, sb; + float ux, uy, vx, vy, a1, da; + float x, y, tanx, tany, a, px = 0, py = 0, ptanx = 0, ptany = 0, t[6]; + float sinrx, cosrx; + int fa, fs; + int i, ndivs; + float hda, kappa; + + rx = fabsf(args[0]); // y radius + ry = fabsf(args[1]); // x radius + rotx = args[2] / 180.0f * NSVG_PI; // x rotation angle + fa = fabsf(args[3]) > 1e-6 ? 1 : 0; // Large arc + fs = fabsf(args[4]) > 1e-6 ? 1 : 0; // Sweep direction + x1 = *cpx; // start point + y1 = *cpy; + if (rel) { // end point + x2 = *cpx + args[5]; + y2 = *cpy + args[6]; + } else { + x2 = args[5]; + y2 = args[6]; + } + + dx = x1 - x2; + dy = y1 - y2; + d = sqrtf(dx*dx + dy*dy); + if (d < 1e-6f || rx < 1e-6f || ry < 1e-6f) { + // The arc degenerates to a line + nsvg__lineTo(p, x2, y2); + *cpx = x2; + *cpy = y2; + return; + } + + sinrx = sinf(rotx); + cosrx = cosf(rotx); + + // Convert to center point parameterization. + // http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + // 1) Compute x1', y1' + x1p = cosrx * dx / 2.0f + sinrx * dy / 2.0f; + y1p = -sinrx * dx / 2.0f + cosrx * dy / 2.0f; + d = nsvg__sqr(x1p)/nsvg__sqr(rx) + nsvg__sqr(y1p)/nsvg__sqr(ry); + if (d > 1) { + d = sqrtf(d); + rx *= d; + ry *= d; + } + // 2) Compute cx', cy' + s = 0.0f; + sa = nsvg__sqr(rx)*nsvg__sqr(ry) - nsvg__sqr(rx)*nsvg__sqr(y1p) - nsvg__sqr(ry)*nsvg__sqr(x1p); + sb = nsvg__sqr(rx)*nsvg__sqr(y1p) + nsvg__sqr(ry)*nsvg__sqr(x1p); + if (sa < 0.0f) sa = 0.0f; + if (sb > 0.0f) + s = sqrtf(sa / sb); + if (fa == fs) + s = -s; + cxp = s * rx * y1p / ry; + cyp = s * -ry * x1p / rx; + + // 3) Compute cx,cy from cx',cy' + cx = (x1 + x2)/2.0f + cosrx*cxp - sinrx*cyp; + cy = (y1 + y2)/2.0f + sinrx*cxp + cosrx*cyp; + + // 4) Calculate theta1, and delta theta. + ux = (x1p - cxp) / rx; + uy = (y1p - cyp) / ry; + vx = (-x1p - cxp) / rx; + vy = (-y1p - cyp) / ry; + a1 = nsvg__vecang(1.0f,0.0f, ux,uy); // Initial angle + da = nsvg__vecang(ux,uy, vx,vy); // Delta angle + +// if (vecrat(ux,uy,vx,vy) <= -1.0f) da = NSVG_PI; +// if (vecrat(ux,uy,vx,vy) >= 1.0f) da = 0; + + if (fs == 0 && da > 0) + da -= 2 * NSVG_PI; + else if (fs == 1 && da < 0) + da += 2 * NSVG_PI; + + // Approximate the arc using cubic spline segments. + t[0] = cosrx; t[1] = sinrx; + t[2] = -sinrx; t[3] = cosrx; + t[4] = cx; t[5] = cy; + + // Split arc into max 90 degree segments. + // The loop assumes an iteration per end point (including start and end), this +1. + ndivs = (int)(fabsf(da) / (NSVG_PI*0.5f) + 1.0f); + hda = (da / (float)ndivs) / 2.0f; + // Fix for ticket #179: division by 0: avoid cotangens around 0 (infinite) + if ((hda < 1e-3f) && (hda > -1e-3f)) + hda *= 0.5f; + else + hda = (1.0f - cosf(hda)) / sinf(hda); + kappa = fabsf(4.0f / 3.0f * hda); + if (da < 0.0f) + kappa = -kappa; + + for (i = 0; i <= ndivs; i++) { + a = a1 + da * ((float)i/(float)ndivs); + dx = cosf(a); + dy = sinf(a); + nsvg__xformPoint(&x, &y, dx*rx, dy*ry, t); // position + nsvg__xformVec(&tanx, &tany, -dy*rx * kappa, dx*ry * kappa, t); // tangent + if (i > 0) + nsvg__cubicBezTo(p, px+ptanx,py+ptany, x-tanx, y-tany, x, y); + px = x; + py = y; + ptanx = tanx; + ptany = tany; + } + + *cpx = x2; + *cpy = y2; +} + +static void nsvg__parsePath(NSVGparser* p, const char** attr) +{ + const char* s = NULL; + char cmd = '\0'; + float args[10]; + int nargs; + int rargs = 0; + char initPoint; + float cpx, cpy, cpx2, cpy2; + const char* tmp[4]; + char closedFlag; + int i; + char item[64]; + + for (i = 0; attr[i]; i += 2) { + if (strcmp(attr[i], "d") == 0) { + s = attr[i + 1]; + } else { + tmp[0] = attr[i]; + tmp[1] = attr[i + 1]; + tmp[2] = 0; + tmp[3] = 0; + nsvg__parseAttribs(p, tmp); + } + } + + if (s) { + nsvg__resetPath(p); + cpx = 0; cpy = 0; + cpx2 = 0; cpy2 = 0; + initPoint = 0; + closedFlag = 0; + nargs = 0; + + while (*s) { + item[0] = '\0'; + if ((cmd == 'A' || cmd == 'a') && (nargs == 3 || nargs == 4)) + s = nsvg__getNextPathItemWhenArcFlag(s, item); + if (!*item) + s = nsvg__getNextPathItem(s, item); + if (!*item) break; + if (cmd != '\0' && nsvg__isCoordinate(item)) { + if (nargs < 10) + args[nargs++] = (float)nsvg__atof(item); + if (nargs >= rargs) { + switch (cmd) { + case 'm': + case 'M': + nsvg__pathMoveTo(p, &cpx, &cpy, args, cmd == 'm' ? 1 : 0); + // Moveto can be followed by multiple coordinate pairs, + // which should be treated as linetos. + cmd = (cmd == 'm') ? 'l' : 'L'; + rargs = nsvg__getArgsPerElement(cmd); + cpx2 = cpx; cpy2 = cpy; + initPoint = 1; + break; + case 'l': + case 'L': + nsvg__pathLineTo(p, &cpx, &cpy, args, cmd == 'l' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'H': + case 'h': + nsvg__pathHLineTo(p, &cpx, &cpy, args, cmd == 'h' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'V': + case 'v': + nsvg__pathVLineTo(p, &cpx, &cpy, args, cmd == 'v' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + case 'C': + case 'c': + nsvg__pathCubicBezTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 'c' ? 1 : 0); + break; + case 'S': + case 's': + nsvg__pathCubicBezShortTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 's' ? 1 : 0); + break; + case 'Q': + case 'q': + nsvg__pathQuadBezTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 'q' ? 1 : 0); + break; + case 'T': + case 't': + nsvg__pathQuadBezShortTo(p, &cpx, &cpy, &cpx2, &cpy2, args, cmd == 't' ? 1 : 0); + break; + case 'A': + case 'a': + nsvg__pathArcTo(p, &cpx, &cpy, args, cmd == 'a' ? 1 : 0); + cpx2 = cpx; cpy2 = cpy; + break; + default: + if (nargs >= 2) { + cpx = args[nargs-2]; + cpy = args[nargs-1]; + cpx2 = cpx; cpy2 = cpy; + } + break; + } + nargs = 0; + } + } else { + cmd = item[0]; + if (cmd == 'M' || cmd == 'm') { + // Commit path. + if (p->npts > 0) + nsvg__addPath(p, closedFlag); + // Start new subpath. + nsvg__resetPath(p); + closedFlag = 0; + nargs = 0; + } else if (initPoint == 0) { + // Do not allow other commands until initial point has been set (moveTo called once). + cmd = '\0'; + } + if (cmd == 'Z' || cmd == 'z') { + closedFlag = 1; + // Commit path. + if (p->npts > 0) { + // Move current point to first point + cpx = p->pts[0]; + cpy = p->pts[1]; + cpx2 = cpx; cpy2 = cpy; + nsvg__addPath(p, closedFlag); + } + // Start new subpath. + nsvg__resetPath(p); + nsvg__moveTo(p, cpx, cpy); + closedFlag = 0; + nargs = 0; + } + rargs = nsvg__getArgsPerElement(cmd); + if (rargs == -1) { + // Command not recognized + cmd = '\0'; + rargs = 0; + } + } + } + // Commit path. + if (p->npts) + nsvg__addPath(p, closedFlag); + } + + nsvg__addShape(p); +} + +static void nsvg__parseRect(NSVGparser* p, const char** attr) +{ + float x = 0.0f; + float y = 0.0f; + float w = 0.0f; + float h = 0.0f; + float rx = -1.0f; // marks not set + float ry = -1.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "x") == 0) x = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y") == 0) y = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "width") == 0) w = nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p)); + if (strcmp(attr[i], "height") == 0) h = nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p)); + if (strcmp(attr[i], "rx") == 0) rx = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p))); + if (strcmp(attr[i], "ry") == 0) ry = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p))); + } + } + + if (rx < 0.0f && ry > 0.0f) rx = ry; + if (ry < 0.0f && rx > 0.0f) ry = rx; + if (rx < 0.0f) rx = 0.0f; + if (ry < 0.0f) ry = 0.0f; + if (rx > w/2.0f) rx = w/2.0f; + if (ry > h/2.0f) ry = h/2.0f; + + if (w != 0.0f && h != 0.0f) { + nsvg__resetPath(p); + + if (rx < 0.00001f || ry < 0.0001f) { + nsvg__moveTo(p, x, y); + nsvg__lineTo(p, x+w, y); + nsvg__lineTo(p, x+w, y+h); + nsvg__lineTo(p, x, y+h); + } else { + // Rounded rectangle + nsvg__moveTo(p, x+rx, y); + nsvg__lineTo(p, x+w-rx, y); + nsvg__cubicBezTo(p, x+w-rx*(1-NSVG_KAPPA90), y, x+w, y+ry*(1-NSVG_KAPPA90), x+w, y+ry); + nsvg__lineTo(p, x+w, y+h-ry); + nsvg__cubicBezTo(p, x+w, y+h-ry*(1-NSVG_KAPPA90), x+w-rx*(1-NSVG_KAPPA90), y+h, x+w-rx, y+h); + nsvg__lineTo(p, x+rx, y+h); + nsvg__cubicBezTo(p, x+rx*(1-NSVG_KAPPA90), y+h, x, y+h-ry*(1-NSVG_KAPPA90), x, y+h-ry); + nsvg__lineTo(p, x, y+ry); + nsvg__cubicBezTo(p, x, y+ry*(1-NSVG_KAPPA90), x+rx*(1-NSVG_KAPPA90), y, x+rx, y); + } + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseCircle(NSVGparser* p, const char** attr) +{ + float cx = 0.0f; + float cy = 0.0f; + float r = 0.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "cx") == 0) cx = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "cy") == 0) cy = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "r") == 0) r = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualLength(p))); + } + } + + if (r > 0.0f) { + nsvg__resetPath(p); + + nsvg__moveTo(p, cx+r, cy); + nsvg__cubicBezTo(p, cx+r, cy+r*NSVG_KAPPA90, cx+r*NSVG_KAPPA90, cy+r, cx, cy+r); + nsvg__cubicBezTo(p, cx-r*NSVG_KAPPA90, cy+r, cx-r, cy+r*NSVG_KAPPA90, cx-r, cy); + nsvg__cubicBezTo(p, cx-r, cy-r*NSVG_KAPPA90, cx-r*NSVG_KAPPA90, cy-r, cx, cy-r); + nsvg__cubicBezTo(p, cx+r*NSVG_KAPPA90, cy-r, cx+r, cy-r*NSVG_KAPPA90, cx+r, cy); + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseEllipse(NSVGparser* p, const char** attr) +{ + float cx = 0.0f; + float cy = 0.0f; + float rx = 0.0f; + float ry = 0.0f; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "cx") == 0) cx = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "cy") == 0) cy = nsvg__parseCoordinate(p, attr[i+1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "rx") == 0) rx = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualWidth(p))); + if (strcmp(attr[i], "ry") == 0) ry = fabsf(nsvg__parseCoordinate(p, attr[i+1], 0.0f, nsvg__actualHeight(p))); + } + } + + if (rx > 0.0f && ry > 0.0f) { + + nsvg__resetPath(p); + + nsvg__moveTo(p, cx+rx, cy); + nsvg__cubicBezTo(p, cx+rx, cy+ry*NSVG_KAPPA90, cx+rx*NSVG_KAPPA90, cy+ry, cx, cy+ry); + nsvg__cubicBezTo(p, cx-rx*NSVG_KAPPA90, cy+ry, cx-rx, cy+ry*NSVG_KAPPA90, cx-rx, cy); + nsvg__cubicBezTo(p, cx-rx, cy-ry*NSVG_KAPPA90, cx-rx*NSVG_KAPPA90, cy-ry, cx, cy-ry); + nsvg__cubicBezTo(p, cx+rx*NSVG_KAPPA90, cy-ry, cx+rx, cy-ry*NSVG_KAPPA90, cx+rx, cy); + + nsvg__addPath(p, 1); + + nsvg__addShape(p); + } +} + +static void nsvg__parseLine(NSVGparser* p, const char** attr) +{ + float x1 = 0.0; + float y1 = 0.0; + float x2 = 0.0; + float y2 = 0.0; + int i; + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "x1") == 0) x1 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y1") == 0) y1 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + if (strcmp(attr[i], "x2") == 0) x2 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigX(p), nsvg__actualWidth(p)); + if (strcmp(attr[i], "y2") == 0) y2 = nsvg__parseCoordinate(p, attr[i + 1], nsvg__actualOrigY(p), nsvg__actualHeight(p)); + } + } + + nsvg__resetPath(p); + + nsvg__moveTo(p, x1, y1); + nsvg__lineTo(p, x2, y2); + + nsvg__addPath(p, 0); + + nsvg__addShape(p); +} + +static void nsvg__parsePoly(NSVGparser* p, const char** attr, int closeFlag) +{ + int i; + const char* s; + float args[2]; + int nargs, npts = 0; + char item[64]; + + nsvg__resetPath(p); + + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "points") == 0) { + s = attr[i + 1]; + nargs = 0; + while (*s) { + s = nsvg__getNextPathItem(s, item); + args[nargs++] = (float)nsvg__atof(item); + if (nargs >= 2) { + if (npts == 0) + nsvg__moveTo(p, args[0], args[1]); + else + nsvg__lineTo(p, args[0], args[1]); + nargs = 0; + npts++; + } + } + } + } + } + + nsvg__addPath(p, (char)closeFlag); + + nsvg__addShape(p); +} + +static void nsvg__parseSVG(NSVGparser* p, const char** attr) +{ + int i; + for (i = 0; attr[i]; i += 2) { + if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "width") == 0) { + p->image->width = nsvg__parseCoordinate(p, attr[i + 1], 0.0f, 0.0f); + } else if (strcmp(attr[i], "height") == 0) { + p->image->height = nsvg__parseCoordinate(p, attr[i + 1], 0.0f, 0.0f); + } else if (strcmp(attr[i], "viewBox") == 0) { + const char *s = attr[i + 1]; + char buf[64]; + s = nsvg__parseNumber(s, buf, 64); + p->viewMinx = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewMiny = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewWidth = nsvg__atof(buf); + while (*s && (nsvg__isspace(*s) || *s == '%' || *s == ',')) s++; + if (!*s) return; + s = nsvg__parseNumber(s, buf, 64); + p->viewHeight = nsvg__atof(buf); + } else if (strcmp(attr[i], "preserveAspectRatio") == 0) { + if (strstr(attr[i + 1], "none") != 0) { + // No uniform scaling + p->alignType = NSVG_ALIGN_NONE; + } else { + // Parse X align + if (strstr(attr[i + 1], "xMin") != 0) + p->alignX = NSVG_ALIGN_MIN; + else if (strstr(attr[i + 1], "xMid") != 0) + p->alignX = NSVG_ALIGN_MID; + else if (strstr(attr[i + 1], "xMax") != 0) + p->alignX = NSVG_ALIGN_MAX; + // Parse X align + if (strstr(attr[i + 1], "yMin") != 0) + p->alignY = NSVG_ALIGN_MIN; + else if (strstr(attr[i + 1], "yMid") != 0) + p->alignY = NSVG_ALIGN_MID; + else if (strstr(attr[i + 1], "yMax") != 0) + p->alignY = NSVG_ALIGN_MAX; + // Parse meet/slice + p->alignType = NSVG_ALIGN_MEET; + if (strstr(attr[i + 1], "slice") != 0) + p->alignType = NSVG_ALIGN_SLICE; + } + } + } + } +} + +static void nsvg__parseGradient(NSVGparser* p, const char** attr, signed char type) +{ + int i; + NSVGgradientData* grad = (NSVGgradientData*)malloc(sizeof(NSVGgradientData)); + if (grad == NULL) return; + memset(grad, 0, sizeof(NSVGgradientData)); + grad->units = NSVG_OBJECT_SPACE; + grad->type = type; + if (grad->type == NSVG_PAINT_LINEAR_GRADIENT) { + grad->linear.x1 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + grad->linear.y1 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + grad->linear.x2 = nsvg__coord(100.0f, NSVG_UNITS_PERCENT); + grad->linear.y2 = nsvg__coord(0.0f, NSVG_UNITS_PERCENT); + } else if (grad->type == NSVG_PAINT_RADIAL_GRADIENT) { + grad->radial.cx = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + grad->radial.cy = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + grad->radial.r = nsvg__coord(50.0f, NSVG_UNITS_PERCENT); + } + + nsvg__xformIdentity(grad->xform); + + for (i = 0; attr[i]; i += 2) { + if (strcmp(attr[i], "id") == 0) { + strncpy(grad->id, attr[i+1], 63); + grad->id[63] = '\0'; + } else if (!nsvg__parseAttr(p, attr[i], attr[i + 1])) { + if (strcmp(attr[i], "gradientUnits") == 0) { + if (strcmp(attr[i+1], "objectBoundingBox") == 0) + grad->units = NSVG_OBJECT_SPACE; + else + grad->units = NSVG_USER_SPACE; + } else if (strcmp(attr[i], "gradientTransform") == 0) { + nsvg__parseTransform(grad->xform, attr[i + 1]); + } else if (strcmp(attr[i], "cx") == 0) { + grad->radial.cx = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "cy") == 0) { + grad->radial.cy = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "r") == 0) { + grad->radial.r = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "fx") == 0) { + grad->radial.fx = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "fy") == 0) { + grad->radial.fy = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "x1") == 0) { + grad->linear.x1 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "y1") == 0) { + grad->linear.y1 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "x2") == 0) { + grad->linear.x2 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "y2") == 0) { + grad->linear.y2 = nsvg__parseCoordinateRaw(attr[i + 1]); + } else if (strcmp(attr[i], "spreadMethod") == 0) { + if (strcmp(attr[i+1], "pad") == 0) + grad->spread = NSVG_SPREAD_PAD; + else if (strcmp(attr[i+1], "reflect") == 0) + grad->spread = NSVG_SPREAD_REFLECT; + else if (strcmp(attr[i+1], "repeat") == 0) + grad->spread = NSVG_SPREAD_REPEAT; + } else if (strcmp(attr[i], "xlink:href") == 0) { + const char *href = attr[i+1]; + strncpy(grad->ref, href+1, 62); + grad->ref[62] = '\0'; + } + } + } + + grad->next = p->gradients; + p->gradients = grad; +} + +static void nsvg__parseGradientStop(NSVGparser* p, const char** attr) +{ + NSVGattrib* curAttr = nsvg__getAttr(p); + NSVGgradientData* grad; + NSVGgradientStop* stop; + int i, idx; + + curAttr->stopOffset = 0; + curAttr->stopColor = 0; + curAttr->stopOpacity = 1.0f; + + for (i = 0; attr[i]; i += 2) { + nsvg__parseAttr(p, attr[i], attr[i + 1]); + } + + // Add stop to the last gradient. + grad = p->gradients; + if (grad == NULL) return; + + grad->nstops++; + grad->stops = (NSVGgradientStop*)realloc(grad->stops, sizeof(NSVGgradientStop)*grad->nstops); + if (grad->stops == NULL) return; + + // Insert + idx = grad->nstops-1; + for (i = 0; i < grad->nstops-1; i++) { + if (curAttr->stopOffset < grad->stops[i].offset) { + idx = i; + break; + } + } + if (idx != grad->nstops-1) { + for (i = grad->nstops-1; i > idx; i--) + grad->stops[i] = grad->stops[i-1]; + } + + stop = &grad->stops[idx]; + stop->color = curAttr->stopColor; + stop->color |= (unsigned int)(curAttr->stopOpacity*255) << 24; + stop->offset = curAttr->stopOffset; +} + +static void nsvg__startElement(void* ud, const char* el, const char** attr) +{ + NSVGparser* p = (NSVGparser*)ud; + + if (p->defsFlag) { + // Skip everything but gradients and styles in defs + if (strcmp(el, "linearGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_LINEAR_GRADIENT); + } else if (strcmp(el, "radialGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_RADIAL_GRADIENT); + } else if (strcmp(el, "stop") == 0) { + nsvg__parseGradientStop(p, attr); + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 1; + } + return; + } + + if (strcmp(el, "g") == 0) { + nsvg__pushAttr(p); + nsvg__parseAttribs(p, attr); + } else if (strcmp(el, "path") == 0) { + if (p->pathFlag) // Do not allow nested paths. + return; + nsvg__pushAttr(p); + nsvg__parsePath(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "rect") == 0) { + nsvg__pushAttr(p); + nsvg__parseRect(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "circle") == 0) { + nsvg__pushAttr(p); + nsvg__parseCircle(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "ellipse") == 0) { + nsvg__pushAttr(p); + nsvg__parseEllipse(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "line") == 0) { + nsvg__pushAttr(p); + nsvg__parseLine(p, attr); + nsvg__popAttr(p); + } else if (strcmp(el, "polyline") == 0) { + nsvg__pushAttr(p); + nsvg__parsePoly(p, attr, 0); + nsvg__popAttr(p); + } else if (strcmp(el, "polygon") == 0) { + nsvg__pushAttr(p); + nsvg__parsePoly(p, attr, 1); + nsvg__popAttr(p); + } else if (strcmp(el, "linearGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_LINEAR_GRADIENT); + } else if (strcmp(el, "radialGradient") == 0) { + nsvg__parseGradient(p, attr, NSVG_PAINT_RADIAL_GRADIENT); + } else if (strcmp(el, "stop") == 0) { + nsvg__parseGradientStop(p, attr); + } else if (strcmp(el, "defs") == 0) { + p->defsFlag = 1; + } else if (strcmp(el, "svg") == 0) { + nsvg__parseSVG(p, attr); + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 1; + } +} + +static void nsvg__endElement(void* ud, const char* el) +{ + NSVGparser* p = (NSVGparser*)ud; + + if (strcmp(el, "g") == 0) { + nsvg__popAttr(p); + } else if (strcmp(el, "path") == 0) { + p->pathFlag = 0; + } else if (strcmp(el, "defs") == 0) { + p->defsFlag = 0; + } else if (strcmp(el, "style") == 0) { + p->styleFlag = 0; + } +} + +static char *nsvg__strndup(const char *s, size_t n) +{ + char *result = (char *)malloc(n + 1); + if (result == NULL) + return NULL; + + memcpy(result, s, n); + result[n] = '\0'; + return result; +} + +static void nsvg__content(void* ud, const char* s) +{ + NSVGparser* p = (NSVGparser*)ud; + if (!p->styleFlag) + return; + + // Parse all the styles inside the style block. Each style's content will be later processed using nsvg__parseStyle(). + // Note: We only support selector lists of simple class selectors (e.g. ".foo, .bar { ... }"). + while (*s) { + NSVGstyleDeclaration* styles[NSVG_MAX_CLASSES]; + int nstyles = 0; + const char* propsStart; + const char* propsEnd; + int i; + + // 1) Parse the selector list up to '{'. For each simple class selector ('.name'), + // allocate a new NSVGstyleDeclaration into the local staging array. Styles are + // only committed to p->styles in step 3 below, once their propertiesText has + // also been allocated successfully. + while (*s && *s != '{') { + const char* selStart; + const char* selEnd; + NSVGstyleDeclaration* style; + + while (*s && (nsvg__isspace(*s) || *s == ',')) + s++; + if (!*s || *s == '{') + break; + + selStart = s; + while (*s && !nsvg__isspace(*s) && *s != ',' && *s != '{') + s++; + selEnd = s; + + if (*selStart != '.' || nstyles >= NSVG_MAX_CLASSES) + continue; // unsupported selector, or staging array full + selStart++; // strip leading '.' + + style = (NSVGstyleDeclaration*)malloc(sizeof(NSVGstyleDeclaration)); + if (style == NULL) + continue; + style->className = nsvg__strndup(selStart, (size_t)(selEnd - selStart)); + if (style->className == NULL) { + free(style); + continue; + } + style->propertiesText = NULL; + style->next = NULL; + styles[nstyles++] = style; + } + if (!*s) { + // No '{' found - discard pending styles and stop. + for (i = 0; i < nstyles; i++) { + free(styles[i]->className); + free(styles[i]); + } + break; + } + s++; // advance past '{' + + // 2) Find the end of the properties block (up to '}'). + propsStart = s; + while (*s && *s != '}') + s++; + propsEnd = s; + + // 3) Allocate propertiesText for each pending style. Commit successful ones to + // p->styles (head-insertion); free any whose allocation fails. + for (i = 0; i < nstyles; i++) { + styles[i]->propertiesText = nsvg__strndup(propsStart, (size_t)(propsEnd - propsStart)); + if (styles[i]->propertiesText == NULL) { + free(styles[i]->className); + free(styles[i]); + } else { + styles[i]->next = p->styles; + p->styles = styles[i]; + } + } + + if (*s) + s++; // advance past '}' + } +} + +static void nsvg__imageBounds(NSVGparser* p, float* bounds) +{ + NSVGshape* shape; + shape = p->image->shapes; + if (shape == NULL) { + bounds[0] = bounds[1] = bounds[2] = bounds[3] = 0.0; + return; + } + bounds[0] = shape->bounds[0]; + bounds[1] = shape->bounds[1]; + bounds[2] = shape->bounds[2]; + bounds[3] = shape->bounds[3]; + for (shape = shape->next; shape != NULL; shape = shape->next) { + bounds[0] = nsvg__minf(bounds[0], shape->bounds[0]); + bounds[1] = nsvg__minf(bounds[1], shape->bounds[1]); + bounds[2] = nsvg__maxf(bounds[2], shape->bounds[2]); + bounds[3] = nsvg__maxf(bounds[3], shape->bounds[3]); + } +} + +static float nsvg__viewAlign(float content, float container, int type) +{ + if (type == NSVG_ALIGN_MIN) + return 0; + else if (type == NSVG_ALIGN_MAX) + return container - content; + // mid + return (container - content) * 0.5f; +} + +static void nsvg__scaleGradient(NSVGgradient* grad, float tx, float ty, float sx, float sy) +{ + float t[6]; + nsvg__xformSetTranslation(t, tx, ty); + nsvg__xformMultiply (grad->xform, t); + + nsvg__xformSetScale(t, sx, sy); + nsvg__xformMultiply (grad->xform, t); +} + +static void nsvg__scaleToViewbox(NSVGparser* p, const char* units) +{ + NSVGshape* shape; + NSVGpath* path; + float tx, ty, sx, sy, us, bounds[4], t[6], avgs; + int i; + float* pt; + + // Guess image size if not set completely. + nsvg__imageBounds(p, bounds); + + if (p->viewWidth == 0) { + if (p->image->width > 0) { + p->viewWidth = p->image->width; + } else { + p->viewMinx = bounds[0]; + p->viewWidth = bounds[2] - bounds[0]; + } + } + if (p->viewHeight == 0) { + if (p->image->height > 0) { + p->viewHeight = p->image->height; + } else { + p->viewMiny = bounds[1]; + p->viewHeight = bounds[3] - bounds[1]; + } + } + if (p->image->width == 0) + p->image->width = p->viewWidth; + if (p->image->height == 0) + p->image->height = p->viewHeight; + + tx = -p->viewMinx; + ty = -p->viewMiny; + sx = p->viewWidth > 0 ? p->image->width / p->viewWidth : 0; + sy = p->viewHeight > 0 ? p->image->height / p->viewHeight : 0; + // Unit scaling + us = 1.0f / nsvg__convertToPixels(p, nsvg__coord(1.0f, nsvg__parseUnits(units)), 0.0f, 1.0f); + + // Fix aspect ratio + if (p->alignType == NSVG_ALIGN_MEET) { + // fit whole image into viewbox + sx = sy = nsvg__minf(sx, sy); + tx += nsvg__viewAlign(p->viewWidth*sx, p->image->width, p->alignX) / sx; + ty += nsvg__viewAlign(p->viewHeight*sy, p->image->height, p->alignY) / sy; + } else if (p->alignType == NSVG_ALIGN_SLICE) { + // fill whole viewbox with image + sx = sy = nsvg__maxf(sx, sy); + tx += nsvg__viewAlign(p->viewWidth*sx, p->image->width, p->alignX) / sx; + ty += nsvg__viewAlign(p->viewHeight*sy, p->image->height, p->alignY) / sy; + } + + // Transform + sx *= us; + sy *= us; + avgs = (sx+sy) / 2.0f; + for (shape = p->image->shapes; shape != NULL; shape = shape->next) { + shape->bounds[0] = (shape->bounds[0] + tx) * sx; + shape->bounds[1] = (shape->bounds[1] + ty) * sy; + shape->bounds[2] = (shape->bounds[2] + tx) * sx; + shape->bounds[3] = (shape->bounds[3] + ty) * sy; + for (path = shape->paths; path != NULL; path = path->next) { + path->bounds[0] = (path->bounds[0] + tx) * sx; + path->bounds[1] = (path->bounds[1] + ty) * sy; + path->bounds[2] = (path->bounds[2] + tx) * sx; + path->bounds[3] = (path->bounds[3] + ty) * sy; + for (i =0; i < path->npts; i++) { + pt = &path->pts[i*2]; + pt[0] = (pt[0] + tx) * sx; + pt[1] = (pt[1] + ty) * sy; + } + } + + if (shape->fill.type == NSVG_PAINT_LINEAR_GRADIENT || shape->fill.type == NSVG_PAINT_RADIAL_GRADIENT) { + nsvg__scaleGradient(shape->fill.gradient, tx,ty, sx,sy); + memcpy(t, shape->fill.gradient->xform, sizeof(float)*6); + nsvg__xformInverse(shape->fill.gradient->xform, t); + } + if (shape->stroke.type == NSVG_PAINT_LINEAR_GRADIENT || shape->stroke.type == NSVG_PAINT_RADIAL_GRADIENT) { + nsvg__scaleGradient(shape->stroke.gradient, tx,ty, sx,sy); + memcpy(t, shape->stroke.gradient->xform, sizeof(float)*6); + nsvg__xformInverse(shape->stroke.gradient->xform, t); + } + + shape->strokeWidth *= avgs; + shape->strokeDashOffset *= avgs; + for (i = 0; i < shape->strokeDashCount; i++) + shape->strokeDashArray[i] *= avgs; + } +} + +static void nsvg__createGradients(NSVGparser* p) +{ + NSVGshape* shape; + + for (shape = p->image->shapes; shape != NULL; shape = shape->next) { + if (shape->fill.type == NSVG_PAINT_UNDEF) { + if (shape->fillGradient[0] != '\0') { + float inv[6], localBounds[4]; + nsvg__xformInverse(inv, shape->xform); + nsvg__getLocalBounds(localBounds, shape, inv); + shape->fill.gradient = nsvg__createGradient(p, shape->fillGradient, localBounds, shape->xform, &shape->fill.type); + } + if (shape->fill.type == NSVG_PAINT_UNDEF) { + shape->fill.type = NSVG_PAINT_NONE; + } + } + if (shape->stroke.type == NSVG_PAINT_UNDEF) { + if (shape->strokeGradient[0] != '\0') { + float inv[6], localBounds[4]; + nsvg__xformInverse(inv, shape->xform); + nsvg__getLocalBounds(localBounds, shape, inv); + shape->stroke.gradient = nsvg__createGradient(p, shape->strokeGradient, localBounds, shape->xform, &shape->stroke.type); + } + if (shape->stroke.type == NSVG_PAINT_UNDEF) { + shape->stroke.type = NSVG_PAINT_NONE; + } + } + } +} + +NSVGimage* nsvgParse(char* input, const char* units, float dpi) +{ + NSVGparser* p; + NSVGimage* ret = 0; + + p = nsvg__createParser(); + if (p == NULL) { + return NULL; + } + p->dpi = dpi; + + nsvg__parseXML(input, nsvg__startElement, nsvg__endElement, nsvg__content, p); + + // Create gradients after all definitions have been parsed + nsvg__createGradients(p); + + // Scale to viewBox + nsvg__scaleToViewbox(p, units); + + ret = p->image; + p->image = NULL; + + nsvg__deleteParser(p); + + return ret; +} + +NSVGimage* nsvgParseFromFile(const char* filename, const char* units, float dpi) +{ + FILE* fp = NULL; + size_t size; + char* data = NULL; + NSVGimage* image = NULL; + + fp = fopen(filename, "rb"); + if (!fp) goto error; + if (fseek(fp, 0, SEEK_END)) goto error; + size = ftell(fp); + if (size == -1l) goto error; + if (fseek(fp, 0, SEEK_SET)) goto error; + data = (char*)malloc(size+1); + if (data == NULL) goto error; + if (fread(data, 1, size, fp) != size) goto error; + data[size] = '\0'; // Must be null terminated. + fclose(fp); + image = nsvgParse(data, units, dpi); + free(data); + + return image; + +error: + if (fp) fclose(fp); + if (data) free(data); + if (image) nsvgDelete(image); + return NULL; +} + +NSVGpath* nsvgDuplicatePath(NSVGpath* p) +{ + NSVGpath* res = NULL; + + if (p == NULL) + return NULL; + + res = (NSVGpath*)malloc(sizeof(NSVGpath)); + if (res == NULL) goto error; + memset(res, 0, sizeof(NSVGpath)); + + res->pts = (float*)malloc(p->npts*2*sizeof(float)); + if (res->pts == NULL) goto error; + memcpy(res->pts, p->pts, p->npts * sizeof(float) * 2); + res->npts = p->npts; + + memcpy(res->bounds, p->bounds, sizeof(p->bounds)); + + res->closed = p->closed; + + return res; + +error: + if (res != NULL) { + free(res->pts); + free(res); + } + return NULL; +} + +void nsvgDelete(NSVGimage* image) +{ + NSVGshape *snext, *shape; + if (image == NULL) return; + shape = image->shapes; + while (shape != NULL) { + snext = shape->next; + nsvg__deletePaths(shape->paths); + nsvg__deletePaint(&shape->fill); + nsvg__deletePaint(&shape->stroke); + free(shape); + shape = snext; + } + free(image); +} + +#endif // NANOSVG_IMPLEMENTATION + +#endif // NANOSVG_H diff --git a/vendor/nanosvg/nanosvgrast.h b/vendor/nanosvg/nanosvgrast.h new file mode 100644 index 00000000..a3041d1d --- /dev/null +++ b/vendor/nanosvg/nanosvgrast.h @@ -0,0 +1,1471 @@ +/* + * Copyright (c) 2013-14 Mikko Mononen memon@inside.org + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * 3. This notice may not be removed or altered from any source distribution. + * + * The polygon rasterization is heavily based on stb_truetype rasterizer + * by Sean Barrett - http://nothings.org/ + * + */ + +#ifndef NANOSVGRAST_H +#define NANOSVGRAST_H + +#include "nanosvg.h" + +#ifndef NANOSVGRAST_CPLUSPLUS +#ifdef __cplusplus +extern "C" { +#endif +#endif + +typedef struct NSVGrasterizer NSVGrasterizer; + +/* Example Usage: + // Load SVG + NSVGimage* image; + image = nsvgParseFromFile("test.svg", "px", 96); + + // Create rasterizer (can be used to render multiple images). + struct NSVGrasterizer* rast = nsvgCreateRasterizer(); + // Allocate memory for image + unsigned char* img = malloc(w*h*4); + // Rasterize + nsvgRasterize(rast, image, 0,0,1, img, w, h, w*4); +*/ + +// Allocated rasterizer context. +NSVGrasterizer* nsvgCreateRasterizer(void); + +// Rasterizes SVG image, returns RGBA image (non-premultiplied alpha) +// r - pointer to rasterizer context +// image - pointer to image to rasterize +// tx,ty - image offset (applied after scaling) +// scale - image scale +// dst - pointer to destination image data, 4 bytes per pixel (RGBA) +// w - width of the image to render +// h - height of the image to render +// stride - number of bytes per scaleline in the destination buffer +void nsvgRasterize(NSVGrasterizer* r, + NSVGimage* image, float tx, float ty, float scale, + unsigned char* dst, int w, int h, int stride); + +// Deletes rasterizer context. +void nsvgDeleteRasterizer(NSVGrasterizer*); + + +#ifndef NANOSVGRAST_CPLUSPLUS +#ifdef __cplusplus +} +#endif +#endif + +#ifdef NANOSVGRAST_IMPLEMENTATION + +#include +#include +#include + +#define NSVG__SUBSAMPLES 5 +#define NSVG__FIXSHIFT 10 +#define NSVG__FIX (1 << NSVG__FIXSHIFT) +#define NSVG__FIXMASK (NSVG__FIX-1) +#define NSVG__MEMPAGE_SIZE 1024 + +typedef struct NSVGedge { + float x0,y0, x1,y1; + int dir; + struct NSVGedge* next; +} NSVGedge; + +typedef struct NSVGpoint { + float x, y; + float dx, dy; + float len; + float dmx, dmy; + unsigned char flags; +} NSVGpoint; + +typedef struct NSVGactiveEdge { + int x,dx; + float ey; + int dir; + struct NSVGactiveEdge *next; +} NSVGactiveEdge; + +typedef struct NSVGmemPage { + unsigned char mem[NSVG__MEMPAGE_SIZE]; + int size; + struct NSVGmemPage* next; +} NSVGmemPage; + +typedef struct NSVGcachedPaint { + signed char type; + char spread; + float xform[6]; + unsigned int colors[256]; +} NSVGcachedPaint; + +struct NSVGrasterizer +{ + float px, py; + + float tessTol; + float distTol; + + NSVGedge* edges; + int nedges; + int cedges; + + NSVGpoint* points; + int npoints; + int cpoints; + + NSVGpoint* points2; + int npoints2; + int cpoints2; + + NSVGactiveEdge* freelist; + NSVGmemPage* pages; + NSVGmemPage* curpage; + + unsigned char* scanline; + int cscanline; + + unsigned char* bitmap; + int width, height, stride; +}; + +NSVGrasterizer* nsvgCreateRasterizer(void) +{ + NSVGrasterizer* r = (NSVGrasterizer*)malloc(sizeof(NSVGrasterizer)); + if (r == NULL) goto error; + memset(r, 0, sizeof(NSVGrasterizer)); + + r->tessTol = 0.25f; + r->distTol = 0.01f; + + return r; + +error: + nsvgDeleteRasterizer(r); + return NULL; +} + +void nsvgDeleteRasterizer(NSVGrasterizer* r) +{ + NSVGmemPage* p; + + if (r == NULL) return; + + p = r->pages; + while (p != NULL) { + NSVGmemPage* next = p->next; + free(p); + p = next; + } + + if (r->edges) free(r->edges); + if (r->points) free(r->points); + if (r->points2) free(r->points2); + if (r->scanline) free(r->scanline); + + free(r); +} + +static NSVGmemPage* nsvg__nextPage(NSVGrasterizer* r, NSVGmemPage* cur) +{ + NSVGmemPage *newp; + + // If using existing chain, return the next page in chain + if (cur != NULL && cur->next != NULL) { + return cur->next; + } + + // Alloc new page + newp = (NSVGmemPage*)malloc(sizeof(NSVGmemPage)); + if (newp == NULL) return NULL; + memset(newp, 0, sizeof(NSVGmemPage)); + + // Add to linked list + if (cur != NULL) + cur->next = newp; + else + r->pages = newp; + + return newp; +} + +static void nsvg__resetPool(NSVGrasterizer* r) +{ + NSVGmemPage* p = r->pages; + while (p != NULL) { + p->size = 0; + p = p->next; + } + r->curpage = r->pages; +} + +static unsigned char* nsvg__alloc(NSVGrasterizer* r, int size) +{ + unsigned char* buf; + if (size > NSVG__MEMPAGE_SIZE) return NULL; + if (r->curpage == NULL || r->curpage->size+size > NSVG__MEMPAGE_SIZE) { + r->curpage = nsvg__nextPage(r, r->curpage); + } + buf = &r->curpage->mem[r->curpage->size]; + r->curpage->size += size; + return buf; +} + +static int nsvg__ptEquals(float x1, float y1, float x2, float y2, float tol) +{ + float dx = x2 - x1; + float dy = y2 - y1; + return dx*dx + dy*dy < tol*tol; +} + +static void nsvg__addPathPoint(NSVGrasterizer* r, float x, float y, int flags) +{ + NSVGpoint* pt; + + if (r->npoints > 0) { + pt = &r->points[r->npoints-1]; + if (nsvg__ptEquals(pt->x,pt->y, x,y, r->distTol)) { + pt->flags = (unsigned char)(pt->flags | flags); + return; + } + } + + if (r->npoints+1 > r->cpoints) { + r->cpoints = r->cpoints > 0 ? r->cpoints * 2 : 64; + r->points = (NSVGpoint*)realloc(r->points, sizeof(NSVGpoint) * r->cpoints); + if (r->points == NULL) return; + } + + pt = &r->points[r->npoints]; + pt->x = x; + pt->y = y; + pt->flags = (unsigned char)flags; + r->npoints++; +} + +static void nsvg__appendPathPoint(NSVGrasterizer* r, NSVGpoint pt) +{ + if (r->npoints+1 > r->cpoints) { + r->cpoints = r->cpoints > 0 ? r->cpoints * 2 : 64; + r->points = (NSVGpoint*)realloc(r->points, sizeof(NSVGpoint) * r->cpoints); + if (r->points == NULL) return; + } + r->points[r->npoints] = pt; + r->npoints++; +} + +static void nsvg__duplicatePoints(NSVGrasterizer* r) +{ + if (r->npoints > r->cpoints2) { + r->cpoints2 = r->npoints; + r->points2 = (NSVGpoint*)realloc(r->points2, sizeof(NSVGpoint) * r->cpoints2); + if (r->points2 == NULL) return; + } + + memcpy(r->points2, r->points, sizeof(NSVGpoint) * r->npoints); + r->npoints2 = r->npoints; +} + +static void nsvg__addEdge(NSVGrasterizer* r, float x0, float y0, float x1, float y1) +{ + NSVGedge* e; + + // Skip horizontal edges + if (y0 == y1) + return; + + if (r->nedges+1 > r->cedges) { + r->cedges = r->cedges > 0 ? r->cedges * 2 : 64; + r->edges = (NSVGedge*)realloc(r->edges, sizeof(NSVGedge) * r->cedges); + if (r->edges == NULL) return; + } + + e = &r->edges[r->nedges]; + r->nedges++; + + if (y0 < y1) { + e->x0 = x0; + e->y0 = y0; + e->x1 = x1; + e->y1 = y1; + e->dir = 1; + } else { + e->x0 = x1; + e->y0 = y1; + e->x1 = x0; + e->y1 = y0; + e->dir = -1; + } +} + +static float nsvg__normalize(float *x, float* y) +{ + float d = sqrtf((*x)*(*x) + (*y)*(*y)); + if (d > 1e-6f) { + float id = 1.0f / d; + *x *= id; + *y *= id; + } + return d; +} + +static float nsvg__absf(float x) { return x < 0 ? -x : x; } +static float nsvg__roundf(float x) { return (x >= 0) ? floorf(x + 0.5) : ceilf(x - 0.5); } + +static void nsvg__flattenCubicBez(NSVGrasterizer* r, + float x1, float y1, float x2, float y2, + float x3, float y3, float x4, float y4, + int level, int type) +{ + float x12,y12,x23,y23,x34,y34,x123,y123,x234,y234,x1234,y1234; + float dx,dy,d2,d3; + + if (level > 10) return; + + x12 = (x1+x2)*0.5f; + y12 = (y1+y2)*0.5f; + x23 = (x2+x3)*0.5f; + y23 = (y2+y3)*0.5f; + x34 = (x3+x4)*0.5f; + y34 = (y3+y4)*0.5f; + x123 = (x12+x23)*0.5f; + y123 = (y12+y23)*0.5f; + + dx = x4 - x1; + dy = y4 - y1; + d2 = nsvg__absf((x2 - x4) * dy - (y2 - y4) * dx); + d3 = nsvg__absf((x3 - x4) * dy - (y3 - y4) * dx); + + if ((d2 + d3)*(d2 + d3) < r->tessTol * (dx*dx + dy*dy)) { + nsvg__addPathPoint(r, x4, y4, type); + return; + } + + x234 = (x23+x34)*0.5f; + y234 = (y23+y34)*0.5f; + x1234 = (x123+x234)*0.5f; + y1234 = (y123+y234)*0.5f; + + nsvg__flattenCubicBez(r, x1,y1, x12,y12, x123,y123, x1234,y1234, level+1, 0); + nsvg__flattenCubicBez(r, x1234,y1234, x234,y234, x34,y34, x4,y4, level+1, type); +} + +static void nsvg__flattenShape(NSVGrasterizer* r, NSVGshape* shape, float scale) +{ + int i, j; + NSVGpath* path; + + for (path = shape->paths; path != NULL; path = path->next) { + r->npoints = 0; + // Flatten path + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, 0); + for (i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + nsvg__flattenCubicBez(r, p[0]*scale,p[1]*scale, p[2]*scale,p[3]*scale, p[4]*scale,p[5]*scale, p[6]*scale,p[7]*scale, 0, 0); + } + // Close path + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, 0); + // Build edges + for (i = 0, j = r->npoints-1; i < r->npoints; j = i++) + nsvg__addEdge(r, r->points[j].x, r->points[j].y, r->points[i].x, r->points[i].y); + } +} + +enum NSVGpointFlags +{ + NSVG_PT_CORNER = 0x01, + NSVG_PT_BEVEL = 0x02, + NSVG_PT_LEFT = 0x04 +}; + +static void nsvg__initClosed(NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + float len = nsvg__normalize(&dx, &dy); + float px = p0->x + dx*len*0.5f, py = p0->y + dy*len*0.5f; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__buttCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int connect) +{ + float w = lineWidth * 0.5f; + float px = p->x, py = p->y; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + + nsvg__addEdge(r, lx, ly, rx, ry); + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__squareCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int connect) +{ + float w = lineWidth * 0.5f; + float px = p->x - dx*w, py = p->y - dy*w; + float dlx = dy, dly = -dx; + float lx = px - dlx*w, ly = py - dly*w; + float rx = px + dlx*w, ry = py + dly*w; + + nsvg__addEdge(r, lx, ly, rx, ry); + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +#ifndef NSVG_PI +#define NSVG_PI (3.14159265358979323846264338327f) +#endif + +static void nsvg__roundCap(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p, float dx, float dy, float lineWidth, int ncap, int connect) +{ + int i; + float w = lineWidth * 0.5f; + float px = p->x, py = p->y; + float dlx = dy, dly = -dx; + float lx = 0, ly = 0, rx = 0, ry = 0, prevx = 0, prevy = 0; + + for (i = 0; i < ncap; i++) { + float a = (float)i/(float)(ncap-1)*NSVG_PI; + float ax = cosf(a) * w, ay = sinf(a) * w; + float x = px - dlx*ax - dx*ay; + float y = py - dly*ax - dy*ay; + + if (i > 0) + nsvg__addEdge(r, prevx, prevy, x, y); + + prevx = x; + prevy = y; + + if (i == 0) { + lx = x; ly = y; + } else if (i == ncap-1) { + rx = x; ry = y; + } + } + + if (connect) { + nsvg__addEdge(r, left->x, left->y, lx, ly); + nsvg__addEdge(r, rx, ry, right->x, right->y); + } + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__bevelJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float lx0 = p1->x - (dlx0 * w), ly0 = p1->y - (dly0 * w); + float rx0 = p1->x + (dlx0 * w), ry0 = p1->y + (dly0 * w); + float lx1 = p1->x - (dlx1 * w), ly1 = p1->y - (dly1 * w); + float rx1 = p1->x + (dlx1 * w), ry1 = p1->y + (dly1 * w); + + nsvg__addEdge(r, lx0, ly0, left->x, left->y); + nsvg__addEdge(r, lx1, ly1, lx0, ly0); + + nsvg__addEdge(r, right->x, right->y, rx0, ry0); + nsvg__addEdge(r, rx0, ry0, rx1, ry1); + + left->x = lx1; left->y = ly1; + right->x = rx1; right->y = ry1; +} + +static void nsvg__miterJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float lx0, rx0, lx1, rx1; + float ly0, ry0, ly1, ry1; + + if (p1->flags & NSVG_PT_LEFT) { + lx0 = lx1 = p1->x - p1->dmx * w; + ly0 = ly1 = p1->y - p1->dmy * w; + nsvg__addEdge(r, lx1, ly1, left->x, left->y); + + rx0 = p1->x + (dlx0 * w); + ry0 = p1->y + (dly0 * w); + rx1 = p1->x + (dlx1 * w); + ry1 = p1->y + (dly1 * w); + nsvg__addEdge(r, right->x, right->y, rx0, ry0); + nsvg__addEdge(r, rx0, ry0, rx1, ry1); + } else { + lx0 = p1->x - (dlx0 * w); + ly0 = p1->y - (dly0 * w); + lx1 = p1->x - (dlx1 * w); + ly1 = p1->y - (dly1 * w); + nsvg__addEdge(r, lx0, ly0, left->x, left->y); + nsvg__addEdge(r, lx1, ly1, lx0, ly0); + + rx0 = rx1 = p1->x + p1->dmx * w; + ry0 = ry1 = p1->y + p1->dmy * w; + nsvg__addEdge(r, right->x, right->y, rx1, ry1); + } + + left->x = lx1; left->y = ly1; + right->x = rx1; right->y = ry1; +} + +static void nsvg__roundJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p0, NSVGpoint* p1, float lineWidth, int ncap) +{ + int i, n; + float w = lineWidth * 0.5f; + float dlx0 = p0->dy, dly0 = -p0->dx; + float dlx1 = p1->dy, dly1 = -p1->dx; + float a0 = atan2f(dly0, dlx0); + float a1 = atan2f(dly1, dlx1); + float da = a1 - a0; + float lx, ly, rx, ry; + + if (da < NSVG_PI) da += NSVG_PI*2; + if (da > NSVG_PI) da -= NSVG_PI*2; + + n = (int)ceilf((nsvg__absf(da) / NSVG_PI) * (float)ncap); + if (n < 2) n = 2; + if (n > ncap) n = ncap; + + lx = left->x; + ly = left->y; + rx = right->x; + ry = right->y; + + for (i = 0; i < n; i++) { + float u = (float)i/(float)(n-1); + float a = a0 + u*da; + float ax = cosf(a) * w, ay = sinf(a) * w; + float lx1 = p1->x - ax, ly1 = p1->y - ay; + float rx1 = p1->x + ax, ry1 = p1->y + ay; + + nsvg__addEdge(r, lx1, ly1, lx, ly); + nsvg__addEdge(r, rx, ry, rx1, ry1); + + lx = lx1; ly = ly1; + rx = rx1; ry = ry1; + } + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static void nsvg__straightJoin(NSVGrasterizer* r, NSVGpoint* left, NSVGpoint* right, NSVGpoint* p1, float lineWidth) +{ + float w = lineWidth * 0.5f; + float lx = p1->x - (p1->dmx * w), ly = p1->y - (p1->dmy * w); + float rx = p1->x + (p1->dmx * w), ry = p1->y + (p1->dmy * w); + + nsvg__addEdge(r, lx, ly, left->x, left->y); + nsvg__addEdge(r, right->x, right->y, rx, ry); + + left->x = lx; left->y = ly; + right->x = rx; right->y = ry; +} + +static int nsvg__curveDivs(float r, float arc, float tol) +{ + float da = acosf(r / (r + tol)) * 2.0f; + int divs = (int)ceilf(arc / da); + if (divs < 2) divs = 2; + return divs; +} + +static void nsvg__expandStroke(NSVGrasterizer* r, NSVGpoint* points, int npoints, int closed, int lineJoin, int lineCap, float lineWidth) +{ + int ncap = nsvg__curveDivs(lineWidth*0.5f, NSVG_PI, r->tessTol); // Calculate divisions per half circle. + NSVGpoint left = {0,0,0,0,0,0,0,0}, right = {0,0,0,0,0,0,0,0}, firstLeft = {0,0,0,0,0,0,0,0}, firstRight = {0,0,0,0,0,0,0,0}; + NSVGpoint* p0, *p1; + int j, s, e; + + // Build stroke edges + if (closed) { + // Looping + p0 = &points[npoints-1]; + p1 = &points[0]; + s = 0; + e = npoints; + } else { + // Add cap + p0 = &points[0]; + p1 = &points[1]; + s = 1; + e = npoints-1; + } + + if (closed) { + nsvg__initClosed(&left, &right, p0, p1, lineWidth); + firstLeft = left; + firstRight = right; + } else { + // Add cap + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + nsvg__normalize(&dx, &dy); + if (lineCap == NSVG_CAP_BUTT) + nsvg__buttCap(r, &left, &right, p0, dx, dy, lineWidth, 0); + else if (lineCap == NSVG_CAP_SQUARE) + nsvg__squareCap(r, &left, &right, p0, dx, dy, lineWidth, 0); + else if (lineCap == NSVG_CAP_ROUND) + nsvg__roundCap(r, &left, &right, p0, dx, dy, lineWidth, ncap, 0); + } + + for (j = s; j < e; ++j) { + if (p1->flags & NSVG_PT_CORNER) { + if (lineJoin == NSVG_JOIN_ROUND) + nsvg__roundJoin(r, &left, &right, p0, p1, lineWidth, ncap); + else if (lineJoin == NSVG_JOIN_BEVEL || (p1->flags & NSVG_PT_BEVEL)) + nsvg__bevelJoin(r, &left, &right, p0, p1, lineWidth); + else + nsvg__miterJoin(r, &left, &right, p0, p1, lineWidth); + } else { + nsvg__straightJoin(r, &left, &right, p1, lineWidth); + } + p0 = p1++; + } + + if (closed) { + // Loop it + nsvg__addEdge(r, firstLeft.x, firstLeft.y, left.x, left.y); + nsvg__addEdge(r, right.x, right.y, firstRight.x, firstRight.y); + } else { + // Add cap + float dx = p1->x - p0->x; + float dy = p1->y - p0->y; + nsvg__normalize(&dx, &dy); + if (lineCap == NSVG_CAP_BUTT) + nsvg__buttCap(r, &right, &left, p1, -dx, -dy, lineWidth, 1); + else if (lineCap == NSVG_CAP_SQUARE) + nsvg__squareCap(r, &right, &left, p1, -dx, -dy, lineWidth, 1); + else if (lineCap == NSVG_CAP_ROUND) + nsvg__roundCap(r, &right, &left, p1, -dx, -dy, lineWidth, ncap, 1); + } +} + +static void nsvg__prepareStroke(NSVGrasterizer* r, float miterLimit, int lineJoin) +{ + int i, j; + NSVGpoint* p0, *p1; + + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + for (i = 0; i < r->npoints; i++) { + // Calculate segment direction and length + p0->dx = p1->x - p0->x; + p0->dy = p1->y - p0->y; + p0->len = nsvg__normalize(&p0->dx, &p0->dy); + // Advance + p0 = p1++; + } + + // calculate joins + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + for (j = 0; j < r->npoints; j++) { + float dlx0, dly0, dlx1, dly1, dmr2, cross; + dlx0 = p0->dy; + dly0 = -p0->dx; + dlx1 = p1->dy; + dly1 = -p1->dx; + // Calculate extrusions + p1->dmx = (dlx0 + dlx1) * 0.5f; + p1->dmy = (dly0 + dly1) * 0.5f; + dmr2 = p1->dmx*p1->dmx + p1->dmy*p1->dmy; + if (dmr2 > 0.000001f) { + float s2 = 1.0f / dmr2; + if (s2 > 600.0f) { + s2 = 600.0f; + } + p1->dmx *= s2; + p1->dmy *= s2; + } + + // Clear flags, but keep the corner. + p1->flags = (p1->flags & NSVG_PT_CORNER) ? NSVG_PT_CORNER : 0; + + // Keep track of left turns. + cross = p1->dx * p0->dy - p0->dx * p1->dy; + if (cross > 0.0f) + p1->flags |= NSVG_PT_LEFT; + + // Check to see if the corner needs to be beveled. + if (p1->flags & NSVG_PT_CORNER) { + if ((dmr2 * miterLimit*miterLimit) < 1.0f || lineJoin == NSVG_JOIN_BEVEL || lineJoin == NSVG_JOIN_ROUND) { + p1->flags |= NSVG_PT_BEVEL; + } + } + + p0 = p1++; + } +} + +static void nsvg__flattenShapeStroke(NSVGrasterizer* r, NSVGshape* shape, float scale) +{ + int i, j, closed; + NSVGpath* path; + NSVGpoint* p0, *p1; + float miterLimit = shape->miterLimit; + int lineJoin = shape->strokeLineJoin; + int lineCap = shape->strokeLineCap; + float lineWidth = shape->strokeWidth * scale; + + for (path = shape->paths; path != NULL; path = path->next) { + // Flatten path + r->npoints = 0; + nsvg__addPathPoint(r, path->pts[0]*scale, path->pts[1]*scale, NSVG_PT_CORNER); + for (i = 0; i < path->npts-1; i += 3) { + float* p = &path->pts[i*2]; + nsvg__flattenCubicBez(r, p[0]*scale,p[1]*scale, p[2]*scale,p[3]*scale, p[4]*scale,p[5]*scale, p[6]*scale,p[7]*scale, 0, NSVG_PT_CORNER); + } + if (r->npoints < 2) + continue; + + closed = path->closed; + + // If the first and last points are the same, remove the last, mark as closed path. + p0 = &r->points[r->npoints-1]; + p1 = &r->points[0]; + if (nsvg__ptEquals(p0->x,p0->y, p1->x,p1->y, r->distTol)) { + r->npoints--; + p0 = &r->points[r->npoints-1]; + closed = 1; + } + + float allDashLen = 0.0f; + for (j = 0; j < shape->strokeDashCount; j++) + allDashLen += shape->strokeDashArray[j]; + if (shape->strokeDashCount & 1) + allDashLen *= 2.0f; + if (shape->strokeDashCount > 0 && allDashLen > 0.0f) { + int idash = 0, dashState = 1; + float totalDist = 0, dashLen, dashOffset; + NSVGpoint cur; + + if (closed) + nsvg__appendPathPoint(r, r->points[0]); + + // Duplicate points -> points2. + nsvg__duplicatePoints(r); + + r->npoints = 0; + cur = r->points2[0]; + nsvg__appendPathPoint(r, cur); + + // Find location inside pattern + dashOffset = fmodf(shape->strokeDashOffset, allDashLen); + if (dashOffset < 0.0f) + dashOffset += allDashLen; + + while (dashOffset > shape->strokeDashArray[idash]) { + dashOffset -= shape->strokeDashArray[idash]; + idash = (idash + 1) % shape->strokeDashCount; + } + dashLen = (shape->strokeDashArray[idash] - dashOffset) * scale; + + for (j = 1; j < r->npoints2; ) { + float dx = r->points2[j].x - cur.x; + float dy = r->points2[j].y - cur.y; + float dist = sqrtf(dx*dx + dy*dy); + + if ((totalDist + dist) > dashLen) { + // Calculate intermediate point + float d = (dashLen - totalDist) / dist; + float x = cur.x + dx * d; + float y = cur.y + dy * d; + nsvg__addPathPoint(r, x, y, NSVG_PT_CORNER); + + // Stroke + if (r->npoints > 1 && dashState) { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, 0, lineJoin, lineCap, lineWidth); + } + // Advance dash pattern + dashState = !dashState; + idash = (idash+1) % shape->strokeDashCount; + dashLen = shape->strokeDashArray[idash] * scale; + // Restart + cur.x = x; + cur.y = y; + cur.flags = NSVG_PT_CORNER; + totalDist = 0.0f; + r->npoints = 0; + nsvg__appendPathPoint(r, cur); + } else { + totalDist += dist; + cur = r->points2[j]; + nsvg__appendPathPoint(r, cur); + j++; + } + } + // Stroke any leftover path + if (r->npoints > 1 && dashState) { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, 0, lineJoin, lineCap, lineWidth); + } + } else { + nsvg__prepareStroke(r, miterLimit, lineJoin); + nsvg__expandStroke(r, r->points, r->npoints, closed, lineJoin, lineCap, lineWidth); + } + } +} + +static int nsvg__cmpEdge(const void *p, const void *q) +{ + const NSVGedge* a = (const NSVGedge*)p; + const NSVGedge* b = (const NSVGedge*)q; + + if (a->y0 < b->y0) return -1; + if (a->y0 > b->y0) return 1; + return 0; +} + + +static NSVGactiveEdge* nsvg__addActive(NSVGrasterizer* r, NSVGedge* e, float startPoint) +{ + NSVGactiveEdge* z; + + if (r->freelist != NULL) { + // Restore from freelist. + z = r->freelist; + r->freelist = z->next; + } else { + // Alloc new edge. + z = (NSVGactiveEdge*)nsvg__alloc(r, sizeof(NSVGactiveEdge)); + if (z == NULL) return NULL; + } + + float dxdy = (e->x1 - e->x0) / (e->y1 - e->y0); +// STBTT_assert(e->y0 <= start_point); + // round dx down to avoid going too far + if (dxdy < 0) + z->dx = (int)(-nsvg__roundf(NSVG__FIX * -dxdy)); + else + z->dx = (int)nsvg__roundf(NSVG__FIX * dxdy); + z->x = (int)nsvg__roundf(NSVG__FIX * (e->x0 + dxdy * (startPoint - e->y0))); +// z->x -= off_x * FIX; + z->ey = e->y1; + z->next = 0; + z->dir = e->dir; + + return z; +} + +static void nsvg__freeActive(NSVGrasterizer* r, NSVGactiveEdge* z) +{ + z->next = r->freelist; + r->freelist = z; +} + +static void nsvg__fillScanline(unsigned char* scanline, int len, int x0, int x1, int maxWeight, int* xmin, int* xmax) +{ + int i = x0 >> NSVG__FIXSHIFT; + int j = x1 >> NSVG__FIXSHIFT; + if (i < *xmin) *xmin = i; + if (j > *xmax) *xmax = j; + if (i < len && j >= 0) { + if (i == j) { + // x0,x1 are the same pixel, so compute combined coverage + scanline[i] = (unsigned char)(scanline[i] + ((x1 - x0) * maxWeight >> NSVG__FIXSHIFT)); + } else { + if (i >= 0) // add antialiasing for x0 + scanline[i] = (unsigned char)(scanline[i] + (((NSVG__FIX - (x0 & NSVG__FIXMASK)) * maxWeight) >> NSVG__FIXSHIFT)); + else + i = -1; // clip + + if (j < len) // add antialiasing for x1 + scanline[j] = (unsigned char)(scanline[j] + (((x1 & NSVG__FIXMASK) * maxWeight) >> NSVG__FIXSHIFT)); + else + j = len; // clip + + for (++i; i < j; ++i) // fill pixels between x0 and x1 + scanline[i] = (unsigned char)(scanline[i] + maxWeight); + } + } +} + +// note: this routine clips fills that extend off the edges... ideally this +// wouldn't happen, but it could happen if the truetype glyph bounding boxes +// are wrong, or if the user supplies a too-small bitmap +static void nsvg__fillActiveEdges(unsigned char* scanline, int len, NSVGactiveEdge* e, int maxWeight, int* xmin, int* xmax, char fillRule) +{ + // non-zero winding fill + int x0 = 0, w = 0; + + if (fillRule == NSVG_FILLRULE_NONZERO) { + // Non-zero + while (e != NULL) { + if (w == 0) { + // if we're currently at zero, we need to record the edge start point + x0 = e->x; w += e->dir; + } else { + int x1 = e->x; w += e->dir; + // if we went to zero, we need to draw + if (w == 0) + nsvg__fillScanline(scanline, len, x0, x1, maxWeight, xmin, xmax); + } + e = e->next; + } + } else if (fillRule == NSVG_FILLRULE_EVENODD) { + // Even-odd + while (e != NULL) { + if (w == 0) { + // if we're currently at zero, we need to record the edge start point + x0 = e->x; w = 1; + } else { + int x1 = e->x; w = 0; + nsvg__fillScanline(scanline, len, x0, x1, maxWeight, xmin, xmax); + } + e = e->next; + } + } +} + +static float nsvg__clampf(float a, float mn, float mx) { + if (isnan(a)) + return mn; + return a < mn ? mn : (a > mx ? mx : a); +} + +static unsigned int nsvg__RGBA(unsigned char r, unsigned char g, unsigned char b, unsigned char a) +{ + return ((unsigned int)r) | ((unsigned int)g << 8) | ((unsigned int)b << 16) | ((unsigned int)a << 24); +} + +static unsigned int nsvg__lerpRGBA(unsigned int c0, unsigned int c1, float u) +{ + int iu = (int)(nsvg__clampf(u, 0.0f, 1.0f) * 256.0f); + int r = (((c0) & 0xff)*(256-iu) + (((c1) & 0xff)*iu)) >> 8; + int g = (((c0>>8) & 0xff)*(256-iu) + (((c1>>8) & 0xff)*iu)) >> 8; + int b = (((c0>>16) & 0xff)*(256-iu) + (((c1>>16) & 0xff)*iu)) >> 8; + int a = (((c0>>24) & 0xff)*(256-iu) + (((c1>>24) & 0xff)*iu)) >> 8; + return nsvg__RGBA((unsigned char)r, (unsigned char)g, (unsigned char)b, (unsigned char)a); +} + +static unsigned int nsvg__applyOpacity(unsigned int c, float u) +{ + int iu = (int)(nsvg__clampf(u, 0.0f, 1.0f) * 256.0f); + int r = (c) & 0xff; + int g = (c>>8) & 0xff; + int b = (c>>16) & 0xff; + int a = (((c>>24) & 0xff)*iu) >> 8; + return nsvg__RGBA((unsigned char)r, (unsigned char)g, (unsigned char)b, (unsigned char)a); +} + +static inline int nsvg__div255(int x) +{ + return ((x+1) * 257) >> 16; +} + +static void nsvg__scanlineSolid(unsigned char* dst, int count, unsigned char* cover, int x, int y, + float tx, float ty, float scale, NSVGcachedPaint* cache) +{ + + if (cache->type == NSVG_PAINT_COLOR) { + int i, cr, cg, cb, ca; + cr = cache->colors[0] & 0xff; + cg = (cache->colors[0] >> 8) & 0xff; + cb = (cache->colors[0] >> 16) & 0xff; + ca = (cache->colors[0] >> 24) & 0xff; + + for (i = 0; i < count; i++) { + int r,g,b; + int a = nsvg__div255((int)cover[0] * ca); + int ia = 255 - a; + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + } + } else if (cache->type == NSVG_PAINT_LINEAR_GRADIENT) { + // TODO: spread modes. + // TODO: plenty of opportunities to optimize. + float fx, fy, dx, gy; + float* t = cache->xform; + int i, cr, cg, cb, ca; + unsigned int c; + + fx = ((float)x - tx) / scale; + fy = ((float)y - ty) / scale; + dx = 1.0f / scale; + + for (i = 0; i < count; i++) { + int r,g,b,a,ia; + gy = fx*t[1] + fy*t[3] + t[5]; + c = cache->colors[(int)nsvg__clampf(gy*255.0f, 0, 255.0f)]; + cr = (c) & 0xff; + cg = (c >> 8) & 0xff; + cb = (c >> 16) & 0xff; + ca = (c >> 24) & 0xff; + + a = nsvg__div255((int)cover[0] * ca); + ia = 255 - a; + + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + fx += dx; + } + } else if (cache->type == NSVG_PAINT_RADIAL_GRADIENT) { + // TODO: spread modes. + // TODO: plenty of opportunities to optimize. + // TODO: focus (fx,fy) + float fx, fy, dx, gx, gy, gd; + float* t = cache->xform; + int i, cr, cg, cb, ca; + unsigned int c; + + fx = ((float)x - tx) / scale; + fy = ((float)y - ty) / scale; + dx = 1.0f / scale; + + for (i = 0; i < count; i++) { + int r,g,b,a,ia; + gx = fx*t[0] + fy*t[2] + t[4]; + gy = fx*t[1] + fy*t[3] + t[5]; + gd = sqrtf(gx*gx + gy*gy); + c = cache->colors[(int)nsvg__clampf(gd*255.0f, 0, 255.0f)]; + cr = (c) & 0xff; + cg = (c >> 8) & 0xff; + cb = (c >> 16) & 0xff; + ca = (c >> 24) & 0xff; + + a = nsvg__div255((int)cover[0] * ca); + ia = 255 - a; + + // Premultiply + r = nsvg__div255(cr * a); + g = nsvg__div255(cg * a); + b = nsvg__div255(cb * a); + + // Blend over + r += nsvg__div255(ia * (int)dst[0]); + g += nsvg__div255(ia * (int)dst[1]); + b += nsvg__div255(ia * (int)dst[2]); + a += nsvg__div255(ia * (int)dst[3]); + + dst[0] = (unsigned char)r; + dst[1] = (unsigned char)g; + dst[2] = (unsigned char)b; + dst[3] = (unsigned char)a; + + cover++; + dst += 4; + fx += dx; + } + } +} + +static void nsvg__rasterizeSortedEdges(NSVGrasterizer *r, float tx, float ty, float scale, NSVGcachedPaint* cache, char fillRule) +{ + NSVGactiveEdge *active = NULL; + int y, s; + int e = 0; + int maxWeight = (255 / NSVG__SUBSAMPLES); // weight per vertical scanline + int xmin, xmax; + + for (y = 0; y < r->height; y++) { + memset(r->scanline, 0, r->width); + xmin = r->width; + xmax = 0; + for (s = 0; s < NSVG__SUBSAMPLES; ++s) { + // find center of pixel for this scanline + float scany = (float)(y*NSVG__SUBSAMPLES + s) + 0.5f; + NSVGactiveEdge **step = &active; + + // update all active edges; + // remove all active edges that terminate before the center of this scanline + while (*step) { + NSVGactiveEdge *z = *step; + if (z->ey <= scany) { + *step = z->next; // delete from list +// NSVG__assert(z->valid); + nsvg__freeActive(r, z); + } else { + z->x += z->dx; // advance to position for current scanline + step = &((*step)->next); // advance through list + } + } + + // resort the list if needed + for (;;) { + int changed = 0; + step = &active; + while (*step && (*step)->next) { + if ((*step)->x > (*step)->next->x) { + NSVGactiveEdge* t = *step; + NSVGactiveEdge* q = t->next; + t->next = q->next; + q->next = t; + *step = q; + changed = 1; + } + step = &(*step)->next; + } + if (!changed) break; + } + + // insert all edges that start before the center of this scanline -- omit ones that also end on this scanline + while (e < r->nedges && r->edges[e].y0 <= scany) { + if (r->edges[e].y1 > scany) { + NSVGactiveEdge* z = nsvg__addActive(r, &r->edges[e], scany); + if (z == NULL) break; + // find insertion point + if (active == NULL) { + active = z; + } else if (z->x < active->x) { + // insert at front + z->next = active; + active = z; + } else { + // find thing to insert AFTER + NSVGactiveEdge* p = active; + while (p->next && p->next->x < z->x) + p = p->next; + // at this point, p->next->x is NOT < z->x + z->next = p->next; + p->next = z; + } + } + e++; + } + + // now process all active edges in non-zero fashion + if (active != NULL) + nsvg__fillActiveEdges(r->scanline, r->width, active, maxWeight, &xmin, &xmax, fillRule); + } + // Blit + if (xmin < 0) xmin = 0; + if (xmax > r->width-1) xmax = r->width-1; + if (xmin <= xmax) { + nsvg__scanlineSolid(&r->bitmap[y * r->stride] + xmin*4, xmax-xmin+1, &r->scanline[xmin], xmin, y, tx,ty, scale, cache); + } + } + +} + +static void nsvg__unpremultiplyAlpha(unsigned char* image, int w, int h, int stride) +{ + int x,y; + + // Unpremultiply + for (y = 0; y < h; y++) { + unsigned char *row = &image[y*stride]; + for (x = 0; x < w; x++) { + int r = row[0], g = row[1], b = row[2], a = row[3]; + if (a != 0) { + row[0] = (unsigned char)(r*255/a); + row[1] = (unsigned char)(g*255/a); + row[2] = (unsigned char)(b*255/a); + } + row += 4; + } + } + + // Defringe + for (y = 0; y < h; y++) { + unsigned char *row = &image[y*stride]; + for (x = 0; x < w; x++) { + int r = 0, g = 0, b = 0, a = row[3], n = 0; + if (a == 0) { + if (x-1 > 0 && row[-1] != 0) { + r += row[-4]; + g += row[-3]; + b += row[-2]; + n++; + } + if (x+1 < w && row[7] != 0) { + r += row[4]; + g += row[5]; + b += row[6]; + n++; + } + if (y-1 > 0 && row[-stride+3] != 0) { + r += row[-stride]; + g += row[-stride+1]; + b += row[-stride+2]; + n++; + } + if (y+1 < h && row[stride+3] != 0) { + r += row[stride]; + g += row[stride+1]; + b += row[stride+2]; + n++; + } + if (n > 0) { + row[0] = (unsigned char)(r/n); + row[1] = (unsigned char)(g/n); + row[2] = (unsigned char)(b/n); + } + } + row += 4; + } + } +} + + +static void nsvg__initPaint(NSVGcachedPaint* cache, NSVGpaint* paint, float opacity) +{ + int i, j; + NSVGgradient* grad; + + cache->type = paint->type; + + if (paint->type == NSVG_PAINT_COLOR) { + cache->colors[0] = nsvg__applyOpacity(paint->color, opacity); + return; + } + + grad = paint->gradient; + + cache->spread = grad->spread; + memcpy(cache->xform, grad->xform, sizeof(float)*6); + + if (grad->nstops == 0) { + for (i = 0; i < 256; i++) + cache->colors[i] = 0; + } else if (grad->nstops == 1) { + unsigned int color = nsvg__applyOpacity(grad->stops[0].color, opacity); + for (i = 0; i < 256; i++) + cache->colors[i] = color; + } else { + unsigned int ca, cb = 0; + float ua, ub, du, u; + int ia, ib, count; + + ca = nsvg__applyOpacity(grad->stops[0].color, opacity); + ua = nsvg__clampf(grad->stops[0].offset, 0, 1); + ub = nsvg__clampf(grad->stops[grad->nstops-1].offset, ua, 1); + ia = (int)(ua * 255.0f); + ib = (int)(ub * 255.0f); + for (i = 0; i < ia; i++) { + cache->colors[i] = ca; + } + + for (i = 0; i < grad->nstops-1; i++) { + ca = nsvg__applyOpacity(grad->stops[i].color, opacity); + cb = nsvg__applyOpacity(grad->stops[i+1].color, opacity); + ua = nsvg__clampf(grad->stops[i].offset, 0, 1); + ub = nsvg__clampf(grad->stops[i+1].offset, 0, 1); + ia = (int)(ua * 255.0f); + ib = (int)(ub * 255.0f); + count = ib - ia; + if (count <= 0) continue; + u = 0; + du = 1.0f / (float)count; + for (j = 0; j < count; j++) { + cache->colors[ia+j] = nsvg__lerpRGBA(ca,cb,u); + u += du; + } + } + + for (i = ib; i < 256; i++) + cache->colors[i] = cb; + } + +} + +/* +static void dumpEdges(NSVGrasterizer* r, const char* name) +{ + float xmin = 0, xmax = 0, ymin = 0, ymax = 0; + NSVGedge *e = NULL; + int i; + if (r->nedges == 0) return; + FILE* fp = fopen(name, "w"); + if (fp == NULL) return; + + xmin = xmax = r->edges[0].x0; + ymin = ymax = r->edges[0].y0; + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + xmin = nsvg__minf(xmin, e->x0); + xmin = nsvg__minf(xmin, e->x1); + xmax = nsvg__maxf(xmax, e->x0); + xmax = nsvg__maxf(xmax, e->x1); + ymin = nsvg__minf(ymin, e->y0); + ymin = nsvg__minf(ymin, e->y1); + ymax = nsvg__maxf(ymax, e->y0); + ymax = nsvg__maxf(ymax, e->y1); + } + + fprintf(fp, "", xmin, ymin, (xmax - xmin), (ymax - ymin)); + + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + fprintf(fp ,"", e->x0,e->y0, e->x1,e->y1); + } + + for (i = 0; i < r->npoints; i++) { + if (i+1 < r->npoints) + fprintf(fp ,"", r->points[i].x, r->points[i].y, r->points[i+1].x, r->points[i+1].y); + fprintf(fp ,"", r->points[i].x, r->points[i].y, r->points[i].flags == 0 ? "#f00" : "#0f0"); + } + + fprintf(fp, ""); + fclose(fp); +} +*/ + +void nsvgRasterize(NSVGrasterizer* r, + NSVGimage* image, float tx, float ty, float scale, + unsigned char* dst, int w, int h, int stride) +{ + NSVGshape *shape = NULL; + NSVGedge *e = NULL; + NSVGcachedPaint cache; + int i; + int j; + unsigned char paintOrder; + + r->bitmap = dst; + r->width = w; + r->height = h; + r->stride = stride; + + if (w > r->cscanline) { + r->cscanline = w; + r->scanline = (unsigned char*)realloc(r->scanline, w); + if (r->scanline == NULL) return; + } + + for (i = 0; i < h; i++) + memset(&dst[i*stride], 0, w*4); + + for (shape = image->shapes; shape != NULL; shape = shape->next) { + if (!(shape->flags & NSVG_FLAGS_VISIBLE)) + continue; + + for (j = 0; j < 3; j++) { + paintOrder = (shape->paintOrder >> (2 * j)) & 0x03; + + if (paintOrder == NSVG_PAINT_FILL && shape->fill.type != NSVG_PAINT_NONE) { + nsvg__resetPool(r); + r->freelist = NULL; + r->nedges = 0; + + nsvg__flattenShape(r, shape, scale); + + // Scale and translate edges + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + e->x0 = tx + e->x0; + e->y0 = (ty + e->y0) * NSVG__SUBSAMPLES; + e->x1 = tx + e->x1; + e->y1 = (ty + e->y1) * NSVG__SUBSAMPLES; + } + + // Rasterize edges + if (r->nedges != 0) + qsort(r->edges, r->nedges, sizeof(NSVGedge), nsvg__cmpEdge); + + // now, traverse the scanlines and find the intersections on each scanline, use non-zero rule + nsvg__initPaint(&cache, &shape->fill, shape->opacity); + + nsvg__rasterizeSortedEdges(r, tx,ty,scale, &cache, shape->fillRule); + } + if (paintOrder == NSVG_PAINT_STROKE && shape->stroke.type != NSVG_PAINT_NONE && (shape->strokeWidth * scale) > 0.01f) { + nsvg__resetPool(r); + r->freelist = NULL; + r->nedges = 0; + + nsvg__flattenShapeStroke(r, shape, scale); + + // dumpEdges(r, "edge.svg"); + + // Scale and translate edges + for (i = 0; i < r->nedges; i++) { + e = &r->edges[i]; + e->x0 = tx + e->x0; + e->y0 = (ty + e->y0) * NSVG__SUBSAMPLES; + e->x1 = tx + e->x1; + e->y1 = (ty + e->y1) * NSVG__SUBSAMPLES; + } + + // Rasterize edges + if (r->nedges != 0) + qsort(r->edges, r->nedges, sizeof(NSVGedge), nsvg__cmpEdge); + + // now, traverse the scanlines and find the intersections on each scanline, use non-zero rule + nsvg__initPaint(&cache, &shape->stroke, shape->opacity); + + nsvg__rasterizeSortedEdges(r, tx,ty,scale, &cache, NSVG_FILLRULE_NONZERO); + } + } + } + + nsvg__unpremultiplyAlpha(dst, w, h, stride); + + r->bitmap = NULL; + r->width = 0; + r->height = 0; + r->stride = 0; +} + +#endif // NANOSVGRAST_IMPLEMENTATION + +#endif // NANOSVGRAST_H