Compare commits

..

39 Commits

Author SHA1 Message Date
Yu Leng
c27ce19ce2 [KBM] Fix check-spelling failures in CLI command template PR
- Remove internal superpowers planning/design docs (not product content;
  avoids whitelisting a username and agent jargon in the global dictionary)
- Add powertoyscli and retargets to spell-check expect.txt
- Reword "non-existent" -> "nonexistent" (line_forbidden.patterns rule)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:46:38 +08:00
Yu Leng
46e3215c10 [KBM] Address review findings in CLI command template feature
Cleanup and small correctness fixes from a self-review of the command
template work:

- MappingConfiguration.cpp: extract ReadTemplateMetadata/WriteTemplateMetadata
  helpers, removing four near-duplicate template (de)serialization blocks.
- MainPage.SaveRunTemplateMapping: preserve StartInDirectory/IfRunningAction/
  Visibility/Elevation so re-saving an edited template mapping no longer resets
  them to defaults; persist null instead of an empty {} parameter dictionary.
- KeyboardMappingService.ReadTemplateFields: broaden the catch so malformed
  on-disk metadata can never leak the other native-allocated strings.
- CommandTemplateCatalog: remove the unused TryFind method (and now-unused
  System.Linq using).
- PowerToysInstallResolver: drop the redundant %ProgramW6432% candidate (the
  editor is always 64-bit, so %ProgramFiles% already covers it).
- KeysDataModel: align templateParameters JsonIgnore with templateId
  (WhenWritingNull) for consistency.
- CommandTemplatePickerViewModel.ApplyTemplate: notify IsAllValid so the host
  re-evaluates Save-button state on template selection, not only on param edits.
- UnifiedMappingControl: select the template before switching action type in
  OnCommandClick to avoid a transient stale validation; clear the cached
  missing-template fallback command in Reset().

Builds clean (C++/C#/WinUI); KBM template unit tests 15/15 and KeysDataModel
template-field tests 3/3 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:01 +08:00
Yu Leng
f0a828ee22 [KBM] Fix C4190 build error in template metadata helper
SetTemplateMetadata previously delegated to a std::wstring-returning
SerializeTemplateParameters defined inside the wrapper's extern "C"
block; a C-linkage function may not return a C++ type (warning C4190,
treated as error). Inline the JSON serialization so both helpers return
void. Verified: KeyboardManagerEditorLibraryWrapper.vcxproj builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:05:13 +08:00
Yu Leng
b2bd24db0d [KBM] Fix review findings in CLI command template feature
Addresses correctness, robustness, and round-trip issues found while
reviewing the CLI-command-template work:

- Required-parameter validation: gate Save on IsAllValid and bubble
  parameter changes to the host (previously could save "--open-settings=").
- FFI read-back: carry templateId/templateParameters back to C# so a
  template mapping survives a rebuild from default.json (struct + both
  GetShortcutRemap[ByType] + KeyboardMappingService projection).
- Catalog load: wrap menu build in try/catch so a malformed catalog
  degrades gracefully instead of crashing the editor at startup.
- Install location: retarget the per-user PowerToys.exe path to a
  machine-wide install when the LOCALAPPDATA path is absent.
- Missing-template "Keep as plain command": preserve the resolved
  command instead of leaving an empty, unsavable OpenApp form.
- TemplateResolver: single-pass substitution + CommandLineToArgvW
  quoting (prevents substitution-injection and arg-splitting).
- C++ load: type-check templateParameters before reading so malformed
  optional metadata no longer drops the whole mapping; dedupe GetObjectW.
- schemaVersion: accept forward-compatible (>=1) catalogs; honor iconGlyph.
- Fix SelectionChanged/AppSpecificCheckBox handler re-subscription leak.
- Add KeyboardManagerEditorUI.UnitTests (resolver + catalog model);
  15 tests, wired into PowerToys.slnx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:36:28 +08:00
Yu Leng
28d6fe1615 Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-06-15 11:16:42 +08:00
Gordon Lam
4d01062f76 Fix check-spelling: exclude ZoomIt rnnoise third-party tree and dedupe excludes (#48548)
## Summary

The `Check Spelling` workflow has been failing on PRs against `main`
(e.g. #48546) due to issues introduced by the recent ZoomIt webcam-blur
/ noise-cancellation change (#48266) plus two pre-existing duplicate
entries in `.github/actions/spell-check/excludes.txt`.

## What the bot reported

| Severity | Type | Location |
|---|---|---|
|  | `forbidden-pattern` (Should be `a`) |
`src/modules/ZoomIt/ZoomIt/rnnoise/kiss_fft.h:79` — third-party kiss_fft
header contains `an fft` |
| ⚠️ | `large-file` (~30 MB) |
`src/modules/ZoomIt/ZoomIt/rnnoise/rnnoise_data_little.c` |
| ⚠️ | `binary-file` |
`src/modules/ZoomIt/ZoomIt/selfie_segmentation.onnx` |
| ⚠️ | `duplicate-pattern` ×2 | `excludes.txt` lines 115/116 duplicate
lines 108/109 (`FuzzyMatcher{Comparison,Diacritics}Tests.cs`) |

## Fix

`.github/actions/spell-check/excludes.txt`:

- **Drop 2 duplicate** `FuzzyMatcher*Tests.cs` lines.
- **Add 2 new exclusions** for the new third-party ZoomIt assets:
- `^src/modules/ZoomIt/ZoomIt/rnnoise/` — entire third-party
rnnoise/kiss_fft tree (covers both the `an fft` forbidden-pattern in
`kiss_fft.h` and the 30 MB `rnnoise_data_little.c` large-file).
- `^src/modules/ZoomIt/ZoomIt/selfie_segmentation\.onnx$` — the ML model
binary.

Net change: `-2` duplicates, `+2` new exclusions → file count unchanged
at 148 lines.

## Notes

- Third-party content under `rnnoise/` should not be spell-checked; this
matches how other vendored/third-party trees in the repo are handled
(e.g. `src/common/CalculatorEngineCommon/exprtk.hpp`,
`src/common/sysinternals/Eula/`).
- No source code changes; pure config.
- Unblocks #48546 and any other PR currently failing `Check Spelling` on
`main`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 09:25:03 +08:00
Knyrps
d7d1e543ae [CmdPal][TimeDate] Open notification center when clicking the clock dock band (#48514)
## Summary

Clicking the clock dock band in the CmdPal Dock now opens the Windows
notification center (Action Center). A separate bell-icon-only dock band
is also exposed for users who prefer a dedicated notification center
shortcut.

Closes #46327

## Detail

- **Clock band left-click**: replaced the previous `NoOpCommand` on
`NowDockBand` with `OpenUrlCommand("ms-actioncenter:")`, dismissing the
Dock on invoke. The `ms-actioncenter:` URI is the correct shell
mechanism - `SendInput` Win+N was tested but dropped because it requires
foreground focus, which the Dock holds at click time.
- **Notification center band**: new `NotificationCenterDockBand`
(`ListItem`) in `TimeDateCommandsProvider.cs`, with a bell icon
(`\uEA8F`, Segoe Fluent Icons) and the same `ms-actioncenter:` command.
Exposed as a second `WrappedDockItem` from `GetDockBands()` under the id
`com.microsoft.cmdpal.timedate.notificationCenterBand`. Users can pin it
from the Dock's edit mode.
- **New resource strings**:
`timedate_show_notification_center_command_name` and
`timedate_notification_center_band_title` added to `Resources.resx` /
`Resources.Designer.cs`.
- **VS 2026 C++ build fixes** (pre-existing failures on `HEAD`): added
`_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS` to
`CalculatorEngineCommon.vcxproj`.

## Screenshots

<img width="339" height="991" alt="image"
src="https://github.com/user-attachments/assets/e0ef8c9a-ec1f-40fa-9620-1e83e6aeeb8d"
/>

## How tested

- Built `Microsoft.CmdPal.UI.csproj` (Debug x64) - 0 errors.
- Launched dev `Microsoft.CmdPal.UI.exe`, clicked the clock band -
notification center opened correctly.
- Right-click context menu on the clock band still shows "Copy time" and
"Copy date" unchanged.
- Pinned the notification center band via edit mode - bell icon renders
icon-only, click opens notification center.
2026-06-12 19:23:46 +00:00
Alex Mihaiuc
272b725ff0 Add ZoomIt webcam backgroun (blur) and microphone noise cancellation (#48266)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This change adds the [RNNoise](https://github.com/xiph/rnnoise) filter
for noise cancellation (audio) and the [Google
mediapipe](https://github.com/google-ai-edge/mediapipe/tree/master)
`selfie_segmentation_cpu` model for webcam background detection and
blurring.

It also fixes an issue introduced with
ba68b88ca1 causing the ZoomIt shortcuts to
fail to register in the standalone version.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

The Settings UI has been extended with a Noise cancellation option, a
Background selection for the webcam and a Brightness slider.

The functionality for these is added to ZoomIt itself. Also, restored
the Mono checkbox which was accidentally masked by
b93fd97e80.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Mario Hewardt <marioh@microsoft.com>
2026-06-12 00:12:35 +02:00
Niels Laute
7884f4217a Update readme (#48392)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-11 06:54:58 +00:00
gilnatab
92014c81b9 [PowerDisplay] Add linked brightness control (#48207)
## Summary of the Pull Request

Adds linked brightness control to PowerDisplay so multiple
brightness-capable monitors can be controlled from a single "All
Displays" slider.

This PR:
- Adds a linked brightness mode with one master brightness slider.
- Seeds the master slider from the linked display with the lowest
Windows DISPLAY number, falling back to monitor ID for determinism.
- Persists linked mode enabled/disabled state.
- Persists per-monitor exclusions by monitor ID.
- Keeps individual display cards available under an expandable section
while linked mode is enabled.
- Shows linked-state guidance in the link icon tooltip instead of a
separate info banner.
- Allows excluded displays to keep their own independent brightness
slider.
- Keeps profiles as per-monitor snapshots; applying a profile turns
linked brightness off before applying the profile values.
- Adds unit tests for linked-brightness selection/seed behavior and
settings compatibility.

## PR Checklist

- [x] Closes: #47319
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

**Screenshots**

| State | Light | Dark |
| --- | --- | --- |
| Linked mode off | <img width="519" height="817" alt="image"
src="https://github.com/user-attachments/assets/bdfae94b-b2e2-4ad3-a45c-7925bb9e5dcd"
/> | <img width="520" height="817" alt="image"
src="https://github.com/user-attachments/assets/69290a70-0375-480d-957c-c9e0af43d18e"
/> |
| Linked mode on | <img width="520" height="307" alt="image"
src="https://github.com/user-attachments/assets/a2b3572b-e51f-4bdc-9209-23ad2f96d27a"
/> | <img width="520" height="307" alt="image"
src="https://github.com/user-attachments/assets/8b14b665-b641-4256-a15b-eced82e62728"
/> |
| Linked mode on — individual displays expanded | <img width="520"
height="895" alt="image"
src="https://github.com/user-attachments/assets/0b40e60d-e78a-4814-baf6-00be7e283edd"
/> | <img width="520" height="895" alt="image"
src="https://github.com/user-attachments/assets/4f59bbfa-d6e5-4cb7-af84-cb484f922a7c"
/> |

The first version is intentionally scoped to brightness-only linked
control. Contrast, volume, color temperature, input source, and
LightSwitch-specific behavior remain independent.

Linked brightness is stored as global PowerDisplay settings:
- `linked_levels_active`
- `excluded_from_sync_monitor_ids`

Newly connected brightness-capable monitors are included by default,
because the exclusion list is the explicit exception. Hotplugging a
monitor does not immediately write brightness; linked hardware writes
happen only after the user changes the master slider.

Profiles remain per-monitor snapshots. This PR does not add
profile-level linked brightness configuration. If linked brightness is
active when a profile is applied, linked mode is turned off first, then
the saved per-monitor profile values are applied. That avoids leaving
the master linked slider active while hardware brightness has been
changed independently per monitor.

When linked mode is turned on, the master slider is seeded from the
linked brightness-capable display with the lowest Windows DISPLAY
number, falling back to monitor ID for determinism. Excluded displays
and displays without brightness support are ignored; if no linked target
remains, the master slider stays disabled. The seed only positions the
slider; it is never written to hardware, so the first user gesture is
the first broadcast.

## Validation Steps Performed

- Built `PowerDisplay.Lib.UnitTests` Debug x64:

```powershell
.\tools\build\build.ps1 -Platform x64 -Configuration Debug -Path src\modules\powerdisplay\PowerDisplay.Lib.UnitTests
```

- Ran `PowerDisplay.Lib.UnitTests` with `vstest.console.exe`
- Ran the XAML styling script:

```powershell
.\.pipelines\applyXamlStyling.ps1 -Main
```

- Result: the XAML styling script completed successfully and processed
`src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml`.
2026-06-10 13:54:40 +08:00
Alex Mihaiuc
d57096af20 Fix broken hotkeys condition (#48401)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This fixes a bug introduced by
`ba68b88ca1617e52647c6dde467c56f53ca2422a` in the hotkey processing
logic.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

A complex condition ended up being incorrectly broken into 2 conditions,
leading to a missed `else` execution. This led to the mishandling of
keyboard shortcuts especially during reassignment in the standalone mode
for ZoomIt.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-09 15:44:08 +02:00
Noraa Junker
f136a4fe04 [Shortcut Guide] Fix foreground window detection (#48386)
## Summary of the Pull Request

This PR fixes Shortcut Guide foreground app detection by resolving app
IDs from the window that was in the foreground before the Shortcut Guide
UI takes focus.

Based on review feedback, it also adds the missing XML `<param>`
documentation for `foregroundWindowHandle` in
`ManifestInterpreter.GetAllCurrentApplicationIds(...)` to satisfy
documentation/style requirements.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

### Functional change
- Capture and reuse the foreground window handle taken before Shortcut
Guide UI activation.
- Use that captured handle for current-application ID resolution instead
of querying foreground window later, improving app-specific shortcut
matching reliability.

### Follow-up feedback fix
- Added missing XML parameter documentation:
-
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Helpers/ManifestInterpreter.cs`
  - Added `<param name="foregroundWindowHandle">...</param>`

## Validation Steps Performed

- Ran `parallel_validation` (Code Review: no issues; CodeQL: skipped as
trivial doc-only follow-up).
- Attempted local `dotnet build` for `ShortcutGuide.Ui.csproj`; blocked
by transient external package feed/network failure while restoring
`Microsoft.Build.CopyOnWrite/1.0.282`.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 20:00:46 +08:00
Yu Leng (from Dev Box)
fbc1a0c3da [KBM] Restore module grouping under Run PowerToys Command
Build the command menu as Run PowerToys Command > <module> > <command>
again (a MenuFlyoutSubItem per catalog module) instead of flattening all
commands directly, so commands stay grouped by module as the catalog grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:31:03 +08:00
Yu Leng (from Dev Box)
20df1fd96e Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-06-02 15:17:47 +08:00
Yu Leng (from Dev Box)
71d91a9616 [KBM] Remove dead resource keys left by the cascading-menu change
ActionType_RunTemplate.Content (the submenu now uses ActionType_RunTemplate_Text)
and TemplatePickerPlaceholder.Text (belonged to the removed in-picker button) are
no longer referenced by any XAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:13:25 +08:00
Yu Leng (from Dev Box)
bd97ba31e8 [KBM] Remove redundant in-picker command button and stale resources
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:02:32 +08:00
Yu Leng (from Dev Box)
245a6db963 [KBM] Address review: init action button label in Loaded; drop unused x:Name
Move the initial UpdateActionButtonContent call from the constructor to
UserControl_Loaded so the menu items' localized Text is guaranteed populated
before it is read. Remove the unused x:Name on the action MenuFlyout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:59:22 +08:00
Yu Leng (from Dev Box)
314f9fe751 [KBM] Make action selector a cascading menu hosting PowerToys commands
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:49:42 +08:00
Yu Leng (from Dev Box)
832db1bfea [KBM] Add SelectCommand/CurrentCommandDisplay to template picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:44:59 +08:00
Yu Leng (from Dev Box)
412028c861 [KBM] Rename Run from template action to Run PowerToys Command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:42:32 +08:00
Yu Leng (from Dev Box)
dda2a89aa6 [KBM] Refactor: split template model/VM classes into separate files
Extract CommandTemplateModule, CommandTemplate, TemplateParameter and
TemplateChoice out of PowerToysCliCatalog.cs, and TemplateChoiceViewModel
out of TemplateParameterViewModel.cs, into their own files. Modernize the
null check to ArgumentNullException.ThrowIfNull and rename the
missing-template InfoBar resources to MissingTemplateInfoBar.*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:42:06 +08:00
Yu Leng (from Dev Box)
164ac6074a Merge remote-tracking branch 'origin/main' into yuleng/kbm/command 2026-05-29 13:59:01 +08:00
Yu Leng
09cb927356 KBM: Document Task 20 architectural revision and C++ wiring decision
Captures the implementation-time discovery that the new editor's save
path goes through a C++ FFI chain rather than directly through
KeysDataModel, and the decision to wire the template fields end-to-end
through the C++ stack instead of relying on a CLR-only model. Notes
that this decision subsumes the originally planned Task 1b (legacy
editor JSON round-trip fix) by making the fields first-class known
fields in MappingConfiguration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:27 +08:00
Yu Leng
78c0e3e131 KBM: Full C++ wiring for template persistence in default.json
Adds templateId / templateParameters round-trip through the full stack:
Shortcut struct → MappingConfiguration (load+save) → EditorLibraryWrapper
(AddShortcutRemap) → C# P/Invoke → KeyboardMappingService. Non-template
mappings produce clean JSON (fields only emitted when non-empty). New
params default to nullptr so existing callers are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:00:33 +08:00
Yu Leng
ebc44a0e9d KBM: Wire RunTemplate action type into UnifiedMappingControl save/load
- Add TemplatePicker_MissingTemplateKeepRequested event handler to fix build failure
- Add RunTemplate to ActionType enum and wire CurrentActionType, SetActionType, IsInputComplete
- Add public getters GetResolvedTemplateExecutable/Args, GetCurrentTemplateId/ParameterValues
- Add SetRunTemplate setter for the load path
- Add TemplateId/TemplateParameters fields to ShortcutKeyMapping for persistence
- Add SaveRunTemplateMapping in MainPage and wire the save dispatch switch
- Add load-path detection in ProgramShortcutsList_ItemClick to restore RunTemplate state
- Wire TemplatePicker.SelectionChanged so validation re-runs on template selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:50:37 +08:00
Yu Leng
d88dca2c1e KBM: Add RunTemplate action type entry to UnifiedMappingControl XAML
Adds a 6th ComboBoxItem 'Run from template' (Tag=RunTemplate) to
ActionTypeComboBox, and a matching Case in ActionSwitchPresenter
that hosts the new CommandTemplatePickerControl. Wires the
picker's MissingTemplateKeepRequested event to the code-behind
handler that will be added with Task 20's save/load wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:44:38 +08:00
Yu Leng
f893fc7a77 KBM: Add CommandTemplatePickerControl (XAML + code-behind)
XAML: DropDownButton + MenuFlyout for cascading template selection,
ItemsControl + ParamSelector for dynamic parameter form, live preview
TextBlock (Consolas, OneWay-bound to ViewModel.ResolvedCommandLine),
and a Warning-severity InfoBar for the missing-template degradation
path. Every DataTemplate declares x:DataType for AOT-safe x:Bind.

Code-behind: BuildFlyout populates the MenuFlyout programmatically
from CommandTemplateCatalog.Instance (WinUI3 MenuFlyout doesn't
support HierarchicalDataTemplate). OnCommandPicked routes flyout
clicks through ViewModel.SelectTemplate. LoadExisting/Reset/
ResolveCurrent/CurrentTemplateId/CurrentParameterValues are the
public surface the parent UnifiedMappingControl uses for save/load.
Missing-template path raises MissingTemplateKeepRequested event so
the parent can switch ActionType to OpenApp (Option B degradation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:43:51 +08:00
Yu Leng
7c3c5514ee KBM: Add template picker ViewModels and DataTemplateSelector
- TemplateParameterViewModel: per-parameter VM. Localizes label/choices
  via ResourceHelper.GetString. Validates required+non-empty. Two-way
  binding via Value (Text) or SelectedChoice (Combo, which mirrors to
  Value on selection change).
- CommandTemplatePickerViewModel: orchestrates selection, parameter
  collection, live preview via TemplateResolver. Owns the
  ObservableCollection<TemplateParameterViewModel> the UI ItemsControl
  binds to. Subscribes to per-param PropertyChanged to recompute the
  preview on any value edit.
- TemplateParameterSelector: maps TemplateParameter.Type ("Text" |
  "Combo") to the corresponding XAML DataTemplate. Pure switch; no
  reflection — AOT friendly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:43:01 +08:00
Yu Leng
3a012d4cf1 KBM: Add resource keys for template picker UI and v1 catalog
20 new keys: action-type label, picker button + placeholder, preview
label, missing-template InfoBar text + 2 button labels, Settings
module + 2 command display strings, Module parameter label, and 7
module display names (ColorPicker through ZoomIt). Translation
pipeline (Crowdin/Touchdown) picks these up automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:42:02 +08:00
Yu Leng
9d58b1bdd1 KBM: Add catalog loader, resolver, and EmbeddedResource wiring
- CommandTemplateCatalog: Lazy singleton, loads from embedded
  powertoyscli.json via source-gen JsonSerializerContext.
  Validates schemaVersion and asserts >=1 module loaded.
- TemplateResolver: Pure substitution of {paramName} placeholders.
  No shell semantics, no quoting (v1 catalog values are safe).
- KeyboardManagerEditorUI.csproj: powertoyscli.json marked as
  EmbeddedResource with explicit LogicalName for predictable
  Assembly.GetManifestResourceStream lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:41:14 +08:00
Yu Leng
6fd36f9579 KBM: Add template catalog models, JSON context, and v1 powertoyscli.json
- POCO models: PowerToysCliCatalog, CommandTemplateModule,
  CommandTemplate, TemplateParameter, TemplateChoice
- Source-generated JsonSerializerContext (AOT-friendly)
- v1 catalog with 'Settings' module: openMain (no params),
  openModule (Combo param for 7 PowerToys modules)
- Executable uses %LOCALAPPDATA%\PowerToys\PowerToys.exe
  (per Task 3 finding: per-user install, ExpandEnvironmentStrings
  applied at trigger time)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:40:28 +08:00
Yu Leng
a8b79158f1 KBM: Add round-trip tests for template fields in KeysDataModel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:39:55 +08:00
Yu Leng
ff578d15a3 KBM: Register Dictionary<string,string> in JsonSerializerContext for template parameters
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:39:25 +08:00
Yu Leng
bccceba97b KBM: Add TemplateId/TemplateParameters to KeysDataModel 2026-05-19 21:38:32 +08:00
Yu Leng
e03d048e8a KBM: Phase 0 Task 3 - PowerToys.exe path resolution findings
Documents which Win32 APIs the KBM engine uses per elevation mode,
confirms ExpandEnvironmentStrings is applied before launch, confirms
no App Paths or main-folder PATH registration in the installer, and
locks in %LOCALAPPDATA%\PowerToys\PowerToys.exe as the executable
value for the powertoyscli.json templates (Task 9).
2026-05-19 21:34:45 +08:00
Yu Leng
806ff3c07a KBM: Phase 0 Task 2 - engine write-path findings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:32:41 +08:00
Yu Leng
c1ecdda60c KBM: Phase 0 Task 1 - legacy editor JSON round-trip findings 2026-05-19 21:29:51 +08:00
Yu Leng
43e530d2e1 KBM: Implementation plan for CLI command templates
29 tasks across 13 phases. Front-loads three pre-implementation
verification tasks (legacy editor JSON round-trip, engine
read-only confirmation, PowerToys.exe path resolution) before
any production code. Each task is bite-sized with concrete code
or commands. Data-layer changes covered by MSTest unit tests in
Settings.UI.UnitTests; catalog/resolver verified via startup
smoke check plus manual end-to-end UI tests in Phase 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:13:55 +08:00
Yu Leng
016e0732b0 KBM: Design doc for CLI command template mappings
Adds the brainstormed design for a new "Run from template" action type
in the new KeyboardManagerEditorUI: 3-level cascading menu (PowerToys
command -> Module -> Command) with dynamically rendered Text/Combo
parameters that resolve at save time into a standard RunProgram mapping.
v1 ships powertoyscli.json with one Settings module containing two
templates; the C++ engine and legacy editor are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:04:51 +08:00
199 changed files with 289699 additions and 34752 deletions

View File

@@ -432,3 +432,6 @@ SHELLEXPERIENCEHOST
SHELLHOST
STARTMENUEXPERIENCEHOST
WIDGETBOARD
# URIs
actioncenter

View File

@@ -1,23 +1,51 @@
accelscroll
acq
ADDTO
ADDTOOL
adr
Adr
ALWAYSTIP
APPLYTOSUBMENUS
ARCHMASK
archs
AUDCLNT
autocorr
avx
axisdefer
axisflip
axisstart
backlight
BEOS
bfi
BFIN
bfly
BGRX
bitmaps
bitrev
blits
Borgerding
Borland
breakc
BREAKSCR
BUFFERFLAGS
bugzilla
Cands
capturepath
cbs
centiseconds
cexp
cfx
cfy
cgem
cifx
cify
CLASSW
coeffs
colblocks
constantbuffer
coprime
cpuid
cpx
CREATEDIBSECTION
CREATESTRUCTW
crossfades
@@ -28,109 +56,216 @@ CTLCOLORDLG
CTLCOLOREDIT
CTLCOLORLISTBOX
CTrim
CVTEPI
DBuffer
dcl
dct
ddx
ddy
Deinterleave
denoise
denoised
DEVSOURCE
DFCS
DIVSCALAR
DJGPP
dlg
dlu
dnn
DONTCARE
downsample
DRAWITEM
DRAWITEMSTRUCT
droppedband
Droppedband
DSPs
dsum
dupburst
dupsegments
DWLP
eband
ebx
ECX
EDITCONTROL
EDSP
emmintrin
EMX
ENABLEHOOK
endloop
ENDOFSTREAM
ener
enh
ettings
expectedlock
expf
fabs
fabsf
facbuf
fastscroll
FDE
ffast
FIXDIV
floorf
fmadd
fout
fstride
fxc
GETCHANNELRECT
GETCHECK
GETCOUNT
GETDISPINFO
GETSCREENSAVEACTIVE
GETSCREENSAVETIMEOUT
GETTHUMBRECT
GIFs
glu
groupshared
gru
hcfdark
hcfwhitespace
hlsl
Hsieh
hstride
HTBOTTOMRIGHT
HTHEME
htol
ICONINFORMATION
ICONWARNING
idct
IDIn
IDISHWND
ifft
igc
ilog
imad
imax
imin
immintrin
Inj
interp
inttypes
ishl
itof
jumprecover
kfft
kheight
kissfft
KSDATAFORMAT
ksize
ktime
lastg
latestcapture
ldx
LEFTNOWORDWRAP
legitjumps
lenmem
letterbox
lld
lldx
llu
llums
logfont
lookback
lpc
lpcnet
LPNMHDR
LPNMTTDISPINFO
lround
lte
luma
Luma
maj
manualdrop
maskcache
maxabs
maxcorr
MAXFACTORS
maxperiod
maxstep
memalign
memid
memneeded
MENUINFO
MFSTARTUP
mfxhw
mic
middledrop
minperiod
MIPSr
MJPEG
MMRESULT
momentumreversal
movc
mrate
mrt
MULBYSCALAR
MULC
MWERKS
mycfg
narrowstrip
nbak
nbytes
ncapture
nchw
ncm
nduplicates
nfft
NHWC
niterations
nmonitor
nnet
NONCLIENTMETRICS
NONOTIFY
nonvle
normf
nredraw
nstop
nsubpixel
ntorn
numthreads
nvw
Octasic
osc
OSCE
ovflw
OWNERDRAW
PBGRA
periodictrap
pillarbox
pfdc
pillarbox
playhead
pnmh
pointerreuse
PPW
prereq
PSHR
pstdint
PSWA
pwfx
QCONST
qpc
Qpc
quantums
qweight
RCSEGMODEL
RCZOOMITSCR
readback
READERF
realcapture
REFKNOWNFOLDERID
relu
reposted
RETURNCMD
rnn
rnnoise
rotateleft
rsqrt
rtcd
RTEXT
RTH
rtvs
SCALEIN
SCALEOUT
SCREENSAVE
SCRNSAVE
SCRNSAVECONFIGURE
@@ -138,43 +273,80 @@ scrnsavw
Scrnsavw
scrollramp
SCROLLSIZEGRIP
selfie
selftest
SETBARCOLOR
SETBKCOLOR
SETDEFID
SETRECT
SETSCREENSAVETIMEOUT
SETTIPSIDE
sgem
sgemv
sgv
SHAREMODE
SHAREVIOLATION
shortlist
simde
siv
slowthenfast
smallstart
SNIPOCR
softmax
sqrtf
SROUND
srvs
ssi
startuprecovery
stdint
stf
stopafter
STREAMFLAGS
SUBFROM
subias
submix
sxx
sxy
symbian
synthesising
syy
tallportal
TBTS
tci
tcsicmp
TEXTCALLBACK
TEXTMETRIC
tgsm
THIRDPARTY
tinystep
tme
toolbars
TOOLINFO
TRACKMOUSEEVENT
TRIANGLELIST
TTM
TTN
TWID
UADD
uav
uavs
uge
Unadvise
upscaled
upscales
USUB
utof
vad
vaddq
vaddvq
valgrind
Valin
vandq
vblank
vcgeq
vdup
vectorizer
VERTID
VIDCAP
vld
vle
@@ -184,6 +356,7 @@ vminq
vmlal
vmull
vqaddq
VSHR
vshrn
vsntprintf
vsnwprintf
@@ -194,7 +367,9 @@ WAVEFORMATEXTENSIBLE
webcam
Webcam
webcams
Wextra
wfopen
WGC
wideportal
wil
WMU
@@ -202,11 +377,46 @@ wrapjump
wtol
WTSSESSION
WTSUn
wxyz
xchg
xcorr
XEnd
Xfl
Xiang
Xiph
xmmintrin
xptr
xshift
XStart
XStep
xxxy
xxyx
xxyz
xyw
xywx
xyxx
xyxz
xyzw
xyzx
xzwx
xzxx
Yfl
YInternal
yshift
YUV
yyyx
yyzw
yzw
yzwy
yzyy
Zhou
Zhu
ZMBS
zncc
Zncc
ZNCC
zrh
zwzz
zyzw
zzwz
zzzw

View File

@@ -112,8 +112,6 @@
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
@@ -137,6 +135,8 @@
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/modules/ZoomIt/ZoomIt/rnnoise/
^src/modules/ZoomIt/ZoomIt/selfie_segmentation\.onnx$
^src/modules/ZoomIt/ZoomIt/ZoomIt\.idc$
^src/Monaco/
^tools/project_template/ModuleTemplate/resource\.h$

View File

@@ -113,7 +113,6 @@ azman
azureaiinference
azureinference
azureopenai
Backlight
backticks
Badflags
Badmode
@@ -472,7 +471,6 @@ DWMWINDOWATTRIBUTE
DWMWINDOWMAXIMIZEDCHANGE
DWORDLONG
dworigin
DWRITE
dxgi
Dxva
eab
@@ -998,7 +996,6 @@ luid
lusrmgr
LVDS
LWA
LWIN
LZero
MAGTRANSFORM
makeappx
@@ -1208,7 +1205,6 @@ nonclient
NONCLIENTMETRICSW
NONELEVATED
nonspace
nonstd
NOOWNERZORDER
NOPARENTNOTIFY
NOPREFIX
@@ -1400,6 +1396,7 @@ POWERRENAMECONTEXTMENU
powerrenameinput
POWERRENAMETEST
POWERTOYNAME
powertoyscli
powertoyssetup
powertoysusersetup
Powrprof
@@ -1422,7 +1419,6 @@ Prefixer
Premul
prependpath
prepopulate
Prereq
prevhost
previewer
PREVIEWHANDLERFRAMEINFO
@@ -1561,6 +1557,7 @@ RESIZETOFIT
resmimetype
RESOURCEID
RESTORETOMAXIMIZED
retargets
RETURNONLYFSDIRS
Revalidates
RGBQUAD
@@ -2000,7 +1997,6 @@ valuegenerator
VARTYPE
vbcscompiler
vcamp
VCENTER
vcgtq
VCINSTALLDIR
vcp
@@ -2038,7 +2034,6 @@ vorrq
VOS
vpaddlq
vqsubq
VREDRAW
vreinterpretq
VSC
VSCBD

View File

@@ -312,3 +312,9 @@ ms-windows-store://\S+
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# Special licenses text from RNNoise (BSD-style disclaimer: ``AS IS'')
``AS IS''
# Old school moniker for macOS from RNNoise
MacOS

3
.gitignore vendored
View File

@@ -19,6 +19,9 @@
[Rr]eleases/
x64/
x86/
!**/rnnoise/
!**/rnnoise/x86/
!**/rnnoise/x86/**
ARM64/
bld/
[Bb]in/

View File

@@ -468,12 +468,6 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/DesktopGrass/">
<Project Path="src/modules/DesktopGrass/DesktopGrass.Native/DesktopGrass.Native.vcxproj" Id="b0d4e1b0-1f5e-4c2d-9f44-da8c3f1a2a11" />
</Folder>
<Folder Name="/modules/DesktopGrass/Tests/">
<Project Path="src/modules/DesktopGrass/DesktopGrass.Native.Tests/DesktopGrass.Native.Tests.vcxproj" />
</Folder>
<Folder Name="/modules/imageresizer/">
<Project Path="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" Id="0b43679e-edfa-4da0-ad30-f4628b308b1b" />
<Project Path="src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj">
@@ -506,6 +500,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/keyboardmanager/KeyboardManagerEditorUI.UnitTests/KeyboardManagerEditorUI.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj" Id="ba661f5b-1d5a-4ffc-9bf1-fc39df280bdd" />
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj" Id="e496b7fc-1e99-4bab-849b-0e8367040b02" />
</Folder>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,25 +0,0 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<?include $(sys.CURRENTDIR)\Common.wxi?>
<Fragment>
<!--
DesktopGrass ships a single statically-linked native executable
(DesktopGrass.Native.exe, /MT CRT, no external runtime dependencies). It is
emitted to the root build output and harvested automatically by the root
sweep in generateAllFileComponents.ps1 into BaseApplicationsComponentGroup.
This component group only carries the module's uninstall marker so the
feature can be referenced explicitly from Product.wxs, matching the
per-module convention used by Awake/Hosts/etc.
-->
<ComponentGroup Id="DesktopGrassComponentGroup">
<Component Id="RemoveDesktopGrassRegistry" Guid="42C1C544-8FD8-422A-85A2-139D99D38B52" Directory="INSTALLFOLDER">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="RemoveDesktopGrassRegistry" Value="" KeyPath="yes" />
</RegistryKey>
</Component>
</ComponentGroup>
</Fragment>
</Wix>

View File

@@ -115,7 +115,6 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="BaseApplications.wxs" />
<Compile Include="CmdPal.wxs" />
<Compile Include="ColorPicker.wxs" />
<Compile Include="DesktopGrass.wxs" />
<Compile Include="EnvironmentVariables.wxs" />
<Compile Include="FileExplorerPreview.wxs" />
<Compile Include="FileLocksmith.wxs" />

View File

@@ -45,7 +45,6 @@
<ComponentGroupRef Id="WinUI3ApplicationsComponentGroup" />
<ComponentGroupRef Id="AwakeComponentGroup" />
<ComponentGroupRef Id="ColorPickerComponentGroup" />
<ComponentGroupRef Id="DesktopGrassComponentGroup" />
<ComponentGroupRef Id="FileExplorerPreviewComponentGroup" />
<ComponentGroupRef Id="FileLocksmithComponentGroup" />
<ComponentGroupRef Id="HostsComponentGroup" />

View File

@@ -73,7 +73,8 @@
<PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile>
<WarningLevel>Level4</WarningLevel>
<AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions>
<PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- TODO: _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS: suppress VS 2026 STL hard error for <experimental/coroutine> until the code is ported to <coroutine> -->
<PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<AdditionalIncludeDirectories>../../..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories>
</ClCompile>

View File

@@ -1,172 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<ProjectGuid>{C2E4F2B0-3A0E-4B1D-A23C-DA8C3F1A2A22}</ProjectGuid>
<RootNamespace>DesktopGrass.Native.Tests</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
<ProjectName>DesktopGrass.Native.Tests</ProjectName>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<OutDir>$(MSBuildProjectDirectory)\out\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(MSBuildProjectDirectory)\obj\$(Platform)\$(Configuration)\</IntDir>
<TargetName>DesktopGrass.Native.Tests</TargetName>
<!-- No precompiled header; opt out of the PowerToys-wide PCH default. -->
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
<RunCodeAnalysis>false</RunCodeAnalysis>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<LanguageStandard>stdcpp17</LanguageStandard>
<SDLCheck>true</SDLCheck>
<ConformanceMode>true</ConformanceMode>
<WarningLevel>Level3</WarningLevel>
<PreprocessorDefinitions>UNICODE;_UNICODE;NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<MultiProcessorCompilation>true</MultiProcessorCompilation>
<AdditionalIncludeDirectories>$(MSBuildProjectDirectory)\src;$(MSBuildProjectDirectory)\third_party\catch2;$(MSBuildProjectDirectory)\..\DesktopGrass.Native\src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<Optimization>Disabled</Optimization>
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<Optimization>MaxSpeed</Optimization>
<RuntimeLibrary>MultiThreadedDLL</RuntimeLibrary>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
<ClCompile>
<Optimization>Disabled</Optimization>
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<ClCompile>
<Optimization>MaxSpeed</Optimization>
<RuntimeLibrary>MultiThreadedDLL</RuntimeLibrary>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
</ClCompile>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="src\main.cpp" />
<ClCompile Include="src\prng_tests.cpp" />
<ClCompile Include="src\blade_gen_tests.cpp" />
<ClCompile Include="src\sway_tests.cpp" />
<ClCompile Include="src\gust_tests.cpp" />
<ClCompile Include="src\cut_tests.cpp" />
<ClCompile Include="src\regrowth_tests.cpp" />
<ClCompile Include="src\flower_tests.cpp" />
<ClCompile Include="src\mushroom_tests.cpp" />
<ClCompile Include="src\ambient_gust_tests.cpp" />
<ClCompile Include="src\scene_tests.cpp" />
<ClCompile Include="src\entity_skeleton_tests.cpp" />
<ClCompile Include="src\desert_tests.cpp" />
<ClCompile Include="src\winter_tests.cpp" />
<ClCompile Include="src\pine_tests.cpp" />
<ClCompile Include="src\autumn_tests.cpp" />
<ClCompile Include="src\ocean_tests.cpp" />
<ClCompile Include="src\critter_tests.cpp" />
<ClCompile Include="src\sheep_greeting_tests.cpp" />
<ClCompile Include="src\cat_tests.cpp" />
<ClCompile Include="src\cat_coat_tests.cpp" />
<ClCompile Include="src\bunny_tests.cpp" />
<ClCompile Include="src\hedgehog_tests.cpp" />
<ClCompile Include="src\butterfly_tests.cpp" />
<ClCompile Include="src\firefly_tests.cpp" />
<ClCompile Include="src\bird_flyby_tests.cpp" />
<ClCompile Include="src\persistence_tests.cpp" />
<ClCompile Include="src\config_tests.cpp" />
<ClCompile Include="src\autostart_tests.cpp" />
<ClCompile Include="src\click_through_smoke_test.cpp" />
<ClCompile Include="src\pacing_tests.cpp" />
<ClCompile Include="src\prop_spacing_tests.cpp" />
<ClCompile Include="..\DesktopGrass.Native\src\AutoStart.cpp" />
<ClCompile Include="..\DesktopGrass.Native\src\Config.cpp" />
<ClCompile Include="..\DesktopGrass.Native\src\Pacing.cpp" />
<ClCompile Include="..\DesktopGrass.Native\src\Persistence.cpp" />
<ClCompile Include="..\DesktopGrass.Native\src\Sim.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="src\snapshot_data.h" />
<ClInclude Include="..\DesktopGrass.Native\src\AutoStart.h" />
<ClInclude Include="..\DesktopGrass.Native\src\Config.h" />
<ClInclude Include="..\DesktopGrass.Native\src\Json.h" />
<ClInclude Include="..\DesktopGrass.Native\src\Pacing.h" />
<ClInclude Include="..\DesktopGrass.Native\src\Persistence.h" />
<ClInclude Include="third_party\catch2\catch.hpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -1,57 +0,0 @@
// snapshot_gen.cpp
// One-shot tool that prints the canonical PRNG + blade snapshot. Used to seed
// constants in DesktopGrass.Native.Tests/src/snapshot_data.h. Not part of the
// shipped binary. Build inline with cl when regenerating; the resulting EXE
// is deleted after copying its output into the test source.
#include <cstdio>
#include <cstdint>
#include "../src/Sim.h"
int main() {
using namespace desktopgrass;
Prng p;
prng_init(p, CANONICAL_TEST_SEED);
std::printf("// canonical PRNG snapshot (seed = 0x6B6173746F)\n");
std::printf("constexpr uint64_t CANONICAL_PRNG_SNAPSHOT[16] = {\n");
for (int i = 0; i < 16; ++i) {
uint64_t v = prng_next_u64(p);
std::printf(" 0x%016llXull,\n", static_cast<unsigned long long>(v));
}
std::printf("};\n");
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
std::printf("\n// blade count: %zu\n", blades.size());
std::printf("constexpr size_t CANONICAL_BLADE_COUNT = %zu;\n", blades.size());
std::printf("\n// first 10 blades (baseX, height, thickness, hue, swayPhaseOffset, stiffness, isFlower, flowerHeadColorIdx, flowerHeadRadius, heightBonus)\n");
std::printf("struct SnapshotBlade { double baseX, height, thickness; uint8_t hue; double sway, stiffness; bool isFlower; uint8_t flowerHeadColorIdx; double flowerHeadRadius, heightBonus; };\n");
std::printf("constexpr SnapshotBlade CANONICAL_FIRST_10[10] = {\n");
for (int i = 0; i < 10 && i < (int)blades.size(); ++i) {
const Blade& b = blades[i];
std::printf(" { %.17g, %.17g, %.17g, %u, %.17g, %.17g, %s, %u, %.17g, %.17g },\n",
b.baseX, b.height, b.thickness, (unsigned)b.hue,
b.swayPhaseOffset, b.stiffness,
b.isFlower ? "true" : "false",
(unsigned)b.flowerHeadColorIdx,
b.flowerHeadRadius, b.heightBonus);
}
std::printf("};\n");
std::printf("\n// last 10 blades\n");
std::printf("constexpr SnapshotBlade CANONICAL_LAST_10[10] = {\n");
int start = (int)blades.size() - 10;
if (start < 0) start = 0;
for (int i = start; i < (int)blades.size(); ++i) {
const Blade& b = blades[i];
std::printf(" { %.17g, %.17g, %.17g, %u, %.17g, %.17g, %s, %u, %.17g, %.17g },\n",
b.baseX, b.height, b.thickness, (unsigned)b.hue,
b.swayPhaseOffset, b.stiffness,
b.isFlower ? "true" : "false",
(unsigned)b.flowerHeadColorIdx,
b.flowerHeadRadius, b.heightBonus);
}
std::printf("};\n");
return 0;
}

View File

@@ -1,252 +0,0 @@
// ambient_gust_tests.cpp
//
// Ambient gust scheduler tests (architecture.md §8.1).
//
// Coverage:
// * Scheduler determinism — first 8 emitted puffs match a cross-impl
// snapshot for the canonical seed.
// * Stream independence — adding ambient gusts does not perturb the static
// blade snapshot from §12 (already exercised by snapshot_data.h, but
// repeated here as a focused regression).
// * Idle ticks consume zero PRNG draws.
// * Apply kernel matches §8.1 (half radius, magnitude scales with magFactor).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <array>
#include <cmath>
#include <cstdint>
using namespace desktopgrass;
namespace {
struct Puff {
double fireTime;
double x;
double signDir;
double magFactor;
};
// Drive the scheduler until N puffs have fired and capture each one. The
// scheduler fires when sim.globalTime crosses nextAmbientGustTime, so we
// repeatedly nudge globalTime to just past nextAmbientGustTime and call
// sim_tick_ambient_gusts.
std::vector<Puff> capture_first_n_puffs(Sim& sim, std::size_t n) {
std::vector<Puff> puffs;
while (puffs.size() < n) {
const double fireTime = sim.nextAmbientGustTime;
sim.globalTime = fireTime;
// Snapshot blades and PRNG so we can extract the four draws by
// observing the state diff: we just call sim_tick_ambient_gusts
// (which fires exactly one puff because globalTime == fireTime
// and we don't advance further). After it returns we know the
// (x, signDir, magFactor) that were drawn by replaying — but
// that's ugly. Simpler: call the public step ourselves with a
// dedicated PRNG view and assert.
//
// Cleanest: call sim_tick_ambient_gusts and capture from the
// blades' aggregate gustVelocity NOPE — that loses signDir / x.
//
// Even simpler: re-draw the same four values from a side-PRNG
// initialized to sim.ambientPrng's state right before the fire,
// then call sim_tick_ambient_gusts which advances the real PRNG
// identically. We assert the two PRNGs end at the same state.
Prng peek = sim.ambientPrng;
const double x = prng_uniform(peek, 0.0, sim.monitorWidth);
const double signDir = prng_uniform(peek, 0.0, 1.0) < 0.5 ? -1.0 : 1.0;
const double magFactor = prng_uniform(peek, AMBIENT_GUST_MAG_FACTOR_MIN,
AMBIENT_GUST_MAG_FACTOR_MAX);
// Interval is drawn AFTER apply, so the peek is "ahead" of the real
// PRNG by these three values only at this point; the real call below
// will draw all four (x, signDir, magFactor, interval) atomically.
sim_tick_ambient_gusts(sim);
puffs.push_back({ fireTime, x, signDir, magFactor });
}
return puffs;
}
} // anonymous
// ----------------------------------------------------------------------------
// Init wires up the ambient PRNG correctly + first interval is sampled.
// ----------------------------------------------------------------------------
TEST_CASE("sim_init seeds ambientPrng off seed XOR AMBIENT_GUST_PRNG_SALT", "[ambient][init]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
Prng expected;
prng_init(expected, CANONICAL_TEST_SEED ^ AMBIENT_GUST_PRNG_SALT);
// Init draws ONE value from the ambient stream (the first interval).
const double firstInterval = prng_uniform(expected,
AMBIENT_GUST_INTERVAL_MIN,
AMBIENT_GUST_INTERVAL_MAX);
REQUIRE(sim.monitorWidth == Approx(1920.0));
REQUIRE(sim.nextAmbientGustTime == Approx(firstInterval));
REQUIRE(sim.nextAmbientGustTime >= AMBIENT_GUST_INTERVAL_MIN);
REQUIRE(sim.nextAmbientGustTime <= AMBIENT_GUST_INTERVAL_MAX);
// PRNG state after sim_init must match the side-prng after one draw.
REQUIRE(sim.ambientPrng.state == expected.state);
}
// ----------------------------------------------------------------------------
// Idle ticks consume zero PRNG draws.
// ----------------------------------------------------------------------------
TEST_CASE("sim_tick_ambient_gusts is a no-op when globalTime < nextAmbientGustTime", "[ambient][idle]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
const uint64_t stateBefore = sim.ambientPrng.state;
// Many idle ticks across less than the minimum interval.
sim.globalTime = AMBIENT_GUST_INTERVAL_MIN * 0.5;
for (int i = 0; i < 100; ++i) sim_tick_ambient_gusts(sim);
REQUIRE(sim.ambientPrng.state == stateBefore);
REQUIRE(sim.nextAmbientGustTime >= sim.globalTime);
}
// ----------------------------------------------------------------------------
// Scheduler determinism — pin the first eight puffs.
// ----------------------------------------------------------------------------
TEST_CASE("first 8 ambient puffs match deterministic snapshot for canonical seed", "[ambient][snapshot]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
std::vector<Puff> puffs = capture_first_n_puffs(sim, 8);
REQUIRE(puffs.size() == 8);
// Bounded sanity for every puff.
for (const Puff& p : puffs) {
REQUIRE(p.x >= 0.0);
REQUIRE(p.x <= 1920.0);
REQUIRE((p.signDir == -1.0 || p.signDir == 1.0));
REQUIRE(p.magFactor >= AMBIENT_GUST_MAG_FACTOR_MIN);
REQUIRE(p.magFactor <= AMBIENT_GUST_MAG_FACTOR_MAX);
REQUIRE(p.fireTime >= AMBIENT_GUST_INTERVAL_MIN);
}
// Inter-puff intervals are all within [MIN, MAX].
for (std::size_t i = 1; i < puffs.size(); ++i) {
const double interval = puffs[i].fireTime - puffs[i - 1].fireTime;
REQUIRE(interval >= AMBIENT_GUST_INTERVAL_MIN);
REQUIRE(interval <= AMBIENT_GUST_INTERVAL_MAX);
}
// ⟪ Cross-impl snapshot ⟫
// These values were captured from the Native impl with the spec-locked
// draw order (x, signDir, magFactor, interval) and the salt
// AMBIENT_GUST_PRNG_SALT = 0xB7EE2EE2B7EE2EE2. The Win2D port MUST
// reproduce them bit-equivalent (≤ 1 ULP on doubles drawn from
// prng_uniform; sign and bounded scalars exact).
//
// First puff's fireTime equals the first interval drawn at sim_init.
// Subsequent fireTimes are cumulative.
//
// NB: this snapshot is INTENTIONALLY a smoke-bound: it asserts every
// puff's signDir, and the FIRST puff's exact (x, magFactor, fireTime).
// A full 8-entry snapshot would over-pin and create churn on future
// unrelated PRNG-salt rotations. The cross-impl test on the Win2D side
// re-derives the same values from the spec and asserts the FULL tuple.
// The first puff fires at sim.nextAmbientGustTime as set in sim_init.
Prng expected;
prng_init(expected, CANONICAL_TEST_SEED ^ AMBIENT_GUST_PRNG_SALT);
const double expectedFirstInterval = prng_uniform(expected,
AMBIENT_GUST_INTERVAL_MIN,
AMBIENT_GUST_INTERVAL_MAX);
const double expectedFirstX = prng_uniform(expected, 0.0, 1920.0);
const double expectedFirstSign = prng_uniform(expected, 0.0, 1.0) < 0.5 ? -1.0 : 1.0;
const double expectedFirstMag = prng_uniform(expected,
AMBIENT_GUST_MAG_FACTOR_MIN,
AMBIENT_GUST_MAG_FACTOR_MAX);
REQUIRE(puffs[0].fireTime == Approx(expectedFirstInterval));
REQUIRE(puffs[0].x == Approx(expectedFirstX));
REQUIRE(puffs[0].signDir == expectedFirstSign);
REQUIRE(puffs[0].magFactor == Approx(expectedFirstMag));
}
// ----------------------------------------------------------------------------
// Apply kernel matches §8.1 (half radius, magnitude scales linearly).
// ----------------------------------------------------------------------------
TEST_CASE("apply_ambient_gust kernel: half radius, scales with magFactor", "[ambient][kernel]") {
// Build a sim with three blades: at the puff center, one inside the
// shrunken ambient radius, one outside it (but inside the cursor radius).
Sim sim;
sim.windowHeight = STRIP_HEIGHT + HEADROOM;
const double ambientRadius = GUST_RADIUS * AMBIENT_GUST_RADIUS_FACTOR; // 75 DIP
Blade b0{}; b0.baseX = 100.0; b0.height = 20.0; b0.cutHeight = 1.0;
Blade b1{}; b1.baseX = 100.0 + ambientRadius * 0.5; b1.height = 20.0; b1.cutHeight = 1.0;
Blade b2{}; b2.baseX = 100.0 + ambientRadius + 5.0; b2.height = 20.0; b2.cutHeight = 1.0;
sim.blades = { b0, b1, b2 };
const double magFactor = 0.5;
sim_apply_ambient_gust(sim, /*x=*/100.0, /*signDir=*/+1.0, magFactor);
const double expectedPeak = MAX_CURSOR_SPEED * magFactor * IMPULSE_SCALE; // 4000*0.5*0.003 = 6.0
REQUIRE(sim.blades[0].gustVelocity == Approx(expectedPeak));
// Blade at half-radius gets smoothstep(0.5) = 0.5.
REQUIRE(sim.blades[1].gustVelocity == Approx(expectedPeak * 0.5));
// Blade outside ambient radius is untouched.
REQUIRE(sim.blades[2].gustVelocity == Approx(0.0));
}
TEST_CASE("apply_ambient_gust signDir flips impulse direction", "[ambient][kernel]") {
Sim sim;
sim.windowHeight = STRIP_HEIGHT + HEADROOM;
Blade b{}; b.baseX = 100.0; b.height = 20.0; b.cutHeight = 1.0;
sim.blades = { b };
sim_apply_ambient_gust(sim, 100.0, -1.0, 0.5);
const double expectedPeak = MAX_CURSOR_SPEED * 0.5 * IMPULSE_SCALE;
REQUIRE(sim.blades[0].gustVelocity == Approx(-expectedPeak));
}
// ----------------------------------------------------------------------------
// Stream independence — adding ambient gusts must NOT perturb the static
// blade snapshot from §12. (sim_init's first blade still matches.)
// ----------------------------------------------------------------------------
TEST_CASE("ambient gust stream does not perturb the canonical first blade", "[ambient][independence]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
REQUIRE(sim.blades.size() == desktopgrass::test::CANONICAL_BLADE_COUNT);
const Blade& first = sim.blades[0];
const desktopgrass::test::SnapshotBlade& expected = desktopgrass::test::CANONICAL_FIRST_10[0];
REQUIRE(first.baseX == Approx(expected.baseX));
REQUIRE(first.height == Approx(expected.height));
REQUIRE(first.thickness == Approx(expected.thickness));
REQUIRE(first.hue == expected.hue);
}
// ----------------------------------------------------------------------------
// sim_tick wires the scheduler into the per-frame loop.
// ----------------------------------------------------------------------------
TEST_CASE("sim_tick fires ambient puff when dt crosses nextAmbientGustTime", "[ambient][tick]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
const double fireTime = sim.nextAmbientGustTime;
REQUIRE(fireTime > 0.0);
// Stash PRNG state to detect a fire.
const uint64_t stateBefore = sim.ambientPrng.state;
// Tick with dt that does NOT cross — no fire.
sim_tick(sim, fireTime * 0.5, nullptr, 0);
REQUIRE(sim.ambientPrng.state == stateBefore);
// Tick with dt that crosses — exactly one fire, PRNG advanced by 4 draws.
sim_tick(sim, fireTime, nullptr, 0);
REQUIRE(sim.ambientPrng.state != stateBefore);
REQUIRE(sim.nextAmbientGustTime > sim.globalTime);
}

View File

@@ -1,156 +0,0 @@
#include "../third_party/catch2/catch.hpp"
#include "AutoStart.h"
#include "Persistence.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace {
std::wstring unique_subkey(const wchar_t* name) {
static std::atomic<int> counter{0};
return std::wstring(L"Software\\DesktopGrass.Test.")
+ std::to_wstring(GetCurrentProcessId()) + L"."
+ std::to_wstring(GetTickCount64()) + L"."
+ std::to_wstring(counter.fetch_add(1)) + L"."
+ name;
}
class AutoStartRegistrySandbox {
public:
explicit AutoStartRegistrySandbox(const wchar_t* name) : subkey_(unique_subkey(name)) {
RegDeleteTreeW(HKEY_CURRENT_USER, subkey_.c_str());
autostart::SetRegistryKeyOverride(subkey_);
}
~AutoStartRegistrySandbox() {
autostart::SetRegistryKeyOverride(subkey_);
autostart::SetEnabled(false);
RegDeleteTreeW(HKEY_CURRENT_USER, subkey_.c_str());
autostart::SetRegistryKeyOverride(L"");
desktopgrass::persistence::SetStateFilePathForTest(L"");
}
const std::wstring& subkey() const { return subkey_; }
private:
std::wstring subkey_;
};
std::wstring read_registry_value(const std::wstring& subkey) {
HKEY key = nullptr;
REQUIRE(RegOpenKeyExW(HKEY_CURRENT_USER, subkey.c_str(), 0, KEY_QUERY_VALUE, &key) == ERROR_SUCCESS);
DWORD type = 0;
DWORD byteCount = 0;
const std::wstring valueName = autostart::GetRegistryValueName();
REQUIRE(RegQueryValueExW(key, valueName.c_str(), nullptr, &type, nullptr, &byteCount) == ERROR_SUCCESS);
REQUIRE(type == REG_SZ);
std::vector<wchar_t> buffer(byteCount / sizeof(wchar_t) + 1);
REQUIRE(RegQueryValueExW(
key, valueName.c_str(), nullptr, &type,
reinterpret_cast<BYTE*>(buffer.data()), &byteCount) == ERROR_SUCCESS);
RegCloseKey(key);
return std::wstring(buffer.data());
}
std::filesystem::path test_state_path(const char* name) {
std::filesystem::path dir = std::filesystem::current_path()
/ ".copilot-scratch"
/ "native-autostart-tests"
/ name;
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir);
return dir / "state.json";
}
} // namespace
TEST_CASE("autostart is disabled when registry value is missing", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"missing");
REQUIRE_FALSE(autostart::IsEnabled());
}
TEST_CASE("autostart enable creates registry value", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"enable");
REQUIRE(autostart::SetEnabled(true));
REQUIRE(autostart::IsEnabled());
}
TEST_CASE("autostart disable deletes registry value", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"disable");
REQUIRE(autostart::SetEnabled(true));
REQUIRE(autostart::SetEnabled(false));
REQUIRE_FALSE(autostart::IsEnabled());
}
TEST_CASE("autostart registry value contains current exe path", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"path");
REQUIRE(autostart::SetEnabled(true));
REQUIRE(read_registry_value(sandbox.subkey()) == autostart::GetCurrentExePath());
}
TEST_CASE("autostart enable is idempotent", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"enable-idempotent");
REQUIRE(autostart::SetEnabled(true));
REQUIRE(autostart::SetEnabled(true));
REQUIRE(autostart::IsEnabled());
}
TEST_CASE("autostart disable missing value is no-op", "[autostart]") {
AutoStartRegistrySandbox sandbox(L"disable-missing");
REQUIRE(autostart::SetEnabled(false));
REQUIRE_FALSE(autostart::IsEnabled());
}
TEST_CASE("autostart persisted true reconciles registry on startup", "[autostart][persistence]") {
AutoStartRegistrySandbox sandbox(L"persisted-true");
const auto path = test_state_path("persisted-true");
desktopgrass::persistence::SetStateFilePathForTest(path.wstring());
desktopgrass::persistence::AppState state;
state.autoStart = true;
REQUIRE(desktopgrass::persistence::SaveAppState(state));
desktopgrass::persistence::AppState loaded;
REQUIRE(desktopgrass::persistence::LoadAppState(loaded));
REQUIRE(autostart::ReconcileWithState(loaded.autoStart));
REQUIRE(autostart::IsEnabled());
}
TEST_CASE("autostart persisted false reconciles registry on startup", "[autostart][persistence]") {
AutoStartRegistrySandbox sandbox(L"persisted-false");
const auto path = test_state_path("persisted-false");
desktopgrass::persistence::SetStateFilePathForTest(path.wstring());
REQUIRE(autostart::SetEnabled(true));
desktopgrass::persistence::AppState state;
state.autoStart = false;
REQUIRE(desktopgrass::persistence::SaveAppState(state));
desktopgrass::persistence::AppState loaded;
REQUIRE(desktopgrass::persistence::LoadAppState(loaded));
REQUIRE(autostart::ReconcileWithState(loaded.autoStart));
REQUIRE_FALSE(autostart::IsEnabled());
}

View File

@@ -1,600 +0,0 @@
// autumn_tests.cpp
//
// Autumn scene tests (architecture.md §16.5).
#include "../third_party/catch2/catch.hpp"
#include "Persistence.h"
#include "Sim.h"
#include "snapshot_data.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <filesystem>
using namespace desktopgrass;
namespace {
constexpr double kMonitor1920 = 1920.0;
constexpr double kEpsilon = 1e-9;
constexpr double kTwoPi = 6.28318530717958647692;
Sim make_sim(uint64_t seed = CANONICAL_TEST_SEED,
double width = kMonitor1920,
double density = DEFAULT_DENSITY) {
return sim_init(seed, width, density);
}
Sim make_autumn_sim(uint64_t seed = CANONICAL_TEST_SEED,
double width = kMonitor1920,
double density = DEFAULT_DENSITY) {
Sim sim = make_sim(seed, width, density);
sim_set_scene(sim, Scene::Autumn);
return sim;
}
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
int count_maples(const Sim& sim) {
return static_cast<int>(std::count_if(sim.blades.begin(), sim.blades.end(),
[](const Blade& b) { return b.isMaple; }));
}
int count_new_leaf_spawns(Sim& sim, double seconds, double dt = 0.05) {
int count = 0;
const int steps = static_cast<int>(std::ceil(seconds / dt));
for (int i = 0; i < steps; ++i) {
sim_tick(sim, dt, nullptr, 0);
for (const Entity& e : sim.entities) {
if (e.kind == EntityKind::Leaf && e.age == Approx(0.0).margin(kEpsilon)) {
++count;
}
}
}
return count;
}
const Entity& spawn_next_leaf(Sim& sim) {
const double dt = std::max(0.0, sim.nextLeafSpawnTime - sim.globalTime);
sim_tick(sim, dt, nullptr, 0);
auto it = std::find_if(sim.entities.rbegin(), sim.entities.rend(),
[](const Entity& e) { return e.kind == EntityKind::Leaf && e.age == Approx(0.0).margin(kEpsilon); });
REQUIRE(it != sim.entities.rend());
return *it;
}
const Blade* first_maple(const Sim& sim) {
auto it = std::find_if(sim.blades.begin(), sim.blades.end(),
[](const Blade& b) { return b.isMaple; });
return it == sim.blades.end() ? nullptr : &*it;
}
Sim make_autumn_sim_with_maple(uint64_t* outSeed = nullptr) {
for (uint64_t offset = 0; offset < 512; ++offset) {
const uint64_t seed = CANONICAL_TEST_SEED + offset;
Sim sim = make_autumn_sim(seed);
if (count_maples(sim) > 0) {
if (outSeed) *outSeed = seed;
return sim;
}
}
FAIL("Unable to find deterministic seed with a maple");
return make_autumn_sim();
}
// Find an Autumn sim that contains at least one leafy (non-bare) maple, and
// return a pointer to it. The returned pointer is valid for the lifetime of
// the returned-by-out sim.
inline const Blade* first_leafy_maple(const Sim& sim) {
auto it = std::find_if(sim.blades.begin(), sim.blades.end(),
[](const Blade& b) { return b.isMaple && !b.mapleIsBare; });
return it == sim.blades.end() ? nullptr : &*it;
}
Sim make_autumn_sim_with_leafy_maple() {
for (uint64_t offset = 0; offset < 2048; ++offset) {
Sim sim = make_autumn_sim(CANONICAL_TEST_SEED + offset);
if (first_leafy_maple(sim) != nullptr) return sim;
}
FAIL("Unable to find deterministic seed with a leafy maple");
return make_autumn_sim();
}
std::filesystem::path autumn_state_path() {
std::filesystem::path dir = std::filesystem::current_path()
/ ".copilot-scratch"
/ "native-autumn-tests";
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir);
return dir / "state.json";
}
} // namespace
TEST_CASE("Autumn scene count bumps to five", "[autumn][scene]") {
REQUIRE(SCENE_COUNT == 5);
}
TEST_CASE("Autumn scene enum value is pinned", "[autumn][scene]") {
REQUIRE(static_cast<int>(Scene::Autumn) == 3);
}
TEST_CASE("Autumn palette is pinned in scene palettes", "[autumn][palette]") {
for (int i = 0; i < PALETTE_SIZE; ++i) {
REQUIRE(SCENE_PALETTES[static_cast<int>(Scene::Autumn)][i] == AUTUMN_PALETTE[i]);
}
}
TEST_CASE("Autumn does not change default scene", "[autumn][scene]") {
REQUIRE(SCENE_DEFAULT == Scene::Grass);
}
TEST_CASE("Leaf constants match Autumn spec", "[autumn][leaf][constants]") {
REQUIRE(LEAF_SPAWN_RATE_PER_SEC_1920DIP == Approx(1.4));
REQUIRE(LEAF_FALL_SPEED_MIN == Approx(14.0));
REQUIRE(LEAF_FALL_SPEED_MAX == Approx(26.0));
REQUIRE(LEAF_HORIZONTAL_DRIFT_AMP == Approx(32.0));
REQUIRE(LEAF_HORIZONTAL_DRIFT_FREQ == Approx(1.4));
REQUIRE(LEAF_ROTATION_SPEED_MIN == Approx(0.8));
REQUIRE(LEAF_ROTATION_SPEED_MAX == Approx(2.4));
REQUIRE(LEAF_SIZE_MIN == Approx(4.0));
REQUIRE(LEAF_SIZE_MAX == Approx(7.0));
REQUIRE(LEAF_SPAWN_Y_OFFSET == Approx(-10.0));
REQUIRE(LEAF_COLOR_COUNT == 6);
constexpr uint32_t expected[LEAF_COLOR_COUNT] = {
0xFFD96B0Cu, 0xFFB54D1Eu, 0xFFE89A3Cu,
0xFFC23E12u, 0xFFE6C849u, 0xFF8C2E0Fu,
};
for (int i = 0; i < LEAF_COLOR_COUNT; ++i) {
REQUIRE(LEAF_COLORS[i] == expected[i]);
}
REQUIRE(LEAF_PRNG_SALT == 0x1EA1DEC1D1EA1D05ull);
}
TEST_CASE("Autumn leaf spawn rate is gated and near mean", "[autumn][leaf]") {
Sim autumn = make_autumn_sim();
const int count = count_new_leaf_spawns(autumn, 100.0);
REQUIRE(count >= 112);
REQUIRE(count <= 168);
}
TEST_CASE("Only Autumn spawns leaves", "[autumn][leaf][gating]") {
for (Scene scene : { Scene::Grass, Scene::Desert, Scene::Winter }) {
Sim sim = make_sim();
sim_set_scene(sim, scene);
count_new_leaf_spawns(sim, 30.0);
REQUIRE(count_kind(sim, EntityKind::Leaf) == 0);
}
}
TEST_CASE("Leaf fall speed stays within pinned range", "[autumn][leaf]") {
Sim sim = make_autumn_sim();
for (int i = 0; i < 32; ++i) {
const Entity& e = spawn_next_leaf(sim);
REQUIRE(e.vy >= LEAF_FALL_SPEED_MIN);
REQUIRE(e.vy <= LEAF_FALL_SPEED_MAX);
REQUIRE(e.baseSpeed == Approx(e.vy));
}
}
TEST_CASE("Leaf size stays within pinned range", "[autumn][leaf]") {
Sim sim = make_autumn_sim();
for (int i = 0; i < 32; ++i) {
const Entity& e = spawn_next_leaf(sim);
REQUIRE(e.size >= LEAF_SIZE_MIN);
REQUIRE(e.size <= LEAF_SIZE_MAX);
}
}
TEST_CASE("Leaf color variant stays within pinned range", "[autumn][leaf]") {
Sim sim = make_autumn_sim();
for (int i = 0; i < 32; ++i) {
const Entity& e = spawn_next_leaf(sim);
REQUIRE(e.colorVariant < LEAF_COLOR_COUNT);
}
}
TEST_CASE("Leaf PRNG draw order matches side stream", "[autumn][leaf][prng]") {
Sim sim = make_autumn_sim();
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ LEAF_PRNG_SALT);
const double lambda = LEAF_SPAWN_RATE_PER_SEC_1920DIP * sim.monitorWidth / 1920.0;
double expectedNext = 0.0;
for (int i = 0; i < 8; ++i) {
const Entity& e = spawn_next_leaf(sim);
const double xFrac = prng_uniform(side, 0.0, 1.0);
const double expectedSpawnX = xFrac * sim.monitorWidth;
const double expectedFallSpeed = prng_uniform(side, LEAF_FALL_SPEED_MIN, LEAF_FALL_SPEED_MAX);
const double expectedPhase = prng_uniform(side, 0.0, kTwoPi);
const double rotationMag = prng_uniform(side, LEAF_ROTATION_SPEED_MIN, LEAF_ROTATION_SPEED_MAX);
const double rotationSign = (prng_next_u64(side) & 1ull) != 0ull ? 1.0 : -1.0;
const double expectedRotation = prng_uniform(side, 0.0, kTwoPi);
const double expectedSize = prng_uniform(side, LEAF_SIZE_MIN, LEAF_SIZE_MAX);
const uint8_t expectedColor = static_cast<uint8_t>(prng_index(side, LEAF_COLOR_COUNT));
expectedNext += prng_exponential(side, lambda);
REQUIRE(e.x0 == Approx(expectedSpawnX).margin(kEpsilon));
REQUIRE(e.x == Approx(expectedSpawnX + LEAF_HORIZONTAL_DRIFT_AMP * std::sin(expectedPhase)).margin(kEpsilon));
REQUIRE(e.vy == Approx(expectedFallSpeed).margin(kEpsilon));
REQUIRE(e.phaseX == Approx(expectedPhase).margin(kEpsilon));
REQUIRE(e.rotationSpeed == Approx(rotationMag * rotationSign).margin(kEpsilon));
REQUIRE(e.rotation == Approx(expectedRotation).margin(kEpsilon));
REQUIRE(e.size == Approx(expectedSize).margin(kEpsilon));
REQUIRE(e.colorVariant == expectedColor);
REQUIRE(sim.nextLeafSpawnTime == Approx(expectedNext).margin(kEpsilon));
}
}
TEST_CASE("Leaf despawns when past ground", "[autumn][leaf]") {
Sim sim = make_autumn_sim();
Entity e{};
e.kind = EntityKind::Leaf;
e.y = sim.windowHeight + 0.1;
e.lifetime = -1.0;
sim.entities.push_back(e);
sim.nextLeafSpawnTime = 1.0e9;
sim_tick_entities(sim, 0.0);
REQUIRE(count_kind(sim, EntityKind::Leaf) == 0);
}
TEST_CASE("Leaf ignores click-cut interaction", "[autumn][leaf]") {
Sim sim = make_autumn_sim();
Entity e{};
e.kind = EntityKind::Leaf;
e.x = 200.0;
e.y = sim.windowHeight - 5.0;
e.size = 5.0;
e.lifetime = -1.0;
sim.entities.push_back(e);
InputEvent click{};
click.type = EventType::Click;
click.x = e.x;
click.y = e.y;
click.time = sim.globalTime;
sim_apply_click(sim, click);
REQUIRE(count_kind(sim, EntityKind::Leaf) == 1);
}
TEST_CASE("Leaf EntityKind value is pinned", "[autumn][leaf][enum]") {
REQUIRE(static_cast<int>(EntityKind::Leaf) == 11);
}
TEST_CASE("Maple constants match Autumn spec", "[autumn][maple][constants]") {
REQUIRE(MAPLE_PROBABILITY == Approx(0.0070));
REQUIRE(MAPLE_HEIGHT_MIN == Approx(50.0));
REQUIRE(MAPLE_HEIGHT_MAX == Approx(85.0));
REQUIRE(MAPLE_TRUNK_WIDTH_MIN == Approx(6.0));
REQUIRE(MAPLE_TRUNK_WIDTH_MAX == Approx(10.0));
REQUIRE(MAPLE_CANOPY_RADIUS_MIN == Approx(14.0));
REQUIRE(MAPLE_CANOPY_RADIUS_MAX == Approx(24.0));
REQUIRE(MAPLE_TRUNK_COLOR == 0xFF4A2C18u);
REQUIRE(MAPLE_TRUNK_DARK == 0xFF2F1B0Eu);
REQUIRE(MAPLE_CANOPY_COLOR_COUNT == 4);
constexpr uint32_t expected[MAPLE_CANOPY_COLOR_COUNT] = {
0xFFD96B0Cu, 0xFFE89A3Cu, 0xFFC23E12u, 0xFFE6C849u,
};
for (int i = 0; i < MAPLE_CANOPY_COLOR_COUNT; ++i) {
REQUIRE(MAPLE_CANOPY_COLORS[i] == expected[i]);
}
REQUIRE(MAPLE_BARE_FRACTION == Approx(0.20));
REQUIRE(MAPLE_PRNG_SALT == 0xC1AA51EC1AA51Eull);
}
TEST_CASE("Maples generate only in Autumn", "[autumn][maple][gating]") {
for (Scene scene : { Scene::Grass, Scene::Desert, Scene::Winter }) {
Sim sim = make_sim();
sim_set_scene(sim, scene);
REQUIRE(count_maples(sim) == 0);
}
REQUIRE(count_maples(make_autumn_sim_with_maple()) > 0);
}
TEST_CASE("Maple promotion probability is near spec", "[autumn][maple]") {
int totalSlots = 0;
int totalMaples = 0;
for (uint64_t seed = CANONICAL_TEST_SEED; seed < CANONICAL_TEST_SEED + 200; ++seed) {
Sim sim = make_autumn_sim(seed);
totalSlots += static_cast<int>(sim.blades.size());
totalMaples += count_maples(sim);
}
const double fraction = static_cast<double>(totalMaples) / static_cast<double>(totalSlots);
REQUIRE(fraction >= MAPLE_PROBABILITY * 0.75);
REQUIRE(fraction <= MAPLE_PROBABILITY * 1.25);
}
TEST_CASE("Maple height stays within pinned range", "[autumn][maple]") {
Sim sim = make_autumn_sim_with_maple();
for (const Blade& b : sim.blades) if (b.isMaple) {
REQUIRE(b.mapleHeight >= MAPLE_HEIGHT_MIN);
REQUIRE(b.mapleHeight <= MAPLE_HEIGHT_MAX);
}
}
TEST_CASE("Maple trunk width stays within pinned range", "[autumn][maple]") {
Sim sim = make_autumn_sim_with_maple();
for (const Blade& b : sim.blades) if (b.isMaple) {
REQUIRE(b.mapleTrunkWidth >= MAPLE_TRUNK_WIDTH_MIN);
REQUIRE(b.mapleTrunkWidth <= MAPLE_TRUNK_WIDTH_MAX);
}
}
TEST_CASE("Maple canopy radius stays within pinned range", "[autumn][maple]") {
Sim sim = make_autumn_sim_with_maple();
for (const Blade& b : sim.blades) if (b.isMaple) {
REQUIRE(b.mapleCanopyRadius >= MAPLE_CANOPY_RADIUS_MIN);
REQUIRE(b.mapleCanopyRadius <= MAPLE_CANOPY_RADIUS_MAX);
}
}
TEST_CASE("Maple canopy color variant stays within pinned range", "[autumn][maple]") {
Sim sim = make_autumn_sim_with_maple();
for (const Blade& b : sim.blades) if (b.isMaple) {
REQUIRE(b.mapleCanopyColorIdx < MAPLE_CANOPY_COLOR_COUNT);
}
}
TEST_CASE("Maple bare fraction is near spec", "[autumn][maple]") {
int totalMaples = 0;
int totalBare = 0;
for (uint64_t seed = CANONICAL_TEST_SEED; seed < CANONICAL_TEST_SEED + 400; ++seed) {
Sim sim = make_autumn_sim(seed);
for (const Blade& b : sim.blades) if (b.isMaple) {
++totalMaples;
if (b.mapleIsBare) ++totalBare;
}
}
REQUIRE(totalMaples > 100);
const double fraction = static_cast<double>(totalBare) / static_cast<double>(totalMaples);
REQUIRE(fraction >= MAPLE_BARE_FRACTION - 0.05);
REQUIRE(fraction <= MAPLE_BARE_FRACTION + 0.05);
}
TEST_CASE("Maple PRNG draw order matches side stream", "[autumn][maple][prng]") {
uint64_t seed = 0;
Sim sim = make_autumn_sim_with_maple(&seed);
Prng side;
prng_init(side, seed ^ MAPLE_PRNG_SALT);
for (std::size_t i = 0; i < sim.blades.size(); ++i) {
const double r = prng_uniform(side, 0.0, 1.0);
if (r >= MAPLE_PROBABILITY) {
REQUIRE_FALSE(sim.blades[i].isMaple);
continue;
}
const double expectedHeight = prng_uniform(side, MAPLE_HEIGHT_MIN, MAPLE_HEIGHT_MAX);
const double expectedTrunkWidth = prng_uniform(side, MAPLE_TRUNK_WIDTH_MIN, MAPLE_TRUNK_WIDTH_MAX);
const double expectedCanopyRadius = prng_uniform(side, MAPLE_CANOPY_RADIUS_MIN, MAPLE_CANOPY_RADIUS_MAX);
const uint8_t expectedColor = static_cast<uint8_t>(prng_index(side, MAPLE_CANOPY_COLOR_COUNT));
const bool expectedBare = prng_uniform(side, 0.0, 1.0) < MAPLE_BARE_FRACTION;
const Blade& b = sim.blades[i];
REQUIRE(b.isMaple);
REQUIRE(b.mapleHeight == Approx(expectedHeight).margin(kEpsilon));
REQUIRE(b.mapleTrunkWidth == Approx(expectedTrunkWidth).margin(kEpsilon));
REQUIRE(b.mapleCanopyRadius == Approx(expectedCanopyRadius).margin(kEpsilon));
REQUIRE(b.mapleCanopyColorIdx == expectedColor);
REQUIRE(b.mapleIsBare == expectedBare);
return;
}
FAIL("Expected a maple promotion");
}
TEST_CASE("Maples are cuttable through existing cut model", "[autumn][maple]") {
Sim sim = make_autumn_sim_with_maple();
const Blade* maple = first_maple(sim);
REQUIRE(maple != nullptr);
const double clickX = maple->baseX;
InputEvent click{};
click.type = EventType::Click;
click.x = clickX;
click.y = sim.windowHeight - 1.0;
click.time = sim.globalTime;
sim_apply_click(sim, click);
sim_tick(sim, CUT_DURATION_SEC + 0.01, nullptr, 0);
const Blade& cutMaple = *std::find_if(sim.blades.begin(), sim.blades.end(),
[clickX](const Blade& b) { return b.isMaple && b.baseX == Approx(clickX); });
// Cut blades now settle at their per-blade stubble floor, not flat zero.
REQUIRE(cutMaple.cutFloor > 0.0);
REQUIRE(cutMaple.cutHeight == Approx(cutMaple.cutFloor).margin(kEpsilon));
}
TEST_CASE("Autumn is critter-free", "[autumn][critter][gating]") {
Sim sim = make_autumn_sim();
REQUIRE(count_kind(sim, EntityKind::Sheep) == 0);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
REQUIRE(count_kind(sim, EntityKind::Hedgehog) == 0);
}
TEST_CASE("Autumn does not spawn snowflakes", "[autumn][weather]") {
Sim sim = make_autumn_sim();
for (int i = 0; i < 500; ++i) sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(count_kind(sim, EntityKind::Snowflake) == 0);
}
TEST_CASE("Autumn scene persists round-trip", "[autumn][persistence]") {
const auto path = autumn_state_path();
persistence::SetStateFilePathForTest(path.wstring());
persistence::AppState expected;
expected.scene = Scene::Autumn;
REQUIRE(persistence::SaveAppState(expected));
persistence::AppState actual;
REQUIRE(persistence::LoadAppState(actual));
REQUIRE(actual.scene == Scene::Autumn);
}
TEST_CASE("Leaf puff constants are pinned", "[autumn][leaf][puff][constants]") {
REQUIRE(LEAF_PUFF_COUNT_MIN == 4);
REQUIRE(LEAF_PUFF_COUNT_MAX == 7);
REQUIRE(LEAF_PUFF_BURST_SPEED_MIN == Approx(18.0));
REQUIRE(LEAF_PUFF_BURST_SPEED_MAX == Approx(42.0));
REQUIRE(LEAF_PUFF_DRAG == Approx(2.2));
REQUIRE(LEAF_PUFF_COOLDOWN_SEC == Approx(1.5));
REQUIRE(LEAF_PUFF_HOVER_RADIUS_MUL == Approx(1.15));
REQUIRE(LEAF_PUFF_MIN_CUT_HEIGHT == Approx(0.5));
REQUIRE(LEAF_PUFF_START_OFFSET_FRAC == Approx(0.4));
}
TEST_CASE("Hovering a leafy maple sheds a leaf puff", "[autumn][leaf][puff]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
REQUIRE(maple != nullptr);
const double cx = maple->baseX;
const double cy = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
const int before = count_kind(sim, EntityKind::Leaf);
InputEvent mv{};
mv.type = EventType::Move;
mv.x = cx;
mv.y = cy;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
const int puffed = count_kind(sim, EntityKind::Leaf) - before;
REQUIRE(puffed >= LEAF_PUFF_COUNT_MIN);
REQUIRE(puffed <= LEAF_PUFF_COUNT_MAX);
const bool anyBurst = std::any_of(sim.entities.begin(), sim.entities.end(),
[](const Entity& e) { return e.kind == EntityKind::Leaf && e.vx != 0.0; });
REQUIRE(anyBurst);
}
TEST_CASE("Leaf puff respects a per-tree cooldown", "[autumn][leaf][puff]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
const double cx = maple->baseX;
const double cy = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
InputEvent mv{};
mv.type = EventType::Move;
mv.x = cx;
mv.y = cy;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
const int afterFirst = count_kind(sim, EntityKind::Leaf);
REQUIRE(afterFirst > 0);
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) == afterFirst);
sim.globalTime += LEAF_PUFF_COOLDOWN_SEC + 0.1;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) > afterFirst);
}
TEST_CASE("Leaf puff ignores cursor away from canopy", "[autumn][leaf][puff]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
const int before = count_kind(sim, EntityKind::Leaf);
InputEvent mv{};
mv.type = EventType::Move;
mv.x = maple->baseX + 400.0;
mv.y = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) == before);
}
TEST_CASE("Leaf puff does not fire outside Autumn", "[autumn][leaf][puff][gating]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
const double cx = maple->baseX;
const double cy = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
sim.currentScene = Scene::Grass;
const int before = count_kind(sim, EntityKind::Leaf);
InputEvent mv{};
mv.type = EventType::Move;
mv.x = cx;
mv.y = cy;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) == before);
}
TEST_CASE("Puff burst decays so leaves settle into flutter", "[autumn][leaf][puff]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
InputEvent mv{};
mv.type = EventType::Move;
mv.x = maple->baseX;
mv.y = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) > 0);
for (int i = 0; i < 40; ++i) sim_tick(sim, 0.05, nullptr, 0);
for (const Entity& e : sim.entities) {
if (e.kind == EntityKind::Leaf) REQUIRE(e.vx == Approx(0.0).margin(kEpsilon));
}
}
TEST_CASE("Re-entering Autumn clears the puff cooldown", "[autumn][leaf][puff]") {
Sim sim = make_autumn_sim_with_leafy_maple();
const Blade* maple = first_leafy_maple(sim);
InputEvent mv{};
mv.type = EventType::Move;
mv.x = maple->baseX;
mv.y = sim.windowHeight - maple->mapleHeight * maple->cutHeight;
mv.time = sim.globalTime;
sim_apply_move(sim, mv);
REQUIRE(count_kind(sim, EntityKind::Leaf) > 0);
// Leaving and re-entering Autumn regenerates the (deterministic) maples and
// must reset their puff cooldown so the fresh scene can puff immediately.
sim_set_scene(sim, Scene::Grass);
sim_set_scene(sim, Scene::Autumn);
const Blade* maple2 = first_leafy_maple(sim);
REQUIRE(maple2 != nullptr);
const int before = count_kind(sim, EntityKind::Leaf);
InputEvent mv2{};
mv2.type = EventType::Move;
mv2.x = maple2->baseX;
mv2.y = sim.windowHeight - maple2->mapleHeight * maple2->cutHeight;
mv2.time = sim.globalTime;
sim_apply_move(sim, mv2);
REQUIRE(count_kind(sim, EntityKind::Leaf) > before);
}
TEST_CASE("Autumn PRNG salts are unique", "[autumn][prng]") {
constexpr std::array<uint64_t, 16> salts = {
REGROW_PRNG_SALT,
FLOWER_PRNG_SALT,
MUSHROOM_PRNG_SALT,
AMBIENT_GUST_PRNG_SALT,
CACTUS_PRNG_SALT,
TUMBLEWEED_PRNG_SALT,
CRITTER_PRNG_SALT,
BUTTERFLY_PRNG_SALT,
FIREFLY_PRNG_SALT,
BIRD_FLYBY_PRNG_SALT,
SNOWFLAKE_PRNG_SALT,
PINE_PRNG_SALT,
LEAF_PRNG_SALT,
MAPLE_PRNG_SALT,
LEAF_PUFF_PRNG_SALT,
};
for (std::size_t i = 0; i < salts.size(); ++i) {
for (std::size_t j = i + 1; j < salts.size(); ++j) {
REQUIRE(salts[i] != salts[j]);
}
}
}

View File

@@ -1,365 +0,0 @@
// bird_flyby_tests.cpp - §17.8 ambient bird flyby tests.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
constexpr double TwoPi = 6.28318530717958647692;
Sim build_sim(uint64_t seed = CANONICAL_TEST_SEED) {
Sim sim = sim_init(seed, Monitor1920, DEFAULT_DENSITY);
sim.currentScene = Scene::Grass;
sim.entities.clear();
return sim;
}
int count_birds(const Sim& sim) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[](const Entity& e) { return e.kind == EntityKind::Bird; }));
}
std::vector<Entity> birds(const Sim& sim) {
std::vector<Entity> out;
for (const Entity& e : sim.entities) {
if (e.kind == EntityKind::Bird) out.push_back(e);
}
return out;
}
int prng_count(Prng& side, int minCount, int maxCount) {
const double draw = prng_uniform(side, static_cast<double>(minCount), static_cast<double>(maxCount + 1));
int count = static_cast<int>(std::floor(draw));
if (count < minCount) count = minCount;
if (count > maxCount) count = maxCount;
return count;
}
void reset_bird_stream_fresh(Sim& sim, uint64_t seed) {
prng_init(sim.birdFlybyPrng, seed ^ BIRD_FLYBY_PRNG_SALT);
sim.nextBirdFlybyAtTime = sim.globalTime;
}
void reset_bird_schedule(Sim& sim, uint64_t seed) {
prng_init(sim.birdFlybyPrng, seed ^ BIRD_FLYBY_PRNG_SALT);
sim.nextBirdFlybyAtTime = sim.globalTime + bird_flyby_sample_interval(sim.birdFlybyPrng);
}
uint64_t find_seed_for_flock_size(int size) {
for (uint64_t i = 1; i < 10000; ++i) {
Sim sim = build_sim(CANONICAL_TEST_SEED + i);
reset_bird_stream_fresh(sim, CANONICAL_TEST_SEED + i);
sim_spawn_bird_flyby(sim);
if (count_birds(sim) == size) return CANONICAL_TEST_SEED + i;
}
return CANONICAL_TEST_SEED;
}
uint64_t find_v_seed(int minSize) {
for (uint64_t i = 1; i < 10000; ++i) {
Sim sim = build_sim(CANONICAL_TEST_SEED + i);
reset_bird_stream_fresh(sim, CANONICAL_TEST_SEED + i);
sim_spawn_bird_flyby(sim);
auto flock = birds(sim);
if (static_cast<int>(flock.size()) >= minSize && flock[0].colorVariant == 0) {
return CANONICAL_TEST_SEED + i;
}
}
return CANONICAL_TEST_SEED;
}
} // namespace
TEST_CASE("Bird flyby constants are pinned to spec values", "[bird][constants]") {
REQUIRE(BIRD_FLYBY_SPAWN_RATE_PER_HOUR == Approx(15.0));
REQUIRE(BIRD_FLOCK_SIZE_MIN == 3);
REQUIRE(BIRD_FLOCK_SIZE_MAX == 7);
REQUIRE(BIRD_FLOCK_FORMATION_SPACING == Approx(9.0));
REQUIRE(BIRD_FLOCK_V_ANGLE_DEG == Approx(22.0));
REQUIRE(BIRD_SPEED_MIN == Approx(65.0));
REQUIRE(BIRD_SPEED_MAX == Approx(95.0));
REQUIRE(BIRD_ALTITUDE_MIN == Approx(78.0));
REQUIRE(BIRD_ALTITUDE_MAX == Approx(96.0));
REQUIRE(BIRD_BODY_LENGTH == Approx(3.6));
REQUIRE(BIRD_WING_SPAN == Approx(5.0));
REQUIRE(BIRD_WING_FLAP_FREQ == Approx(7.0));
REQUIRE(BIRD_WING_FLAP_PHASE_JITTER == Approx(0.6));
REQUIRE(BIRD_BODY_COLOR == 0xFF1A1610u);
REQUIRE(BIRD_WING_OPEN_RATIO == Approx(1.0));
REQUIRE(BIRD_WING_FOLD_RATIO == Approx(0.30));
REQUIRE(BIRD_FADE_IN_FRAC == Approx(0.08));
REQUIRE(BIRD_FADE_OUT_FRAC == Approx(0.08));
REQUIRE(BIRD_DRIFT_AMP_Y == Approx(3.0));
REQUIRE(BIRD_DRIFT_FREQ_Y == Approx(0.8));
REQUIRE(BIRD_FLYBY_PRNG_SALT == 0xB12D1F1A1B12D1Aull);
}
TEST_CASE("Bird flyby PRNG salt is unique", "[bird][constants]") {
const uint64_t salts[] = {
REGROW_PRNG_SALT, FLOWER_PRNG_SALT, MUSHROOM_PRNG_SALT,
AMBIENT_GUST_PRNG_SALT, CACTUS_PRNG_SALT, TUMBLEWEED_PRNG_SALT,
CRITTER_PRNG_SALT, BUTTERFLY_PRNG_SALT, FIREFLY_PRNG_SALT,
SNOWFLAKE_PRNG_SALT, PINE_PRNG_SALT,
};
for (uint64_t salt : salts) {
REQUIRE(BIRD_FLYBY_PRNG_SALT != salt);
}
}
TEST_CASE("Bird flyby flock size stays in range over seeds", "[bird][spawn]") {
for (uint64_t i = 0; i < 256; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
sim_spawn_bird_flyby(sim);
REQUIRE(count_birds(sim) >= BIRD_FLOCK_SIZE_MIN);
REQUIRE(count_birds(sim) <= BIRD_FLOCK_SIZE_MAX);
}
}
TEST_CASE("Bird flyby leader altitude stays in range", "[bird][spawn]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i;
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
sim_spawn_bird_flyby(sim);
auto flock = birds(sim);
REQUIRE(!flock.empty());
REQUIRE(flock[0].altitudeAnchor >= BIRD_ALTITUDE_MIN);
REQUIRE(flock[0].altitudeAnchor < BIRD_ALTITUDE_MAX);
}
}
TEST_CASE("Bird flyby leader speed stays in range", "[bird][spawn]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i;
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
sim_spawn_bird_flyby(sim);
auto flock = birds(sim);
REQUIRE(!flock.empty());
REQUIRE(flock[0].baseSpeed >= BIRD_SPEED_MIN);
REQUIRE(flock[0].baseSpeed < BIRD_SPEED_MAX);
REQUIRE(std::abs(flock[0].vx) == Approx(flock[0].baseSpeed));
}
}
TEST_CASE("Bird flyby PRNG draw order matches side stream", "[bird][prng]") {
const uint64_t seed = 0xB17D5EED1234ull;
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
Prng side;
prng_init(side, seed ^ BIRD_FLYBY_PRNG_SALT);
sim_spawn_bird_flyby(sim);
const int expectedCount = prng_count(side, BIRD_FLOCK_SIZE_MIN, BIRD_FLOCK_SIZE_MAX);
const uint64_t directionBit = prng_next_u64(side) & 1ull;
const double direction = directionBit != 0ull ? 1.0 : -1.0;
const double leaderAltitude = prng_uniform(side, BIRD_ALTITUDE_MIN, BIRD_ALTITUDE_MAX);
const double leaderSpeed = prng_uniform(side, BIRD_SPEED_MIN, BIRD_SPEED_MAX);
const uint64_t formationStyle = prng_next_u64(side) & 1ull;
std::vector<double> wingPhases;
std::vector<double> driftPhases;
for (int i = 0; i < expectedCount; ++i) {
wingPhases.push_back(prng_uniform(side, -BIRD_WING_FLAP_PHASE_JITTER, BIRD_WING_FLAP_PHASE_JITTER));
driftPhases.push_back(prng_uniform(side, 0.0, TwoPi));
}
auto flock = birds(sim);
REQUIRE(static_cast<int>(flock.size()) == expectedCount);
REQUIRE(sim.birdFlybyPrng.state == side.state);
const double spawnX = direction > 0.0 ? -50.0 : Monitor1920 + 50.0;
const double sinAngle = std::sin(BIRD_FLOCK_V_ANGLE_DEG * 3.14159265358979323846 / 180.0);
for (int i = 0; i < expectedCount; ++i) {
const double along = -static_cast<double>(i) * BIRD_FLOCK_FORMATION_SPACING;
double perpendicular = 0.0;
if (formationStyle == 0ull) {
const int armIndex = (i + 1) / 2;
const double sideSign = (i % 2) == 0 ? 1.0 : -1.0;
perpendicular = sideSign * static_cast<double>(armIndex) * BIRD_FLOCK_FORMATION_SPACING * sinAngle;
} else {
perpendicular = static_cast<double>(i) * BIRD_FLOCK_FORMATION_SPACING * sinAngle;
}
const Entity& e = flock[static_cast<std::size_t>(i)];
REQUIRE(e.x0 == Approx(spawnX + direction * along));
REQUIRE(e.x == Approx(e.x0));
REQUIRE(e.vx == Approx(direction * leaderSpeed));
REQUIRE(e.baseSpeed == Approx(leaderSpeed));
REQUIRE(e.altitudeAnchor == Approx(leaderAltitude - perpendicular));
REQUIRE(e.phaseX == Approx(wingPhases[static_cast<std::size_t>(i)]));
REQUIRE(e.phaseY == Approx(driftPhases[static_cast<std::size_t>(i)]));
REQUIRE(e.formationOffsetAlongFlight == Approx(along));
REQUIRE(e.formationOffsetPerpendicular == Approx(perpendicular));
REQUIRE(e.colorVariant == static_cast<uint8_t>(formationStyle));
REQUIRE(e.spawnTime == Approx(sim.globalTime));
}
}
TEST_CASE("Bird flybys are Grass scene only", "[bird][scene]") {
for (Scene scene : { Scene::Desert, Scene::Winter }) {
Sim sim = build_sim();
sim_set_scene(sim, scene);
sim.entities.clear();
reset_bird_schedule(sim, CANONICAL_TEST_SEED);
for (int i = 0; i < 8 * 3600; ++i) {
sim.globalTime += 1.0;
sim_tick_bird_flybys(sim);
}
REQUIRE(count_birds(sim) == 0);
}
}
TEST_CASE("Bird flyby Poisson spawns when schedule elapses", "[bird][time]") {
Sim sim = build_sim(0xDAD1B17Dull);
reset_bird_schedule(sim, 0xDAD1B17Dull);
int flybys = 0;
for (int i = 0; i < 10 * 3600; ++i) {
sim.globalTime += 1.0;
const int before = count_birds(sim);
sim_tick_bird_flybys(sim);
if (count_birds(sim) > before) {
++flybys;
sim.entities.clear();
}
}
const double observedPerHour = static_cast<double>(flybys) / 10.0;
REQUIRE(observedPerHour == Approx(BIRD_FLYBY_SPAWN_RATE_PER_HOUR).epsilon(0.15));
}
TEST_CASE("Bird V formation geometry is locked", "[bird][formation]") {
const uint64_t seed = find_v_seed(5);
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
sim_spawn_bird_flyby(sim);
auto flock = birds(sim);
REQUIRE(flock.size() >= 5);
REQUIRE(flock[0].colorVariant == 0);
REQUIRE(flock[0].formationOffsetAlongFlight == Approx(0.0));
for (std::size_t i = 1; i < flock.size(); ++i) {
REQUIRE(std::fabs(flock[0].formationOffsetAlongFlight)
< std::fabs(flock[i].formationOffsetAlongFlight));
REQUIRE(flock[i - 1].formationOffsetAlongFlight - flock[i].formationOffsetAlongFlight
== Approx(BIRD_FLOCK_FORMATION_SPACING));
const double expectedSign = (i % 2 == 0) ? 1.0 : -1.0;
REQUIRE((flock[i].formationOffsetPerpendicular > 0.0 ? 1.0 : -1.0) == expectedSign);
}
}
TEST_CASE("Bird wing flap scale stays in range", "[bird][wing]") {
for (int i = 0; i < 200; ++i) {
const double t = i * 0.137;
const double phase = -BIRD_WING_FLAP_PHASE_JITTER
+ (2.0 * BIRD_WING_FLAP_PHASE_JITTER) * (static_cast<double>(i) / 199.0);
const double scale = bird_wing_scale(t, phase);
REQUIRE(scale >= BIRD_WING_FOLD_RATIO);
REQUIRE(scale <= BIRD_WING_OPEN_RATIO);
}
}
TEST_CASE("Bird wing phases decorrelate within a flock", "[bird][wing]") {
for (uint64_t i = 1; i < 10000; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i;
Sim sim = build_sim(seed);
reset_bird_stream_fresh(sim, seed);
sim_spawn_bird_flyby(sim);
auto flock = birds(sim);
if (flock.size() != 5) continue;
std::vector<double> distinct;
for (const Entity& e : flock) {
const double scale = bird_wing_scale(1.234, e.phaseX);
bool seen = false;
for (double existing : distinct) {
if (std::fabs(existing - scale) < 1e-6) { seen = true; break; }
}
if (!seen) distinct.push_back(scale);
}
if (distinct.size() >= 3) {
REQUIRE(distinct.size() >= 3);
return;
}
}
FAIL("no decorrelated 5-bird flock found");
}
TEST_CASE("Birds despawn past opposite boundary", "[bird][despawn]") {
Sim sim = build_sim();
sim.currentScene = Scene::Desert;
sim.entities.clear();
Entity bird{};
bird.kind = EntityKind::Bird;
bird.x = Monitor1920 + 49.0;
bird.y = 10.0;
bird.vx = 20.0;
bird.altitudeAnchor = BIRD_ALTITUDE_MIN;
bird.lifetime = -1.0;
sim.entities.push_back(bird);
sim_tick_entities(sim, 0.2);
REQUIRE(count_birds(sim) == 0);
}
TEST_CASE("Birds do not interact with cuts or critters", "[bird][interaction]") {
Sim sim = build_sim();
sim.entities.clear();
Entity bird{};
bird.kind = EntityKind::Bird;
bird.x = 500.0;
bird.y = sim.windowHeight - STRIP_HEIGHT - 10.0;
bird.vx = BIRD_SPEED_MIN;
bird.baseSpeed = BIRD_SPEED_MIN;
bird.altitudeAnchor = BIRD_ALTITUDE_MIN;
bird.lifetime = -1.0;
sim.entities.push_back(bird);
Entity sheep{};
sheep.kind = EntityKind::Sheep;
sheep.x = bird.x;
sheep.y = sim.windowHeight - SHEEP_BODY_HEIGHT - SHEEP_LEG_LENGTH;
sheep.vx = SHEEP_WALK_SPEED_MIN;
sheep.state = SHEEP_STATE_WALKING;
sheep.stateTimer = 10.0;
sim.entities.push_back(sheep);
InputEvent ev{};
ev.type = EventType::Click;
ev.x = bird.x;
ev.y = bird.y;
sim_apply_click(sim, ev);
REQUIRE(sim.entities[0].kind == EntityKind::Bird);
REQUIRE(sim.entities[0].baseSpeed == Approx(BIRD_SPEED_MIN));
REQUIRE(sim.entities[1].state == SHEEP_STATE_WALKING);
for (const Blade& b : sim.blades) REQUIRE(b.cutAnimStart < 0.0);
}
TEST_CASE("Bird flyby Poisson inter-arrivals keep expected mean", "[bird][poisson]") {
const uint64_t seed = 0x510B17D00ull;
Sim sim = build_sim(seed);
reset_bird_schedule(sim, seed);
double prev = sim.globalTime;
double totalInterval = 0.0;
constexpr int Events = 100;
for (int i = 0; i < Events; ++i) {
sim.globalTime = sim.nextBirdFlybyAtTime;
totalInterval += sim.globalTime - prev;
prev = sim.globalTime;
sim_tick_bird_flybys(sim);
sim.entities.clear();
}
const double expectedMean = 3600.0 / BIRD_FLYBY_SPAWN_RATE_PER_HOUR;
REQUIRE((totalInterval / Events) == Approx(expectedMean).epsilon(0.20));
}

View File

@@ -1,153 +0,0 @@
// blade_gen_tests.cpp
//
// Snapshot + invariant tests for procedural blade generation (architecture.md §5).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <cmath>
#include <vector>
using namespace desktopgrass;
using namespace desktopgrass::test;
namespace {
void requireBladeEquals(const Blade& actual, const SnapshotBlade& expected, std::size_t index) {
INFO("blade index = " << index);
REQUIRE(actual.baseX == Approx(expected.baseX ).margin(1e-12));
REQUIRE(actual.height == Approx(expected.height ).margin(1e-12));
REQUIRE(actual.thickness == Approx(expected.thickness ).margin(1e-12));
REQUIRE(actual.hue == expected.hue);
REQUIRE(actual.swayPhaseOffset == Approx(expected.sway ).margin(1e-12));
REQUIRE(actual.stiffness == Approx(expected.stiffness ).margin(1e-12));
REQUIRE(actual.isFlower == expected.isFlower);
REQUIRE(actual.flowerHeadColorIdx == expected.flowerHeadColorIdx);
REQUIRE(actual.flowerHeadRadius == Approx(expected.flowerHeadRadius).margin(1e-12));
REQUIRE(actual.heightBonus == Approx(expected.heightBonus ).margin(1e-12));
}
} // anonymous
TEST_CASE("blade generation matches the canonical snapshot", "[blade-gen][snapshot]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() == CANONICAL_BLADE_COUNT);
SECTION("first 10 blades match") {
for (std::size_t i = 0; i < 10; ++i) {
requireBladeEquals(blades[i], CANONICAL_FIRST_10[i], i);
}
}
SECTION("last 10 blades match") {
const std::size_t start = blades.size() - 10;
for (std::size_t i = 0; i < 10; ++i) {
requireBladeEquals(blades[start + i], CANONICAL_LAST_10[i], start + i);
}
}
}
TEST_CASE("blade fields stay within spec ranges", "[blade-gen]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
constexpr double TWO_PI = 6.283185307179586;
for (std::size_t i = 0; i < blades.size(); ++i) {
const Blade& b = blades[i];
INFO("blade index = " << i);
REQUIRE(b.baseX >= 0.0);
REQUIRE(b.baseX < 1920.0);
REQUIRE(b.height >= BLADE_HEIGHT_MIN);
REQUIRE(b.height < BLADE_HEIGHT_MAX);
REQUIRE(b.thickness >= BLADE_THICKNESS_MIN);
REQUIRE(b.thickness < BLADE_THICKNESS_MAX);
REQUIRE(b.hue < PALETTE_SIZE);
REQUIRE(b.swayPhaseOffset >= 0.0);
REQUIRE(b.swayPhaseOffset < TWO_PI);
REQUIRE(b.stiffness >= STIFFNESS_MIN);
REQUIRE(b.stiffness < STIFFNESS_MAX);
REQUIRE(b.cutHeight == Approx(1.0));
REQUIRE(b.gustVelocity == Approx(0.0));
REQUIRE(b.cutAnimStart == Approx(-1.0));
REQUIRE(b.cutInitialHeight == Approx(1.0));
if (b.isFlower) {
REQUIRE(b.flowerHeadColorIdx < FLOWER_PALETTE_SIZE);
REQUIRE(b.flowerHeadRadius >= FLOWER_HEAD_RADIUS_MIN);
REQUIRE(b.flowerHeadRadius < FLOWER_HEAD_RADIUS_MAX);
REQUIRE(b.heightBonus >= FLOWER_HEIGHT_BONUS_MIN);
REQUIRE(b.heightBonus < FLOWER_HEIGHT_BONUS_MAX);
} else {
REQUIRE(b.flowerHeadColorIdx == 0);
REQUIRE(b.flowerHeadRadius == Approx(0.0));
REQUIRE(b.heightBonus == Approx(1.0));
}
}
}
TEST_CASE("flower count is near configured probability and ordinary blades use defaults", "[blade-gen][flowers]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 100);
std::size_t flowerCount = 0;
for (const Blade& b : blades) {
if (b.isFlower) {
++flowerCount;
} else {
REQUIRE(b.flowerHeadColorIdx == 0);
REQUIRE(b.flowerHeadRadius == Approx(0.0));
REQUIRE(b.heightBonus == Approx(1.0));
}
}
const double n = static_cast<double>(blades.size());
const double p = FLOWER_PROBABILITY;
const double mu = n * p;
const double sd = std::sqrt(n * p * (1.0 - p));
REQUIRE(flowerCount >= static_cast<std::size_t>(std::floor(mu - 3.0 * sd)));
REQUIRE(flowerCount <= static_cast<std::size_t>(std::ceil(mu + 3.0 * sd)));
}
TEST_CASE("blade baseX is strictly increasing", "[blade-gen]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 0);
for (std::size_t i = 1; i < blades.size(); ++i) {
INFO("between " << (i-1) << " and " << i);
REQUIRE(blades[i].baseX > blades[i-1].baseX);
}
}
TEST_CASE("blade generation is deterministic across repeat runs", "[blade-gen]") {
std::vector<Blade> a, b;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, a);
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, b);
REQUIRE(a.size() == b.size());
for (std::size_t i = 0; i < a.size(); ++i) {
REQUIRE(a[i].baseX == b[i].baseX);
REQUIRE(a[i].height == b[i].height);
REQUIRE(a[i].thickness == b[i].thickness);
REQUIRE(a[i].hue == b[i].hue);
REQUIRE(a[i].swayPhaseOffset == b[i].swayPhaseOffset);
REQUIRE(a[i].stiffness == b[i].stiffness);
}
}
TEST_CASE("density scales blade count roughly linearly", "[blade-gen]") {
std::vector<Blade> low, high;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 0.5, low);
generate_blades(CANONICAL_TEST_SEED, 1920.0, 2.0, high);
REQUIRE(low.size() > 0);
REQUIRE(high.size() > low.size() * 3); // 4x density ≈ 4x blades, allow slack
}
TEST_CASE("blade count near plan default at density 1.25", "[blade-gen]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.25, blades);
// Plan target: ~400 blades per 1920 px at the v1 default density of 1.25.
REQUIRE(blades.size() >= 350);
REQUIRE(blades.size() <= 450);
}

View File

@@ -1,311 +0,0 @@
// bunny_tests.cpp
//
// §18 Bunny critter tests. Mirrors Win2D BunnyTests.cs.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <cwchar>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
Sim build_grass_sim(uint64_t seed = CANONICAL_TEST_SEED) {
Sim sim = sim_init(seed, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Grass);
sim_set_critter(sim, CritterKind::Bunny);
return sim;
}
Entity bunny_entity(double x = 500.0, double vx = BUNNY_HOP_SPEED_MIN) {
Entity e{};
e.kind = EntityKind::Bunny;
e.size = BUNNY_BODY_RADIUS;
e.x = x;
e.y = STRIP_HEIGHT + HEADROOM - BUNNY_BODY_HEIGHT - BUNNY_LEG_LENGTH;
e.vx = vx;
e.rotationSpeed = std::abs(vx);
e.lifetime = -1.0;
e.state = BUNNY_STATE_HOPPING;
e.stateTimer = BUNNY_HOP_DURATION;
return e;
}
InputEvent click_event(double x, double y) {
InputEvent ev{};
ev.type = EventType::Click;
ev.x = x;
ev.y = y;
ev.time = 0.0;
return ev;
}
int prng_count(Prng& side, int minCount, int maxCount) {
const double draw = prng_uniform(side, static_cast<double>(minCount), static_cast<double>(maxCount + 1));
int count = static_cast<int>(std::floor(draw));
if (count < minCount) count = minCount;
if (count > maxCount) count = maxCount;
return count;
}
void advance_sheep(Prng& side, int count) {
for (int i = 0; i < count; ++i) {
const double margin = SHEEP_BODY_RADIUS + 8.0;
(void)prng_uniform(side, margin, Monitor1920 - margin);
(void)prng_uniform(side, SHEEP_WALK_SPEED_MIN, SHEEP_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, SHEEP_WALK_DURATION_MIN, SHEEP_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0])));
}
}
void advance_cats(Prng& side, int count) {
for (int i = 0; i < count; ++i) {
const double margin = CAT_BODY_RADIUS + 8.0;
(void)prng_uniform(side, margin, Monitor1920 - margin);
(void)prng_uniform(side, CAT_WALK_SPEED_MIN, CAT_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, CAT_WALK_DURATION_MIN, CAT_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0])));
(void)prng_index(side, static_cast<uint32_t>(CAT_COAT_VARIANT_COUNT));
}
}
bool bunny_name_in_pool(const Entity& e) {
if (e.nameIndex >= sizeof(BUNNY_NAME_POOL) / sizeof(BUNNY_NAME_POOL[0])) return false;
const wchar_t* name = BUNNY_NAME_POOL[e.nameIndex];
for (const wchar_t* candidate : BUNNY_NAME_POOL) {
if (std::wcscmp(name, candidate) == 0) return true;
}
return false;
}
} // namespace
TEST_CASE("Bunny constants are pinned to spec values", "[bunny][constants]") {
REQUIRE(BUNNY_COUNT_MIN == 1);
REQUIRE(BUNNY_COUNT_MAX == 2);
REQUIRE(BUNNY_HOP_SPEED_MIN == Approx(22.0));
REQUIRE(BUNNY_HOP_SPEED_MAX == Approx(38.0));
REQUIRE(BUNNY_BODY_RADIUS == Approx(8.0));
REQUIRE(BUNNY_BODY_HEIGHT == Approx(6.5));
REQUIRE(BUNNY_HEAD_RADIUS == Approx(4.2));
REQUIRE(BUNNY_EAR_HEIGHT == Approx(9.0));
REQUIRE(BUNNY_EAR_WIDTH == Approx(2.2));
REQUIRE(BUNNY_EAR_SPACING == Approx(3.0));
REQUIRE(BUNNY_LEG_LENGTH == Approx(4.0));
REQUIRE(BUNNY_TAIL_RADIUS == Approx(2.4));
REQUIRE(BUNNY_BODY_COLOR == 0xFF8A6A4Au);
REQUIRE(BUNNY_BELLY_COLOR == 0xFFC4A98Du);
REQUIRE(BUNNY_EAR_COLOR == 0xFF8A6A4Au);
REQUIRE(BUNNY_EAR_INNER_COLOR == 0xFFD9A0A0u);
REQUIRE(BUNNY_TAIL_COLOR == 0xFFF7F4EBu);
REQUIRE(BUNNY_EYE_COLOR == 0xFF1A1208u);
REQUIRE(BUNNY_NOSE_COLOR == 0xFF8A4040u);
REQUIRE(BUNNY_STATE_HOPPING == 0);
REQUIRE(BUNNY_STATE_GRAZING == 1);
REQUIRE(BUNNY_STATE_IDLE == 2);
REQUIRE(BUNNY_STATE_SLEEPING == 3);
REQUIRE(BUNNY_STATE_STARTLED == 4);
REQUIRE(BUNNY_HOP_DURATION == Approx(0.40));
REQUIRE(BUNNY_HOP_HEIGHT == Approx(8.0));
REQUIRE(BUNNY_HOP_GAP_MIN == Approx(0.05));
REQUIRE(BUNNY_HOP_GAP_MAX == Approx(0.20));
REQUIRE(BUNNY_GRAZE_DURATION_MIN == Approx(2.5));
REQUIRE(BUNNY_GRAZE_DURATION_MAX == Approx(4.5));
REQUIRE(BUNNY_IDLE_DURATION_MIN == Approx(2.0));
REQUIRE(BUNNY_IDLE_DURATION_MAX == Approx(4.0));
REQUIRE(BUNNY_SLEEP_DURATION_MIN == Approx(6.0));
REQUIRE(BUNNY_SLEEP_DURATION_MAX == Approx(12.0));
REQUIRE(BUNNY_GRAZE_PROBABILITY == Approx(0.55));
REQUIRE(BUNNY_IDLE_PROBABILITY == Approx(0.30));
REQUIRE(BUNNY_SLEEP_PROB == Approx(0.05));
REQUIRE(BUNNY_STARTLE_RADIUS == Approx(90.0));
REQUIRE(BUNNY_STARTLE_BOOST == Approx(2.0));
REQUIRE(BUNNY_STARTLE_HOP_HEIGHT == Approx(14.0));
REQUIRE(BUNNY_STARTLE_DURATION == Approx(3.0));
REQUIRE(BUNNY_NOSE_TWITCH_FREQ == Approx(6.0));
REQUIRE(BUNNY_NOSE_TWITCH_AMP == Approx(0.5));
REQUIRE(BUNNY_EAR_WIGGLE_FREQ == Approx(1.2));
REQUIRE(BUNNY_EAR_WIGGLE_AMP == Approx(0.20));
REQUIRE(BUNNY_ZZZ_CYCLE_SEC == Approx(SHEEP_ZZZ_CYCLE_SEC));
REQUIRE(BUNNY_ZZZ_RISE == Approx(SHEEP_ZZZ_RISE * 0.7));
REQUIRE(BUNNY_ZZZ_SIZE_START == Approx(SHEEP_ZZZ_SIZE_START * 0.7));
REQUIRE(BUNNY_ZZZ_SIZE_END == Approx(SHEEP_ZZZ_SIZE_END * 0.7));
REQUIRE(sizeof(BUNNY_NAME_POOL) / sizeof(BUNNY_NAME_POOL[0]) == 12);
REQUIRE(std::wcscmp(BUNNY_NAME_POOL[0], L"Clover") == 0);
REQUIRE(std::wcscmp(BUNNY_NAME_POOL[11], L"Snowdrop") == 0);
}
TEST_CASE("Grass generation produces bunny count in range", "[bunny][gen]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = build_grass_sim(seed);
const int bunnies = count_kind(sim, EntityKind::Bunny);
REQUIRE(bunnies >= BUNNY_COUNT_MIN);
REQUIRE(bunnies <= BUNNY_COUNT_MAX);
}
}
TEST_CASE("Bunnies are Grass scene only", "[bunny][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
sim_set_scene(sim, Scene::Winter);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
sim_set_critter(sim, CritterKind::Bunny);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
}
TEST_CASE("Generated bunnies have speed range", "[bunny][gen]") {
Sim sim = build_grass_sim();
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Bunny) continue;
REQUIRE(std::abs(e.vx) >= BUNNY_HOP_SPEED_MIN);
REQUIRE(std::abs(e.vx) < BUNNY_HOP_SPEED_MAX);
REQUIRE(e.rotationSpeed == Approx(std::abs(e.vx)));
}
}
TEST_CASE("Generated bunnies have names in pool", "[bunny][gen]") {
Sim sim = build_grass_sim();
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Bunny) continue;
REQUIRE(bunny_name_in_pool(e));
}
}
TEST_CASE("Bunny PRNG draw order follows sheep and cats", "[bunny][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = build_grass_sim();
const int sheepCount = prng_count(side, SHEEP_COUNT_MIN, SHEEP_COUNT_MAX);
advance_sheep(side, sheepCount);
const int catCount = prng_count(side, CAT_COUNT_MIN, CAT_COUNT_MAX);
advance_cats(side, catCount);
const int bunnyCount = prng_count(side, BUNNY_COUNT_MIN, BUNNY_COUNT_MAX);
REQUIRE(count_kind(sim, EntityKind::Bunny) == bunnyCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Bunny) continue;
const double margin = BUNNY_BODY_RADIUS + 8.0;
const double xFrac = prng_uniform(side, 0.0, 1.0);
const double expectedX = margin + xFrac * (Monitor1920 - 2.0 * margin);
const uint64_t vxSign = prng_next_u64(side) & 1ull;
const double expectedDir = vxSign != 0ull ? 1.0 : -1.0;
const double expectedSpeed = prng_uniform(side, BUNNY_HOP_SPEED_MIN, BUNNY_HOP_SPEED_MAX);
const uint8_t expectedName = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(sizeof(BUNNY_NAME_POOL) / sizeof(BUNNY_NAME_POOL[0]))));
REQUIRE(e.x == Approx(expectedX));
REQUIRE(e.vx == Approx(expectedDir * expectedSpeed));
REQUIRE(e.nameIndex == expectedName);
++seen;
}
REQUIRE(seen == bunnyCount);
}
TEST_CASE("Bunny edge bounce flips direction", "[bunny][motion]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim.currentScene = Scene::Desert;
sim.entities.clear();
Entity e = bunny_entity(Monitor1920 - (BUNNY_BODY_RADIUS + 2.0) + 0.1, BUNNY_HOP_SPEED_MIN);
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities.front().vx < 0.0);
}
TEST_CASE("Bunny startle radius hops away and outside click does nothing", "[bunny][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim.entities.clear();
Entity e = bunny_entity(500.0, -BUNNY_HOP_SPEED_MIN);
e.state = BUNNY_STATE_IDLE;
e.stateTimer = 3.0;
sim.entities.push_back(e);
sim_apply_click(sim, click_event(500.0 - 20.0, e.y));
REQUIRE(sim.entities.front().state == BUNNY_STATE_STARTLED);
REQUIRE(sim.entities.front().vx > 0.0);
REQUIRE(sim.entities.front().stateTimer == Approx(BUNNY_STARTLE_DURATION));
Entity after = sim.entities.front();
const double vxBefore = after.vx;
const uint8_t stateBefore = after.state;
sim_apply_click(sim, click_event(after.x + BUNNY_STARTLE_RADIUS + 10.0, after.y));
REQUIRE(sim.entities.front().state == stateBefore);
REQUIRE(sim.entities.front().vx == Approx(vxBefore));
}
TEST_CASE("Bunny wakes from sleep on startle", "[bunny][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim.entities.clear();
Entity e = bunny_entity(500.0, BUNNY_HOP_SPEED_MIN);
e.state = BUNNY_STATE_SLEEPING;
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_apply_click(sim, click_event(e.x + 10.0, e.y));
REQUIRE(sim.entities.front().state == BUNNY_STATE_STARTLED);
REQUIRE(sim.entities.front().state != BUNNY_STATE_SLEEPING);
REQUIRE(sim.entities.front().vx < 0.0);
}
TEST_CASE("Bunny hop arc is bounded", "[bunny][motion]") {
REQUIRE(bunny_hop_y_offset(0.0, false) == Approx(0.0));
REQUIRE(bunny_hop_y_offset(BUNNY_HOP_DURATION, false) == Approx(0.0).margin(1e-12));
const double peak = bunny_hop_y_offset(BUNNY_HOP_DURATION * 0.5, false);
REQUIRE(peak > 0.0);
REQUIRE(peak <= BUNNY_HOP_HEIGHT);
}
TEST_CASE("Bunny state transition probabilities are stable", "[bunny][state]") {
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
constexpr int N = 10000;
int graze = 0;
int idle = 0;
int sleep = 0;
for (int i = 0; i < N; ++i) {
const uint8_t state = bunny_choose_rest_state(p);
if (state == BUNNY_STATE_GRAZING) ++graze;
else if (state == BUNNY_STATE_IDLE) ++idle;
else if (state == BUNNY_STATE_SLEEPING) ++sleep;
}
const double sleepProb = BUNNY_SLEEP_PROB;
const double activeWeight = BUNNY_GRAZE_PROBABILITY + BUNNY_IDLE_PROBABILITY;
const double expectedGraze = (1.0 - sleepProb) * BUNNY_GRAZE_PROBABILITY / activeWeight;
const double expectedIdle = (1.0 - sleepProb) * BUNNY_IDLE_PROBABILITY / activeWeight;
REQUIRE(static_cast<double>(sleep) / N == Approx(sleepProb).margin(0.02));
REQUIRE(static_cast<double>(graze) / N == Approx(expectedGraze).margin(0.02));
REQUIRE(static_cast<double>(idle) / N == Approx(expectedIdle).margin(0.02));
}
TEST_CASE("Bunny sleep probability is stable", "[bunny][state]") {
constexpr int N = 20000;
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ 0x1234ull);
int sleep = 0;
for (int i = 0; i < N; ++i) {
if (bunny_choose_rest_state(p) == BUNNY_STATE_SLEEPING) ++sleep;
}
REQUIRE(static_cast<double>(sleep) / N == Approx(BUNNY_SLEEP_PROB).margin(0.02));
}

View File

@@ -1,180 +0,0 @@
// butterfly_tests.cpp - §17.6 ambient Butterfly tests.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
Sim build_grass_sim(uint64_t seed = CANONICAL_TEST_SEED) {
Sim sim = sim_init(seed, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Grass);
return sim;
}
int prng_count(Prng& side, int minCount, int maxCount) {
const double draw = prng_uniform(side, static_cast<double>(minCount), static_cast<double>(maxCount + 1));
int count = static_cast<int>(std::floor(draw));
if (count < minCount) count = minCount;
if (count > maxCount) count = maxCount;
return count;
}
const Entity* first_butterfly(const Sim& sim) {
for (const Entity& e : sim.entities) if (e.kind == EntityKind::Butterfly) return &e;
return nullptr;
}
} // namespace
TEST_CASE("Butterfly constants are pinned to spec values", "[butterfly][constants]") {
REQUIRE(BUTTERFLY_COUNT_MIN == 2);
REQUIRE(BUTTERFLY_COUNT_MAX == 4);
REQUIRE(BUTTERFLY_SPEED_MIN == Approx(18.0));
REQUIRE(BUTTERFLY_SPEED_MAX == Approx(32.0));
REQUIRE(BUTTERFLY_BODY_LENGTH == Approx(2.4));
REQUIRE(BUTTERFLY_WING_RADIUS == Approx(3.5));
REQUIRE(BUTTERFLY_WING_OFFSET == Approx(2.2));
REQUIRE(BUTTERFLY_FLUTTER_FREQ == Approx(16.0));
REQUIRE(BUTTERFLY_FLUTTER_MIN_SCALE == Approx(0.20));
REQUIRE(BUTTERFLY_MEANDER_FREQ_Y == Approx(0.8));
REQUIRE(BUTTERFLY_MEANDER_AMP_Y == Approx(16.0));
REQUIRE(BUTTERFLY_MEANDER_FREQ_X == Approx(0.5));
REQUIRE(BUTTERFLY_MEANDER_AMP_X == Approx(0.4));
REQUIRE(BUTTERFLY_ALTITUDE_MIN == Approx(18.0));
REQUIRE(BUTTERFLY_ALTITUDE_MAX == Approx(70.0));
REQUIRE(BUTTERFLY_BODY_COLOR == 0xFF2A2018u);
REQUIRE(BUTTERFLY_COLOR_COUNT == 5);
REQUIRE(BUTTERFLY_PRNG_SALT == 0xB07DEF1E0001ull);
}
TEST_CASE("Grass generation produces butterfly count in range", "[butterfly][gen]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = build_grass_sim(seed);
REQUIRE(count_kind(sim, EntityKind::Butterfly) >= BUTTERFLY_COUNT_MIN);
REQUIRE(count_kind(sim, EntityKind::Butterfly) <= BUTTERFLY_COUNT_MAX);
}
}
TEST_CASE("Butterflies are Grass scene only", "[butterfly][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE(count_kind(sim, EntityKind::Butterfly) == 0);
sim_set_scene(sim, Scene::Winter);
REQUIRE(count_kind(sim, EntityKind::Butterfly) == 0);
}
TEST_CASE("Generated butterflies have speed altitude and color ranges", "[butterfly][gen]") {
Sim sim = build_grass_sim();
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Butterfly) continue;
REQUIRE(e.baseSpeed >= BUTTERFLY_SPEED_MIN);
REQUIRE(e.baseSpeed < BUTTERFLY_SPEED_MAX);
REQUIRE(e.altitudeAnchor >= BUTTERFLY_ALTITUDE_MIN);
REQUIRE(e.altitudeAnchor < BUTTERFLY_ALTITUDE_MAX);
REQUIRE(e.colorVariant < BUTTERFLY_COLOR_COUNT);
}
}
TEST_CASE("Butterfly PRNG draw order matches side stream", "[butterfly][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ BUTTERFLY_PRNG_SALT);
Sim sim = build_grass_sim();
const int expectedCount = prng_count(side, BUTTERFLY_COUNT_MIN, BUTTERFLY_COUNT_MAX);
REQUIRE(count_kind(sim, EntityKind::Butterfly) == expectedCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Butterfly) continue;
const double xFrac = prng_uniform(side, 0.0, 1.0);
const double yFrac = prng_uniform(side, 0.0, 1.0);
const uint64_t vxSign = prng_next_u64(side) & 1ull;
const double expectedDir = vxSign != 0ull ? 1.0 : -1.0;
const double expectedSpeed = prng_uniform(side, BUTTERFLY_SPEED_MIN, BUTTERFLY_SPEED_MAX);
const uint8_t expectedColor = static_cast<uint8_t>(prng_index(side, static_cast<uint32_t>(BUTTERFLY_COLOR_COUNT)));
const double expectedPhaseY = prng_uniform(side, 0.0, 2.0 * 3.14159265358979323846);
const double expectedPhaseX = prng_uniform(side, 0.0, 2.0 * 3.14159265358979323846);
const double expectedAltitude = BUTTERFLY_ALTITUDE_MIN + yFrac * (BUTTERFLY_ALTITUDE_MAX - BUTTERFLY_ALTITUDE_MIN);
const double expectedVx = expectedDir * expectedSpeed * (1.0 + BUTTERFLY_MEANDER_AMP_X * std::sin(expectedPhaseX));
REQUIRE(e.x == Approx(xFrac * Monitor1920));
REQUIRE(e.altitudeAnchor == Approx(expectedAltitude));
REQUIRE(e.baseSpeed == Approx(expectedSpeed));
REQUIRE(e.vx == Approx(expectedVx));
REQUIRE(e.colorVariant == expectedColor);
REQUIRE(e.phaseY == Approx(expectedPhaseY));
REQUIRE(e.phaseX == Approx(expectedPhaseX));
++seen;
}
REQUIRE(seen == expectedCount);
}
TEST_CASE("Butterfly edge wrap preserves altitude anchor", "[butterfly][motion]") {
Sim sim = build_grass_sim();
auto it = std::find_if(sim.entities.begin(), sim.entities.end(), [](const Entity& e) { return e.kind == EntityKind::Butterfly; });
REQUIRE(it != sim.entities.end());
const double margin = BUTTERFLY_WING_OFFSET + BUTTERFLY_WING_RADIUS;
it->x = Monitor1920 + margin + 1.0;
it->vx = std::abs(it->vx);
const double altitude = it->altitudeAnchor;
sim.currentScene = Scene::Desert;
sim_tick_entities(sim, 0.016);
REQUIRE(it->x == Approx(-margin));
REQUIRE(it->altitudeAnchor == Approx(altitude));
}
TEST_CASE("Butterflies do not interact with cuts or pets", "[butterfly][interaction]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim.entities.clear();
Entity butterfly{};
butterfly.kind = EntityKind::Butterfly;
butterfly.x = 500.0;
butterfly.y = sim.windowHeight - STRIP_HEIGHT - 5.0;
butterfly.vx = BUTTERFLY_SPEED_MIN;
butterfly.baseSpeed = BUTTERFLY_SPEED_MIN;
butterfly.altitudeAnchor = BUTTERFLY_ALTITUDE_MIN;
butterfly.lifetime = -1.0;
sim.entities.push_back(butterfly);
Entity sheep{};
sheep.kind = EntityKind::Sheep;
sheep.x = butterfly.x;
sheep.y = sim.windowHeight - SHEEP_BODY_HEIGHT - SHEEP_LEG_LENGTH;
sheep.vx = SHEEP_WALK_SPEED_MIN;
sheep.state = SHEEP_STATE_WALKING;
sheep.stateTimer = 10.0;
sim.entities.push_back(sheep);
InputEvent ev{};
ev.type = EventType::Click;
ev.x = butterfly.x;
ev.y = butterfly.y;
sim_apply_click(sim, ev);
REQUIRE(sim.entities[0].kind == EntityKind::Butterfly);
REQUIRE(sim.entities[0].baseSpeed == Approx(BUTTERFLY_SPEED_MIN));
REQUIRE(sim.entities[1].state == SHEEP_STATE_WALKING);
for (const Blade& b : sim.blades) REQUIRE(b.cutAnimStart < 0.0);
}
TEST_CASE("Butterfly wing scale stays within flutter bounds", "[butterfly][render]") {
for (int i = 0; i < 200; ++i) {
const double t = i * 0.05;
const double scale = butterfly_wing_scale(t, 1.3);
REQUIRE(scale >= BUTTERFLY_FLUTTER_MIN_SCALE);
REQUIRE(scale <= 1.0);
}
}

View File

@@ -1,158 +0,0 @@
// cat_coat_tests.cpp
//
// §17 Cat coat palette and deterministic coat variant tests. Mirrors Win2D CatCoatTests.cs.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <cmath>
#include <cstdint>
using namespace desktopgrass;
namespace {
int count_kind(const Sim& sim, EntityKind kind) {
int n = 0;
for (const Entity& e : sim.entities) if (e.kind == kind) ++n;
return n;
}
constexpr CatCoatPalette EXPECTED_CAT_COATS[CAT_COAT_VARIANT_COUNT] = {
{ 0xFF6B6259u, 0xFF3D3733u, 0xFF6B6259u, 0xFF3D3733u, 0xFF1A1614u },
{ 0xFFD89A6Fu, 0xFFA56B40u, 0xFFD89A6Fu, 0xFFA56B40u, 0xFF2B1A0Eu },
{ 0xFF2A2522u, 0xFF140F0Cu, 0xFF2A2522u, 0xFF140F0Cu, 0xFFD9B85Bu },
{ 0xFFEDE9E1u, 0xFFBDB7ABu, 0xFFEDE9E1u, 0xFFBDB7ABu, 0xFF1F1817u },
{ 0xFF7A5F3Cu, 0xFF4E3F26u, 0xFF7A5F3Cu, 0xFF4E3F26u, 0xFF1A1108u },
{ 0xFFC9B898u, 0xFF8E7F6Bu, 0xFFC9B898u, 0xFF8E7F6Bu, 0xFF2E251Du },
};
uint8_t next_cat_coat_after_prefix(Prng& side) {
(void)prng_uniform(side, CAT_BODY_RADIUS + 8.0, 1920.0 - (CAT_BODY_RADIUS + 8.0));
(void)prng_uniform(side, CAT_WALK_SPEED_MIN, CAT_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, CAT_WALK_DURATION_MIN, CAT_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0])));
return static_cast<uint8_t>(prng_index(side, static_cast<uint32_t>(CAT_COAT_VARIANT_COUNT)));
}
} // namespace
TEST_CASE("Cat coat variant count is pinned", "[cat][coat][constants]") {
REQUIRE(CAT_COAT_VARIANT_COUNT == 6);
}
TEST_CASE("Cat coat palette zero matches backward-compatible aliases", "[cat][coat][constants]") {
REQUIRE(CAT_COAT_PALETTES[0].body == CAT_BODY_COLOR);
REQUIRE(CAT_COAT_PALETTES[0].leg == CAT_LEG_COLOR);
REQUIRE(CAT_COAT_PALETTES[0].face == CAT_FACE_COLOR);
REQUIRE(CAT_COAT_PALETTES[0].ear == CAT_EAR_COLOR);
REQUIRE(CAT_COAT_PALETTES[0].ink == CAT_INK_COLOR);
}
TEST_CASE("All cat coat palettes are pinned", "[cat][coat][constants]") {
for (int i = 0; i < CAT_COAT_VARIANT_COUNT; ++i) {
CAPTURE(i);
REQUIRE(CAT_COAT_PALETTES[i].body == EXPECTED_CAT_COATS[i].body);
REQUIRE(CAT_COAT_PALETTES[i].leg == EXPECTED_CAT_COATS[i].leg);
REQUIRE(CAT_COAT_PALETTES[i].face == EXPECTED_CAT_COATS[i].face);
REQUIRE(CAT_COAT_PALETTES[i].ear == EXPECTED_CAT_COATS[i].ear);
REQUIRE(CAT_COAT_PALETTES[i].ink == EXPECTED_CAT_COATS[i].ink);
}
}
TEST_CASE("Cat coat body colors are distinct", "[cat][coat][constants]") {
for (int i = 0; i < CAT_COAT_VARIANT_COUNT; ++i) {
for (int j = i + 1; j < CAT_COAT_VARIANT_COUNT; ++j) {
CAPTURE(i);
CAPTURE(j);
REQUIRE(CAT_COAT_PALETTES[i].body != CAT_COAT_PALETTES[j].body);
}
}
}
TEST_CASE("Canonical cat flock pins deterministic coat variants", "[cat][coat][gen]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
const uint8_t expectedCoats[] = { 1 };
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
REQUIRE(seen < static_cast<int>(sizeof(expectedCoats) / sizeof(expectedCoats[0])));
REQUIRE(e.coatVariantIndex == expectedCoats[seen]);
++seen;
}
REQUIRE(seen == static_cast<int>(sizeof(expectedCoats) / sizeof(expectedCoats[0])));
}
TEST_CASE("Cat coat PRNG draw follows nameIndex", "[cat][coat][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
const double countDraw = prng_uniform(side, CAT_COUNT_MIN, CAT_COUNT_MAX + 1);
int expectedCount = static_cast<int>(std::floor(countDraw));
if (expectedCount < CAT_COUNT_MIN) expectedCount = CAT_COUNT_MIN;
if (expectedCount > CAT_COUNT_MAX) expectedCount = CAT_COUNT_MAX;
REQUIRE(count_kind(sim, EntityKind::Cat) == expectedCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
const uint8_t expectedCoat = next_cat_coat_after_prefix(side);
REQUIRE(e.coatVariantIndex == expectedCoat);
++seen;
}
REQUIRE(seen == expectedCount);
}
TEST_CASE("Generated cat coats always stay within palette range", "[cat][coat][gen]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = sim_init(seed, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
REQUIRE(e.coatVariantIndex < CAT_COAT_VARIANT_COUNT);
++seen;
}
REQUIRE(seen >= CAT_COUNT_MIN);
REQUIRE(seen <= CAT_COUNT_MAX);
}
}
TEST_CASE("Sheep keep default coat variant zero", "[cat][coat][sheep]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
REQUIRE(count_kind(sim, EntityKind::Sheep) >= SHEEP_COUNT_MIN);
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Sheep) continue;
REQUIRE(e.coatVariantIndex == 0);
}
}
TEST_CASE("Fixed cat count coat PRNG skips only the count draw", "[cat][coat][count][prng]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
sim_set_critter_count(sim, 3);
REQUIRE(count_kind(sim, EntityKind::Cat) == 3);
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
const uint8_t expectedCoat = next_cat_coat_after_prefix(side);
REQUIRE(e.coatVariantIndex == expectedCoat);
++seen;
}
REQUIRE(seen == 3);
}

View File

@@ -1,332 +0,0 @@
// cat_tests.cpp
//
// §17 Cat critter tests. Mirrors Win2D CatTests.cs.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
using namespace desktopgrass;
namespace {
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
Entity* first_kind(Sim& sim, EntityKind kind) {
for (Entity& e : sim.entities) if (e.kind == kind) return &e;
return nullptr;
}
const Entity* first_kind(const Sim& sim, EntityKind kind) {
for (const Entity& e : sim.entities) if (e.kind == kind) return &e;
return nullptr;
}
void keep_first_cat_only(Sim& sim) {
Entity* cat = first_kind(sim, EntityKind::Cat);
REQUIRE(cat != nullptr);
const Entity copy = *cat;
sim.entities.clear();
sim.entities.push_back(copy);
}
InputEvent click_event(double x, double y) {
InputEvent ev{};
ev.type = EventType::Click;
ev.x = x;
ev.y = y;
ev.time = 0.0;
return ev;
}
} // namespace
TEST_CASE("CritterKind::Cat and CRITTER_COUNT are pinned", "[cat][enum]") {
REQUIRE(static_cast<int>(CritterKind::None) == 0);
REQUIRE(static_cast<int>(CritterKind::Sheep) == 1);
REQUIRE(static_cast<int>(CritterKind::Cat) == 2);
REQUIRE(static_cast<int>(CritterKind::Bunny) == 3);
REQUIRE(CRITTER_COUNT == 4);
REQUIRE(CRITTER_DEFAULT == CritterKind::None);
}
TEST_CASE("EntityKind::Cat is pinned", "[cat][enum]") {
REQUIRE(static_cast<int>(EntityKind::None) == 0);
REQUIRE(static_cast<int>(EntityKind::Tumbleweed) == 1);
REQUIRE(static_cast<int>(EntityKind::Snowflake) == 2);
REQUIRE(static_cast<int>(EntityKind::Sheep) == 3);
REQUIRE(static_cast<int>(EntityKind::Cat) == 4);
}
TEST_CASE("Cat constants are pinned to spec values", "[cat][constants]") {
REQUIRE(CAT_COUNT_MIN == 1);
REQUIRE(CAT_COUNT_MAX == 2);
REQUIRE(CAT_WALK_SPEED_MIN == Approx(10.0));
REQUIRE(CAT_WALK_SPEED_MAX == Approx(22.0));
REQUIRE(CAT_POUNCE_SPEED == Approx(60.0));
REQUIRE(CAT_BODY_RADIUS == Approx(11.0));
REQUIRE(CAT_BODY_HEIGHT == Approx(7.0));
REQUIRE(CAT_HEAD_RADIUS == Approx(4.5));
REQUIRE(CAT_LEG_LENGTH == Approx(5.0));
REQUIRE(CAT_TAIL_LENGTH == Approx(13.0));
REQUIRE(CAT_TAIL_THICKNESS == Approx(1.6));
REQUIRE(CAT_EAR_HEIGHT == Approx(4.5));
REQUIRE(CAT_BODY_COLOR == 0xFF6B6259u);
REQUIRE(CAT_LEG_COLOR == 0xFF3D3733u);
REQUIRE(CAT_FACE_COLOR == 0xFF6B6259u);
REQUIRE(CAT_EAR_COLOR == 0xFF3D3733u);
REQUIRE(CAT_INK_COLOR == 0xFF1A1614u);
REQUIRE(CAT_WALK_PERIOD == Approx(0.50));
REQUIRE(CAT_LEG_CYCLE_AMP == Approx(1.6));
REQUIRE(CAT_HEAD_BOB_AMP == Approx(0.4));
REQUIRE(CAT_TAIL_SWAY_FREQ == Approx(1.2));
REQUIRE(CAT_TAIL_SWAY_AMP == Approx(0.35));
REQUIRE(CAT_STATE_WALKING == SHEEP_STATE_WALKING);
REQUIRE(CAT_STATE_IDLE == SHEEP_STATE_IDLE);
REQUIRE(CAT_STATE_SLEEPING == SHEEP_STATE_SLEEPING);
REQUIRE(CAT_STATE_POUNCING == SHEEP_STATE_HOPPING);
REQUIRE(CAT_WALK_DURATION_MIN == Approx(6.0));
REQUIRE(CAT_WALK_DURATION_MAX == Approx(10.0));
REQUIRE(CAT_IDLE_DURATION_MIN == Approx(4.0));
REQUIRE(CAT_IDLE_DURATION_MAX == Approx(8.0));
REQUIRE(CAT_SLEEP_DURATION_MIN == Approx(20.0));
REQUIRE(CAT_SLEEP_DURATION_MAX == Approx(40.0));
REQUIRE(CAT_POUNCE_DURATION == Approx(0.45));
REQUIRE(CAT_IDLE_PROBABILITY == Approx(0.65));
REQUIRE(CAT_SLEEP_PROBABILITY == Approx(0.30));
REQUIRE(CAT_SLEEP_FROM_IDLE_PROB == Approx(0.50));
REQUIRE(CAT_POUNCE_RADIUS == Approx(80.0));
REQUIRE(CAT_POUNCE_HEIGHT == Approx(9.0));
REQUIRE(CAT_CURIOUS_RADIUS == Approx(100.0));
REQUIRE(CAT_CURIOUS_HEAD_TURN_MAX == Approx(0.7));
}
TEST_CASE("sim_init defaults to None and does not generate cats until selected", "[cat][init]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
REQUIRE(sim.currentCritter == CritterKind::None);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
sim_set_critter(sim, CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Cat) >= CAT_COUNT_MIN);
}
TEST_CASE("sim_set_critter(Cat) produces deterministic cats", "[cat][gen]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
REQUIRE(sim.currentCritter == CritterKind::Cat);
const int k = count_kind(sim, EntityKind::Cat);
REQUIRE(k >= CAT_COUNT_MIN);
REQUIRE(k <= CAT_COUNT_MAX);
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
REQUIRE(e.state == CAT_STATE_WALKING);
REQUIRE(e.stateTimer >= CAT_WALK_DURATION_MIN);
REQUIRE(e.stateTimer < CAT_WALK_DURATION_MAX);
REQUIRE(std::fabs(e.vx) >= CAT_WALK_SPEED_MIN);
REQUIRE(std::fabs(e.vx) < CAT_WALK_SPEED_MAX);
const double margin = e.size + 8.0;
REQUIRE(e.x >= margin);
REQUIRE(e.x <= sim.monitorWidth - margin);
REQUIRE(e.y == Approx(sim.windowHeight - CAT_BODY_HEIGHT - CAT_LEG_LENGTH));
REQUIRE(e.size == Approx(CAT_BODY_RADIUS));
REQUIRE(e.lifetime < 0.0);
REQUIRE(e.nameIndex < sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0]));
REQUIRE(e.coatVariantIndex < CAT_COAT_VARIANT_COUNT);
}
}
TEST_CASE("Cat PRNG draw order matches a side stream", "[cat][prng]") {
// count, then per-cat: x, speed, dir-coin, seed, stateTimer, nameIndex, coatVariantIndex
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
const double countDraw = prng_uniform(side, CAT_COUNT_MIN, CAT_COUNT_MAX + 1);
int expectedCount = static_cast<int>(std::floor(countDraw));
if (expectedCount < CAT_COUNT_MIN) expectedCount = CAT_COUNT_MIN;
if (expectedCount > CAT_COUNT_MAX) expectedCount = CAT_COUNT_MAX;
REQUIRE(count_kind(sim, EntityKind::Cat) == expectedCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
const double margin = CAT_BODY_RADIUS + 8.0;
const double expectedX = prng_uniform(side, margin, 1920.0 - margin);
const double expectedSpeed = prng_uniform(side, CAT_WALK_SPEED_MIN, CAT_WALK_SPEED_MAX);
const double dirCoin = prng_uniform(side, 0.0, 1.0);
const double expectedDir = (dirCoin < 0.5) ? -1.0 : 1.0;
const uint32_t expectedSeed = prng_next_u32(side);
const double expectedTimer = prng_uniform(side, CAT_WALK_DURATION_MIN, CAT_WALK_DURATION_MAX);
const uint8_t expectedNameIndex = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0]))));
const uint8_t expectedCoatVariantIndex = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(CAT_COAT_VARIANT_COUNT)));
REQUIRE(e.x == Approx(expectedX));
REQUIRE(e.vx == Approx(expectedSpeed * expectedDir));
REQUIRE(e.seed == expectedSeed);
REQUIRE(e.stateTimer == Approx(expectedTimer));
REQUIRE(e.nameIndex == expectedNameIndex);
REQUIRE(e.coatVariantIndex == expectedCoatVariantIndex);
++seen;
}
REQUIRE(seen == expectedCount);
}
TEST_CASE("sim_set_critter(None) clears ambient cats", "[cat][toggle]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Cat) >= CAT_COUNT_MIN);
sim_set_critter(sim, CritterKind::None);
REQUIRE(sim.currentCritter == CritterKind::None);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
}
TEST_CASE("Switching between critter species replaces the previous species", "[cat][toggle]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Cat) >= CAT_COUNT_MIN);
REQUIRE(count_kind(sim, EntityKind::Sheep) == 0);
sim_set_critter(sim, CritterKind::Sheep);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
REQUIRE(count_kind(sim, EntityKind::Sheep) >= SHEEP_COUNT_MIN);
sim_set_critter(sim, CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Sheep) == 0);
REQUIRE(count_kind(sim, EntityKind::Cat) >= CAT_COUNT_MIN);
}
TEST_CASE("sim_set_scene gates active Cat to Grass", "[cat][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
const int catsGrass = count_kind(sim, EntityKind::Cat);
REQUIRE(catsGrass >= CAT_COUNT_MIN);
sim_set_scene(sim, Scene::Desert);
REQUIRE(sim.currentCritter == CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.currentCritter == CritterKind::Cat);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
sim_set_scene(sim, Scene::Grass);
REQUIRE(count_kind(sim, EntityKind::Cat) == catsGrass);
}
TEST_CASE("Click within CAT_POUNCE_RADIUS pounces toward the click", "[cat][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
keep_first_cat_only(sim);
Entity& cat = sim.entities.front();
cat.x = 500.0;
cat.vx = -CAT_WALK_SPEED_MIN;
cat.age = 5.0;
sim_apply_click(sim, click_event(cat.x + 16.0, sim.windowHeight - 20.0));
const Entity& after = sim.entities.front();
REQUIRE(after.state == CAT_STATE_POUNCING);
REQUIRE(after.stateTimer == Approx(CAT_POUNCE_DURATION));
REQUIRE(after.age == Approx(0.0));
REQUIRE(after.vx == Approx(CAT_POUNCE_SPEED));
}
TEST_CASE("Click outside CAT_POUNCE_RADIUS leaves cat alone", "[cat][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
keep_first_cat_only(sim);
Entity& cat = sim.entities.front();
cat.x = 500.0;
cat.vx = -CAT_WALK_SPEED_MIN;
const uint8_t stateBefore = cat.state;
const double vxBefore = cat.vx;
sim_apply_click(sim, click_event(cat.x + CAT_POUNCE_RADIUS + 5.0, sim.windowHeight - 20.0));
const Entity& after = sim.entities.front();
REQUIRE(after.state == stateBefore);
REQUIRE(after.vx == Approx(vxBefore));
}
TEST_CASE("Cats do not greet other cats", "[cat][greeting]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
keep_first_cat_only(sim);
Entity first = sim.entities.front();
first.x = 400.0;
first.vx = CAT_WALK_SPEED_MIN;
first.state = CAT_STATE_WALKING;
first.stateTimer = 10.0;
first.age = SHEEP_GREET_MIN_AGE + 1.0;
Entity second = first;
second.x = first.x + 20.0;
second.vx = -CAT_WALK_SPEED_MIN;
sim.entities.clear();
sim.entities.push_back(first);
sim.entities.push_back(second);
sim_tick_entities(sim, 0.016);
REQUIRE(count_kind(sim, EntityKind::Cat) == 2);
for (const Entity& e : sim.entities) {
if (e.kind == EntityKind::Cat) REQUIRE(e.state != SHEEP_STATE_GREETING);
}
}
TEST_CASE("Cats do not greet sheep", "[cat][greeting]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Cat);
keep_first_cat_only(sim);
Entity cat = sim.entities.front();
cat.x = 400.0;
cat.vx = CAT_WALK_SPEED_MIN;
cat.state = CAT_STATE_WALKING;
cat.stateTimer = 10.0;
cat.age = SHEEP_GREET_MIN_AGE + 1.0;
Entity sheep{};
sheep.kind = EntityKind::Sheep;
sheep.size = SHEEP_BODY_RADIUS;
sheep.x = cat.x + 20.0;
sheep.y = sim.windowHeight - SHEEP_BODY_HEIGHT - SHEEP_LEG_LENGTH;
sheep.vx = -SHEEP_WALK_SPEED_MIN;
sheep.age = SHEEP_GREET_MIN_AGE + 1.0;
sheep.lifetime = -1.0;
sheep.state = SHEEP_STATE_WALKING;
sheep.stateTimer = 10.0;
sim.entities.clear();
sim.entities.push_back(cat);
sim.entities.push_back(sheep);
sim_tick_entities(sim, 0.016);
REQUIRE(count_kind(sim, EntityKind::Cat) == 1);
REQUIRE(count_kind(sim, EntityKind::Sheep) == 1);
for (const Entity& e : sim.entities) REQUIRE(e.state != SHEEP_STATE_GREETING);
}

View File

@@ -1,176 +0,0 @@
#include "../third_party/catch2/catch.hpp"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <atomic>
#include <chrono>
#include <string>
#include <thread>
namespace {
std::atomic<bool> g_probeReceivedLeftDown{false};
enum class ClickThroughResult {
Passed,
Skipped,
Failed,
};
std::wstring unique_class_name(const wchar_t* suffix) {
return std::wstring(L"DesktopGrass.Native.ClickThrough.")
+ std::to_wstring(GetCurrentProcessId()) + L"."
+ std::to_wstring(GetTickCount64()) + L"."
+ suffix;
}
LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == WM_LBUTTONDOWN) {
g_probeReceivedLeftDown.store(true, std::memory_order_release);
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
LRESULT CALLBACK OverlayWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
return DefWindowProcW(hwnd, msg, wp, lp);
}
void pump_messages_for(std::chrono::milliseconds duration) {
const auto deadline = std::chrono::steady_clock::now() + duration;
MSG msg{};
do {
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (g_probeReceivedLeftDown.load(std::memory_order_acquire)) {
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
} while (std::chrono::steady_clock::now() < deadline);
}
bool has_interactive_desktop() {
if (GetConsoleWindow() == nullptr) {
return false;
}
HDESK inputDesktop = OpenInputDesktop(0, FALSE, DESKTOP_SWITCHDESKTOP);
if (inputDesktop == nullptr) {
return false;
}
CloseDesktop(inputDesktop);
return true;
}
ClickThroughResult spawn_probe_window_and_click_through_overlay() {
if (!has_interactive_desktop()) {
return ClickThroughResult::Skipped;
}
g_probeReceivedLeftDown.store(false, std::memory_order_release);
const HINSTANCE instance = GetModuleHandleW(nullptr);
const std::wstring probeClass = unique_class_name(L"Probe");
const std::wstring overlayClass = unique_class_name(L"Overlay");
WNDCLASSEXW probeWc{};
probeWc.cbSize = sizeof(probeWc);
probeWc.lpfnWndProc = ProbeWndProc;
probeWc.hInstance = instance;
probeWc.lpszClassName = probeClass.c_str();
WNDCLASSEXW overlayWc{};
overlayWc.cbSize = sizeof(overlayWc);
overlayWc.lpfnWndProc = OverlayWndProc;
overlayWc.hInstance = instance;
overlayWc.lpszClassName = overlayClass.c_str();
if (!RegisterClassExW(&probeWc)) {
return ClickThroughResult::Failed;
}
if (!RegisterClassExW(&overlayWc)) {
UnregisterClassW(probeClass.c_str(), instance);
return ClickThroughResult::Failed;
}
const int x = GetSystemMetrics(SM_XVIRTUALSCREEN) + 96;
const int y = GetSystemMetrics(SM_YVIRTUALSCREEN) + 96;
constexpr int kWidth = 96;
constexpr int kHeight = 64;
const int clickX = x + 24;
const int clickY = y + 24;
HWND probe = CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
probeClass.c_str(), L"DesktopGrass click-through probe",
WS_POPUP | WS_VISIBLE,
x, y, kWidth, kHeight,
nullptr, nullptr, instance, nullptr);
HWND overlay = nullptr;
bool ok = probe != nullptr;
if (ok) {
SetWindowPos(probe, HWND_TOPMOST, x, y, kWidth, kHeight, SWP_SHOWWINDOW);
overlay = CreateWindowExW(
WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST |
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
overlayClass.c_str(), L"DesktopGrass click-through overlay",
WS_POPUP,
x, y, kWidth, kHeight,
nullptr, nullptr, instance, nullptr);
ok = overlay != nullptr;
}
if (ok) {
SetLayeredWindowAttributes(overlay, 0, 1, LWA_ALPHA);
ShowWindow(overlay, SW_SHOWNOACTIVATE);
SetWindowPos(overlay, HWND_TOPMOST, x, y, kWidth, kHeight,
SWP_SHOWWINDOW | SWP_NOACTIVATE);
pump_messages_for(std::chrono::milliseconds(50));
if (!SetCursorPos(clickX, clickY)) {
ok = false;
}
}
ClickThroughResult result = ClickThroughResult::Failed;
if (ok) {
INPUT inputs[2]{};
inputs[0].type = INPUT_MOUSE;
inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
inputs[1].type = INPUT_MOUSE;
inputs[1].mi.dwFlags = MOUSEEVENTF_LEFTUP;
const UINT sent = SendInput(2, inputs, sizeof(INPUT));
if (sent != 2) {
result = ClickThroughResult::Skipped;
} else {
pump_messages_for(std::chrono::milliseconds(200));
result = g_probeReceivedLeftDown.load(std::memory_order_acquire)
? ClickThroughResult::Passed
: ClickThroughResult::Failed;
}
}
if (overlay) DestroyWindow(overlay);
if (probe) DestroyWindow(probe);
UnregisterClassW(overlayClass.c_str(), instance);
UnregisterClassW(probeClass.c_str(), instance);
return result;
}
} // namespace
TEST_CASE("Overlay click-through allows input to reach windows beneath", "[smoke][input]") {
const ClickThroughResult result = spawn_probe_window_and_click_through_overlay();
if (result == ClickThroughResult::Skipped) {
WARN("Skipping click-through smoke test: requires an interactive desktop and SendInput.");
SUCCEED("Requires interactive session");
return;
}
REQUIRE(result == ClickThroughResult::Passed);
}

View File

@@ -1,154 +0,0 @@
#include "../third_party/catch2/catch.hpp"
#include "Config.h"
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
using namespace desktopgrass;
namespace {
std::filesystem::path test_config_path(const char* name) {
std::filesystem::path dir = std::filesystem::current_path()
/ ".copilot-scratch"
/ "native-config-tests"
/ name;
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir);
return dir / "config.json";
}
void write_text(const std::filesystem::path& path, const std::string& text) {
std::filesystem::create_directories(path.parent_path());
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file << text;
}
std::string read_text(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
std::ostringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
} // namespace
TEST_CASE("Config: missing file yields defaults and writes a template", "[config]") {
const std::filesystem::path path = test_config_path("missing");
REQUIRE_FALSE(std::filesystem::exists(path));
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == config::kTargetFpsDefault);
CHECK(cfg.bladeDensity == Approx(config::kBladeDensityDefault));
// A default file should have been created and be re-readable (it is JSONC).
REQUIRE(std::filesystem::exists(path));
const config::Config reread = config::LoadConfig(path.wstring());
CHECK(reread.targetFps == config::kTargetFpsDefault);
CHECK(reread.bladeDensity == Approx(config::kBladeDensityDefault));
}
TEST_CASE("Config: valid values are parsed", "[config]") {
const std::filesystem::path path = test_config_path("valid");
write_text(path, "{ \"version\": 1, \"targetFps\": 60, \"bladeDensity\": 1.5 }");
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == 60);
CHECK(cfg.bladeDensity == Approx(1.5));
}
TEST_CASE("Config: out-of-range values are clamped", "[config]") {
const std::filesystem::path path = test_config_path("clamp");
write_text(path, "{ \"targetFps\": 1000, \"bladeDensity\": 99.0 }");
config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == config::kTargetFpsMax);
CHECK(cfg.bladeDensity == Approx(config::kBladeDensityMax));
write_text(path, "{ \"targetFps\": 0, \"bladeDensity\": 0.0 }");
cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == config::kTargetFpsMin);
CHECK(cfg.bladeDensity == Approx(config::kBladeDensityMin));
}
TEST_CASE("Config: JSONC comments and trailing commas are tolerated", "[config]") {
const std::filesystem::path path = test_config_path("jsonc");
write_text(path,
"{\n"
" // a comment\n"
" \"targetFps\": 24, /* inline */\n"
" \"bladeDensity\": 2.0,\n" // trailing comma below
"}\n");
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == 24);
CHECK(cfg.bladeDensity == Approx(2.0));
}
TEST_CASE("Config: malformed file falls back to defaults and is preserved", "[config]") {
const std::filesystem::path path = test_config_path("malformed");
write_text(path, "{ not valid json ");
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == config::kTargetFpsDefault);
CHECK(cfg.bladeDensity == Approx(config::kBladeDensityDefault));
// The user's (broken) file must be left untouched for them to fix.
CHECK(read_text(path) == "{ not valid json ");
}
TEST_CASE("Config: missing keys fall back to per-key defaults", "[config]") {
const std::filesystem::path path = test_config_path("partial");
write_text(path, "{ \"targetFps\": 45 }");
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == 45);
CHECK(cfg.bladeDensity == Approx(config::kBladeDensityDefault));
CHECK(cfg.swaySpeed == Approx(config::kSwaySpeedDefault));
CHECK(cfg.swayAmplitude == Approx(config::kSwayAmplitudeDefault));
}
TEST_CASE("Config: keys are matched case-insensitively", "[config]") {
const std::filesystem::path path = test_config_path("case-insensitive");
write_text(path,
"{ \"TargetFps\": 60, \"BLADEDENSITY\": 1.5, "
"\"SwaySpeed\": 0.5, \"swayamplitude\": 2.0 }");
const config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.targetFps == 60);
CHECK(cfg.bladeDensity == Approx(1.5));
CHECK(cfg.swaySpeed == Approx(0.5));
CHECK(cfg.swayAmplitude == Approx(2.0));
}
TEST_CASE("Config: sway knobs parse, clamp, and reject non-finite", "[config]") {
const std::filesystem::path path = test_config_path("sway");
// Defaults when absent.
write_text(path, "{ }");
config::Config cfg = config::LoadConfig(path.wstring());
CHECK(cfg.swaySpeed == Approx(config::kSwaySpeedDefault));
CHECK(cfg.swayAmplitude == Approx(config::kSwayAmplitudeDefault));
// Valid values parsed.
write_text(path, "{ \"swaySpeed\": 0.5, \"swayAmplitude\": 2.0 }");
cfg = config::LoadConfig(path.wstring());
CHECK(cfg.swaySpeed == Approx(0.5));
CHECK(cfg.swayAmplitude == Approx(2.0));
// Out-of-range clamped to bounds.
write_text(path, "{ \"swaySpeed\": 99.0, \"swayAmplitude\": -5.0 }");
cfg = config::LoadConfig(path.wstring());
CHECK(cfg.swaySpeed == Approx(config::kSwaySpeedMax));
CHECK(cfg.swayAmplitude == Approx(config::kSwayAmplitudeMin));
// Non-finite (inf from overflow) falls back to default, never poisons the sim.
write_text(path, "{ \"swaySpeed\": 1e999, \"swayAmplitude\": 1e999 }");
cfg = config::LoadConfig(path.wstring());
CHECK(cfg.swaySpeed == Approx(config::kSwaySpeedDefault));
CHECK(cfg.swayAmplitude == Approx(config::kSwayAmplitudeDefault));
}

View File

@@ -1,379 +0,0 @@
// critter_tests.cpp
//
// Critter subsystem tests (architecture.md §13.3 / §16). Orthogonal to Scene.
//
// Coverage:
// * CritterKind discriminants are spec-locked ({None=0, Sheep=1}).
// * EntityKind::Sheep == 3 (added after the original {None, Tumbleweed,
// Snowflake} enum).
// * SHEEP_* and CRITTER_* constants are pinned to spec values.
// * sim_init defaults sim.currentCritter to None (no sheep until the user
// opts in via tray).
// * sim_set_critter(Sheep) on CANONICAL_TEST_SEED + 1920 produces
// deterministic count K ∈ [SHEEP_COUNT_MIN, SHEEP_COUNT_MAX], with
// every sheep entity well-formed: kind=Sheep, state=Walking, stateTimer
// in [WALK_DURATION_MIN, MAX], speed in [WALK_SPEED_MIN, MAX], x within
// monitor margins.
// * sim_set_critter(None) erases all sheep but preserves scene entities
// (snowflakes/tumbleweeds aren't touched).
// * sim_set_scene preserves the active critter — flipping Grass→Desert
// re-spawns sheep on the new scene.
// * Sheep PRNG draw order is bit-identical to a side-stream Prng for the
// locked sequence (count, then per-sheep: x, speed, dir-coin, seed,
// stateTimer, nameIndex).
// * Click within SHEEP_STARTLE_RADIUS pushes a sheep into Hopping, flips
// vx away from the cursor, and resets age.
// * Click outside SHEEP_STARTLE_RADIUS leaves sheep state untouched.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <cwchar>
using namespace desktopgrass;
namespace {
int count_kind(const Sim& sim, EntityKind kind) {
int n = 0;
for (const Entity& e : sim.entities) if (e.kind == kind) ++n;
return n;
}
int count_sheep(const Sim& sim) {
return count_kind(sim, EntityKind::Sheep);
}
const Entity* first_sheep(const Sim& sim) {
for (const Entity& e : sim.entities) if (e.kind == EntityKind::Sheep) return &e;
return nullptr;
}
} // namespace
TEST_CASE("CritterKind has spec-locked discriminants", "[critter][enum]") {
REQUIRE(static_cast<int>(CritterKind::None) == 0);
REQUIRE(static_cast<int>(CritterKind::Sheep) == 1);
REQUIRE(static_cast<int>(CritterKind::Cat) == 2);
REQUIRE(static_cast<int>(CritterKind::Bunny) == 3);
REQUIRE(static_cast<int>(EntityKind::Sheep) == 3);
REQUIRE(static_cast<int>(EntityKind::Bunny) == 6);
REQUIRE(static_cast<int>(EntityKind::Butterfly) == 7);
REQUIRE(static_cast<int>(EntityKind::Firefly) == 8);
REQUIRE(CRITTER_DEFAULT == CritterKind::None);
}
TEST_CASE("Sheep constants are pinned to spec values", "[critter][constants]") {
REQUIRE(SHEEP_COUNT_MIN == 2);
REQUIRE(SHEEP_COUNT_MAX == 3);
REQUIRE(sizeof(PET_COUNT_OPTIONS) / sizeof(PET_COUNT_OPTIONS[0]) == 6);
for (int i = 0; i < 6; ++i) REQUIRE(PET_COUNT_OPTIONS[i] == i + 1);
REQUIRE(PET_COUNT_DEFAULT_SHEEP == SHEEP_COUNT_MIN);
REQUIRE(PET_COUNT_DEFAULT_CAT == CAT_COUNT_MIN);
REQUIRE(PET_COUNT_MAX_PER_MONITOR == 6);
REQUIRE(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0]) == 8);
REQUIRE(sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0]) == 8);
REQUIRE(std::wcscmp(SHEEP_NAME_POOL[0], L"Bessie") == 0);
REQUIRE(std::wcscmp(SHEEP_NAME_POOL[7], L"Hazel") == 0);
REQUIRE(std::wcscmp(CAT_NAME_POOL[0], L"Mittens") == 0);
REQUIRE(std::wcscmp(CAT_NAME_POOL[7], L"Juno") == 0);
REQUIRE(PET_NAME_HOVER_RADIUS == Approx(50.0));
REQUIRE(PET_NAME_FADE_DURATION == Approx(1.5));
REQUIRE(PET_NAME_FONT_SIZE == Approx(11.0));
REQUIRE(PET_NAME_OFFSET_Y == Approx(-8.0));
REQUIRE(PET_NAME_COLOR == 0xFFFFFFFFu);
REQUIRE(PET_NAME_SHADOW_COLOR == 0xC0000000u);
REQUIRE(SHEEP_WALK_SPEED_MIN == Approx(14.0));
REQUIRE(SHEEP_WALK_SPEED_MAX == Approx(26.0));
REQUIRE(SHEEP_BODY_RADIUS == Approx(12.0));
REQUIRE(SHEEP_HEAD_RADIUS == Approx(5.0));
REQUIRE(SHEEP_LEG_LENGTH == Approx(5.5));
REQUIRE(SHEEP_STATE_WALKING == 0);
REQUIRE(SHEEP_STATE_GRAZING == 1);
REQUIRE(SHEEP_STATE_IDLE == 2);
REQUIRE(SHEEP_STATE_SLEEPING == 3);
REQUIRE(SHEEP_STATE_HOPPING == 4);
REQUIRE(SHEEP_HOP_DURATION == Approx(0.55));
REQUIRE(SHEEP_HOP_HEIGHT == Approx(11.0));
REQUIRE(SHEEP_STARTLE_RADIUS == Approx(64.0));
REQUIRE(SHEEP_STARTLE_BOOST == Approx(1.6));
REQUIRE(SHEEP_GRAZE_PROBABILITY == Approx(0.60));
REQUIRE(SHEEP_IDLE_PROBABILITY == Approx(0.25));
REQUIRE(SHEEP_SLEEP_FROM_IDLE_PROB == Approx(0.30));
}
TEST_CASE("sim_init defaults critter to None", "[critter][init]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
REQUIRE(sim.currentCritter == CritterKind::None);
REQUIRE(count_sheep(sim) == 0);
}
TEST_CASE("sim_set_critter(Sheep) produces deterministic flock", "[critter][gen]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
REQUIRE(sim.currentCritter == CritterKind::Sheep);
const int k = count_sheep(sim);
REQUIRE(k >= SHEEP_COUNT_MIN);
REQUIRE(k <= SHEEP_COUNT_MAX);
const double groundY = sim.windowHeight;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Sheep) continue;
REQUIRE(e.state == SHEEP_STATE_WALKING);
REQUIRE(e.stateTimer >= SHEEP_WALK_DURATION_MIN);
REQUIRE(e.stateTimer < SHEEP_WALK_DURATION_MAX);
REQUIRE(std::fabs(e.vx) >= SHEEP_WALK_SPEED_MIN);
REQUIRE(std::fabs(e.vx) < SHEEP_WALK_SPEED_MAX);
const double margin = e.size + 8.0;
REQUIRE(e.x >= margin);
REQUIRE(e.x <= sim.monitorWidth - margin);
REQUIRE(e.y == Approx(groundY - SHEEP_BODY_HEIGHT - SHEEP_LEG_LENGTH));
REQUIRE(e.lifetime < 0.0); // infinite — sheep don't expire
REQUIRE(e.nameIndex < sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0]));
}
}
TEST_CASE("Sheep PRNG draw order matches a side stream", "[critter][prng]") {
// Independent side stream that walks the documented sequence:
// count
// per-sheep: x, speed, dir-coin, seed, stateTimer, nameIndex
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
const double countDraw = prng_uniform(side, SHEEP_COUNT_MIN, SHEEP_COUNT_MAX + 1);
int expectedCount = static_cast<int>(std::floor(countDraw));
if (expectedCount < SHEEP_COUNT_MIN) expectedCount = SHEEP_COUNT_MIN;
if (expectedCount > SHEEP_COUNT_MAX) expectedCount = SHEEP_COUNT_MAX;
REQUIRE(count_sheep(sim) == expectedCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Sheep) continue;
const double margin = SHEEP_BODY_RADIUS + 8.0;
const double expectedX = prng_uniform(side, margin, 1920.0 - margin);
const double expectedSpeed = prng_uniform(side, SHEEP_WALK_SPEED_MIN, SHEEP_WALK_SPEED_MAX);
const double dirCoin = prng_uniform(side, 0.0, 1.0);
const double expectedDir = (dirCoin < 0.5) ? -1.0 : 1.0;
const uint32_t expectedSeed = prng_next_u32(side);
const double expectedTimer = prng_uniform(side, SHEEP_WALK_DURATION_MIN, SHEEP_WALK_DURATION_MAX);
const uint8_t expectedNameIndex = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0]))));
REQUIRE(e.x == Approx(expectedX));
REQUIRE(e.vx == Approx(expectedSpeed * expectedDir));
REQUIRE(e.seed == expectedSeed);
REQUIRE(e.stateTimer == Approx(expectedTimer));
REQUIRE(e.nameIndex == expectedNameIndex);
++seen;
}
REQUIRE(seen == expectedCount);
}
TEST_CASE("canonical critter name indices are stable and species-local", "[critter][names]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
const uint8_t expectedSheepNames[] = { 4, 7 };
int sheepSeen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Sheep) continue;
REQUIRE(sheepSeen < static_cast<int>(sizeof(expectedSheepNames) / sizeof(expectedSheepNames[0])));
REQUIRE(e.nameIndex == expectedSheepNames[sheepSeen]);
REQUIRE(std::wcscmp(SHEEP_NAME_POOL[e.nameIndex], sheepSeen == 0 ? L"Pippin" : L"Hazel") == 0);
++sheepSeen;
}
REQUIRE(sheepSeen == 2);
sim_set_critter(sim, CritterKind::Cat);
const uint8_t expectedCatNames[] = { 4 };
int catSeen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Cat) continue;
REQUIRE(catSeen < static_cast<int>(sizeof(expectedCatNames) / sizeof(expectedCatNames[0])));
REQUIRE(e.nameIndex == expectedCatNames[catSeen]);
REQUIRE(e.nameIndex < sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0]));
REQUIRE(std::wcscmp(CAT_NAME_POOL[e.nameIndex], L"Smokey") == 0);
++catSeen;
}
REQUIRE(catSeen == 1);
}
TEST_CASE("sim_set_critter_count(0) preserves random sheep count draw", "[critter][count]") {
bool sawMin = false;
bool sawMax = false;
for (uint64_t i = 0; i < 64; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = sim_init(seed, 1920.0, DEFAULT_DENSITY);
sim_set_critter_count(sim, 3);
sim_set_critter_count(sim, 0);
sim_set_critter(sim, CritterKind::Sheep);
Prng side;
prng_init(side, seed ^ CRITTER_PRNG_SALT);
const double countDraw = prng_uniform(side, SHEEP_COUNT_MIN, SHEEP_COUNT_MAX + 1);
int expectedCount = static_cast<int>(std::floor(countDraw));
if (expectedCount < SHEEP_COUNT_MIN) expectedCount = SHEEP_COUNT_MIN;
if (expectedCount > SHEEP_COUNT_MAX) expectedCount = SHEEP_COUNT_MAX;
REQUIRE(count_sheep(sim) == expectedCount);
sawMin = sawMin || expectedCount == SHEEP_COUNT_MIN;
sawMax = sawMax || expectedCount == SHEEP_COUNT_MAX;
}
REQUIRE(sawMin);
REQUIRE(sawMax);
}
TEST_CASE("fixed sheep count override skips the count PRNG draw", "[critter][count][prng]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
sim_set_critter_count(sim, 3);
REQUIRE(sim.critterCountOverride == 3);
REQUIRE(count_sheep(sim) == 3);
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Sheep) continue;
const double margin = SHEEP_BODY_RADIUS + 8.0;
const double expectedX = prng_uniform(side, margin, 1920.0 - margin);
const double expectedSpeed = prng_uniform(side, SHEEP_WALK_SPEED_MIN, SHEEP_WALK_SPEED_MAX);
const double dirCoin = prng_uniform(side, 0.0, 1.0);
const double expectedDir = (dirCoin < 0.5) ? -1.0 : 1.0;
const uint32_t expectedSeed = prng_next_u32(side);
const double expectedTimer = prng_uniform(side, SHEEP_WALK_DURATION_MIN, SHEEP_WALK_DURATION_MAX);
const uint8_t expectedNameIndex = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0]))));
REQUIRE(e.x == Approx(expectedX));
REQUIRE(e.vx == Approx(expectedSpeed * expectedDir));
REQUIRE(e.seed == expectedSeed);
REQUIRE(e.stateTimer == Approx(expectedTimer));
REQUIRE(e.nameIndex == expectedNameIndex);
++seen;
}
REQUIRE(seen == 3);
}
TEST_CASE("fixed critter count override supports tray range and clamps", "[critter][count]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
sim_set_critter_count(sim, 6);
REQUIRE(count_sheep(sim) == 6);
sim_set_critter_count(sim, 8);
REQUIRE(count_sheep(sim) == PET_COUNT_MAX_PER_MONITOR);
sim_set_critter(sim, CritterKind::Cat);
sim_set_critter_count(sim, 2);
REQUIRE(count_kind(sim, EntityKind::Cat) == 2);
REQUIRE(count_sheep(sim) == 0);
}
TEST_CASE("sim_set_critter(None) clears all ground critters",
"[critter][toggle]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
REQUIRE(count_sheep(sim) >= SHEEP_COUNT_MIN);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
sim_set_critter(sim, CritterKind::None);
REQUIRE(count_sheep(sim) == 0);
REQUIRE(count_kind(sim, EntityKind::Cat) == 0);
REQUIRE(count_kind(sim, EntityKind::Bunny) == 0);
REQUIRE(count_kind(sim, EntityKind::Hedgehog) == 0);
}
TEST_CASE("sim_set_scene gates active sheep to Grass", "[critter][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
const int sheepCountGrass = count_sheep(sim);
REQUIRE(sheepCountGrass >= SHEEP_COUNT_MIN);
sim_set_scene(sim, Scene::Desert);
REQUIRE(count_sheep(sim) == 0);
REQUIRE(sim.currentCritter == CritterKind::Sheep);
sim_set_scene(sim, Scene::Winter);
REQUIRE(count_sheep(sim) == 0);
REQUIRE(sim.currentCritter == CritterKind::Sheep);
sim_set_scene(sim, Scene::Grass);
REQUIRE(count_sheep(sim) == sheepCountGrass);
}
TEST_CASE("Click within SHEEP_STARTLE_RADIUS triggers hop away", "[critter][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
Entity* target = nullptr;
for (Entity& e : sim.entities) {
if (e.kind == EntityKind::Sheep) { target = &e; break; }
}
REQUIRE(target != nullptr);
// Click 16 DIP to the left of the sheep — well within startle radius,
// inside the cut band (so the early y-gate doesn't reject).
const double clickX = target->x - 16.0;
const double clickY = sim.windowHeight - 20.0;
target->age = 5.0; // pre-set age to verify reset
InputEvent ev{};
ev.type = EventType::Click;
ev.x = clickX;
ev.y = clickY;
ev.time = 0.0;
sim_apply_click(sim, ev);
Entity* after = nullptr;
for (Entity& e : sim.entities) {
if (e.kind == EntityKind::Sheep) { after = &e; break; }
}
REQUIRE(after != nullptr);
REQUIRE(after->state == SHEEP_STATE_HOPPING);
REQUIRE(after->stateTimer == Approx(SHEEP_HOP_DURATION));
REQUIRE(after->age == Approx(0.0));
REQUIRE(after->vx > 0.0); // sheep was right of click → vx flipped to +
REQUIRE(std::fabs(after->vx) <= SHEEP_WALK_SPEED_MAX * SHEEP_STARTLE_BOOST);
}
TEST_CASE("Click outside SHEEP_STARTLE_RADIUS leaves sheep alone", "[critter][click]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
Entity* target = nullptr;
for (Entity& e : sim.entities) {
if (e.kind == EntityKind::Sheep) { target = &e; break; }
}
REQUIRE(target != nullptr);
const uint8_t stateBefore = target->state;
const double vxBefore = target->vx;
// Click far away (300 DIP) but still in the cut band.
const double clickX = target->x + SHEEP_STARTLE_RADIUS + 200.0;
const double clickY = sim.windowHeight - 20.0;
InputEvent ev{};
ev.type = EventType::Click;
ev.x = clickX;
ev.y = clickY;
ev.time = 0.0;
sim_apply_click(sim, ev);
Entity* after = nullptr;
for (Entity& e : sim.entities) {
if (e.kind == EntityKind::Sheep) { after = &e; break; }
}
REQUIRE(after != nullptr);
REQUIRE(after->state == stateBefore);
REQUIRE(after->vx == Approx(vxBefore));
}

View File

@@ -1,259 +0,0 @@
// cut_tests.cpp
//
// Cut state animation tests (architecture.md §9).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <cmath>
#include <vector>
using namespace desktopgrass;
using namespace desktopgrass::test;
namespace {
Sim make_sim_with_blades(std::initializer_list<double> baseXs) {
Sim sim;
sim.windowHeight = STRIP_HEIGHT + HEADROOM;
for (double x : baseXs) {
Blade b{};
b.baseX = x;
b.height = 20.0;
b.thickness = 1.5;
b.swayPhaseOffset = 0.0;
b.stiffness = 1.0;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutAnimStart = -1.0;
sim.blades.push_back(b);
}
return sim;
}
InputEvent click(double x, double y, double t) {
return InputEvent{ EventType::Click, x, y, t };
}
} // anonymous
TEST_CASE("click inside cut band animates blades within radius to 0", "[cut]") {
Sim sim = make_sim_with_blades({100.0, 110.0, 200.0});
const double y_in_band = sim.windowHeight - 40.0; // inside strip
InputEvent ev = click(100.0, y_in_band, 0.0);
sim_tick(sim, 0.0, &ev, 1);
// Apply 5 ticks of 50 ms (total = 250 ms > CUT_DURATION_SEC).
for (int i = 0; i < 5; ++i) {
sim_tick(sim, 0.05, nullptr, 0);
}
REQUIRE(sim.blades[0].cutHeight == Approx(0.0));
REQUIRE(sim.blades[0].cutAnimStart == Approx(-1.0));
REQUIRE(sim.blades[1].cutHeight == Approx(0.0));
// Blade at 200 is outside CUT_RADIUS = 30.
REQUIRE(sim.blades[2].cutHeight == Approx(1.0));
REQUIRE(sim.blades[2].cutAnimStart == Approx(-1.0));
}
TEST_CASE("cut animation is linear over CUT_DURATION_SEC", "[cut]") {
Sim sim = make_sim_with_blades({100.0});
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
// After tick(0.0) globalTime = 0 still; cutAnimStart = 0.
// 50 ms in → cutHeight ≈ 0.75.
sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.75).margin(1e-9));
// 100 ms in → 0.5.
sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.5).margin(1e-9));
// 150 ms in → 0.25.
sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.25).margin(1e-9));
// 200 ms in → 0.0 and idle.
sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0).margin(1e-9));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
}
TEST_CASE("click outside cut band is ignored", "[cut]") {
Sim sim = make_sim_with_blades({100.0});
const double y_above = sim.windowHeight - STRIP_HEIGHT - 5.0;
InputEvent ev = click(100.0, y_above, 0.0);
sim_tick(sim, 0.0, &ev, 1);
REQUIRE(sim.blades[0].cutHeight == Approx(1.0));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
}
TEST_CASE("repeat click on in-flight blade is idempotent", "[cut]") {
Sim sim = make_sim_with_blades({100.0});
const double y = sim.windowHeight - 40.0;
InputEvent first = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &first, 1);
// Mid-animation second click → should not reset cutAnimStart.
sim_tick(sim, 0.05, nullptr, 0); // 0.05 elapsed; cutHeight = 0.75
const double startSnapshot = sim.blades[0].cutAnimStart;
const double heightSnapshot = sim.blades[0].cutHeight;
InputEvent second = click(100.0, y, 0.05);
sim_tick(sim, 0.0, &second, 1);
REQUIRE(sim.blades[0].cutAnimStart == Approx(startSnapshot));
REQUIRE(sim.blades[0].cutInitialHeight == Approx(1.0));
REQUIRE(sim.blades[0].cutHeight == Approx(heightSnapshot));
}
TEST_CASE("click on already-cut blade is a no-op", "[cut]") {
Sim sim = make_sim_with_blades({100.0});
sim.blades[0].cutHeight = 0.0;
sim.blades[0].cutInitialHeight = 0.0;
sim.blades[0].cutAnimStart = -1.0;
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
}
TEST_CASE("blades outside cut radius are untouched", "[cut]") {
Sim sim = make_sim_with_blades({100.0, 131.0, 200.0});
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
REQUIRE(sim.blades[0].cutAnimStart >= 0.0);
REQUIRE(sim.blades[1].cutAnimStart < 0.0);
REQUIRE(sim.blades[2].cutAnimStart < 0.0);
}
TEST_CASE("compute_blade_stroke degenerates to a stump under threshold", "[cut][geometry]") {
Blade b{};
b.baseX = 100.0;
b.height = 20.0;
b.thickness = 1.5;
b.hue = 2;
b.cutHeight = 0.04; // below CUT_STUMP_THRESHOLD = 0.05
b.effectiveLean = 5.0;
b.cutInitialHeight = 1.0;
b.cutAnimStart = 0.0;
Stroke s = compute_blade_stroke(b, 110.0, Scene::Grass);
REQUIRE(s.tip.x == Approx(100.0));
REQUIRE(s.tip.y == Approx(110.0 - STUMP_HEIGHT));
REQUIRE(s.argb == PALETTE[2]);
}
TEST_CASE("compute_blade_stroke produces vertical line when lean is zero", "[cut][geometry]") {
Blade b{};
b.baseX = 100.0;
b.height = 20.0;
b.thickness = 1.5;
b.hue = 1;
b.cutHeight = 1.0;
b.effectiveLean = 0.0;
Stroke s = compute_blade_stroke(b, 110.0, Scene::Grass);
REQUIRE(s.base.x == Approx(100.0));
REQUIRE(s.base.y == Approx(110.0));
REQUIRE(s.tip.x == Approx(100.0));
REQUIRE(s.tip.y == Approx(90.0));
REQUIRE(s.control.x == Approx(100.0));
}
// ---------------------------------------------------------------------------
// Cut-floor (stubble) variation
// ---------------------------------------------------------------------------
TEST_CASE("generated blades get a per-blade cut floor within spec range", "[cut][floor]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 50);
for (const Blade& b : blades) {
REQUIRE(b.cutFloor >= CUT_FLOOR_MIN);
REQUIRE(b.cutFloor < CUT_FLOOR_MAX);
// Stubble must render as a short blade, never a degenerate stump.
REQUIRE(b.cutFloor >= CUT_STUMP_THRESHOLD);
}
// The whole point is variation: not every blade settles at the same height.
bool varies = false;
for (std::size_t i = 1; i < blades.size(); ++i) {
if (blades[i].cutFloor != blades[0].cutFloor) { varies = true; break; }
}
REQUIRE(varies);
}
TEST_CASE("cut settles at the per-blade stubble floor, not flat zero", "[cut][floor]") {
Blade b{};
b.height = 20.0;
b.thickness = 1.5;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutFloor = 0.12;
b.cutAnimStart = 0.0;
// Advance past the full cut duration.
advance_cut(b, CUT_DURATION_SEC + 0.01);
REQUIRE(b.cutHeight == Approx(0.12));
REQUIRE(b.cutAnimStart == Approx(-1.0));
}
TEST_CASE("cut-down animation lerps toward the floor", "[cut][floor]") {
Blade b{};
b.height = 20.0;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutFloor = 0.10;
b.cutAnimStart = 0.0;
// Half-way through the cut: lerp(1.0 -> 0.10) at t=0.5 = 0.10 + 0.90*0.5.
advance_cut(b, CUT_DURATION_SEC * 0.5);
REQUIRE(b.cutHeight == Approx(0.10 + 0.90 * 0.5).margin(1e-9));
}
TEST_CASE("regrowth grows back from the floor to full height", "[cut][floor][regrowth]") {
Blade b{};
b.height = 20.0;
b.cutFloor = 0.10;
b.cutHeight = 0.10;
b.cutAnimStart = -1.0;
b.regrowDuration = 0.4;
b.regrowStart = 0.0;
// Half-way through regrowth: lerp(0.10 -> 1.0) at t=0.5.
advance_cut(b, 0.2);
REQUIRE(b.cutHeight == Approx(0.10 + 0.90 * 0.5).margin(1e-9));
// Fully regrown.
advance_cut(b, 0.4);
REQUIRE(b.cutHeight == Approx(1.0).margin(1e-9));
}
TEST_CASE("zero-floor blades still collapse fully (back-compat)", "[cut][floor]") {
Blade b{};
b.height = 20.0;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutFloor = 0.0;
b.cutAnimStart = 0.0;
advance_cut(b, CUT_DURATION_SEC + 0.01);
REQUIRE(b.cutHeight == Approx(0.0));
}

View File

@@ -1,220 +0,0 @@
// desert_tests.cpp - §14 Desert scene cacti + tumbleweeds.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <cmath>
#include <cstddef>
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double kMonitor1920 = 1920.0;
struct ExpectedCactus {
std::size_t slotIndex = 0;
uint8_t type = 0;
double height = 0.0;
double width = 0.0;
int8_t armSide = +1;
};
ExpectedCactus first_expected_cactus(std::size_t bladeCount) {
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ CACTUS_PRNG_SALT);
for (std::size_t i = 0; i < bladeCount; ++i) {
const double r = prng_uniform(p, 0.0, 1.0);
if (r >= CACTUS_PROBABILITY) continue;
ExpectedCactus expected{};
expected.slotIndex = i;
expected.height = prng_uniform(p, CACTUS_HEIGHT_MIN, CACTUS_HEIGHT_MAX);
expected.width = prng_uniform(p, CACTUS_WIDTH_MIN, CACTUS_WIDTH_MAX);
const double armDraw = prng_uniform(p, 0.0, 1.0);
const double noArmThreshold = 1.0 - CACTUS_ARM_PROBABILITY;
const double twoArmThreshold = noArmThreshold + CACTUS_TWO_ARM_PROBABILITY * CACTUS_ARM_PROBABILITY;
if (armDraw < noArmThreshold) {
expected.type = 0;
} else if (armDraw < twoArmThreshold) {
expected.type = 2;
} else {
expected.type = 1;
expected.armSide = prng_uniform(p, 0.0, 1.0) < 0.5
? static_cast<int8_t>(-1)
: static_cast<int8_t>(+1);
}
if (expected.height < CACTUS_ARM_MIN_HEIGHT) {
expected.type = 0;
expected.armSide = +1;
}
return expected;
}
FAIL("canonical seed produced no cactus slot");
return {};
}
int expected_tumbleweed_count(double monitorWidth) {
if (monitorWidth < 480.0) return 0;
int count = static_cast<int>(std::floor(monitorWidth / 1920.0 * static_cast<double>(TUMBLEWEED_COUNT_PER_1920DIP)));
return count < 1 ? 1 : count;
}
} // anonymous
TEST_CASE("Desert constants are pinned", "[desert][constants]") {
REQUIRE(CACTUS_PROBABILITY == Approx(0.005));
REQUIRE(CACTUS_HEIGHT_MIN == Approx(30.0));
REQUIRE(CACTUS_HEIGHT_MAX == Approx(70.0));
REQUIRE(CACTUS_COLOR == 0xFF2D7A2Du);
REQUIRE(TUMBLEWEED_COUNT_PER_1920DIP == 4);
REQUIRE(TUMBLEWEED_SPEED_MAX == Approx(72.0));
REQUIRE(TUMBLEWEED_PRNG_SALT == 0x7B0117CA7B0117CAull);
}
TEST_CASE("sim_set_scene Desert clears entities and generates cacti", "[desert][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
Entity fake{};
fake.kind = EntityKind::Snowflake;
sim.entities.push_back(fake);
sim_set_scene(sim, Scene::Desert);
REQUIRE(sim.currentScene == Scene::Desert);
REQUIRE(sim.entities.size() == static_cast<std::size_t>(expected_tumbleweed_count(kMonitor1920)));
for (const Entity& e : sim.entities) REQUIRE(e.kind == EntityKind::Tumbleweed);
std::size_t cactusCount = 0;
for (const Blade& b : sim.blades) if (b.isCactus) ++cactusCount;
REQUIRE(cactusCount >= 1);
REQUIRE(cactusCount <= 10);
}
TEST_CASE("First cactus matches the spec-derived PRNG snapshot", "[desert][cactus]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
const ExpectedCactus expected = first_expected_cactus(sim.blades.size());
sim_set_scene(sim, Scene::Desert);
REQUIRE(expected.slotIndex < sim.blades.size());
const Blade& b = sim.blades[expected.slotIndex];
REQUIRE(b.isCactus);
REQUIRE(b.cactusType == expected.type);
REQUIRE(b.cactusHeight == Approx(expected.height).margin(1e-12));
REQUIRE(b.cactusWidth == Approx(expected.width).margin(1e-12));
if (expected.type == 1) REQUIRE(b.cactusArmSide == expected.armSide);
}
TEST_CASE("Grass scene restores original flower and mushroom slot variants", "[desert][restore]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
const ExpectedCactus expected = first_expected_cactus(sim.blades.size());
REQUIRE(expected.slotIndex < sim.blades.size());
Blade& target = sim.blades[expected.slotIndex];
target.isFlower = true;
target.isMushroom = true;
target.originalIsFlower = true;
target.originalIsMushroom = true;
sim_set_scene(sim, Scene::Desert);
REQUIRE(sim.blades[expected.slotIndex].isCactus);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isFlower);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isMushroom);
sim_set_scene(sim, Scene::Grass);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isCactus);
REQUIRE(sim.blades[expected.slotIndex].isFlower);
REQUIRE(sim.blades[expected.slotIndex].isMushroom);
}
TEST_CASE("Desert generates the expected tumbleweed count", "[desert][tumbleweed]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
const int expected = expected_tumbleweed_count(kMonitor1920);
REQUIRE(expected >= 1);
REQUIRE(sim.entities.size() == static_cast<std::size_t>(expected));
}
TEST_CASE("First tumbleweed matches the spec-derived PRNG snapshot", "[desert][tumbleweed]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE_FALSE(sim.entities.empty());
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ TUMBLEWEED_PRNG_SALT);
const double expectedSize = prng_uniform(p, TUMBLEWEED_SIZE_MIN, TUMBLEWEED_SIZE_MAX);
const double expectedX = prng_uniform(p, 0.0, kMonitor1920);
const double expectedY = sim.windowHeight - prng_uniform(p, TUMBLEWEED_Y_OFFSET_MIN, TUMBLEWEED_Y_OFFSET_MAX);
const double speed = prng_uniform(p, TUMBLEWEED_SPEED_MIN, TUMBLEWEED_SPEED_MAX);
const double direction = prng_uniform(p, 0.0, 1.0) < 0.5 ? -1.0 : 1.0;
const double expectedVx = direction * speed;
const double expectedRotation = prng_uniform(p, 0.0, 6.28318530717958647692);
const Entity& e = sim.entities[0];
REQUIRE(e.kind == EntityKind::Tumbleweed);
REQUIRE(e.size == Approx(expectedSize).margin(1e-12));
REQUIRE(e.x == Approx(expectedX).margin(1e-12));
REQUIRE(e.y == Approx(expectedY).margin(1e-12));
REQUIRE(e.vx == Approx(expectedVx).margin(1e-12));
REQUIRE(e.rotation == Approx(expectedRotation).margin(1e-12));
REQUIRE(e.rotationSpeed == Approx(expectedVx / expectedSize).margin(1e-12));
}
TEST_CASE("Tumbleweed respawns at the opposite edge when off-screen", "[desert][tumbleweed]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE_FALSE(sim.entities.empty());
sim.entities[0].x = sim.monitorWidth + 100.0;
sim_tick_entities(sim, 0.0);
const Entity& e = sim.entities[0];
REQUIRE(e.kind == EntityKind::Tumbleweed);
REQUIRE(e.x == Approx(-e.size).margin(1e-12));
REQUIRE(e.vx > 0.0);
}
TEST_CASE("Tumbleweed hops above its baseline then settles", "[desert][tumbleweed]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE_FALSE(sim.entities.empty());
double yBase = sim.entities[0].altitudeAnchor;
REQUIRE(sim.entities[0].y == Approx(yBase).margin(1e-9)); // starts grounded
double minY = sim.entities[0].y;
for (int i = 0; i < 900; ++i) {
sim_tick_entities(sim, 1.0 / 60.0);
Entity& t = sim.entities[0];
// Pin x on-screen so it doesn't roll off and respawn mid-test.
if (t.x < 50.0) t.x = 50.0;
if (t.x > sim.monitorWidth - 50.0) t.x = sim.monitorWidth - 50.0;
yBase = t.altitudeAnchor;
minY = std::min(minY, t.y);
REQUIRE(t.y <= yBase + 1e-6); // never sinks below the baseline
}
REQUIRE(minY < yBase - 1.0); // it left the ground at least once
}
TEST_CASE("Desert scene leaves the canonical first blade geometry bit-identical", "[desert][snapshot]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, 1.0);
REQUIRE(sim.blades.size() == desktopgrass::test::CANONICAL_BLADE_COUNT);
sim_set_scene(sim, Scene::Desert);
const Blade& first = sim.blades[0];
const auto& expected = desktopgrass::test::CANONICAL_FIRST_10[0];
REQUIRE(first.baseX == Approx(expected.baseX).margin(1e-12));
REQUIRE(first.height == Approx(expected.height).margin(1e-12));
REQUIRE(first.thickness == Approx(expected.thickness).margin(1e-12));
REQUIRE(first.hue == expected.hue);
REQUIRE(first.swayPhaseOffset == Approx(expected.sway).margin(1e-12));
REQUIRE(first.stiffness == Approx(expected.stiffness).margin(1e-12));
}

View File

@@ -1,113 +0,0 @@
// entity_skeleton_tests.cpp
//
// Entity subsystem skeleton tests (architecture.md §13.2).
//
// Coverage:
// * EntityKind discriminants match the spec ({None=0, Tumbleweed=1,
// Snowflake=2}).
// * MAX_ENTITIES_PER_MONITOR is the locked cap (= 64).
// * sim_init defaults sim.entities to empty, capacity >= cap.
// * sim_set_scene clears entities (currently a no-op since the Grass
// scene generates none; §14/§15 add per-scene generators).
// * sim_tick_entities is safe on empty (no exceptions, no growth).
// * Tick on empty entities does not perturb other sim state (blades
// untouched, ambient PRNG untouched).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
using namespace desktopgrass;
TEST_CASE("EntityKind has spec-locked discriminants", "[entities][enum]") {
REQUIRE(static_cast<int>(EntityKind::None) == 0);
REQUIRE(static_cast<int>(EntityKind::Tumbleweed) == 1);
REQUIRE(static_cast<int>(EntityKind::Snowflake) == 2);
REQUIRE(static_cast<int>(EntityKind::Sheep) == 3);
REQUIRE(static_cast<int>(EntityKind::Cat) == 4);
REQUIRE(static_cast<int>(EntityKind::Bunny) == 6);
REQUIRE(static_cast<int>(EntityKind::Butterfly) == 7);
REQUIRE(static_cast<int>(EntityKind::Firefly) == 8);
REQUIRE(static_cast<int>(EntityKind::Bird) == 9);
REQUIRE(MAX_ENTITIES_PER_MONITOR == 64);
}
TEST_CASE("sim_init reserves entities capacity", "[entities][init]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
REQUIRE(sim.entities.empty());
REQUIRE(sim.entities.capacity() >= static_cast<std::size_t>(MAX_ENTITIES_PER_MONITOR));
REQUIRE(sim.entitySeed == CANONICAL_TEST_SEED);
}
TEST_CASE("sim_set_scene clears entities", "[entities][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
// Push a fake entity directly to verify scene-transition removal runs.
Entity fake{};
fake.kind = EntityKind::Tumbleweed;
fake.x = 100.0;
sim.entities.push_back(fake);
REQUIRE(sim.entities.size() == 1);
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.entities.empty());
REQUIRE(sim.entities.capacity() >= static_cast<std::size_t>(MAX_ENTITIES_PER_MONITOR));
}
TEST_CASE("sim_tick_entities is a no-op on empty outside Grass", "[entities][tick]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
sim.currentScene = Scene::Desert;
const auto bladesBefore = sim.blades.size();
const auto prngBefore = sim.ambientPrng.state;
sim_tick_entities(sim, 0.016);
sim_tick_entities(sim, 0.5);
REQUIRE(sim.entities.empty());
REQUIRE(sim.blades.size() == bladesBefore);
REQUIRE(sim.ambientPrng.state == prngBefore);
}
TEST_CASE("sim_tick_entities advances a populated entity", "[entities][tick]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
sim.currentScene = Scene::Desert;
Entity e{};
e.kind = EntityKind::Tumbleweed;
e.x = 100.0;
e.y = 50.0;
e.vx = 50.0; // DIP/sec
e.vy = 0.0;
e.size = 10.0;
e.rotation = 0.5;
e.rotationSpeed = 1.0; // rad/sec
e.age = 0.0;
e.lifetime = -1.0; // infinite
e.seed = 0xDEADBEEF;
sim.entities.push_back(e);
const double dt = 0.5;
sim_tick_entities(sim, dt);
REQUIRE(sim.entities.size() == 1);
const Entity& after = sim.entities[0];
REQUIRE(after.x == Approx(100.0 + 50.0 * dt));
REQUIRE(after.y == Approx(50.0));
REQUIRE(after.rotation == Approx(0.5 + 1.0 * dt));
REQUIRE(after.age == Approx(0.0 + dt));
REQUIRE(after.kind == EntityKind::Tumbleweed);
}
TEST_CASE("sim_tick calls sim_tick_entities (wiring check)", "[entities][tick]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
sim.currentScene = Scene::Desert;
Entity e{};
e.kind = EntityKind::Snowflake;
e.x = 0.0; e.y = 0.0;
e.vx = 10.0; e.vy = 20.0;
e.size = 2.0;
e.age = 0.0; e.lifetime = 100.0;
sim.entities.push_back(e);
sim_tick(sim, 0.1, nullptr, 0);
REQUIRE(sim.entities.size() == 1);
REQUIRE(sim.entities[0].x == Approx(1.0)); // 10 * 0.1
REQUIRE(sim.entities[0].y == Approx(2.0)); // 20 * 0.1
}

View File

@@ -1,195 +0,0 @@
// firefly_tests.cpp - §17.7 ambient Firefly tests.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
Sim build_grass_sim(uint64_t seed = CANONICAL_TEST_SEED) {
Sim sim = sim_init(seed, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Grass);
return sim;
}
int prng_count(Prng& side, int minCount, int maxCount) {
const double draw = prng_uniform(side, static_cast<double>(minCount), static_cast<double>(maxCount + 1));
int count = static_cast<int>(std::floor(draw));
if (count < minCount) count = minCount;
if (count > maxCount) count = maxCount;
return count;
}
} // namespace
TEST_CASE("Firefly constants are pinned to spec values", "[firefly][constants]") {
REQUIRE(FIREFLY_COUNT_MIN == 3);
REQUIRE(FIREFLY_COUNT_MAX == 6);
REQUIRE(FIREFLY_DRIFT_SPEED_MIN == Approx(4.0));
REQUIRE(FIREFLY_DRIFT_SPEED_MAX == Approx(10.0));
REQUIRE(FIREFLY_BODY_RADIUS == Approx(1.2));
REQUIRE(FIREFLY_GLOW_RADIUS == Approx(5.0));
REQUIRE(FIREFLY_BLINK_PERIOD_MIN == Approx(1.4));
REQUIRE(FIREFLY_BLINK_PERIOD_MAX == Approx(2.6));
REQUIRE(FIREFLY_BLINK_DUTY == Approx(0.55));
REQUIRE(FIREFLY_BLINK_FADE == Approx(0.30));
REQUIRE(FIREFLY_DRIFT_FREQ_X == Approx(0.4));
REQUIRE(FIREFLY_DRIFT_FREQ_Y == Approx(0.6));
REQUIRE(FIREFLY_DRIFT_AMP_X == Approx(0.6));
REQUIRE(FIREFLY_DRIFT_AMP_Y == Approx(8.0));
REQUIRE(FIREFLY_ALTITUDE_MIN == Approx(8.0));
REQUIRE(FIREFLY_ALTITUDE_MAX == Approx(55.0));
REQUIRE(FIREFLY_BODY_COLOR == 0xFFFFEE88u);
REQUIRE(FIREFLY_GLOW_COLOR_RGB == 0xEEDD66u);
REQUIRE(FIREFLY_GLOW_ALPHA_MAX == 110);
REQUIRE(FIREFLY_BODY_ALPHA_MAX == 255);
REQUIRE(FIREFLY_PRNG_SALT == 0xF13EF1E7777ull);
}
TEST_CASE("Grass generation produces firefly count in range", "[firefly][gen]") {
for (uint64_t i = 0; i < 128; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = build_grass_sim(seed);
REQUIRE(count_kind(sim, EntityKind::Firefly) >= FIREFLY_COUNT_MIN);
REQUIRE(count_kind(sim, EntityKind::Firefly) <= FIREFLY_COUNT_MAX);
}
}
TEST_CASE("Fireflies are Grass scene only", "[firefly][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert);
REQUIRE(count_kind(sim, EntityKind::Firefly) == 0);
sim_set_scene(sim, Scene::Winter);
REQUIRE(count_kind(sim, EntityKind::Firefly) == 0);
}
TEST_CASE("Generated fireflies have speed altitude and blink period ranges", "[firefly][gen]") {
Sim sim = build_grass_sim();
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Firefly) continue;
REQUIRE(e.baseSpeed >= FIREFLY_DRIFT_SPEED_MIN);
REQUIRE(e.baseSpeed < FIREFLY_DRIFT_SPEED_MAX);
REQUIRE(e.altitudeAnchor >= FIREFLY_ALTITUDE_MIN);
REQUIRE(e.altitudeAnchor < FIREFLY_ALTITUDE_MAX);
REQUIRE(e.blinkPeriod >= FIREFLY_BLINK_PERIOD_MIN);
REQUIRE(e.blinkPeriod < FIREFLY_BLINK_PERIOD_MAX);
}
}
TEST_CASE("Firefly PRNG draw order matches side stream", "[firefly][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ FIREFLY_PRNG_SALT);
Sim sim = build_grass_sim();
const int expectedCount = prng_count(side, FIREFLY_COUNT_MIN, FIREFLY_COUNT_MAX);
REQUIRE(count_kind(sim, EntityKind::Firefly) == expectedCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Firefly) continue;
const double xFrac = prng_uniform(side, 0.0, 1.0);
const double yFrac = prng_uniform(side, 0.0, 1.0);
const uint64_t vxSign = prng_next_u64(side) & 1ull;
const double expectedDir = vxSign != 0ull ? 1.0 : -1.0;
const double expectedSpeed = prng_uniform(side, FIREFLY_DRIFT_SPEED_MIN, FIREFLY_DRIFT_SPEED_MAX);
const double expectedBlinkPeriod = prng_uniform(side, FIREFLY_BLINK_PERIOD_MIN, FIREFLY_BLINK_PERIOD_MAX);
const double expectedBlinkPhase = prng_uniform(side, 0.0, 1.0);
const double expectedPhaseY = prng_uniform(side, 0.0, 2.0 * 3.14159265358979323846);
const double expectedPhaseX = prng_uniform(side, 0.0, 2.0 * 3.14159265358979323846);
const double expectedAltitude = FIREFLY_ALTITUDE_MIN + yFrac * (FIREFLY_ALTITUDE_MAX - FIREFLY_ALTITUDE_MIN);
const double expectedVx = expectedDir * expectedSpeed * (1.0 + FIREFLY_DRIFT_AMP_X * std::sin(expectedPhaseX));
REQUIRE(e.x == Approx(xFrac * Monitor1920));
REQUIRE(e.altitudeAnchor == Approx(expectedAltitude));
REQUIRE(e.baseSpeed == Approx(expectedSpeed));
REQUIRE(e.blinkPeriod == Approx(expectedBlinkPeriod));
REQUIRE(e.blinkPhase == Approx(expectedBlinkPhase));
REQUIRE(e.vx == Approx(expectedVx));
REQUIRE(e.phaseY == Approx(expectedPhaseY));
REQUIRE(e.phaseX == Approx(expectedPhaseX));
++seen;
}
REQUIRE(seen == expectedCount);
}
TEST_CASE("Firefly edge wrap preserves altitude anchor", "[firefly][motion]") {
Sim sim = build_grass_sim();
auto it = std::find_if(sim.entities.begin(), sim.entities.end(), [](const Entity& e) { return e.kind == EntityKind::Firefly; });
REQUIRE(it != sim.entities.end());
const double margin = FIREFLY_GLOW_RADIUS;
it->x = Monitor1920 + margin + 1.0;
it->vx = std::abs(it->vx);
const double altitude = it->altitudeAnchor;
sim.currentScene = Scene::Desert;
sim_tick_entities(sim, 0.016);
REQUIRE(it->x == Approx(-margin));
REQUIRE(it->altitudeAnchor == Approx(altitude));
}
TEST_CASE("Fireflies do not interact with cuts or pets", "[firefly][interaction]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim.entities.clear();
Entity firefly{};
firefly.kind = EntityKind::Firefly;
firefly.x = 500.0;
firefly.y = sim.windowHeight - STRIP_HEIGHT - 5.0;
firefly.vx = FIREFLY_DRIFT_SPEED_MIN;
firefly.baseSpeed = FIREFLY_DRIFT_SPEED_MIN;
firefly.altitudeAnchor = FIREFLY_ALTITUDE_MIN;
firefly.blinkPeriod = FIREFLY_BLINK_PERIOD_MIN;
firefly.lifetime = -1.0;
sim.entities.push_back(firefly);
Entity sheep{};
sheep.kind = EntityKind::Sheep;
sheep.x = firefly.x;
sheep.y = sim.windowHeight - SHEEP_BODY_HEIGHT - SHEEP_LEG_LENGTH;
sheep.vx = SHEEP_WALK_SPEED_MIN;
sheep.state = SHEEP_STATE_WALKING;
sheep.stateTimer = 10.0;
sim.entities.push_back(sheep);
InputEvent ev{};
ev.type = EventType::Click;
ev.x = firefly.x;
ev.y = firefly.y;
sim_apply_click(sim, ev);
REQUIRE(sim.entities[0].kind == EntityKind::Firefly);
REQUIRE(sim.entities[0].baseSpeed == Approx(FIREFLY_DRIFT_SPEED_MIN));
REQUIRE(sim.entities[1].state == SHEEP_STATE_WALKING);
for (const Blade& b : sim.blades) REQUIRE(b.cutAnimStart < 0.0);
}
TEST_CASE("Firefly blink brightness has on and off phases", "[firefly][blink]") {
const double period = 2.0;
REQUIRE(firefly_blink_brightness(period * 0.25, period, 0.0) == Approx(1.0));
REQUIRE(firefly_blink_brightness(period * 0.80, period, 0.0) == Approx(0.0));
}
TEST_CASE("Firefly phases decorrelate visible brightness", "[firefly][blink]") {
const double period = 2.0;
const double phases[] = { 0.0, 0.0375, 0.075, 0.1125, 0.25, 0.80 };
std::vector<double> distinct;
for (double phase : phases) {
const double b = firefly_blink_brightness(0.0, period, phase);
bool seen = false;
for (double existing : distinct) {
if (std::fabs(existing - b) < 1e-6) { seen = true; break; }
}
if (!seen) distinct.push_back(b);
}
REQUIRE(distinct.size() >= 4);
}

View File

@@ -1,54 +0,0 @@
// flower_tests.cpp
//
// Tests for §5 flower stream + §7 head-render contract.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <cmath>
#include <vector>
using namespace desktopgrass;
TEST_CASE("flower stream is deterministic for a given seed", "[flowers]") {
std::vector<Blade> a, b;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, a);
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, b);
REQUIRE(a.size() == b.size());
for (std::size_t i = 0; i < a.size(); ++i) {
REQUIRE(a[i].isFlower == b[i].isFlower);
REQUIRE(a[i].flowerHeadColorIdx == b[i].flowerHeadColorIdx);
REQUIRE(a[i].flowerHeadRadius == b[i].flowerHeadRadius);
REQUIRE(a[i].heightBonus == b[i].heightBonus);
}
}
TEST_CASE("flower count is within 3-sigma of FLOWER_PROBABILITY", "[flowers]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 100);
std::size_t flowerCount = 0;
for (const Blade& b : blades) if (b.isFlower) ++flowerCount;
const double n = static_cast<double>(blades.size());
const double p = FLOWER_PROBABILITY;
const double mu = n * p;
const double sd = std::sqrt(n * p * (1.0 - p));
// 3-sigma tolerance keeps this test stable across spec-conformant
// PRNG sequences. For seed=0x6B6173746F, n=321 we expect ~12.84 with
// sd≈3.51, so [2,24] is the acceptable range.
REQUIRE(flowerCount >= static_cast<std::size_t>(std::floor(mu - 3.0 * sd)));
REQUIRE(flowerCount <= static_cast<std::size_t>(std::ceil(mu + 3.0 * sd)));
}
TEST_CASE("flower stream does not perturb the main stream", "[flowers][conformance]") {
// Regenerate blades and assert the main-stream fields match the
// canonical snapshot. This is implicitly covered by blade_gen_tests
// (the first/last 10 still match), but pin it here for clarity.
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 0);
REQUIRE(blades[0].baseX == Approx(4.941073726820111).margin(1e-12));
REQUIRE(blades[0].height == Approx(24.469991818248864).margin(1e-12));
}

View File

@@ -1,164 +0,0 @@
// gust_tests.cpp
//
// Gust impulse model tests (architecture.md §8).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <cmath>
using namespace desktopgrass;
namespace {
Sim make_sim_with_blades(std::initializer_list<double> baseXs) {
Sim sim;
sim.windowHeight = STRIP_HEIGHT + HEADROOM;
for (double x : baseXs) {
Blade b{};
b.baseX = x;
b.height = 20.0;
b.thickness = 1.5;
b.swayPhaseOffset = 0.0;
b.stiffness = 1.0;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutAnimStart = -1.0;
sim.blades.push_back(b);
}
return sim;
}
InputEvent move(double x, double y, double t) {
return InputEvent{ EventType::Move, x, y, t };
}
} // anonymous
TEST_CASE("first move event is a baseline; no impulse", "[gust]") {
Sim sim = make_sim_with_blades({100.0});
const double groundY = sim.windowHeight;
const double bandY = groundY - 10.0; // in band
sim_apply_move(sim, move(100.0, bandY, 0.0));
REQUIRE(sim.blades[0].gustVelocity == Approx(0.0));
REQUIRE(sim.prevCursorTime == Approx(0.0));
}
TEST_CASE("a second move inside the band emits an impulse", "[gust]") {
Sim sim = make_sim_with_blades({100.0, 200.0, 400.0});
const double bandY = sim.windowHeight - 10.0;
sim_apply_move(sim, move( 0.0, bandY, 0.0));
sim_apply_move(sim, move(100.0, bandY, 0.05)); // velocity = 2000 DIP/sec
// Blade at 100 is right under the cursor → max impulse.
// Expected magnitude:
// capped = 2000 (≤ 4000 cap)
// impulseMagnitude = 2000 * 0.003 = 6.0
// smoothstep at distance 0 = 1.0
// smoothstep at distance 100/150 (blade @ 200) = (1-2/3)² * (3 - 2*(1-2/3))
REQUIRE(sim.blades[0].gustVelocity == Approx(6.0).margin(1e-9));
// Blade outside radius (400, dist=300 > 150) → no impulse.
REQUIRE(sim.blades[2].gustVelocity == Approx(0.0));
REQUIRE(sim.blades[1].gustVelocity > 0.0);
REQUIRE(sim.blades[1].gustVelocity < 6.0);
}
TEST_CASE("impulse is signed by motion direction", "[gust]") {
Sim left = make_sim_with_blades({100.0});
Sim right = make_sim_with_blades({100.0});
const double y = left.windowHeight - 10.0;
sim_apply_move(left, move(200.0, y, 0.0));
sim_apply_move(left, move(100.0, y, 0.05)); // moving left
sim_apply_move(right, move( 0.0, y, 0.0));
sim_apply_move(right, move(100.0, y, 0.05)); // moving right
REQUIRE(left.blades[0].gustVelocity < 0.0);
REQUIRE(right.blades[0].gustVelocity > 0.0);
REQUIRE(std::fabs(left.blades[0].gustVelocity) ==
Approx(std::fabs(right.blades[0].gustVelocity)).margin(1e-9));
}
TEST_CASE("cursor speed is capped at MAX_CURSOR_SPEED", "[gust]") {
Sim sim = make_sim_with_blades({100.0});
const double y = sim.windowHeight - 10.0;
sim_apply_move(sim, move(0.0, y, 0.0));
sim_apply_move(sim, move(100000.0, y, 0.05)); // velocity ≈ 2e6 DIP/sec
// capped magnitude = MAX_CURSOR_SPEED * IMPULSE_SCALE = 4000 * 0.003 = 12
// but the blade is at distance ~100k from cursor: outside radius → no impulse
REQUIRE(sim.blades[0].gustVelocity == Approx(0.0));
}
TEST_CASE("max impulse at the cursor equals capped magnitude", "[gust]") {
Sim sim = make_sim_with_blades({1000.0});
const double y = sim.windowHeight - 10.0;
sim_apply_move(sim, move(0.0, y, 0.0));
sim_apply_move(sim, move(1000.0, y, 0.0001)); // velocity huge → saturates
// Saturated: cursor lands at x=1000 (blade), distance=0, smoothstep=1.0
REQUIRE(sim.blades[0].gustVelocity ==
Approx(MAX_CURSOR_SPEED * IMPULSE_SCALE).margin(1e-9));
}
TEST_CASE("moves outside the gust band don't emit impulses", "[gust]") {
Sim sim = make_sim_with_blades({100.0});
const double y_above_band = sim.windowHeight - STRIP_HEIGHT - HEADROOM - 20.0;
sim_apply_move(sim, move( 0.0, y_above_band, 0.0));
sim_apply_move(sim, move(100.0, y_above_band, 0.05));
REQUIRE(sim.blades[0].gustVelocity == Approx(0.0));
}
TEST_CASE("out-of-band move updates baseline; re-entry parity", "[gust]") {
Sim sim = make_sim_with_blades({700.0});
const double inBandY = sim.windowHeight - 10.0;
const double outOfBandY = sim.windowHeight - STRIP_HEIGHT - HEADROOM - 20.0;
// t0 in-band: primes baseline (first event, no impulse).
sim_apply_move(sim, move(500.0, inBandY, 0.0));
// t1 out-of-band: updates baseline but emits no impulse.
sim_apply_move(sim, move(520.0, outOfBandY, 0.05));
REQUIRE(sim.prevCursorX == Approx(520.0));
REQUIRE(sim.prevCursorTime == Approx(0.05));
REQUIRE(sim.blades[0].gustVelocity == Approx(0.0));
// t2 re-enter in-band: emits impulse off the out-of-band baseline.
sim_apply_move(sim, move(700.0, inBandY, 0.10));
const double dtEv = std::max(0.10 - 0.05, 1.0 / 1000.0);
const double velX = (700.0 - 520.0) / dtEv;
const double capped = std::max(-MAX_CURSOR_SPEED, std::min(velX, MAX_CURSOR_SPEED));
const double expected = capped * IMPULSE_SCALE; // distance 0 → smoothstep = 1
REQUIRE(sim.blades[0].gustVelocity == Approx(expected).margin(1e-9));
}
TEST_CASE("large time gap resets cursor baseline", "[gust]") {
Sim sim = make_sim_with_blades({100.0});
const double y = sim.windowHeight - 10.0;
sim_apply_move(sim, move( 0.0, y, 0.0));
sim_apply_move(sim, move(500.0, y, 0.5)); // > CURSOR_REINIT_GAP_SEC (0.25)
REQUIRE(sim.blades[0].gustVelocity == Approx(0.0));
}
TEST_CASE("impulse falls off smoothly with distance", "[gust]") {
Sim sim = make_sim_with_blades({100.0, 130.0, 175.0, 200.0, 249.0, 251.0});
const double y = sim.windowHeight - 10.0;
sim_apply_move(sim, move( 0.0, y, 0.0));
sim_apply_move(sim, move(100.0, y, 0.05));
// Monotonic falloff: cursor at 100.
REQUIRE(sim.blades[0].gustVelocity > sim.blades[1].gustVelocity);
REQUIRE(sim.blades[1].gustVelocity > sim.blades[2].gustVelocity);
REQUIRE(sim.blades[2].gustVelocity > sim.blades[3].gustVelocity);
REQUIRE(sim.blades[3].gustVelocity > sim.blades[4].gustVelocity);
// Just outside radius (251 → distance 151 > 150) → zero.
REQUIRE(sim.blades[5].gustVelocity == Approx(0.0));
}

View File

@@ -1,371 +0,0 @@
// hedgehog_tests.cpp
//
// §17.9 Hedgehog critter tests. Mirrors Win2D HedgehogTests.cs.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <cwchar>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
Sim build_sim(uint64_t seed = CANONICAL_TEST_SEED) {
return sim_init(seed, Monitor1920, DEFAULT_DENSITY);
}
Sim build_grass_sim(uint64_t seed = CANONICAL_TEST_SEED) {
Sim sim = build_sim(seed);
sim_set_scene(sim, Scene::Grass);
sim_set_critter(sim, CritterKind::Bunny);
return sim;
}
Entity hedgehog_entity(double x = 500.0, double vx = HEDGEHOG_WALK_SPEED_MIN) {
Entity e{};
e.kind = EntityKind::Hedgehog;
e.size = HEDGEHOG_BODY_RADIUS;
e.x = x;
e.y = STRIP_HEIGHT + HEADROOM - HEDGEHOG_BODY_HEIGHT - HEDGEHOG_LEG_LENGTH;
e.vx = vx;
e.vy = 0.0;
e.rotationSpeed = std::abs(vx);
e.lifetime = -1.0;
e.state = HEDGEHOG_STATE_WALKING;
e.stateTimer = HEDGEHOG_WALK_DURATION_MIN;
e.previousState = HEDGEHOG_STATE_WALKING;
return e;
}
InputEvent click_event(double x, double y) {
InputEvent ev{};
ev.type = EventType::Click;
ev.x = x;
ev.y = y;
ev.time = 0.0;
return ev;
}
int prng_count(Prng& side, int minCount, int maxCount) {
const double draw = prng_uniform(side, static_cast<double>(minCount), static_cast<double>(maxCount + 1));
int count = static_cast<int>(std::floor(draw));
if (count < minCount) count = minCount;
if (count > maxCount) count = maxCount;
return count;
}
void advance_sheep(Prng& side, int count) {
for (int i = 0; i < count; ++i) {
const double margin = SHEEP_BODY_RADIUS + 8.0;
(void)prng_uniform(side, margin, Monitor1920 - margin);
(void)prng_uniform(side, SHEEP_WALK_SPEED_MIN, SHEEP_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, SHEEP_WALK_DURATION_MIN, SHEEP_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0])));
}
}
void advance_cats(Prng& side, int count) {
for (int i = 0; i < count; ++i) {
const double margin = CAT_BODY_RADIUS + 8.0;
(void)prng_uniform(side, margin, Monitor1920 - margin);
(void)prng_uniform(side, CAT_WALK_SPEED_MIN, CAT_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, CAT_WALK_DURATION_MIN, CAT_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(CAT_NAME_POOL) / sizeof(CAT_NAME_POOL[0])));
(void)prng_index(side, static_cast<uint32_t>(CAT_COAT_VARIANT_COUNT));
}
}
void advance_bunnies(Prng& side, int count) {
for (int i = 0; i < count; ++i) {
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u64(side);
(void)prng_uniform(side, BUNNY_HOP_SPEED_MIN, BUNNY_HOP_SPEED_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(BUNNY_NAME_POOL) / sizeof(BUNNY_NAME_POOL[0])));
}
}
bool hedgehog_name_in_pool(const Entity& e) {
if (e.nameIndex >= sizeof(HEDGEHOG_NAME_POOL) / sizeof(HEDGEHOG_NAME_POOL[0])) return false;
const wchar_t* name = HEDGEHOG_NAME_POOL[e.nameIndex];
for (const wchar_t* candidate : HEDGEHOG_NAME_POOL) {
if (std::wcscmp(name, candidate) == 0) return true;
}
return false;
}
} // namespace
TEST_CASE("Hedgehog constants are pinned to spec values", "[hedgehog][constants]") {
REQUIRE(HEDGEHOG_COUNT_MIN == 0);
REQUIRE(HEDGEHOG_COUNT_MAX == 1);
REQUIRE(HEDGEHOG_COUNT_PROBABILITY == Approx(0.55));
REQUIRE(HEDGEHOG_WALK_SPEED_MIN == Approx(4.0));
REQUIRE(HEDGEHOG_WALK_SPEED_MAX == Approx(8.0));
REQUIRE(HEDGEHOG_BODY_RADIUS == Approx(9.0));
REQUIRE(HEDGEHOG_BODY_HEIGHT == Approx(5.5));
REQUIRE(HEDGEHOG_HEAD_RADIUS == Approx(3.6));
REQUIRE(HEDGEHOG_NOSE_RADIUS == Approx(0.8));
REQUIRE(HEDGEHOG_LEG_LENGTH == Approx(2.5));
REQUIRE(HEDGEHOG_SPIKE_COUNT == 14);
REQUIRE(HEDGEHOG_SPIKE_LENGTH == Approx(3.0));
REQUIRE(HEDGEHOG_SPIKE_WIDTH == Approx(1.4));
REQUIRE(HEDGEHOG_SPIKE_ARC_START_DEG == Approx(-20.0));
REQUIRE(HEDGEHOG_SPIKE_ARC_END_DEG == Approx(200.0));
REQUIRE(HEDGEHOG_BODY_COLOR == 0xFF5C4633u);
REQUIRE(HEDGEHOG_SPIKE_COLOR == 0xFF3A2A1Fu);
REQUIRE(HEDGEHOG_SPIKE_TIP_COLOR == 0xFF1E150Eu);
REQUIRE(HEDGEHOG_NOSE_COLOR == 0xFF1A1208u);
REQUIRE(HEDGEHOG_EYE_COLOR == 0xFF1A1208u);
REQUIRE(HEDGEHOG_STATE_WALKING == 0);
REQUIRE(HEDGEHOG_STATE_SNUFFLING == 1);
REQUIRE(HEDGEHOG_STATE_IDLE == 2);
REQUIRE(HEDGEHOG_STATE_SLEEPING == 3);
REQUIRE(HEDGEHOG_STATE_CURLED == 4);
REQUIRE(HEDGEHOG_WALK_DURATION_MIN == Approx(6.0));
REQUIRE(HEDGEHOG_WALK_DURATION_MAX == Approx(12.0));
REQUIRE(HEDGEHOG_SNUFFLE_DURATION_MIN == Approx(3.0));
REQUIRE(HEDGEHOG_SNUFFLE_DURATION_MAX == Approx(6.0));
REQUIRE(HEDGEHOG_IDLE_DURATION_MIN == Approx(1.5));
REQUIRE(HEDGEHOG_IDLE_DURATION_MAX == Approx(3.0));
REQUIRE(HEDGEHOG_SLEEP_DURATION_MIN == Approx(10.0));
REQUIRE(HEDGEHOG_SLEEP_DURATION_MAX == Approx(25.0));
REQUIRE(HEDGEHOG_CURL_DURATION_MIN == Approx(3.0));
REQUIRE(HEDGEHOG_CURL_DURATION_MAX == Approx(5.5));
REQUIRE(HEDGEHOG_SNUFFLE_PROBABILITY == Approx(0.55));
REQUIRE(HEDGEHOG_IDLE_PROBABILITY == Approx(0.30));
REQUIRE(HEDGEHOG_SLEEP_PROB == Approx(0.50));
REQUIRE(HEDGEHOG_STARTLE_RADIUS == Approx(70.0));
REQUIRE(HEDGEHOG_SNUFFLE_HEAD_FREQ == Approx(5.0));
REQUIRE(HEDGEHOG_SNUFFLE_HEAD_AMP == Approx(0.7));
REQUIRE(HEDGEHOG_WADDLE_FREQ == Approx(4.0));
REQUIRE(HEDGEHOG_WADDLE_AMP == Approx(0.8));
REQUIRE(HEDGEHOG_ZZZ_CYCLE_SEC == Approx(SHEEP_ZZZ_CYCLE_SEC));
REQUIRE(HEDGEHOG_ZZZ_RISE == Approx(SHEEP_ZZZ_RISE * 0.5));
REQUIRE(HEDGEHOG_ZZZ_SIZE_START == Approx(SHEEP_ZZZ_SIZE_START * 0.6));
REQUIRE(HEDGEHOG_ZZZ_SIZE_END == Approx(SHEEP_ZZZ_SIZE_END * 0.6));
REQUIRE(sizeof(HEDGEHOG_NAME_POOL) / sizeof(HEDGEHOG_NAME_POOL[0]) == 12);
REQUIRE(std::wcscmp(HEDGEHOG_NAME_POOL[0], L"Bristle") == 0);
REQUIRE(std::wcscmp(HEDGEHOG_NAME_POOL[11], L"Burdock") == 0);
}
TEST_CASE("Hedgehog count distribution is probabilistic rare sighting", "[hedgehog][gen]") {
constexpr int N = 1000;
int present = 0;
for (uint64_t i = 0; i < N; ++i) {
const uint64_t seed = CANONICAL_TEST_SEED + i * 0x9E3779B97F4A7C15ull;
Sim sim = build_grass_sim(seed);
const int count = count_kind(sim, EntityKind::Hedgehog);
REQUIRE(count >= HEDGEHOG_COUNT_MIN);
REQUIRE(count <= HEDGEHOG_COUNT_MAX);
present += count;
}
REQUIRE(static_cast<double>(present) / N == Approx(HEDGEHOG_COUNT_PROBABILITY).margin(0.05));
}
TEST_CASE("Hedgehogs are Grass scene only", "[hedgehog][scene]") {
Sim sim = build_sim();
sim_set_scene(sim, Scene::Desert);
REQUIRE(count_kind(sim, EntityKind::Hedgehog) == 0);
sim_set_scene(sim, Scene::Winter);
REQUIRE(count_kind(sim, EntityKind::Hedgehog) == 0);
}
TEST_CASE("Generated hedgehogs have speed range", "[hedgehog][gen]") {
bool sawHedgehog = false;
for (uint64_t i = 0; i < 128; ++i) {
Sim sim = build_grass_sim(CANONICAL_TEST_SEED + i * 0xD1B54A32D192ED03ull);
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Hedgehog) continue;
sawHedgehog = true;
REQUIRE(std::abs(e.vx) >= HEDGEHOG_WALK_SPEED_MIN);
REQUIRE(std::abs(e.vx) <= HEDGEHOG_WALK_SPEED_MAX);
REQUIRE(e.rotationSpeed == Approx(std::abs(e.vx)));
}
}
REQUIRE(sawHedgehog);
}
TEST_CASE("Generated hedgehogs have names in pool", "[hedgehog][gen]") {
bool sawHedgehog = false;
for (uint64_t i = 0; i < 128; ++i) {
Sim sim = build_grass_sim(CANONICAL_TEST_SEED + i * 0x94D049BB133111EBull);
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Hedgehog) continue;
sawHedgehog = true;
REQUIRE(hedgehog_name_in_pool(e));
}
}
REQUIRE(sawHedgehog);
}
TEST_CASE("Hedgehog PRNG draw order follows sheep cats and bunnies", "[hedgehog][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = build_grass_sim();
const int sheepCount = prng_count(side, SHEEP_COUNT_MIN, SHEEP_COUNT_MAX);
advance_sheep(side, sheepCount);
const int catCount = prng_count(side, CAT_COUNT_MIN, CAT_COUNT_MAX);
advance_cats(side, catCount);
const int bunnyCount = prng_count(side, BUNNY_COUNT_MIN, BUNNY_COUNT_MAX);
advance_bunnies(side, bunnyCount);
const double hasDraw = prng_uniform(side, 0.0, 1.0);
const int hedgehogCount = hasDraw < HEDGEHOG_COUNT_PROBABILITY ? 1 : 0;
REQUIRE(count_kind(sim, EntityKind::Hedgehog) == hedgehogCount);
int seen = 0;
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::Hedgehog) continue;
const double margin = HEDGEHOG_BODY_RADIUS + 8.0;
const double xFrac = prng_uniform(side, 0.0, 1.0);
const double expectedX = margin + xFrac * (Monitor1920 - 2.0 * margin);
const uint64_t vxSign = prng_next_u64(side) & 1ull;
const double expectedDir = vxSign != 0ull ? 1.0 : -1.0;
const double expectedSpeed = prng_uniform(side, HEDGEHOG_WALK_SPEED_MIN, HEDGEHOG_WALK_SPEED_MAX);
const uint8_t expectedName = static_cast<uint8_t>(prng_index(side,
static_cast<uint32_t>(sizeof(HEDGEHOG_NAME_POOL) / sizeof(HEDGEHOG_NAME_POOL[0]))));
REQUIRE(e.x == Approx(expectedX));
REQUIRE(e.vx == Approx(expectedDir * expectedSpeed));
REQUIRE(e.nameIndex == expectedName);
++seen;
}
REQUIRE(seen == hedgehogCount);
}
TEST_CASE("Hedgehog edge bounce flips direction", "[hedgehog][motion]") {
Sim sim = build_sim();
sim.currentScene = Scene::Desert;
sim.entities.clear();
Entity e = hedgehog_entity(Monitor1920 - (HEDGEHOG_BODY_RADIUS + 2.0) + 0.1, HEDGEHOG_WALK_SPEED_MIN);
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities.front().vx < 0.0);
}
TEST_CASE("Hedgehog startle radius curls without flipping vx", "[hedgehog][click]") {
Sim sim = build_sim();
sim.entities.clear();
Entity e = hedgehog_entity(500.0, -HEDGEHOG_WALK_SPEED_MIN);
e.state = HEDGEHOG_STATE_WALKING;
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_apply_click(sim, click_event(e.x + 10.0, e.y));
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_CURLED);
REQUIRE(sim.entities.front().vx == Approx(-HEDGEHOG_WALK_SPEED_MIN));
REQUIRE(sim.entities.front().stateTimer >= HEDGEHOG_CURL_DURATION_MIN);
REQUIRE(sim.entities.front().stateTimer <= HEDGEHOG_CURL_DURATION_MAX);
Sim outside = build_sim();
outside.entities.clear();
Entity far = hedgehog_entity(500.0, HEDGEHOG_WALK_SPEED_MIN);
outside.entities.push_back(far);
sim_apply_click(outside, click_event(far.x + HEDGEHOG_STARTLE_RADIUS + 10.0, far.y));
REQUIRE(outside.entities.front().state == HEDGEHOG_STATE_WALKING);
REQUIRE(outside.entities.front().vx == Approx(HEDGEHOG_WALK_SPEED_MIN));
}
TEST_CASE("Hedgehog curl auto uncurls to previous state", "[hedgehog][state]") {
Sim sim = build_sim();
sim.entities.clear();
Entity e = hedgehog_entity(500.0, HEDGEHOG_WALK_SPEED_MIN);
e.state = HEDGEHOG_STATE_IDLE;
e.stateTimer = 2.5;
sim.entities.push_back(e);
sim_apply_click(sim, click_event(e.x, e.y));
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_CURLED);
sim_tick_entities(sim, HEDGEHOG_CURL_DURATION_MAX + 0.1);
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_IDLE);
REQUIRE(sim.entities.front().vx == Approx(HEDGEHOG_WALK_SPEED_MIN));
}
TEST_CASE("Hedgehog wakes from sleep on startle and does not resume sleep", "[hedgehog][click]") {
Sim sim = build_sim();
sim.entities.clear();
Entity e = hedgehog_entity(500.0, HEDGEHOG_WALK_SPEED_MIN);
e.state = HEDGEHOG_STATE_SLEEPING;
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_apply_click(sim, click_event(e.x + 10.0, e.y));
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_CURLED);
REQUIRE(sim.entities.front().state != HEDGEHOG_STATE_SLEEPING);
sim_tick_entities(sim, HEDGEHOG_CURL_DURATION_MAX + 0.1);
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_WALKING);
REQUIRE(sim.entities.front().state != HEDGEHOG_STATE_SLEEPING);
}
TEST_CASE("Hedgehog state transition probabilities are stable", "[hedgehog][state]") {
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
constexpr int N = 10000;
int snuffle = 0;
int idle = 0;
int sleep = 0;
for (int i = 0; i < N; ++i) {
const uint8_t state = hedgehog_choose_rest_state(p);
if (state == HEDGEHOG_STATE_SNUFFLING) ++snuffle;
else if (state == HEDGEHOG_STATE_IDLE) ++idle;
else if (state == HEDGEHOG_STATE_SLEEPING) ++sleep;
}
const double sleepProb = HEDGEHOG_SLEEP_PROB;
const double activeWeight = HEDGEHOG_SNUFFLE_PROBABILITY + HEDGEHOG_IDLE_PROBABILITY;
const double expectedSnuffle = (1.0 - sleepProb) * HEDGEHOG_SNUFFLE_PROBABILITY / activeWeight;
const double expectedIdle = (1.0 - sleepProb) * HEDGEHOG_IDLE_PROBABILITY / activeWeight;
REQUIRE(static_cast<double>(sleep) / N == Approx(sleepProb).margin(0.02));
REQUIRE(static_cast<double>(snuffle) / N == Approx(expectedSnuffle).margin(0.02));
REQUIRE(static_cast<double>(idle) / N == Approx(expectedIdle).margin(0.02));
}
TEST_CASE("Hedgehog sleep probability is stable", "[hedgehog][state]") {
constexpr int N = 20000;
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ 0x1234ull);
int sleep = 0;
for (int i = 0; i < N; ++i) {
if (hedgehog_choose_rest_state(p) == HEDGEHOG_STATE_SLEEPING) ++sleep;
}
REQUIRE(static_cast<double>(sleep) / N == Approx(HEDGEHOG_SLEEP_PROB).margin(0.02));
}
TEST_CASE("Hedgehog has no active interaction states", "[hedgehog][state]") {
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ 0xCAFEull);
for (int i = 0; i < 1000; ++i) {
const uint8_t state = hedgehog_choose_rest_state(p);
REQUIRE((state == HEDGEHOG_STATE_SNUFFLING
|| state == HEDGEHOG_STATE_IDLE
|| state == HEDGEHOG_STATE_SLEEPING));
}
Sim sim = build_sim();
sim.entities.clear();
Entity e = hedgehog_entity(500.0, HEDGEHOG_WALK_SPEED_MIN);
e.stateTimer = 10.0;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities.front().state == HEDGEHOG_STATE_WALKING);
REQUIRE(std::abs(sim.entities.front().vx) == Approx(HEDGEHOG_WALK_SPEED_MIN));
}

View File

@@ -1,7 +0,0 @@
// main.cpp
//
// Catch2 entry point. The Sim.cpp translation unit is also linked in via the
// vcxproj's source list so we can test it directly.
#define CATCH_CONFIG_MAIN
#include "../third_party/catch2/catch.hpp"

View File

@@ -1,89 +0,0 @@
// mushroom_tests.cpp
//
// Tests for §5 mushroom stream + §7 mushroom-render contract.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <vector>
using namespace desktopgrass;
TEST_CASE("mushroom stream is deterministic for a given seed", "[mushrooms]") {
std::vector<Blade> a, b;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, a);
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, b);
REQUIRE(a.size() == b.size());
for (std::size_t i = 0; i < a.size(); ++i) {
REQUIRE(a[i].isMushroom == b[i].isMushroom);
REQUIRE(a[i].mushroomCapColorIdx == b[i].mushroomCapColorIdx);
REQUIRE(a[i].mushroomCapWidth == b[i].mushroomCapWidth);
REQUIRE(a[i].mushroomCapHeight == b[i].mushroomCapHeight);
REQUIRE(a[i].mushroomStemHeight == b[i].mushroomStemHeight);
REQUIRE(a[i].mushroomStemThickness == b[i].mushroomStemThickness);
}
}
TEST_CASE("mushroom count is within 3-sigma of MUSHROOM_PROBABILITY", "[mushrooms]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 100);
std::size_t mushroomCount = 0;
for (const Blade& b : blades) if (b.isMushroom) ++mushroomCount;
const double n = static_cast<double>(blades.size());
const double p = MUSHROOM_PROBABILITY;
const double mu = n * p;
const double sd = std::sqrt(n * p * (1.0 - p));
// 3-sigma tolerance keeps this test stable across spec-conformant
// PRNG sequences. For seed=0x6B6173746F, n=321 we expect ~8.03 with
// sd≈2.80, so the inclusive 3-sigma range is roughly [0, 17].
const double lo = std::max(0.0, std::floor(mu - 3.0 * sd));
REQUIRE(mushroomCount >= static_cast<std::size_t>(lo));
REQUIRE(mushroomCount <= static_cast<std::size_t>(std::ceil(mu + 3.0 * sd)));
}
TEST_CASE("mushroom stream does not perturb the main stream", "[mushrooms][conformance]") {
// The mushroom stream is independent (seed ^ MUSHROOM_PRNG_SALT) so
// the main-stream first-blade values must still match the canonical.
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 0);
REQUIRE(blades[0].baseX == Approx(4.941073726820111).margin(1e-12));
REQUIRE(blades[0].height == Approx(24.469991818248864).margin(1e-12));
}
TEST_CASE("non-mushroom blades have zero mushroom fields", "[mushrooms]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
for (const Blade& b : blades) {
if (!b.isMushroom) {
REQUIRE(b.mushroomCapColorIdx == 0);
REQUIRE(b.mushroomCapWidth == 0.0);
REQUIRE(b.mushroomCapHeight == 0.0);
REQUIRE(b.mushroomStemHeight == 0.0);
REQUIRE(b.mushroomStemThickness == 0.0);
}
}
}
TEST_CASE("mushroom field ranges respect spec", "[mushrooms]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
for (const Blade& b : blades) {
if (b.isMushroom) {
REQUIRE(b.mushroomCapColorIdx < MUSHROOM_PALETTE_SIZE);
REQUIRE(b.mushroomCapWidth >= MUSHROOM_CAP_WIDTH_MIN);
REQUIRE(b.mushroomCapWidth < MUSHROOM_CAP_WIDTH_MAX);
REQUIRE(b.mushroomCapHeight >= MUSHROOM_CAP_HEIGHT_MIN);
REQUIRE(b.mushroomCapHeight < MUSHROOM_CAP_HEIGHT_MAX);
REQUIRE(b.mushroomStemHeight >= MUSHROOM_STEM_HEIGHT_MIN);
REQUIRE(b.mushroomStemHeight < MUSHROOM_STEM_HEIGHT_MAX);
REQUIRE(b.mushroomStemThickness >= MUSHROOM_STEM_THICKNESS_MIN);
REQUIRE(b.mushroomStemThickness < MUSHROOM_STEM_THICKNESS_MAX);
}
}
}

View File

@@ -1,114 +0,0 @@
// ocean_tests.cpp
//
// Ocean scene tests (architecture.md §17). Mirror of the Win2D OceanTests so
// the coral blade variant, bubble emitter, and fish swimmers stay in lockstep
// across impls.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
using namespace desktopgrass;
namespace {
constexpr double kMonitor1920 = 1920.0;
Sim make_ocean_sim(uint64_t seed = CANONICAL_TEST_SEED,
double width = kMonitor1920,
double density = DEFAULT_DENSITY) {
Sim sim = sim_init(seed, width, density);
sim_set_scene(sim, Scene::Ocean);
return sim;
}
int count_kind(const Sim& sim, EntityKind kind) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[kind](const Entity& e) { return e.kind == kind; }));
}
} // namespace
TEST_CASE("Ocean scene generates at least one coral and keeps values in range",
"[ocean][coral]") {
Sim sim = make_ocean_sim();
int coralCount = 0;
for (const Blade& b : sim.blades) {
if (!b.isCoral) continue;
++coralCount;
REQUIRE_FALSE(b.isPine);
REQUIRE_FALSE(b.isCactus);
REQUIRE_FALSE(b.isMaple);
REQUIRE_FALSE(b.isFlower);
REQUIRE_FALSE(b.isMushroom);
REQUIRE(b.coralHeight >= CORAL_HEIGHT_MIN);
REQUIRE(b.coralHeight <= CORAL_HEIGHT_MAX);
REQUIRE(b.coralWidth >= CORAL_WIDTH_MIN);
REQUIRE(b.coralWidth <= CORAL_WIDTH_MAX);
REQUIRE(static_cast<int>(b.coralType) >= 0);
REQUIRE(static_cast<int>(b.coralType) <= CORAL_TYPE_COUNT - 1);
REQUIRE(static_cast<int>(b.coralColorIdx) >= 0);
REQUIRE(static_cast<int>(b.coralColorIdx) <= CORAL_COLOR_COUNT - 1);
}
REQUIRE(coralCount > 0);
}
TEST_CASE("Ocean scene spawns initial fish at or above the target minimum",
"[ocean][fish]") {
Sim sim = make_ocean_sim();
const int fishCount = count_kind(sim, EntityKind::Fish);
REQUIRE(fishCount >= FISH_COUNT_MIN);
REQUIRE(fishCount <= FISH_COUNT_MAX);
}
TEST_CASE("Ocean fish count rounds half-to-even deterministically",
"[ocean][fish]") {
// scaled = 2.5 * width / 1920. Widths chosen so scaled lands exactly on a
// .5 tie; round-half-to-even must pick the even neighbor (NOT half-up),
// matching C# Math.Round and independent of the FPU rounding mode.
Sim tie25 = make_ocean_sim(CANONICAL_TEST_SEED, 1920.0); // scaled 2.5 -> 2
REQUIRE(count_kind(tie25, EntityKind::Fish) == 2);
Sim tie45 = make_ocean_sim(CANONICAL_TEST_SEED, 3456.0); // scaled 4.5 -> 4
REQUIRE(count_kind(tie45, EntityKind::Fish) == 4);
}
TEST_CASE("Ocean tick emits bubbles over time", "[ocean][bubble]") {
Sim sim = make_ocean_sim();
const double dt = 1.0 / 60.0;
for (int i = 0; i < 600; ++i) {
sim.globalTime += dt;
sim_tick_entities(sim, dt);
}
REQUIRE(count_kind(sim, EntityKind::Bubble) > 0);
}
TEST_CASE("Switching from Ocean to Grass wipes bubbles and fish",
"[ocean][scene]") {
Sim sim = make_ocean_sim();
const double dt = 1.0 / 60.0;
for (int i = 0; i < 120; ++i) {
sim.globalTime += dt;
sim_tick_entities(sim, dt);
}
REQUIRE(count_kind(sim, EntityKind::Fish) > 0);
sim_set_scene(sim, Scene::Grass);
REQUIRE(count_kind(sim, EntityKind::Bubble) == 0);
REQUIRE(count_kind(sim, EntityKind::Fish) == 0);
REQUIRE(std::none_of(sim.blades.begin(), sim.blades.end(),
[](const Blade& b) { return b.isCoral; }));
}
TEST_CASE("Ocean palette is pinned in scene palettes", "[ocean][palette]") {
for (int i = 0; i < PALETTE_SIZE; ++i) {
REQUIRE(SCENE_PALETTES[static_cast<int>(Scene::Ocean)][i] == OCEAN_PALETTE[i]);
}
}

View File

@@ -1,80 +0,0 @@
// pacing_tests.cpp
//
// FramePacer behaviour tests.
//
// Goal: lock in the contract that on supported Windows (10 1803+) the pacer
// honours sub-15.6 ms waits via the high-resolution waitable timer, not the
// default system timer resolution. A regression that drops the high-res flag
// would silently re-introduce the ~48 ms dt_p95 pacing bug; the timing-bound
// assertion below catches that without needing benchmark numbers.
//
// The timing assertions are deliberately generous (we measure absolute upper
// bounds, not exact wait times) so CI runners with momentary scheduling
// hiccups don't flake. Even at the loosest bound the test still distinguishes
// high-res (~sub-ms) from default-resolution (~15.6 ms minimum tick) behaviour.
#include "../third_party/catch2/catch.hpp"
#include "Pacing.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
using namespace desktopgrass;
namespace {
double qpc_now_sec() {
LARGE_INTEGER c{}, f{};
QueryPerformanceCounter(&c);
QueryPerformanceFrequency(&f);
return static_cast<double>(c.QuadPart) / static_cast<double>(f.QuadPart);
}
} // namespace
TEST_CASE("FramePacer: creates a high-resolution waitable timer on supported Windows",
"[pacing]") {
FramePacer pacer;
// DesktopGrass requires Windows 10 1809+, which is well past the
// CREATE_WAITABLE_TIMER_HIGH_RESOLUTION minimum (Win 10 1803). Build/CI
// environments below that floor are not supported.
REQUIRE(pacer.IsHighResolution());
}
TEST_CASE("FramePacer: zero or negative wait returns essentially immediately",
"[pacing]") {
FramePacer pacer;
const double t0 = qpc_now_sec();
pacer.WaitUntilNextFrame(0.0);
pacer.WaitUntilNextFrame(-1.0);
const double dt = qpc_now_sec() - t0;
// Two no-op calls should complete in well under a millisecond, but allow
// 5 ms of slop for loaded CI machines.
REQUIRE(dt < 0.005);
}
TEST_CASE("FramePacer: honours sub-15.6 ms waits via the high-resolution timer",
"[pacing]") {
FramePacer pacer;
REQUIRE(pacer.IsHighResolution());
// Five 1 ms waits. With the high-resolution timer the cumulative time
// should sit well below 30 ms. Without it (legacy ~15.6 ms tick) each
// wait would round up to ~15.6 ms for a total of ~78 ms, so 30 ms is a
// wide safety margin that still catches regressions cleanly.
constexpr int kIterations = 5;
constexpr double kWaitSec = 0.001;
const double t0 = qpc_now_sec();
for (int i = 0; i < kIterations; ++i) {
pacer.WaitUntilNextFrame(kWaitSec);
}
const double total = qpc_now_sec() - t0;
// Lower bound: we asked for 5 ms total — actual wait must be at least
// a small fraction of that, otherwise we are not waiting at all.
REQUIRE(total >= 0.0005);
// Upper bound: must beat the default ~15.6 ms tick by a comfortable
// margin. 30 ms catches the regression (78 ms) without flaking on CI.
REQUIRE(total < 0.030);
}

View File

@@ -1,285 +0,0 @@
#include "../third_party/catch2/catch.hpp"
#include "Persistence.h"
#include "Sim.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
using namespace desktopgrass;
namespace {
std::filesystem::path test_state_path(const char* name) {
std::filesystem::path dir = std::filesystem::current_path()
/ ".copilot-scratch"
/ "native-persistence-tests"
/ name;
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir);
return dir / "state.json";
}
void use_state_path(const std::filesystem::path& path) {
persistence::SetStateFilePathForTest(path.wstring());
}
void write_text(const std::filesystem::path& path, const std::string& text) {
std::filesystem::create_directories(path.parent_path());
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file << text;
}
std::string read_text(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
std::ostringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
persistence::AppState make_state_with_cuts() {
persistence::AppState state;
state.scene = Scene::Winter;
state.critter = CritterKind::Cat;
state.critterCountOverride = 4;
state.autoStart = true;
for (int i = 0; i < 3; ++i) {
persistence::MonitorState monitor;
monitor.width = 1920 + i * 320;
monitor.height = 1080 + i * 120;
monitor.left = i * 1920;
monitor.top = i == 2 ? -120 : 0;
const int cutCount = 2 + i;
for (int j = 0; j < cutCount; ++j) {
monitor.cuts.push_back(persistence::CutRecord{ i * 100 + j, -5.0 - i - j * 0.5 });
}
state.monitors.push_back(monitor);
}
return state;
}
void assert_state_equal(const persistence::AppState& expected, const persistence::AppState& actual) {
REQUIRE(actual.version == 2);
REQUIRE(actual.scene == expected.scene);
REQUIRE(actual.critter == expected.critter);
REQUIRE(actual.critterCountOverride == expected.critterCountOverride);
REQUIRE(actual.autoStart == expected.autoStart);
REQUIRE(actual.monitors.size() == expected.monitors.size());
for (std::size_t i = 0; i < expected.monitors.size(); ++i) {
const auto& e = expected.monitors[i];
const auto& a = actual.monitors[i];
REQUIRE(a.width == e.width);
REQUIRE(a.height == e.height);
REQUIRE(a.left == e.left);
REQUIRE(a.top == e.top);
REQUIRE(a.cuts.size() == e.cuts.size());
for (std::size_t j = 0; j < e.cuts.size(); ++j) {
REQUIRE(a.cuts[j].bladeIndex == e.cuts[j].bladeIndex);
REQUIRE(a.cuts[j].cutTime == Approx(e.cuts[j].cutTime).margin(1e-9));
}
}
}
Blade make_blade(double regrowDelay, double regrowDuration) {
Blade b{};
b.baseX = 100.0;
b.height = 20.0;
b.thickness = 1.0;
b.cutHeight = 1.0;
b.cutAnimStart = -1.0;
b.cutInitialHeight = 1.0;
b.regrowDelay = regrowDelay;
b.regrowDuration = regrowDuration;
b.regrowStart = -1.0;
return b;
}
} // namespace
TEST_CASE("persistence round-trips empty state", "[persistence]") {
const auto path = test_state_path("round-trip-empty");
use_state_path(path);
persistence::AppState expected;
REQUIRE(persistence::SaveAppState(expected));
persistence::AppState actual;
REQUIRE(persistence::LoadAppState(actual));
assert_state_equal(expected, actual);
}
TEST_CASE("persistence round-trips state with cuts", "[persistence]") {
const auto path = test_state_path("round-trip-cuts");
use_state_path(path);
const persistence::AppState expected = make_state_with_cuts();
REQUIRE(persistence::SaveAppState(expected));
persistence::AppState actual;
REQUIRE(persistence::LoadAppState(actual));
assert_state_equal(expected, actual);
}
TEST_CASE("persistence round-trips every scene", "[persistence]") {
const Scene scenes[] = {
Scene::Grass, Scene::Desert, Scene::Winter, Scene::Autumn, Scene::Ocean
};
for (Scene scene : scenes) {
const auto path = test_state_path("round-trip-scene");
use_state_path(path);
persistence::AppState expected;
expected.scene = scene;
REQUIRE(persistence::SaveAppState(expected));
persistence::AppState actual;
REQUIRE(persistence::LoadAppState(actual));
REQUIRE(actual.scene == scene);
}
}
TEST_CASE("persistence version mismatch returns false", "[persistence]") {
const auto path = test_state_path("version-mismatch");
use_state_path(path);
write_text(path, "{ \"version\": 999, \"monitors\": {} }");
persistence::AppState actual;
REQUIRE_FALSE(persistence::LoadAppState(actual));
}
TEST_CASE("persistence missing file returns false", "[persistence]") {
const auto path = test_state_path("missing-file");
use_state_path(path);
persistence::AppState actual;
REQUIRE_FALSE(persistence::LoadAppState(actual));
}
TEST_CASE("persistence malformed json returns false", "[persistence]") {
const auto path = test_state_path("malformed-json");
use_state_path(path);
write_text(path, "not-json");
persistence::AppState actual;
REQUIRE_FALSE(persistence::LoadAppState(actual));
}
TEST_CASE("persistence atomic write leaves final file and removes tmp", "[persistence]") {
const auto path = test_state_path("atomic-write");
use_state_path(path);
persistence::AppState state;
REQUIRE(persistence::SaveAppState(state));
REQUIRE(std::filesystem::exists(path));
REQUIRE_FALSE(std::filesystem::exists(std::filesystem::path(path.wstring() + L".tmp")));
}
TEST_CASE("persistence monitor key format round-trips", "[persistence]") {
const auto path = test_state_path("monitor-key");
use_state_path(path);
persistence::AppState state;
persistence::MonitorState monitor;
monitor.width = 1920;
monitor.height = 1080;
monitor.left = 0;
monitor.top = 0;
state.monitors.push_back(monitor);
REQUIRE(persistence::SaveAppState(state));
REQUIRE(read_text(path).find("\"1920x1080@0,0\"") != std::string::npos);
persistence::AppState loaded;
REQUIRE(persistence::LoadAppState(loaded));
REQUIRE(loaded.monitors.size() == 1);
REQUIRE(persistence::MonitorKey(loaded.monitors[0]) == "1920x1080@0,0");
}
TEST_CASE("persistence cut timestamps shift for fresh sim load", "[persistence]") {
const auto path = test_state_path("time-shift");
use_state_path(path);
Sim running;
running.globalTime = 100.0;
running.blades.push_back(make_blade(30.0, 10.0));
running.blades[0].cutHeight = 0.0;
running.blades[0].regrowStart = 80.0 + CUT_DURATION_SEC + running.blades[0].regrowDelay;
auto cuts = sim_get_cuts(running);
REQUIRE(cuts.size() == 1);
REQUIRE(cuts[0].cutTime == Approx(-20.0).margin(1e-9));
persistence::AppState state;
persistence::MonitorState monitor;
monitor.width = 1920;
monitor.height = 1080;
monitor.left = 0;
monitor.top = 0;
monitor.cuts = cuts;
state.monitors.push_back(monitor);
REQUIRE(persistence::SaveAppState(state));
persistence::AppState loaded;
REQUIRE(persistence::LoadAppState(loaded));
REQUIRE(loaded.monitors[0].cuts[0].cutTime < 0.0);
Sim fresh;
fresh.globalTime = 0.0;
fresh.blades.push_back(make_blade(30.0, 10.0));
sim_apply_cuts(fresh, loaded.monitors[0].cuts);
REQUIRE(fresh.blades[0].cutHeight == Approx(0.0).margin(1e-9));
REQUIRE(fresh.blades[0].regrowStart == Approx(10.0 + CUT_DURATION_SEC).margin(1e-9));
}
TEST_CASE("persistence unmatched monitor cuts are skipped", "[persistence]") {
const auto path = test_state_path("unmatched-monitor");
use_state_path(path);
persistence::AppState state;
persistence::MonitorState unmatched;
unmatched.width = 9999;
unmatched.height = 9999;
unmatched.left = 99;
unmatched.top = 99;
unmatched.cuts.push_back(persistence::CutRecord{ 0, -20.0 });
state.monitors.push_back(unmatched);
REQUIRE(persistence::SaveAppState(state));
persistence::AppState loaded;
REQUIRE(persistence::LoadAppState(loaded));
Sim sim;
sim.blades.push_back(make_blade(30.0, 10.0));
const int width = 1920;
const int height = 1080;
const int left = 0;
const int top = 0;
const auto match = std::find_if(loaded.monitors.begin(), loaded.monitors.end(),
[&](const persistence::MonitorState& monitor) {
return monitor.width == width && monitor.height == height
&& monitor.left == left && monitor.top == top;
});
if (match != loaded.monitors.end()) {
sim_apply_cuts(sim, match->cuts);
}
REQUIRE(sim_get_cuts(sim).empty());
}
TEST_CASE("persistence json is human readable", "[persistence]") {
const auto path = test_state_path("human-readable");
use_state_path(path);
REQUIRE(persistence::SaveAppState(make_state_with_cuts()));
const std::string text = read_text(path);
REQUIRE(text.find('\n') != std::string::npos);
REQUIRE(text.find(" \"version\"") != std::string::npos);
REQUIRE(text.find(" \"") != std::string::npos);
}

View File

@@ -1,255 +0,0 @@
// pine_tests.cpp - §15.1 Winter pine trees (slot-bound, mirrors §14 cacti).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <cmath>
#include <cstddef>
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double kMonitor1920 = 1920.0;
struct ExpectedTree {
std::size_t slotIndex = 0;
uint8_t variant = 0;
double height = 0.0;
double width = 0.0;
int tierCount = 0;
};
ExpectedTree first_expected_tree(std::size_t bladeCount) {
Prng p;
prng_init(p, CANONICAL_TEST_SEED ^ PINE_PRNG_SALT);
for (std::size_t i = 0; i < bladeCount; ++i) {
const double r = prng_uniform(p, 0.0, 1.0);
if (r >= PINE_PROBABILITY) continue;
ExpectedTree expected{};
expected.slotIndex = i;
const double variantDraw = prng_uniform(p, 0.0, 1.0);
expected.variant = variantDraw < BIRCH_VARIANT_PROBABILITY ? 1 : 0;
expected.height = prng_uniform(p, PINE_HEIGHT_MIN, PINE_HEIGHT_MAX);
if (expected.variant == 1) {
expected.width = prng_uniform(p, BIRCH_TRUNK_WIDTH_MIN, BIRCH_TRUNK_WIDTH_MAX);
} else {
expected.width = prng_uniform(p, PINE_WIDTH_MIN, PINE_WIDTH_MAX);
}
const double tierDraw = prng_uniform(p,
static_cast<double>(PINE_TIER_COUNT_MIN),
static_cast<double>(PINE_TIER_COUNT_MAX + 1));
int tiers = static_cast<int>(std::floor(tierDraw));
if (tiers < PINE_TIER_COUNT_MIN) tiers = PINE_TIER_COUNT_MIN;
if (tiers > PINE_TIER_COUNT_MAX) tiers = PINE_TIER_COUNT_MAX;
expected.tierCount = tiers;
return expected;
}
FAIL("canonical seed produced no tree slot");
return {};
}
} // anonymous
TEST_CASE("Pine constants are pinned", "[pine][constants]") {
REQUIRE(PINE_PROBABILITY == Approx(0.0075));
REQUIRE(PINE_HEIGHT_MIN == Approx(45.0));
REQUIRE(PINE_HEIGHT_MAX == Approx(90.0));
REQUIRE(PINE_WIDTH_MIN == Approx(28.0));
REQUIRE(PINE_WIDTH_MAX == Approx(48.0));
REQUIRE(PINE_TIER_COUNT_MIN == 2);
REQUIRE(PINE_TIER_COUNT_MAX == 4);
REQUIRE(PINE_TIP_TAPER == Approx(0.25));
REQUIRE(PINE_TIER_OVERLAP == Approx(0.15));
REQUIRE(PINE_SNOW_CAP_FRACTION == Approx(0.30));
REQUIRE(PINE_COLOR == 0xFF1B5E20u);
REQUIRE(PINE_PRNG_SALT == 0x50494E4550494E45ull);
}
TEST_CASE("Birch constants are pinned", "[pine][birch][constants]") {
REQUIRE(BIRCH_VARIANT_PROBABILITY == Approx(0.30));
REQUIRE(BIRCH_TRUNK_WIDTH_MIN == Approx(4.0));
REQUIRE(BIRCH_TRUNK_WIDTH_MAX == Approx(7.0));
REQUIRE(BIRCH_BARK_MARK_COUNT == 5);
REQUIRE(BIRCH_BARK_MARK_LENGTH_FRAC == Approx(0.50));
REQUIRE(BIRCH_BRANCH_COUNT == 6);
REQUIRE(BIRCH_SNOW_CAP_FRACTION == Approx(0.18));
REQUIRE(BIRCH_BARK_COLOR == 0xFFEFEFE6u);
REQUIRE(BIRCH_MARK_COLOR == 0xFF2A2A28u);
}
TEST_CASE("sim_set_scene Winter promotes some slots to trees", "[pine][scene]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.currentScene == Scene::Winter);
std::size_t treeCount = 0;
for (const Blade& b : sim.blades) {
if (b.isPine) {
++treeCount;
REQUIRE(b.pineTierCount >= PINE_TIER_COUNT_MIN);
REQUIRE(b.pineTierCount <= PINE_TIER_COUNT_MAX);
REQUIRE(b.pineHeight >= PINE_HEIGHT_MIN);
REQUIRE(b.pineHeight <= PINE_HEIGHT_MAX);
const double widthMin = (b.treeVariant == 1) ? BIRCH_TRUNK_WIDTH_MIN : PINE_WIDTH_MIN;
const double widthMax = (b.treeVariant == 1) ? BIRCH_TRUNK_WIDTH_MAX : PINE_WIDTH_MAX;
REQUIRE(b.pineWidth >= widthMin);
REQUIRE(b.pineWidth <= widthMax);
}
}
REQUIRE(treeCount >= 1);
REQUIRE(treeCount <= 25);
}
TEST_CASE("First tree matches the spec-derived PRNG snapshot", "[pine][snapshot]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
const ExpectedTree expected = first_expected_tree(sim.blades.size());
sim_set_scene(sim, Scene::Winter);
REQUIRE(expected.slotIndex < sim.blades.size());
const Blade& b = sim.blades[expected.slotIndex];
REQUIRE(b.isPine);
REQUIRE(b.treeVariant == expected.variant);
REQUIRE(b.pineHeight == Approx(expected.height).margin(1e-12));
REQUIRE(b.pineWidth == Approx(expected.width).margin(1e-12));
REQUIRE(b.pineTierCount == expected.tierCount);
}
TEST_CASE("Grass scene restores tree slots to vanilla variants", "[pine][restore]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
const ExpectedTree expected = first_expected_tree(sim.blades.size());
REQUIRE(expected.slotIndex < sim.blades.size());
Blade& target = sim.blades[expected.slotIndex];
target.isFlower = true;
target.isMushroom = true;
target.originalIsFlower = true;
target.originalIsMushroom = true;
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.blades[expected.slotIndex].isPine);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isFlower);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isMushroom);
sim_set_scene(sim, Scene::Grass);
REQUIRE_FALSE(sim.blades[expected.slotIndex].isPine);
REQUIRE(sim.blades[expected.slotIndex].treeVariant == 0);
REQUIRE(sim.blades[expected.slotIndex].isFlower);
REQUIRE(sim.blades[expected.slotIndex].isMushroom);
}
TEST_CASE("Winter produces both pine and birch variants over canonical seed", "[pine][birch]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Winter);
std::size_t pineCount = 0;
std::size_t birchCount = 0;
for (const Blade& b : sim.blades) {
if (!b.isPine) continue;
if (b.treeVariant == 0) {
++pineCount;
REQUIRE(b.pineWidth >= PINE_WIDTH_MIN);
REQUIRE(b.pineWidth <= PINE_WIDTH_MAX);
} else {
REQUIRE(b.treeVariant == 1);
++birchCount;
REQUIRE(b.pineWidth >= BIRCH_TRUNK_WIDTH_MIN);
REQUIRE(b.pineWidth <= BIRCH_TRUNK_WIDTH_MAX);
}
}
REQUIRE(pineCount >= 1);
REQUIRE(birchCount >= 1);
}
TEST_CASE("Winter scene suppresses mushrooms on every slot", "[pine][winter][mushroom]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
// Pre-mark a handful of slots as mushrooms; Winter must clear them all.
for (std::size_t i = 0; i < sim.blades.size(); i += 17) {
sim.blades[i].isMushroom = true;
sim.blades[i].originalIsMushroom = true;
}
sim_set_scene(sim, Scene::Winter);
for (const Blade& b : sim.blades) REQUIRE_FALSE(b.isMushroom);
// Switching back to Grass must restore the original mushroom flags.
sim_set_scene(sim, Scene::Grass);
REQUIRE(sim.blades[0].isMushroom == sim.blades[0].originalIsMushroom);
}
TEST_CASE("Winter grass height scale is pinned", "[pine][winter][scale]") {
REQUIRE(WINTER_GRASS_HEIGHT_SCALE == Approx(0.5));
}
TEST_CASE("Tree depth constants are pinned", "[pine][depth][constants]") {
REQUIRE(TREE_BACKGROUND_PROBABILITY == Approx(0.45));
REQUIRE(TREE_BG_SCALE == Approx(0.62));
REQUIRE(TREE_BG_OPACITY == Approx(0.78f));
}
TEST_CASE("Winter mixes foreground and background trees", "[pine][depth]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Winter);
std::size_t fg = 0;
std::size_t bg = 0;
for (const Blade& b : sim.blades) {
if (!b.isPine) continue;
if (b.treeBackground) ++bg; else ++fg;
}
REQUIRE(fg >= 1);
REQUIRE(bg >= 1);
}
TEST_CASE("Tree depth assignment is deterministic across re-entry", "[pine][depth]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Winter);
std::vector<bool> firstPass;
for (const Blade& b : sim.blades) {
if (b.isPine) firstPass.push_back(b.treeBackground);
}
// Leaving and re-entering Winter must reproduce the same depth layout.
sim_set_scene(sim, Scene::Grass);
sim_set_scene(sim, Scene::Winter);
std::size_t idx = 0;
for (const Blade& b : sim.blades) {
if (!b.isPine) continue;
REQUIRE(idx < firstPass.size());
REQUIRE(b.treeBackground == firstPass[idx]);
++idx;
}
REQUIRE(idx == firstPass.size());
}
TEST_CASE("Non-winter scenes clear the tree background flag", "[pine][depth][restore]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Winter);
sim_set_scene(sim, Scene::Grass);
for (const Blade& b : sim.blades) REQUIRE_FALSE(b.treeBackground);
}
TEST_CASE("Winter scene leaves the canonical first blade geometry bit-identical", "[pine][snapshot]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, kMonitor1920, 1.0);
REQUIRE(sim.blades.size() == desktopgrass::test::CANONICAL_BLADE_COUNT);
sim_set_scene(sim, Scene::Winter);
const Blade& first = sim.blades[0];
const auto& expected = desktopgrass::test::CANONICAL_FIRST_10[0];
REQUIRE(first.baseX == Approx(expected.baseX).margin(1e-12));
REQUIRE(first.height == Approx(expected.height).margin(1e-12));
REQUIRE(first.thickness == Approx(expected.thickness).margin(1e-12));
REQUIRE(first.hue == expected.hue);
REQUIRE(first.swayPhaseOffset == Approx(expected.sway).margin(1e-12));
REQUIRE(first.stiffness == Approx(expected.stiffness).margin(1e-12));
}

View File

@@ -1,78 +0,0 @@
// prng_tests.cpp
//
// Conformance + snapshot tests for the PRNG (architecture.md §3).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
using namespace desktopgrass;
using namespace desktopgrass::test;
TEST_CASE("PRNG matches the canonical 16-output snapshot", "[prng][snapshot]") {
Prng p;
prng_init(p, CANONICAL_TEST_SEED);
for (std::size_t i = 0; i < 16; ++i) {
uint64_t v = prng_next_u64(p);
INFO("index = " << i);
REQUIRE(v == CANONICAL_PRNG_SNAPSHOT[i]);
}
}
TEST_CASE("PRNG is deterministic for a given seed", "[prng]") {
Prng a, b;
prng_init(a, CANONICAL_TEST_SEED);
prng_init(b, CANONICAL_TEST_SEED);
for (int i = 0; i < 1000; ++i) {
REQUIRE(prng_next_u64(a) == prng_next_u64(b));
}
}
TEST_CASE("PRNG decorrelates seed=0 via splitmix64", "[prng]") {
// seed == 0 must not produce a stuck-at-zero PRNG.
Prng p;
prng_init(p, 0);
REQUIRE(p.state != 0);
uint64_t a = prng_next_u64(p);
uint64_t b = prng_next_u64(p);
REQUIRE(a != 0);
REQUIRE(b != 0);
REQUIRE(a != b);
}
TEST_CASE("prng_next_unit is in [0, 1)", "[prng]") {
Prng p;
prng_init(p, CANONICAL_TEST_SEED);
for (int i = 0; i < 10000; ++i) {
double u = prng_next_unit(p);
REQUIRE(u >= 0.0);
REQUIRE(u < 1.0);
}
}
TEST_CASE("prng_uniform stays within [lo, hi)", "[prng]") {
Prng p;
prng_init(p, 12345);
for (int i = 0; i < 10000; ++i) {
double v = prng_uniform(p, 8.0, 40.0);
REQUIRE(v >= 8.0);
REQUIRE(v < 40.0);
}
}
TEST_CASE("prng_index is in [0, n)", "[prng]") {
Prng p;
prng_init(p, 42);
bool sawZero = false;
bool sawFive = false;
for (int i = 0; i < 10000; ++i) {
uint32_t v = prng_index(p, PALETTE_SIZE);
REQUIRE(v < PALETTE_SIZE);
if (v == 0) sawZero = true;
if (v == 5) sawFive = true;
}
// Distribution sanity. Not strict — just confirms we cover both extremes.
REQUIRE(sawZero);
REQUIRE(sawFive);
}

View File

@@ -1,136 +0,0 @@
#include "catch.hpp"
#include "Sim.h"
#include "Constants.h"
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double kMonitor1920 = 1920.0;
constexpr double kDensity = 1.0;
constexpr uint64_t kSeed = 0xDE5C70F6A55ED511ull;
struct Prop {
double leftEdge;
double rightEdge;
};
double cactus_half_width(const Blade& b) {
return (b.cactusType != 0) ? b.cactusWidth * 1.55 : b.cactusWidth * 0.5;
}
double pine_half_width(const Blade& b) {
double hw = (b.treeVariant == 1) ? b.pineWidth * 4.0 : b.pineWidth * 0.5;
if (b.treeBackground) hw *= TREE_BG_SCALE;
return hw;
}
// Walk the prop list left-to-right and verify that every adjacent pair has
// at least PROP_MIN_GAP_DIP between the right edge of one and the left edge
// of the next. The generators emit props in baseX order so a single linear
// pass is sufficient.
void require_spacing(const std::vector<Prop>& props, double minGap, const char* label) {
INFO(label << ": " << props.size() << " props placed");
REQUIRE(props.size() >= 1);
for (std::size_t i = 1; i < props.size(); ++i) {
const double gap = props[i].leftEdge - props[i - 1].rightEdge;
INFO("pair " << (i - 1) << "" << i
<< " right=" << props[i - 1].rightEdge
<< " left=" << props[i].leftEdge
<< " gap=" << gap);
REQUIRE(gap >= minGap);
}
}
} // namespace
TEST_CASE("Desert cacti keep at least PROP_MIN_GAP_DIP between neighbours",
"[spacing][desert][cactus]") {
Sim sim = sim_init(kSeed, kMonitor1920, kDensity);
sim_set_scene(sim, Scene::Desert);
std::vector<Prop> cacti;
for (const Blade& b : sim.blades) {
if (!b.isCactus) continue;
const double hw = cactus_half_width(b);
cacti.push_back({b.baseX - hw, b.baseX + hw});
}
require_spacing(cacti, PROP_MIN_GAP_DIP, "cacti");
}
TEST_CASE("Winter pines keep at least PROP_MIN_GAP_DIP between same-layer neighbours",
"[spacing][winter][pine]") {
Sim sim = sim_init(kSeed, kMonitor1920, kDensity);
sim_set_scene(sim, Scene::Winter);
std::vector<Prop> fgPines;
std::vector<Prop> bgPines;
for (const Blade& b : sim.blades) {
if (!b.isPine) continue;
const double hw = pine_half_width(b);
Prop p{b.baseX - hw, b.baseX + hw};
if (b.treeBackground) bgPines.push_back(p);
else fgPines.push_back(p);
}
require_spacing(fgPines, PROP_MIN_GAP_DIP, "foreground pines");
require_spacing(bgPines, PROP_MIN_GAP_DIP, "background pines");
}
TEST_CASE("Autumn maples keep at least PROP_MIN_GAP_DIP between neighbours",
"[spacing][autumn][maple]") {
Sim sim = sim_init(kSeed, kMonitor1920, kDensity);
sim_set_scene(sim, Scene::Autumn);
std::vector<Prop> maples;
for (const Blade& b : sim.blades) {
if (!b.isMaple) continue;
const double hw = b.mapleCanopyRadius;
maples.push_back({b.baseX - hw, b.baseX + hw});
}
require_spacing(maples, PROP_MIN_GAP_DIP, "maples");
}
TEST_CASE("Ocean coral keep at least PROP_MIN_GAP_DIP between neighbours",
"[spacing][ocean][coral]") {
Sim sim = sim_init(kSeed, kMonitor1920, kDensity);
sim_set_scene(sim, Scene::Ocean);
std::vector<Prop> coral;
for (const Blade& b : sim.blades) {
if (!b.isCoral) continue;
const double hw = b.coralWidth * 0.5;
coral.push_back({b.baseX - hw, b.baseX + hw});
}
require_spacing(coral, PROP_MIN_GAP_DIP, "coral");
}
TEST_CASE("Prop spacing rule reduces but doesn't decimate the population",
"[spacing][population]") {
// Sanity check that gap rejection isn't aggressive enough to break the
// existing "near-spec probability" tests — each scene should still place
// at least a handful of props on a 1920-DIP window with canonical seed.
Sim sim = sim_init(kSeed, kMonitor1920, kDensity);
sim_set_scene(sim, Scene::Desert);
int cactusCount = 0;
for (const Blade& b : sim.blades) if (b.isCactus) ++cactusCount;
REQUIRE(cactusCount >= 1);
sim_set_scene(sim, Scene::Winter);
int pineCount = 0;
for (const Blade& b : sim.blades) if (b.isPine) ++pineCount;
REQUIRE(pineCount >= 3);
sim_set_scene(sim, Scene::Autumn);
int mapleCount = 0;
for (const Blade& b : sim.blades) if (b.isMaple) ++mapleCount;
REQUIRE(mapleCount >= 1);
sim_set_scene(sim, Scene::Ocean);
int coralCount = 0;
for (const Blade& b : sim.blades) if (b.isCoral) ++coralCount;
REQUIRE(coralCount >= 5);
}

View File

@@ -1,196 +0,0 @@
// regrowth_tests.cpp
//
// Regrowth lifecycle tests (architecture.md §9 "Regrowth").
//
// Lifecycle: alive (cutHeight=1) -> cut anim (0.2s) -> stump (cutHeight=0,
// regrowStart scheduled) -> wait regrowDelay -> regrow (linear over
// regrowDuration) -> alive again.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <cmath>
using namespace desktopgrass;
namespace {
// A test blade that opts in to regrowth — sets delay and duration to small,
// known values so we can deterministically tick through the lifecycle.
Blade make_regrowing_blade(double baseX, double regrowDelay, double regrowDuration) {
Blade b{};
b.baseX = baseX;
b.height = 20.0;
b.thickness = 1.5;
b.swayPhaseOffset = 0.0;
b.stiffness = 1.0;
b.cutHeight = 1.0;
b.cutInitialHeight = 1.0;
b.cutAnimStart = -1.0;
b.regrowDelay = regrowDelay;
b.regrowDuration = regrowDuration;
b.regrowStart = -1.0;
return b;
}
Sim make_sim_with(Blade b) {
Sim sim;
sim.windowHeight = STRIP_HEIGHT + HEADROOM;
sim.blades.push_back(b);
return sim;
}
InputEvent click(double x, double y, double t) {
return InputEvent{ EventType::Click, x, y, t };
}
} // anonymous
TEST_CASE("cut completion schedules regrowth", "[regrowth]") {
Sim sim = make_sim_with(make_regrowing_blade(100.0, /*delay=*/1.0, /*dur=*/0.5));
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
// Run the cut animation to completion (200 ms).
for (int i = 0; i < 4; ++i) sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
// regrowStart is scheduled at globalTime + regrowDelay = 0.2 + 1.0 = 1.2.
REQUIRE(sim.blades[0].regrowStart == Approx(1.2).margin(1e-9));
}
TEST_CASE("regrowth is linear over regrowDuration", "[regrowth]") {
Sim sim = make_sim_with(make_regrowing_blade(100.0, /*delay=*/0.5, /*dur=*/0.4));
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
// Cut animation: 4 x 50 ms -> globalTime=0.20, cutHeight=0, regrowStart=0.70.
for (int i = 0; i < 4; ++i) sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].regrowStart == Approx(0.70).margin(1e-9));
// Tick through the regrow delay (0.5s = 10 frames). Blade stays cut.
for (int i = 0; i < 10; ++i) {
sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0).margin(1e-9));
}
// globalTime = 0.70 now (start of regrowth).
// Quarter of the way through regrowth (dur=0.4 -> 0.10 elapsed): cutHeight = 0.25.
sim_tick(sim, 0.10, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.25).margin(1e-9));
// Half way: cutHeight = 0.5.
sim_tick(sim, 0.10, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.5).margin(1e-9));
// Three quarters: cutHeight = 0.75.
sim_tick(sim, 0.10, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.75).margin(1e-9));
// Full: cutHeight = 1.0, regrowStart idle.
sim_tick(sim, 0.10, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(1.0).margin(1e-9));
REQUIRE(sim.blades[0].regrowStart < 0.0);
// After regrowth, further ticks don't change cutHeight.
sim_tick(sim, 1.0, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(1.0).margin(1e-9));
}
TEST_CASE("re-click during regrowth restarts the cut from current height", "[regrowth]") {
Sim sim = make_sim_with(make_regrowing_blade(100.0, /*delay=*/0.1, /*dur=*/0.4));
const double y = sim.windowHeight - 40.0;
InputEvent ev1 = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev1, 1);
// Drive the cut to completion + delay + halfway through regrowth.
// 4 ticks of 50 ms = 200 ms (cut done), globalTime=0.20, regrowStart=0.30.
for (int i = 0; i < 4; ++i) sim_tick(sim, 0.05, nullptr, 0);
// 2 ticks of 50 ms = 100 ms further -> globalTime=0.30 (regrowth starts).
for (int i = 0; i < 2; ++i) sim_tick(sim, 0.05, nullptr, 0);
// 4 ticks of 50 ms = 200 ms into the 0.4s regrowth -> cutHeight should be 0.5.
for (int i = 0; i < 4; ++i) sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.5).margin(1e-9));
REQUIRE(sim.blades[0].regrowStart > 0.0);
// Click again mid-regrowth.
InputEvent ev2 = click(100.0, y, 0.5);
sim_tick(sim, 0.0, &ev2, 1);
// Cut should restart: cutAnimStart valid, cutInitialHeight = 0.5,
// regrowStart cleared.
REQUIRE(sim.blades[0].cutAnimStart >= 0.0);
REQUIRE(sim.blades[0].cutInitialHeight == Approx(0.5).margin(1e-9));
REQUIRE(sim.blades[0].regrowStart < 0.0);
// Animate cut for 200 ms -> cutHeight returns to 0 and regrowth re-schedules.
for (int i = 0; i < 4; ++i) sim_tick(sim, 0.05, nullptr, 0);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0).margin(1e-9));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
REQUIRE(sim.blades[0].regrowStart > 0.0);
}
TEST_CASE("click on stump (cut, waiting to regrow) is a no-op", "[regrowth]") {
// cutHeight=0 and regrowStart scheduled but not yet started.
Sim sim = make_sim_with(make_regrowing_blade(100.0, /*delay=*/10.0, /*dur=*/1.0));
sim.blades[0].cutHeight = 0.0;
sim.blades[0].cutAnimStart = -1.0;
sim.blades[0].regrowStart = 5.0; // scheduled
const double y = sim.windowHeight - 40.0;
InputEvent ev = click(100.0, y, 0.0);
sim_tick(sim, 0.0, &ev, 1);
REQUIRE(sim.blades[0].cutHeight == Approx(0.0));
REQUIRE(sim.blades[0].cutAnimStart < 0.0);
REQUIRE(sim.blades[0].regrowStart == Approx(5.0));
}
TEST_CASE("regrowth jitter is deterministic for a given seed", "[regrowth][snapshot]") {
std::vector<Blade> a;
std::vector<Blade> b;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, a);
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, b);
REQUIRE(a.size() == b.size());
for (std::size_t i = 0; i < a.size(); ++i) {
REQUIRE(a[i].regrowDelay == Approx(b[i].regrowDelay ).margin(1e-12));
REQUIRE(a[i].regrowDuration == Approx(b[i].regrowDuration).margin(1e-12));
}
}
TEST_CASE("regrowth jitter falls within configured min/max", "[regrowth][snapshot]") {
std::vector<Blade> blades;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, blades);
REQUIRE(blades.size() > 50);
for (const Blade& b : blades) {
REQUIRE(b.regrowDelay >= REGROW_DELAY_MIN);
REQUIRE(b.regrowDelay < REGROW_DELAY_MAX);
REQUIRE(b.regrowDuration >= REGROW_DURATION_MIN);
REQUIRE(b.regrowDuration < REGROW_DURATION_MAX);
REQUIRE(b.regrowStart == Approx(-1.0));
}
}
TEST_CASE("regrowth jitter does not perturb static-field generation", "[regrowth][snapshot]") {
// Whole point of the salted second-stream design: snapshot tests for
// baseX/height/etc are unaffected by adding regrowth. Cross-check by
// generating with and without regrowth jitter via two seeds that share
// the main stream but differ in regrow stream (i.e. same seed produces
// identical static fields).
std::vector<Blade> a;
generate_blades(CANONICAL_TEST_SEED, 1920.0, 1.0, a);
// Spec gates the static-field count + per-blade values; this is here
// as a tripwire if anyone slips an extra prng_next_* call into the
// main stream during generation.
REQUIRE(a.size() > 0);
REQUIRE(a[0].baseX == Approx(a[0].baseX)); // tautology — placeholder
}

View File

@@ -1,105 +0,0 @@
// scene_tests.cpp
//
// Scene infrastructure tests (architecture.md §13).
//
// Coverage:
// * Scene enum discriminants match the spec ({Grass=0, Desert=1, Winter=2, Autumn=3}).
// * sim_init defaults currentScene to SCENE_DEFAULT (= Grass).
// * sim_set_scene does not perturb blade positions/dimensions/hues or
// any non-scene PRNG stream.
// * Per-scene palette tables are 6 entries each with full-alpha ARGB.
// * SCENE_PALETTES[Grass] is bit-identical to the original §4 PALETTE.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include "snapshot_data.h"
#include <cstdint>
using namespace desktopgrass;
TEST_CASE("Scene enum has spec-locked discriminants", "[scene][enum]") {
REQUIRE(static_cast<int>(Scene::Grass) == 0);
REQUIRE(static_cast<int>(Scene::Desert) == 1);
REQUIRE(static_cast<int>(Scene::Winter) == 2);
REQUIRE(static_cast<int>(Scene::Autumn) == 3);
REQUIRE(static_cast<int>(Scene::Ocean) == 4);
REQUIRE(SCENE_COUNT == 5);
REQUIRE(static_cast<int>(SCENE_DEFAULT) == 0);
}
TEST_CASE("sim_init defaults currentScene to Grass", "[scene][init]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
REQUIRE(sim.currentScene == Scene::Grass);
}
TEST_CASE("sim_set_scene does not perturb blade geometry or hues", "[scene][independence]") {
Sim a = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
Sim b = sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
// Same seed → same blades initially.
REQUIRE(a.blades.size() == b.blades.size());
REQUIRE(a.blades.size() == desktopgrass::test::CANONICAL_BLADE_COUNT);
sim_set_scene(b, Scene::Desert);
REQUIRE(b.currentScene == Scene::Desert);
REQUIRE(a.currentScene == Scene::Grass);
REQUIRE(a.blades.size() == b.blades.size());
for (size_t i = 0; i < a.blades.size(); ++i) {
REQUIRE(a.blades[i].baseX == Approx(b.blades[i].baseX));
REQUIRE(a.blades[i].height == Approx(b.blades[i].height));
REQUIRE(a.blades[i].thickness == Approx(b.blades[i].thickness));
REQUIRE(a.blades[i].hue == b.blades[i].hue);
}
// Desert cacti may mutate variant tags, but geometry and ambient PRNG stay untouched.
REQUIRE(a.ambientPrng.state == b.ambientPrng.state);
REQUIRE(a.nextAmbientGustTime == Approx(b.nextAmbientGustTime));
}
TEST_CASE("sim_set_scene round-trips through all values", "[scene][api]") {
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim_set_scene(sim, Scene::Desert); REQUIRE(sim.currentScene == Scene::Desert);
sim_set_scene(sim, Scene::Winter); REQUIRE(sim.currentScene == Scene::Winter);
sim_set_scene(sim, Scene::Autumn); REQUIRE(sim.currentScene == Scene::Autumn);
sim_set_scene(sim, Scene::Ocean); REQUIRE(sim.currentScene == Scene::Ocean);
sim_set_scene(sim, Scene::Grass); REQUIRE(sim.currentScene == Scene::Grass);
}
TEST_CASE("Per-scene palette tables are 6 ARGB entries with full alpha", "[scene][palette]") {
for (int s = 0; s < SCENE_COUNT; ++s) {
for (int i = 0; i < PALETTE_SIZE; ++i) {
const uint32_t argb = SCENE_PALETTES[s][i];
const uint8_t alpha = static_cast<uint8_t>((argb >> 24) & 0xFFu);
REQUIRE(alpha == 0xFFu);
}
}
}
TEST_CASE("Grass scene palette is bit-identical to the original §4 PALETTE", "[scene][palette]") {
for (int i = 0; i < PALETTE_SIZE; ++i) {
REQUIRE(SCENE_PALETTES[static_cast<int>(Scene::Grass)][i] == PALETTE[i]);
}
}
TEST_CASE("Desert palette values match spec §13", "[scene][palette]") {
constexpr uint32_t expected[PALETTE_SIZE] = {
0xFFC9A26Bu, 0xFFB48A56u, 0xFFD9B57Au,
0xFF8F6E3Fu, 0xFFE6C896u, 0xFFA67843u,
};
for (int i = 0; i < PALETTE_SIZE; ++i) {
REQUIRE(SCENE_PALETTES[static_cast<int>(Scene::Desert)][i] == expected[i]);
REQUIRE(DESERT_PALETTE[i] == expected[i]);
}
}
TEST_CASE("Winter palette values match spec §13", "[scene][palette]") {
constexpr uint32_t expected[PALETTE_SIZE] = {
0xFFE8EEF5u, 0xFFB7C4D2u, 0xFFCBD8E5u,
0xFFD7E2EEu, 0xFFA8B7C6u, 0xFFEEF3F8u,
};
for (int i = 0; i < PALETTE_SIZE; ++i) {
REQUIRE(SCENE_PALETTES[static_cast<int>(Scene::Winter)][i] == expected[i]);
REQUIRE(WINTER_PALETTE[i] == expected[i]);
}
}

View File

@@ -1,228 +0,0 @@
// sheep_greeting_tests.cpp
//
// §16 sheep proximity-greeting tests. Mirrors Win2D SheepGreetingTests.cs.
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <vector>
using namespace desktopgrass;
namespace {
constexpr double Monitor1920 = 1920.0;
constexpr double EligibleAge = 2.0;
constexpr double LongTimer = 10.0;
Sim build_sheep_sim() {
Sim sim = sim_init(CANONICAL_TEST_SEED, Monitor1920, DEFAULT_DENSITY);
sim_set_critter(sim, CritterKind::Sheep);
return sim;
}
std::vector<std::size_t> sheep_indices(const Sim& sim) {
std::vector<std::size_t> indices;
for (std::size_t i = 0; i < sim.entities.size(); ++i) {
if (sim.entities[i].kind == EntityKind::Sheep) indices.push_back(i);
}
return indices;
}
void set_sheep(Sim& sim, std::size_t index, double x, double vx,
uint8_t state = SHEEP_STATE_WALKING,
double age = EligibleAge,
double stateTimer = LongTimer) {
Entity& e = sim.entities[index];
e.x = x;
e.vx = vx;
e.state = state;
e.age = age;
e.stateTimer = stateTimer;
}
std::vector<std::size_t> prepare_two_sheep(Sim& sim, double gap = 40.0,
double ageA = EligibleAge,
double ageB = EligibleAge) {
std::vector<std::size_t> indices = sheep_indices(sim);
REQUIRE(indices.size() >= 2);
set_sheep(sim, indices[0], 500.0, -20.0, SHEEP_STATE_WALKING, ageA);
set_sheep(sim, indices[1], 500.0 + gap, 18.0, SHEEP_STATE_WALKING, ageB);
for (std::size_t n = 2; n < indices.size(); ++n) {
set_sheep(sim, indices[n], 1000.0 + 150.0 * static_cast<double>(n), 16.0);
}
return indices;
}
int advance_side_past_sheep_generation(Prng& side) {
const double countDraw = prng_uniform(side, SHEEP_COUNT_MIN, SHEEP_COUNT_MAX + 1);
int expectedCount = static_cast<int>(std::floor(countDraw));
if (expectedCount < SHEEP_COUNT_MIN) expectedCount = SHEEP_COUNT_MIN;
if (expectedCount > SHEEP_COUNT_MAX) expectedCount = SHEEP_COUNT_MAX;
for (int i = 0; i < expectedCount; ++i) {
const double margin = SHEEP_BODY_RADIUS + 8.0;
(void)prng_uniform(side, margin, Monitor1920 - margin);
(void)prng_uniform(side, SHEEP_WALK_SPEED_MIN, SHEEP_WALK_SPEED_MAX);
(void)prng_uniform(side, 0.0, 1.0);
(void)prng_next_u32(side);
(void)prng_uniform(side, SHEEP_WALK_DURATION_MIN, SHEEP_WALK_DURATION_MAX);
(void)prng_index(side, static_cast<uint32_t>(sizeof(SHEEP_NAME_POOL) / sizeof(SHEEP_NAME_POOL[0])));
}
return expectedCount;
}
int count_sheep_in_state(const Sim& sim, uint8_t state) {
return static_cast<int>(std::count_if(sim.entities.begin(), sim.entities.end(),
[state](const Entity& e) {
return e.kind == EntityKind::Sheep && e.state == state;
}));
}
} // namespace
TEST_CASE("Sheep greeting constants are pinned to spec values", "[sheep][greeting][constants]") {
REQUIRE(SHEEP_STATE_GREETING == 5);
REQUIRE(SHEEP_GREET_RADIUS == Approx(50.0));
REQUIRE(SHEEP_GREET_DURATION_MIN == Approx(1.6));
REQUIRE(SHEEP_GREET_DURATION_MAX == Approx(2.8));
REQUIRE(SHEEP_GREET_MIN_AGE == Approx(1.5));
REQUIRE(SHEEP_GREET_HEAD_BOB_FREQ == Approx(4.5));
REQUIRE(SHEEP_GREET_HEAD_BOB_AMP == Approx(0.7));
}
TEST_CASE("Sheep curious constants are pinned to spec values", "[sheep][curious][constants]") {
REQUIRE(SHEEP_CURIOUS_RADIUS == Approx(80.0));
REQUIRE(SHEEP_CURIOUS_HEAD_TURN_MAX == Approx(0.55));
}
TEST_CASE("Eligible nearby sheep enter Greeting facing each other", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
const std::vector<std::size_t> indices = prepare_two_sheep(sim);
sim_tick_entities(sim, 0.016);
const Entity& a = sim.entities[indices[0]];
const Entity& b = sim.entities[indices[1]];
REQUIRE(a.state == SHEEP_STATE_GREETING);
REQUIRE(b.state == SHEEP_STATE_GREETING);
REQUIRE(a.stateTimer >= SHEEP_GREET_DURATION_MIN);
REQUIRE(a.stateTimer <= SHEEP_GREET_DURATION_MAX);
REQUIRE(a.stateTimer == Approx(b.stateTimer));
REQUIRE(a.vx > 0.0);
REQUIRE(b.vx < 0.0);
}
TEST_CASE("Far apart eligible sheep do not greet", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
const std::vector<std::size_t> indices = prepare_two_sheep(sim, 200.0);
for (int i = 0; i < 3; ++i) sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities[indices[0]].state == SHEEP_STATE_WALKING);
REQUIRE(sim.entities[indices[1]].state == SHEEP_STATE_WALKING);
}
TEST_CASE("Sheep under greeting minimum age do not greet", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
const std::vector<std::size_t> indices = prepare_two_sheep(sim, 40.0, 0.5, EligibleAge);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities[indices[0]].state == SHEEP_STATE_WALKING);
REQUIRE(sim.entities[indices[1]].state == SHEEP_STATE_WALKING);
}
TEST_CASE("Sleeping hopping and greeting sheep are not greeting-eligible", "[sheep][greeting]") {
const uint8_t blockedStates[] = {
SHEEP_STATE_SLEEPING,
SHEEP_STATE_HOPPING,
SHEEP_STATE_GREETING,
};
for (uint8_t blockedState : blockedStates) {
Sim sim = build_sheep_sim();
const std::vector<std::size_t> indices = prepare_two_sheep(sim);
set_sheep(sim, indices[0], 500.0, -20.0, blockedState, EligibleAge);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities[indices[0]].state == blockedState);
REQUIRE(sim.entities[indices[1]].state == SHEEP_STATE_WALKING);
}
}
TEST_CASE("Greeting expiry returns sheep to Walking with vx flipped", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
const std::vector<std::size_t> indices = prepare_two_sheep(sim);
sim_tick_entities(sim, 0.016);
const double duration = sim.entities[indices[0]].stateTimer;
const double aGreetingVx = sim.entities[indices[0]].vx;
const double bGreetingVx = sim.entities[indices[1]].vx;
sim_tick_entities(sim, duration + 0.01);
REQUIRE(sim.entities[indices[0]].state == SHEEP_STATE_WALKING);
REQUIRE(sim.entities[indices[1]].state == SHEEP_STATE_WALKING);
REQUIRE(sim.entities[indices[0]].vx == Approx(-aGreetingVx));
REQUIRE(sim.entities[indices[1]].vx == Approx(-bGreetingVx));
}
TEST_CASE("Greeting trigger consumes one PRNG draw per pair", "[sheep][greeting][prng]") {
Prng side;
prng_init(side, CANONICAL_TEST_SEED ^ CRITTER_PRNG_SALT);
Sim sim = build_sheep_sim();
const int expectedCount = advance_side_past_sheep_generation(side);
REQUIRE(static_cast<int>(sheep_indices(sim).size()) == expectedCount);
const std::vector<std::size_t> indices = prepare_two_sheep(sim);
const double expectedDuration = prng_uniform(side,
SHEEP_GREET_DURATION_MIN,
SHEEP_GREET_DURATION_MAX);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities[indices[0]].stateTimer == Approx(expectedDuration));
REQUIRE(sim.entities[indices[1]].stateTimer == Approx(expectedDuration));
}
TEST_CASE("Single sheep cannot enter Greeting", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
sim.currentScene = Scene::Desert;
REQUIRE(sim.entities.size() >= 1);
sim.entities.erase(sim.entities.begin() + 1, sim.entities.end());
set_sheep(sim, 0, 500.0, 20.0);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities.size() == 1);
REQUIRE(sim.entities[0].state == SHEEP_STATE_WALKING);
}
TEST_CASE("Three sheep cluster greets only the first encountered pair", "[sheep][greeting]") {
Sim sim = build_sheep_sim();
std::vector<std::size_t> indices = sheep_indices(sim);
REQUIRE(indices.size() >= 2);
if (indices.size() < 3) {
sim.entities.push_back(sim.entities[indices[1]]);
indices = sheep_indices(sim);
}
REQUIRE(indices.size() >= 3);
set_sheep(sim, indices[0], 500.0, -20.0);
set_sheep(sim, indices[1], 540.0, 18.0);
set_sheep(sim, indices[2], 580.0, 16.0);
sim_tick_entities(sim, 0.016);
REQUIRE(sim.entities[indices[0]].state == SHEEP_STATE_GREETING);
REQUIRE(sim.entities[indices[1]].state == SHEEP_STATE_GREETING);
REQUIRE(sim.entities[indices[2]].state == SHEEP_STATE_WALKING);
REQUIRE(count_sheep_in_state(sim, SHEEP_STATE_GREETING) == 2);
}

View File

@@ -1,73 +0,0 @@
// snapshot_data.h
//
// Canonical snapshot values for conformance tests. Generated by
// snapshot_gen.cpp against the Native implementation; the Win2D test
// project shares these exact same values to prove cross-impl parity.
//
// To regenerate after changing the spec:
// cd tests/DesktopGrass.Native.Tests
// cl /nologo /std:c++17 /EHsc /O2 /I../../src/DesktopGrass.Native/src \
// /Fe:snapshot_gen.exe snapshot_gen.cpp ../../src/DesktopGrass.Native/src/Sim.cpp
// ./snapshot_gen.exe > snapshot_data_generated.h
// Then copy the contents of snapshot_data_generated.h into this file.
#pragma once
#include <cstdint>
#include <cstddef>
namespace desktopgrass::test {
// canonical PRNG snapshot (seed = 0x6B6173746F)
constexpr uint64_t CANONICAL_PRNG_SNAPSHOT[16] = {
0x3C3A8D4BF44D4757ull,
0xC5036418082CE819ull,
0x637C39DC81179789ull,
0xA8D438AF7ACD7AE6ull,
0x872C242C0B1C9993ull,
0xEFA4F8384FDEA460ull,
0x1C028EE81E340128ull,
0x292DB46E8579232Aull,
0xD68F60B495865BECull,
0xB92C6D6C0EF02C5Bull,
0xEA3E31B01AEBBAC3ull,
0x69414C59CD84BD76ull,
0x824EF03EDB86298Cull,
0x2EC0BC0D0F34C6DFull,
0x06931E51B1E4F892ull,
0x51E8736B5F6D55E3ull,
};
// blade count: 321
constexpr size_t CANONICAL_BLADE_COUNT = 321;
// first 10 blades (baseX, height, thickness, hue, swayPhaseOffset, stiffness, isFlower, flowerHeadColorIdx, flowerHeadRadius, heightBonus)
struct SnapshotBlade { double baseX, height, thickness; uint8_t hue; double sway, stiffness; bool isFlower; uint8_t flowerHeadColorIdx; double flowerHeadRadius, heightBonus; };
constexpr SnapshotBlade CANONICAL_FIRST_10[10] = {
{ 4.941073726820111, 24.469991818248864, 1.5829214329729786, 3, 3.3176304956845826, 0.97444439458772458, false, 0, 0, 1 },
{ 9.3787298687475591, 9.8604876018392638, 2.2571879063910156, 4, 5.7491868538687054, 0.76446104886036426, false, 0, 0, 1 },
{ 15.414797889934666, 10.383081509132303, 1.0385235237289103, 1, 3.002564694512488, 0.80184457223353733, true, 5, 1.9856510266114094, 1.2028967677469276 },
{ 19.593121666328006, 27.357762722959727, 1.0339384653459984, 3, 1.6105552667895404, 0.81282211516340619, false, 0, 0, 1 },
{ 24.583549065022112, 10.405811371734785, 1.3631340217308754, 3, 6.0791471337675995, 0.85778838989075124, false, 0, 0, 1 },
{ 30.469280325562636, 14.64969214497285, 2.1029229162066789, 4, 1.369186973739968, 0.64921394446231895, false, 0, 0, 1 },
{ 36.151633528778135, 24.905416507570557, 1.681128965493375, 5, 1.0984313545668589, 0.61705905497643643, false, 0, 0, 1 },
{ 41.240173248804979, 21.090216438210287, 2.4112504781311586, 1, 2.5650668705987827, 0.80856258993385732, false, 0, 0, 1 },
{ 45.909481179288093, 25.779836864342794, 1.9217430631389112, 3, 4.5760223476063198, 0.6897456846181147, false, 0, 0, 1 },
{ 51.704527631340518, 7.0226866871355051, 2.0844748317130479, 5, 0.35993160065393376, 0.95409362721021629, false, 0, 0, 1 },
};
// last 10 blades
constexpr SnapshotBlade CANONICAL_LAST_10[10] = {
{ 1862.1862973905477, 12.711608036449295, 1.012073444534392, 3, 2.3651694770128948, 0.87280041193860214, false, 0, 0, 1 },
{ 1869.0137044788548, 29.295061932038202, 1.8599729032248227, 5, 0.93125378903474243, 0.77711311572472863, false, 0, 0, 1 },
{ 1876.3989600185221, 16.412749219937503, 2.3707904389430361, 4, 6.2236497795954646, 0.69830242079702853, false, 0, 0, 1 },
{ 1883.2648022027838, 27.079136980574535, 1.3818519218724266, 1, 5.6607957368262252, 0.64471754349581489, false, 0, 0, 1 },
{ 1889.9657219661015, 6.5120673117922729, 1.3927977522226092, 0, 1.0400004070684932, 0.65011504476310344, false, 0, 0, 1 },
{ 1897.0421995171516, 22.778199667770664, 1.6103911154185315, 3, 5.4418514925265704, 0.6792514093313039, false, 0, 0, 1 },
{ 1902.4342767348269, 14.612095947624056, 2.4718071777795467, 4, 5.8520526497642198, 0.91196804564197653, false, 0, 0, 1 },
{ 1907.2058102690753, 11.469067214809311, 1.0067274803347863, 1, 3.1644688274678971, 0.97325380897540192, false, 0, 0, 1 },
{ 1911.3865054893965, 26.080515240165873, 2.0193479120917956, 2, 3.350989422282157, 0.72097617434818306, false, 0, 0, 1 },
{ 1915.6595838732392, 8.7174302729300273, 1.7257363895237519, 3, 2.9693994932808887, 0.74923939092464364, false, 0, 0, 1 },
};
} // namespace desktopgrass::test

View File

@@ -1,139 +0,0 @@
// sway_tests.cpp
//
// Sway physics tests (architecture.md §6).
#include "../third_party/catch2/catch.hpp"
#include "Sim.h"
#include <cmath>
using namespace desktopgrass;
namespace {
constexpr double kPi = 3.14159265358979323846;
Blade make_blade(double phase, double stiffness) {
Blade b{};
b.baseX = 0.0;
b.height = 20.0;
b.thickness = 1.5;
b.hue = 0;
b.swayPhaseOffset = phase;
b.stiffness = stiffness;
b.cutHeight = 1.0;
b.gustVelocity = 0.0;
b.cutAnimStart = -1.0;
b.cutInitialHeight = 1.0;
return b;
}
} // anonymous
TEST_CASE("sway phase advances linearly with globalTime", "[sway]") {
Blade b = make_blade(0.0, 1.0);
update_blade_dynamics(b, 0.0, 0.016);
const double leanT0 = b.effectiveLean;
// After one full BASE_SWAY_SPEED period (6 sec) the lean returns to ~same.
update_blade_dynamics(b, (2.0 * kPi) / BASE_SWAY_SPEED, 0.016);
REQUIRE(b.effectiveLean == Approx(leanT0).margin(1e-9));
}
TEST_CASE("sway lean stays bounded by BASE_AMPLITUDE * stiffness", "[sway]") {
Blade b = make_blade(0.0, 1.0);
double maxAbs = 0.0;
// Sample one full period at fine granularity.
for (double t = 0.0; t < (2.0 * kPi) / BASE_SWAY_SPEED; t += 0.001) {
update_blade_dynamics(b, t, 0.001);
maxAbs = std::max(maxAbs, std::fabs(b.effectiveLean));
}
REQUIRE(maxAbs <= BASE_AMPLITUDE + 1e-9);
REQUIRE(maxAbs >= BASE_AMPLITUDE * 0.99);
}
TEST_CASE("stiffness scales sway amplitude", "[sway]") {
Blade soft = make_blade(0.0, 0.6);
Blade hard = make_blade(0.0, 1.0);
double softMax = 0.0, hardMax = 0.0;
for (double t = 0.0; t < (2.0 * kPi) / BASE_SWAY_SPEED; t += 0.001) {
update_blade_dynamics(soft, t, 0.001);
update_blade_dynamics(hard, t, 0.001);
softMax = std::max(softMax, std::fabs(soft.effectiveLean));
hardMax = std::max(hardMax, std::fabs(hard.effectiveLean));
}
REQUIRE(softMax < hardMax);
REQUIRE(softMax == Approx(hardMax * 0.6).margin(1e-3));
}
TEST_CASE("swayAmplitude scale multiplies the lean", "[sway]") {
// At the same time/phase, ampScale=2.0 doubles the lean; ampScale=0 zeroes it.
Blade base = make_blade(0.3, 1.0);
Blade dbl = make_blade(0.3, 1.0);
Blade zero = make_blade(0.3, 1.0);
const double t = 1.234;
update_blade_dynamics(base, t, 0.016, 1.0, 1.0);
update_blade_dynamics(dbl, t, 0.016, 1.0, 2.0);
update_blade_dynamics(zero, t, 0.016, 1.0, 0.0);
REQUIRE(dbl.effectiveLean == Approx(2.0 * base.effectiveLean).margin(1e-12));
REQUIRE(zero.effectiveLean == Approx(0.0).margin(1e-12));
}
TEST_CASE("swaySpeed scale stretches the phase advance", "[sway]") {
// speedScale=2.0 at time t equals the default at time 2t (pure phase scaling).
Blade fast = make_blade(0.1, 1.0);
Blade slow = make_blade(0.1, 1.0);
const double t = 0.9;
update_blade_dynamics(fast, t, 0.016, 2.0, 1.0);
update_blade_dynamics(slow, 2.0 * t, 0.016, 1.0, 1.0);
REQUIRE(fast.effectiveLean == Approx(slow.effectiveLean).margin(1e-12));
}
TEST_CASE("sim_tick applies the Sim sway scales to blades", "[sway]") {
// Proves the knobs are actually wired through the per-frame tick, not just
// the standalone helper: a sim with swayAmpScale=0 produces zero base lean.
Sim sim = sim_init(CANONICAL_TEST_SEED, 1920.0, DEFAULT_DENSITY);
sim.swayAmpScale = 0.0;
sim.swaySpeedScale = 1.0;
sim_tick(sim, 0.5, nullptr, 0);
for (const Blade& b : sim.blades) {
// No ambient gust fired (gustVelocity stays 0), so effectiveLean is pure
// base lean, which ampScale=0 must flatten to 0.
REQUIRE(b.gustVelocity == Approx(0.0).margin(1e-12));
REQUIRE(b.effectiveLean == Approx(0.0).margin(1e-12));
}
}
TEST_CASE("phase offset shifts the sine wave", "[sway]") {
Blade a = make_blade(0.0, 1.0);
Blade b = make_blade(kPi / 2.0, 1.0);
update_blade_dynamics(a, 0.0, 0.001);
update_blade_dynamics(b, 0.0, 0.001);
// At t=0 with stiffness=1: a -> sin(0)*6 = 0; b -> sin(π/2)*6 = 6.
REQUIRE(a.effectiveLean == Approx(0.0).margin(1e-9));
REQUIRE(b.effectiveLean == Approx(BASE_AMPLITUDE).margin(1e-9));
}
TEST_CASE("gust velocity decays exponentially with dt", "[sway]") {
Blade b = make_blade(0.0, 1.0);
b.gustVelocity = 10.0;
// After 1 second, expect gustVelocity ≈ 10 * exp(-2.5).
update_blade_dynamics(b, 0.0, 1.0);
REQUIRE(b.gustVelocity == Approx(10.0 * std::exp(-DECAY_RATE * 1.0)).margin(1e-9));
}
TEST_CASE("gust velocity contributes to effective lean", "[sway]") {
Blade b = make_blade(0.0, 1.0);
b.gustVelocity = 2.0;
// tiny dt so decay is negligible
update_blade_dynamics(b, 0.0, 1e-6);
const double expectedFromGust = 2.0 * GUST_TO_LEAN_FACTOR;
// At t=0 sway contribution is sin(0)=0; only gust remains.
REQUIRE(b.effectiveLean == Approx(expectedFromGust).margin(1e-3));
}

View File

@@ -1,463 +0,0 @@
#include "catch.hpp"
#include "Sim.h"
#include "Constants.h"
#include "snapshot_data.h"
#include <cstddef>
#include <cmath>
#include <algorithm>
#include <array>
#include <limits>
#include <vector>
using namespace desktopgrass;
using namespace desktopgrass::test;
namespace {
constexpr double kTwoPi = 6.28318530717958647692;
Sim MakeWinterTestSim() {
return sim_init(CANONICAL_TEST_SEED, 1920.0, 1.0);
}
void TickUntilFirstSnowflake(Sim& sim) {
for (int i = 0; i < 10000 && sim.entities.empty(); ++i) {
sim_tick(sim, 0.01, nullptr, 0);
}
REQUIRE_FALSE(sim.entities.empty());
}
}
TEST_CASE("Winter constants are pinned", "[winter][constants]") {
REQUIRE(SNOWFLAKE_EMIT_RATE_PER_1920DIP == Approx(8.0));
REQUIRE(SNOWFLAKE_FALL_SPEED_MIN == Approx(20.0));
REQUIRE(SNOWFLAKE_FALL_SPEED_MAX == Approx(40.0));
REQUIRE(SNOWFLAKE_SIZE_MIN == Approx(1.5));
REQUIRE(SNOWFLAKE_SWAY_AMPLITUDE == Approx(10.0));
REQUIRE(SNOWFLAKE_PRNG_SALT == 0xC0FFEE1CECAFEBABull);
REQUIRE(SNOW_TIP_RADIUS_FACTOR == Approx(1.25));
REQUIRE(SNOW_TIP_COLOR == 0xFFFFFFFFu);
}
TEST_CASE("Winter blade cull is deterministic and ~25%", "[winter][cull]") {
// Pinned bitmask for indices 0..31 — must match the Win2D renderer exactly so
// both impls thin the same blades. '1' == culled (skipped in Winter).
const char* kExpected = "10100111000100000000000010000000";
for (uint32_t i = 0; i < 32; ++i) {
const bool expected = kExpected[i] == '1';
REQUIRE(winter_blade_culled(i) == expected);
}
REQUIRE(WINTER_CULL_MASK == 3u);
int culled = 0;
for (uint32_t i = 0; i < 2500; ++i) {
if (winter_blade_culled(i)) ++culled;
}
REQUIRE(culled == 624); // 24.96% of 2500 — effectively the target 25%
}
TEST_CASE("SetScene Winter initializes snowflake scheduler", "[winter][scene]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.nextSnowflakeSpawnTime > sim.globalTime);
REQUIRE(sim.nextSnowflakeSpawnTime < 100.0);
}
TEST_CASE("First winter snowflake emits on scheduled tick", "[winter][entities]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
TickUntilFirstSnowflake(sim);
REQUIRE(sim.entities.size() == 1);
REQUIRE(sim.entities[0].kind == EntityKind::Snowflake);
}
TEST_CASE("First winter snowflake matches spec-derived PRNG snapshot", "[winter][entities][snapshot]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
TickUntilFirstSnowflake(sim);
REQUIRE(sim.entities.size() == 1);
Prng expected{};
prng_init(expected, CANONICAL_TEST_SEED ^ SNOWFLAKE_PRNG_SALT);
const double lambda = SNOWFLAKE_EMIT_RATE_PER_1920DIP * sim.monitorWidth / 1920.0;
const double firstInterval = prng_exponential(expected, lambda);
const double expectedSize = prng_uniform(expected, SNOWFLAKE_SIZE_MIN, SNOWFLAKE_SIZE_MAX);
const double expectedX = prng_uniform(expected, -20.0, sim.monitorWidth + 20.0);
const double expectedFallSpeed = prng_uniform(expected, SNOWFLAKE_FALL_SPEED_MIN, SNOWFLAKE_FALL_SPEED_MAX);
const double expectedRotation = prng_uniform(expected, 0.0, kTwoPi);
const double expectedRotationSpeed = prng_uniform(expected, -1.5, 1.5);
const uint32_t expectedSeed = prng_next_u32(expected);
const double nextInterval = prng_exponential(expected, lambda);
const Entity& e = sim.entities[0];
REQUIRE(e.size == Approx(expectedSize).margin(1e-12));
REQUIRE(e.x == Approx(expectedX).margin(1e-12));
REQUIRE(e.vy == Approx(expectedFallSpeed).margin(1e-12));
REQUIRE(e.rotation == Approx(expectedRotation).margin(1e-12));
REQUIRE(e.rotationSpeed == Approx(expectedRotationSpeed).margin(1e-12));
REQUIRE(e.seed == expectedSeed);
REQUIRE(sim.nextSnowflakeSpawnTime == Approx(firstInterval + nextInterval).margin(1e-12));
}
TEST_CASE("Snowflake sway velocity wobbles from seed phase", "[winter][entities]") {
Sim sim = MakeWinterTestSim();
sim.currentScene = Scene::Desert;
Entity e{};
e.kind = EntityKind::Snowflake;
e.seed = 0;
e.age = 0.0;
e.lifetime = 100.0;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.0);
const double expectedVx = SNOWFLAKE_SWAY_AMPLITUDE * SNOWFLAKE_SWAY_FREQUENCY * kTwoPi * std::cos(0.0);
REQUIRE(sim.entities.size() == 1);
REQUIRE(sim.entities[0].vx == Approx(expectedVx).margin(1e-12));
}
TEST_CASE("Snowflakes are culled after lifetime", "[winter][entities]") {
Sim sim = MakeWinterTestSim();
sim.currentScene = Scene::Desert;
Entity e{};
e.kind = EntityKind::Snowflake;
e.lifetime = 1.0;
e.age = 0.9;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.2);
REQUIRE(sim.entities.empty());
}
TEST_CASE("Snowflakes are culled below ground line", "[winter][entities]") {
Sim sim = MakeWinterTestSim();
sim.currentScene = Scene::Desert;
Entity e{};
e.kind = EntityKind::Snowflake;
e.y = sim.windowHeight + 5.0;
e.lifetime = 100.0;
sim.entities.push_back(e);
sim_tick_entities(sim, 0.0);
REQUIRE(sim.entities.empty());
}
TEST_CASE("Winter snowflake emitter honors max entity cap", "[winter][entities]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
sim.nextSnowflakeSpawnTime = sim.globalTime;
for (int i = 0; i < MAX_ENTITIES_PER_MONITOR; ++i) {
Entity e{};
e.kind = EntityKind::Snowflake;
e.lifetime = 100.0;
sim.entities.push_back(e);
}
sim_tick_entities(sim, 0.0);
REQUIRE(sim.entities.size() <= static_cast<std::size_t>(MAX_ENTITIES_PER_MONITOR));
REQUIRE(sim.entities.size() == static_cast<std::size_t>(MAX_ENTITIES_PER_MONITOR));
}
TEST_CASE("Winter scene does not perturb first-blade snapshot", "[winter][snapshot]") {
Sim sim = MakeWinterTestSim();
REQUIRE(sim.blades[0].baseX == Approx(CANONICAL_FIRST_10[0].baseX).margin(1e-12));
sim_set_scene(sim, Scene::Winter);
REQUIRE(sim.blades[0].baseX == Approx(CANONICAL_FIRST_10[0].baseX).margin(1e-12));
sim_set_scene(sim, Scene::Grass);
REQUIRE(sim.blades[0].baseX == Approx(CANONICAL_FIRST_10[0].baseX).margin(1e-12));
}
TEST_CASE("Snowflakes do not emit in non-winter scenes", "[winter][entities][scene]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Grass);
sim.nextSnowflakeSpawnTime = 0.0;
sim_tick(sim, 2.0, nullptr, 0);
REQUIRE(std::none_of(sim.entities.begin(), sim.entities.end(),
[](const Entity& e) { return e.kind == EntityKind::Snowflake; }));
sim_set_scene(sim, Scene::Desert);
sim.entities.clear();
sim.nextSnowflakeSpawnTime = 0.0;
sim_tick(sim, 2.0, nullptr, 0);
REQUIRE(sim.entities.empty());
}
namespace {
int count_snow_puffs(const Sim& sim) {
int n = 0;
for (const Entity& e : sim.entities)
if (e.kind == EntityKind::SnowPuff) ++n;
return n;
}
InputEvent WinterClick(const Sim& sim, double x) {
InputEvent ev{};
ev.type = EventType::Click;
ev.x = x;
ev.y = sim.windowHeight - 5.0;
ev.time = sim.globalTime;
return ev;
}
}
TEST_CASE("Snow puff constants are pinned", "[winter][puff][constants]") {
REQUIRE(SNOW_PUFF_COUNT_MIN == 9);
REQUIRE(SNOW_PUFF_COUNT_MAX == 16);
REQUIRE(SNOW_PUFF_SIZE_MIN == Approx(3.5));
REQUIRE(SNOW_PUFF_SIZE_MAX == Approx(8.0));
REQUIRE(SNOW_PUFF_GRAVITY == Approx(150.0));
REQUIRE(SNOW_PUFF_DRAG == Approx(1.6));
REQUIRE(SNOW_PUFF_SPREAD_RAD == Approx(1.25));
REQUIRE(SNOW_PUFF_PRNG_SALT == 0x5503FF1E5503FF1Eull);
}
TEST_CASE("Clicking the winter snowbank sheds a snow puff burst", "[winter][puff]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
InputEvent ev = WinterClick(sim, 400.0);
sim_apply_click(sim, ev);
const int puffs = count_snow_puffs(sim);
REQUIRE(puffs >= SNOW_PUFF_COUNT_MIN);
REQUIRE(puffs <= SNOW_PUFF_COUNT_MAX);
// Every puff launches upward (y is screen-down, so up is negative vy) and
// spawns at or above the ground line.
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::SnowPuff) continue;
REQUIRE(e.vy < 0.0);
REQUIRE(e.y <= sim.windowHeight + 1e-9);
}
}
TEST_CASE("Snow puff only fires in Winter", "[winter][puff][scene]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Grass);
InputEvent ev = WinterClick(sim, 400.0);
sim_apply_click(sim, ev);
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("A non-finite click sheds no snow puff", "[winter][puff][guard]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
InputEvent ev{};
ev.type = EventType::Click;
ev.x = std::numeric_limits<double>::quiet_NaN();
ev.y = sim.windowHeight - 5.0;
ev.time = sim.globalTime;
sim_apply_click(sim, ev);
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("Snow puff burst rises then settles and is culled", "[winter][puff]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
InputEvent ev = WinterClick(sim, 400.0);
sim_apply_click(sim, ev);
REQUIRE(count_snow_puffs(sim) > 0);
// 4 s easily exceeds SNOW_PUFF_LIFETIME_MAX (1.8 s); every puff should be
// culled (lifetime expiry and/or falling back below the ground line).
for (int i = 0; i < 200; ++i) sim_tick_entities(sim, 0.02);
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("Snow puff draw order matches a side PRNG stream", "[winter][puff][prng]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
InputEvent ev = WinterClick(sim, 300.0);
sim_apply_click(sim, ev);
Prng side{};
prng_init(side, CANONICAL_TEST_SEED ^ SNOW_PUFF_PRNG_SALT);
const int expectedCount = SNOW_PUFF_COUNT_MIN
+ static_cast<int>(prng_index(side, SNOW_PUFF_COUNT_MAX - SNOW_PUFF_COUNT_MIN + 1));
REQUIRE(count_snow_puffs(sim) == expectedCount);
// The first locked draw inside make_snow_puff is `size`.
const double expectedSize = prng_uniform(side, SNOW_PUFF_SIZE_MIN, SNOW_PUFF_SIZE_MAX);
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::SnowPuff) continue;
REQUIRE(e.size == Approx(expectedSize).margin(1e-12));
break;
}
}
TEST_CASE("Snow puff salt is unique among winter PRNG salts", "[winter][puff][prng]") {
const std::array<uint64_t, 15> otherSalts = {
REGROW_PRNG_SALT, FLOWER_PRNG_SALT, MUSHROOM_PRNG_SALT,
AMBIENT_GUST_PRNG_SALT, CACTUS_PRNG_SALT, TUMBLEWEED_PRNG_SALT,
CRITTER_PRNG_SALT, BUTTERFLY_PRNG_SALT, FIREFLY_PRNG_SALT,
BIRD_FLYBY_PRNG_SALT, SNOWFLAKE_PRNG_SALT,
PINE_PRNG_SALT, LEAF_PUFF_PRNG_SALT, SNOW_DRIFT_PRNG_SALT,
};
for (uint64_t s : otherSalts) {
REQUIRE(SNOW_PUFF_PRNG_SALT != s);
}
}
// ---------------------------------------------------------------------------
// §21.1 snow drift (cursor-move spindrift)
// ---------------------------------------------------------------------------
namespace {
// Prime the cursor baseline, then brush across at `x0`→`x1` over `dt` seconds in
// the low snow band. Returns the velocity-carrying second event already applied.
void WinterDrift(Sim& sim, double x0, double x1, double dt) {
const double y = sim.windowHeight - 5.0;
InputEvent prime{};
prime.type = EventType::Move;
prime.x = x0; prime.y = y; prime.time = sim.globalTime;
sim_apply_move(sim, prime);
InputEvent move{};
move.type = EventType::Move;
move.x = x1; move.y = y; move.time = sim.globalTime + dt;
sim_apply_move(sim, move);
}
}
TEST_CASE("Snow drift constants are pinned", "[winter][drift][constants]") {
REQUIRE(SNOW_DRIFT_COUNT_MIN == 4);
REQUIRE(SNOW_DRIFT_COUNT_MAX == 8);
REQUIRE(SNOW_DRIFT_REACH_DIP == Approx(70.0));
REQUIRE(SNOW_DRIFT_MIN_SPEED == Approx(90.0));
REQUIRE(SNOW_DRIFT_COOLDOWN_SEC == Approx(0.12));
REQUIRE(SNOW_DRIFT_SIZE_SCALE == Approx(0.9));
REQUIRE(SNOW_DRIFT_SPEED_SCALE == Approx(0.85));
REQUIRE(SNOW_DRIFT_PRNG_SALT == 0x5D81F77D5D81F77Dull);
}
TEST_CASE("Brushing the cursor across the snowbank kicks up a drift wisp", "[winter][drift]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
WinterDrift(sim, 300.0, 360.0, 0.05); // 60 DIP / 0.05 s = 1200 DIP/s
const int puffs = count_snow_puffs(sim);
REQUIRE(puffs >= SNOW_DRIFT_COUNT_MIN);
REQUIRE(puffs <= SNOW_DRIFT_COUNT_MAX);
// Drift grains are smaller than a click burst and still launch upward.
for (const Entity& e : sim.entities) {
if (e.kind != EntityKind::SnowPuff) continue;
REQUIRE(e.vy < 0.0);
REQUIRE(e.size <= SNOW_PUFF_SIZE_MAX * SNOW_DRIFT_SIZE_SCALE + 1e-9);
// Drift puffs originate at the snow surface beneath the cursor, not at
// the cursor's floating height: y sits within START_RADIUS of the
// ground even though the cursor is 5 DIP above it.
const double groundY = sim.windowHeight;
REQUIRE(e.y <= groundY + 1e-9);
REQUIRE(e.y >= groundY - SNOW_PUFF_START_RADIUS - 1e-9);
}
}
TEST_CASE("Snow drift only fires in Winter", "[winter][drift][scene]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Grass);
WinterDrift(sim, 300.0, 360.0, 0.05);
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("A slow cursor brush kicks up no drift", "[winter][drift][gate]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
WinterDrift(sim, 300.0, 302.0, 0.05); // 2 DIP / 0.05 s = 40 DIP/s < 90
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("A high cursor brush above the snow band kicks up no drift", "[winter][drift][gate]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
// Inside the gust band but far above the low drift band near the ground.
const double y = sim.windowHeight - SNOW_DRIFT_REACH_DIP - 20.0;
InputEvent prime{};
prime.type = EventType::Move; prime.x = 300.0; prime.y = y; prime.time = sim.globalTime;
sim_apply_move(sim, prime);
InputEvent move{};
move.type = EventType::Move; move.x = 360.0; move.y = y; move.time = sim.globalTime + 0.05;
sim_apply_move(sim, move);
REQUIRE(count_snow_puffs(sim) == 0);
}
TEST_CASE("Snow drift respects the global cooldown", "[winter][drift][cooldown]") {
Sim sim = MakeWinterTestSim();
sim_set_scene(sim, Scene::Winter);
WinterDrift(sim, 300.0, 360.0, 0.05);
const int first = count_snow_puffs(sim);
REQUIRE(first >= SNOW_DRIFT_COUNT_MIN);
// Same frame (globalTime unchanged): a second qualifying brush is gated.
InputEvent again{};
again.type = EventType::Move;
again.x = 420.0; again.y = sim.windowHeight - 5.0; again.time = sim.globalTime + 0.10;
sim_apply_move(sim, again);
REQUIRE(count_snow_puffs(sim) == first);
// Advance past the cooldown: a fresh brush kicks up another wisp.
sim.globalTime += SNOW_DRIFT_COOLDOWN_SEC + 0.01;
InputEvent later{};
later.type = EventType::Move;
later.x = 480.0; later.y = sim.windowHeight - 5.0; later.time = sim.globalTime + 0.05;
sim_apply_move(sim, later);
REQUIRE(count_snow_puffs(sim) > first);
}
TEST_CASE("Snow drift moves leave the click puff stream untouched", "[winter][drift][prng]") {
Sim a = MakeWinterTestSim();
sim_set_scene(a, Scene::Winter);
Sim b = MakeWinterTestSim();
sim_set_scene(b, Scene::Winter);
// a brushes up some drift wisps first; b does not.
WinterDrift(a, 300.0, 360.0, 0.05);
const std::size_t aPreClick = a.entities.size();
// Both click identically; the click puffs must match byte-for-byte because
// the click stream is a separate PRNG from the drift stream.
InputEvent ca = WinterClick(a, 800.0);
sim_apply_click(a, ca);
InputEvent cb = WinterClick(b, 800.0);
sim_apply_click(b, cb);
// Collect the click puffs from each (a's are those appended after the drift).
std::vector<Entity> aClick(a.entities.begin() + static_cast<std::ptrdiff_t>(aPreClick), a.entities.end());
std::vector<Entity> bClick;
for (const Entity& e : b.entities)
if (e.kind == EntityKind::SnowPuff) bClick.push_back(e);
REQUIRE(aClick.size() == bClick.size());
for (std::size_t i = 0; i < aClick.size(); ++i) {
REQUIRE(aClick[i].size == Approx(bClick[i].size).margin(1e-12));
REQUIRE(aClick[i].vx == Approx(bClick[i].vx).margin(1e-12));
REQUIRE(aClick[i].vy == Approx(bClick[i].vy).margin(1e-12));
REQUIRE(aClick[i].lifetime == Approx(bClick[i].lifetime).margin(1e-12));
}
}

View File

@@ -1,14 +0,0 @@
# DesktopGrass.Native.Tests — third_party
This directory contains source vendored at known versions to keep the test
build hermetic. None of it ships in the runtime binary.
## catch2/catch.hpp
[Catch2](https://github.com/catchorg/Catch2) v2.13.10 single-header
amalgamation, vendored verbatim from the upstream release. License: Boost
Software License 1.0. Copy lives at `catch2/catch.hpp`.
We intentionally avoid pulling Catch2 from vcpkg/NuGet for this v1 — the
single-header approach builds with `cl` out of the box and removes one
moving piece from the test step.

View File

@@ -1,6 +0,0 @@
{
"name": "desktopgrass-native-tests",
"version-string": "1.0.0",
"description": "DesktopGrass Native test project. Catch2 single-header is vendored under third_party/catch2/ (license MIT) to keep the test build fully offline-friendly.",
"dependencies": []
}

View File

@@ -1,11 +0,0 @@
// DesktopGrass.Native.rc
//
// Resource script: embeds the icon and the app manifest.
#include <windows.h>
#include "resource.h"
IDI_APPICON ICON "res/icon.ico"
IDI_TRAYICON ICON "res/icon.ico"
// Manifest binding handled by linker /MANIFESTUAC + <ApplicationManifest>.

View File

@@ -1,178 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|ARM64">
<Configuration>Debug</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<ProjectGuid>{B0D4E1B0-1F5E-4C2D-9F44-DA8C3F1A2A11}</ProjectGuid>
<RootNamespace>DesktopGrass.Native</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
<ProjectName>DesktopGrass.Native</ProjectName>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<OutDir>$(MSBuildProjectDirectory)\..\..\..\..\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(MSBuildProjectDirectory)\obj\$(Platform)\$(Configuration)\</IntDir>
<TargetName>DesktopGrass.Native</TargetName>
<!-- DesktopGrass.Native uses no precompiled header; opt out of the PowerToys-wide PCH default. -->
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
<!-- Self-contained leaf module: opt out of the repo-wide CppCoreCheck-as-errors (cf. FileLocksmithCLI). -->
<RunCodeAnalysis>false</RunCodeAnalysis>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<LanguageStandard>stdcpp17</LanguageStandard>
<SDLCheck>true</SDLCheck>
<ConformanceMode>true</ConformanceMode>
<WarningLevel>Level3</WarningLevel>
<PreprocessorDefinitions>UNICODE;_UNICODE;NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<MultiProcessorCompilation>true</MultiProcessorCompilation>
<AdditionalIncludeDirectories>$(MSBuildProjectDirectory)\src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>d3d11.lib;dxgi.lib;d2d1.lib;dcomp.lib;dwrite.lib;Shcore.lib;Shell32.lib;User32.lib;Gdi32.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
<ResourceCompile>
<PreprocessorDefinitions>UNICODE;_UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<AdditionalIncludeDirectories>$(MSBuildProjectDirectory);%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ResourceCompile>
<Manifest>
<AdditionalManifestFiles>app.manifest</AdditionalManifestFiles>
</Manifest>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<Optimization>Disabled</Optimization>
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<Optimization>MaxSpeed</Optimization>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
</ClCompile>
<Link>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
<ClCompile>
<Optimization>Disabled</Optimization>
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<ClCompile>
<Optimization>MaxSpeed</Optimization>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
</ClCompile>
<Link>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="src\main.cpp" />
<ClCompile Include="src\App.cpp" />
<ClCompile Include="src\AutoStart.cpp" />
<ClCompile Include="src\Benchmark.cpp" />
<ClCompile Include="src\Config.cpp" />
<ClCompile Include="src\GrassWindow.cpp" />
<ClCompile Include="src\Renderer.cpp" />
<ClCompile Include="src\MouseHook.cpp" />
<ClCompile Include="src\Pacing.cpp" />
<ClCompile Include="src\Persistence.cpp" />
<ClCompile Include="src\Sim.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="src\App.h" />
<ClInclude Include="src\AutoStart.h" />
<ClInclude Include="src\Benchmark.h" />
<ClInclude Include="src\Config.h" />
<ClInclude Include="src\Constants.h" />
<ClInclude Include="src\GrassWindow.h" />
<ClInclude Include="src\Json.h" />
<ClInclude Include="src\MouseHook.h" />
<ClInclude Include="src\Pacing.h" />
<ClInclude Include="src\Persistence.h" />
<ClInclude Include="src\Renderer.h" />
<ClInclude Include="src\Sim.h" />
<ClInclude Include="resource.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="DesktopGrass.Native.rc" />
</ItemGroup>
<ItemGroup>
<None Include="app.manifest" />
<None Include="res\icon.ico" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0"
processorArchitecture="*"
name="DesktopGrass.Native"
type="win32"/>
<description>DesktopGrass — procedural grass on the desktop edge.</description>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

View File

@@ -1,6 +0,0 @@
// resource.h
#pragma once
#define IDI_APPICON 101
#define IDI_TRAYICON 102

View File

@@ -1,577 +0,0 @@
// App.cpp
#include "App.h"
#include "AutoStart.h"
#include "Constants.h"
#include "Sim.h"
#include "../resource.h"
#include <shellscalingapi.h>
#include <algorithm>
#include <cstdio>
#include <string>
#include <utility>
#pragma comment(lib, "shell32.lib")
#pragma comment(lib, "Shcore.lib")
#pragma comment(lib, "User32.lib")
namespace desktopgrass {
namespace {
constexpr const wchar_t* kMsgWindowClass = L"DesktopGrass.Native.MessageWindow";
// Fixed launch seed shared with the Win2D implementation so both produce
// identical, deterministic per-monitor blade layouts.
constexpr uint64_t kAppSeed = 0xD3C7C0F30070D511ull;
// Per-monitor seed: combine the fixed app seed with the monitor's physical
// origin so different screens get different — but stable across launches —
// blade layouts. Mirrors Win2D App.cs exactly, including C# `(ulong)int`
// sign-extension and unchecked uint64 multiply/wraparound semantics.
uint64_t make_monitor_seed(const RECT& bounds) {
const uint64_t left = static_cast<uint64_t>(static_cast<int64_t>(bounds.left));
const uint64_t top = static_cast<uint64_t>(static_cast<int64_t>(bounds.top));
return kAppSeed
^ (left * 0xA0761D6478BD642Full)
^ (top * 0xE7037ED1A0B428DBull);
}
// EnumDisplayMonitors callback context.
struct MonitorEnumCtx {
App* app;
std::vector<RECT> bounds;
std::vector<UINT> dpis;
};
BOOL CALLBACK MonitorEnumProc(HMONITOR hMon, HDC, LPRECT, LPARAM lParam) {
auto* ctx = reinterpret_cast<MonitorEnumCtx*>(lParam);
MONITORINFO mi{};
mi.cbSize = sizeof(mi);
if (GetMonitorInfoW(hMon, &mi)) {
// Use the work area, not the full monitor rect, so the grass sits on
// top of the taskbar instead of being drawn behind it. On monitors with
// no taskbar (typical secondary displays), rcWork == rcMonitor.
ctx->bounds.push_back(mi.rcWork);
UINT xDpi = 96, yDpi = 96;
if (FAILED(GetDpiForMonitor(hMon, MDT_EFFECTIVE_DPI, &xDpi, &yDpi))) {
xDpi = 96;
}
ctx->dpis.push_back(xDpi);
}
return TRUE;
}
} // anonymous
App::~App() {
DestroyAllGrassWindows();
RemoveTrayIcon();
if (trayMenu_) { DestroyMenu(trayMenu_); trayMenu_ = nullptr; }
DestroyMessageWindow();
uninstall_mouse_hook();
}
bool App::Initialize(HINSTANCE hInst) {
hInst_ = hInst;
config_ = config::LoadConfig();
QueryPerformanceFrequency(&qpcFreq_);
QueryPerformanceCounter(&qpcLast_);
hasPersistedState_ = persistence::LoadAppState(persistedState_);
if (hasPersistedState_) {
currentScene_ = persistedState_.scene;
currentCritter_ = persistedState_.critter;
currentCritterCount_ = persistedState_.critterCountOverride;
autoStart_ = persistedState_.autoStart;
}
if (!autostart::ReconcileWithState(autoStart_)) {
OutputDebugStringA("[DesktopGrass] unable to reconcile Start with Windows registry state\n");
}
lastPersistenceSaveMs_ = GetTickCount64();
if (!GrassWindow::RegisterWindowClass(hInst_)) return false;
if (!CreateMessageWindow()) return false;
if (!CreateTrayIcon()) return false;
if (!EnumerateMonitorsAndCreateWindows()) return false;
if (!install_mouse_hook(&queue_)) {
OutputDebugStringA("[DesktopGrass] install_mouse_hook failed\n");
// Non-fatal — the grass will still sway, just no gusts/cuts.
}
return true;
}
bool App::CreateMessageWindow() {
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = App::MessageWindowProc;
wc.hInstance = hInst_;
wc.lpszClassName = kMsgWindowClass;
ATOM atom = RegisterClassExW(&wc);
if (atom == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
return false;
}
msgHwnd_ = CreateWindowExW(
0, kMsgWindowClass, L"DesktopGrass.Msg",
0, 0, 0, 0, 0,
HWND_MESSAGE, nullptr, hInst_, this);
return msgHwnd_ != nullptr;
}
void App::DestroyMessageWindow() {
if (msgHwnd_) {
DestroyWindow(msgHwnd_);
msgHwnd_ = nullptr;
}
}
bool App::CreateTrayIcon() {
// Build the menu: Scene ▸ (radio: Grass / Desert / Winter / Autumn) | Quit.
// The Scene submenu is a child popup of trayMenu_; DestroyMenu is
// recursive so destroying trayMenu_ cleans up the submenu too.
trayMenu_ = CreatePopupMenu();
if (!trayMenu_) return false;
sceneSubmenu_ = CreatePopupMenu();
if (!sceneSubmenu_) return false;
AppendMenuW(sceneSubmenu_, MF_STRING, kMenuSceneGrass, L"Grass");
AppendMenuW(sceneSubmenu_, MF_STRING, kMenuSceneDesert, L"Desert");
AppendMenuW(sceneSubmenu_, MF_STRING, kMenuSceneWinter, L"Winter");
AppendMenuW(sceneSubmenu_, MF_STRING, kMenuSceneAutumn, L"Autumn");
AppendMenuW(sceneSubmenu_, MF_STRING, kMenuSceneOcean, L"Ocean");
AppendMenuW(trayMenu_, MF_POPUP | MF_STRING,
reinterpret_cast<UINT_PTR>(sceneSubmenu_), L"Scene");
critterSubmenu_ = CreatePopupMenu();
if (!critterSubmenu_) return false;
AppendMenuW(critterSubmenu_, MF_STRING, kMenuCritterNone, L"None");
AppendMenuW(critterSubmenu_, MF_STRING, kMenuCritterSheep, L"Sheep");
AppendMenuW(critterSubmenu_, MF_STRING, kMenuCritterCat, L"Cat");
AppendMenuW(critterSubmenu_, MF_STRING, kMenuCritterAll, L"All");
petCountSubmenu_ = CreatePopupMenu();
if (!petCountSubmenu_) return false;
AppendMenuW(petCountSubmenu_, MF_STRING, kMenuPetCountRandom, L"Random");
for (int n : PET_COUNT_OPTIONS) {
AppendMenuW(petCountSubmenu_, MF_STRING,
static_cast<UINT_PTR>(kMenuPetCount1 + (n - 1)),
std::to_wstring(n).c_str());
}
AppendMenuW(critterSubmenu_, MF_SEPARATOR, 0, nullptr);
AppendMenuW(critterSubmenu_, MF_POPUP | MF_STRING,
reinterpret_cast<UINT_PTR>(petCountSubmenu_), L"Pet count");
AppendMenuW(trayMenu_, MF_POPUP | MF_STRING,
reinterpret_cast<UINT_PTR>(critterSubmenu_), L"Critter");
AppendMenuW(trayMenu_, MF_STRING, kMenuAutoStart, L"Start with Windows");
AppendMenuW(trayMenu_, MF_SEPARATOR, 0, nullptr);
AppendMenuW(trayMenu_, MF_STRING, kMenuQuit, L"Quit DesktopGrass");
UpdateSceneMenuCheck();
UpdateCritterMenuCheck();
UpdatePetCountMenuCheck();
UpdateAutoStartMenuCheck();
nid_ = {};
nid_.cbSize = sizeof(nid_);
nid_.hWnd = msgHwnd_;
nid_.uID = kTrayIconId;
nid_.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid_.uCallbackMessage = kTrayMessage;
HICON icon = LoadIconW(hInst_, MAKEINTRESOURCEW(IDI_TRAYICON));
if (!icon) icon = LoadIconW(nullptr, IDI_APPLICATION);
nid_.hIcon = icon;
wcsncpy_s(nid_.szTip, L"Desktop Grass", _TRUNCATE);
BOOL ok = Shell_NotifyIconW(NIM_ADD, &nid_);
trayAdded_ = (ok == TRUE);
if (!trayAdded_) {
OutputDebugStringA("[DesktopGrass] Shell_NotifyIcon(NIM_ADD) failed\n");
}
return true; // non-fatal
}
void App::UpdateSceneMenuCheck() {
if (!sceneSubmenu_) return;
// Radio-style check: kMenuSceneGrass + Scene enum index.
const int activeId = kMenuSceneGrass + static_cast<int>(currentScene_);
CheckMenuRadioItem(sceneSubmenu_,
kMenuSceneGrass, kMenuSceneOcean,
activeId, MF_BYCOMMAND);
}
void App::SetScene(Scene s) {
if (s == currentScene_) {
UpdateSceneMenuCheck();
return;
}
currentScene_ = s;
for (auto& w : windows_) {
sim_set_scene(w->GetRenderer().GetSim(), s);
}
UpdateSceneMenuCheck();
SaveCurrentState();
}
void App::UpdateCritterMenuCheck() {
if (!critterSubmenu_) return;
const int activeId = kMenuCritterNone + static_cast<int>(currentCritter_);
CheckMenuRadioItem(critterSubmenu_,
kMenuCritterNone, kMenuCritterAll,
activeId, MF_BYCOMMAND);
}
void App::UpdatePetCountMenuCheck() {
if (!petCountSubmenu_) return;
const int activeId = currentCritterCount_ > 0
? kMenuPetCount1 + (std::min(currentCritterCount_, PET_COUNT_MAX_PER_MONITOR) - 1)
: kMenuPetCountRandom;
CheckMenuRadioItem(petCountSubmenu_,
kMenuPetCountRandom, kMenuPetCount6,
activeId, MF_BYCOMMAND);
}
void App::UpdateAutoStartMenuCheck() {
if (!trayMenu_) return;
CheckMenuItem(trayMenu_, kMenuAutoStart,
MF_BYCOMMAND | (autoStart_ ? MF_CHECKED : MF_UNCHECKED));
}
void App::SetAutoStart(bool enabled) {
if (enabled == autoStart_ && autostart::IsEnabled() == enabled) {
UpdateAutoStartMenuCheck();
return;
}
if (!autostart::SetEnabled(enabled)) {
OutputDebugStringA("[DesktopGrass] unable to update Start with Windows registry state\n");
UpdateAutoStartMenuCheck();
return;
}
autoStart_ = enabled;
UpdateAutoStartMenuCheck();
SaveCurrentState();
}
void App::SetCritter(CritterKind c) {
if (c == currentCritter_) {
UpdateCritterMenuCheck();
return;
}
currentCritter_ = c;
for (auto& w : windows_) {
sim_set_critter(w->GetRenderer().GetSim(), c);
}
UpdateCritterMenuCheck();
SaveCurrentState();
}
void App::SetCritterCount(int n) {
const int sanitized = n > 0 ? std::min(n, PET_COUNT_MAX_PER_MONITOR) : 0;
if (sanitized == currentCritterCount_) {
UpdatePetCountMenuCheck();
return;
}
currentCritterCount_ = sanitized;
for (auto& w : windows_) {
sim_set_critter_count(w->GetRenderer().GetSim(), currentCritterCount_);
}
UpdatePetCountMenuCheck();
SaveCurrentState();
}
void App::RemoveTrayIcon() {
if (trayAdded_) {
Shell_NotifyIconW(NIM_DELETE, &nid_);
trayAdded_ = false;
}
}
bool App::EnumerateMonitorsAndCreateWindows() {
DestroyAllGrassWindows();
MonitorEnumCtx ctx{ this, {}, {} };
EnumDisplayMonitors(nullptr, nullptr, MonitorEnumProc,
reinterpret_cast<LPARAM>(&ctx));
if (ctx.bounds.empty()) return false;
for (size_t i = 0; i < ctx.bounds.size(); ++i) {
auto w = std::make_unique<GrassWindow>();
// Each monitor gets a deterministic seed derived from its physical
// origin (shared formula with Win2D) so blade patterns differ across
// monitors but stay stable across launches and line up with persisted
// cut records.
const uint64_t mseed = make_monitor_seed(ctx.bounds[i]);
if (w->Create(hInst_, ctx.bounds[i], ctx.dpis[i], mseed, config_.bladeDensity,
config_.swaySpeed, config_.swayAmplitude)) {
ApplyPersistedStateToWindow(*w, ctx.bounds[i]);
w->Show();
windows_.push_back(std::move(w));
} else {
OutputDebugStringA("[DesktopGrass] GrassWindow::Create failed\n");
}
}
return !windows_.empty();
}
void App::DestroyAllGrassWindows() {
windows_.clear();
}
void App::OnDisplayChanged() {
SaveCurrentState();
EnumerateMonitorsAndCreateWindows();
}
void App::DispatchMouseEvents() {
// Drain the lock-free queue once. Each event is then routed to whichever
// GrassWindow's screen rect contains it.
RawMouseEvent raw[256];
while (true) {
std::size_t n = queue_.drain(raw, 256);
if (n == 0) break;
for (std::size_t i = 0; i < n; ++i) {
const RawMouseEvent& e = raw[i];
for (auto& w : windows_) {
const RECT& r = w->GetScreenBounds();
// Move events fire across the gust band; click events only
// when in the strip. The Sim's band-check (apply_move / click)
// re-filters in window-local coords. Here we route any event
// whose x is in the window's horizontal range — Move events
// need to update the prevCursor baseline even outside the
// band so the baseline stays accurate, and the spec already
// handles the band rejection.
if (e.screenX < r.left || e.screenX > r.right) continue;
// For move events we accept any y; for click events we only
// accept y inside the band.
if (e.type == EventType::Click) {
if (e.screenY < r.top || e.screenY > r.bottom) continue;
}
// Convert to window-local DIPs.
const UINT dpi = w->GetRenderer().GetDpi();
const double scale = 96.0 / static_cast<double>(dpi);
InputEvent ie{};
ie.type = e.type;
ie.x = (e.screenX - r.left) * scale;
ie.y = (e.screenY - r.top) * scale;
ie.time = e.timeSeconds;
// Apply directly to the sim. Note that this happens BEFORE
// sim_tick (which itself drains its events list), so we apply
// events through the per-tick path — collect into a per-window
// event vector instead.
// To keep things simple, push to the Sim immediately:
if (ie.type == EventType::Move) {
sim_apply_move(w->GetRenderer().GetSim(), ie);
} else {
sim_apply_click(w->GetRenderer().GetSim(), ie);
}
break; // each event belongs to at most one window
}
}
if (n < 256) break;
}
}
void App::RenderAllWindows(double dt) {
DispatchMouseEvents();
for (auto& w : windows_) {
w->RenderFrame(dt, nullptr, 0);
}
}
void App::ApplyPersistedStateToWindow(GrassWindow& window, const RECT& monitorBounds) {
Sim& sim = window.GetRenderer().GetSim();
sim_set_scene(sim, currentScene_);
sim_set_critter_count(sim, currentCritterCount_);
sim_set_critter(sim, currentCritter_);
if (!hasPersistedState_) return;
const int width = monitorBounds.right - monitorBounds.left;
const int height = monitorBounds.bottom - monitorBounds.top;
for (const persistence::MonitorState& monitor : persistedState_.monitors) {
if (monitor.width == width
&& monitor.height == height
&& monitor.left == monitorBounds.left
&& monitor.top == monitorBounds.top) {
sim_apply_cuts(sim, monitor.cuts);
return;
}
}
}
persistence::AppState App::BuildAppState() {
persistence::AppState state;
state.version = 2;
state.scene = currentScene_;
state.critter = currentCritter_;
state.critterCountOverride = currentCritterCount_;
state.autoStart = autoStart_;
state.monitors.reserve(windows_.size());
for (const auto& w : windows_) {
const RECT& bounds = w->GetMonitorBounds();
persistence::MonitorState monitor;
monitor.width = bounds.right - bounds.left;
monitor.height = bounds.bottom - bounds.top;
monitor.left = bounds.left;
monitor.top = bounds.top;
const Sim& sim = w->GetRenderer().GetSim();
monitor.cuts = sim_get_cuts(sim);
state.monitors.push_back(std::move(monitor));
}
return state;
}
void App::SaveCurrentState() {
persistedState_ = BuildAppState();
hasPersistedState_ = true;
persistence::SaveAppState(persistedState_);
lastPersistenceSaveMs_ = GetTickCount64();
}
int App::Run() {
MSG msg{};
// Calm ambient content renders at the configured target fps (default 24
// via Config.h kTargetFpsDefault) to keep per-frame CPU low; motion is
// time-based (dt), so the rate only changes how often the same animation
// is sampled. The user can override this in config.json (targetFps).
const double kTargetFrameSec = 1.0 / static_cast<double>(config_.targetFps);
while (!quitRequested_) {
// Drain pending messages without blocking.
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
if (msg.message == WM_QUIT) {
quitRequested_ = true;
break;
}
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (quitRequested_) break;
// Compute dt.
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
const double dt = static_cast<double>(now.QuadPart - qpcLast_.QuadPart) /
static_cast<double>(qpcFreq_.QuadPart);
qpcLast_ = now;
RenderAllWindows(dt);
if (GetTickCount64() - lastPersistenceSaveMs_ >= 60000ull) {
SaveCurrentState();
}
// Pace to the target frame interval, accounting for the time already
// spent rendering/presenting this iteration so the cadence holds at
// the target fps regardless of how long Present blocked. The pacer
// uses a high-resolution waitable timer (Win 10 1803+) so the wait
// honours sub-15.6 ms remainders instead of getting clamped to the
// default system timer resolution. The wait returns early if input
// arrives, keeping the app responsive.
LARGE_INTEGER after;
QueryPerformanceCounter(&after);
const double elapsedSec = static_cast<double>(after.QuadPart - now.QuadPart) /
static_cast<double>(qpcFreq_.QuadPart);
const double remainingSec = kTargetFrameSec - elapsedSec;
pacer_.WaitUntilNextFrame(remainingSec);
}
SaveCurrentState();
return static_cast<int>(msg.wParam);
}
void App::RequestQuit() {
quitRequested_ = true;
PostQuitMessage(0);
}
LRESULT CALLBACK App::MessageWindowProc(HWND hwnd, UINT msg,
WPARAM wp, LPARAM lp)
{
App* self = nullptr;
if (msg == WM_NCCREATE) {
auto* cs = reinterpret_cast<CREATESTRUCTW*>(lp);
self = reinterpret_cast<App*>(cs->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
if (self) self->msgHwnd_ = hwnd;
} else {
self = reinterpret_cast<App*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
if (self) return self->HandleMessageWindowMessage(msg, wp, lp);
return DefWindowProcW(hwnd, msg, wp, lp);
}
LRESULT App::HandleMessageWindowMessage(UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case kTrayMessage:
if (LOWORD(lp) == WM_RBUTTONUP || LOWORD(lp) == WM_CONTEXTMENU) {
POINT pt;
GetCursorPos(&pt);
SetForegroundWindow(msgHwnd_);
TrackPopupMenu(trayMenu_,
TPM_RIGHTBUTTON | TPM_BOTTOMALIGN,
pt.x, pt.y, 0, msgHwnd_, nullptr);
PostMessageW(msgHwnd_, WM_NULL, 0, 0);
}
return 0;
case WM_COMMAND: {
const int id = LOWORD(wp);
if (id == kMenuPetCountRandom) {
SetCritterCount(0);
return 0;
}
if (id >= kMenuPetCount1 && id <= kMenuPetCount6) {
SetCritterCount(id - kMenuPetCount1 + 1);
return 0;
}
switch (id) {
case kMenuQuit: RequestQuit(); break;
case kMenuAutoStart: SetAutoStart(!autoStart_); break;
case kMenuSceneGrass: SetScene(Scene::Grass); break;
case kMenuSceneDesert: SetScene(Scene::Desert); break;
case kMenuSceneWinter: SetScene(Scene::Winter); break;
case kMenuSceneAutumn: SetScene(Scene::Autumn); break;
case kMenuSceneOcean: SetScene(Scene::Ocean); break;
case kMenuCritterNone: SetCritter(CritterKind::None); break;
case kMenuCritterSheep: SetCritter(CritterKind::Sheep); break;
case kMenuCritterCat: SetCritter(CritterKind::Cat); break;
case kMenuCritterAll: SetCritter(CritterKind::Bunny); break;
}
return 0;
}
case WM_DISPLAYCHANGE:
OnDisplayChanged();
return 0;
case WM_CLOSE:
// The smoke harness sends WM_CLOSE to the *grass* window, which
// PostQuitMessages from its WndProc. Also handle it here for
// robustness.
RequestQuit();
return 0;
default:
return DefWindowProcW(msgHwnd_, msg, wp, lp);
}
}
} // namespace desktopgrass

View File

@@ -1,102 +0,0 @@
// App.h
//
// Application lifecycle. Owns the tray icon, the mouse hook, the per-monitor
// GrassWindow list, and the message loop.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <shellapi.h>
#include <memory>
#include <vector>
#include "GrassWindow.h"
#include "MouseHook.h"
#include "Pacing.h"
#include "Persistence.h"
#include "Config.h"
namespace desktopgrass {
class App {
public:
static constexpr UINT kTrayMessage = WM_APP + 100;
static constexpr UINT kTrayIconId = 1;
static constexpr int kMenuQuit = 1001;
static constexpr int kMenuSceneGrass = 1010;
static constexpr int kMenuSceneDesert = 1011;
static constexpr int kMenuSceneWinter = 1012;
static constexpr int kMenuSceneAutumn = 1013;
static constexpr int kMenuSceneOcean = 1014;
static constexpr int kMenuCritterNone = 1020;
static constexpr int kMenuCritterSheep = 1021;
static constexpr int kMenuCritterCat = 1022;
static constexpr int kMenuCritterAll = 1023;
static constexpr int kMenuPetCountRandom = 1030;
static constexpr int kMenuPetCount1 = 1031;
static constexpr int kMenuPetCount6 = 1036;
static constexpr int kMenuAutoStart = 1040;
App() = default;
~App();
bool Initialize(HINSTANCE hInst);
int Run();
void RequestQuit();
void SetScene(Scene s);
Scene GetScene() const { return currentScene_; }
void SetCritter(CritterKind c);
CritterKind GetCritter() const { return currentCritter_; }
void SetCritterCount(int n);
int GetCritterCount() const { return currentCritterCount_; }
private:
bool CreateMessageWindow();
bool CreateTrayIcon();
void RemoveTrayIcon();
void DestroyMessageWindow();
bool EnumerateMonitorsAndCreateWindows();
void DestroyAllGrassWindows();
void OnDisplayChanged();
void DispatchMouseEvents();
void RenderAllWindows(double dt);
void ApplyPersistedStateToWindow(GrassWindow& window, const RECT& monitorBounds);
persistence::AppState BuildAppState();
void SaveCurrentState();
void SetAutoStart(bool enabled);
void UpdateSceneMenuCheck();
void UpdateCritterMenuCheck();
void UpdatePetCountMenuCheck();
void UpdateAutoStartMenuCheck();
static LRESULT CALLBACK MessageWindowProc(HWND hwnd, UINT msg,
WPARAM wp, LPARAM lp);
LRESULT HandleMessageWindowMessage(UINT msg, WPARAM wp, LPARAM lp);
HINSTANCE hInst_ = nullptr;
HWND msgHwnd_ = nullptr;
HMENU trayMenu_ = nullptr;
HMENU sceneSubmenu_ = nullptr;
HMENU critterSubmenu_ = nullptr;
HMENU petCountSubmenu_ = nullptr;
NOTIFYICONDATAW nid_{};
bool trayAdded_ = false;
MouseEventQueue queue_{};
std::vector<std::unique_ptr<GrassWindow>> windows_;
config::Config config_{};
Scene currentScene_ = SCENE_DEFAULT;
CritterKind currentCritter_ = CRITTER_DEFAULT;
int currentCritterCount_ = 0;
bool autoStart_ = false;
bool hasPersistedState_ = false;
persistence::AppState persistedState_{};
ULONGLONG lastPersistenceSaveMs_ = 0;
LARGE_INTEGER qpcFreq_{};
LARGE_INTEGER qpcLast_{};
FramePacer pacer_{};
bool quitRequested_ = false;
};
} // namespace desktopgrass

View File

@@ -1,107 +0,0 @@
#include "AutoStart.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <vector>
#pragma comment(lib, "Advapi32.lib")
namespace autostart {
namespace {
constexpr const wchar_t* kDefaultRunSubKey = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
std::wstring g_registryKeyOverride;
std::wstring GetRegistrySubKey() {
return g_registryKeyOverride.empty() ? std::wstring(kDefaultRunSubKey) : g_registryKeyOverride;
}
} // namespace
std::wstring GetRegistryValueName() {
return L"DesktopGrass.Native";
}
std::wstring GetCurrentExePath() {
std::vector<wchar_t> buffer(MAX_PATH);
while (true) {
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
if (length == 0) {
return L"";
}
if (length < buffer.size()) {
return std::wstring(buffer.data(), length);
}
buffer.resize(buffer.size() * 2);
}
}
bool IsEnabled() {
HKEY key = nullptr;
const std::wstring subKey = GetRegistrySubKey();
const LSTATUS openStatus = RegOpenKeyExW(
HKEY_CURRENT_USER, subKey.c_str(), 0, KEY_QUERY_VALUE, &key);
if (openStatus != ERROR_SUCCESS) {
return false;
}
DWORD type = 0;
const std::wstring valueName = GetRegistryValueName();
const LSTATUS queryStatus = RegQueryValueExW(
key, valueName.c_str(), nullptr, &type, nullptr, nullptr);
RegCloseKey(key);
return queryStatus == ERROR_SUCCESS && (type == REG_SZ || type == REG_EXPAND_SZ);
}
bool SetEnabled(bool on) {
const std::wstring subKey = GetRegistrySubKey();
const std::wstring valueName = GetRegistryValueName();
if (on) {
const std::wstring path = GetCurrentExePath();
if (path.empty()) {
return false;
}
HKEY key = nullptr;
const LSTATUS createStatus = RegCreateKeyExW(
HKEY_CURRENT_USER, subKey.c_str(), 0, nullptr, REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE, nullptr, &key, nullptr);
if (createStatus != ERROR_SUCCESS) {
return false;
}
const DWORD byteCount = static_cast<DWORD>((path.size() + 1) * sizeof(wchar_t));
const LSTATUS setStatus = RegSetValueExW(
key, valueName.c_str(), 0, REG_SZ,
reinterpret_cast<const BYTE*>(path.c_str()), byteCount);
RegCloseKey(key);
return setStatus == ERROR_SUCCESS;
}
HKEY key = nullptr;
const LSTATUS openStatus = RegOpenKeyExW(
HKEY_CURRENT_USER, subKey.c_str(), 0, KEY_SET_VALUE, &key);
if (openStatus == ERROR_FILE_NOT_FOUND) {
return true;
}
if (openStatus != ERROR_SUCCESS) {
return false;
}
const LSTATUS deleteStatus = RegDeleteValueW(key, valueName.c_str());
RegCloseKey(key);
return deleteStatus == ERROR_SUCCESS || deleteStatus == ERROR_FILE_NOT_FOUND;
}
bool ReconcileWithState(bool autoStart) {
return IsEnabled() == autoStart || SetEnabled(autoStart);
}
void SetRegistryKeyOverride(const std::wstring& subkey) {
g_registryKeyOverride = subkey;
}
} // namespace autostart

View File

@@ -1,14 +0,0 @@
#pragma once
#include <string>
namespace autostart {
bool IsEnabled();
bool SetEnabled(bool on);
std::wstring GetRegistryValueName();
std::wstring GetCurrentExePath();
bool ReconcileWithState(bool autoStart);
void SetRegistryKeyOverride(const std::wstring& subkey);
} // namespace autostart

View File

@@ -1,328 +0,0 @@
// Benchmark.cpp
//
// See Benchmark.h. Minimal, side-effect-free measurement runner.
#include "Benchmark.h"
#include "GrassWindow.h"
#include "Pacing.h"
#include "Sim.h"
#include <shellscalingapi.h>
#include <algorithm>
#include <cstdio>
#include <cwchar>
#include <cstring>
#include <string>
#pragma comment(lib, "Shcore.lib")
#pragma comment(lib, "User32.lib")
namespace desktopgrass::benchmark {
namespace {
// Same seed used by the production App so blade layouts are bit-identical
// across production runs and benchmark runs at the same monitor origin.
constexpr uint64_t kBenchmarkDefaultSeed = 0xD3C7C0F30070D511ull;
bool ParseInt(const wchar_t* s, int& out) {
if (!s || !*s) return false;
wchar_t* end = nullptr;
long v = std::wcstol(s, &end, 10);
if (end == s || *end != L'\0') return false;
out = static_cast<int>(v);
return true;
}
bool ParseU64(const wchar_t* s, uint64_t& out) {
if (!s || !*s) return false;
wchar_t* end = nullptr;
int base = 10;
if (s[0] == L'0' && (s[1] == L'x' || s[1] == L'X')) {
base = 16;
s += 2;
}
unsigned long long v = std::wcstoull(s, &end, base);
if (end == s || *end != L'\0') return false;
out = static_cast<uint64_t>(v);
return true;
}
bool ParseScene(const wchar_t* s, Scene& out) {
int n = -1;
if (ParseInt(s, n) && n >= 0 && n < SCENE_COUNT) {
out = static_cast<Scene>(n);
return true;
}
if (_wcsicmp(s, L"grass") == 0) { out = Scene::Grass; return true; }
if (_wcsicmp(s, L"desert") == 0) { out = Scene::Desert; return true; }
if (_wcsicmp(s, L"winter") == 0) { out = Scene::Winter; return true; }
if (_wcsicmp(s, L"autumn") == 0) { out = Scene::Autumn; return true; }
if (_wcsicmp(s, L"ocean") == 0) { out = Scene::Ocean; return true; }
return false;
}
bool ParseCritter(const wchar_t* s, CritterKind& out) {
int n = -1;
if (ParseInt(s, n) && n >= 0 && n < CRITTER_COUNT) {
out = static_cast<CritterKind>(n);
return true;
}
if (_wcsicmp(s, L"none") == 0) { out = CritterKind::None; return true; }
if (_wcsicmp(s, L"sheep") == 0) { out = CritterKind::Sheep; return true; }
if (_wcsicmp(s, L"cat") == 0) { out = CritterKind::Cat; return true; }
if (_wcsicmp(s, L"bunny") == 0) { out = CritterKind::Bunny; return true; }
if (_wcsicmp(s, L"all") == 0) { out = CritterKind::Bunny; return true; }
return false;
}
// Split an arg of the form `--key=value` into (key, value). If the arg is
// `--key` with the value in the next arg, value is set to the next arg and
// `consumedExtra` is true.
struct KV {
std::wstring key;
const wchar_t* value = nullptr;
bool consumedExtra = false;
};
KV SplitArg(const wchar_t* arg, const wchar_t* nextArg) {
KV kv;
if (!arg || arg[0] != L'-' || arg[1] != L'-') return kv;
const wchar_t* eq = std::wcschr(arg, L'=');
if (eq) {
kv.key.assign(arg + 2, eq);
kv.value = eq + 1;
} else {
kv.key.assign(arg + 2);
if (nextArg && nextArg[0] != L'-') {
kv.value = nextArg;
kv.consumedExtra = true;
}
}
return kv;
}
// Get the primary monitor's work area + effective DPI.
bool GetPrimaryMonitorInfo(RECT& workOut, UINT& dpiOut) {
POINT origin{ 0, 0 };
HMONITOR mon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
if (!mon) return false;
MONITORINFO mi{};
mi.cbSize = sizeof(mi);
if (!GetMonitorInfoW(mon, &mi)) return false;
workOut = mi.rcWork;
UINT xDpi = 96, yDpi = 96;
if (FAILED(GetDpiForMonitor(mon, MDT_EFFECTIVE_DPI, &xDpi, &yDpi))) {
xDpi = 96;
}
dpiOut = xDpi;
return true;
}
} // anonymous
bool ParseOptions(int argc, wchar_t** argv, Options& out) {
// argv[0] is the executable; start at 1.
for (int i = 1; i < argc; ++i) {
const wchar_t* arg = argv[i];
if (!arg || arg[0] != L'-' || arg[1] != L'-') continue;
// --benchmark is handled by the caller (it's the mode switch) — skip.
if (_wcsicmp(arg, L"--benchmark") == 0) continue;
const wchar_t* nextArg = (i + 1 < argc) ? argv[i + 1] : nullptr;
KV kv = SplitArg(arg, nextArg);
if (kv.key.empty()) continue;
if (kv.consumedExtra) ++i;
if (!kv.value) continue;
if (_wcsicmp(kv.key.c_str(), L"scene") == 0) {
if (!ParseScene(kv.value, out.scene)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"critter") == 0) {
if (!ParseCritter(kv.value, out.critter)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"critter-count") == 0 ||
_wcsicmp(kv.key.c_str(), L"crittercount") == 0) {
if (!ParseInt(kv.value, out.critterCount)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"seed") == 0) {
if (!ParseU64(kv.value, out.seed)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"duration") == 0) {
if (!ParseInt(kv.value, out.durationSec)) return false;
if (out.durationSec < 1) out.durationSec = 1;
} else if (_wcsicmp(kv.key.c_str(), L"width") == 0) {
if (!ParseInt(kv.value, out.widthPx)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"height") == 0) {
if (!ParseInt(kv.value, out.heightPx)) return false;
} else if (_wcsicmp(kv.key.c_str(), L"fps") == 0 ||
_wcsicmp(kv.key.c_str(), L"target-fps") == 0) {
if (!ParseInt(kv.value, out.targetFps)) return false;
if (out.targetFps < 1) out.targetFps = 1;
} else if (_wcsicmp(kv.key.c_str(), L"out") == 0 ||
_wcsicmp(kv.key.c_str(), L"csv") == 0) {
out.outCsvPath.assign(kv.value);
} else if (_wcsicmp(kv.key.c_str(), L"hidden") == 0 ||
_wcsicmp(kv.key.c_str(), L"hide") == 0) {
// Treat presence of the flag as `true`. Accept explicit value too.
int v = 1;
ParseInt(kv.value, v);
out.hideWindow = (v != 0);
}
// Unknown flags are silently ignored — older drivers stay compatible
// with newer binaries that add flags.
}
return true;
}
int Run(HINSTANCE hInst, const Options& opts) {
RECT primaryWork{};
UINT primaryDpi = 96;
if (!GetPrimaryMonitorInfo(primaryWork, primaryDpi)) {
std::fwprintf(stderr, L"[benchmark] failed to query primary monitor\n");
return 1;
}
const int primaryW = primaryWork.right - primaryWork.left;
const int primaryH = primaryWork.bottom - primaryWork.top;
const int targetW = opts.widthPx > 0 ? opts.widthPx : primaryW;
const int defaultStripPx = static_cast<int>(
((STRIP_HEIGHT + HEADROOM) * primaryDpi / 96.0) + 0.5);
const int targetH = opts.heightPx > 0 ? opts.heightPx : defaultStripPx;
// GrassWindow.Create takes a "monitor work area" rect; it derives window
// origin/size from it. To honour width/height overrides while keeping the
// window pinned to the bottom-left of the primary work area, fabricate a
// monitorBounds rect of the requested width whose bottom matches the
// primary work-area bottom. Height is forced via the strip-DIP formula
// inside GrassWindow.Create; if the caller asked for a non-default
// heightPx we ignore that for the actual HWND (the renderer always uses
// STRIP_HEIGHT + HEADROOM in DIPs), but we still log targetH in the CSV
// header for traceability.
RECT monitorBounds = primaryWork;
monitorBounds.right = monitorBounds.left + targetW;
(void)targetH; // logged in CSV header below; HWND height is fixed by spec
if (!GrassWindow::RegisterWindowClass(hInst)) {
std::fwprintf(stderr, L"[benchmark] RegisterWindowClass failed\n");
return 1;
}
const uint64_t seed = opts.seed != 0 ? opts.seed : kBenchmarkDefaultSeed;
GrassWindow window;
if (!window.Create(hInst, monitorBounds, primaryDpi, seed,
/*density=*/1.0, /*swaySpeed=*/1.0, /*swayAmplitude=*/1.0)) {
std::fwprintf(stderr, L"[benchmark] GrassWindow::Create failed\n");
return 1;
}
if (!opts.hideWindow) {
window.Show();
}
Sim& sim = window.GetRenderer().GetSim();
sim_set_scene(sim, opts.scene);
sim_set_critter_count(sim, opts.critterCount);
sim_set_critter(sim, opts.critter);
// Optional per-frame CSV.
FILE* csv = nullptr;
if (!opts.outCsvPath.empty()) {
if (_wfopen_s(&csv, opts.outCsvPath.c_str(), L"w, ccs=UTF-8") != 0 || !csv) {
std::fwprintf(stderr, L"[benchmark] failed to open %ls for write\n",
opts.outCsvPath.c_str());
return 1;
}
std::fwprintf(csv,
L"# scene=%d critter=%d critter_count=%d seed=0x%016llX "
L"duration_s=%d target_fps=%d width_px=%d height_px=%d dpi=%u "
L"primary_work=%dx%d\n",
static_cast<int>(opts.scene),
static_cast<int>(opts.critter),
opts.critterCount,
static_cast<unsigned long long>(seed),
opts.durationSec, opts.targetFps, targetW, targetH,
primaryDpi, primaryW, primaryH);
std::fwprintf(csv, L"frame_index,t_seconds,dt_ms,render_ms\n");
}
LARGE_INTEGER qpcFreq{};
QueryPerformanceFrequency(&qpcFreq);
const double freq = static_cast<double>(qpcFreq.QuadPart);
LARGE_INTEGER tStart{};
QueryPerformanceCounter(&tStart);
LARGE_INTEGER tPrev = tStart;
const double durationS = static_cast<double>(opts.durationSec);
const double targetFrameSec = 1.0 / static_cast<double>(opts.targetFps);
long long frameIndex = 0;
bool userClosed = false;
FramePacer pacer;
int exitCode = 0;
MSG msg{};
while (true) {
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
if (msg.message == WM_QUIT) {
userClosed = true;
break;
}
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (userClosed) break;
LARGE_INTEGER tNow{};
QueryPerformanceCounter(&tNow);
const double elapsedSinceStart =
static_cast<double>(tNow.QuadPart - tStart.QuadPart) / freq;
if (elapsedSinceStart >= durationS) break;
const double dt = static_cast<double>(tNow.QuadPart - tPrev.QuadPart) / freq;
tPrev = tNow;
LARGE_INTEGER tRenderStart{};
QueryPerformanceCounter(&tRenderStart);
window.RenderFrame(dt, nullptr, 0);
LARGE_INTEGER tRenderEnd{};
QueryPerformanceCounter(&tRenderEnd);
const double renderMs =
static_cast<double>(tRenderEnd.QuadPart - tRenderStart.QuadPart) * 1000.0 / freq;
if (csv) {
std::fwprintf(csv, L"%lld,%.6f,%.4f,%.4f\n",
frameIndex, elapsedSinceStart, dt * 1000.0, renderMs);
}
++frameIndex;
// Pace to target fps via the shared high-resolution waitable timer.
LARGE_INTEGER tAfter{};
QueryPerformanceCounter(&tAfter);
const double spentSec =
static_cast<double>(tAfter.QuadPart - tNow.QuadPart) / freq;
const double remaining = targetFrameSec - spentSec;
pacer.WaitUntilNextFrame(remaining);
}
if (csv) std::fclose(csv);
if (userClosed) exitCode = 2;
// Print one-line summary so the harness can capture even without parsing
// the CSV — written to stdout so it shows up in run logs.
LARGE_INTEGER tEnd{};
QueryPerformanceCounter(&tEnd);
const double totalSec =
static_cast<double>(tEnd.QuadPart - tStart.QuadPart) / freq;
const double effectiveFps = frameIndex > 0 && totalSec > 0
? static_cast<double>(frameIndex) / totalSec
: 0.0;
std::wprintf(L"[benchmark] scene=%d frames=%lld duration_s=%.3f fps=%.2f exit=%d\n",
static_cast<int>(opts.scene), frameIndex, totalSec, effectiveFps,
exitCode);
return exitCode;
}
} // namespace desktopgrass::benchmark

View File

@@ -1,55 +0,0 @@
// Benchmark.h
//
// Optional benchmark entry point used by tools/benchmark/ to measure renderer
// cost in a deterministic, headless-ish run. NOT compiled into the production
// path — main.cpp only invokes this when `--benchmark` appears on the command
// line. Production code is untouched.
//
// The benchmark:
// * Creates ONE GrassWindow on the primary monitor's bottom strip (visible,
// same DComp/Direct2D path users see).
// * Skips the tray icon, MouseHook, persistence, and multi-monitor enum.
// * Forces a fixed scene + critter + seed so blade/entity content is
// reproducible across runs.
// * Renders for the requested duration, capturing per-frame CPU-side
// timings to a CSV.
// * Exits cleanly so an external driver can collect counters and move on.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdint>
#include <string>
#include "Constants.h"
namespace desktopgrass::benchmark {
struct Options {
Scene scene = Scene::Grass;
CritterKind critter = CRITTER_DEFAULT;
int critterCount = 0; // 0 = scene default (random species count)
uint64_t seed = 0; // 0 = use the production app seed
int durationSec = 60;
int widthPx = 0; // 0 = primary-monitor work-area width
int heightPx = 0; // 0 = STRIP_HEIGHT + HEADROOM at primary DPI
int targetFps = 24; // matches Config.h kTargetFpsDefault; overridable
std::wstring outCsvPath; // empty = no per-frame log written
bool hideWindow = false; // SW_HIDE instead of SW_SHOWNOACTIVATE
};
// Parse `--benchmark`-mode args. argv[0] should be the executable; subsequent
// entries are recognized in the form `--key=value` or `--key value`. Unknown
// flags are silently ignored so future production flags don't break older
// harness invocations. Returns false if a required value is malformed.
bool ParseOptions(int argc, wchar_t** argv, Options& out);
// Run a single benchmark using `opts`. Returns the process exit code:
// 0 -> ran for the full duration
// 1 -> setup failure (window/renderer init, output CSV open)
// 2 -> early exit (user closed the window before duration elapsed)
int Run(HINSTANCE hInst, const Options& opts);
} // namespace desktopgrass::benchmark

View File

@@ -1,150 +0,0 @@
// Config.cpp
#include "Config.h"
#include "Json.h"
#include <Windows.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
namespace desktopgrass::config {
namespace {
// Annotated default config written verbatim on first run. JSONC comments are
// tolerated by the loader, so users can keep these notes while editing.
constexpr char kDefaultConfigTemplate[] =
"{\n"
" // DesktopGrass settings. Edit and restart the app to apply.\n"
" // This file is created once and never overwritten, so your edits stick.\n"
" \"version\": 1,\n"
"\n"
" // Animation frame rate. Lower = less CPU, choppier motion. Range 5-144.\n"
" \"targetFps\": 24,\n"
"\n"
" // Grass blade density. Lower = fewer blades (less CPU). Range 0.2-5.0.\n"
" // Default 2.53125.\n"
" \"bladeDensity\": 2.53125,\n"
"\n"
" // Grass sway speed multiplier. 1.0 = default, 0.0 = still, higher = faster.\n"
" // Range 0.0-3.0.\n"
" \"swaySpeed\": 1.0,\n"
"\n"
" // Grass sway amplitude multiplier (how far blades lean). 1.0 = default,\n"
" // 0.0 = upright. Range 0.0-3.0.\n"
" \"swayAmplitude\": 1.0\n"
"}\n";
std::wstring DefaultConfigFilePath() {
wchar_t* localAppData = nullptr;
std::size_t length = 0;
_wdupenv_s(&localAppData, &length, L"LOCALAPPDATA");
std::filesystem::path path = localAppData && length > 0
? std::filesystem::path(localAppData)
: std::filesystem::current_path();
if (localAppData) {
std::free(localAppData);
}
path /= L"DesktopGrass";
path /= L"config.json";
return path.wstring();
}
// Writes the annotated default config, but only if no file exists yet. Uses
// CREATE_NEW so a concurrent writer or an existing user file is never clobbered.
void TryWriteDefaultConfig(const std::filesystem::path& path) {
const std::filesystem::path directory = path.parent_path();
if (!directory.empty()) {
std::error_code ec;
std::filesystem::create_directories(directory, ec);
}
HANDLE handle = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE) {
return; // Already exists (or unwritable): leave it untouched.
}
DWORD written = 0;
WriteFile(handle, kDefaultConfigTemplate,
static_cast<DWORD>(sizeof(kDefaultConfigTemplate) - 1), &written, nullptr);
CloseHandle(handle);
}
template <typename T>
T Clamp(T value, T lo, T hi) {
return value < lo ? lo : (value > hi ? hi : value);
}
// Clamps a config double, treating non-finite values (NaN/inf from malformed
// input) as the supplied default so they can never poison the sim.
double ClampFinite(double value, double lo, double hi, double fallback) {
if (!std::isfinite(value)) return fallback;
return Clamp(value, lo, hi);
}
Config ApplyAndClamp(const json::Value& root) {
Config cfg;
cfg.version = json::ReadInt(root, "version").value_or(kConfigVersion);
const int fps = json::ReadInt(root, "targetFps").value_or(kTargetFpsDefault);
cfg.targetFps = Clamp(fps, kTargetFpsMin, kTargetFpsMax);
const double density = json::ReadDouble(root, "bladeDensity").value_or(kBladeDensityDefault);
cfg.bladeDensity = ClampFinite(density, kBladeDensityMin, kBladeDensityMax, kBladeDensityDefault);
const double swaySpeed = json::ReadDouble(root, "swaySpeed").value_or(kSwaySpeedDefault);
cfg.swaySpeed = ClampFinite(swaySpeed, kSwaySpeedMin, kSwaySpeedMax, kSwaySpeedDefault);
const double swayAmp = json::ReadDouble(root, "swayAmplitude").value_or(kSwayAmplitudeDefault);
cfg.swayAmplitude = ClampFinite(swayAmp, kSwayAmplitudeMin, kSwayAmplitudeMax, kSwayAmplitudeDefault);
return cfg;
}
} // namespace
std::wstring GetConfigFilePath() {
return DefaultConfigFilePath();
}
Config LoadConfig(const std::wstring& pathStr) {
const std::filesystem::path path(pathStr);
if (!std::filesystem::exists(path)) {
TryWriteDefaultConfig(path);
return Config{}; // Defaults match the template we just wrote.
}
std::ifstream file(path, std::ios::binary);
if (!file) {
return Config{};
}
std::ostringstream buffer;
buffer << file.rdbuf();
const std::string text = buffer.str();
json::Value root;
if (!json::Parse(text, root) || root.type != json::Value::Type::Object) {
OutputDebugStringA("DesktopGrass config: malformed config.json; using defaults.\n");
return Config{}; // Preserve the user's file; just fall back to defaults.
}
return ApplyAndClamp(root);
}
Config LoadConfig() {
return LoadConfig(GetConfigFilePath());
}
} // namespace desktopgrass::config

View File

@@ -1,50 +0,0 @@
// Config.h
//
// User-editable settings loaded from config.json. Distinct from state.json
// (which the app owns and rewrites): config.json is written once with annotated
// defaults if missing, and thereafter only ever read — never overwritten — so a
// user's hand edits are preserved. Loaded once at startup.
#pragma once
#include "Constants.h"
#include <string>
namespace desktopgrass::config {
// Valid ranges and defaults for the exposed knobs. Defaults reproduce the app's
// historical behavior exactly.
constexpr int kConfigVersion = 1;
constexpr int kTargetFpsDefault = 24;
constexpr int kTargetFpsMin = 5;
constexpr int kTargetFpsMax = 144;
constexpr double kBladeDensityDefault = DEFAULT_DENSITY; // 2.53125
constexpr double kBladeDensityMin = 0.2;
constexpr double kBladeDensityMax = 5.0;
constexpr double kSwaySpeedDefault = 1.0;
constexpr double kSwaySpeedMin = 0.0;
constexpr double kSwaySpeedMax = 3.0;
constexpr double kSwayAmplitudeDefault = 1.0;
constexpr double kSwayAmplitudeMin = 0.0;
constexpr double kSwayAmplitudeMax = 3.0;
struct Config {
int version = kConfigVersion;
int targetFps = kTargetFpsDefault;
double bladeDensity = kBladeDensityDefault;
double swaySpeed = kSwaySpeedDefault;
double swayAmplitude = kSwayAmplitudeDefault;
};
// Loads config.json from the default location, creating an annotated default
// file if it is missing. Returns clamped, validated values; on any error falls
// back to defaults without overwriting an existing file. Always succeeds.
Config LoadConfig();
// Path overload for tests: reads/creates the config at the given path.
Config LoadConfig(const std::wstring& path);
std::wstring GetConfigFilePath();
} // namespace desktopgrass::config

View File

@@ -1,988 +0,0 @@
// Constants.h
//
// Single source of truth for all simulation constants.
// Mirrors docs/architecture.md §11. If a constant changes here it MUST change
// in the spec first.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace desktopgrass {
// Geometry --------------------------------------------------------------------
constexpr double STRIP_HEIGHT = 80.0;
constexpr double HEADROOM = 30.0;
// Procedural generation -------------------------------------------------------
constexpr double DEFAULT_DENSITY = 2.53125;
constexpr double BLADE_SPACING_MIN = 4.0;
constexpr double BLADE_SPACING_MAX = 8.0;
constexpr double BLADE_HEIGHT_MIN = 6.0;
constexpr double BLADE_HEIGHT_MAX = 30.0;
constexpr double BLADE_THICKNESS_MIN = 1.0;
constexpr double BLADE_THICKNESS_MAX = 2.5;
// Render-only stroke-width bonus added to each blade so grass reads thicker
// on screen without perturbing the generation PRNG / blade snapshots.
constexpr double BLADE_THICKNESS_RENDER_BONUS = 1.5;
constexpr double STIFFNESS_MIN = 0.6;
constexpr double STIFFNESS_MAX = 1.0;
constexpr int PALETTE_SIZE = 6;
// Sway / gust physics ---------------------------------------------------------
// π / 3 → 6-second sway period.
constexpr double BASE_SWAY_SPEED = 1.0471975511965976;
constexpr double BASE_AMPLITUDE = 3.3;
constexpr double DECAY_RATE = 2.5;
constexpr double GUST_TO_LEAN_FACTOR = 0.75;
constexpr double MAX_CURSOR_SPEED = 4000.0;
constexpr double IMPULSE_SCALE = 0.003;
constexpr double GUST_RADIUS = 150.0;
constexpr double CURSOR_REINIT_GAP_SEC = 0.25;
// Cut ------------------------------------------------------------------------
constexpr double CUT_RADIUS = 15.0;
constexpr double CUT_DURATION_SEC = 0.2;
constexpr double CUT_STUMP_THRESHOLD = 0.05;
constexpr double STUMP_HEIGHT = 2.0;
constexpr double MUSHROOM_STUMP_HEIGHT = 4.0; // §7 — sits a touch above the grass stub line
constexpr double CTRL_OFFSET_FACTOR = 0.6;
// fraction of blade length that the tip may horizontally displace; clamps gust impulses so the blade never folds completely flat.
constexpr double MAX_LEAN_FRACTION = 0.95;
// Cut residual height (stubble). A freshly mowed blade does not collapse to a
// flat stump; it settles at a small per-blade normalized height in
// [CUT_FLOOR_MIN, CUT_FLOOR_MAX] so the cut line reads with gentle, natural
// variation instead of a perfectly even edge. Both bounds stay above
// CUT_STUMP_THRESHOLD so stubble renders as a short blade (still swaying), not
// a degenerate stump. Sampled from an independent stream salted with
// CUT_FLOOR_PRNG_SALT so it does NOT perturb the main generation sequence.
constexpr double CUT_FLOOR_MIN = 0.06;
constexpr double CUT_FLOOR_MAX = 0.16;
constexpr uint64_t CUT_FLOOR_PRNG_SALT = 0xC07F100DC07F100Dull;
// Regrowth -------------------------------------------------------------------
// After a blade's cut animation finishes, it waits `regrowDelay` seconds (a
// per-blade jittered value in [MIN, MAX]) and then grows back from cutHeight=0
// to cutHeight=1 linearly over `regrowDuration` seconds (also per-blade
// jittered). The jitter is sampled from a second xorshift64 stream seeded with
// `seed XOR REGROW_PRNG_SALT` so it does NOT perturb blade positions/heights
// drawn from the main stream — conformance with seed 0x6B6173746F is preserved.
constexpr double REGROW_DELAY_MIN = 30.0;
constexpr double REGROW_DELAY_MAX = 90.0;
constexpr double REGROW_DURATION_MIN = 2.0;
constexpr double REGROW_DURATION_MAX = 4.0;
constexpr uint64_t REGROW_PRNG_SALT = 0xDEADBEEFCAFEBABEull;
// Flowers (§4, §5, §7). Sampled from a third independent PRNG stream
// (seed XOR FLOWER_PRNG_SALT) so the main stream stays bit-identical
// to the pre-flower implementation. 4% of blades become flowers; each
// flower has a head color (6-entry palette), head radius, and a stem
// height bonus of 1.2x1.5x. Non-flower blades carry heightBonus=1.0.
constexpr double FLOWER_PROBABILITY = 0.04;
constexpr double FLOWER_HEIGHT_BONUS_MIN = 1.2;
constexpr double FLOWER_HEIGHT_BONUS_MAX = 1.5;
constexpr double FLOWER_HEAD_RADIUS_MIN = 1.8; // DIP
constexpr double FLOWER_HEAD_RADIUS_MAX = 3.0; // DIP
constexpr int FLOWER_PALETTE_SIZE = 6;
constexpr uint64_t FLOWER_PRNG_SALT = 0xC0FFEEFACE0FFE5ull;
constexpr uint32_t FLOWER_PALETTE[FLOWER_PALETTE_SIZE] = {
0xFFFFEB3Bu, // 0 yellow (dandelion)
0xFFFFA726u, // 1 orange (marigold)
0xFFFF80ABu, // 2 pink (cosmos)
0xFFE1BEE7u, // 3 lavender
0xFFFFFFFFu, // 4 white (daisy)
0xFFEF5350u, // 5 red (poppy)
};
// Mushrooms (PROTOTYPE — Native-only for now). 2.5% of blade slots become
// mushrooms (filled-ellipse cap on a short stem). Sampled from a fourth
// independent PRNG stream so adding mushrooms does NOT perturb the existing
// flower / regrowth / main streams. Mushrooms preempt grass rendering at a
// slot: the renderer draws the mushroom geometry and skips the grass blade
// + flower head for that slot.
constexpr double MUSHROOM_PROBABILITY = 0.025;
constexpr double MUSHROOM_CAP_WIDTH_MIN = 4.0; // DIP, radius X
constexpr double MUSHROOM_CAP_WIDTH_MAX = 8.0;
constexpr double MUSHROOM_CAP_HEIGHT_MIN = 2.5; // DIP, radius Y (flatter than width)
constexpr double MUSHROOM_CAP_HEIGHT_MAX = 5.0;
constexpr double MUSHROOM_STEM_HEIGHT_MIN = 4.0; // DIP
constexpr double MUSHROOM_STEM_HEIGHT_MAX = 10.0;
constexpr double MUSHROOM_STEM_THICKNESS_MIN = 2.0; // DIP
constexpr double MUSHROOM_STEM_THICKNESS_MAX = 4.0;
constexpr int MUSHROOM_PALETTE_SIZE = 6;
constexpr uint64_t MUSHROOM_PRNG_SALT = 0xBADC0FFEE0FACE21ull;
constexpr uint32_t MUSHROOM_STEM_COLOR = 0xFFF5F5DCu; // beige/ivory
// Ambient gusts (architecture.md §8.1). Small, randomly scheduled puffs of
// wind that fire independently of cursor input. Implemented via a fifth
// independent PRNG stream salted with AMBIENT_GUST_PRNG_SALT so adding
// ambient gusts does NOT perturb the main / regrowth / flower / mushroom
// streams — the §12 static blade snapshot is unchanged.
//
// Per-fire draw order (locked in §8.1): x, signDir, magFactor, interval.
// Four draws per emitted puff, zero draws on idle ticks.
constexpr uint64_t AMBIENT_GUST_PRNG_SALT = 0xB7EE2EE2B7EE2EE2ull;
constexpr double AMBIENT_GUST_INTERVAL_MIN = 5.0; // sec
constexpr double AMBIENT_GUST_INTERVAL_MAX = 15.0; // sec
constexpr double AMBIENT_GUST_MAG_FACTOR_MIN = 0.3; // unitless, fraction of MAX_CURSOR_SPEED
constexpr double AMBIENT_GUST_MAG_FACTOR_MAX = 0.6;
constexpr double AMBIENT_GUST_RADIUS_FACTOR = 0.5; // unitless, fraction of GUST_RADIUS
// Desert scene shrinks non-cactus, non-mushroom blade heights at render
// time so cacti read as the dominant biome feature.
constexpr double DESERT_GRASS_HEIGHT_SCALE = 0.5;
// Winter scene shrinks ordinary blade heights so pines and snow caps
// read as the dominant features; mushrooms are also suppressed below.
constexpr double WINTER_GRASS_HEIGHT_SCALE = 0.5;
// Cacti (§14). Slot-bound Desert blade variants generated from an independent
// PRNG stream so the §12 static blade snapshot remains unchanged.
constexpr double CACTUS_PROBABILITY = 0.005;
constexpr double CACTUS_HEIGHT_MIN = 30.0;
constexpr double CACTUS_HEIGHT_MAX = 70.0;
constexpr double CACTUS_WIDTH_MIN = 8.0;
constexpr double CACTUS_WIDTH_MAX = 14.0;
constexpr double CACTUS_ARM_PROBABILITY = 0.55;
constexpr double CACTUS_TWO_ARM_PROBABILITY = 0.35;
constexpr double CACTUS_ARM_MIN_HEIGHT = 50.0; // only tall cacti grow arms (range is 30-70)
constexpr double CACTUS_ARM_MIN_CUT_HEIGHT = 0.85; // render-only: hide arms once a cactus is cut
constexpr uint32_t CACTUS_COLOR = 0xFF2D7A2Du;
constexpr uint64_t CACTUS_PRNG_SALT = 0xCAC75CAC75CAC75Cull;
// Tumbleweeds (§14). Desert roaming entities generated and respawned from a
// persistent stream seeded with seed XOR TUMBLEWEED_PRNG_SALT.
constexpr int TUMBLEWEED_COUNT_PER_1920DIP = 4;
constexpr double TUMBLEWEED_SIZE_MIN = 8.0;
constexpr double TUMBLEWEED_SIZE_MAX = 18.0;
constexpr double TUMBLEWEED_SPEED_MIN = 24.0;
constexpr double TUMBLEWEED_SPEED_MAX = 72.0;
constexpr double TUMBLEWEED_Y_OFFSET_MIN = 8.0;
constexpr double TUMBLEWEED_Y_OFFSET_MAX = 20.0;
constexpr uint32_t TUMBLEWEED_COLOR = 0xFF8A6A3Du;
constexpr uint64_t TUMBLEWEED_PRNG_SALT = 0x7B0117CA7B0117CAull;
// Gentle, staggered vertical hop (§14). Heights are a fraction of the
// tumbleweed radius so the bounce stays subtle; period is the rough gap
// between hops, jittered per-hop. Gravity sets the arc/airtime.
constexpr double TUMBLEWEED_BOUNCE_GRAVITY = 300.0;
constexpr double TUMBLEWEED_BOUNCE_HEIGHT_MIN_FRAC = 0.35;
constexpr double TUMBLEWEED_BOUNCE_HEIGHT_MAX_FRAC = 0.75;
constexpr double TUMBLEWEED_BOUNCE_PERIOD_MIN = 2.5;
constexpr double TUMBLEWEED_BOUNCE_PERIOD_MAX = 6.0;
// Scenes (architecture.md §13). Render-time presentation modes that share
// generation, sway, gust, cut, and ambient-gust logic. The infrastructure
// pass swaps only the blade palette; per-scene entity content (cacti,
// tumbleweeds, snowflakes, frost, falling leaves, maples) ships in §14/§15/§16.5.
enum class Scene : uint8_t {
Grass = 0, // default
Desert = 1,
Winter = 2,
Autumn = 3,
Ocean = 4,
};
constexpr int SCENE_COUNT = 5;
constexpr Scene SCENE_DEFAULT = Scene::Grass;
// Per-scene blade palettes (§13). Each is six ARGB colors indexed by
// blade.hue (drawn from the §5 main PRNG stream — generation is
// scene-agnostic). The Grass palette is the original §4 PALETTE; the
// Desert and Winter palettes are listed below.
constexpr uint32_t DESERT_PALETTE[PALETTE_SIZE] = {
0xFFC9A26Bu, // 0 dried-grass tan
0xFFB48A56u, // 1 warm sand
0xFFD9B57Au, // 2 light dune
0xFF8F6E3Fu, // 3 dust brown
0xFFE6C896u, // 4 pale beige
0xFFA67843u, // 5 burnt sienna
};
constexpr uint32_t WINTER_PALETTE[PALETTE_SIZE] = {
0xFFE8EEF5u, // 0 frost white
0xFFB7C4D2u, // 1 cool silver
0xFFCBD8E5u, // 2 pale ice
0xFFD7E2EEu, // 3 light snow
0xFFA8B7C6u, // 4 winter slate
0xFFEEF3F8u, // 5 hoarfrost
};
constexpr uint32_t AUTUMN_PALETTE[PALETTE_SIZE] = {
0xFFD96B0Cu, // 0 burnt orange
0xFFB54D1Eu, // 1 deep rust
0xFFE89A3Cu, // 2 warm amber
0xFFC23E12u, // 3 vibrant red-orange
0xFFD9A65Cu, // 4 honey-gold
0xFF8C2E0Fu, // 5 dark maroon
};
// Ocean palette — seafloor sand / silt / pebble tones used for blades on
// the Ocean scene (so non-coral grass slots read as wisps of seagrass on
// a sandy bottom rather than green lawn).
constexpr uint32_t OCEAN_PALETTE[PALETTE_SIZE] = {
0xFF3FA9A6u, // 0 teal
0xFF2E8C8Au, // 1 deep teal
0xFF6FC6C2u, // 2 pale aqua
0xFF1F6F75u, // 3 deep sea green
0xFF8FD7CCu, // 4 light sea foam
0xFF257D7Bu, // 5 mid teal
};
constexpr uint32_t MUSHROOM_PALETTE[MUSHROOM_PALETTE_SIZE] = {
0xFFD32F2Fu, // 0 red (amanita)
0xFF8D6E63u, // 1 brown
0xFFC9A66Bu, // 2 tan
0xFFFFF8E1u, // 3 ivory
0xFFE57373u, // 4 dusty pink
0xFF6D4C41u, // 5 dark brown
};
// Tests -----------------------------------------------------------------------
constexpr uint64_t CANONICAL_TEST_SEED = 0x6B6173746Full;
// ARGB palette. Alpha is always 0xFF; window-level transparency is at the
// compositor.
constexpr uint32_t PALETTE[PALETTE_SIZE] = {
0xFF2C5E1Au,
0xFF3A7A24u,
0xFF4C9A2Eu,
0xFF66B845u,
0xFF7AC957u,
0xFF8FD96Au,
};
// (§13) Per-scene blade palettes indexed by `[scene][hue]`. The Grass row
// is the original §4 PALETTE, repeated for uniform indexing.
constexpr uint32_t SCENE_PALETTES[SCENE_COUNT][PALETTE_SIZE] = {
{ PALETTE[0], PALETTE[1], PALETTE[2], PALETTE[3], PALETTE[4], PALETTE[5] },
{ DESERT_PALETTE[0], DESERT_PALETTE[1], DESERT_PALETTE[2], DESERT_PALETTE[3], DESERT_PALETTE[4], DESERT_PALETTE[5] },
{ WINTER_PALETTE[0], WINTER_PALETTE[1], WINTER_PALETTE[2], WINTER_PALETTE[3], WINTER_PALETTE[4], WINTER_PALETTE[5] },
{ AUTUMN_PALETTE[0], AUTUMN_PALETTE[1], AUTUMN_PALETTE[2], AUTUMN_PALETTE[3], AUTUMN_PALETTE[4], AUTUMN_PALETTE[5] },
{ OCEAN_PALETTE[0], OCEAN_PALETTE[1], OCEAN_PALETTE[2], OCEAN_PALETTE[3], OCEAN_PALETTE[4], OCEAN_PALETTE[5] },
};
// Roaming-entity subsystem (§13.2). EntityKind discriminants are
// cross-impl-locked. MAX_ENTITIES_PER_MONITOR caps the snowflake emitter
// so the entities vector cannot grow without bound; the Sim pre-reserves
// to this size at construction to avoid grow churn during the tick.
enum class EntityKind : uint8_t {
None = 0,
Tumbleweed = 1,
Snowflake = 2,
Sheep = 3,
Cat = 4,
// 5 retired (Raindrop — rain effect removed); discriminant left as a gap
// so the remaining cross-impl-locked ordinals stay stable.
Bunny = 6,
Butterfly = 7,
Firefly = 8,
Bird = 9,
Hedgehog = 10,
Leaf = 11,
SnowPuff = 12,
Bubble = 13,
Fish = 14,
};
constexpr int MAX_ENTITIES_PER_MONITOR = 64;
// Critter subsystem — Grass-scene ambient critters plus legacy tray selectors.
// CritterKind discriminants are cross-impl-locked.
enum class CritterKind : uint8_t {
None = 0,
Sheep = 1,
Cat = 2,
Bunny = 3,
};
constexpr int CRITTER_COUNT = 4;
constexpr CritterKind CRITTER_DEFAULT = CritterKind::None;
constexpr uint64_t CRITTER_PRNG_SALT = 0x5C8EE05C8EE05C8Eull;
constexpr int PET_COUNT_OPTIONS[] = { 1, 2, 3, 4, 5, 6 };
constexpr int PET_COUNT_DEFAULT_SHEEP = 2;
constexpr int PET_COUNT_DEFAULT_CAT = 1;
constexpr int PET_COUNT_MAX_PER_MONITOR = 6;
constexpr const wchar_t* SHEEP_NAME_POOL[] = {
L"Bessie", L"Wooly", L"Clover", L"Daisy", L"Pippin", L"Buttercup", L"Mossy", L"Hazel"
};
constexpr const wchar_t* CAT_NAME_POOL[] = {
L"Mittens", L"Whiskers", L"Shadow", L"Ginger", L"Smokey", L"Boots", L"Sage", L"Juno"
};
constexpr const wchar_t* BUNNY_NAME_POOL[] = {
L"Clover", L"Hazel", L"Thumper", L"Mochi", L"Pip", L"Acorn",
L"Biscuit", L"Willow", L"Pepper", L"Hopper", L"Juniper", L"Snowdrop"
};
constexpr const wchar_t* HEDGEHOG_NAME_POOL[] = {
L"Bristle", L"Quill", L"Mossy", L"Truffle", L"Prickles", L"Snuffles",
L"Pinecone", L"Hazel", L"Bramble", L"Pip", L"Sage", L"Burdock"
};
constexpr double PET_NAME_HOVER_RADIUS = 50.0;
constexpr double PET_NAME_FADE_DURATION = 1.5;
constexpr double PET_NAME_FONT_SIZE = 11.0;
constexpr double PET_NAME_OFFSET_Y = -8.0;
constexpr uint32_t PET_NAME_COLOR = 0xFFFFFFFFu;
constexpr uint32_t PET_NAME_SHADOW_COLOR = 0xC0000000u;
// Sheep (§16). Procedurally drawn pet that walks, grazes, and idles along
// the bottom strip. Phase 2: state machine (Walking / Grazing / Idle) with
// animated leg cycle + head bob + grazing-head-down + idle-head-sweep.
// Counts/speeds/sizes are sampled per-monitor from the critter PRNG so
// different displays get different flocks. (Cursor-startle lands in §16.3.)
constexpr int SHEEP_COUNT_MIN = 2;
constexpr int SHEEP_COUNT_MAX = 3;
constexpr double SHEEP_WALK_SPEED_MIN = 14.0; // DIP/sec
constexpr double SHEEP_WALK_SPEED_MAX = 26.0;
// Geometry (DIP). Round, slightly tall body so the silhouette reads as
// "cloud with legs" from a distance.
constexpr double SHEEP_BODY_RADIUS = 12.0; // body x-radius
constexpr double SHEEP_BODY_HEIGHT = 9.5; // body y-radius
constexpr double SHEEP_HEAD_RADIUS = 5.0;
constexpr double SHEEP_LEG_LENGTH = 5.5;
constexpr double SHEEP_TAIL_RADIUS = 3.2; // rear puff
// Palette. Suffolk-style sheep: white wool, dark face, dark legs — that's
// the silhouette people instantly read as "sheep". Cream/pink faces look
// like a different creature entirely at this pixel scale.
constexpr uint32_t SHEEP_BODY_COLOR = 0xFFF7F4EBu; // off-white wool
constexpr uint32_t SHEEP_LEG_COLOR = 0xFF1F1A16u; // near-black
constexpr uint32_t SHEEP_FACE_COLOR = 0xFF1F1A16u; // dark Suffolk face
constexpr uint32_t SHEEP_EAR_COLOR = 0xFF14110Eu; // slightly darker than face
constexpr uint32_t SHEEP_INK_COLOR = 0xFFF7F4EBu; // eyes = light dots on dark face
// Animation cycle. WALK_PERIOD is one full leg cycle (one stride pair).
constexpr double SHEEP_WALK_PERIOD = 0.55; // seconds
constexpr double SHEEP_LEG_CYCLE_AMP = 2.0; // DIP vertical sway of leg-tip
constexpr double SHEEP_HEAD_BOB_AMP = 0.7; // DIP head Y bob during walk
constexpr double SHEEP_TAIL_WIGGLE_AMP = 0.6; // DIP tail X wiggle
// State machine. State encodes 0=Walking, 1=Grazing, 2=Idle, 3=Sleeping,
// 4=Hopping, 5=Greeting in Entity.state. Walking → expires → Grazing /
// Idle / Hopping. Idle → expires → Walking or Sleeping. Other states →
// expires → Walking; Greeting flips vx on exit so paired sheep walk apart.
// Durations drawn from critter PRNG on every transition so behavior is
// deterministic per (seed, monitor). Click-near-sheep also forces Hopping.
constexpr uint8_t SHEEP_STATE_WALKING = 0;
constexpr uint8_t SHEEP_STATE_GRAZING = 1;
constexpr uint8_t SHEEP_STATE_IDLE = 2;
constexpr uint8_t SHEEP_STATE_SLEEPING = 3;
constexpr uint8_t SHEEP_STATE_HOPPING = 4;
constexpr uint8_t SHEEP_STATE_GREETING = 5;
constexpr double SHEEP_WALK_DURATION_MIN = 8.0; // sec — average walk leg before pause
constexpr double SHEEP_WALK_DURATION_MAX = 14.0;
constexpr double SHEEP_GRAZE_DURATION_MIN = 3.0; // sec — head down chewing grass
constexpr double SHEEP_GRAZE_DURATION_MAX = 5.0;
constexpr double SHEEP_IDLE_DURATION_MIN = 1.5; // sec — looking around
constexpr double SHEEP_IDLE_DURATION_MAX = 3.0;
constexpr double SHEEP_SLEEP_DURATION_MIN = 8.0; // sec — Zzz nap
constexpr double SHEEP_SLEEP_DURATION_MAX = 16.0;
constexpr double SHEEP_HOP_DURATION = 0.55; // sec — one parabolic arc
constexpr double SHEEP_GREET_RADIUS = 50.0; // DIP, center-to-center
constexpr double SHEEP_GREET_DURATION_MIN = 1.6; // sec
constexpr double SHEEP_GREET_DURATION_MAX = 2.8;
constexpr double SHEEP_GREET_MIN_AGE = 1.5; // sec, natural cooldown
constexpr double SHEEP_CURIOUS_RADIUS = 80.0; // DIP, cursor proximity for noticing
constexpr double SHEEP_CURIOUS_HEAD_TURN_MAX = 0.55; // radians, max head rotation toward cursor
// Walking-expiry distribution. Cumulative: r<GRAZE → Grazing, else
// r<GRAZE+IDLE → Idle, else → Hopping. GRAZE + IDLE + HOP_PROB == 1.0.
constexpr double SHEEP_GRAZE_PROBABILITY = 0.60;
constexpr double SHEEP_IDLE_PROBABILITY = 0.25;
// Idle-expiry: chance of slipping into Sleeping vs returning to Walking.
constexpr double SHEEP_SLEEP_FROM_IDLE_PROB = 0.30;
// Idle / Grazing / Greeting tiny animations.
constexpr double SHEEP_IDLE_SWEEP_FREQ = 1.4; // rad/sec for L/R head turn
constexpr double SHEEP_GRAZE_MUNCH_FREQ = 8.0; // rad/sec for head nibble bob
constexpr double SHEEP_GRAZE_MUNCH_AMP = 0.6; // DIP
constexpr double SHEEP_GREET_HEAD_BOB_FREQ = 4.5; // rad/sec
constexpr double SHEEP_GREET_HEAD_BOB_AMP = 0.7; // DIP, gentle nuzzle bob
// Hop arc + click-startle.
constexpr double SHEEP_HOP_HEIGHT = 11.0; // DIP peak vertical offset
constexpr double SHEEP_STARTLE_RADIUS = 64.0; // DIP — click within this hops the sheep
constexpr double SHEEP_STARTLE_BOOST = 1.6; // walking speed multiplier post-startle
// Sleeping cosmetic — "Zzz" glyphs drift up from the sheep's head.
constexpr double SHEEP_ZZZ_CYCLE_SEC = 1.8; // one Z lifespan
constexpr double SHEEP_ZZZ_RISE = 11.0; // DIP rise over one cycle
constexpr double SHEEP_ZZZ_SIZE_START = 2.0; // DIP starting Z side
constexpr double SHEEP_ZZZ_SIZE_END = 4.0; // DIP ending Z side
// Cat (§17). Calm color-varied critter that reuses the sheep state byte values but
// only uses Walking, Idle, Sleeping, and Hopping (semantically Pouncing).
constexpr int CAT_COUNT_MIN = 1;
constexpr int CAT_COUNT_MAX = 2;
constexpr double CAT_WALK_SPEED_MIN = 10.0;
constexpr double CAT_WALK_SPEED_MAX = 22.0;
constexpr double CAT_POUNCE_SPEED = 60.0;
constexpr double CAT_BODY_RADIUS = 11.0;
constexpr double CAT_BODY_HEIGHT = 7.0;
constexpr double CAT_HEAD_RADIUS = 4.5;
constexpr double CAT_LEG_LENGTH = 5.0;
constexpr double CAT_TAIL_LENGTH = 13.0;
constexpr double CAT_TAIL_THICKNESS = 1.6;
constexpr double CAT_EAR_HEIGHT = 4.5;
constexpr int CAT_COAT_VARIANT_COUNT = 6;
struct CatCoatPalette {
uint32_t body;
uint32_t leg;
uint32_t face;
uint32_t ear;
uint32_t ink;
};
constexpr CatCoatPalette CAT_COAT_PALETTES[CAT_COAT_VARIANT_COUNT] = {
{ 0xFF6B6259u, 0xFF3D3733u, 0xFF6B6259u, 0xFF3D3733u, 0xFF1A1614u }, // 0 Gray tabby (existing)
{ 0xFFD89A6Fu, 0xFFA56B40u, 0xFFD89A6Fu, 0xFFA56B40u, 0xFF2B1A0Eu }, // 1 Orange
{ 0xFF2A2522u, 0xFF140F0Cu, 0xFF2A2522u, 0xFF140F0Cu, 0xFFD9B85Bu }, // 2 Black (yellow eyes)
{ 0xFFEDE9E1u, 0xFFBDB7ABu, 0xFFEDE9E1u, 0xFFBDB7ABu, 0xFF1F1817u }, // 3 White
{ 0xFF7A5F3Cu, 0xFF4E3F26u, 0xFF7A5F3Cu, 0xFF4E3F26u, 0xFF1A1108u }, // 4 Brown tabby
{ 0xFFC9B898u, 0xFF8E7F6Bu, 0xFFC9B898u, 0xFF8E7F6Bu, 0xFF2E251Du }, // 5 Cream
};
// Backward-compat aliases: variant 0 preserves the original muted gray tabby.
constexpr uint32_t CAT_BODY_COLOR = CAT_COAT_PALETTES[0].body;
constexpr uint32_t CAT_LEG_COLOR = CAT_COAT_PALETTES[0].leg;
constexpr uint32_t CAT_FACE_COLOR = CAT_COAT_PALETTES[0].face;
constexpr uint32_t CAT_EAR_COLOR = CAT_COAT_PALETTES[0].ear;
constexpr uint32_t CAT_INK_COLOR = CAT_COAT_PALETTES[0].ink;
constexpr double CAT_WALK_PERIOD = 0.50;
constexpr double CAT_LEG_CYCLE_AMP = 1.6;
constexpr double CAT_HEAD_BOB_AMP = 0.4;
constexpr double CAT_TAIL_SWAY_FREQ = 1.2;
constexpr double CAT_TAIL_SWAY_AMP = 0.35;
constexpr uint8_t CAT_STATE_WALKING = SHEEP_STATE_WALKING; // 0
constexpr uint8_t CAT_STATE_IDLE = SHEEP_STATE_IDLE; // 2, sit-and-watch
constexpr uint8_t CAT_STATE_SLEEPING = SHEEP_STATE_SLEEPING; // 3
constexpr uint8_t CAT_STATE_POUNCING = SHEEP_STATE_HOPPING; // 4, semantic alias
constexpr double CAT_WALK_DURATION_MIN = 6.0;
constexpr double CAT_WALK_DURATION_MAX = 10.0;
constexpr double CAT_IDLE_DURATION_MIN = 4.0;
constexpr double CAT_IDLE_DURATION_MAX = 8.0;
constexpr double CAT_SLEEP_DURATION_MIN = 20.0;
constexpr double CAT_SLEEP_DURATION_MAX = 40.0;
constexpr double CAT_POUNCE_DURATION = 0.45;
constexpr double CAT_IDLE_PROBABILITY = 0.65;
constexpr double CAT_SLEEP_PROBABILITY = 0.30;
constexpr double CAT_SLEEP_FROM_IDLE_PROB = 0.50;
constexpr double CAT_POUNCE_RADIUS = 80.0;
constexpr double CAT_POUNCE_HEIGHT = 9.0;
constexpr double CAT_CURIOUS_RADIUS = 100.0;
constexpr double CAT_CURIOUS_HEAD_TURN_MAX = 0.7;
// Bunny (§18). Grass-only woodland critter: shy, passive, and always hopping
// when it moves. Generated after sheep and cats from the shared critter PRNG.
constexpr int BUNNY_COUNT_MIN = 1;
constexpr int BUNNY_COUNT_MAX = 2;
constexpr double BUNNY_HOP_SPEED_MIN = 22.0;
constexpr double BUNNY_HOP_SPEED_MAX = 38.0;
constexpr double BUNNY_BODY_RADIUS = 8.0;
constexpr double BUNNY_BODY_HEIGHT = 6.5;
constexpr double BUNNY_HEAD_RADIUS = 4.2;
constexpr double BUNNY_EAR_HEIGHT = 9.0;
constexpr double BUNNY_EAR_WIDTH = 2.2;
constexpr double BUNNY_EAR_SPACING = 3.0;
constexpr double BUNNY_LEG_LENGTH = 4.0;
constexpr double BUNNY_TAIL_RADIUS = 2.4;
constexpr uint32_t BUNNY_BODY_COLOR = 0xFF8A6A4Au;
constexpr uint32_t BUNNY_BELLY_COLOR = 0xFFC4A98Du;
constexpr uint32_t BUNNY_EAR_COLOR = 0xFF8A6A4Au;
constexpr uint32_t BUNNY_EAR_INNER_COLOR = 0xFFD9A0A0u;
constexpr uint32_t BUNNY_TAIL_COLOR = 0xFFF7F4EBu;
constexpr uint32_t BUNNY_EYE_COLOR = 0xFF1A1208u;
constexpr uint32_t BUNNY_NOSE_COLOR = 0xFF8A4040u;
constexpr uint8_t BUNNY_STATE_HOPPING = 0;
constexpr uint8_t BUNNY_STATE_GRAZING = 1;
constexpr uint8_t BUNNY_STATE_IDLE = 2;
constexpr uint8_t BUNNY_STATE_SLEEPING = 3;
constexpr uint8_t BUNNY_STATE_STARTLED = 4;
constexpr double BUNNY_HOP_DURATION = 0.40;
constexpr double BUNNY_HOP_HEIGHT = 8.0;
constexpr double BUNNY_HOP_GAP_MIN = 0.05;
constexpr double BUNNY_HOP_GAP_MAX = 0.20;
constexpr double BUNNY_GRAZE_DURATION_MIN = 2.5;
constexpr double BUNNY_GRAZE_DURATION_MAX = 4.5;
constexpr double BUNNY_IDLE_DURATION_MIN = 2.0;
constexpr double BUNNY_IDLE_DURATION_MAX = 4.0;
constexpr double BUNNY_SLEEP_DURATION_MIN = 6.0;
constexpr double BUNNY_SLEEP_DURATION_MAX = 12.0;
constexpr double BUNNY_GRAZE_PROBABILITY = 0.55;
constexpr double BUNNY_IDLE_PROBABILITY = 0.30;
constexpr double BUNNY_SLEEP_PROB = 0.05;
constexpr double BUNNY_STARTLE_RADIUS = 90.0;
constexpr double BUNNY_STARTLE_BOOST = 2.0;
constexpr double BUNNY_STARTLE_HOP_HEIGHT = 14.0;
constexpr double BUNNY_STARTLE_DURATION = 3.0;
constexpr double BUNNY_NOSE_TWITCH_FREQ = 6.0;
constexpr double BUNNY_NOSE_TWITCH_AMP = 0.5;
constexpr double BUNNY_EAR_WIGGLE_FREQ = 1.2;
constexpr double BUNNY_EAR_WIGGLE_AMP = 0.20;
constexpr double BUNNY_ZZZ_CYCLE_SEC = SHEEP_ZZZ_CYCLE_SEC;
constexpr double BUNNY_ZZZ_RISE = SHEEP_ZZZ_RISE * 0.7;
constexpr double BUNNY_ZZZ_SIZE_START = SHEEP_ZZZ_SIZE_START * 0.7;
constexpr double BUNNY_ZZZ_SIZE_END = SHEEP_ZZZ_SIZE_END * 0.7;
// Hedgehog (§17.9). Grass-only, solitary nocturnal critter. Generated after
// bunnies from the shared critter PRNG; passive defense curls into a ball.
constexpr int HEDGEHOG_COUNT_MIN = 0;
constexpr int HEDGEHOG_COUNT_MAX = 1;
constexpr double HEDGEHOG_COUNT_PROBABILITY = 0.55;
constexpr double HEDGEHOG_WALK_SPEED_MIN = 4.0;
constexpr double HEDGEHOG_WALK_SPEED_MAX = 8.0;
constexpr double HEDGEHOG_BODY_RADIUS = 9.0;
constexpr double HEDGEHOG_BODY_HEIGHT = 5.5;
constexpr double HEDGEHOG_HEAD_RADIUS = 3.6;
constexpr double HEDGEHOG_NOSE_RADIUS = 0.8;
constexpr double HEDGEHOG_LEG_LENGTH = 2.5;
constexpr int HEDGEHOG_SPIKE_COUNT = 14;
constexpr double HEDGEHOG_SPIKE_LENGTH = 3.0;
constexpr double HEDGEHOG_SPIKE_WIDTH = 1.4;
constexpr double HEDGEHOG_SPIKE_ARC_START_DEG = -20.0;
constexpr double HEDGEHOG_SPIKE_ARC_END_DEG = 200.0;
constexpr uint32_t HEDGEHOG_BODY_COLOR = 0xFF5C4633u;
constexpr uint32_t HEDGEHOG_SPIKE_COLOR = 0xFF3A2A1Fu;
constexpr uint32_t HEDGEHOG_SPIKE_TIP_COLOR = 0xFF1E150Eu;
constexpr uint32_t HEDGEHOG_NOSE_COLOR = 0xFF1A1208u;
constexpr uint32_t HEDGEHOG_EYE_COLOR = 0xFF1A1208u;
constexpr uint8_t HEDGEHOG_STATE_WALKING = 0;
constexpr uint8_t HEDGEHOG_STATE_SNUFFLING = 1;
constexpr uint8_t HEDGEHOG_STATE_IDLE = 2;
constexpr uint8_t HEDGEHOG_STATE_SLEEPING = 3;
constexpr uint8_t HEDGEHOG_STATE_CURLED = 4;
constexpr double HEDGEHOG_WALK_DURATION_MIN = 6.0;
constexpr double HEDGEHOG_WALK_DURATION_MAX = 12.0;
constexpr double HEDGEHOG_SNUFFLE_DURATION_MIN = 3.0;
constexpr double HEDGEHOG_SNUFFLE_DURATION_MAX = 6.0;
constexpr double HEDGEHOG_IDLE_DURATION_MIN = 1.5;
constexpr double HEDGEHOG_IDLE_DURATION_MAX = 3.0;
constexpr double HEDGEHOG_SLEEP_DURATION_MIN = 10.0;
constexpr double HEDGEHOG_SLEEP_DURATION_MAX = 25.0;
constexpr double HEDGEHOG_CURL_DURATION_MIN = 3.0;
constexpr double HEDGEHOG_CURL_DURATION_MAX = 5.5;
constexpr double HEDGEHOG_SNUFFLE_PROBABILITY = 0.55;
constexpr double HEDGEHOG_IDLE_PROBABILITY = 0.30;
constexpr double HEDGEHOG_SLEEP_PROB = 0.50;
constexpr double HEDGEHOG_STARTLE_RADIUS = 70.0;
constexpr double HEDGEHOG_SNUFFLE_HEAD_FREQ = 5.0;
constexpr double HEDGEHOG_SNUFFLE_HEAD_AMP = 0.7;
constexpr double HEDGEHOG_WADDLE_FREQ = 4.0;
constexpr double HEDGEHOG_WADDLE_AMP = 0.8;
constexpr double HEDGEHOG_ZZZ_CYCLE_SEC = SHEEP_ZZZ_CYCLE_SEC;
constexpr double HEDGEHOG_ZZZ_RISE = SHEEP_ZZZ_RISE * 0.5;
constexpr double HEDGEHOG_ZZZ_SIZE_START = SHEEP_ZZZ_SIZE_START * 0.6;
constexpr double HEDGEHOG_ZZZ_SIZE_END = SHEEP_ZZZ_SIZE_END * 0.6;
// Butterflies (§17.6). Grass-only, passive daytime ambient flyers.
constexpr int BUTTERFLY_COUNT_MIN = 2;
constexpr int BUTTERFLY_COUNT_MAX = 4;
constexpr double BUTTERFLY_SPEED_MIN = 18.0;
constexpr double BUTTERFLY_SPEED_MAX = 32.0;
constexpr double BUTTERFLY_BODY_LENGTH = 2.4;
constexpr double BUTTERFLY_WING_RADIUS = 3.5;
constexpr double BUTTERFLY_WING_OFFSET = 2.2;
constexpr double BUTTERFLY_FLUTTER_FREQ = 16.0;
constexpr double BUTTERFLY_FLUTTER_MIN_SCALE = 0.20;
constexpr double BUTTERFLY_MEANDER_FREQ_Y = 0.8;
constexpr double BUTTERFLY_MEANDER_AMP_Y = 16.0;
constexpr double BUTTERFLY_MEANDER_FREQ_X = 0.5;
constexpr double BUTTERFLY_MEANDER_AMP_X = 0.4;
constexpr double BUTTERFLY_ALTITUDE_MIN = 18.0;
constexpr double BUTTERFLY_ALTITUDE_MAX = 70.0;
constexpr uint32_t BUTTERFLY_BODY_COLOR = 0xFF2A2018u;
constexpr int BUTTERFLY_COLOR_COUNT = 5;
constexpr uint64_t BUTTERFLY_PRNG_SALT = 0xB07DEF1E0001ull;
struct ButterflyPalette {
uint32_t wingColor;
uint32_t accentColor;
};
constexpr ButterflyPalette BUTTERFLY_PALETTES[BUTTERFLY_COLOR_COUNT] = {
{ 0xFFFF9A2Eu, 0xFF1A130Cu }, // 0 Monarch: orange + black tips
{ 0xFFFFD34Du, 0xFF1A130Cu }, // 1 Swallowtail: yellow + black
{ 0xFFFFF8E8u, 0xFF3A3A3Au }, // 2 Cabbage: white + dark dots
{ 0xFF63C7FFu, 0xFF1B4D99u }, // 3 Morpho: sky blue + deeper blue
{ 0xFFFFA6C8u, 0xFFFF6EA8u }, // 4 Pink: soft pink + rose
};
// Fireflies (§17.7). Grass-only, passive ambient flyers.
constexpr int FIREFLY_COUNT_MIN = 3;
constexpr int FIREFLY_COUNT_MAX = 6;
constexpr double FIREFLY_DRIFT_SPEED_MIN = 4.0;
constexpr double FIREFLY_DRIFT_SPEED_MAX = 10.0;
constexpr double FIREFLY_BODY_RADIUS = 1.2;
constexpr double FIREFLY_GLOW_RADIUS = 5.0;
constexpr double FIREFLY_BLINK_PERIOD_MIN = 1.4;
constexpr double FIREFLY_BLINK_PERIOD_MAX = 2.6;
constexpr double FIREFLY_BLINK_DUTY = 0.55;
constexpr double FIREFLY_BLINK_FADE = 0.30;
constexpr double FIREFLY_DRIFT_FREQ_X = 0.4;
constexpr double FIREFLY_DRIFT_FREQ_Y = 0.6;
constexpr double FIREFLY_DRIFT_AMP_X = 0.6;
constexpr double FIREFLY_DRIFT_AMP_Y = 8.0;
constexpr double FIREFLY_ALTITUDE_MIN = 8.0;
constexpr double FIREFLY_ALTITUDE_MAX = 55.0;
constexpr uint32_t FIREFLY_BODY_COLOR = 0xFFFFEE88u;
constexpr uint32_t FIREFLY_GLOW_COLOR_RGB = 0xEEDD66u;
constexpr int FIREFLY_GLOW_ALPHA_MAX = 110;
constexpr int FIREFLY_BODY_ALPHA_MAX = 255;
constexpr uint64_t FIREFLY_PRNG_SALT = 0xF13EF1E7777ull;
// Bird flybys (§17.8). Grass-only transient flocks.
constexpr double BIRD_FLYBY_SPAWN_RATE_PER_HOUR = 15.0;
constexpr int BIRD_FLOCK_SIZE_MIN = 3;
constexpr int BIRD_FLOCK_SIZE_MAX = 7;
constexpr double BIRD_FLOCK_FORMATION_SPACING = 9.0;
constexpr double BIRD_FLOCK_V_ANGLE_DEG = 22.0;
constexpr double BIRD_SPEED_MIN = 65.0;
constexpr double BIRD_SPEED_MAX = 95.0;
constexpr double BIRD_ALTITUDE_MIN = 78.0;
constexpr double BIRD_ALTITUDE_MAX = 96.0;
constexpr double BIRD_BODY_LENGTH = 3.6;
constexpr double BIRD_WING_SPAN = 5.0;
constexpr double BIRD_WING_FLAP_FREQ = 7.0;
constexpr double BIRD_WING_FLAP_PHASE_JITTER = 0.6;
constexpr uint32_t BIRD_BODY_COLOR = 0xFF1A1610u;
constexpr double BIRD_WING_OPEN_RATIO = 1.0;
constexpr double BIRD_WING_FOLD_RATIO = 0.30;
constexpr double BIRD_FADE_IN_FRAC = 0.08;
constexpr double BIRD_FADE_OUT_FRAC = 0.08;
constexpr double BIRD_DRIFT_AMP_Y = 3.0;
constexpr double BIRD_DRIFT_FREQ_Y = 0.8;
constexpr uint64_t BIRD_FLYBY_PRNG_SALT = 0xB12D1F1A1B12D1Aull;
// Snowflakes (§15)
constexpr double SNOWFLAKE_EMIT_RATE_PER_1920DIP = 8.0; // flakes/sec
constexpr double SNOWFLAKE_FALL_SPEED_MIN = 20.0; // DIP/sec
constexpr double SNOWFLAKE_FALL_SPEED_MAX = 40.0;
constexpr double SNOWFLAKE_SIZE_MIN = 1.5; // DIP
constexpr double SNOWFLAKE_SIZE_MAX = 3.0;
constexpr double SNOWFLAKE_SWAY_AMPLITUDE = 10.0; // DIP
constexpr double SNOWFLAKE_SWAY_FREQUENCY = 0.6; // Hz
constexpr double SNOWFLAKE_LIFETIME_PADDING_SEC = 2.0;
constexpr uint32_t SNOWFLAKE_COLOR = 0xFFFFFFFFu;
constexpr uint64_t SNOWFLAKE_PRNG_SALT = 0xC0FFEE1CECAFEBABull;
// Snow puff (§21). A click in the Winter scene kicks up a short-lived burst
// of powder. Dedicated PRNG stream (salted) so the burst never perturbs the
// snowflake emitter; it only fires on click input. y is screen-down, so an
// upward launch is negative vy and SNOW_PUFF_GRAVITY pulls back toward ground.
constexpr int SNOW_PUFF_COUNT_MIN = 9;
constexpr int SNOW_PUFF_COUNT_MAX = 16;
constexpr double SNOW_PUFF_SIZE_MIN = 3.5; // DIP
constexpr double SNOW_PUFF_SIZE_MAX = 8.0;
constexpr double SNOW_PUFF_BURST_SPEED_MIN = 70.0; // DIP/sec
constexpr double SNOW_PUFF_BURST_SPEED_MAX = 150.0;
constexpr double SNOW_PUFF_SPREAD_RAD = 1.25; // half-angle about vertical
constexpr double SNOW_PUFF_GRAVITY = 150.0; // DIP/sec^2
constexpr double SNOW_PUFF_DRAG = 1.6; // horizontal decay
constexpr double SNOW_PUFF_START_RADIUS = 7.0; // initial scatter around click
constexpr double SNOW_PUFF_LIFETIME_MIN = 1.0; // sec
constexpr double SNOW_PUFF_LIFETIME_MAX = 1.8;
// Puffs are white, so on the white bank they need an edge: the cool bank-shadow
// brush is reused to draw a slightly larger disc offset down behind the core.
constexpr double SNOW_PUFF_SHADOW_SCALE = 1.35; // shadow radius vs core
constexpr double SNOW_PUFF_SHADOW_OFFSET = 0.45; // downward offset vs core radius
constexpr double SNOW_PUFF_SHADOW_OPACITY = 0.55; // relative to the puff's age alpha
constexpr uint64_t SNOW_PUFF_PRNG_SALT = 0x5503FF1E5503FF1Eull;
// §21.1 Snow drift (Winter cursor-move spindrift). Brushing the cursor low and
// fast across the snowbank kicks up a small, gentle wisp of powder — the Winter
// analogue of the autumn leaf-puff hover, giving the scene a calm move-driven
// interaction to match grass/desert/fall. Reuses the snow-puff particle but with
// fewer, smaller, slower grains. A global cooldown keeps it from spamming.
constexpr int SNOW_DRIFT_COUNT_MIN = 4;
constexpr int SNOW_DRIFT_COUNT_MAX = 8;
constexpr double SNOW_DRIFT_REACH_DIP = 70.0; // cursor must be this near the ground
constexpr double SNOW_DRIFT_MIN_SPEED = 90.0; // DIP/sec; only kicks up while moving
constexpr double SNOW_DRIFT_COOLDOWN_SEC = 0.12; // global gate (~8 wisps/sec max)
constexpr double SNOW_DRIFT_SIZE_SCALE = 0.9; // a touch smaller than a click burst
constexpr double SNOW_DRIFT_SPEED_SCALE = 0.85; // a touch gentler kick
constexpr uint64_t SNOW_DRIFT_PRNG_SALT = 0x5D81F77D5D81F77Dull;
// Cool shadow tint reused by the live snow-puff to give white powder an edge
// against light backgrounds (despite the legacy "bank" name).
constexpr uint32_t SNOW_BANK_SHADOW_COLOR = 0xFFBFCDE4u; // cool blue base/trough shadow
// Falling leaves (§16.5). Autumn-only transient particles.
constexpr double LEAF_SPAWN_RATE_PER_SEC_1920DIP = 1.4;
constexpr double LEAF_FALL_SPEED_MIN = 14.0;
constexpr double LEAF_FALL_SPEED_MAX = 26.0;
constexpr double LEAF_HORIZONTAL_DRIFT_AMP = 32.0;
constexpr double LEAF_HORIZONTAL_DRIFT_FREQ = 1.4;
constexpr double LEAF_ROTATION_SPEED_MIN = 0.8;
constexpr double LEAF_ROTATION_SPEED_MAX = 2.4;
constexpr double LEAF_SIZE_MIN = 4.0;
constexpr double LEAF_SIZE_MAX = 7.0;
constexpr double LEAF_SPAWN_Y_OFFSET = -10.0;
constexpr int LEAF_COLOR_COUNT = 6;
constexpr uint32_t LEAF_COLOR_0 = 0xFFD96B0Cu;
constexpr uint32_t LEAF_COLOR_1 = 0xFFB54D1Eu;
constexpr uint32_t LEAF_COLOR_2 = 0xFFE89A3Cu;
constexpr uint32_t LEAF_COLOR_3 = 0xFFC23E12u;
constexpr uint32_t LEAF_COLOR_4 = 0xFFE6C849u;
constexpr uint32_t LEAF_COLOR_5 = 0xFF8C2E0Fu;
constexpr uint64_t LEAF_PRNG_SALT = 0x1EA1DEC1D1EA1D05ull;
constexpr uint32_t LEAF_COLORS[LEAF_COLOR_COUNT] = {
LEAF_COLOR_0, LEAF_COLOR_1, LEAF_COLOR_2,
LEAF_COLOR_3, LEAF_COLOR_4, LEAF_COLOR_5,
};
// Snow-tipped blade caps (§15)
constexpr double SNOW_TIP_RADIUS_FACTOR = 1.25;
constexpr uint32_t SNOW_TIP_COLOR = 0xFFFFFFFFu;
// Pine trees (§15.1). Winter biome anchor — slot-bound, mirrors §14 cacti.
constexpr double PINE_PROBABILITY = 0.0075;
constexpr double PINE_HEIGHT_MIN = 45.0;
constexpr double PINE_HEIGHT_MAX = 90.0;
constexpr double PINE_WIDTH_MIN = 28.0;
constexpr double PINE_WIDTH_MAX = 48.0;
constexpr int PINE_TIER_COUNT_MIN = 2;
constexpr int PINE_TIER_COUNT_MAX = 4;
constexpr double PINE_TIP_TAPER = 0.25;
constexpr double PINE_TIER_OVERLAP = 0.15;
constexpr double PINE_SNOW_CAP_FRACTION = 0.30;
constexpr uint32_t PINE_COLOR = 0xFF1B5E20u;
// Dimensional shading for pine boughs: a darker green dropped down-right as a
// self-shadow and a lighter green dabbed on the upper-left lit face, so each
// tier reads as a rounded bough instead of a flat triangle.
constexpr uint32_t PINE_SHADOW_COLOR = 0xFF103D16u;
constexpr uint32_t PINE_HIGHLIGHT_COLOR = 0xFF43A047u;
constexpr double PINE_SHADOW_OFFSET_X_FRAC = 0.14;
constexpr double PINE_SHADOW_OFFSET_Y_FRAC = 0.07;
constexpr double PINE_HIGHLIGHT_OFFSET_X_FRAC = 0.20;
constexpr double PINE_HIGHLIGHT_WIDTH_FRAC = 0.50;
constexpr float PINE_HIGHLIGHT_OPACITY = 0.5f;
constexpr uint64_t PINE_PRNG_SALT = 0x50494E4550494E45ull;
// Tree sway (§15.2). Fall maples and winter pines/birches are blades, so they
// already carry effectiveLean (ambient sway + nearby-cursor gusts). The renderer
// shears each tree about its trunk base by a fraction of that lean so the canopy
// drifts ever so slightly with the wind and the mouse — the same life the grass
// has, scaled way down. TREE_SWAY_LEAN_FACTOR damps the grass-calibrated lean;
// TREE_SWAY_MAX_HEIGHT_FRACTION clamps the apex shift to a fraction of tree
// height so a hard gust can never visibly snap the trunk.
constexpr double TREE_SWAY_LEAN_FACTOR = 0.6;
constexpr double TREE_SWAY_MAX_HEIGHT_FRACTION = 0.05;
// Tree depth layering (§15.4). Winter pines/birches are split into a foreground
// layer (full size, drawn in front of the snowbank) and a background layer
// (scaled down, hazier, drawn behind the snowbank) so the treeline reads with
// real fore/background depth instead of a single flat row. The depth is chosen
// by one locked PRNG draw per tree at generation time. Render-only scale/opacity.
constexpr double TREE_BACKGROUND_PROBABILITY = 0.45; // share of trees pushed to the back
constexpr double TREE_BG_SCALE = 0.62; // background trees are ~62% size
constexpr float TREE_BG_OPACITY = 0.78f; // atmospheric haze on background trees
// Minimum visible gap between two adjacent props of the same kind/layer
// (pine/birch, cactus, maple, coral). Without this, the per-blade probability
// roll can place two props directly on top of each other in tight clusters.
// Each generator computes an effective collision half-width per prop and
// enforces `nextLeftEdge >= prevRightEdge + PROP_MIN_GAP_DIP`. Background
// vs foreground pines are tracked independently — they render at different
// z-depths and overlap there is intentional parallax, not visual crowding.
constexpr double PROP_MIN_GAP_DIP = 4.0;
// Birch tree variant (§15.1). Second tree style — vertical white trunk
// with dark bark marks and short bare branches. Selected per-slot via
// an additional PRNG draw on tree promotion.
constexpr double BIRCH_VARIANT_PROBABILITY = 0.30;
constexpr double BIRCH_TRUNK_WIDTH_MIN = 4.0; // DIP
constexpr double BIRCH_TRUNK_WIDTH_MAX = 7.0; // DIP
constexpr int BIRCH_BARK_MARK_COUNT = 5; // short centered horizontal dashes
constexpr double BIRCH_BARK_MARK_LENGTH_FRAC = 0.50; // max fraction of trunk width
constexpr int BIRCH_BRANCH_COUNT = 6; // upward-angled branches with snow tips
constexpr double BIRCH_SNOW_CAP_FRACTION = 0.18; // fraction of trunk height
constexpr uint32_t BIRCH_BARK_COLOR = 0xFFEFEFE6u; // off-white trunk
constexpr uint32_t BIRCH_MARK_COLOR = 0xFF2A2A28u; // dark bark stripes
// Maple trees (§16.5). Autumn slot-bound biome anchor, shorter and warmer than pines.
constexpr double MAPLE_PROBABILITY = 0.0070;
constexpr double MAPLE_HEIGHT_MIN = 50.0;
constexpr double MAPLE_HEIGHT_MAX = 85.0;
constexpr double MAPLE_TRUNK_WIDTH_MIN = 6.0;
constexpr double MAPLE_TRUNK_WIDTH_MAX = 10.0;
constexpr double MAPLE_CANOPY_RADIUS_MIN = 14.0;
constexpr double MAPLE_CANOPY_RADIUS_MAX = 24.0;
constexpr uint32_t MAPLE_TRUNK_COLOR = 0xFF4A2C18u;
constexpr uint32_t MAPLE_TRUNK_DARK = 0xFF2F1B0Eu;
constexpr int MAPLE_CANOPY_COLOR_COUNT = 4;
constexpr uint32_t MAPLE_CANOPY_COLOR_0 = 0xFFD96B0Cu;
constexpr uint32_t MAPLE_CANOPY_COLOR_1 = 0xFFE89A3Cu;
constexpr uint32_t MAPLE_CANOPY_COLOR_2 = 0xFFC23E12u;
constexpr uint32_t MAPLE_CANOPY_COLOR_3 = 0xFFE6C849u;
constexpr double MAPLE_BARE_FRACTION = 0.20;
constexpr uint64_t MAPLE_PRNG_SALT = 0xC1AA51EC1AA51Eull;
constexpr uint32_t MAPLE_CANOPY_COLORS[MAPLE_CANOPY_COLOR_COUNT] = {
MAPLE_CANOPY_COLOR_0, MAPLE_CANOPY_COLOR_1,
MAPLE_CANOPY_COLOR_2, MAPLE_CANOPY_COLOR_3,
};
// Leaf puff (§16.6). Hovering the cursor over a leafy maple canopy in Autumn
// shakes a small flurry of leaves loose, like a gust caught the crown. Each
// puff draws from an independent salted PRNG stream so it never perturbs the
// ambient leaf emitter. A per-tree cooldown keeps re-hovers calm. Puff leaves
// reuse the ordinary Leaf entity but carry an outward burst velocity (vx) that
// decays via LEAF_PUFF_DRAG before they settle into the usual flutter-down.
constexpr int LEAF_PUFF_COUNT_MIN = 4;
constexpr int LEAF_PUFF_COUNT_MAX = 7;
constexpr double LEAF_PUFF_BURST_SPEED_MIN = 18.0; // DIP/s outward
constexpr double LEAF_PUFF_BURST_SPEED_MAX = 42.0; // DIP/s outward
constexpr double LEAF_PUFF_DRAG = 2.2; // exp decay rate (1/s) on burst vx
constexpr double LEAF_PUFF_COOLDOWN_SEC = 1.5; // per-tree re-puff gate
constexpr double LEAF_PUFF_HOVER_RADIUS_MUL = 1.15; // × canopy radius
constexpr double LEAF_PUFF_MIN_CUT_HEIGHT = 0.5; // tree must be reasonably leafy
constexpr double LEAF_PUFF_START_OFFSET_FRAC = 0.4; // spawn spread within canopy
constexpr uint64_t LEAF_PUFF_PRNG_SALT = 0x9E3779B97F4A7C15ull;
// Ocean scene — coral (blade variant), bubbles (rising entity), fish
// (horizontal swimmer). Coral probability is intentionally lower than
// pines/maples because each piece is wider (multi-DIP fan/brain).
constexpr double CORAL_PROBABILITY = 0.018;
constexpr double CORAL_HEIGHT_MIN = 22.0;
constexpr double CORAL_HEIGHT_MAX = 48.0;
constexpr double CORAL_WIDTH_MIN = 10.0;
constexpr double CORAL_WIDTH_MAX = 20.0;
constexpr int CORAL_TYPE_COUNT = 3; // 0 = fan, 1 = branching, 2 = brain
constexpr int CORAL_COLOR_COUNT = 5;
constexpr uint32_t CORAL_COLOR_0 = 0xFFFF6FA8u; // pink
constexpr uint32_t CORAL_COLOR_1 = 0xFFFF8A3Du; // orange
constexpr uint32_t CORAL_COLOR_2 = 0xFFB155D9u; // purple
constexpr uint32_t CORAL_COLOR_3 = 0xFFE53935u; // red
constexpr uint32_t CORAL_COLOR_4 = 0xFFFFE6D0u; // bone-white
constexpr uint32_t CORAL_COLORS[CORAL_COLOR_COUNT] = {
CORAL_COLOR_0, CORAL_COLOR_1, CORAL_COLOR_2, CORAL_COLOR_3, CORAL_COLOR_4,
};
constexpr uint64_t CORAL_PRNG_SALT = 0xC04A1C04A1C04A1Cull;
// Bubbles — rise from the seafloor with horizontal wobble, pop at the top
// of the canvas. Emit rate mirrors snowflake but at a calmer cadence.
constexpr double BUBBLE_EMIT_RATE_PER_1920DIP = 1.8;
constexpr double BUBBLE_RISE_SPEED_MIN = 18.0;
constexpr double BUBBLE_RISE_SPEED_MAX = 38.0;
constexpr double BUBBLE_SIZE_MIN = 2.0;
constexpr double BUBBLE_SIZE_MAX = 4.5;
constexpr double BUBBLE_WOBBLE_AMPLITUDE = 6.0;
constexpr double BUBBLE_WOBBLE_FREQUENCY = 0.7;
constexpr double BUBBLE_LIFETIME_PADDING_SEC = 1.5;
constexpr uint32_t BUBBLE_STROKE_COLOR = 0xCCB0E4FFu;
constexpr uint32_t BUBBLE_HIGHLIGHT_COLOR = 0xFFFFFFFFu;
constexpr uint64_t BUBBLE_PRNG_SALT = 0xB0BB1EB0BB1EB0BBull;
// Fish — small swimmers confined to the visible strip so they stay on
// canvas. The overlay is only STRIP_HEIGHT + HEADROOM (≈110 DIP) tall,
// so altitudes are tight: 25..75 DIP above the ground line.
constexpr double FISH_COUNT_PER_1920DIP = 2.5;
constexpr int FISH_COUNT_MIN = 2;
constexpr int FISH_COUNT_MAX = 8;
constexpr double FISH_SPEED_MIN = 18.0;
constexpr double FISH_SPEED_MAX = 38.0;
constexpr double FISH_SIZE_MIN = 5.0; // body half-length DIP
constexpr double FISH_SIZE_MAX = 8.5;
constexpr double FISH_ALTITUDE_MIN = 25.0; // DIP above ground
constexpr double FISH_ALTITUDE_MAX = 75.0;
constexpr double FISH_TAIL_WOBBLE_FREQ = 6.0;
constexpr double FISH_TAIL_WOBBLE_AMP = 0.45; // radians
constexpr int FISH_COLOR_COUNT = 4;
constexpr uint32_t FISH_COLOR_0 = 0xFFFFA844u; // clownfish orange
constexpr uint32_t FISH_COLOR_1 = 0xFFFFD54Fu; // yellow
constexpr uint32_t FISH_COLOR_2 = 0xFF42A5F5u; // bright blue
constexpr uint32_t FISH_COLOR_3 = 0xFFE57373u; // coral pink
constexpr uint32_t FISH_COLORS[FISH_COLOR_COUNT] = {
FISH_COLOR_0, FISH_COLOR_1, FISH_COLOR_2, FISH_COLOR_3,
};
constexpr uint32_t FISH_FIN_COLOR = 0xFF222222u;
constexpr uint64_t FISH_PRNG_SALT = 0xF15F15F15F15F15Full;
inline double ambient_clamp01(double value) noexcept {
if (value <= 0.0) return 0.0;
if (value >= 1.0) return 1.0;
return value;
}
inline double ambient_smoothstep01(double value) noexcept {
const double t = ambient_clamp01(value);
return t * t * (3.0 - 2.0 * t);
}
// §CPU: Winter draws a snow cap on every plain grass blade, which is the scene's
// dominant render cost (~2,500 extra fills/frame). To lighten it, deterministically
// cull a fixed fraction of plain blades (and their caps) in Winter only. The
// decision is a pure hash of the blade's stable array index, so it is identical
// across frames and across the Native/Win2D renderers, and survives cuts (the slot
// model keeps indices stable). (hash & 3)==0 drops ~25% of blades.
constexpr uint32_t WINTER_CULL_MASK = 3u;
inline bool winter_blade_culled(uint32_t bladeIndex) noexcept {
uint32_t h = bladeIndex * 2654435761u;
h ^= h >> 13;
h *= 0x85ebca6bu;
h ^= h >> 16;
return (h & WINTER_CULL_MASK) == 0u;
}
inline double butterfly_wing_scale(double timeSeconds, double phaseY) noexcept {
const double raw = std::cos(timeSeconds * BUTTERFLY_FLUTTER_FREQ + phaseY);
if (raw < BUTTERFLY_FLUTTER_MIN_SCALE) return BUTTERFLY_FLUTTER_MIN_SCALE;
if (raw > 1.0) return 1.0;
return raw;
}
inline double bird_wing_scale(double timeSeconds, double wingPhaseOffset) noexcept {
const double t = 0.5 + 0.5 * std::cos(timeSeconds * BIRD_WING_FLAP_FREQ + wingPhaseOffset);
return BIRD_WING_FOLD_RATIO + (BIRD_WING_OPEN_RATIO - BIRD_WING_FOLD_RATIO) * t;
}
inline double bird_fade_alpha(double x, double vx, double monitorWidth) noexcept {
if (monitorWidth <= 0.0) return 0.0;
const double visibleSpan = monitorWidth;
const double fadeInDist = BIRD_FADE_IN_FRAC * visibleSpan;
const double fadeOutDist = BIRD_FADE_OUT_FRAC * visibleSpan;
double alpha = 1.0;
if (vx >= 0.0) {
if (fadeInDist > 0.0 && x < fadeInDist) alpha = std::min(alpha, ambient_clamp01((x + 50.0) / fadeInDist));
if (fadeOutDist > 0.0 && x > monitorWidth - fadeOutDist) alpha = std::min(alpha, ambient_clamp01((monitorWidth + 50.0 - x) / fadeOutDist));
} else {
if (fadeInDist > 0.0 && x > monitorWidth - fadeInDist) alpha = std::min(alpha, ambient_clamp01((monitorWidth + 50.0 - x) / fadeInDist));
if (fadeOutDist > 0.0 && x < fadeOutDist) alpha = std::min(alpha, ambient_clamp01((x + 50.0) / fadeOutDist));
}
return ambient_clamp01(alpha);
}
inline double firefly_blink_brightness(double timeSeconds, double blinkPeriod, double blinkPhase) noexcept {
if (blinkPeriod <= 0.0) return 0.0;
double cycleT = std::fmod(timeSeconds / blinkPeriod + blinkPhase, 1.0);
if (cycleT < 0.0) cycleT += 1.0;
if (cycleT >= FIREFLY_BLINK_DUTY) return 0.0;
const double fadeFrac = ambient_clamp01(FIREFLY_BLINK_FADE / blinkPeriod);
double brightness = 1.0;
if (fadeFrac > 0.0) {
if (cycleT < fadeFrac) {
brightness = ambient_smoothstep01(cycleT / fadeFrac);
} else if (cycleT > FIREFLY_BLINK_DUTY - fadeFrac) {
brightness = ambient_smoothstep01((FIREFLY_BLINK_DUTY - cycleT) / fadeFrac);
}
}
return ambient_clamp01(brightness);
}
} // namespace desktopgrass

View File

@@ -1,160 +0,0 @@
// GrassWindow.cpp
#include "GrassWindow.h"
#include <shellscalingapi.h>
#pragma comment(lib, "Shcore.lib")
#pragma comment(lib, "User32.lib")
namespace desktopgrass {
namespace {
constexpr UINT_PTR kProp = 0; // placeholder; we use SetWindowLongPtr(GWLP_USERDATA)
} // anonymous
bool GrassWindow::RegisterWindowClass(HINSTANCE hInst) {
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = GrassWindow::WndProc;
wc.hInstance = hInst;
wc.lpszClassName = kWindowClassName;
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = nullptr; // we paint everything; never let GDI clear
ATOM atom = RegisterClassExW(&wc);
if (atom == 0) {
DWORD err = GetLastError();
if (err != ERROR_CLASS_ALREADY_EXISTS) {
return false;
}
}
return true;
}
GrassWindow::~GrassWindow() {
Destroy();
}
bool GrassWindow::Create(HINSTANCE hInst,
const RECT& monitorBounds, UINT dpi,
uint64_t seed, double density,
double swaySpeed, double swayAmplitude)
{
dpi_ = dpi == 0 ? 96 : dpi;
seed_ = seed;
density_ = density;
monitorBounds_ = monitorBounds;
// Compute window dims in pixels: full monitor width × (STRIP_HEIGHT +
// HEADROOM) DIP. Bottom-aligned to the monitor.
const int monitorW = monitorBounds.right - monitorBounds.left;
const int heightPx = static_cast<int>(
((STRIP_HEIGHT + HEADROOM) * dpi_ / 96.0) + 0.5);
screenBounds_.left = monitorBounds.left;
screenBounds_.right = monitorBounds.left + monitorW;
screenBounds_.bottom = monitorBounds.bottom;
screenBounds_.top = monitorBounds.bottom - heightPx;
const DWORD exStyle =
WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST |
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
const DWORD style = WS_POPUP;
hwnd_ = CreateWindowExW(
exStyle, kWindowClassName, L"Desktop Grass",
style,
screenBounds_.left, screenBounds_.top,
monitorW, heightPx,
nullptr, nullptr, hInst, this);
if (!hwnd_) {
return false;
}
if (!renderer_.Initialize(hwnd_, monitorW, heightPx, dpi_, seed, density,
swaySpeed, swayAmplitude)) {
DestroyWindow(hwnd_);
hwnd_ = nullptr;
return false;
}
renderer_.SetWindowOriginScreen(screenBounds_.left, screenBounds_.top);
return true;
}
void GrassWindow::Show() {
if (hwnd_) {
ShowWindow(hwnd_, SW_SHOWNOACTIVATE);
}
}
void GrassWindow::Destroy() {
if (hwnd_) {
DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
}
void GrassWindow::RenderFrame(double dt,
const InputEvent* events, std::size_t numEvents)
{
renderer_.RenderFrame(dt, events, numEvents);
}
LRESULT CALLBACK GrassWindow::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
GrassWindow* self = nullptr;
if (msg == WM_NCCREATE) {
auto* cs = reinterpret_cast<CREATESTRUCTW*>(lp);
self = reinterpret_cast<GrassWindow*>(cs->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
if (self) self->hwnd_ = hwnd;
} else {
self = reinterpret_cast<GrassWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
if (self) return self->HandleMessage(msg, wp, lp);
return DefWindowProcW(hwnd, msg, wp, lp);
}
LRESULT GrassWindow::HandleMessage(UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_CLOSE:
// The smoke harness sends WM_CLOSE. Forward to the main thread as
// a request to terminate the message loop.
PostQuitMessage(0);
return 0;
case WM_DPICHANGED: {
const UINT newDpi = HIWORD(wp);
auto* rect = reinterpret_cast<const RECT*>(lp);
if (rect) {
SetWindowPos(hwnd_, nullptr,
rect->left, rect->top,
rect->right - rect->left,
rect->bottom - rect->top,
SWP_NOZORDER | SWP_NOACTIVATE);
renderer_.Resize(rect->right - rect->left,
rect->bottom - rect->top, newDpi);
dpi_ = newDpi;
screenBounds_ = *rect;
renderer_.SetWindowOriginScreen(rect->left, rect->top);
// Mirror the Win2D rebuild: regenerate the blade layout for the
// new DIP width using the same per-monitor seed so the result is
// identical to a fresh launch at this DPI. Reuses the stored
// seed_/density_; sway scales and scene/critter/cut state are
// preserved inside RegenerateForDpi.
renderer_.RegenerateForDpi(seed_, density_);
}
return 0;
}
case WM_DESTROY:
return 0;
default:
return DefWindowProcW(hwnd_, msg, wp, lp);
}
}
} // namespace desktopgrass

View File

@@ -1,61 +0,0 @@
// GrassWindow.h
//
// One HWND + one Renderer per monitor. Layered, click-through, topmost,
// no-activate, tool-window — see WS_EX flags listed in the plan and asserted
// by tests/smoke.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <memory>
#include "Renderer.h"
#include "MouseHook.h"
namespace desktopgrass {
class GrassWindow {
public:
static constexpr const wchar_t* kWindowClassName = L"DesktopGrass.Native.Window";
static constexpr UINT kWmAppQuit = WM_APP + 1;
static bool RegisterWindowClass(HINSTANCE hInst);
GrassWindow() = default;
~GrassWindow();
GrassWindow(const GrassWindow&) = delete;
GrassWindow& operator=(const GrassWindow&) = delete;
// Creates the HWND, attaches a Renderer, generates blades using `seed`.
bool Create(HINSTANCE hInst,
const RECT& monitorBounds, UINT dpi,
uint64_t seed, double density,
double swaySpeed = 1.0, double swayAmplitude = 1.0);
void Show();
void Destroy();
void RenderFrame(double dt,
const InputEvent* events, std::size_t numEvents);
HWND GetHwnd() const { return hwnd_; }
Renderer& GetRenderer() { return renderer_; }
const RECT& GetScreenBounds() const { return screenBounds_; }
const RECT& GetMonitorBounds() const { return monitorBounds_; }
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
LRESULT HandleMessage(UINT msg, WPARAM wp, LPARAM lp);
HWND hwnd_ = nullptr;
Renderer renderer_;
RECT screenBounds_{}; // window screen-rect (left, top, right, bottom)
RECT monitorBounds_{}; // monitor work-area rect used for persistence keys
UINT dpi_ = 96;
uint64_t seed_ = 0;
double density_ = 1.0;
};
} // namespace desktopgrass

View File

@@ -1,355 +0,0 @@
// Json.h
//
// Minimal, dependency-free JSON reader shared by the persistence and config
// loaders. Tolerates JSONC niceties — // line comments, /* block */ comments,
// and trailing commas — so the human-editable config.json can be annotated.
// Header-only: every free helper is marked inline to stay ODR-safe across the
// translation units that include it.
#pragma once
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace desktopgrass::json {
// ASCII-only lowercase fold (a-z), locale-independent, so object member keys
// can be matched case-insensitively to mirror the Win2D loader's
// PropertyNameCaseInsensitive=true / OrdinalIgnoreCase behavior. Non-ASCII and
// non-letter bytes pass through unchanged.
inline std::string AsciiLower(std::string_view text) {
std::string out(text);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') {
c = static_cast<char>(c - 'A' + 'a');
}
}
return out;
}
struct Value {
enum class Type { Null, Bool, Number, String, Array, Object };
Type type = Type::Null;
bool boolValue = false;
double numberValue = 0.0;
std::string stringValue;
std::vector<Value> arrayValue;
std::map<std::string, Value> objectValue;
};
class Parser {
public:
explicit Parser(std::string_view text) : text_(text) {}
bool Parse(Value& out) {
SkipWhitespace();
if (!ParseValue(out)) {
return false;
}
SkipWhitespace();
return pos_ == text_.size();
}
private:
void SkipWhitespace() noexcept {
while (pos_ < text_.size()) {
const unsigned char c = static_cast<unsigned char>(text_[pos_]);
if (std::isspace(c)) {
++pos_;
continue;
}
// JSONC: skip // line and /* block */ comments.
if (c == '/' && pos_ + 1 < text_.size()) {
if (text_[pos_ + 1] == '/') {
pos_ += 2;
while (pos_ < text_.size() && text_[pos_] != '\n') ++pos_;
continue;
}
if (text_[pos_ + 1] == '*') {
pos_ += 2;
while (pos_ + 1 < text_.size() &&
!(text_[pos_] == '*' && text_[pos_ + 1] == '/')) {
++pos_;
}
pos_ = (pos_ + 1 < text_.size()) ? pos_ + 2 : text_.size();
continue;
}
}
break;
}
}
bool Match(std::string_view literal) noexcept {
if (text_.substr(pos_, literal.size()) != literal) {
return false;
}
pos_ += literal.size();
return true;
}
bool ParseValue(Value& out) {
SkipWhitespace();
if (pos_ >= text_.size()) return false;
const char c = text_[pos_];
if (c == '{') return ParseObject(out);
if (c == '[') return ParseArray(out);
if (c == '"') {
out.type = Value::Type::String;
return ParseString(out.stringValue);
}
if (c == 't') {
if (!Match("true")) return false;
out.type = Value::Type::Bool;
out.boolValue = true;
return true;
}
if (c == 'f') {
if (!Match("false")) return false;
out.type = Value::Type::Bool;
out.boolValue = false;
return true;
}
if (c == 'n') {
if (!Match("null")) return false;
out.type = Value::Type::Null;
return true;
}
if (c == '-' || (c >= '0' && c <= '9')) {
return ParseNumber(out);
}
return false;
}
bool ParseObject(Value& out) {
if (text_[pos_] != '{') return false;
++pos_;
out.type = Value::Type::Object;
out.objectValue.clear();
SkipWhitespace();
if (pos_ < text_.size() && text_[pos_] == '}') {
++pos_;
return true;
}
while (pos_ < text_.size()) {
SkipWhitespace();
std::string key;
if (!ParseString(key)) return false;
SkipWhitespace();
if (pos_ >= text_.size() || text_[pos_] != ':') return false;
++pos_;
Value value;
if (!ParseValue(value)) return false;
// Normalize keys to ASCII-lowercase so config/state lookups are
// case-insensitive (matching the Win2D loader). Monitor keys like
// "1920x1080@0,0" contain no uppercase letters, so this is a no-op
// for them.
out.objectValue.emplace(AsciiLower(key), std::move(value));
SkipWhitespace();
if (pos_ >= text_.size()) return false;
if (text_[pos_] == '}') {
++pos_;
return true;
}
if (text_[pos_] != ',') return false;
++pos_;
// JSONC: allow a trailing comma before the closing brace.
SkipWhitespace();
if (pos_ < text_.size() && text_[pos_] == '}') {
++pos_;
return true;
}
}
return false;
}
bool ParseArray(Value& out) {
if (text_[pos_] != '[') return false;
++pos_;
out.type = Value::Type::Array;
out.arrayValue.clear();
SkipWhitespace();
if (pos_ < text_.size() && text_[pos_] == ']') {
++pos_;
return true;
}
while (pos_ < text_.size()) {
Value value;
if (!ParseValue(value)) return false;
out.arrayValue.push_back(std::move(value));
SkipWhitespace();
if (pos_ >= text_.size()) return false;
if (text_[pos_] == ']') {
++pos_;
return true;
}
if (text_[pos_] != ',') return false;
++pos_;
// JSONC: allow a trailing comma before the closing bracket.
SkipWhitespace();
if (pos_ < text_.size() && text_[pos_] == ']') {
++pos_;
return true;
}
}
return false;
}
bool ParseString(std::string& out) {
if (pos_ >= text_.size() || text_[pos_] != '"') return false;
++pos_;
out.clear();
while (pos_ < text_.size()) {
const char c = text_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (pos_ >= text_.size()) return false;
const char esc = text_[pos_++];
switch (esc) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u':
if (pos_ + 4 > text_.size()) return false;
out.push_back('?');
pos_ += 4;
break;
default:
return false;
}
} else {
out.push_back(c);
}
}
return false;
}
bool ParseNumber(Value& out) {
const std::size_t start = pos_;
if (text_[pos_] == '-') ++pos_;
if (pos_ >= text_.size()) return false;
if (text_[pos_] == '0') {
++pos_;
} else if (text_[pos_] >= '1' && text_[pos_] <= '9') {
while (pos_ < text_.size() && text_[pos_] >= '0' && text_[pos_] <= '9') {
++pos_;
}
} else {
return false;
}
if (pos_ < text_.size() && text_[pos_] == '.') {
++pos_;
if (pos_ >= text_.size() || text_[pos_] < '0' || text_[pos_] > '9') return false;
while (pos_ < text_.size() && text_[pos_] >= '0' && text_[pos_] <= '9') {
++pos_;
}
}
if (pos_ < text_.size() && (text_[pos_] == 'e' || text_[pos_] == 'E')) {
++pos_;
if (pos_ < text_.size() && (text_[pos_] == '+' || text_[pos_] == '-')) ++pos_;
if (pos_ >= text_.size() || text_[pos_] < '0' || text_[pos_] > '9') return false;
while (pos_ < text_.size() && text_[pos_] >= '0' && text_[pos_] <= '9') {
++pos_;
}
}
const std::string token(text_.substr(start, pos_ - start));
char* endPtr = nullptr;
const double value = std::strtod(token.c_str(), &endPtr);
if (endPtr == token.c_str() || *endPtr != '\0') return false;
out.type = Value::Type::Number;
out.numberValue = value;
return true;
}
std::string_view text_;
std::size_t pos_ = 0;
};
inline bool Parse(std::string_view text, Value& out) {
Parser parser(text);
return parser.Parse(out);
}
inline const Value* FindMember(const Value& object, const std::string& name) {
if (object.type != Value::Type::Object) return nullptr;
// Keys are stored ASCII-lowercased at parse time, so fold the lookup name
// the same way for case-insensitive matching.
const auto it = object.objectValue.find(AsciiLower(name));
return it == object.objectValue.end() ? nullptr : &it->second;
}
inline std::optional<int> ReadInt(const Value& object, const std::string& name) {
const Value* value = FindMember(object, name);
if (!value || value->type != Value::Type::Number) return std::nullopt;
return static_cast<int>(value->numberValue);
}
inline std::optional<double> ReadDouble(const Value& object, const std::string& name) {
const Value* value = FindMember(object, name);
if (!value || value->type != Value::Type::Number) return std::nullopt;
return value->numberValue;
}
inline std::optional<bool> ReadBool(const Value& object, const std::string& name) {
const Value* value = FindMember(object, name);
if (!value || value->type != Value::Type::Bool) return std::nullopt;
return value->boolValue;
}
inline std::optional<std::string> ReadString(const Value& object, const std::string& name) {
const Value* value = FindMember(object, name);
if (!value || value->type != Value::Type::String) return std::nullopt;
return value->stringValue;
}
inline std::string Escape(std::string_view text) {
std::string out;
for (char c : text) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buffer[7]{};
std::snprintf(buffer, sizeof(buffer), "\\u%04x", static_cast<unsigned char>(c));
out += buffer;
} else {
out.push_back(c);
}
break;
}
}
return out;
}
} // namespace desktopgrass::json

View File

@@ -1,72 +0,0 @@
// MouseHook.cpp
#include "MouseHook.h"
#include <atomic>
#include <chrono>
namespace desktopgrass {
namespace {
std::atomic<MouseEventQueue*> g_queue{nullptr};
HHOOK g_hook = nullptr;
LARGE_INTEGER g_qpcFreq{};
LARGE_INTEGER g_qpcStart{};
double now_seconds() noexcept {
LARGE_INTEGER c;
QueryPerformanceCounter(&c);
return static_cast<double>(c.QuadPart - g_qpcStart.QuadPart) /
static_cast<double>(g_qpcFreq.QuadPart);
}
LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
// Per spec: always pass the event through. Never consume.
if (nCode == HC_ACTION) {
MouseEventQueue* q = g_queue.load(std::memory_order_acquire);
if (q) {
const MSLLHOOKSTRUCT* m = reinterpret_cast<const MSLLHOOKSTRUCT*>(lParam);
RawMouseEvent ev{};
ev.timeSeconds = now_seconds();
ev.screenX = m->pt.x;
ev.screenY = m->pt.y;
switch (wParam) {
case WM_MOUSEMOVE:
ev.type = EventType::Move;
q->push(ev);
break;
case WM_LBUTTONDOWN:
ev.type = EventType::Click;
q->push(ev);
break;
default:
break;
}
}
}
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
} // anonymous
bool install_mouse_hook(MouseEventQueue* queue) noexcept {
if (g_hook) return false;
QueryPerformanceFrequency(&g_qpcFreq);
QueryPerformanceCounter(&g_qpcStart);
g_queue.store(queue, std::memory_order_release);
g_hook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc,
GetModuleHandleW(nullptr), 0);
return g_hook != nullptr;
}
void uninstall_mouse_hook() noexcept {
if (g_hook) {
UnhookWindowsHookEx(g_hook);
g_hook = nullptr;
}
g_queue.store(nullptr, std::memory_order_release);
}
} // namespace desktopgrass

View File

@@ -1,71 +0,0 @@
// MouseHook.h
//
// WH_MOUSE_LL global low-level mouse hook. The callback runs on Windows'
// dedicated hook thread and must return very quickly (≤ ~200 µs is the kind of
// budget where Windows un-installs you if you exceed it). It pushes a fixed-size
// snapshot of the event into a lock-free single-producer / single-consumer ring
// buffer. The render loop drains the queue once per frame.
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "Sim.h"
namespace desktopgrass {
struct RawMouseEvent {
EventType type;
double timeSeconds;
int32_t screenX; // virtual screen coords, raw from the hook
int32_t screenY;
};
class MouseEventQueue {
public:
static constexpr std::size_t CAPACITY = 1024; // power of two
MouseEventQueue() : head_(0), tail_(0) {}
// Producer side (low-level hook thread). Returns false if full (we drop the
// event rather than block — UI freezes are worse than a missed gust).
bool push(const RawMouseEvent& e) noexcept {
const std::size_t head = head_.load(std::memory_order_relaxed);
const std::size_t next = (head + 1) & (CAPACITY - 1);
if (next == tail_.load(std::memory_order_acquire)) {
return false; // full
}
buffer_[head] = e;
head_.store(next, std::memory_order_release);
return true;
}
// Consumer side (render thread). Returns the number of events read.
std::size_t drain(RawMouseEvent* dst, std::size_t maxCount) noexcept {
std::size_t n = 0;
std::size_t tail = tail_.load(std::memory_order_relaxed);
const std::size_t head = head_.load(std::memory_order_acquire);
while (tail != head && n < maxCount) {
dst[n++] = buffer_[tail];
tail = (tail + 1) & (CAPACITY - 1);
}
tail_.store(tail, std::memory_order_release);
return n;
}
private:
RawMouseEvent buffer_[CAPACITY];
std::atomic<std::size_t> head_; // producer
std::atomic<std::size_t> tail_; // consumer
};
// Singleton-style install / uninstall. Only one hook per process.
bool install_mouse_hook(MouseEventQueue* queue) noexcept;
void uninstall_mouse_hook() noexcept;
} // namespace desktopgrass

View File

@@ -1,60 +0,0 @@
// Pacing.cpp
#include "Pacing.h"
namespace desktopgrass {
namespace {
// SetWaitableTimer's lpDueTime takes 100-ns intervals. Negative values mean
// "relative to now". One second == 10,000,000 hundred-ns units.
constexpr double kHundredNsPerSec = 10'000'000.0;
} // anonymous
FramePacer::FramePacer() {
// CREATE_WAITABLE_TIMER_HIGH_RESOLUTION (0x00000002) requires Windows 10
// 1803+. DesktopGrass already requires Windows 10 1809+ (see README), so
// creation should succeed in supported environments. The nullptr returned
// on any older system is fine: WaitUntilNextFrame falls back to the
// legacy MWFMOe(NULL, waitMs, ...) path so behaviour degrades gracefully.
timer_ = CreateWaitableTimerExW(
nullptr, nullptr,
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
TIMER_ALL_ACCESS);
}
FramePacer::~FramePacer() {
if (timer_) {
CloseHandle(timer_);
timer_ = nullptr;
}
}
void FramePacer::WaitUntilNextFrame(double waitSec) {
if (waitSec <= 0.0) return;
if (timer_) {
// Relative due time in 100-ns units; round down so we never sleep
// longer than asked. SetWaitableTimer will fire immediately if the
// computed magnitude is zero.
LARGE_INTEGER due{};
const double hundredNs = waitSec * kHundredNsPerSec;
due.QuadPart = -static_cast<LONGLONG>(hundredNs);
if (SetWaitableTimer(timer_, &due, 0, nullptr, nullptr, FALSE)) {
MsgWaitForMultipleObjectsEx(
1, &timer_, INFINITE, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
return;
}
// SetWaitableTimer can theoretically fail (e.g. handle revoked);
// fall through to the legacy wait so the loop still makes progress.
}
// Legacy ms-resolution wait. Round to nearest millisecond.
const DWORD waitMs = static_cast<DWORD>(waitSec * 1000.0 + 0.5);
MsgWaitForMultipleObjectsEx(
0, nullptr, waitMs, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
}
} // namespace desktopgrass

View File

@@ -1,48 +0,0 @@
// Pacing.h
//
// Frame pacing helper.
//
// The default Windows system timer resolution is ~15.6 ms, which clamps any
// `MsgWaitForMultipleObjectsEx(NULL, waitMs, ...)`-based loop to roughly
// 64 fps even when the caller asks for a shorter wait. At our 30 fps target
// that's actually below the requested cadence: a frame that asks for ~30 ms
// of wait ends up paying for two ~15.6 ms ticks (~31 ms) on the lucky path
// and three ticks (~46 ms) on the unlucky one, producing visibly uneven
// motion and dt_p95 around 48 ms.
//
// `FramePacer` uses a per-process high-resolution waitable timer
// (CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, Windows 10 1803+) so the wait
// honours its argument to roughly sub-ms granularity without changing the
// system-wide timer resolution. If the high-res timer cannot be created the
// pacer transparently falls back to the legacy ms-resolution wait, matching
// the pre-fix behaviour.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
namespace desktopgrass {
class FramePacer {
public:
FramePacer();
~FramePacer();
FramePacer(const FramePacer&) = delete;
FramePacer& operator=(const FramePacer&) = delete;
// True iff the high-resolution waitable timer was created. False means the
// pacer is operating in legacy MWFMOe(NULL, waitMs, ...) mode.
bool IsHighResolution() const { return timer_ != nullptr; }
// Block until `waitSec` elapses or input arrives in the calling thread's
// message queue (QS_ALLINPUT, MWMO_INPUTAVAILABLE). Returns immediately
// when `waitSec <= 0`.
void WaitUntilNextFrame(double waitSec);
private:
HANDLE timer_ = nullptr;
};
} // namespace desktopgrass

View File

@@ -1,283 +0,0 @@
#include "Persistence.h"
#include "Json.h"
#include <Windows.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <optional>
#include <sstream>
#include <string_view>
#include <utility>
namespace desktopgrass::persistence {
namespace {
using desktopgrass::json::FindMember;
using desktopgrass::json::ReadBool;
using desktopgrass::json::ReadDouble;
using desktopgrass::json::ReadInt;
using desktopgrass::json::ReadString;
using JsonValue = desktopgrass::json::Value;
using JsonParser = desktopgrass::json::Parser;
std::optional<std::wstring> g_stateFilePathForTest;
constexpr int kCurrentVersion = 2;
std::string JsonEscape(std::string_view text) {
return desktopgrass::json::Escape(text);
}
std::string SceneToString(Scene scene) noexcept {
switch (scene) {
case Scene::Grass: return "Grass";
case Scene::Desert: return "Desert";
case Scene::Winter: return "Winter";
case Scene::Autumn: return "Autumn";
case Scene::Ocean: return "Ocean";
}
return "Grass";
}
Scene SceneFromString(const std::string& scene) noexcept {
if (scene == "Desert") return Scene::Desert;
if (scene == "Winter") return Scene::Winter;
if (scene == "Autumn") return Scene::Autumn;
if (scene == "Ocean") return Scene::Ocean;
return Scene::Grass;
}
std::string CritterToString(CritterKind critter) noexcept {
switch (critter) {
case CritterKind::None: return "None";
case CritterKind::Sheep: return "Sheep";
case CritterKind::Cat: return "Cat";
case CritterKind::Bunny: return "Bunny";
}
return "None";
}
CritterKind CritterFromString(const std::string& critter) noexcept {
if (critter == "Sheep") return CritterKind::Sheep;
if (critter == "Cat") return CritterKind::Cat;
if (critter == "Bunny") return CritterKind::Bunny;
return CritterKind::None;
}
std::string CurrentUtcTimestamp() {
const auto now = std::chrono::system_clock::now();
const std::time_t time = std::chrono::system_clock::to_time_t(now);
std::tm utc{};
gmtime_s(&utc, &time);
std::ostringstream out;
out << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ");
return out.str();
}
bool TryParseMonitorKey(const std::string& key, MonitorState& monitor) {
int consumed = 0;
const int matched = sscanf_s(key.c_str(), "%dx%d@%d,%d%n",
&monitor.width,
&monitor.height,
&monitor.left,
&monitor.top,
&consumed);
return matched == 4 && consumed == static_cast<int>(key.size());
}
std::string Serialize(const AppState& state) {
std::ostringstream out;
out << std::setprecision(17);
out << "{\n";
out << " \"version\": " << kCurrentVersion << ",\n";
out << " \"savedAt\": \"" << CurrentUtcTimestamp() << "\",\n";
out << " \"scene\": \"" << SceneToString(state.scene) << "\",\n";
out << " \"critter\": \"" << CritterToString(state.critter) << "\",\n";
out << " \"critterCount\": " << state.critterCountOverride << ",\n";
out << " \"autoStart\": " << (state.autoStart ? "true" : "false") << ",\n";
out << " \"monitors\": {\n";
for (std::size_t i = 0; i < state.monitors.size(); ++i) {
const MonitorState& monitor = state.monitors[i];
out << " \"" << JsonEscape(MonitorKey(monitor)) << "\": {\n";
out << " \"cuts\": [";
if (!monitor.cuts.empty()) {
out << "\n";
for (std::size_t j = 0; j < monitor.cuts.size(); ++j) {
const CutRecord& cut = monitor.cuts[j];
out << " { \"bladeIndex\": " << cut.bladeIndex
<< ", \"cutTime\": " << cut.cutTime << " }";
if (j + 1 < monitor.cuts.size()) out << ",";
out << "\n";
}
out << " ";
}
out << "]\n";
out << " }";
if (i + 1 < state.monitors.size()) out << ",";
out << "\n";
}
out << " }\n";
out << "}\n";
return out.str();
}
bool ParseAppState(const JsonValue& root, AppState& out) {
if (root.type != JsonValue::Type::Object) return false;
const int version = ReadInt(root, "version").value_or(0);
if (version != 1 && version != kCurrentVersion) {
OutputDebugStringA("DesktopGrass persistence: unsupported state.json version; starting fresh.\n");
return false;
}
AppState parsed;
parsed.version = kCurrentVersion;
auto sceneName = ReadString(root, "scene");
if (!sceneName) sceneName = ReadString(root, "currentScene");
parsed.scene = SceneFromString(sceneName.value_or("Grass"));
auto critterName = ReadString(root, "critter");
if (!critterName) critterName = ReadString(root, "currentCritter");
parsed.critter = CritterFromString(critterName.value_or("None"));
auto critterCount = ReadInt(root, "critterCount");
if (!critterCount) critterCount = ReadInt(root, "critterCountOverride");
parsed.critterCountOverride = critterCount.value_or(0);
if (parsed.critterCountOverride < 0 || parsed.critterCountOverride > PET_COUNT_MAX_PER_MONITOR) {
parsed.critterCountOverride = 0;
}
parsed.autoStart = ReadBool(root, "autoStart").value_or(false);
const JsonValue* monitors = FindMember(root, "monitors");
if (monitors && monitors->type == JsonValue::Type::Object) {
for (const auto& [key, value] : monitors->objectValue) {
MonitorState monitor;
if (!TryParseMonitorKey(key, monitor)) {
continue;
}
const JsonValue* cuts = FindMember(value, "cuts");
if (cuts && cuts->type == JsonValue::Type::Array) {
for (const JsonValue& cutValue : cuts->arrayValue) {
if (cutValue.type != JsonValue::Type::Object) continue;
const auto bladeIndex = ReadInt(cutValue, "bladeIndex");
const auto cutTime = ReadDouble(cutValue, "cutTime");
if (!bladeIndex || !cutTime) continue;
monitor.cuts.push_back(CutRecord{ *bladeIndex, *cutTime });
}
}
parsed.monitors.push_back(std::move(monitor));
}
}
out = std::move(parsed);
return true;
}
std::wstring DefaultStateFilePath() {
wchar_t* localAppData = nullptr;
std::size_t length = 0;
_wdupenv_s(&localAppData, &length, L"LOCALAPPDATA");
std::filesystem::path path = localAppData && length > 0
? std::filesystem::path(localAppData)
: std::filesystem::current_path();
if (localAppData) {
std::free(localAppData);
}
path /= L"DesktopGrass";
path /= L"state.json";
return path.wstring();
}
} // namespace
std::string MonitorKey(int width, int height, int left, int top) {
return std::to_string(width) + "x" + std::to_string(height) + "@"
+ std::to_string(left) + "," + std::to_string(top);
}
std::string MonitorKey(const MonitorState& monitor) {
return MonitorKey(monitor.width, monitor.height, monitor.left, monitor.top);
}
std::wstring GetStateFilePath() {
if (g_stateFilePathForTest) {
return *g_stateFilePathForTest;
}
return DefaultStateFilePath();
}
void SetStateFilePathForTest(const std::wstring& path) {
if (path.empty()) {
g_stateFilePathForTest.reset();
} else {
g_stateFilePathForTest = path;
}
}
bool LoadAppState(AppState& out) {
const std::filesystem::path path(GetStateFilePath());
std::ifstream file(path, std::ios::binary);
if (!file) {
return false;
}
std::ostringstream buffer;
buffer << file.rdbuf();
const std::string json = buffer.str();
JsonValue root;
JsonParser parser(json);
if (!parser.Parse(root)) {
OutputDebugStringA("DesktopGrass persistence: malformed state.json; starting fresh.\n");
return false;
}
return ParseAppState(root, out);
}
bool SaveAppState(const AppState& state) {
const std::filesystem::path path(GetStateFilePath());
const std::filesystem::path directory = path.parent_path();
if (!directory.empty()) {
std::error_code ec;
std::filesystem::create_directories(directory, ec);
if (ec) return false;
}
const std::filesystem::path tempPath(path.wstring() + L".tmp");
{
std::ofstream file(tempPath, std::ios::binary | std::ios::trunc);
if (!file) return false;
const std::string json = Serialize(state);
file.write(json.data(), static_cast<std::streamsize>(json.size()));
if (!file) return false;
}
if (!MoveFileExW(tempPath.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
std::error_code ec;
std::filesystem::remove(tempPath, ec);
return false;
}
return true;
}
} // namespace desktopgrass::persistence

View File

@@ -1,39 +0,0 @@
#pragma once
#include "Constants.h"
#include <string>
#include <vector>
namespace desktopgrass::persistence {
struct CutRecord {
int bladeIndex = 0;
double cutTime = 0.0;
};
struct MonitorState {
int width = 0;
int height = 0;
int left = 0;
int top = 0;
std::vector<CutRecord> cuts;
};
struct AppState {
int version = 2;
Scene scene = Scene::Grass;
CritterKind critter = CritterKind::None;
int critterCountOverride = 0;
bool autoStart = false;
std::vector<MonitorState> monitors;
};
bool LoadAppState(AppState& out);
bool SaveAppState(const AppState& state);
std::wstring GetStateFilePath();
void SetStateFilePathForTest(const std::wstring& path);
std::string MonitorKey(int width, int height, int left, int top);
std::string MonitorKey(const MonitorState& monitor);
} // namespace desktopgrass::persistence

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +0,0 @@
// Renderer.h
//
// Per-window Direct2D + DXGI renderer attached to a DirectComposition target.
// Owns the swap chain, the D2D device context bound to it, and the per-window
// Sim. Renders the procedural grass once per frame.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <wrl/client.h>
#include <d3d11.h>
#include <dxgi1_3.h>
#include <d2d1_3.h>
#include <dcomp.h>
#include <dwrite.h>
#include <unordered_map>
#include "Sim.h"
namespace desktopgrass {
class Renderer {
public:
Renderer() = default;
~Renderer();
// Sets up D3D / D2D / DComp on `hwnd` of the given width × height in DIPs,
// and generates the initial blade list with `seed`. Returns false on
// failure (logged via OutputDebugString).
bool Initialize(HWND hwnd, int widthPx, int heightPx,
UINT dpi, uint64_t seed, double density,
double swaySpeed = 1.0, double swayAmplitude = 1.0);
// Resize the swap chain & D2D target. Call when the monitor changes size
// (DPI change, mode change). Leaves Sim intact; caller may regenerate it.
bool Resize(int widthPx, int heightPx, UINT dpi);
// Regenerate the blade layout for the current (post-Resize) DIP width after
// a DPI change, reseeding with the same deterministic per-monitor seed and
// preserving scene/critter/cut state. Mirrors the Win2D rebuild path. Must
// NOT be called on device-loss recovery (which leaves the Sim untouched).
void RegenerateForDpi(uint64_t seed, double density);
// Advance the simulation by `dt` seconds, then draw a frame.
void RenderFrame(double dt,
const InputEvent* events,
std::size_t numEvents);
// For windows that have been minimized / occluded: skip rendering but keep
// the simulation alive.
void Tick(double dt,
const InputEvent* events,
std::size_t numEvents);
Sim& GetSim() { return sim_; }
const Sim& GetSim() const { return sim_; }
HWND GetHwnd() const { return hwnd_; }
void SetWindowOriginScreen(int x, int y) { windowOriginScreenX_ = x; windowOriginScreenY_ = y; }
int GetWindowOriginScreenX() const { return windowOriginScreenX_; }
int GetWindowOriginScreenY() const { return windowOriginScreenY_; }
int GetWidthPx() const { return widthPx_; }
int GetHeightPx() const { return heightPx_; }
UINT GetDpi() const { return dpi_; }
private:
template<class T> using ComPtr = Microsoft::WRL::ComPtr<T>;
void Cleanup();
bool CreateDeviceResources();
bool CreateSwapChainResources(int widthPx, int heightPx);
void DiscardDeviceResources();
void DrawGrass(bool treesOnly, bool backgroundTrees);
void DrawEntities(const D2D1_POINT_2F* cursorPosition);
void DrawButterfly(const Entity& e);
void DrawFirefly(const Entity& e);
void DrawBird(const Entity& e);
void DrawCoral(const Blade& b, float groundY);
void DrawFish(const Entity& e);
void DrawCat(const Entity& e, const D2D1_POINT_2F* cursorPosition);
void DrawBunny(const Entity& e);
void DrawHedgehog(const Entity& e);
void DrawPetName(const Entity& e, const D2D1_POINT_2F* cursorPosition);
bool TryGetCursorPositionDip(D2D1_POINT_2F& cursorPosition) const;
HWND hwnd_ = nullptr;
int widthPx_ = 0;
int heightPx_ = 0;
UINT dpi_ = 96;
int windowOriginScreenX_ = 0;
int windowOriginScreenY_ = 0;
ComPtr<ID3D11Device> d3dDevice_;
ComPtr<ID3D11DeviceContext> d3dContext_;
ComPtr<IDXGIDevice1> dxgiDevice_;
ComPtr<IDXGIFactory2> dxgiFactory_;
ComPtr<IDXGISwapChain1> swapChain_;
ComPtr<ID2D1Factory1> d2dFactory_;
ComPtr<ID2D1Device> d2dDevice_;
ComPtr<ID2D1DeviceContext> d2dContext_;
ComPtr<ID2D1Bitmap1> d2dTarget_;
ComPtr<ID2D1SolidColorBrush> brushes_[SCENE_COUNT][PALETTE_SIZE];
ComPtr<ID2D1SolidColorBrush> flowerHeadBrushes_[FLOWER_PALETTE_SIZE];
ComPtr<ID2D1SolidColorBrush> mushroomCapBrushes_[MUSHROOM_PALETTE_SIZE];
ComPtr<ID2D1SolidColorBrush> mushroomStemBrush_;
ComPtr<ID2D1SolidColorBrush> cactusBrush_;
ComPtr<ID2D1StrokeStyle> roundStrokeStyle_;
ComPtr<ID2D1SolidColorBrush> tumbleweedBrush_;
ComPtr<ID2D1SolidColorBrush> snowflakeBrush_;
ComPtr<ID2D1SolidColorBrush> leafBrushes_[LEAF_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> snowTipBrush_;
ComPtr<ID2D1SolidColorBrush> snowBankShadowBrush_;
ComPtr<ID2D1SolidColorBrush> pineBrush_;
ComPtr<ID2D1SolidColorBrush> pineShadowBrush_;
ComPtr<ID2D1SolidColorBrush> pineHighlightBrush_;
ComPtr<ID2D1SolidColorBrush> birchBarkBrush_;
ComPtr<ID2D1SolidColorBrush> birchMarkBrush_;
ComPtr<ID2D1SolidColorBrush> mapleTrunkBrush_;
ComPtr<ID2D1SolidColorBrush> mapleTrunkDarkBrush_;
ComPtr<ID2D1SolidColorBrush> mapleCanopyBrushes_[MAPLE_CANOPY_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> coralBrushes_[CORAL_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> bubbleStrokeBrush_;
ComPtr<ID2D1SolidColorBrush> bubbleHighlightBrush_;
ComPtr<ID2D1SolidColorBrush> fishBrushes_[FISH_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> fishFinBrush_;
ComPtr<ID2D1SolidColorBrush> sheepBodyBrush_;
ComPtr<ID2D1SolidColorBrush> sheepLegBrush_;
ComPtr<ID2D1SolidColorBrush> sheepFaceBrush_;
ComPtr<ID2D1SolidColorBrush> sheepEarBrush_;
ComPtr<ID2D1SolidColorBrush> sheepInkBrush_;
struct CatCoatBrushSet {
ComPtr<ID2D1SolidColorBrush> body;
ComPtr<ID2D1SolidColorBrush> leg;
ComPtr<ID2D1SolidColorBrush> face;
ComPtr<ID2D1SolidColorBrush> ear;
ComPtr<ID2D1SolidColorBrush> ink;
};
CatCoatBrushSet catCoatBrushes_[CAT_COAT_VARIANT_COUNT];
ComPtr<ID2D1SolidColorBrush> bunnyBodyBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyBellyBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyEarBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyEarInnerBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyTailBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyEyeBrush_;
ComPtr<ID2D1SolidColorBrush> bunnyNoseBrush_;
ComPtr<ID2D1SolidColorBrush> hedgehogBodyBrush_;
ComPtr<ID2D1SolidColorBrush> hedgehogSpikeBrush_;
ComPtr<ID2D1SolidColorBrush> hedgehogSpikeTipBrush_;
ComPtr<ID2D1SolidColorBrush> hedgehogNoseBrush_;
ComPtr<ID2D1SolidColorBrush> hedgehogEyeBrush_;
ComPtr<ID2D1SolidColorBrush> butterflyBodyBrush_;
ComPtr<ID2D1SolidColorBrush> butterflyWingBrushes_[BUTTERFLY_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> butterflyAccentBrushes_[BUTTERFLY_COLOR_COUNT];
ComPtr<ID2D1SolidColorBrush> fireflyBodyBrush_;
ComPtr<ID2D1SolidColorBrush> fireflyGlowBrush_;
ComPtr<ID2D1SolidColorBrush> birdBrush_;
ComPtr<ID2D1SolidColorBrush> petNameBrush_;
ComPtr<ID2D1SolidColorBrush> petNameShadowBrush_;
ComPtr<IDWriteFactory> dwriteFactory_;
ComPtr<IDWriteTextFormat> petNameTextFormat_;
ComPtr<IDCompositionDevice> dcompDevice_;
ComPtr<IDCompositionTarget> dcompTarget_;
ComPtr<IDCompositionVisual> dcompVisual_;
Sim sim_{};
std::unordered_map<uint64_t, double> petNameLastHover_;
bool initialized_ = false;
};
} // namespace desktopgrass

File diff suppressed because it is too large Load Diff

View File

@@ -1,404 +0,0 @@
// Sim.h
//
// Pure data + math: PRNG, blade generation, sway, gust, cut. NO Direct2D, D3D,
// COM, Win32 UI, or threading dependencies. This is what the unit-test project
// links against.
//
// Mirrors docs/architecture.md §§3-10. Constants live in Constants.h.
#pragma once
#include <cstdint>
#include <cstddef>
#include <vector>
#include "Constants.h"
#include "Persistence.h"
namespace desktopgrass {
// ---------------------------------------------------------------------------
// PRNG: xorshift64 seeded via SplitMix64. See architecture.md §3.
// ---------------------------------------------------------------------------
struct Prng {
uint64_t state;
};
uint64_t splitmix64(uint64_t z) noexcept;
void prng_init(Prng& p, uint64_t seed) noexcept;
uint64_t prng_next_u64(Prng& p) noexcept;
uint32_t prng_next_u32(Prng& p) noexcept;
double prng_next_unit(Prng& p) noexcept;
double prng_uniform(Prng& p, double lo, double hi) noexcept;
double prng_exponential(Prng& p, double lambda) noexcept;
uint32_t prng_index(Prng& p, uint32_t n) noexcept;
// ---------------------------------------------------------------------------
// Blade. Layout matches architecture.md §4 — field set, not field order, is
// load-bearing.
// ---------------------------------------------------------------------------
struct Blade {
// Static (set once at generation).
double baseX;
double height;
double thickness;
uint8_t hue;
double swayPhaseOffset;
double stiffness;
// Runtime.
double cutHeight;
double gustVelocity;
double cutAnimStart;
double cutInitialHeight;
// Residual normalized height a mowed blade settles at (stubble). Assigned
// once at generation from an independent salted PRNG stream so it does not
// perturb the static-field draws. Defaults to 0.0 so hand-built `Blade b{}`
// fixtures collapse fully to a flat stump exactly as before.
double cutFloor = 0.0;
// Regrowth (Constants.h §"Regrowth"). regrowDelay / regrowDuration are
// assigned once at generation from an independent PRNG stream (so they do
// not perturb the static fields' draws). regrowStart is the absolute
// globalTime at which the regrow animation begins; -1 means "not
// scheduled". When cutAnimStart finishes, advance_cut sets
// regrowStart = globalTime + regrowDelay. A click on a regrowing blade
// cancels regrowth (clears regrowStart) and restarts the cut from current
// cutHeight. In-class initializers keep `Blade b{};` (used by tests &
// helpers) in the correct "no regrowth scheduled" state.
double regrowDelay = 0.0;
double regrowDuration = 0.0;
double regrowStart = -1.0;
// Flower (§4, §5, §7). Static, set once at generation from an
// independent PRNG stream. isFlower=false means this is an ordinary
// grass blade; heightBonus defaults to 1.0 so the L formula in
// compute_blade_stroke is a no-op for non-flowers.
bool isFlower = false;
uint8_t flowerHeadColorIdx = 0;
double flowerHeadRadius = 0.0;
double heightBonus = 1.0;
// Mushroom (PROTOTYPE — Native-only). Static, set once at generation from
// a fourth independent PRNG stream. When isMushroom=true the renderer
// draws a filled-ellipse cap on a short stem at this slot and skips the
// grass blade + flower head. cutHeight still drives cut/regrow animation
// for mushrooms (cap+stem shrink/grow linearly with it).
bool isMushroom = false;
uint8_t mushroomCapColorIdx = 0;
double mushroomCapWidth = 0.0; // radius X (DIP)
double mushroomCapHeight = 0.0; // radius Y (DIP)
double mushroomStemHeight = 0.0; // DIP
double mushroomStemThickness = 0.0; // DIP
// Original Grass-scene slot variants. Desert cacti temporarily replace
// flower/mushroom tags; switching back to Grass restores these snapshots.
bool originalIsFlower = false;
bool originalIsMushroom = false;
// Cactus (§14). Desert-only slot-bound blade variant.
bool isCactus = false;
uint8_t cactusType = 0; // 0 = column, 1 = single-arm, 2 = saguaro
double cactusHeight = 0.0; // DIP
double cactusWidth = 0.0; // DIP
int8_t cactusArmSide = +1; // -1 or +1 for type 1
// Pine (§15.1). Winter-only slot-bound blade variant.
bool isPine = false;
uint8_t pineTierCount = 0; // 2..4 (only meaningful for treeVariant == 0)
uint8_t treeVariant = 0; // 0 = pine, 1 = birch
double pineHeight = 0.0; // DIP
double pineWidth = 0.0; // DIP, base-tier width (pine) or trunk width (birch)
bool treeBackground = false; // §15.4 depth layer: true = small/hazy, drawn behind the snowbank
// Maple (§16.5). Autumn-only slot-bound blade variant.
bool isMaple = false;
double mapleHeight = 0.0; // DIP
double mapleTrunkWidth = 0.0; // DIP
double mapleCanopyRadius = 0.0; // DIP
uint8_t mapleCanopyColorIdx = 0;
bool mapleIsBare = false;
// Coral (§17). Ocean-only slot-bound blade variant.
bool isCoral = false;
uint8_t coralType = 0; // 0 = fan, 1 = branching, 2 = brain
double coralHeight = 0.0; // DIP
double coralWidth = 0.0; // DIP
uint8_t coralColorIdx = 0;
// Leaf puff cooldown (§16.6). Absolute globalTime before which this maple
// will not shed another hover-triggered leaf flurry. Runtime-only; default
// 0.0 keeps `Blade b{}` fixtures ready to puff immediately.
double leafPuffCooldownEnd = 0.0;
// Derived per-frame. Stored on the blade for the renderer to consume; not
// part of the persistent state and ignored by snapshot tests.
double effectiveLean;
};
// ---------------------------------------------------------------------------
// Stroke (rendering geometry). architecture.md §7.
// ---------------------------------------------------------------------------
struct Point { double x, y; };
struct Stroke {
Point base;
Point control;
Point tip;
double thickness;
uint32_t argb;
};
Stroke compute_blade_stroke(const Blade& b, double groundY, Scene scene) noexcept;
// ---------------------------------------------------------------------------
// Input event queue. The renderer drains the OS hook into this struct each
// frame, then calls sim_tick.
// ---------------------------------------------------------------------------
enum class EventType : uint8_t {
Move = 0,
Click = 1,
};
struct InputEvent {
EventType type;
double x; // window-local DIP
double y; // window-local DIP
double time; // seconds, monotonic
};
// ---------------------------------------------------------------------------
// Roaming entities (architecture.md §13.2). Tumbleweeds (Desert §14),
// snowflakes (Winter §15), sheep (§16), cats (§17), bunnies (§17.5),
// butterflies (§17.6), fireflies (§17.7), and birds (§17.8) live in sim.entities.
// The struct fields are shared across kinds; per-kind tick logic branches on `kind`.
// ---------------------------------------------------------------------------
struct Entity {
EntityKind kind = EntityKind::None;
double x = 0.0;
double y = 0.0;
double vx = 0.0;
double vy = 0.0;
double size = 0.0; // radius (DIP)
double rotation = 0.0; // radians
double rotationSpeed = 0.0; // rad/sec
double age = 0.0;
double lifetime = -1.0; // <= 0 means infinite (respawn-in-place)
uint32_t seed = 0;
// Critter state machine (§16-§18). Sheep and Cat share state bytes;
// Cat reuses Hopping semantically as Pouncing. Bunny uses BUNNY_STATE_*.
// Values are ignored by tumbleweeds/snowflakes and inert by default.
uint8_t state = 0; // critters: see species STATE constants
double stateTimer = 0.0; // sec remaining in current state
uint8_t previousState = 0; // hedgehog: pre-curl state
double previousStateTimer = 0.0; // hedgehog: remaining pre-curl time
uint8_t nameIndex = 0; // critters: index into species name pool
uint8_t coatVariantIndex = 0; // cat: index into CAT_COAT_PALETTES
// Ambient flyers (§17.6-§17.8). These fields are ignored by grounded pets.
double baseSpeed = 0.0;
double altitudeAnchor = 0.0;
double phaseY = 0.0; // butterflies/fireflies: Y phase; birds: vertical drift phase
double phaseX = 0.0; // butterflies/fireflies: X phase; birds: wing phase offset
double blinkPeriod = 0.0;
double blinkPhase = 0.0;
uint8_t colorVariant = 0;
// Bird flyby (§17.8) transient metadata.
double x0 = 0.0;
double spawnTime = 0.0;
double formationOffsetAlongFlight = 0.0;
double formationOffsetPerpendicular = 0.0;
};
// ---------------------------------------------------------------------------
// Sim — the simulation state for one monitor window.
// ---------------------------------------------------------------------------
struct Sim {
std::vector<Blade> blades;
double globalTime = 0.0;
double prevCursorX = 0.0;
double prevCursorTime = -1.0;
double windowHeight = STRIP_HEIGHT + HEADROOM;
// §config sway knobs. Multipliers applied in update_blade_dynamics; default
// 1.0 reproduces historical behavior. Set once from config after sim_init and
// preserved across scene changes (sim_set_scene does not touch them).
double swaySpeedScale = 1.0;
double swayAmpScale = 1.0;
// Ambient gust scheduler (§8.1). Initialized by sim_init / sim_regenerate.
Prng ambientPrng = { 0 };
double nextAmbientGustTime = 0.0;
double monitorWidth = 0.0;
// Current scene (§13). Default Grass; updated by sim_set_scene.
Scene currentScene = SCENE_DEFAULT;
// Roaming entities (§13.2). Desert and Winter add their own scene entities
// via sim_set_scene. Pre-reserved to MAX_ENTITIES_PER_MONITOR at init so the
// tick path never grows the vector.
std::vector<Entity> entities;
// Per-scene entity-stream seed. Set at sim_init; used by sim_set_scene
// to construct the per-kind generator PRNGs.
uint64_t entitySeed = 0;
// Persistent tumbleweed stream (§14). Initialized when entering Desert and
// consumed by off-edge respawns so replay stays deterministic.
Prng tumbleweedPrng = { 0 };
// §15 snowflake emitter (Winter scene only)
Prng snowflakePrng = { 0 };
double nextSnowflakeSpawnTime = 0.0;
// §16.5 leaf emitter (Autumn scene only).
Prng leafPrng = { 0 };
double nextLeafSpawnTime = 0.0;
// §16.6 leaf-puff emitter (Autumn scene only). Independent salted stream so
// cursor-triggered puffs never perturb the ambient leaf emitter's draws.
Prng leafPuffPrng = { 0 };
// §21 snow-puff emitter (Winter scene only). Independent salted stream so
// click-triggered powder bursts never perturb the snowflake emitter's draws.
Prng snowPuffPrng = { 0 };
// §21.1 snow-drift emitter (Winter scene only). Cursor-move spindrift wisps
// share an independent salted stream so they never perturb the click puff or
// snowflake draws. A global cooldown keeps the kicked-up powder calm.
Prng snowDriftPrng = { 0 };
double snowDriftCooldownEnd = 0.0;
// §17 Ocean emitters. Bubble stream rises from the seafloor; fish stream
// maintains the swimmer population. Independent salted streams so neither
// perturbs the other's draws or the shared coral generator.
Prng bubblePrng = { 0 };
double nextBubbleSpawnTime = 0.0;
Prng fishPrng = { 0 };
// §17.8 daytime bird-flyby emitter. Transient Grass-only flocks share one
// persistent stream and one next-event time across scene switches.
Prng birdFlybyPrng = { 0 };
double nextBirdFlybyAtTime = 0.0;
// Critter subsystem (§13.3, §16-§18). Grass-scene critters share one PRNG
// stream seeded from entitySeed XOR CRITTER_PRNG_SALT at generation time.
// critterCountOverride is 0 for random species count, or a fixed count
// capped by PET_COUNT_MAX_PER_MONITOR during legacy single-species generation.
CritterKind currentCritter = CRITTER_DEFAULT;
Prng critterPrng = { 0 };
int critterCountOverride = 0;
};
// Construct a sim with blades generated for the given monitor width, density,
// and seed. windowHeight defaults to STRIP_HEIGHT + HEADROOM.
Sim sim_init(uint64_t seed, double monitorWidth, double density = 1.0);
// Re-run generation in place, resetting all runtime state.
void sim_regenerate(Sim& sim, uint64_t seed, double monitorWidth, double density = 1.0);
// Apply a move event. Updates prevCursorX/prevCursorTime and distributes gust
// impulse. Caller is responsible for the dt-clamp / cap on cursor speed; this
// function performs the cap internally per the spec.
void sim_apply_move(Sim& sim, const InputEvent& e) noexcept;
// Apply a click event. cutBand filtering uses sim.windowHeight as groundY.
void sim_apply_click(Sim& sim, const InputEvent& e) noexcept;
// Ambient gust application (§8.1). Same impulse kernel as cursor gusts but
// uses GUST_RADIUS * AMBIENT_GUST_RADIUS_FACTOR and an impulse magnitude
// parameterised by magFactor (a fraction of a saturated cursor sweep) instead
// of the cursor-derived velocity. Exposed for unit tests.
void sim_apply_ambient_gust(Sim& sim, double x, double signDir, double magFactor) noexcept;
// Run the ambient gust scheduler one tick. Exposed for unit tests. Idempotent
// on idle ticks (zero PRNG draws); fires zero or more puffs depending on how
// many scheduled fire times sim.globalTime has crossed.
void sim_tick_ambient_gusts(Sim& sim) noexcept;
// Set the current scene (§13). State-only update — does not regenerate
// blades or perturb any PRNG stream. Renderer reads sim.currentScene at
// draw time.
//
// Phase-3 amendment (§13.1): in addition to the field assign, this clears
// sim.entities and dispatches to per-scene generators (none yet — empty
// generator hooks for Grass / Desert / Winter; Desert and Winter content
// agents add their generators here).
void sim_set_scene(Sim& sim, Scene s) noexcept;
// Advance roaming entities by dt (§13.2). Generic per-kind tick: position
// integration, rotation update, age advance, snowflake horizontal sway,
// tumbleweed off-edge respawn, snowflake culling. Currently a no-op when
// sim.entities is empty (which is always until §14/§15 generators run).
void sim_tick_entities(Sim& sim, double dt) noexcept;
// Critter selection (§16-§18). Removes existing critter-kind entities
// (preserving scene entities like tumbleweeds/snowflakes), then re-runs the
// critter generator. CritterKind::None spawns no ground critters; only the
// ambient flyers (butterflies/fireflies) remain.
void sim_set_critter(Sim& sim, CritterKind c) noexcept;
// Fixed critter count override (§13.3). n=0 clears to random; positive values
// are capped at PET_COUNT_MAX_PER_MONITOR during generation.
void sim_set_critter_count(Sim& sim, int n) noexcept;
double bunny_hop_y_offset(double age, bool startled) noexcept;
uint8_t bunny_choose_rest_state(Prng& p) noexcept;
uint8_t hedgehog_choose_rest_state(Prng& p) noexcept;
double bird_flyby_sample_interval(Prng& p) noexcept;
void sim_spawn_bird_flyby(Sim& sim) noexcept;
void sim_tick_bird_flybys(Sim& sim) noexcept;
// Advance the simulation by dt seconds. Drains the provided event list in
// order, then runs per-blade dynamics + cut animation. Pass numEvents = 0 if
// no events fired this frame.
void sim_tick(Sim& sim, double dt,
const InputEvent* events, std::size_t numEvents) noexcept;
// Generator used by sim_init / sim_regenerate. Exposed for unit tests.
void generate_blades(uint64_t seed, double monitorWidth, double density,
std::vector<Blade>& out);
// Desert generators (§14). Exposed for unit tests.
void generate_cacti_for_desert(Sim& sim) noexcept;
void generate_tumbleweeds(Sim& sim) noexcept;
// Pine tree generator (§15.1). Iterates blade slots; promotes a small
// fraction to pines from the PINE_PRNG_SALT stream when entering Winter.
// Slot-bound and reversed by restore_original_variants on scene exit.
void generate_pines_for_winter(Sim& sim) noexcept;
// Maple tree generator (§16.5). Iterates blade slots; promotes a small
// fraction to maples from the MAPLE_PRNG_SALT stream when entering Autumn.
void generate_maples_for_autumn(Sim& sim) noexcept;
void generate_coral_for_ocean(Sim& sim) noexcept;
// Per-blade dynamics helper (visible for tests).
void update_blade_dynamics(Blade& b, double globalTime, double dt,
double swaySpeedScale = 1.0,
double swayAmpScale = 1.0) noexcept;
void advance_cut(Blade& b, double globalTime) noexcept;
// Persistence helpers. GetCuts stores cut timestamps shifted relative to the
// current sim time (for example, -20 means the blade was cut 20 seconds ago)
// so a fresh sim can resume regrowth after restart.
std::vector<persistence::CutRecord> sim_get_cuts(const Sim& sim);
void sim_apply_cuts(Sim& sim, const std::vector<persistence::CutRecord>& cuts) noexcept;
// dt clamp helper. Required at the renderer boundary so a long pause does not
// produce visible artifacts. See architecture.md §10.
constexpr double DT_MIN = 0.001;
constexpr double DT_MAX = 0.1;
constexpr double clamp_dt(double dt) noexcept {
return (dt < DT_MIN) ? DT_MIN : (dt > DT_MAX ? DT_MAX : dt);
}
} // namespace desktopgrass

View File

@@ -1,65 +0,0 @@
// main.cpp
//
// Entry point: set up DPI awareness, COM, the App, run the message loop.
//
// When the command line contains `--benchmark`, dispatch into the benchmark
// runner instead of the normal App lifecycle. Production tray/persistence
// code remains untouched in that path.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <combaseapi.h>
#include <shellapi.h>
#include <cwchar>
#include "App.h"
#include "Benchmark.h"
namespace {
bool HasBenchmarkFlag(int argc, wchar_t** argv) {
for (int i = 1; i < argc; ++i) {
if (argv[i] && _wcsicmp(argv[i], L"--benchmark") == 0) return true;
}
return false;
}
} // anonymous
int APIENTRY wWinMain(HINSTANCE hInst, HINSTANCE, LPWSTR, int) {
// Per-Monitor V2 DPI awareness. Also declared in the manifest so OSes that
// honour the manifest pick it up before WinMain runs.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
if (FAILED(hr)) {
return -1;
}
int argc = 0;
wchar_t** argv = CommandLineToArgvW(GetCommandLineW(), &argc);
int exitCode = 0;
if (argv && HasBenchmarkFlag(argc, argv)) {
desktopgrass::benchmark::Options opts;
if (!desktopgrass::benchmark::ParseOptions(argc, argv, opts)) {
if (argv) LocalFree(argv);
CoUninitialize();
return -3;
}
exitCode = desktopgrass::benchmark::Run(hInst, opts);
} else {
desktopgrass::App app;
if (!app.Initialize(hInst)) {
if (argv) LocalFree(argv);
CoUninitialize();
return -2;
}
exitCode = app.Run();
}
if (argv) LocalFree(argv);
CoUninitialize();
return exitCode;
}

View File

@@ -1,6 +0,0 @@
{
"name": "desktopgrass-native",
"version-string": "1.0.0",
"description": "DesktopGrass Native (Win32 + Direct2D, C++). Manifest reserved; no vcpkg-managed deps in v1 — vendored where needed.",
"dependencies": []
}

View File

@@ -102,19 +102,18 @@ namespace ShortcutGuide.Helpers
/// <summary>
/// Retrieves all application IDs that should be displayed, based on the foreground window and background processes.
/// </summary>
/// <param name="foregroundWindowHandle">The window handle captured before Shortcut Guide UI takes focus.</param>
/// <returns>
/// A dictionary mapping each application ID to the full path of the executable
/// that caused the match (used for icon extraction), or <c>null</c> when no
/// specific executable is associated (for example, wildcard filters like the
/// default shell).
/// </returns>
public static Dictionary<string, string?> GetAllCurrentApplicationIds()
public static Dictionary<string, string?> GetAllCurrentApplicationIds(nint foregroundWindowHandle)
{
nint handle = NativeMethods.GetForegroundWindow();
Dictionary<string, string?> applicationIds = new(StringComparer.Ordinal);
if (NativeMethods.GetWindowThreadProcessId(handle, out uint processId) > 0)
if (NativeMethods.GetWindowThreadProcessId(foregroundWindowHandle, out uint processId) > 0)
{
string? name = null;
string? executablePath = null;

View File

@@ -21,9 +21,12 @@ namespace ShortcutGuide
{
public static Thread CopyAndIndexGenerationThread { get; private set; } = null!;
public static nint ForegroundWindowHandle { get; private set; } = nint.Zero;
[STAThread]
public static void Main(string[] args)
{
ForegroundWindowHandle = NativeMethods.GetForegroundWindow();
Logger.InitializeLogger("\\ShortcutGuide\\Logs");
// The module interface passes: <powertoys_pid> [telemetry]

View File

@@ -53,7 +53,7 @@ namespace ShortcutGuide
_getAppIdsTask = Task.Run(() =>
{
Program.CopyAndIndexGenerationThread.Join();
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds();
_currentApplicationIds = ManifestInterpreter.GetAllCurrentApplicationIds(Program.ForegroundWindowHandle);
return _currentApplicationIds;
});

View File

@@ -34,15 +34,17 @@ namespace winrt
using namespace Windows::Devices::Enumeration;
}
AudioSampleGenerator::AudioSampleGenerator(bool captureMicrophone, bool captureSystemAudio, bool micMonoMix)
AudioSampleGenerator::AudioSampleGenerator(bool captureMicrophone, bool captureSystemAudio, bool mixMicrophoneMono, bool useNoiseCancellation)
: m_captureMicrophone(captureMicrophone)
, m_captureSystemAudio(captureSystemAudio)
, m_micMonoMix(micMonoMix)
, m_mixMicrophoneMono(mixMicrophoneMono)
, m_useNoiseCancellation(useNoiseCancellation)
{
OutputDebugStringA(("AudioSampleGenerator created, captureMicrophone=" +
std::string(captureMicrophone ? "true" : "false") +
", captureSystemAudio=" + std::string(captureSystemAudio ? "true" : "false") +
", micMonoMix=" + std::string(micMonoMix ? "true" : "false") + "\n").c_str());
", mixMicrophoneMono=" + std::string(mixMicrophoneMono ? "true" : "false") +
", useNoiseCancellation=" + std::string(useNoiseCancellation ? "true" : "false") + "\n").c_str());
m_audioEvent.create(wil::EventOptions::ManualReset);
m_endEvent.create(wil::EventOptions::ManualReset);
m_startEvent.create(wil::EventOptions::ManualReset);
@@ -158,8 +160,24 @@ winrt::IAsyncAction AudioSampleGenerator::InitializeAsync()
throw winrt::hresult_error(E_FAIL, L"Failed to initialize loopback audio capture!");
}
// Initialize noise suppressor for microphone audio if enabled
if (m_useNoiseCancellation && m_captureMicrophone)
{
m_noiseSuppressor = std::make_unique<NoiseSuppressor>();
OutputDebugStringA("Noise cancellation enabled for microphone\n");
}
m_audioGraph.QuantumStarted({ this, &AudioSampleGenerator::OnAudioQuantumStarted });
// Start the AudioGraph now so the microphone device begins warming up
// during the remaining recording initialization (transcoder setup, etc.).
// OnAudioQuantumStarted returns early while m_started is false, so audio
// samples are discarded until Start() is called. The side-effect of
// starting the graph early is that the system mic-active icon appears
// sooner, which also triggers a desktop-content change that helps
// unblock the WGC frame pool wait in OnMediaStreamSourceStarting.
m_audioGraph.Start();
m_asyncInitialized.SetEvent();
}
}
@@ -205,67 +223,65 @@ std::optional<winrt::MediaStreamSample> AudioSampleGenerator::TryGetNextSample()
}
}
// Wait for audio samples to become available, retrying on spurious wakes
// (e.g. when OnAudioQuantumStarted signals m_audioEvent but the quantum
// produced an empty buffer so m_samples is still empty).
for (;;)
{
auto lock = m_lock.lock_exclusive();
if (m_samples.empty() && m_endEvent.is_signaled())
{
auto lock = m_lock.lock_exclusive();
if (m_samples.empty() && m_endEvent.is_signaled())
{
return std::nullopt;
}
else if (!m_samples.empty())
{
std::optional result(m_samples.front());
m_samples.pop_front();
return result;
}
}
m_audioEvent.ResetEvent();
std::vector<HANDLE> events = { m_endEvent.get(), m_audioEvent.get() };
auto waitResult = WaitForMultipleObjectsEx(static_cast<DWORD>(events.size()), events.data(), false, INFINITE, false);
auto eventIndex = -1;
switch (waitResult)
{
case WAIT_OBJECT_0:
case WAIT_OBJECT_0 + 1:
eventIndex = waitResult - WAIT_OBJECT_0;
break;
}
WINRT_VERIFY(eventIndex >= 0);
auto signaledEvent = events[eventIndex];
if (signaledEvent == m_endEvent.get())
{
// End was signaled, but check for any remaining samples before returning nullopt
auto lock = m_lock.lock_exclusive();
if (!m_samples.empty())
{
std::optional result(m_samples.front());
m_samples.pop_front();
return result;
}
return std::nullopt;
}
else if (!m_samples.empty())
{
std::optional result(m_samples.front());
m_samples.pop_front();
return result;
}
}
m_audioEvent.ResetEvent();
std::vector<HANDLE> events = { m_endEvent.get(), m_audioEvent.get() };
auto waitResult = WaitForMultipleObjectsEx(static_cast<DWORD>(events.size()), events.data(), false, INFINITE, false);
auto eventIndex = -1;
switch (waitResult)
{
case WAIT_OBJECT_0:
case WAIT_OBJECT_0 + 1:
eventIndex = waitResult - WAIT_OBJECT_0;
break;
}
WINRT_VERIFY(eventIndex >= 0);
auto signaledEvent = events[eventIndex];
if (signaledEvent == m_endEvent.get())
{
// End was signaled, but check for any remaining samples before returning nullopt
auto lock = m_lock.lock_exclusive();
if (!m_samples.empty())
{
std::optional result(m_samples.front());
m_samples.pop_front();
return result;
}
return std::nullopt;
}
else
{
auto lock = m_lock.lock_exclusive();
if (m_samples.empty())
{
// Spurious wake or race - no samples available
// If end is signaled, return nullopt
return m_endEvent.is_signaled() ? std::nullopt : std::optional<winrt::MediaStreamSample>{};
}
std::optional result(m_samples.front());
m_samples.pop_front();
return result;
// m_audioEvent was signaled — loop back to check m_samples again.
// If the quantum produced an empty buffer, m_samples will still be
// empty and we'll wait for the next quantum.
}
}
void AudioSampleGenerator::Start()
void AudioSampleGenerator::Start(int64_t videoStartTimestamp)
{
CheckInitialized();
m_videoStartTimestamp = videoStartTimestamp;
auto expected = false;
if (m_started.compare_exchange_strong(expected, true))
{
OutputDebugStringW( L"[AudioGen] Start(): m_started set to true, setting m_startEvent\n" );
m_endEvent.ResetEvent();
m_startEvent.SetEvent();
@@ -284,7 +300,7 @@ void AudioSampleGenerator::Start()
m_loopbackCapture->Start();
}
m_audioGraph.Start();
// AudioGraph was already started in InitializeAsync for mic warmup.
}
}
@@ -611,12 +627,21 @@ void AudioSampleGenerator::CombineQueuedSamples()
void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender, winrt::IInspectable const& args)
{
// Don't process if we're not actively recording
// Don't process if we're not actively recording, but DO drain the
// output node so stale audio doesn't accumulate during mic warmup.
// Without this, the first GetFrame() after m_started becomes true
// would return several seconds of buffered audio, confusing the
// transcoder's A/V interleaving.
if (!m_started.load())
{
auto frame = m_audioOutputNode.GetFrame();
(void)frame; // discard
return;
}
static int s_quantumCount = 0;
s_quantumCount++;
{
auto lock = m_lock.lock_exclusive();
@@ -628,6 +653,14 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender
auto sampleBuffer = winrt::Buffer::CreateCopyFromMemoryBuffer(audioBuffer);
sampleBuffer.Length(audioBuffer.Length());
if( s_quantumCount <= 5 )
{
wchar_t dbg[256];
swprintf_s( dbg, L"[AudioGen] quantum #%d: audioBuffer.Length=%u sampleBuffer.Length=%u started=%d\n",
s_quantumCount, audioBuffer.Length(), sampleBuffer.Length(), m_started.load() ? 1 : 0 );
OutputDebugStringW( dbg );
}
// Calculate expected samples per quantum (~10ms at graph sample rate)
// AudioGraph uses 10ms quantums by default
uint32_t expectedSamplesPerQuantum = (m_graphSampleRate / 100) * m_graphChannels;
@@ -636,7 +669,7 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender
// Apply mono mixing to microphone audio if enabled
// This converts stereo mic input (with same signal on both channels) to true mono
// by averaging the channels and writing the result to both channels
if (m_micMonoMix && m_captureMicrophone && numMicSamples > 0 && m_graphChannels >= 2)
if (m_mixMicrophoneMono && m_captureMicrophone && numMicSamples > 0 && m_graphChannels >= 2)
{
float* micData = reinterpret_cast<float*>(sampleBuffer.data());
uint32_t numFrames = numMicSamples / m_graphChannels;
@@ -656,6 +689,12 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender
}
}
}
// Apply noise suppression to microphone audio before mixing with loopback
if (m_noiseSuppressor && m_captureMicrophone && numMicSamples > 0)
{
float* micData = reinterpret_cast<float*>(sampleBuffer.data());
m_noiseSuppressor->Process(micData, numMicSamples, m_graphChannels);
}
// Drain loopback samples regardless of whether we have mic audio
if (m_loopbackCapture)
@@ -733,13 +772,25 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender
if (sampleBuffer.Length() > 0)
{
auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp.value());
// Rebase audio timestamps to the video's SystemRelativeTime domain.
// AudioGraph RelativeTime starts near 0 (or a few hundred ms after
// warmup draining), while video uses absolute SRT (~hours since boot).
// Without rebasing, the transcoder sees audio far behind video and
// starves video while trying to fill the gap with audio.
if (!m_hasTimestampOffset && timestamp.has_value())
{
m_timestampOffset = m_videoStartTimestamp - timestamp.value().count();
m_hasTimestampOffset = true;
}
auto adjustedTs = winrt::TimeSpan{ timestamp.value().count() + m_timestampOffset };
auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, adjustedTs);
m_samples.push_back(sample);
const uint32_t sampleCount = sampleBuffer.Length() / sizeof(float);
const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0;
const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast<int64_t>(frames) * 10000000LL / m_graphSampleRate) : 0;
m_lastSampleTimestamp = timestamp.value();
m_lastSampleTimestamp = adjustedTs;
m_lastSampleDuration = winrt::TimeSpan{ durationTicks };
m_hasLastSampleTimestamp = true;
}

View File

@@ -1,18 +1,23 @@
#pragma once
#include "LoopbackCapture.h"
#include "NoiseSuppressor.h"
#include <deque>
#include <optional>
#include <memory>
class AudioSampleGenerator
{
public:
AudioSampleGenerator(bool captureMicrophone = true, bool captureSystemAudio = true, bool micMonoMix = false);
AudioSampleGenerator(bool captureMicrophone = true, bool captureSystemAudio = true, bool mixMicrophoneMono = false, bool useNoiseCancellation = false);
~AudioSampleGenerator();
winrt::Windows::Foundation::IAsyncAction InitializeAsync();
winrt::Windows::Media::MediaProperties::AudioEncodingProperties GetEncodingProperties();
std::optional<winrt::Windows::Media::Core::MediaStreamSample> TryGetNextSample();
void Start();
void Start(int64_t videoStartTimestamp = 0);
void Stop();
private:
@@ -70,5 +75,14 @@ private:
std::atomic<bool> m_started = false;
bool m_captureMicrophone = true;
bool m_captureSystemAudio = true;
bool m_micMonoMix = false;
};
bool m_mixMicrophoneMono = false;
bool m_useNoiseCancellation = false;
std::unique_ptr<NoiseSuppressor> m_noiseSuppressor;
// Timestamp rebasing: audio RelativeTime → video SystemRelativeTime domain.
// Without this, the transcoder sees audio timestamps (~350ms) far below video
// timestamps (~111 billion ticks) and starves video while trying to fill the gap.
int64_t m_videoStartTimestamp = 0;
int64_t m_timestampOffset = 0;
bool m_hasTimestampOffset = false;
};

View File

@@ -0,0 +1,859 @@
//==============================================================================
//
// BackgroundBlur.cpp
//
// Windows ML-based person segmentation and background blur for the
// webcam overlay. Uses the built-in Windows.AI.MachineLearning API
// to load an ONNX segmentation model (e.g. MediaPipe SelfieSegmentation)
// and produce a per-pixel person mask, then blurs or replaces the
// background using an iterated box blur or a user-chosen image.
//
// Copyright (C) Mark Russinovich
// Sysinternals - www.sysinternals.com
//
//==============================================================================
#include "pch.h"
#include "BackgroundBlur.h"
#include <algorithm>
#include <cstring>
#include <wincodec.h>
#include <wil/com.h>
namespace winml = winrt::Windows::AI::MachineLearning;
namespace wf = winrt::Windows::Foundation::Collections;
// Defined in Zoomit.cpp; compiles to nothing in Release builds.
void OutputDebug(const TCHAR* format, ...);
//----------------------------------------------------------------------------
// BackgroundBlur::Initialize
//
// Loads the ONNX segmentation model via Windows ML and inspects its
// input/output tensor shapes to auto-configure preprocessing.
//----------------------------------------------------------------------------
bool BackgroundBlur::Initialize( const wchar_t* modelPath )
{
try
{
// Load the model from file.
m_model = winml::LearningModel::LoadFromFilePath( modelPath );
// Try GPU (DirectML) first for faster inference; fall back to CPU
// if no suitable GPU is available.
try
{
winml::LearningModelDevice gpuDevice( winml::LearningModelDeviceKind::DirectXHighPerformance );
m_session = winml::LearningModelSession( m_model, gpuDevice );
m_usingGpu = true;
OutputDebug( L"[BackgroundBlur] Using DirectML (GPU) for inference\n" );
}
catch( ... )
{
winml::LearningModelDevice cpuDevice( winml::LearningModelDeviceKind::Cpu );
m_session = winml::LearningModelSession( m_model, cpuDevice );
m_usingGpu = false;
OutputDebug( L"[BackgroundBlur] GPU unavailable, falling back to CPU\n" );
}
m_binding = winml::LearningModelBinding( m_session );
// Get input feature descriptor.
auto inputFeatures = m_model.InputFeatures();
if( inputFeatures.Size() == 0 )
{
OutputDebug( L"[BackgroundBlur] Model has no input features\n" );
return false;
}
auto inputDesc = inputFeatures.GetAt( 0 );
m_inputName = inputDesc.Name();
// Inspect input tensor shape.
auto tensorDesc = inputDesc.as<winml::ITensorFeatureDescriptor>();
auto shape = tensorDesc.Shape();
if( shape.Size() == 4 )
{
if( shape.GetAt( 1 ) == 3 || shape.GetAt( 1 ) == 1 )
{
// NCHW layout.
m_inputIsNchw = true;
m_modelInputChannels = shape.GetAt( 1 );
m_modelInputHeight = shape.GetAt( 2 ) > 0 ? shape.GetAt( 2 ) : 256;
m_modelInputWidth = shape.GetAt( 3 ) > 0 ? shape.GetAt( 3 ) : 256;
}
else
{
// NHWC layout.
m_inputIsNchw = false;
m_modelInputHeight = shape.GetAt( 1 ) > 0 ? shape.GetAt( 1 ) : 256;
m_modelInputWidth = shape.GetAt( 2 ) > 0 ? shape.GetAt( 2 ) : 256;
m_modelInputChannels = shape.GetAt( 3 );
}
}
// Get output feature name.
auto outputFeatures = m_model.OutputFeatures();
if( outputFeatures.Size() == 0 )
{
OutputDebug( L"[BackgroundBlur] Model has no output features\n" );
return false;
}
m_outputName = outputFeatures.GetAt( 0 ).Name();
OutputDebug( L"[BackgroundBlur] Model loaded: input=%s %lldx%lld (ch=%lld, %s)\n",
m_inputName.c_str(), m_modelInputWidth, m_modelInputHeight,
m_modelInputChannels, m_inputIsNchw ? L"NCHW" : L"NHWC" );
// Pre-allocate input tensor buffer.
m_inputTensor.resize( static_cast<size_t>( m_modelInputChannels * m_modelInputHeight * m_modelInputWidth ) );
return true;
}
catch( winrt::hresult_error const& ex )
{
OutputDebug( L"[BackgroundBlur] Initialize failed: %s (0x%08X)\n", ex.message().c_str(), ex.code().value );
m_session = nullptr;
m_model = nullptr;
return false;
}
}
//----------------------------------------------------------------------------
// BackgroundBlur::RunSegmentation
//
// Resizes the BGRA frame to the model's expected input size, converts
// to float RGB, runs inference via Windows ML, and produces a float mask
// in m_mask where 1.0 = person, 0.0 = background.
//----------------------------------------------------------------------------
bool BackgroundBlur::RunSegmentation( const uint8_t* bgraPixels, uint32_t width, uint32_t height,
bool modelResOnly )
{
const int64_t mW = m_modelInputWidth;
const int64_t mH = m_modelInputHeight;
const int64_t mC = m_modelInputChannels;
// Resize BGRA → model-sized float RGB using nearest-neighbor.
for( int64_t y = 0; y < mH; y++ )
{
uint32_t srcY = static_cast<uint32_t>( y * height / mH );
for( int64_t x = 0; x < mW; x++ )
{
uint32_t srcX = static_cast<uint32_t>( x * width / mW );
const uint8_t* px = bgraPixels + ( static_cast<size_t>( srcY ) * width + srcX ) * 4;
float b = px[0] / 255.0f;
float g = px[1] / 255.0f;
float r = px[2] / 255.0f;
if( m_inputIsNchw )
{
m_inputTensor[static_cast<size_t>(0ll * mH * mW + y * mW + x)] = r;
if( mC > 1 ) m_inputTensor[static_cast<size_t>(1ll * mH * mW + y * mW + x)] = g;
if( mC > 2 ) m_inputTensor[static_cast<size_t>(2ll * mH * mW + y * mW + x)] = b;
}
else
{
size_t idx = static_cast<size_t>( (y * mW + x) * mC );
m_inputTensor[idx + 0] = r;
if( mC > 1 ) m_inputTensor[idx + 1] = g;
if( mC > 2 ) m_inputTensor[idx + 2] = b;
}
}
}
try
{
// Create the input tensor shape.
std::vector<int64_t> inputShape;
if( m_inputIsNchw )
inputShape = { 1, mC, mH, mW };
else
inputShape = { 1, mH, mW, mC };
// Create a TensorFloat from our data.
auto inputTensor = winml::TensorFloat::CreateFromArray(
inputShape, winrt::array_view<const float>( m_inputTensor.data(),
m_inputTensor.data() + m_inputTensor.size() ) );
// Bind input and evaluate.
m_binding.Clear();
m_binding.Bind( m_inputName, inputTensor );
auto result = m_session.Evaluate( m_binding, L"" );
// Extract output tensor — bulk-copy to a raw float array so we
// avoid per-element WinRT/COM dispatch in the hot loop.
auto outputTensor = result.Outputs().Lookup( m_outputName ).as<winml::TensorFloat>();
auto outputShape = outputTensor.Shape();
auto outputView = outputTensor.GetAsVectorView();
const uint32_t outputSize = outputView.Size();
m_outputBuf.resize( outputSize );
outputView.GetMany( 0, m_outputBuf );
const float* outData = m_outputBuf.data();
// Determine output mask dimensions.
int64_t outH = mH, outW = mW;
int64_t numClasses = 1;
if( outputShape.Size() == 4 )
{
if( outputShape.GetAt( 1 ) <= 2 && outputShape.GetAt( 2 ) > 2 )
{
// [1, classes, H, W]
numClasses = outputShape.GetAt( 1 );
outH = outputShape.GetAt( 2 );
outW = outputShape.GetAt( 3 );
}
else
{
// [1, H, W, classes]
outH = outputShape.GetAt( 1 );
outW = outputShape.GetAt( 2 );
numClasses = outputShape.GetAt( 3 );
}
}
else if( outputShape.Size() == 3 )
{
outH = outputShape.GetAt( 1 );
outW = outputShape.GetAt( 2 );
}
// Store actual output dimensions for GetModelMaskWidth/Height.
m_modelOutputWidth = outW;
m_modelOutputHeight = outH;
// Build model-resolution mask first, apply sigmoid sharpening
// at model resolution (e.g. 256×256 = 65K pixels), then upscale
// to frame resolution.
const size_t modelPixels = static_cast<size_t>( outH * outW );
m_erodeBuf.resize( modelPixels );
// Extract person scores at model resolution from the raw array.
// Apply a hard threshold to produce a binary mask. This is much
// faster than a sigmoid (no expf) and eliminates the partial-blur
// halo that was bleeding onto body/head edges.
for( int64_t y = 0; y < outH; y++ )
{
for( int64_t x = 0; x < outW; x++ )
{
float personScore;
if( numClasses == 1 )
{
personScore = outData[y * outW + x];
}
else
{
float bg = outData[0 * outH * outW + y * outW + x];
float fg = outData[1 * outH * outW + y * outW + x];
personScore = ( fg > bg ) ? 1.0f : 0.0f;
}
m_erodeBuf[static_cast<size_t>( y * outW + x )] = ( personScore > 0.5f ) ? 1.0f : 0.0f;
}
}
// ── GPU path: model-resolution post-processing only ────────
// When modelResOnly is true, apply feathering and temporal
// smoothing at model resolution (e.g. 256×256 = 65K pixels)
// and return early. The GPU's hardware bilinear sampler will
// handle upscaling to frame resolution for free.
if( modelResOnly )
{
// Small box blur on m_erodeBuf for edge feathering.
// Radius 1 at 256×256 provides similar smoothing to
// radius 3 at 960×540 after bilinear upscale.
const int modelBlurRadius = 1;
const int modelDiam = modelBlurRadius * 2 + 1;
m_maskBlurBuf.resize( modelPixels );
// Horizontal pass.
for( int64_t y = 0; y < outH; y++ )
{
const float* srcRow = m_erodeBuf.data() + y * outW;
float* dstRow = m_maskBlurBuf.data() + y * outW;
float sum = 0.0f;
for( int i = -modelBlurRadius; i <= modelBlurRadius; i++ )
sum += srcRow[(std::max)( static_cast<int64_t>(0), (std::min)( outW - 1, static_cast<int64_t>( i ) ) )];
for( int64_t x = 0; x < outW; x++ )
{
dstRow[x] = sum / modelDiam;
int64_t remX = (std::max)( static_cast<int64_t>(0), x - modelBlurRadius );
int64_t addX = (std::min)( outW - 1, x + modelBlurRadius + 1 );
sum += srcRow[addX] - srcRow[remX];
}
}
// Vertical pass.
for( int64_t x = 0; x < outW; x++ )
{
float sum = 0.0f;
for( int i = -modelBlurRadius; i <= modelBlurRadius; i++ )
{
int64_t iy = (std::max)( static_cast<int64_t>(0), (std::min)( outH - 1, static_cast<int64_t>( i ) ) );
sum += m_maskBlurBuf[static_cast<size_t>( iy * outW + x )];
}
for( int64_t y = 0; y < outH; y++ )
{
m_erodeBuf[static_cast<size_t>( y * outW + x )] = sum / modelDiam;
int64_t remY = (std::max)( static_cast<int64_t>(0), y - modelBlurRadius );
int64_t addY = (std::min)( outH - 1, y + modelBlurRadius + 1 );
sum += m_maskBlurBuf[static_cast<size_t>( addY * outW + x )] -
m_maskBlurBuf[static_cast<size_t>( remY * outW + x )];
}
}
// Temporal smoothing at model resolution.
if( m_prevModelMask.size() == modelPixels )
{
constexpr float alpha = 0.6f;
constexpr float beta = 0.4f;
for( size_t i = 0; i < modelPixels; i++ )
m_erodeBuf[i] = alpha * m_erodeBuf[i] + beta * m_prevModelMask[i];
}
m_prevModelMask = m_erodeBuf;
return true;
}
// Upscale processed mask to frame dimensions via bilinear interpolation
// to produce smooth edges instead of staircase artifacts.
const size_t maskPixels = static_cast<size_t>( width ) * height;
m_mask.resize( maskPixels );
for( uint32_t y = 0; y < height; y++ )
{
float srcYf = ( y + 0.5f ) * outH / static_cast<float>( height ) - 0.5f;
srcYf = (std::max)( 0.0f, (std::min)( srcYf, static_cast<float>( outH - 1 ) ) );
int64_t y0 = static_cast<int64_t>( srcYf );
int64_t y1 = (std::min)( y0 + 1, outH - 1 );
float fy = srcYf - y0;
for( uint32_t x = 0; x < width; x++ )
{
float srcXf = ( x + 0.5f ) * outW / static_cast<float>( width ) - 0.5f;
srcXf = (std::max)( 0.0f, (std::min)( srcXf, static_cast<float>( outW - 1 ) ) );
int64_t x0 = static_cast<int64_t>( srcXf );
int64_t x1 = (std::min)( x0 + 1, outW - 1 );
float fx = srcXf - x0;
float v00 = m_erodeBuf[static_cast<size_t>(y0 * outW + x0)];
float v01 = m_erodeBuf[static_cast<size_t>(y0 * outW + x1)];
float v10 = m_erodeBuf[static_cast<size_t>(y1 * outW + x0)];
float v11 = m_erodeBuf[static_cast<size_t>(y1 * outW + x1)];
m_mask[static_cast<size_t>( y ) * width + x] =
v00 * ( 1.0f - fx ) * ( 1.0f - fy ) +
v01 * fx * ( 1.0f - fy ) +
v10 * ( 1.0f - fx ) * fy +
v11 * fx * fy;
}
}
// Apply a small box blur to the upscaled mask to feather edges.
const int maskBlurRadius = 3;
const int maskDiam = maskBlurRadius * 2 + 1;
m_maskBlurBuf.resize( maskPixels );
// Horizontal pass.
for( uint32_t y = 0; y < height; y++ )
{
const float* srcRow = m_mask.data() + static_cast<size_t>( y ) * width;
float* dstRow = m_maskBlurBuf.data() + static_cast<size_t>( y ) * width;
float sum = 0.0f;
for( int i = -maskBlurRadius; i <= maskBlurRadius; i++ )
sum += srcRow[(std::max)( 0, (std::min)( static_cast<int>( width ) - 1, i ) )];
for( uint32_t x = 0; x < width; x++ )
{
dstRow[x] = sum / maskDiam;
int remX = (std::max)( 0, static_cast<int>( x ) - maskBlurRadius );
int addX = (std::min)( static_cast<int>( width ) - 1, static_cast<int>( x ) + maskBlurRadius + 1 );
sum += srcRow[addX] - srcRow[remX];
}
}
// Vertical pass.
for( uint32_t x = 0; x < width; x++ )
{
float sum = 0.0f;
for( int i = -maskBlurRadius; i <= maskBlurRadius; i++ )
{
int iy = (std::max)( 0, (std::min)( static_cast<int>( height ) - 1, i ) );
sum += m_maskBlurBuf[static_cast<size_t>( iy ) * width + x];
}
for( uint32_t y = 0; y < height; y++ )
{
m_mask[static_cast<size_t>( y ) * width + x] = sum / maskDiam;
int remY = (std::max)( 0, static_cast<int>( y ) - maskBlurRadius );
int addY = (std::min)( static_cast<int>( height ) - 1, static_cast<int>( y ) + maskBlurRadius + 1 );
sum += m_maskBlurBuf[static_cast<size_t>( addY ) * width + x] -
m_maskBlurBuf[static_cast<size_t>( remY ) * width + x];
}
}
// Temporal smoothing: blend the current mask with the previous
// frame's mask to stabilize edges and reduce flicker. A weight
// of 0.6 current + 0.4 previous keeps edges responsive while
// dampening the frame-to-frame jitter around fine details like
// ears, hair, and fingers.
{
const size_t maskPixelsInner = static_cast<size_t>( width ) * height;
if( m_prevMask.size() == maskPixelsInner )
{
constexpr float alpha = 0.6f; // current frame weight
constexpr float beta = 0.4f; // previous frame weight
for( size_t i = 0; i < maskPixelsInner; i++ )
{
m_mask[i] = alpha * m_mask[i] + beta * m_prevMask[i];
}
}
m_prevMask = m_mask;
}
return true;
}
catch( winrt::hresult_error const& ex )
{
OutputDebug( L"[BackgroundBlur] Evaluate failed: %s (0x%08X)\n", ex.message().c_str(), ex.code().value );
return false;
}
}
//----------------------------------------------------------------------------
// HorizontalBoxBlur / VerticalBoxBlur
//
// Separable box blur passes used to build an approximate Gaussian.
//----------------------------------------------------------------------------
static void HorizontalBoxBlur(
const uint8_t* src, uint8_t* dst, uint32_t width, uint32_t height, int radius )
{
const int diameter = radius * 2 + 1;
for( uint32_t y = 0; y < height; y++ )
{
int rSum = 0, gSum = 0, bSum = 0;
const uint8_t* row = src + static_cast<size_t>( y ) * width * 4;
// Initialize window with clamped left edge.
for( int i = -radius; i <= radius; i++ )
{
int ix = (std::max)( 0, (std::min)( static_cast<int>( width ) - 1, i ) );
const uint8_t* px = row + ix * 4;
bSum += px[0];
gSum += px[1];
rSum += px[2];
}
uint8_t* dstRow = dst + static_cast<size_t>( y ) * width * 4;
for( uint32_t x = 0; x < width; x++ )
{
dstRow[x * 4 + 0] = static_cast<uint8_t>( bSum / diameter );
dstRow[x * 4 + 1] = static_cast<uint8_t>( gSum / diameter );
dstRow[x * 4 + 2] = static_cast<uint8_t>( rSum / diameter );
dstRow[x * 4 + 3] = 0xFF;
// Slide window: add right, remove left.
int removeX = (std::max)( 0, static_cast<int>( x ) - radius );
int addX = (std::min)( static_cast<int>( width ) - 1, static_cast<int>( x ) + radius + 1 );
const uint8_t* remPx = row + removeX * 4;
const uint8_t* addPx = row + addX * 4;
bSum += addPx[0] - remPx[0];
gSum += addPx[1] - remPx[1];
rSum += addPx[2] - remPx[2];
}
}
}
static void VerticalBoxBlur(
const uint8_t* src, uint8_t* dst, uint32_t width, uint32_t height, int radius )
{
const int diameter = radius * 2 + 1;
for( uint32_t x = 0; x < width; x++ )
{
int rSum = 0, gSum = 0, bSum = 0;
// Initialize window with clamped top edge.
for( int i = -radius; i <= radius; i++ )
{
int iy = (std::max)( 0, (std::min)( static_cast<int>( height ) - 1, i ) );
const uint8_t* px = src + ( static_cast<size_t>( iy ) * width + x ) * 4;
bSum += px[0];
gSum += px[1];
rSum += px[2];
}
for( uint32_t y = 0; y < height; y++ )
{
uint8_t* dstPx = dst + ( static_cast<size_t>( y ) * width + x ) * 4;
dstPx[0] = static_cast<uint8_t>( bSum / diameter );
dstPx[1] = static_cast<uint8_t>( gSum / diameter );
dstPx[2] = static_cast<uint8_t>( rSum / diameter );
dstPx[3] = 0xFF;
int removeY = (std::max)( 0, static_cast<int>( y ) - radius );
int addY = (std::min)( static_cast<int>( height ) - 1, static_cast<int>( y ) + radius + 1 );
const uint8_t* remPx = src + ( static_cast<size_t>( removeY ) * width + x ) * 4;
const uint8_t* addPx = src + ( static_cast<size_t>( addY ) * width + x ) * 4;
bSum += addPx[0] - remPx[0];
gSum += addPx[1] - remPx[1];
rSum += addPx[2] - remPx[2];
}
}
}
//----------------------------------------------------------------------------
// BackgroundBlur::ApplyBlurWithMask
//
// Downscales the frame to a small working size, blurs there, then
// performs a single full-resolution pass that blends the original
// pixels with the upscaled blurred pixels according to the mask.
//----------------------------------------------------------------------------
void BackgroundBlur::ApplyBlurWithMask( uint8_t* bgraPixels, uint32_t width, uint32_t height, int blurRadius )
{
const size_t frameBytes = static_cast<size_t>( width ) * height * 4;
m_blurredFrame.resize( frameBytes );
m_tempFrame.resize( frameBytes );
// The input is already capped at 960×540 by WebcamCapture, so blur
// directly — no need for a secondary downscale.
int effectiveRadius = (std::max)( 3, blurRadius );
// 2 iterations of box blur → approximate Gaussian.
HorizontalBoxBlur( bgraPixels, m_blurredFrame.data(), width, height, effectiveRadius );
VerticalBoxBlur( m_blurredFrame.data(), m_tempFrame.data(), width, height, effectiveRadius );
HorizontalBoxBlur( m_tempFrame.data(), m_blurredFrame.data(), width, height, effectiveRadius );
VerticalBoxBlur( m_blurredFrame.data(), m_tempFrame.data(), width, height, effectiveRadius );
// Blend pass with alpha support for smooth mask edges.
const uint8_t* blurData = m_tempFrame.data();
for( uint32_t y = 0; y < height; y++ )
{
uint8_t* dstRow = bgraPixels + static_cast<size_t>( y ) * width * 4;
const uint8_t* blurRow = blurData + static_cast<size_t>( y ) * width * 4;
const float* maskRow = m_mask.data() + static_cast<size_t>( y ) * width;
for( uint32_t x = 0; x < width; x++ )
{
float maskVal = maskRow[x];
// Fast path: fully person → keep original pixel untouched.
if( maskVal >= 1.0f )
continue;
uint8_t* dp = dstRow + x * 4;
const uint8_t* bp = blurRow + x * 4;
// Fast path: fully background → copy blurred pixel.
if( maskVal <= 0.0f )
{
*reinterpret_cast<uint32_t*>( dp ) = *reinterpret_cast<const uint32_t*>( bp );
continue;
}
// Edge pixel → alpha blend original and blurred.
float inv = 1.0f - maskVal;
dp[0] = static_cast<uint8_t>( dp[0] * maskVal + bp[0] * inv + 0.5f );
dp[1] = static_cast<uint8_t>( dp[1] * maskVal + bp[1] * inv + 0.5f );
dp[2] = static_cast<uint8_t>( dp[2] * maskVal + bp[2] * inv + 0.5f );
}
}
}
//----------------------------------------------------------------------------
// BackgroundBlur::SetBackgroundImage
//
// Loads an image file via WIC and stores it as a BGRA pixel buffer.
//----------------------------------------------------------------------------
bool BackgroundBlur::SetBackgroundImage( const wchar_t* imagePath )
{
m_bgImage.clear();
m_bgImageWidth = 0;
m_bgImageHeight = 0;
m_scaledBgImage.clear();
m_scaledBgW = 0;
m_scaledBgH = 0;
if( !imagePath || !*imagePath )
return false;
auto factory = wil::CoCreateInstance<IWICImagingFactory>( CLSID_WICImagingFactory );
if( !factory )
return false;
wil::com_ptr<IWICBitmapDecoder> decoder;
HRESULT hr = factory->CreateDecoderFromFilename(
imagePath, nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder );
if( FAILED( hr ) )
{
OutputDebug( L"[BackgroundBlur] Failed to decode image: %s (hr=0x%08X)\n", imagePath, hr );
return false;
}
wil::com_ptr<IWICBitmapFrameDecode> frame;
hr = decoder->GetFrame( 0, &frame );
if( FAILED( hr ) )
return false;
// Convert to BGRA 32bpp.
wil::com_ptr<IWICFormatConverter> converter;
hr = factory->CreateFormatConverter( &converter );
if( FAILED( hr ) )
return false;
hr = converter->Initialize(
frame.get(), GUID_WICPixelFormat32bppBGRA,
WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom );
if( FAILED( hr ) )
return false;
UINT w = 0, h = 0;
converter->GetSize( &w, &h );
if( w == 0 || h == 0 )
return false;
m_bgImage.resize( static_cast<size_t>( w ) * h * 4 );
hr = converter->CopyPixels( nullptr, w * 4, static_cast<UINT>( m_bgImage.size() ), m_bgImage.data() );
if( FAILED( hr ) )
{
m_bgImage.clear();
return false;
}
m_bgImageWidth = w;
m_bgImageHeight = h;
OutputDebug( L"[BackgroundBlur] Background image loaded: %ux%u from %s\n", w, h, imagePath );
return true;
}
//----------------------------------------------------------------------------
// BackgroundBlur::EnsureScaledBgImage
//
// Scales the loaded background image to the specified dimensions using
// nearest-neighbor. The result is cached and only recomputed when the
// target dimensions change. The image is center-cropped to preserve
// aspect ratio (like "cover" scaling).
//----------------------------------------------------------------------------
void BackgroundBlur::EnsureScaledBgImage( uint32_t width, uint32_t height )
{
if( m_scaledBgW == width && m_scaledBgH == height && !m_scaledBgImage.empty() )
return;
m_scaledBgImage.resize( static_cast<size_t>( width ) * height * 4 );
m_scaledBgW = width;
m_scaledBgH = height;
// Compute center-crop of the source image to match the target aspect ratio.
double targetAspect = static_cast<double>( width ) / height;
double srcAspect = static_cast<double>( m_bgImageWidth ) / m_bgImageHeight;
uint32_t cropW, cropH, cropX, cropY;
if( srcAspect > targetAspect )
{
// Source is wider — crop horizontally.
cropH = m_bgImageHeight;
cropW = static_cast<uint32_t>( m_bgImageHeight * targetAspect + 0.5 );
cropX = ( m_bgImageWidth - cropW ) / 2;
cropY = 0;
}
else
{
// Source is taller — crop vertically.
cropW = m_bgImageWidth;
cropH = static_cast<uint32_t>( m_bgImageWidth / targetAspect + 0.5 );
cropX = 0;
cropY = ( m_bgImageHeight - cropH ) / 2;
}
for( uint32_t y = 0; y < height; y++ )
{
uint32_t srcY = cropY + y * cropH / height;
for( uint32_t x = 0; x < width; x++ )
{
uint32_t srcX = cropX + x * cropW / width;
size_t srcIdx = ( static_cast<size_t>( srcY ) * m_bgImageWidth + srcX ) * 4;
size_t dstIdx = ( static_cast<size_t>( y ) * width + x ) * 4;
m_scaledBgImage[dstIdx + 0] = m_bgImage[srcIdx + 0];
m_scaledBgImage[dstIdx + 1] = m_bgImage[srcIdx + 1];
m_scaledBgImage[dstIdx + 2] = m_bgImage[srcIdx + 2];
m_scaledBgImage[dstIdx + 3] = 0xFF;
}
}
}
//----------------------------------------------------------------------------
// BackgroundBlur::ApplyImageWithMask
//
// Replaces background pixels with the loaded background image using the
// segmentation mask. Person pixels are preserved, background pixels come
// from the scaled image.
//----------------------------------------------------------------------------
void BackgroundBlur::ApplyImageWithMask( uint8_t* bgraPixels, uint32_t width, uint32_t height )
{
EnsureScaledBgImage( width, height );
const uint8_t* bgData = m_scaledBgImage.data();
for( uint32_t y = 0; y < height; y++ )
{
uint8_t* dstRow = bgraPixels + static_cast<size_t>( y ) * width * 4;
const uint8_t* bgRow = bgData + static_cast<size_t>( y ) * width * 4;
const float* maskRow = m_mask.data() + static_cast<size_t>( y ) * width;
for( uint32_t x = 0; x < width; x++ )
{
float maskVal = maskRow[x];
// Fully person → keep original pixel.
if( maskVal >= 1.0f )
continue;
uint8_t* dp = dstRow + x * 4;
const uint8_t* bp = bgRow + x * 4;
// Fully background → copy background image pixel.
if( maskVal <= 0.0f )
{
*reinterpret_cast<uint32_t*>( dp ) = *reinterpret_cast<const uint32_t*>( bp );
continue;
}
// Edge pixel → alpha blend person and background image.
float inv = 1.0f - maskVal;
dp[0] = static_cast<uint8_t>( dp[0] * maskVal + bp[0] * inv + 0.5f );
dp[1] = static_cast<uint8_t>( dp[1] * maskVal + bp[1] * inv + 0.5f );
dp[2] = static_cast<uint8_t>( dp[2] * maskVal + bp[2] * inv + 0.5f );
}
}
}
//----------------------------------------------------------------------------
// BackgroundBlur::ShouldRunInference
//
// Decides whether segmentation inference should run this frame.
// Uses a combination of periodic fallback and motion detection:
// motion is estimated by comparing luminance at a sparse grid of
// sample points with the previous frame. When the scene changes
// quickly (fast head movement), inference runs every frame.
//----------------------------------------------------------------------------
bool BackgroundBlur::ShouldRunInference( const uint8_t* bgraPixels, uint32_t width, uint32_t height )
{
// Always run if no cached mask or dimensions changed.
if( !m_hasCachedMask || m_lastMaskWidth != width || m_lastMaskHeight != height )
return true;
// Periodic fallback: run at least every N frames.
const uint32_t pixels = width * height;
const int inferenceInterval = ( pixels > 500000 ) ? 6 : 3;
if( ( m_frameCounter % inferenceInterval ) == 0 )
return true;
// Motion detection: sample luminance at a sparse grid and compare
// with the previous frame.
constexpr int gridSize = MOTION_GRID_SIZE;
constexpr int numSamples = gridSize * gridSize;
float curSamples[numSamples];
for( int gy = 0; gy < gridSize; gy++ )
{
uint32_t sy = ( gy * 2 + 1 ) * height / ( gridSize * 2 );
for( int gx = 0; gx < gridSize; gx++ )
{
uint32_t sx = ( gx * 2 + 1 ) * width / ( gridSize * 2 );
const uint8_t* px = bgraPixels + ( static_cast<size_t>( sy ) * width + sx ) * 4;
curSamples[gy * gridSize + gx] = 0.299f * px[2] + 0.587f * px[1] + 0.114f * px[0];
}
}
float motionScore = 0.0f;
if( m_hasPrevSamples )
{
for( int i = 0; i < numSamples; i++ )
{
float diff = curSamples[i] - m_prevSamples[i];
motionScore += diff > 0.0f ? diff : -diff;
}
motionScore /= numSamples;
}
memcpy( m_prevSamples, curSamples, sizeof( curSamples ) );
m_hasPrevSamples = true;
// Average per-sample luminance change > 5/255 indicates significant motion.
return motionScore > 5.0f;
}
//----------------------------------------------------------------------------
// BackgroundBlur::ApplyImageReplacement
//
// Main entry point for background image replacement mode.
//----------------------------------------------------------------------------
bool BackgroundBlur::ApplyImageReplacement( uint8_t* bgraPixels, uint32_t width, uint32_t height )
{
if( !m_session || !bgraPixels || width == 0 || height == 0 )
return false;
if( m_bgImage.empty() )
return false;
if( ShouldRunInference( bgraPixels, width, height ) )
{
if( !RunSegmentation( bgraPixels, width, height ) )
return false;
m_lastMaskWidth = width;
m_lastMaskHeight = height;
m_hasCachedMask = true;
}
m_frameCounter++;
ApplyImageWithMask( bgraPixels, width, height );
return true;
}
//----------------------------------------------------------------------------
// BackgroundBlur::Apply
//
// Main entry point: runs segmentation and applies blur to the background.
//----------------------------------------------------------------------------
bool BackgroundBlur::Apply( uint8_t* bgraPixels, uint32_t width, uint32_t height, int blurRadius )
{
if( !m_session || !bgraPixels || width == 0 || height == 0 )
return false;
if( ShouldRunInference( bgraPixels, width, height ) )
{
if( !RunSegmentation( bgraPixels, width, height ) )
return false;
m_lastMaskWidth = width;
m_lastMaskHeight = height;
m_hasCachedMask = true;
}
m_frameCounter++;
ApplyBlurWithMask( bgraPixels, width, height, blurRadius );
return true;
}
//----------------------------------------------------------------------------
// BackgroundBlur::RunSegmentationOnly
//
// Runs the segmentation model and produces the mask, but does NOT blur
// or modify the pixel buffer. Used when the GPU compute shader will
// perform the box blur instead of the CPU.
//----------------------------------------------------------------------------
bool BackgroundBlur::RunSegmentationOnly( const uint8_t* bgraPixels, uint32_t width, uint32_t height )
{
if( !m_session || !bgraPixels || width == 0 || height == 0 )
return false;
if( ShouldRunInference( bgraPixels, width, height ) )
{
// Model-resolution only: skip CPU upscale+feather at frame
// resolution — the GPU bilinear sampler handles that.
if( !RunSegmentation( bgraPixels, width, height, /*modelResOnly=*/ true ) )
return false;
m_lastMaskWidth = width;
m_lastMaskHeight = height;
m_hasCachedMask = true;
}
m_frameCounter++;
return m_hasCachedMask;
}

View File

@@ -0,0 +1,160 @@
//==============================================================================
//
// BackgroundBlur.h
//
// Performs person segmentation using Windows ML (Windows.AI.MachineLearning)
// and applies either a Gaussian blur or a custom background image to the
// background of a BGRA webcam frame. The segmentation model runs on CPU
// via the Windows ML default device, keeping the GPU free for recording.
//
// Copyright (C) Mark Russinovich
// Sysinternals - www.sysinternals.com
//
//==============================================================================
#pragma once
#include <vector>
#include <string>
#include <cstdint>
#include <winrt/Windows.AI.MachineLearning.h>
// Background processing mode for the webcam overlay.
enum class WebcamBackgroundMode : uint32_t
{
None = 0, // No background processing
Blur = 1, // Gaussian blur on the background
Image = 2, // Replace background with a user-chosen image
};
class BackgroundBlur
{
public:
BackgroundBlur() = default;
~BackgroundBlur() = default;
// Initialize the ONNX model. modelPath must point to a valid .onnx
// segmentation model file. Returns true on success.
bool Initialize( const wchar_t* modelPath );
// Load a background replacement image from the given file path.
// The image is decoded via WIC and stored as a BGRA buffer.
// Returns true on success.
bool SetBackgroundImage( const wchar_t* imagePath );
// Returns true if a background image has been loaded.
bool HasBackgroundImage() const { return !m_bgImage.empty(); }
// Apply background blur to a BGRA pixel buffer in-place.
// width/height are the frame dimensions. blurRadius controls
// the strength of the Gaussian blur (in pixels).
// Returns true if segmentation + blur was applied successfully.
bool Apply( uint8_t* bgraPixels, uint32_t width, uint32_t height, int blurRadius = 21 );
// Apply background image replacement to a BGRA pixel buffer in-place.
// Uses the previously loaded background image (via SetBackgroundImage).
// Returns true if segmentation + image replacement was applied.
bool ApplyImageReplacement( uint8_t* bgraPixels, uint32_t width, uint32_t height );
// Returns true if the model has been loaded successfully.
bool IsInitialized() const { return m_session != nullptr; }
// Access the segmentation mask after Apply()/ApplyImageReplacement().
// The mask has one float [0..1] per pixel at the processing resolution
// (1.0 = person / foreground, 0.0 = background).
const std::vector<float>& GetMask() const { return m_mask; }
uint32_t GetMaskWidth() const { return m_lastMaskWidth; }
uint32_t GetMaskHeight() const { return m_lastMaskHeight; }
bool HasCachedMask() const { return m_hasCachedMask; }
// Run segmentation only (no CPU blur or mask blend). Use this when
// the blur will be performed on the GPU via a compute shader.
// Populates the mask (GetMask) but does NOT touch bgraPixels.
bool RunSegmentationOnly( const uint8_t* bgraPixels, uint32_t width, uint32_t height );
// Access the fully-blurred frame after Apply().
// Contains all pixels blurred (before mask-based compositing).
// Only valid after Apply() — NOT after ApplyImageReplacement().
const std::vector<uint8_t>& GetBlurredFrame() const { return m_tempFrame; }
// Access the model-resolution mask before upscaling (e.g. 256×256).
// Useful when the GPU handles upscaling via hardware bilinear filtering.
const std::vector<float>& GetModelMask() const { return m_erodeBuf; }
int64_t GetModelMaskWidth() const { return m_modelOutputWidth; }
int64_t GetModelMaskHeight() const { return m_modelOutputHeight; }
private:
// Run the segmentation model and produce a float mask [0..1] per pixel.
// When modelResOnly is true, stops after model-resolution post-processing
// (feathering + temporal smoothing at 256×256) and skips the CPU upscale
// to frame resolution — the GPU bilinear sampler handles that instead.
bool RunSegmentation( const uint8_t* bgraPixels, uint32_t width, uint32_t height,
bool modelResOnly = false );
// Apply box blur (iterated for Gaussian approximation) to bgraPixels
// only where the mask indicates background.
void ApplyBlurWithMask( uint8_t* bgraPixels, uint32_t width, uint32_t height, int blurRadius );
// Replace background pixels with the loaded background image.
void ApplyImageWithMask( uint8_t* bgraPixels, uint32_t width, uint32_t height );
// Scale the loaded background image to the given dimensions (cached).
void EnsureScaledBgImage( uint32_t width, uint32_t height );
// Decide whether inference is needed this frame (periodic + motion-adaptive).
bool ShouldRunInference( const uint8_t* bgraPixels, uint32_t width, uint32_t height );
// Windows ML objects.
winrt::Windows::AI::MachineLearning::LearningModel m_model{ nullptr };
winrt::Windows::AI::MachineLearning::LearningModelSession m_session{ nullptr };
winrt::Windows::AI::MachineLearning::LearningModelBinding m_binding{ nullptr };
winrt::hstring m_inputName;
winrt::hstring m_outputName;
// Model metadata (detected from the loaded model).
int64_t m_modelInputWidth = 256;
int64_t m_modelInputHeight = 256;
int64_t m_modelInputChannels = 3;
bool m_inputIsNchw = true; // true = [1,C,H,W], false = [1,H,W,C]
bool m_usingGpu = false; // true if DirectML session is active
// Actual model output dimensions (may differ from input dimensions).
int64_t m_modelOutputWidth = 256;
int64_t m_modelOutputHeight = 256;
// Reusable buffers to avoid per-frame allocations.
std::vector<float> m_inputTensor; // RGB float [1,3,H,W] or [1,H,W,3]
std::vector<float> m_outputBuf; // Raw copy of output tensor data
std::vector<float> m_mask; // Segmentation mask [width*height]
std::vector<float> m_erodeBuf; // Model-resolution mask buffer
std::vector<float> m_maskBlurBuf; // Temp buffer for mask edge smoothing
std::vector<uint8_t> m_blurredFrame; // Temporary blurred copy
std::vector<uint8_t> m_tempFrame; // Second temp buffer for blur passes
// Background image (original resolution, BGRA).
std::vector<uint8_t> m_bgImage;
uint32_t m_bgImageWidth = 0;
uint32_t m_bgImageHeight = 0;
// Scaled background image (cached at overlay dimensions).
std::vector<uint8_t> m_scaledBgImage;
uint32_t m_scaledBgW = 0;
uint32_t m_scaledBgH = 0;
// Frame-skipping: reuse the segmentation mask for N frames.
int m_frameCounter = 0;
uint32_t m_lastMaskWidth = 0;
uint32_t m_lastMaskHeight = 0;
bool m_hasCachedMask = false;
// Motion detection: luminance samples from the previous frame.
static constexpr int MOTION_GRID_SIZE = 8; // 8×8 = 64 sample points
float m_prevSamples[MOTION_GRID_SIZE * MOTION_GRID_SIZE] = {};
bool m_hasPrevSamples = false;
// Temporal smoothing: previous frame's mask blended with current
// to stabilize edges and reduce flicker.
std::vector<float> m_prevMask;
// Model-resolution previous mask for GPU path temporal smoothing.
std::vector<float> m_prevModelMask;
};

View File

@@ -0,0 +1,606 @@
#if 0
//
// Generated by Microsoft (R) HLSL Shader Compiler 10.1
//
//
// Buffer Definitions:
//
// cbuffer BlurConstants
// {
//
// uint Direction; // Offset: 0 Size: 4
// int Radius; // Offset: 4 Size: 4
// uint Width; // Offset: 8 Size: 4
// uint Height; // Offset: 12 Size: 4
//
// }
//
//
// Resource Bindings:
//
// Name Type Format Dim HLSL Bind Count
// ------------------------------ ---------- ------- ----------- -------------- ------
// InputTex texture float4 2d t0 1
// OutputTex UAV float4 2d u0 1
// BlurConstants cbuffer NA NA cb0 1
//
//
//
// Input signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// no Input
//
// Output signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// no Output
cs_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer CB0[1], immediateIndexed
dcl_resource_texture2d (float,float,float,float) t0
dcl_uav_typed_texture2d (float,float,float,float) u0
dcl_input vThreadGroupID.xy
dcl_input vThreadIDInGroup.x
dcl_temps 4
dcl_tgsm_structured g0, 16, 320
dcl_thread_group 256, 1, 1
imin r0.x, cb0[0].y, l(32)
ishl r0.y, r0.x, l(1)
iadd r0.z, r0.y, l(256)
if_z cb0[0].x
uge r0.w, vThreadGroupID.y, cb0[0].w
if_nz r0.w
ret
endif
ishl r0.w, vThreadGroupID.x, l(8)
iadd r1.x, cb0[0].z, l(-1)
mov r2.y, vThreadGroupID.y
mov r2.zw, l(0,0,0,0)
mov r1.y, vThreadIDInGroup.x
loop
ige r1.z, r1.y, r0.z
breakc_nz r1.z
iadd r1.z, r0.w, r1.y
iadd r1.z, -r0.x, r1.z
imax r1.z, r1.z, l(0)
imin r2.x, r1.x, r1.z
ld_indexable(texture2d)(float,float,float,float) r3.xyzw, r2.xyzw, t0.xyzw
store_structured g0.xyzw, r1.y, l(0), r3.xyzw
iadd r1.y, r1.y, l(256)
endloop
sync_g_t
imad r1.x, vThreadGroupID.x, l(256), vThreadIDInGroup.x
uge r0.w, r1.x, cb0[0].z
if_nz r0.w
ret
endif
mov r2.xyzw, l(0,0,0,0)
mov r0.w, l(0)
loop
ilt r3.x, r0.y, r0.w
breakc_nz r3.x
iadd r3.x, r0.w, vThreadIDInGroup.x
ld_structured r3.xyzw, r3.x, l(0), g0.xyzw
add r2.xyzw, r2.xyzw, r3.xyzw
iadd r0.w, r0.w, l(1)
endloop
bfi r0.w, l(31), l(1), r0.x, l(1)
itof r0.w, r0.w
div r2.xyzw, r2.xyzw, r0.wwww
mov r1.yzw, vThreadGroupID.yyyy
store_uav_typed u0.xyzw, r1.xyzw, r2.xyzw
else
uge r0.w, vThreadGroupID.y, cb0[0].z
if_nz r0.w
ret
endif
ishl r0.w, vThreadGroupID.x, l(8)
iadd r1.x, cb0[0].w, l(-1)
mov r2.x, vThreadGroupID.y
mov r2.zw, l(0,0,0,0)
mov r1.y, vThreadIDInGroup.x
loop
ige r1.z, r1.y, r0.z
breakc_nz r1.z
iadd r1.z, r0.w, r1.y
iadd r1.z, -r0.x, r1.z
imax r1.z, r1.z, l(0)
imin r2.y, r1.x, r1.z
ld_indexable(texture2d)(float,float,float,float) r3.xyzw, r2.xyzw, t0.xyzw
store_structured g0.xyzw, r1.y, l(0), r3.xyzw
iadd r1.y, r1.y, l(256)
endloop
sync_g_t
imad r1.yzw, vThreadGroupID.xxxx, l(0, 256, 256, 256), vThreadIDInGroup.xxxx
uge r0.z, r1.w, cb0[0].w
if_nz r0.z
ret
endif
mov r2.xyzw, l(0,0,0,0)
mov r0.z, l(0)
loop
ilt r0.w, r0.y, r0.z
breakc_nz r0.w
iadd r0.w, r0.z, vThreadIDInGroup.x
ld_structured r3.xyzw, r0.w, l(0), g0.xyzw
add r2.xyzw, r2.xyzw, r3.xyzw
iadd r0.z, r0.z, l(1)
endloop
bfi r0.x, l(31), l(1), r0.x, l(1)
itof r0.x, r0.x
div r0.xyzw, r2.xyzw, r0.xxxx
mov r1.x, vThreadGroupID.y
store_uav_typed u0.xyzw, r1.xyzw, r0.xyzw
endif
ret
// Approximately 89 instruction slots used
#endif
const BYTE g_BoxBlurCS[] =
{
68, 88, 66, 67, 109, 156,
172, 79, 78, 198, 69, 61,
87, 5, 27, 232, 85, 229,
69, 181, 1, 0, 0, 0,
212, 10, 0, 0, 5, 0,
0, 0, 52, 0, 0, 0,
80, 2, 0, 0, 96, 2,
0, 0, 112, 2, 0, 0,
56, 10, 0, 0, 82, 68,
69, 70, 20, 2, 0, 0,
1, 0, 0, 0, 192, 0,
0, 0, 3, 0, 0, 0,
60, 0, 0, 0, 0, 5,
83, 67, 0, 1, 0, 0,
233, 1, 0, 0, 82, 68,
49, 49, 60, 0, 0, 0,
24, 0, 0, 0, 32, 0,
0, 0, 40, 0, 0, 0,
36, 0, 0, 0, 12, 0,
0, 0, 0, 0, 0, 0,
156, 0, 0, 0, 2, 0,
0, 0, 5, 0, 0, 0,
4, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
1, 0, 0, 0, 13, 0,
0, 0, 165, 0, 0, 0,
4, 0, 0, 0, 5, 0,
0, 0, 4, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 1, 0, 0, 0,
13, 0, 0, 0, 175, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0,
0, 0, 1, 0, 0, 0,
73, 110, 112, 117, 116, 84,
101, 120, 0, 79, 117, 116,
112, 117, 116, 84, 101, 120,
0, 66, 108, 117, 114, 67,
111, 110, 115, 116, 97, 110,
116, 115, 0, 171, 171, 171,
175, 0, 0, 0, 4, 0,
0, 0, 216, 0, 0, 0,
16, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
120, 1, 0, 0, 0, 0,
0, 0, 4, 0, 0, 0,
2, 0, 0, 0, 136, 1,
0, 0, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 172, 1,
0, 0, 4, 0, 0, 0,
4, 0, 0, 0, 2, 0,
0, 0, 184, 1, 0, 0,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 220, 1, 0, 0,
8, 0, 0, 0, 4, 0,
0, 0, 2, 0, 0, 0,
136, 1, 0, 0, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
226, 1, 0, 0, 12, 0,
0, 0, 4, 0, 0, 0,
2, 0, 0, 0, 136, 1,
0, 0, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 68, 105,
114, 101, 99, 116, 105, 111,
110, 0, 100, 119, 111, 114,
100, 0, 0, 0, 19, 0,
1, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 130, 1,
0, 0, 82, 97, 100, 105,
117, 115, 0, 105, 110, 116,
0, 171, 0, 0, 2, 0,
1, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 179, 1,
0, 0, 87, 105, 100, 116,
104, 0, 72, 101, 105, 103,
104, 116, 0, 77, 105, 99,
114, 111, 115, 111, 102, 116,
32, 40, 82, 41, 32, 72,
76, 83, 76, 32, 83, 104,
97, 100, 101, 114, 32, 67,
111, 109, 112, 105, 108, 101,
114, 32, 49, 48, 46, 49,
0, 171, 171, 171, 73, 83,
71, 78, 8, 0, 0, 0,
0, 0, 0, 0, 8, 0,
0, 0, 79, 83, 71, 78,
8, 0, 0, 0, 0, 0,
0, 0, 8, 0, 0, 0,
83, 72, 69, 88, 192, 7,
0, 0, 80, 0, 5, 0,
240, 1, 0, 0, 106, 8,
0, 1, 89, 0, 0, 4,
70, 142, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
88, 24, 0, 4, 0, 112,
16, 0, 0, 0, 0, 0,
85, 85, 0, 0, 156, 24,
0, 4, 0, 224, 17, 0,
0, 0, 0, 0, 85, 85,
0, 0, 95, 0, 0, 2,
50, 16, 2, 0, 95, 0,
0, 2, 18, 32, 2, 0,
104, 0, 0, 2, 4, 0,
0, 0, 160, 0, 0, 5,
0, 240, 17, 0, 0, 0,
0, 0, 16, 0, 0, 0,
64, 1, 0, 0, 155, 0,
0, 4, 0, 1, 0, 0,
1, 0, 0, 0, 1, 0,
0, 0, 37, 0, 0, 8,
18, 0, 16, 0, 0, 0,
0, 0, 26, 128, 32, 0,
0, 0, 0, 0, 0, 0,
0, 0, 1, 64, 0, 0,
32, 0, 0, 0, 41, 0,
0, 7, 34, 0, 16, 0,
0, 0, 0, 0, 10, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 1, 0,
0, 0, 30, 0, 0, 7,
66, 0, 16, 0, 0, 0,
0, 0, 26, 0, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 0, 1, 0, 0,
31, 0, 0, 4, 10, 128,
32, 0, 0, 0, 0, 0,
0, 0, 0, 0, 80, 0,
0, 7, 130, 0, 16, 0,
0, 0, 0, 0, 26, 16,
2, 0, 58, 128, 32, 0,
0, 0, 0, 0, 0, 0,
0, 0, 31, 0, 4, 3,
58, 0, 16, 0, 0, 0,
0, 0, 62, 0, 0, 1,
21, 0, 0, 1, 41, 0,
0, 6, 130, 0, 16, 0,
0, 0, 0, 0, 10, 16,
2, 0, 1, 64, 0, 0,
8, 0, 0, 0, 30, 0,
0, 8, 18, 0, 16, 0,
1, 0, 0, 0, 42, 128,
32, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 64,
0, 0, 255, 255, 255, 255,
54, 0, 0, 4, 34, 0,
16, 0, 2, 0, 0, 0,
26, 16, 2, 0, 54, 0,
0, 8, 194, 0, 16, 0,
2, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
54, 0, 0, 4, 34, 0,
16, 0, 1, 0, 0, 0,
10, 32, 2, 0, 48, 0,
0, 1, 33, 0, 0, 7,
66, 0, 16, 0, 1, 0,
0, 0, 26, 0, 16, 0,
1, 0, 0, 0, 42, 0,
16, 0, 0, 0, 0, 0,
3, 0, 4, 3, 42, 0,
16, 0, 1, 0, 0, 0,
30, 0, 0, 7, 66, 0,
16, 0, 1, 0, 0, 0,
58, 0, 16, 0, 0, 0,
0, 0, 26, 0, 16, 0,
1, 0, 0, 0, 30, 0,
0, 8, 66, 0, 16, 0,
1, 0, 0, 0, 10, 0,
16, 128, 65, 0, 0, 0,
0, 0, 0, 0, 42, 0,
16, 0, 1, 0, 0, 0,
36, 0, 0, 7, 66, 0,
16, 0, 1, 0, 0, 0,
42, 0, 16, 0, 1, 0,
0, 0, 1, 64, 0, 0,
0, 0, 0, 0, 37, 0,
0, 7, 18, 0, 16, 0,
2, 0, 0, 0, 10, 0,
16, 0, 1, 0, 0, 0,
42, 0, 16, 0, 1, 0,
0, 0, 45, 0, 0, 137,
194, 0, 0, 128, 67, 85,
21, 0, 242, 0, 16, 0,
3, 0, 0, 0, 70, 14,
16, 0, 2, 0, 0, 0,
70, 126, 16, 0, 0, 0,
0, 0, 168, 0, 0, 9,
242, 240, 17, 0, 0, 0,
0, 0, 26, 0, 16, 0,
1, 0, 0, 0, 1, 64,
0, 0, 0, 0, 0, 0,
70, 14, 16, 0, 3, 0,
0, 0, 30, 0, 0, 7,
34, 0, 16, 0, 1, 0,
0, 0, 26, 0, 16, 0,
1, 0, 0, 0, 1, 64,
0, 0, 0, 1, 0, 0,
22, 0, 0, 1, 190, 24,
0, 1, 35, 0, 0, 7,
18, 0, 16, 0, 1, 0,
0, 0, 10, 16, 2, 0,
1, 64, 0, 0, 0, 1,
0, 0, 10, 32, 2, 0,
80, 0, 0, 8, 130, 0,
16, 0, 0, 0, 0, 0,
10, 0, 16, 0, 1, 0,
0, 0, 42, 128, 32, 0,
0, 0, 0, 0, 0, 0,
0, 0, 31, 0, 4, 3,
58, 0, 16, 0, 0, 0,
0, 0, 62, 0, 0, 1,
21, 0, 0, 1, 54, 0,
0, 8, 242, 0, 16, 0,
2, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
54, 0, 0, 5, 130, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 0, 0,
0, 0, 48, 0, 0, 1,
34, 0, 0, 7, 18, 0,
16, 0, 3, 0, 0, 0,
26, 0, 16, 0, 0, 0,
0, 0, 58, 0, 16, 0,
0, 0, 0, 0, 3, 0,
4, 3, 10, 0, 16, 0,
3, 0, 0, 0, 30, 0,
0, 6, 18, 0, 16, 0,
3, 0, 0, 0, 58, 0,
16, 0, 0, 0, 0, 0,
10, 32, 2, 0, 167, 0,
0, 9, 242, 0, 16, 0,
3, 0, 0, 0, 10, 0,
16, 0, 3, 0, 0, 0,
1, 64, 0, 0, 0, 0,
0, 0, 70, 254, 17, 0,
0, 0, 0, 0, 0, 0,
0, 7, 242, 0, 16, 0,
2, 0, 0, 0, 70, 14,
16, 0, 2, 0, 0, 0,
70, 14, 16, 0, 3, 0,
0, 0, 30, 0, 0, 7,
130, 0, 16, 0, 0, 0,
0, 0, 58, 0, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 1, 0, 0, 0,
22, 0, 0, 1, 140, 0,
0, 11, 130, 0, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 31, 0, 0, 0,
1, 64, 0, 0, 1, 0,
0, 0, 10, 0, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 1, 0, 0, 0,
43, 0, 0, 5, 130, 0,
16, 0, 0, 0, 0, 0,
58, 0, 16, 0, 0, 0,
0, 0, 14, 0, 0, 7,
242, 0, 16, 0, 2, 0,
0, 0, 70, 14, 16, 0,
2, 0, 0, 0, 246, 15,
16, 0, 0, 0, 0, 0,
54, 0, 0, 4, 226, 0,
16, 0, 1, 0, 0, 0,
86, 21, 2, 0, 164, 0,
0, 7, 242, 224, 17, 0,
0, 0, 0, 0, 70, 14,
16, 0, 1, 0, 0, 0,
70, 14, 16, 0, 2, 0,
0, 0, 18, 0, 0, 1,
80, 0, 0, 7, 130, 0,
16, 0, 0, 0, 0, 0,
26, 16, 2, 0, 42, 128,
32, 0, 0, 0, 0, 0,
0, 0, 0, 0, 31, 0,
4, 3, 58, 0, 16, 0,
0, 0, 0, 0, 62, 0,
0, 1, 21, 0, 0, 1,
41, 0, 0, 6, 130, 0,
16, 0, 0, 0, 0, 0,
10, 16, 2, 0, 1, 64,
0, 0, 8, 0, 0, 0,
30, 0, 0, 8, 18, 0,
16, 0, 1, 0, 0, 0,
58, 128, 32, 0, 0, 0,
0, 0, 0, 0, 0, 0,
1, 64, 0, 0, 255, 255,
255, 255, 54, 0, 0, 4,
18, 0, 16, 0, 2, 0,
0, 0, 26, 16, 2, 0,
54, 0, 0, 8, 194, 0,
16, 0, 2, 0, 0, 0,
2, 64, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 54, 0, 0, 4,
34, 0, 16, 0, 1, 0,
0, 0, 10, 32, 2, 0,
48, 0, 0, 1, 33, 0,
0, 7, 66, 0, 16, 0,
1, 0, 0, 0, 26, 0,
16, 0, 1, 0, 0, 0,
42, 0, 16, 0, 0, 0,
0, 0, 3, 0, 4, 3,
42, 0, 16, 0, 1, 0,
0, 0, 30, 0, 0, 7,
66, 0, 16, 0, 1, 0,
0, 0, 58, 0, 16, 0,
0, 0, 0, 0, 26, 0,
16, 0, 1, 0, 0, 0,
30, 0, 0, 8, 66, 0,
16, 0, 1, 0, 0, 0,
10, 0, 16, 128, 65, 0,
0, 0, 0, 0, 0, 0,
42, 0, 16, 0, 1, 0,
0, 0, 36, 0, 0, 7,
66, 0, 16, 0, 1, 0,
0, 0, 42, 0, 16, 0,
1, 0, 0, 0, 1, 64,
0, 0, 0, 0, 0, 0,
37, 0, 0, 7, 34, 0,
16, 0, 2, 0, 0, 0,
10, 0, 16, 0, 1, 0,
0, 0, 42, 0, 16, 0,
1, 0, 0, 0, 45, 0,
0, 137, 194, 0, 0, 128,
67, 85, 21, 0, 242, 0,
16, 0, 3, 0, 0, 0,
70, 14, 16, 0, 2, 0,
0, 0, 70, 126, 16, 0,
0, 0, 0, 0, 168, 0,
0, 9, 242, 240, 17, 0,
0, 0, 0, 0, 26, 0,
16, 0, 1, 0, 0, 0,
1, 64, 0, 0, 0, 0,
0, 0, 70, 14, 16, 0,
3, 0, 0, 0, 30, 0,
0, 7, 34, 0, 16, 0,
1, 0, 0, 0, 26, 0,
16, 0, 1, 0, 0, 0,
1, 64, 0, 0, 0, 1,
0, 0, 22, 0, 0, 1,
190, 24, 0, 1, 35, 0,
0, 10, 226, 0, 16, 0,
1, 0, 0, 0, 6, 16,
2, 0, 2, 64, 0, 0,
0, 0, 0, 0, 0, 1,
0, 0, 0, 1, 0, 0,
0, 1, 0, 0, 6, 32,
2, 0, 80, 0, 0, 8,
66, 0, 16, 0, 0, 0,
0, 0, 58, 0, 16, 0,
1, 0, 0, 0, 58, 128,
32, 0, 0, 0, 0, 0,
0, 0, 0, 0, 31, 0,
4, 3, 42, 0, 16, 0,
0, 0, 0, 0, 62, 0,
0, 1, 21, 0, 0, 1,
54, 0, 0, 8, 242, 0,
16, 0, 2, 0, 0, 0,
2, 64, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 54, 0, 0, 5,
66, 0, 16, 0, 0, 0,
0, 0, 1, 64, 0, 0,
0, 0, 0, 0, 48, 0,
0, 1, 34, 0, 0, 7,
130, 0, 16, 0, 0, 0,
0, 0, 26, 0, 16, 0,
0, 0, 0, 0, 42, 0,
16, 0, 0, 0, 0, 0,
3, 0, 4, 3, 58, 0,
16, 0, 0, 0, 0, 0,
30, 0, 0, 6, 130, 0,
16, 0, 0, 0, 0, 0,
42, 0, 16, 0, 0, 0,
0, 0, 10, 32, 2, 0,
167, 0, 0, 9, 242, 0,
16, 0, 3, 0, 0, 0,
58, 0, 16, 0, 0, 0,
0, 0, 1, 64, 0, 0,
0, 0, 0, 0, 70, 254,
17, 0, 0, 0, 0, 0,
0, 0, 0, 7, 242, 0,
16, 0, 2, 0, 0, 0,
70, 14, 16, 0, 2, 0,
0, 0, 70, 14, 16, 0,
3, 0, 0, 0, 30, 0,
0, 7, 66, 0, 16, 0,
0, 0, 0, 0, 42, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 1, 0,
0, 0, 22, 0, 0, 1,
140, 0, 0, 11, 18, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 31, 0,
0, 0, 1, 64, 0, 0,
1, 0, 0, 0, 10, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 1, 0,
0, 0, 43, 0, 0, 5,
18, 0, 16, 0, 0, 0,
0, 0, 10, 0, 16, 0,
0, 0, 0, 0, 14, 0,
0, 7, 242, 0, 16, 0,
0, 0, 0, 0, 70, 14,
16, 0, 2, 0, 0, 0,
6, 0, 16, 0, 0, 0,
0, 0, 54, 0, 0, 4,
18, 0, 16, 0, 1, 0,
0, 0, 26, 16, 2, 0,
164, 0, 0, 7, 242, 224,
17, 0, 0, 0, 0, 0,
70, 14, 16, 0, 1, 0,
0, 0, 70, 14, 16, 0,
0, 0, 0, 0, 21, 0,
0, 1, 62, 0, 0, 1,
83, 84, 65, 84, 148, 0,
0, 0, 89, 0, 0, 0,
4, 0, 0, 0, 0, 0,
0, 0, 2, 0, 0, 0,
4, 0, 0, 0, 27, 0,
0, 0, 4, 0, 0, 0,
7, 0, 0, 0, 8, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 2, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
12, 0, 0, 0, 0, 0,
0, 0, 2, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
2, 0, 0, 0, 0, 0,
0, 0, 2, 0, 0, 0
};

View File

@@ -0,0 +1,115 @@
//==============================================================================
//
// BoxBlurCS.hlsl
//
// D3D11 compute shader for separable box blur. Each dispatch performs
// either a horizontal or a vertical pass controlled by the Direction
// constant. Run four dispatches (H→V→H→V) to approximate a Gaussian.
//
// Uses group-shared memory so each texel is loaded once per thread
// group, then reused across the sliding window.
//
// Entry point:
// CSMain (cs_5_0)
//
// Recompile with:
// fxc /T cs_5_0 /E CSMain /Fh BoxBlurCS.h /Vn g_BoxBlurCS BoxBlurCS.hlsl
//
// Copyright (C) Mark Russinovich
// Sysinternals - www.sysinternals.com
//
//==============================================================================
// Input texture (read-only).
Texture2D<float4> InputTex : register(t0);
// Output texture (write-only).
RWTexture2D<float4> OutputTex : register(u0);
cbuffer BlurConstants : register(b0)
{
uint Direction; // 0 = horizontal, 1 = vertical
int Radius; // Box blur radius in pixels
uint Width; // Image width
uint Height; // Image height
};
// Thread group: 256 threads along the blur axis.
#define GROUP_SIZE 256
// Max radius we support. Shared memory = (GROUP_SIZE + 2*MAX_RADIUS) * 4 floats * 4 bytes
// = (256 + 64) * 16 = ~5 KB, well within the 32 KB limit.
#define MAX_RADIUS 32
// Shared memory tile: enough for the group + apron on both sides.
groupshared float4 tile[GROUP_SIZE + 2 * MAX_RADIUS];
[numthreads(GROUP_SIZE, 1, 1)]
void CSMain( uint3 groupId : SV_GroupID,
uint3 groupTid : SV_GroupThreadID,
uint3 dispatchId : SV_DispatchThreadID )
{
int r = min( Radius, MAX_RADIUS );
int tileSize = GROUP_SIZE + 2 * r;
int tid = (int)groupTid.x;
if( Direction == 0 )
{
// ── Horizontal pass ────────────────────────────────────────
int row = (int)groupId.y;
if( (uint)row >= Height )
return;
int groupStart = (int)groupId.x * GROUP_SIZE;
// Load tile: each thread loads its primary texel + apron.
for( int i = tid; i < tileSize; i += GROUP_SIZE )
{
int srcX = clamp( groupStart + i - r, 0, (int)Width - 1 );
tile[i] = InputTex[int2( srcX, row )];
}
GroupMemoryBarrierWithGroupSync();
int outX = groupStart + tid;
if( (uint)outX >= Width )
return;
// Sum the window from shared memory.
float4 sum = (float4)0;
int windowStart = tid; // tile index = tid + r - r
for( int k = 0; k <= 2 * r; k++ )
{
sum += tile[windowStart + k];
}
OutputTex[int2( outX, row )] = sum / (float)( 2 * r + 1 );
}
else
{
// ── Vertical pass ──────────────────────────────────────────
int col = (int)groupId.y;
if( (uint)col >= Width )
return;
int groupStart = (int)groupId.x * GROUP_SIZE;
// Load tile.
for( int i = tid; i < tileSize; i += GROUP_SIZE )
{
int srcY = clamp( groupStart + i - r, 0, (int)Height - 1 );
tile[i] = InputTex[int2( col, srcY )];
}
GroupMemoryBarrierWithGroupSync();
int outY = groupStart + tid;
if( (uint)outY >= Height )
return;
float4 sum = (float4)0;
int windowStart = tid;
for( int k = 0; k <= 2 * r; k++ )
{
sum += tile[windowStart + k];
}
OutputTex[int2( col, outY )] = sum / (float)( 2 * r + 1 );
}
}

View File

@@ -77,6 +77,7 @@ std::optional<CaptureFrame> CaptureFrameWait::TryGetNextFrame()
if (m_currentFrame != nullptr)
{
m_currentFrame.Close();
m_currentFrame = nullptr; // Prevent double-Close on subsequent calls
}
m_nextFrameEvent.ResetEvent();
@@ -107,6 +108,33 @@ std::optional<CaptureFrame> CaptureFrameWait::TryGetNextFrame()
}
//----------------------------------------------------------------------------
//
// CaptureFrameWait::PeekCurrentFrame
//
// Returns the frame that is currently held (if any) without closing
// it and without waiting for a new one. This is useful during
// recording startup: the constructor captured a frame when the
// session began, and OnMediaStreamSourceStarting can use it
// immediately instead of blocking until the next desktop change.
// The frame remains alive in the pool until the next
// TryGetNextFrame() call closes it.
//
//----------------------------------------------------------------------------
std::optional<CaptureFrame> CaptureFrameWait::PeekCurrentFrame() const
{
if (m_currentFrame != nullptr)
{
return std::optional<CaptureFrame>(
{
m_currentFrame.Surface(),
m_currentFrame.ContentSize(),
m_currentFrame.SystemRelativeTime(),
});
}
return std::nullopt;
}
//----------------------------------------------------------------------------
//
// CaptureFrameWait::TryGetNextFrame (with timeout)
@@ -121,6 +149,7 @@ std::optional<CaptureFrame> CaptureFrameWait::TryGetNextFrame( DWORD timeoutMs )
if( m_currentFrame != nullptr )
{
m_currentFrame.Close();
m_currentFrame = nullptr; // Prevent double-Close on subsequent calls
}
m_nextFrameEvent.ResetEvent();

View File

@@ -108,6 +108,7 @@ public:
std::optional<CaptureFrame> TryGetNextFrame();
std::optional<CaptureFrame> TryGetNextFrame( DWORD timeoutMs );
std::optional<CaptureFrame> PeekCurrentFrame() const;
void StopCapture();
void EnableCursorCapture( bool enable = true )
{

View File

@@ -0,0 +1,113 @@
#include "pch.h"
#include "NoiseSuppressor.h"
extern "C" {
#include "rnnoise/rnnoise.h"
}
// RNNoise processes 480 mono samples per frame (10ms at 48kHz)
static constexpr uint32_t RNNOISE_FRAME_SIZE = 480;
// RNNoise expects samples in PCM16 range (-32768 to 32767), not normalized float [-1, 1]
static constexpr float PCM16_SCALE = 32768.0f;
static constexpr float PCM16_SCALE_INV = 1.0f / 32768.0f;
NoiseSuppressor::NoiseSuppressor()
{
}
NoiseSuppressor::~NoiseSuppressor()
{
for (auto& channel : m_channels)
{
if (channel.state)
{
rnnoise_destroy(channel.state);
}
}
}
void NoiseSuppressor::EnsureChannelCount(uint32_t channels)
{
if (m_channels.size() == channels)
{
return;
}
// Channel count changed (or first call): rebuild per-channel RNNoise state.
for (auto& channel : m_channels)
{
if (channel.state)
{
rnnoise_destroy(channel.state);
}
}
m_channels.clear();
m_channels.resize(channels);
for (auto& channel : m_channels)
{
channel.state = rnnoise_create(nullptr);
}
}
void NoiseSuppressor::Process(float* samples, uint32_t sampleCount, uint32_t channels)
{
if (sampleCount == 0 || channels == 0)
{
return;
}
EnsureChannelCount(channels);
uint32_t frameCount = sampleCount / channels;
// Denoise each channel independently so the original channel layout is
// preserved (e.g. a mic wired only to the left channel stays on the left
// and silent channels stay silent instead of being filled with the voice).
for (uint32_t ch = 0; ch < channels; ch++)
{
ChannelState& channel = m_channels[ch];
if (!channel.state)
{
continue;
}
uint32_t residualCount = static_cast<uint32_t>(channel.residual.size());
uint32_t totalSamples = residualCount + frameCount;
channel.work.resize(totalSamples);
// Copy residual from previous call
if (residualCount > 0)
{
memcpy(channel.work.data(), channel.residual.data(), residualCount * sizeof(float));
}
// Deinterleave this channel and scale to PCM16 range for RNNoise
for (uint32_t i = 0; i < frameCount; i++)
{
channel.work[residualCount + i] = samples[i * channels + ch] * PCM16_SCALE;
}
// Process complete 480-sample frames through RNNoise
uint32_t processedSamples = 0;
while (processedSamples + RNNOISE_FRAME_SIZE <= totalSamples)
{
rnnoise_process_frame(channel.state, &channel.work[processedSamples], &channel.work[processedSamples]);
processedSamples += RNNOISE_FRAME_SIZE;
}
// Save unprocessed residual for next call
channel.residual.assign(
channel.work.begin() + processedSamples,
channel.work.end());
// Write denoised samples back to the interleaved buffer, scaling back to
// normalized float. Only this call's input region is written back.
for (uint32_t i = 0; i < frameCount; i++)
{
samples[i * channels + ch] = channel.work[residualCount + i] * PCM16_SCALE_INV;
}
}
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <vector>
#include <stdint.h>
struct DenoiseState;
class NoiseSuppressor
{
public:
NoiseSuppressor();
~NoiseSuppressor();
NoiseSuppressor(const NoiseSuppressor&) = delete;
NoiseSuppressor& operator=(const NoiseSuppressor&) = delete;
// Process interleaved multi-channel float samples in-place.
// Each channel is denoised independently through its own RNNoise state in
// 480-sample frames, preserving the original channel layout (e.g. a mic
// wired only to the left channel stays on the left and is not duplicated
// onto the right).
void Process(float* samples, uint32_t sampleCount, uint32_t channels);
private:
// Per-channel RNNoise state and buffers so each channel is denoised
// independently and the channel layout is preserved.
struct ChannelState
{
DenoiseState* state = nullptr;
std::vector<float> work; // Working buffer for the current quantum
std::vector<float> residual; // Leftover samples from previous quantum
};
void EnsureChannelCount(uint32_t channels);
std::vector<ChannelState> m_channels;
};

View File

@@ -32,6 +32,9 @@ extern DWORD g_WebcamPosition;
extern DWORD g_WebcamSize;
extern DWORD g_WebcamShape;
extern TCHAR g_WebcamDeviceSymLink[MAX_PATH];
extern DWORD g_WebcamBackgroundMode;
extern TCHAR g_WebcamBackgroundImage[];
extern DWORD g_WebcamBrightness;
extern class ClassRegistry reg;
extern REG_SETTING RegSettings[];
extern HINSTANCE g_hInstance;
@@ -106,6 +109,34 @@ static double RecDiagElapsedMs()
return static_cast<double>( now.QuadPart - s_origin.QuadPart ) * 1000.0 / s_freq.QuadPart;
}
static FILE* s_recDiagFile = nullptr;
static void RecDiagOpenFile()
{
if( !s_recDiagFile )
{
wchar_t path[MAX_PATH];
if( ExpandEnvironmentStringsW( L"%TEMP%\\ZoomIt_RecDiag.log", path, MAX_PATH ) )
{
_wfopen_s( &s_recDiagFile, path, L"a" );
if( s_recDiagFile )
{
fwprintf( s_recDiagFile, L"\n===== NEW SESSION =====\n" );
fflush( s_recDiagFile );
}
}
}
}
static void RecDiagCloseFile()
{
if( s_recDiagFile )
{
fclose( s_recDiagFile );
s_recDiagFile = nullptr;
}
}
static void RecDiag( const wchar_t* fmt, ... )
{
wchar_t buf[512];
@@ -123,8 +154,19 @@ static void RecDiag( const wchar_t* fmt, ... )
_vsnwprintf_s( buf + offset, _countof( buf ) - offset, _TRUNCATE, fmt, va );
va_end( va );
OutputDebugStringW( buf );
RecDiagOpenFile();
if( s_recDiagFile )
{
fwprintf( s_recDiagFile, L"%s", buf );
fflush( s_recDiagFile );
}
}
static int s_diagVideoCount = 0;
static int s_diagAudioCount = 0;
static int64_t s_diagStartTs = 0; // SystemRelativeTime from OnStarting
static bool IsGifPath(const std::wstring& path)
{
try
@@ -930,12 +972,20 @@ VideoRecordingSession::VideoRecordingSession(
winrt::GraphicsCaptureItem const& item,
RECT const cropRect,
uint32_t frameRate,
bool captureAudio,
bool captureSystemAudio,
bool micMonoMix,
std::unique_ptr<AudioSampleGenerator> audioGenerator,
winrt::IAsyncAction audioInitAction,
winrt::Streams::IRandomAccessStream const& stream)
{
RecDiag( L"Constructor: entry\n" );
// Take ownership of pre-created audio generator. Its InitializeAsync
// was started in StartRecordingAsync so it runs in parallel with all
// the D3D, capture-item, and webcam setup below.
m_audioGenerator = std::move( audioGenerator );
m_audioInitAction = audioInitAction;
RecDiag( L"Constructor: audio generator received (init %s)\n",
m_audioInitAction ? L"pending" : L"none" );
m_device = device;
m_d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(m_device);
m_d3dDevice->GetImmediateContext(m_d3dContext.put());
@@ -1038,8 +1088,15 @@ VideoRecordingSession::VideoRecordingSession(
probeCapture.Close();
return true;
}
catch( winrt::hresult_error const& ex )
{
RecDiag( L"Constructor: webcam probe failed hr=0x%08X: %s\n",
static_cast<unsigned>( ex.code() ), ex.message().c_str() );
return false;
}
catch( ... )
{
RecDiag( L"Constructor: webcam probe failed with unknown exception\n" );
return false;
}
});
@@ -1067,7 +1124,10 @@ VideoRecordingSession::VideoRecordingSession(
static_cast<WebcamCapture::Position>( g_WebcamPosition ),
static_cast<WebcamCapture::Size>( g_WebcamSize ),
webcamShape,
isFullScreenRecording );
isFullScreenRecording,
static_cast<WebcamBackgroundMode>( g_WebcamBackgroundMode ),
g_WebcamBackgroundImage,
static_cast<int>( g_WebcamBrightness ) );
m_webcamCapture->Start();
RecDiag( L"Constructor: WebcamCapture::Start() returned\n" );
}
@@ -1089,12 +1149,12 @@ VideoRecordingSession::VideoRecordingSession(
// Store frame interval for timeout-based frame production when webcam is active.
m_frameIntervalTicks = ( frameRate > 0 ) ? ( 10'000'000LL / frameRate ) : 333'333LL;
if (captureAudio || captureSystemAudio)
if (m_audioGenerator)
{
// Always set up audio profile for loopback capture (stereo AAC)
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
auto audioProps = m_audioGenerator->GetEncodingProperties();
m_encodingProfile.Audio(winrt::AudioEncodingProperties::CreateAac(
audioProps.SampleRate(), audioProps.ChannelCount(), 192000));
}
// Describe our input: uncompressed BGRA8 buffers
@@ -1113,15 +1173,6 @@ VideoRecordingSession::VideoRecordingSession(
DXGI_FORMAT_B8G8R8A8_UNORM,
2);
if (captureAudio || captureSystemAudio)
{
m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio, micMonoMix);
}
else
{
m_audioGenerator = nullptr;
}
// Wait for the webcam's first frame now that all other setup is done.
// The camera was started early, so most of its ~850 ms sensor warmup has
// overlapped with the encoding profile, swap chain, and audio generator
@@ -1155,6 +1206,7 @@ VideoRecordingSession::VideoRecordingSession(
VideoRecordingSession::~VideoRecordingSession()
{
Close();
RecDiagCloseFile();
}
@@ -1173,8 +1225,13 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
// Create our MediaStreamSource
if(m_audioGenerator) {
RecDiag( L"StartAsync: co_await InitializeAsync...\n" );
co_await m_audioGenerator->InitializeAsync();
RecDiag( L"StartAsync: co_await audio init...\n" );
if (m_audioInitAction) {
co_await m_audioInitAction; // started in constructor
m_audioInitAction = nullptr;
} else {
co_await m_audioGenerator->InitializeAsync();
}
RecDiag( L"StartAsync: audio initialized\n" );
m_streamSource = winrt::MediaStreamSource(m_videoDescriptor, winrt::AudioStreamDescriptor(m_audioGenerator->GetEncodingProperties()));
}
@@ -1236,6 +1293,9 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync()
//----------------------------------------------------------------------------
void VideoRecordingSession::Close()
{
RecDiag( L"Close: totalVideoFrames=%d totalAudioSamples=%d\n",
s_diagVideoCount, s_diagAudioCount );
// Stop webcam capture before closing the main session.
if( m_webcamCapture )
{
@@ -1285,28 +1345,45 @@ void VideoRecordingSession::OnMediaStreamSourceStarting(
winrt::MediaStreamSource const&,
winrt::MediaStreamSourceStartingEventArgs const& args)
{
RecDiag( L"OnStarting: entry, calling TryGetNextFrame...\n" );
auto frame = m_frameWait->TryGetNextFrame();
// Close the stale frame captured in the constructor (~1-2 seconds
// ago) and grab a FRESH frame via TryGetNextFrame. This gives us
// both current visual content and a current SystemRelativeTime,
// avoiding the frozen-first-frame artefact. WGC delivers a new
// frame within one vblank (~16 ms) after the old frame is released
// back to the pool, so a 200 ms timeout is very generous.
RecDiag( L"OnStarting: calling TryGetNextFrame(200) for fresh frame...\n" );
auto frame = m_frameWait->TryGetNextFrame( 200 );
int64_t startSRT = 0;
if (frame) {
RecDiag( L"OnStarting: got frame, SystemRelativeTime=%lld (%.1fms)\n",
frame->SystemRelativeTime.count(),
frame->SystemRelativeTime.count() / 10000.0 );
args.Request().SetActualStartPosition(frame->SystemRelativeTime);
startSRT = frame->SystemRelativeTime.count();
RecDiag( L"OnStarting: got fresh frame, SRT=%lld (%.1fms)\n",
startSRT, startSRT / 10000.0 );
// Cache this frame so it can be served as the very first video
// sample in OnMediaStreamSourceSampleRequested. Without this,
// the frame is discarded and the first encoded sample comes from
// the *next* capture, creating a visible timestamp gap.
args.Request().SetActualStartPosition( frame->SystemRelativeTime );
m_cachedStartingFrame = frame;
if (m_audioGenerator) {
RecDiag( L"OnStarting: calling AudioSampleGenerator::Start()\n" );
m_audioGenerator->Start();
RecDiag( L"OnStarting: audio started\n" );
}
m_adjustedStartSRT = startSRT;
} else {
RecDiag( L"OnStarting: TryGetNextFrame returned nullopt!\n" );
// Timeout (very unlikely). Fall back to QPC-derived SRT.
// Use double for the intermediate product to avoid int64
// overflow (naive integer multiply overflows after ~25.6 h).
RecDiag( L"OnStarting: TryGetNextFrame timed out, using QPC fallback\n" );
LARGE_INTEGER qpcFreq, qpcNow;
QueryPerformanceFrequency( &qpcFreq );
QueryPerformanceCounter( &qpcNow );
startSRT = static_cast<int64_t>(
static_cast<double>( qpcNow.QuadPart ) * 10'000'000.0 / qpcFreq.QuadPart );
args.Request().SetActualStartPosition( winrt::TimeSpan{ startSRT } );
m_adjustedStartSRT = startSRT;
}
// Start audio capture. Pass the video start SRT so audio
// timestamps can be rebased to the same domain as video.
if (m_audioGenerator) {
RecDiag( L"OnStarting: calling AudioSampleGenerator::Start(videoStartSRT=%lld)\n", startSRT );
m_audioGenerator->Start( startSRT );
RecDiag( L"OnStarting: audio started\n" );
}
RecDiag( L"OnStarting: exit\n" );
}
@@ -1321,12 +1398,11 @@ std::shared_ptr<VideoRecordingSession> VideoRecordingSession::Create(
winrt::GraphicsCaptureItem const& item,
RECT const& crop,
uint32_t frameRate,
bool captureAudio,
bool captureSystemAudio,
bool micMonoMix,
std::unique_ptr<AudioSampleGenerator> audioGenerator,
winrt::IAsyncAction audioInitAction,
winrt::Streams::IRandomAccessStream const& stream)
{
return std::shared_ptr<VideoRecordingSession>(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, captureSystemAudio, micMonoMix, stream));
return std::shared_ptr<VideoRecordingSession>(new VideoRecordingSession(device, item, crop, frameRate, std::move(audioGenerator), audioInitAction, stream));
}
//----------------------------------------------------------------------------
@@ -1338,10 +1414,6 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
winrt::MediaStreamSource const&,
winrt::MediaStreamSourceSampleRequestedEventArgs const& args)
{
static int s_diagVideoCount = 0;
static int s_diagAudioCount = 0;
static int64_t s_diagStartTs = 0; // SystemRelativeTime from OnStarting
auto request = args.Request();
auto streamDescriptor = request.StreamDescriptor();
if (auto videoStreamDescriptor = streamDescriptor.try_as<winrt::VideoStreamDescriptor>())
@@ -1412,7 +1484,16 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
else
{
// New desktop frame — crop and copy to back buffer.
timeStamp = frame->SystemRelativeTime;
// If this is the cached starting frame, use the adjusted
// SRT (computed from QPC in OnStarting) instead of the
// stale SRT from the constructor. The stale SRT is ~2-3s
// behind, creating a massive timestamp gap between frame
// #1 and #2 that causes the transcoder to starve video
// while filling the gap with audio.
if( cachedFrame.has_value() && m_adjustedStartSRT != 0 )
timeStamp = winrt::TimeSpan{ m_adjustedStartSRT };
else
timeStamp = frame->SystemRelativeTime;
auto contentSize = frame->ContentSize;
auto frameTexture = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame->FrameTexture);
D3D11_TEXTURE2D_DESC desc = {};
@@ -1448,7 +1529,7 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
m_d3dContext->CopyResource( m_cachedDesktopTex.get(), backBuffer.get() );
}
// Log first 10 video frames with timing.
// Log first 50 video frames with timing.
if( !m_hasVideoSample.load() )
{
s_diagVideoCount = 0;
@@ -1462,13 +1543,14 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
m_hasQpcOrigin = true;
}
s_diagVideoCount++;
if( s_diagVideoCount <= 10 )
if( s_diagVideoCount <= 50 )
{
RecDiag( L"SampleReq VIDEO #%d: sysRelTime=%lld deltaFromStart=%.1fms repeat=%d\n",
RecDiag( L"SampleReq VIDEO #%d: sysRelTime=%lld deltaFromStart=%.1fms repeat=%d cached=%d\n",
s_diagVideoCount,
timeStamp.count(),
( timeStamp.count() - s_diagStartTs ) / 10000.0,
isRepeatFrame ? 1 : 0 );
isRepeatFrame ? 1 : 0,
( s_diagVideoCount == 1 && cachedFrame.has_value() ) ? 1 : 0 );
}
#if _DEBUG
@@ -1492,6 +1574,11 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
// Composite webcam overlay onto the back buffer.
if( m_webcamCapture )
{
if( s_diagVideoCount <= 3 )
{
RecDiag( L"SampleReq VIDEO #%d: compositing LIVE webcam frame\n",
s_diagVideoCount );
}
m_webcamCapture->CompositeOnto( backBuffer.get() );
}
@@ -1541,7 +1628,9 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
}
catch (winrt::hresult_error const& error)
{
OutputDebugStringW(error.message().c_str());
RecDiag( L"SampleReq VIDEO EXCEPTION on frame #%d: hr=0x%08X %s\n",
s_diagVideoCount, static_cast<unsigned>(error.code()),
error.message().c_str() );
request.Sample(nullptr);
CloseInternal();
return;
@@ -1551,26 +1640,53 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
{
try
{
static int s_audioReqCount = 0;
if( !m_hasVideoSample.load() )
s_audioReqCount = 0;
s_audioReqCount++;
if( s_audioReqCount <= 5 )
{
RecDiag( L"SampleReq AUDIO req #%d: calling TryGetNextSample (started=%d)...\n",
s_audioReqCount, m_audioGenerator ? 1 : 0 );
}
LARGE_INTEGER tBefore, tAfter, tFreq;
QueryPerformanceFrequency( &tFreq );
QueryPerformanceCounter( &tBefore );
if (auto sample = m_audioGenerator ? m_audioGenerator->TryGetNextSample() : std::optional<winrt::MediaStreamSample>{}; sample.has_value())
{
QueryPerformanceCounter( &tAfter );
double waitMs = static_cast<double>( tAfter.QuadPart - tBefore.QuadPart ) * 1000.0 / tFreq.QuadPart;
s_diagAudioCount++;
if( s_diagAudioCount <= 10 )
if( s_diagAudioCount <= 50 )
{
RecDiag( L"SampleReq AUDIO #%d: timestamp=%lld (%.1fms)\n",
s_diagAudioCount,
sample.value().Timestamp().count(),
sample.value().Timestamp().count() / 10000.0 );
auto ts = sample.value().Timestamp().count();
auto dur = sample.value().Duration().count();
RecDiag( L"SampleReq AUDIO #%d (req %d): timestamp=%lld (%.1fms) duration=%lld (%.1fms) waitMs=%.1f\n",
s_diagAudioCount, s_audioReqCount,
ts, ts / 10000.0,
dur, dur / 10000.0,
waitMs );
}
request.Sample(sample.value());
}
else
{
QueryPerformanceCounter( &tAfter );
double waitMs = static_cast<double>( tAfter.QuadPart - tBefore.QuadPart ) * 1000.0 / tFreq.QuadPart;
RecDiag( L"SampleReq AUDIO req #%d: TryGetNextSample returned EMPTY after %.1fms → end-of-audio-stream\n",
s_audioReqCount, waitMs );
request.Sample(nullptr);
}
}
catch (winrt::hresult_error const& error)
{
OutputDebugStringW(error.message().c_str());
RecDiag( L"SampleReq AUDIO EXCEPTION on sample #%d: hr=0x%08X %s\n",
s_diagAudioCount, static_cast<unsigned>(error.code()),
error.message().c_str() );
request.Sample(nullptr);
CloseInternal();
return;
@@ -1659,7 +1775,7 @@ public:
}
return S_OK;
}
IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; }
IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; }
IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; }
@@ -2838,7 +2954,7 @@ static void HandlePlaybackCommand(int controlId, VideoRecordingSession::TrimDial
case IDC_TRIM_REWIND:
{
StopPlayback(hDlg, pData, false);
// Use 1 second step for timelines < 20 seconds, 2 seconds
// Use 1 second step for timelines < 20 seconds, 2 seconds
const int64_t duration = pData->trimEnd.count() - pData->trimStart.count();
const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks;
const int64_t newTicks = (std::max)(pData->trimStart.count(), pData->currentPosition.count() - stepTicks);
@@ -2854,7 +2970,7 @@ static void HandlePlaybackCommand(int controlId, VideoRecordingSession::TrimDial
case IDC_TRIM_FORWARD:
{
StopPlayback(hDlg, pData, false);
// Use 1 second step for timelines < 20 seconds, 2 seconds
// Use 1 second step for timelines < 20 seconds, 2 seconds
const int64_t duration = pData->trimEnd.count() - pData->trimStart.count();
const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks;
const int64_t newTicks = (std::min)(pData->trimEnd.count(), pData->currentPosition.count() + stepTicks);
@@ -5517,7 +5633,7 @@ INT_PTR CALLBACK VideoRecordingSession::TrimDialogProc(HWND hDlg, UINT message,
const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) };
SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true);
}
if (elapsedMs >= frameDurationMs)
{
// Time to advance to next frame

View File

@@ -27,9 +27,8 @@ public:
winrt::GraphicsCaptureItem const& item,
RECT const& cropRect,
uint32_t frameRate,
bool captureAudio,
bool captureSystemAudio,
bool micMonoMix,
std::unique_ptr<AudioSampleGenerator> audioGenerator,
winrt::Windows::Foundation::IAsyncAction audioInitAction,
winrt::Streams::IRandomAccessStream const& stream);
~VideoRecordingSession();
@@ -217,9 +216,8 @@ private:
winrt::Capture::GraphicsCaptureItem const& item,
RECT const cropRect,
uint32_t frameRate,
bool captureAudio,
bool captureSystemAudio,
bool micMonoMix,
std::unique_ptr<AudioSampleGenerator> audioGenerator,
winrt::Windows::Foundation::IAsyncAction audioInitAction,
winrt::Streams::IRandomAccessStream const& stream);
void CloseInternal();
@@ -279,5 +277,9 @@ private:
LARGE_INTEGER m_qpcFreq{};
LARGE_INTEGER m_qpcRecordingStart{}; // QPC at first sample
int64_t m_startSystemRelativeTime = 0; // SystemRelativeTime of first frame
int64_t m_adjustedStartSRT = 0; // QPC-based current SRT set in OnStarting
bool m_hasQpcOrigin = false;
// Audio initialization started in the constructor, awaited in StartAsync.
winrt::Windows::Foundation::IAsyncAction m_audioInitAction{ nullptr };
};

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,37 @@
#include <mfreadwrite.h>
#include <atomic>
#include <condition_variable>
#include <memory>
#include "BackgroundBlur.h"
#include <mutex>
#include <thread>
#include <vector>
#include <winrt/base.h>
class BackgroundBlur;
// Must match CompositeConstants cbuffer layout in WebcamComposite.hlsl.
struct GpuCompositeConstants
{
float CropOffsetX, CropOffsetY; // Camera crop UV offset
float CropScaleX, CropScaleY; // Camera crop UV scale
float Gamma; // Gamma correction exponent
float CornerRadius; // Corner radius in output pixels
float OutputW, OutputH; // Output dimensions
UINT ShapeType; // 0=Square, 1=RoundedRect, 2=RoundedSquare, 3=Circle
UINT HasMask; // 1 if mask texture valid
float Pad[2];
};
// Must match BlurConstants cbuffer layout in BoxBlurCS.hlsl.
struct GpuBlurConstants
{
UINT Direction; // 0 = horizontal, 1 = vertical
INT Radius; // Box blur radius in pixels
UINT Width; // Image width
UINT Height; // Image height
};
class WebcamCapture
{
public:
@@ -44,7 +70,10 @@ public:
Position position,
Size size,
Shape shape,
bool fullScreenRecording = false );
bool fullScreenRecording = false,
WebcamBackgroundMode backgroundMode = WebcamBackgroundMode::None,
const wchar_t* backgroundImagePath = nullptr,
int brightness = 50 );
~WebcamCapture();
// Start/stop the capture thread.
@@ -106,6 +135,19 @@ private:
bool InitSourceReader();
RECT ComputeDestRect() const;
void ComputeOverlayDimensions();
bool InitGpuComposite();
bool GpuComposite( const UINT32* cameraPixels, UINT camW, UINT camH,
const UINT32* blurPixels, UINT blurW, UINT blurH,
const float* mask, UINT maskW, UINT maskH,
UINT outW, UINT outH,
UINT srcCropX, UINT srcCropY, UINT srcCropW, UINT srcCropH,
float gamma, Shape shape, float cornerRadius,
ID3D11ShaderResourceView* preBlurSRV = nullptr );
// GPU box blur: runs 4 compute-shader dispatches (H→V→H→V) on the
// processing-resolution frame. The result stays GPU-resident in
// m_blurPingPong[0] for direct use by GpuComposite.
bool GpuBoxBlur( const UINT32* pixels, UINT width, UINT height, int radius );
winrt::com_ptr<ID3D11Device> m_d3dDevice;
winrt::com_ptr<ID3D11DeviceContext> m_d3dContext;
@@ -135,6 +177,7 @@ private:
// Reusable frame buffer for the capture thread (avoids per-frame alloc).
std::vector<BYTE> m_framePixels;
std::vector<BYTE> m_scaledPixels;
std::vector<BYTE> m_upscalePixels;
UINT m_overlayW = 0;
UINT m_overlayH = 0;
@@ -142,6 +185,11 @@ private:
UINT m_camHeight = 0;
RECT m_destRect = {};
// Brightness correction (user-controlled, fixed gamma LUT).
int m_brightness = 50; // 0=dark, 50=neutral, 100=bright
std::array<uint8_t, 256> m_gammaLUT = {}; // current LUT
double m_lutGamma = 1.0; // gamma used for m_gammaLUT
// Output dimensions (recording output after crop+scale).
UINT m_outputWidth = 0;
UINT m_outputHeight = 0;
@@ -163,8 +211,57 @@ private:
std::condition_variable m_readyCV;
bool m_firstFrameCaptured = false;
// Background processing.
WebcamBackgroundMode m_backgroundMode = WebcamBackgroundMode::None;
std::wstring m_backgroundImagePath;
std::unique_ptr<BackgroundBlur> m_backgroundBlur;
// Debug counters for CompositeOnto logging.
int m_compositeCount = 0;
int m_lockFailCount = 0;
int m_uploadCount = 0;
// ── GPU composite pipeline ──────────────────────────────
// Separate D3D device for capture thread (avoids contention
// with the recording session's device/context).
winrt::com_ptr<ID3D11Device> m_gpuDevice;
winrt::com_ptr<ID3D11DeviceContext> m_gpuContext;
winrt::com_ptr<ID3D11VertexShader> m_compositeVS;
winrt::com_ptr<ID3D11PixelShader> m_compositePS;
winrt::com_ptr<ID3D11Buffer> m_compositeCB;
winrt::com_ptr<ID3D11SamplerState> m_bilinearSampler;
winrt::com_ptr<ID3D11RasterizerState> m_gpuRasterState;
winrt::com_ptr<ID3D11BlendState> m_gpuBlendState;
// Input textures + SRVs (recreated when dimensions change).
winrt::com_ptr<ID3D11Texture2D> m_gpuCameraTex;
winrt::com_ptr<ID3D11ShaderResourceView> m_gpuCameraSRV;
UINT m_gpuCameraW = 0, m_gpuCameraH = 0;
winrt::com_ptr<ID3D11Texture2D> m_gpuBlurTex;
winrt::com_ptr<ID3D11ShaderResourceView> m_gpuBlurSRV;
UINT m_gpuBlurW = 0, m_gpuBlurH = 0;
winrt::com_ptr<ID3D11Texture2D> m_gpuMaskTex;
winrt::com_ptr<ID3D11ShaderResourceView> m_gpuMaskSRV;
UINT m_gpuMaskW = 0, m_gpuMaskH = 0;
// Render target + staging for readback.
winrt::com_ptr<ID3D11Texture2D> m_gpuRenderTarget;
winrt::com_ptr<ID3D11RenderTargetView> m_gpuRTV;
winrt::com_ptr<ID3D11Texture2D> m_gpuStaging;
UINT m_gpuRTW = 0, m_gpuRTH = 0;
bool m_gpuCompositeReady = false;
// ── GPU box-blur compute pipeline ───────────────────────
winrt::com_ptr<ID3D11ComputeShader> m_blurCS;
winrt::com_ptr<ID3D11Buffer> m_blurCB;
// Ping-pong textures with SRV + UAV for blur passes.
winrt::com_ptr<ID3D11Texture2D> m_blurPingPong[2];
winrt::com_ptr<ID3D11ShaderResourceView> m_blurPingSRV[2];
winrt::com_ptr<ID3D11UnorderedAccessView> m_blurPingUAV[2];
UINT m_blurPPW = 0, m_blurPPH = 0;
bool m_gpuBlurReady = false;
};

View File

@@ -0,0 +1,143 @@
//==============================================================================
//
// WebcamComposite.hlsl
//
// GPU composite shader for webcam overlay.
// Composites sharp foreground from full-resolution camera with
// blurred background from processing-resolution blur buffer, using
// a segmentation mask to blend between the two sources.
//
// The GPU's hardware texture sampler provides free bilinear filtering,
// making this orders of magnitude faster than the equivalent CPU loop.
//
// Entry points:
// VSMain (vs_5_0) — full-screen triangle, no vertex buffer needed
// PSMain (ps_5_0) — composite camera + blur + mask + shape mask
//
// Recompile with:
// fxc /T vs_5_0 /E VSMain /Fh WebcamCompositeVS.h /Vn g_WebcamCompositeVS WebcamComposite.hlsl
// fxc /T ps_5_0 /E PSMain /Fh WebcamCompositePS.h /Vn g_WebcamCompositePS WebcamComposite.hlsl
//
// Copyright (C) Mark Russinovich
// Sysinternals - www.sysinternals.com
//
//==============================================================================
// Camera frame at full resolution (e.g. 1920x1080), B8G8R8A8_UNORM.
// Shader sees RGBA due to hardware swizzle.
Texture2D CameraTex : register(t0);
// Blurred processing buffer at reduced resolution (e.g. 960x540), B8G8R8A8_UNORM.
// Already gamma-corrected from CPU downsample.
Texture2D BlurTex : register(t1);
// Segmentation mask from ONNX model, R32_FLOAT.
// 1.0 = foreground (person), 0.0 = background.
Texture2D MaskTex : register(t2);
// Bilinear sampler with clamp addressing — used for all textures.
SamplerState BilinearSamp : register(s0);
cbuffer CompositeConstants : register(b0)
{
float2 CropOffset; // Camera crop UV offset (srcCropX/camW, srcCropY/camH)
float2 CropScale; // Camera crop UV scale (srcCropW/camW, srcCropH/camH)
float Gamma; // Gamma correction exponent (< 1 brightens)
float CornerRadius; // Corner radius in output pixels
float OutputW; // Output width in pixels
float OutputH; // Output height in pixels
uint ShapeType; // 0=Square, 1=RoundedRect, 2=RoundedSquare, 3=Circle
uint HasMask; // 1 if segmentation mask is valid
float2 Pad;
};
struct VSOutput
{
float4 Position : SV_POSITION;
float2 TexCoord : TEXCOORD0;
};
//----------------------------------------------------------------------------
// Vertex shader: full-screen triangle from SV_VertexID (no vertex buffer).
// Draw(3, 0) to invoke. The triangle covers [-1,1] clip space.
//----------------------------------------------------------------------------
VSOutput VSMain( uint vertexId : SV_VertexID )
{
VSOutput output;
output.TexCoord = float2( (vertexId << 1) & 2, vertexId & 2 );
output.Position = float4( output.TexCoord * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0 );
return output;
}
//----------------------------------------------------------------------------
// Pixel shader: composite camera foreground with blurred background.
// - Shape masking (circle, rounded rect) produces alpha = 0 outside.
// - Segmentation mask blends camera (foreground) with blur (background).
// - Gamma correction applied to camera samples only (blur already corrected).
// - Hardware bilinear filtering on all texture samples (free).
//----------------------------------------------------------------------------
float4 PSMain( VSOutput input ) : SV_TARGET
{
float2 uv = input.TexCoord;
float px = uv.x * OutputW;
float py = uv.y * OutputH;
// ── Shape mask ─────────────────────────────────────────────────────
if( ShapeType == 3 ) // Circle
{
float halfW = OutputW * 0.5;
float halfH = OutputH * 0.5;
float radius = min( halfW, halfH );
float dx = ( px - halfW ) / radius;
float dy = ( py - halfH ) / radius;
if( dx * dx + dy * dy > 1.0 )
return float4( 0, 0, 0, 0 );
}
else if( ShapeType >= 1 ) // RoundedRect or RoundedSquare
{
float cx = 0, cy = 0;
bool inCorner = false;
if( px < CornerRadius && py < CornerRadius )
{ cx = CornerRadius; cy = CornerRadius; inCorner = true; }
else if( px > OutputW - CornerRadius && py < CornerRadius )
{ cx = OutputW - CornerRadius; cy = CornerRadius; inCorner = true; }
else if( px < CornerRadius && py > OutputH - CornerRadius )
{ cx = CornerRadius; cy = OutputH - CornerRadius; inCorner = true; }
else if( px > OutputW - CornerRadius && py > OutputH - CornerRadius )
{ cx = OutputW - CornerRadius; cy = OutputH - CornerRadius; inCorner = true; }
if( inCorner )
{
float ddx = px - cx;
float ddy = py - cy;
if( ddx * ddx + ddy * ddy > CornerRadius * CornerRadius )
return float4( 0, 0, 0, 0 );
}
}
// ── Composite ──────────────────────────────────────────────────────
if( HasMask )
{
// Segmentation mask (bilinear-filtered for smooth edges).
float mask = saturate( MaskTex.Sample( BilinearSamp, uv ).r );
// Camera: crop-to-fill UV mapping + gamma correction.
float2 camUV = CropOffset + uv * CropScale;
float4 cam = CameraTex.Sample( BilinearSamp, camUV );
cam.rgb = pow( max( cam.rgb, 0.001 ), Gamma );
// Blur: already gamma-corrected from CPU downsample.
float4 blur = BlurTex.Sample( BilinearSamp, uv );
// Blend: mask=1 → camera (foreground), mask=0 → blur (background).
float3 result = lerp( blur.rgb, cam.rgb, mask );
return float4( result, 1.0 );
}
else
{
// No segmentation mask — just display the processing buffer.
float4 blur = BlurTex.Sample( BilinearSamp, uv );
return float4( blur.rgb, 1.0 );
}
}

View File

@@ -0,0 +1,616 @@
#if 0
//
// Generated by Microsoft (R) HLSL Shader Compiler 10.1
//
//
// Buffer Definitions:
//
// cbuffer CompositeConstants
// {
//
// float2 CropOffset; // Offset: 0 Size: 8
// float2 CropScale; // Offset: 8 Size: 8
// float Gamma; // Offset: 16 Size: 4
// float CornerRadius; // Offset: 20 Size: 4
// float OutputW; // Offset: 24 Size: 4
// float OutputH; // Offset: 28 Size: 4
// uint ShapeType; // Offset: 32 Size: 4
// uint HasMask; // Offset: 36 Size: 4
// float2 Pad; // Offset: 40 Size: 8 [unused]
//
// }
//
//
// Resource Bindings:
//
// Name Type Format Dim HLSL Bind Count
// ------------------------------ ---------- ------- ----------- -------------- ------
// BilinearSamp sampler NA NA s0 1
// CameraTex texture float4 2d t0 1
// BlurTex texture float4 2d t1 1
// MaskTex texture float4 2d t2 1
// CompositeConstants cbuffer NA NA cb0 1
//
//
//
// Input signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_POSITION 0 xyzw 0 POS float
// TEXCOORD 0 xy 1 NONE float xy
//
//
// Output signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_TARGET 0 xyzw 0 TARGET float xyzw
//
ps_5_0
dcl_globalFlags refactoringAllowed
dcl_constantbuffer CB0[3], immediateIndexed
dcl_sampler s0, mode_default
dcl_resource_texture2d (float,float,float,float) t0
dcl_resource_texture2d (float,float,float,float) t1
dcl_resource_texture2d (float,float,float,float) t2
dcl_input_ps linear v1.xy
dcl_output o0.xyzw
dcl_temps 4
mul r0.xy, v1.xyxx, cb0[1].zwzz
ieq r0.z, cb0[2].x, l(3)
if_nz r0.z
mul r0.zw, cb0[1].zzzw, l(0.000000, 0.000000, 0.500000, 0.500000)
min r1.x, r0.w, r0.z
mad r0.zw, v1.xxxy, cb0[1].zzzw, -r0.zzzw
div r0.zw, r0.zzzw, r1.xxxx
mul r0.zw, r0.zzzw, r0.zzzw
add r0.z, r0.w, r0.z
lt r0.z, l(1.000000), r0.z
if_nz r0.z
mov o0.xyzw, l(0,0,0,0)
ret
endif
else
uge r0.z, cb0[2].x, l(1)
if_nz r0.z
lt r0.zw, r0.yyyx, cb0[1].yyyy
and r1.x, r0.z, r0.w
add r2.xy, -cb0[1].yyyy, cb0[1].zwzz
lt r0.xy, r2.xyxx, r0.xyxx
and r0.zw, r0.zzzw, r0.xxxy
and r3.z, r0.y, r0.x
and r3.xy, r2.xyxx, r3.zzzz
mov r2.z, cb0[1].y
mov r2.w, l(-1)
movc r0.xyw, r0.wwww, r2.zyzw, r3.xyxz
movc r0.xyz, r0.zzzz, r2.xzwx, r0.xywx
movc r0.xyz, r1.xxxx, r2.zzwz, r0.xyzx
if_nz r0.z
mad r0.xy, v1.xyxx, cb0[1].zwzz, -r0.xyxx
mul r0.xy, r0.xyxx, r0.xyxx
add r0.x, r0.y, r0.x
mul r0.y, cb0[1].y, cb0[1].y
lt r0.x, r0.y, r0.x
if_nz r0.x
mov o0.xyzw, l(0,0,0,0)
ret
endif
endif
endif
endif
if_nz cb0[2].y
sample_indexable(texture2d)(float,float,float,float) r0.x, v1.xyxx, t2.xyzw, s0
mov_sat r0.x, r0.x
mad r0.yz, v1.xxyx, cb0[0].zzwz, cb0[0].xxyx
sample_indexable(texture2d)(float,float,float,float) r0.yzw, r0.yzyy, t0.wxyz, s0
max r0.yzw, r0.yyzw, l(0.000000, 0.001000, 0.001000, 0.001000)
log r0.yzw, r0.yyzw
mul r0.yzw, r0.yyzw, cb0[1].xxxx
exp r0.yzw, r0.yyzw
sample_indexable(texture2d)(float,float,float,float) r1.xyz, v1.xyxx, t1.xyzw, s0
add r0.yzw, r0.yyzw, -r1.xxyz
mad o0.xyz, r0.xxxx, r0.yzwy, r1.xyzx
mov o0.w, l(1.000000)
ret
else
sample_indexable(texture2d)(float,float,float,float) r0.xyz, v1.xyxx, t1.xyzw, s0
mov o0.xyz, r0.xyzx
mov o0.w, l(1.000000)
ret
endif
ret
// Approximately 63 instruction slots used
#endif
const BYTE g_WebcamCompositePS[] =
{
68, 88, 66, 67, 78, 221,
134, 205, 221, 100, 55, 97,
108, 36, 219, 137, 244, 189,
76, 224, 1, 0, 0, 0,
108, 11, 0, 0, 5, 0,
0, 0, 52, 0, 0, 0,
208, 3, 0, 0, 40, 4,
0, 0, 92, 4, 0, 0,
208, 10, 0, 0, 82, 68,
69, 70, 148, 3, 0, 0,
1, 0, 0, 0, 24, 1,
0, 0, 5, 0, 0, 0,
60, 0, 0, 0, 0, 5,
255, 255, 0, 1, 0, 0,
108, 3, 0, 0, 82, 68,
49, 49, 60, 0, 0, 0,
24, 0, 0, 0, 32, 0,
0, 0, 40, 0, 0, 0,
36, 0, 0, 0, 12, 0,
0, 0, 0, 0, 0, 0,
220, 0, 0, 0, 3, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
1, 0, 0, 0, 1, 0,
0, 0, 233, 0, 0, 0,
2, 0, 0, 0, 5, 0,
0, 0, 4, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 1, 0, 0, 0,
13, 0, 0, 0, 243, 0,
0, 0, 2, 0, 0, 0,
5, 0, 0, 0, 4, 0,
0, 0, 255, 255, 255, 255,
1, 0, 0, 0, 1, 0,
0, 0, 13, 0, 0, 0,
251, 0, 0, 0, 2, 0,
0, 0, 5, 0, 0, 0,
4, 0, 0, 0, 255, 255,
255, 255, 2, 0, 0, 0,
1, 0, 0, 0, 13, 0,
0, 0, 3, 1, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0,
1, 0, 0, 0, 66, 105,
108, 105, 110, 101, 97, 114,
83, 97, 109, 112, 0, 67,
97, 109, 101, 114, 97, 84,
101, 120, 0, 66, 108, 117,
114, 84, 101, 120, 0, 77,
97, 115, 107, 84, 101, 120,
0, 67, 111, 109, 112, 111,
115, 105, 116, 101, 67, 111,
110, 115, 116, 97, 110, 116,
115, 0, 171, 171, 3, 1,
0, 0, 9, 0, 0, 0,
48, 1, 0, 0, 48, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 152, 2,
0, 0, 0, 0, 0, 0,
8, 0, 0, 0, 2, 0,
0, 0, 172, 2, 0, 0,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 208, 2, 0, 0,
8, 0, 0, 0, 8, 0,
0, 0, 2, 0, 0, 0,
172, 2, 0, 0, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
218, 2, 0, 0, 16, 0,
0, 0, 4, 0, 0, 0,
2, 0, 0, 0, 232, 2,
0, 0, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 12, 3,
0, 0, 20, 0, 0, 0,
4, 0, 0, 0, 2, 0,
0, 0, 232, 2, 0, 0,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 25, 3, 0, 0,
24, 0, 0, 0, 4, 0,
0, 0, 2, 0, 0, 0,
232, 2, 0, 0, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
33, 3, 0, 0, 28, 0,
0, 0, 4, 0, 0, 0,
2, 0, 0, 0, 232, 2,
0, 0, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 41, 3,
0, 0, 32, 0, 0, 0,
4, 0, 0, 0, 2, 0,
0, 0, 60, 3, 0, 0,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 96, 3, 0, 0,
36, 0, 0, 0, 4, 0,
0, 0, 2, 0, 0, 0,
60, 3, 0, 0, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 255, 255,
255, 255, 0, 0, 0, 0,
104, 3, 0, 0, 40, 0,
0, 0, 8, 0, 0, 0,
0, 0, 0, 0, 172, 2,
0, 0, 0, 0, 0, 0,
255, 255, 255, 255, 0, 0,
0, 0, 255, 255, 255, 255,
0, 0, 0, 0, 67, 114,
111, 112, 79, 102, 102, 115,
101, 116, 0, 102, 108, 111,
97, 116, 50, 0, 171, 171,
1, 0, 3, 0, 1, 0,
2, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 163, 2, 0, 0,
67, 114, 111, 112, 83, 99,
97, 108, 101, 0, 71, 97,
109, 109, 97, 0, 102, 108,
111, 97, 116, 0, 171, 171,
0, 0, 3, 0, 1, 0,
1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 224, 2, 0, 0,
67, 111, 114, 110, 101, 114,
82, 97, 100, 105, 117, 115,
0, 79, 117, 116, 112, 117,
116, 87, 0, 79, 117, 116,
112, 117, 116, 72, 0, 83,
104, 97, 112, 101, 84, 121,
112, 101, 0, 100, 119, 111,
114, 100, 0, 171, 171, 171,
0, 0, 19, 0, 1, 0,
1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 51, 3, 0, 0,
72, 97, 115, 77, 97, 115,
107, 0, 80, 97, 100, 0,
77, 105, 99, 114, 111, 115,
111, 102, 116, 32, 40, 82,
41, 32, 72, 76, 83, 76,
32, 83, 104, 97, 100, 101,
114, 32, 67, 111, 109, 112,
105, 108, 101, 114, 32, 49,
48, 46, 49, 0, 73, 83,
71, 78, 80, 0, 0, 0,
2, 0, 0, 0, 8, 0,
0, 0, 56, 0, 0, 0,
0, 0, 0, 0, 1, 0,
0, 0, 3, 0, 0, 0,
0, 0, 0, 0, 15, 0,
0, 0, 68, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 3, 0, 0, 0,
1, 0, 0, 0, 3, 3,
0, 0, 83, 86, 95, 80,
79, 83, 73, 84, 73, 79,
78, 0, 84, 69, 88, 67,
79, 79, 82, 68, 0, 171,
171, 171, 79, 83, 71, 78,
44, 0, 0, 0, 1, 0,
0, 0, 8, 0, 0, 0,
32, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
3, 0, 0, 0, 0, 0,
0, 0, 15, 0, 0, 0,
83, 86, 95, 84, 65, 82,
71, 69, 84, 0, 171, 171,
83, 72, 69, 88, 108, 6,
0, 0, 80, 0, 0, 0,
155, 1, 0, 0, 106, 8,
0, 1, 89, 0, 0, 4,
70, 142, 32, 0, 0, 0,
0, 0, 3, 0, 0, 0,
90, 0, 0, 3, 0, 96,
16, 0, 0, 0, 0, 0,
88, 24, 0, 4, 0, 112,
16, 0, 0, 0, 0, 0,
85, 85, 0, 0, 88, 24,
0, 4, 0, 112, 16, 0,
1, 0, 0, 0, 85, 85,
0, 0, 88, 24, 0, 4,
0, 112, 16, 0, 2, 0,
0, 0, 85, 85, 0, 0,
98, 16, 0, 3, 50, 16,
16, 0, 1, 0, 0, 0,
101, 0, 0, 3, 242, 32,
16, 0, 0, 0, 0, 0,
104, 0, 0, 2, 4, 0,
0, 0, 56, 0, 0, 8,
50, 0, 16, 0, 0, 0,
0, 0, 70, 16, 16, 0,
1, 0, 0, 0, 230, 138,
32, 0, 0, 0, 0, 0,
1, 0, 0, 0, 32, 0,
0, 8, 66, 0, 16, 0,
0, 0, 0, 0, 10, 128,
32, 0, 0, 0, 0, 0,
2, 0, 0, 0, 1, 64,
0, 0, 3, 0, 0, 0,
31, 0, 4, 3, 42, 0,
16, 0, 0, 0, 0, 0,
56, 0, 0, 11, 194, 0,
16, 0, 0, 0, 0, 0,
166, 142, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
2, 64, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 63, 0, 0,
0, 63, 51, 0, 0, 7,
18, 0, 16, 0, 1, 0,
0, 0, 58, 0, 16, 0,
0, 0, 0, 0, 42, 0,
16, 0, 0, 0, 0, 0,
50, 0, 0, 11, 194, 0,
16, 0, 0, 0, 0, 0,
6, 20, 16, 0, 1, 0,
0, 0, 166, 142, 32, 0,
0, 0, 0, 0, 1, 0,
0, 0, 166, 14, 16, 128,
65, 0, 0, 0, 0, 0,
0, 0, 14, 0, 0, 7,
194, 0, 16, 0, 0, 0,
0, 0, 166, 14, 16, 0,
0, 0, 0, 0, 6, 0,
16, 0, 1, 0, 0, 0,
56, 0, 0, 7, 194, 0,
16, 0, 0, 0, 0, 0,
166, 14, 16, 0, 0, 0,
0, 0, 166, 14, 16, 0,
0, 0, 0, 0, 0, 0,
0, 7, 66, 0, 16, 0,
0, 0, 0, 0, 58, 0,
16, 0, 0, 0, 0, 0,
42, 0, 16, 0, 0, 0,
0, 0, 49, 0, 0, 7,
66, 0, 16, 0, 0, 0,
0, 0, 1, 64, 0, 0,
0, 0, 128, 63, 42, 0,
16, 0, 0, 0, 0, 0,
31, 0, 4, 3, 42, 0,
16, 0, 0, 0, 0, 0,
54, 0, 0, 8, 242, 32,
16, 0, 0, 0, 0, 0,
2, 64, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 62, 0, 0, 1,
21, 0, 0, 1, 18, 0,
0, 1, 80, 0, 0, 8,
66, 0, 16, 0, 0, 0,
0, 0, 10, 128, 32, 0,
0, 0, 0, 0, 2, 0,
0, 0, 1, 64, 0, 0,
1, 0, 0, 0, 31, 0,
4, 3, 42, 0, 16, 0,
0, 0, 0, 0, 49, 0,
0, 8, 194, 0, 16, 0,
0, 0, 0, 0, 86, 1,
16, 0, 0, 0, 0, 0,
86, 133, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
1, 0, 0, 7, 18, 0,
16, 0, 1, 0, 0, 0,
42, 0, 16, 0, 0, 0,
0, 0, 58, 0, 16, 0,
0, 0, 0, 0, 0, 0,
0, 10, 50, 0, 16, 0,
2, 0, 0, 0, 86, 133,
32, 128, 65, 0, 0, 0,
0, 0, 0, 0, 1, 0,
0, 0, 230, 138, 32, 0,
0, 0, 0, 0, 1, 0,
0, 0, 49, 0, 0, 7,
50, 0, 16, 0, 0, 0,
0, 0, 70, 0, 16, 0,
2, 0, 0, 0, 70, 0,
16, 0, 0, 0, 0, 0,
1, 0, 0, 7, 194, 0,
16, 0, 0, 0, 0, 0,
166, 14, 16, 0, 0, 0,
0, 0, 6, 4, 16, 0,
0, 0, 0, 0, 1, 0,
0, 7, 66, 0, 16, 0,
3, 0, 0, 0, 26, 0,
16, 0, 0, 0, 0, 0,
10, 0, 16, 0, 0, 0,
0, 0, 1, 0, 0, 7,
50, 0, 16, 0, 3, 0,
0, 0, 70, 0, 16, 0,
2, 0, 0, 0, 166, 10,
16, 0, 3, 0, 0, 0,
54, 0, 0, 6, 66, 0,
16, 0, 2, 0, 0, 0,
26, 128, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
54, 0, 0, 5, 130, 0,
16, 0, 2, 0, 0, 0,
1, 64, 0, 0, 255, 255,
255, 255, 55, 0, 0, 9,
178, 0, 16, 0, 0, 0,
0, 0, 246, 15, 16, 0,
0, 0, 0, 0, 102, 14,
16, 0, 2, 0, 0, 0,
70, 8, 16, 0, 3, 0,
0, 0, 55, 0, 0, 9,
114, 0, 16, 0, 0, 0,
0, 0, 166, 10, 16, 0,
0, 0, 0, 0, 134, 3,
16, 0, 2, 0, 0, 0,
70, 3, 16, 0, 0, 0,
0, 0, 55, 0, 0, 9,
114, 0, 16, 0, 0, 0,
0, 0, 6, 0, 16, 0,
1, 0, 0, 0, 166, 11,
16, 0, 2, 0, 0, 0,
70, 2, 16, 0, 0, 0,
0, 0, 31, 0, 4, 3,
42, 0, 16, 0, 0, 0,
0, 0, 50, 0, 0, 11,
50, 0, 16, 0, 0, 0,
0, 0, 70, 16, 16, 0,
1, 0, 0, 0, 230, 138,
32, 0, 0, 0, 0, 0,
1, 0, 0, 0, 70, 0,
16, 128, 65, 0, 0, 0,
0, 0, 0, 0, 56, 0,
0, 7, 50, 0, 16, 0,
0, 0, 0, 0, 70, 0,
16, 0, 0, 0, 0, 0,
70, 0, 16, 0, 0, 0,
0, 0, 0, 0, 0, 7,
18, 0, 16, 0, 0, 0,
0, 0, 26, 0, 16, 0,
0, 0, 0, 0, 10, 0,
16, 0, 0, 0, 0, 0,
56, 0, 0, 9, 34, 0,
16, 0, 0, 0, 0, 0,
26, 128, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
26, 128, 32, 0, 0, 0,
0, 0, 1, 0, 0, 0,
49, 0, 0, 7, 18, 0,
16, 0, 0, 0, 0, 0,
26, 0, 16, 0, 0, 0,
0, 0, 10, 0, 16, 0,
0, 0, 0, 0, 31, 0,
4, 3, 10, 0, 16, 0,
0, 0, 0, 0, 54, 0,
0, 8, 242, 32, 16, 0,
0, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
62, 0, 0, 1, 21, 0,
0, 1, 21, 0, 0, 1,
21, 0, 0, 1, 21, 0,
0, 1, 31, 0, 4, 4,
26, 128, 32, 0, 0, 0,
0, 0, 2, 0, 0, 0,
69, 0, 0, 139, 194, 0,
0, 128, 67, 85, 21, 0,
18, 0, 16, 0, 0, 0,
0, 0, 70, 16, 16, 0,
1, 0, 0, 0, 70, 126,
16, 0, 2, 0, 0, 0,
0, 96, 16, 0, 0, 0,
0, 0, 54, 32, 0, 5,
18, 0, 16, 0, 0, 0,
0, 0, 10, 0, 16, 0,
0, 0, 0, 0, 50, 0,
0, 11, 98, 0, 16, 0,
0, 0, 0, 0, 6, 17,
16, 0, 1, 0, 0, 0,
166, 139, 32, 0, 0, 0,
0, 0, 0, 0, 0, 0,
6, 129, 32, 0, 0, 0,
0, 0, 0, 0, 0, 0,
69, 0, 0, 139, 194, 0,
0, 128, 67, 85, 21, 0,
226, 0, 16, 0, 0, 0,
0, 0, 150, 5, 16, 0,
0, 0, 0, 0, 54, 121,
16, 0, 0, 0, 0, 0,
0, 96, 16, 0, 0, 0,
0, 0, 52, 0, 0, 10,
226, 0, 16, 0, 0, 0,
0, 0, 86, 14, 16, 0,
0, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 0,
111, 18, 131, 58, 111, 18,
131, 58, 111, 18, 131, 58,
47, 0, 0, 5, 226, 0,
16, 0, 0, 0, 0, 0,
86, 14, 16, 0, 0, 0,
0, 0, 56, 0, 0, 8,
226, 0, 16, 0, 0, 0,
0, 0, 86, 14, 16, 0,
0, 0, 0, 0, 6, 128,
32, 0, 0, 0, 0, 0,
1, 0, 0, 0, 25, 0,
0, 5, 226, 0, 16, 0,
0, 0, 0, 0, 86, 14,
16, 0, 0, 0, 0, 0,
69, 0, 0, 139, 194, 0,
0, 128, 67, 85, 21, 0,
114, 0, 16, 0, 1, 0,
0, 0, 70, 16, 16, 0,
1, 0, 0, 0, 70, 126,
16, 0, 1, 0, 0, 0,
0, 96, 16, 0, 0, 0,
0, 0, 0, 0, 0, 8,
226, 0, 16, 0, 0, 0,
0, 0, 86, 14, 16, 0,
0, 0, 0, 0, 6, 9,
16, 128, 65, 0, 0, 0,
1, 0, 0, 0, 50, 0,
0, 9, 114, 32, 16, 0,
0, 0, 0, 0, 6, 0,
16, 0, 0, 0, 0, 0,
150, 7, 16, 0, 0, 0,
0, 0, 70, 2, 16, 0,
1, 0, 0, 0, 54, 0,
0, 5, 130, 32, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 0, 0, 128, 63,
62, 0, 0, 1, 18, 0,
0, 1, 69, 0, 0, 139,
194, 0, 0, 128, 67, 85,
21, 0, 114, 0, 16, 0,
0, 0, 0, 0, 70, 16,
16, 0, 1, 0, 0, 0,
70, 126, 16, 0, 1, 0,
0, 0, 0, 96, 16, 0,
0, 0, 0, 0, 54, 0,
0, 5, 114, 32, 16, 0,
0, 0, 0, 0, 70, 2,
16, 0, 0, 0, 0, 0,
54, 0, 0, 5, 130, 32,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 0, 0,
128, 63, 62, 0, 0, 1,
21, 0, 0, 1, 62, 0,
0, 1, 83, 84, 65, 84,
148, 0, 0, 0, 63, 0,
0, 0, 4, 0, 0, 0,
0, 0, 0, 0, 2, 0,
0, 0, 23, 0, 0, 0,
1, 0, 0, 0, 5, 0,
0, 0, 7, 0, 0, 0,
5, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
4, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 8, 0, 0, 0,
3, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0
};

View File

@@ -0,0 +1,162 @@
#if 0
//
// Generated by Microsoft (R) HLSL Shader Compiler 10.1
//
//
//
// Input signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_VertexID 0 x 0 VERTID uint x
//
//
// Output signature:
//
// Name Index Mask Register SysValue Format Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_POSITION 0 xyzw 0 POS float xyzw
// TEXCOORD 0 xy 1 NONE float xy
//
vs_5_0
dcl_globalFlags refactoringAllowed
dcl_input_sgv v0.x, vertex_id
dcl_output_siv o0.xyzw, position
dcl_output o1.xy
dcl_temps 1
bfi r0.x, l(1), l(1), v0.x, l(0)
and r0.z, v0.x, l(2)
utof r0.xy, r0.xzxx
mad o0.xy, r0.xyxx, l(2.000000, -2.000000, 0.000000, 0.000000), l(-1.000000, 1.000000, 0.000000, 0.000000)
mov o1.xy, r0.xyxx
mov o0.zw, l(0,0,0,1.000000)
ret
// Approximately 7 instruction slots used
#endif
const BYTE g_WebcamCompositeVS[] =
{
68, 88, 66, 67, 94, 195,
253, 40, 165, 172, 45, 84,
30, 136, 47, 40, 247, 112,
58, 27, 1, 0, 0, 0,
224, 2, 0, 0, 5, 0,
0, 0, 52, 0, 0, 0,
160, 0, 0, 0, 212, 0,
0, 0, 44, 1, 0, 0,
68, 2, 0, 0, 82, 68,
69, 70, 100, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
60, 0, 0, 0, 0, 5,
254, 255, 0, 1, 0, 0,
60, 0, 0, 0, 82, 68,
49, 49, 60, 0, 0, 0,
24, 0, 0, 0, 32, 0,
0, 0, 40, 0, 0, 0,
36, 0, 0, 0, 12, 0,
0, 0, 0, 0, 0, 0,
77, 105, 99, 114, 111, 115,
111, 102, 116, 32, 40, 82,
41, 32, 72, 76, 83, 76,
32, 83, 104, 97, 100, 101,
114, 32, 67, 111, 109, 112,
105, 108, 101, 114, 32, 49,
48, 46, 49, 0, 73, 83,
71, 78, 44, 0, 0, 0,
1, 0, 0, 0, 8, 0,
0, 0, 32, 0, 0, 0,
0, 0, 0, 0, 6, 0,
0, 0, 1, 0, 0, 0,
0, 0, 0, 0, 1, 1,
0, 0, 83, 86, 95, 86,
101, 114, 116, 101, 120, 73,
68, 0, 79, 83, 71, 78,
80, 0, 0, 0, 2, 0,
0, 0, 8, 0, 0, 0,
56, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0,
3, 0, 0, 0, 0, 0,
0, 0, 15, 0, 0, 0,
68, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
3, 0, 0, 0, 1, 0,
0, 0, 3, 12, 0, 0,
83, 86, 95, 80, 79, 83,
73, 84, 73, 79, 78, 0,
84, 69, 88, 67, 79, 79,
82, 68, 0, 171, 171, 171,
83, 72, 69, 88, 16, 1,
0, 0, 80, 0, 1, 0,
68, 0, 0, 0, 106, 8,
0, 1, 96, 0, 0, 4,
18, 16, 16, 0, 0, 0,
0, 0, 6, 0, 0, 0,
103, 0, 0, 4, 242, 32,
16, 0, 0, 0, 0, 0,
1, 0, 0, 0, 101, 0,
0, 3, 50, 32, 16, 0,
1, 0, 0, 0, 104, 0,
0, 2, 1, 0, 0, 0,
140, 0, 0, 11, 18, 0,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 1, 0,
0, 0, 1, 64, 0, 0,
1, 0, 0, 0, 10, 16,
16, 0, 0, 0, 0, 0,
1, 64, 0, 0, 0, 0,
0, 0, 1, 0, 0, 7,
66, 0, 16, 0, 0, 0,
0, 0, 10, 16, 16, 0,
0, 0, 0, 0, 1, 64,
0, 0, 2, 0, 0, 0,
86, 0, 0, 5, 50, 0,
16, 0, 0, 0, 0, 0,
134, 0, 16, 0, 0, 0,
0, 0, 50, 0, 0, 15,
50, 32, 16, 0, 0, 0,
0, 0, 70, 0, 16, 0,
0, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 64,
0, 0, 0, 192, 0, 0,
0, 0, 0, 0, 0, 0,
2, 64, 0, 0, 0, 0,
128, 191, 0, 0, 128, 63,
0, 0, 0, 0, 0, 0,
0, 0, 54, 0, 0, 5,
50, 32, 16, 0, 1, 0,
0, 0, 70, 0, 16, 0,
0, 0, 0, 0, 54, 0,
0, 8, 194, 32, 16, 0,
0, 0, 0, 0, 2, 64,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 128, 63,
62, 0, 0, 1, 83, 84,
65, 84, 148, 0, 0, 0,
7, 0, 0, 0, 1, 0,
0, 0, 0, 0, 0, 0,
3, 0, 0, 0, 1, 0,
0, 0, 0, 0, 0, 0,
1, 0, 0, 0, 1, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 2, 0,
0, 0, 0, 0, 0, 0,
1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0
};

View File

@@ -121,7 +121,7 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
BEGIN
DEFPUSHBUTTON "OK",IDOK,184,308,50,14
PUSHBUTTON "Cancel",IDCANCEL,241,308,50,14
LTEXT "ZoomIt v12.0",IDC_VERSION,42,7,73,10
LTEXT "ZoomIt v12.1",IDC_VERSION,42,7,73,10
LTEXT "Copyright \251 2006-2026 Mark Russinovich",IDC_COPYRIGHT,42,17,251,8
CONTROL "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",IDC_LINK,
"SysLink",WS_TABSTOP,42,26,150,9
@@ -267,33 +267,52 @@ RECORD DIALOGEX 0, 0, 263, 228
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
CONTROL "",IDC_RECORD_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,61,71,80,12
LTEXT "Record Toggle:",IDC_STATIC,7,73,54,8
CONTROL "",IDC_RECORD_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,65,59,80,12
RTEXT "Record Toggle:",IDC_STATIC,7,62,54,8
LTEXT "Record video of the unzoomed live screen or a static zoomed session by entering the recording hot key and finish the recording by entering it again. ",IDC_STATIC,7,7,248,22
LTEXT "Scaling:",IDC_STATIC,30,90,26,8
COMBOBOX IDC_RECORD_SCALING,61,89,26,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | WS_VSCROLL | WS_TABSTOP
CONTROL "16:9:",IDC_RECORD_ASPECT_RATIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,93,90,30,10
LTEXT "Format:",IDC_STATIC,30,108,26,8
COMBOBOX IDC_RECORD_FORMAT,61,106,60,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | WS_VSCROLL | WS_TABSTOP
LTEXT "Frame Rate:",IDC_STATIC,135,90,44,8,NOT WS_VISIBLE
COMBOBOX IDC_RECORD_FRAME_RATE,182,89,42,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | NOT WS_VISIBLE | WS_VSCROLL | WS_TABSTOP
LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,29,245,18
LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,48,251,19
CONTROL "Capture &system audio",IDC_CAPTURE_SYSTEM_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,124,86,10
CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,125,124,83,10
COMBOBOX IDC_MICROPHONE,81,137,128,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Microphone:",IDC_MICROPHONE_LABEL,34,139,47,8
RTEXT "Scaling:",IDC_STATIC,36,79,26,8
COMBOBOX IDC_RECORD_SCALING,65,78,26,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | WS_VSCROLL | WS_TABSTOP
CONTROL "16:9:",IDC_RECORD_ASPECT_RATIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,94,79,30,10
RTEXT "Format:",IDC_STATIC,36,97,26,8
COMBOBOX IDC_RECORD_FORMAT,65,96,60,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | WS_VSCROLL | WS_TABSTOP
LTEXT "Frame Rate:",IDC_STATIC,134,79,44,8,NOT WS_VISIBLE
COMBOBOX IDC_RECORD_FRAME_RATE,177,78,42,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | NOT WS_VISIBLE | WS_VSCROLL | WS_TABSTOP
LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,7,245,18
LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,38,251,19
CONTROL "Capture &system audio:",IDC_CAPTURE_SYSTEM_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | BS_RIGHT | WS_TABSTOP,7,115,90,10
CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | BS_RIGHT | WS_TABSTOP,11,133,86,10
CONTROL "Mono:",IDC_MIC_MONO_MIX,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | BS_RIGHT | WS_TABSTOP,108,147,48,10
COMBOBOX IDC_MICROPHONE,52,162,208,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Microphone:",IDC_MICROPHONE_LABEL,7,163,42,8
CONTROL "&Noise cancellation:",IDC_NOISE_CANCELLATION,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | BS_RIGHT | WS_TABSTOP,23,147,74,10
CONTROL "Show &webcam overlay (Ctrl+C toggles)",IDC_WEBCAM_OVERLAY,
"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,153,148,10
LTEXT "Camera:",IDC_WEBCAM_DEVICE_LABEL,46,167,28,8
COMBOBOX IDC_WEBCAM_DEVICE,82,165,127,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Position:",IDC_WEBCAM_POSITION_LABEL,33,185,32,8
COMBOBOX IDC_WEBCAM_POSITION,64,183,55,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Size:",IDC_WEBCAM_SIZE_LABEL,137,185,20,8
COMBOBOX IDC_WEBCAM_SIZE,159,183,50,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Shape:",IDC_WEBCAM_SHAPE_LABEL,33,201,24,8
COMBOBOX IDC_WEBCAM_SHAPE,64,199,80,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
PUSHBUTTON "&Trim",IDC_TRIM_FILE,207,207,53,14
"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,181,148,10
PUSHBUTTON "Webcam S&ettings...",IDC_WEBCAM_SETTINGS,192,180,68,14
PUSHBUTTON "&Trim",IDC_TRIM_FILE,207,209,53,14
END
WEBCAM_SETTINGS DIALOGEX 0, 0, 220, 163
STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Webcam Settings"
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Camera:",IDC_WEBCAM_DEVICE_LABEL,14,10,28,8
COMBOBOX IDC_WEBCAM_DEVICE,50,8,158,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Position:",IDC_WEBCAM_POSITION_LABEL,14,28,32,8
COMBOBOX IDC_WEBCAM_POSITION,50,26,65,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Size:",IDC_WEBCAM_SIZE_LABEL,125,28,20,8
COMBOBOX IDC_WEBCAM_SIZE,148,26,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Shape:",IDC_WEBCAM_SHAPE_LABEL,14,46,24,8
COMBOBOX IDC_WEBCAM_SHAPE,50,44,80,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
LTEXT "Background:",IDC_WEBCAM_BG_LABEL,14,64,44,8
COMBOBOX IDC_WEBCAM_BG_MODE,60,62,55,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
EDITTEXT IDC_WEBCAM_BG_IMAGE,117,62,83,12,ES_AUTOHSCROLL | ES_READONLY
PUSHBUTTON "...",IDC_WEBCAM_BG_BROWSE,202,62,14,12
LTEXT "Brightness:",IDC_WEBCAM_BRIGHTNESS_LABEL,14,82,44,8
CONTROL "",IDC_WEBCAM_BRIGHTNESS_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_NOTICKS | TBS_TOOLTIPS | WS_TABSTOP,60,80,148,15
LTEXT "Uses MediaPipe SelfieSegmentation (Apache 2.0)",IDC_THIRDPARTY_NOTICES,14,102,200,8
DEFPUSHBUTTON "OK",IDOK,108,142,50,14
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
END
SNIP DIALOGEX 0, 0, 260, 80

Some files were not shown because too many files have changed in this diff Show More