diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 223a3f0..ede7e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,7 @@ # A version tag (v0.1.0): test, build the installer with that version, and publish it as a GitHub Release. # 0.x versions and versions with a suffix (v1.0.0-rc1) are marked as pre-releases. +# The release text is release-notes/.md (release-notes/v1.2.0.md), followed by GitHub's generated list of changes. +# A release needs its notes file; a pre-release without one gets the generated list only. name: Release on: @@ -15,6 +17,25 @@ jobs: steps: - uses: actions/checkout@v4 + # Checked first, so a missing notes file fails the run in seconds rather than after the build. + - name: Find the release notes + id: notes + shell: pwsh + run: | + $tag = '${{ github.ref_name }}' + $version = $tag.TrimStart('v') + $prerelease = $version.StartsWith('0.') -or $version.Contains('-') + $file = "release-notes/$tag.md" + if (Test-Path $file) { + if (-not (Get-Content $file -Raw).Trim()) { throw "$file is empty: write the release notes, then tag again" } + "file=$file" >> $env:GITHUB_OUTPUT + } elseif ($prerelease) { + Write-Host "::notice::No $file; the pre-release gets GitHub's generated notes only" + } else { + throw "No release notes for $tag. Add $file (see release-notes/README.md), then tag again." + } + "prerelease=$($prerelease.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + - uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' @@ -53,8 +74,12 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + NOTES_FILE: ${{ steps.notes.outputs.file }} + PRERELEASE: ${{ steps.notes.outputs.prerelease }} run: | $version = '${{ github.ref_name }}'.TrimStart('v') + # With --generate-notes, the notes file comes first and GitHub's list of changes is added after it. $flags = @('--title', "OrthoStreamXP $version", '--generate-notes') - if ($version.StartsWith('0.') -or $version.Contains('-')) { $flags += '--prerelease' } + if ($env:NOTES_FILE) { $flags += @('--notes-file', $env:NOTES_FILE) } + if ($env:PRERELEASE -eq 'true') { $flags += '--prerelease' } gh release create '${{ github.ref_name }}' (Get-ChildItem artifacts\releases -File).FullName @flags diff --git a/CLAUDE.md b/CLAUDE.md index bd98cc9..a56cf98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ dotnet test --solution OrthoStreamXP.slnx --filter-method "*Name*" # single te - `TreatWarningsAsErrors` and `AnalysisMode=Recommended` are on (`Directory.Build.props`): warnings fail the build. - `MountSmokeTests` performs a real WinFsp mount with synthetic imagery; it is skipped locally if WinFsp is missing, but always runs when the `CI` env var is set. CI (`.github/workflows/ci.yml`) runs on Windows and installs WinFsp. - Tests use `FakeTileServer` / `TempTree` helpers instead of the network. +- Releases: pushing a `v*` tag runs `.github/workflows/release.yml`, which needs `release-notes/.md` (pre-releases may skip it) and puts it above GitHub's generated notes. See `release-notes/README.md`. ## Branching diff --git a/README.md b/README.md index 5226af7..420e510 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Traditional photo scenery often requires downloading and building large areas be - **Settings that adapt to your setup** — test your connection before the first flight to get a recommended preset, and after a sufficiently long flight, get recommendations based on how the scenery performed on your PC and internet connection. You decide whether to apply them. - **Even colours** — optionally soften the patchwork look where neighbouring photos were taken on different days, so a grey, hazy patch stands out less. - **Seasonal colours** — optionally give summer imagery a more autumnal or winter appearance, based on the simulated date and location. +- **Clean photos up close** — X-Plane's procedural grass and dirt speckles are kept off the aerial photos by default, or faded to the strength you like, without changing your scenery packages. - **Exposure and a live preview** — tone down photos that look too bright next to X-Plane's own scenery, and see any colour change on a photo from your cache before you fly. - **See what happened during your flight** — review download performance, scenery loading, and your flight route. diff --git a/docs/OrthoStreamXP-UserGuide.pdf b/docs/OrthoStreamXP-UserGuide.pdf index 819dcab..2c08015 100644 Binary files a/docs/OrthoStreamXP-UserGuide.pdf and b/docs/OrthoStreamXP-UserGuide.pdf differ diff --git a/docs/README.md b/docs/README.md index 92d1d55..d508839 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ OrthoStreamXP adds real-world aerial photos to your X-Plane 12 scenery while you 1. [Installation](installation.md): what you need and how to install it. 2. [First setup](first-setup.md): the six-step setup that runs the first time you open OrthoStreamXP. 3. [Flying](flying.md): the start order, the Home page, and the report you get after each flight. -4. [Settings](settings.md): quality, folders, downloads, colours, seasons and exposure. +4. [Settings](settings.md): quality and ground detail, folders, downloads, colours, seasons and exposure. 5. [Cache](cache.md): how much disk space the scenery uses and how to free it. 6. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages. 7. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug. diff --git a/docs/images/settings-quality.png b/docs/images/settings-quality.png index cfd4d10..27afb25 100644 Binary files a/docs/images/settings-quality.png and b/docs/images/settings-quality.png differ diff --git a/docs/settings.md b/docs/settings.md index 7f3ba33..026cdbb 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -17,7 +17,7 @@ At the bottom of every tab: Choose how sharp the ground is. Each preset comes with settings tested for it and keeps its own textures in the cache, so you can switch at any time without losing anything. -| Preset | Textures | Imagery zoom | Ground detail | Best for | +| Preset | Textures | Imagery zoom | Resolution | Best for | |---|---|---|---|---| | **Balanced** | 1024 px | 15, 16 near airports | ~6.8 m per pixel | High flying, a slower connection or little graphics memory | | **Sharp** | 2048 px | 16, 17 near airports | ~3.4 m per pixel | Everyday flying, approaches included | @@ -31,6 +31,18 @@ Each card shows the imagery zoom, the metres per pixel, roughly how long a new a - The **Recommended** badge marks the preset that suits your connection and graphics card, with the reason below it. - If a preset's photo textures plus X-Plane's own needs (about 5 GB) would be more than your graphics card has, its card shows a warning. X-Plane may then stutter or lower its texture detail; a smaller preset is safer. +### Ground detail + +Up close, X-Plane can sprinkle its own procedural grass, shrubs and dirt over the ground. This helps X-Plane's blurry default textures, but on sharp aerial photos it looks like noise: dark speckles and pale stony patches that the real imagery does not have. The photo scenery packages ask for it in every photo tile, so you see it with any scenery packages. + +The **Ground detail** slider sets how strong this effect is: + +- **Off** (0 %, the default): the photos are shown as they are. +- **10–90 %**: a lighter version of X-Plane's effect, for some extra texture when you are low. +- **100 %**: X-Plane's full effect, as the packages define it. + +Your scenery packages are not changed: OrthoStreamXP adjusts the terrain files as X-Plane reads them. The in-between strengths are made from X-Plane's own decal files, so they need the **X-Plane folder** to be set. Like every setting, a change applies from the next start. + ## Folders ![Settings: Folders](images/settings-folders.png) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e6d8fb4..6a9785a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,6 +22,7 @@ When something goes wrong, Home shows a notice that says what happened, usually | **Blurry ground for a while** | A new area takes a few minutes the first time. If it happens often, try a lower preset or apply the tuning suggestions after your flight (see [Flying](flying.md#tuning-suggestions)). | | **Patchy colours between photos** | Turn on **Even out the colours between photos** in [Settings › Colours](settings.md#colours). | | **Photos look too bright next to X-Plane's own scenery** | Lower **Exposure** in [Settings › Colours](settings.md#exposure). | +| **Noisy, speckled ground up close** | X-Plane's grass and dirt detail is drawn over the photos. Set **Ground detail** to **Off** (or lower it) in [Settings › Quality](settings.md#ground-detail). | ## The Log page diff --git a/release-notes/README.md b/release-notes/README.md new file mode 100644 index 0000000..3589514 --- /dev/null +++ b/release-notes/README.md @@ -0,0 +1,37 @@ +# Release notes + +One Markdown file per release, named after its tag: `v1.2.0.md` for the tag `v1.2.0`. + +When the tag is pushed, the [release workflow](../.github/workflows/release.yml) puts the file's text at the top of +the GitHub Release. GitHub's generated list of changes (merged pull requests, new contributors) follows it. + +## Releasing + +1. Write `release-notes/vX.Y.Z.md` and commit it with the release. +2. Push the tag `vX.Y.Z`. + +A release (`v1.2.0`) needs its notes file. If the file is missing or empty, the workflow stops at its first step, +before anything is built or published: add the file, then delete and push the tag again. A pre-release (`v0.x`, or a +suffix such as `v1.2.0-rc1`) may have no notes file; it then gets the generated list only. + +## Writing the notes + +The notes are for people who fly with OrthoStreamXP, not for developers: say what changed for them, in plain words. +GitHub already lists the pull requests below, so there is no need to repeat them. + +```markdown +## What's new + +- **Feature name.** What it does and where to find it. + +## Fixes + +- What no longer goes wrong. + +## Good to know + +- Anything to do after updating, or a change of behaviour people should expect. +``` + +Leave out the sections that have nothing in them. The page title ("OrthoStreamXP 1.2.0") is set by the workflow, +so the file needs no title of its own. diff --git a/release-notes/v1.2.0.md b/release-notes/v1.2.0.md new file mode 100644 index 0000000..8f739b2 --- /dev/null +++ b/release-notes/v1.2.0.md @@ -0,0 +1,7 @@ +## What's new + +- **Ground detail.** X-Plane draws its own procedural grass, shrubs and dirt over the ground up close. On sharp aerial photos this looks like noise: dark speckles and pale stony patches that the real imagery does not have. It is now off by default, so the photos look as they do on the map. If you like some extra texture when flying low, raise **Ground detail** in **Settings › Quality**: anything between 10 and 90 % is a lighter version of the effect, and 100 % is X-Plane's full effect. Your scenery packages are not changed. + +## Good to know + +- After updating, the ground up close looks cleaner than before. To get the old look back, set **Ground detail** to 100 %. diff --git a/src/OrthoStreamXP/Configuration/MountSettings.cs b/src/OrthoStreamXP/Configuration/MountSettings.cs index 5bd1f78..e9cc6a9 100644 --- a/src/OrthoStreamXP/Configuration/MountSettings.cs +++ b/src/OrthoStreamXP/Configuration/MountSettings.cs @@ -40,6 +40,8 @@ internal sealed record MountSettings public double ProvisionalWait { get; init; } public string? TextureStore { get; init; } public bool LazyTerrainListing { get; init; } + /// How strong X-Plane's detail decals are on the photo terrain, 0 (left out) to 100 percent (as the packages define). + public int GroundDetail { get; init; } /// A texture-quality value from an older settings file: no longer used, noted in the log. public string? IgnoredTextureQuality { get; init; } /// Photo textures are served level by level as X-Plane reads them (off: built whole first). @@ -137,6 +139,8 @@ public static MountSettings FromValues(SettingValues values, bool console = true throw new SettingsException($"exposure must be between {OrthoStreamXP.Textures.Exposure.Min} and {OrthoStreamXP.Textures.Exposure.Full}"); string readLogValue = values.String("read-log") ?? "off"; bool readLog = ParseOnOff(readLogValue) ?? throw new SettingsException($"read-log must be on or off, not '{readLogValue}'"); + int groundDetail = values.Int("ground-detail", 0); + if (groundDetail is < 0 or > Scenery.TerrainDecals.Full) throw new SettingsException("ground-detail must be between 0 and 100"); if (downloadWorkers < 1) throw new SettingsException("download-workers must be at least 1"); if (downloadTimeout is < 1 or > 120) throw new SettingsException("download-timeout must be between 1 and 120 seconds"); @@ -188,6 +192,7 @@ public static MountSettings FromValues(SettingValues values, bool console = true ProvisionalWait = provisionalWait, TextureStore = textureStore, LazyTerrainListing = values.Flag("lazy-terrain-listing"), + GroundDetail = groundDetail, IgnoredTextureQuality = values.String("texture-quality"), MipStreaming = mipStreaming, NearWait = nearWait, diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs index e801d1c..c08ca95 100644 --- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs +++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs @@ -29,6 +29,7 @@ internal static class SettingsOptions .Add("filesystem-threads", $"Threads answering X-Plane's file requests, 2-512. A request waiting for a texture build keeps its thread, so this must comfortably exceed the textures X-Plane waits for at once (default: {MountSettings.DefaultFileSystemThreads})") .Add("texture-quality", "No longer used: the texture size follows level-change (accepted so older settings files still load)") .Add("background-textures", "Build missing DDS files in a bounded background queue when a local DDS fallback exists", arity: 0) + .Add("ground-detail", "How strong X-Plane's detail decals are over the photo scenery, 0-100 percent. The terrain definitions name a decal library (DECAL_LIB) that sprinkles procedural grass, shrubs and dirt over the ground up close; it adds texture to X-Plane's own blurry land-class textures, but over sharp aerial photos it looks like noise. 0 leaves the decals out, 100 keeps them as the packages define, and anything between fades them (read from the X-Plane folder, so it needs xplane). Applied to the photo tiles' terrain definitions as X-Plane reads them; the packages' files are not changed. Takes effect at the next start (default: 0)") .Add("lazy-terrain-listing", "Show an empty terrain directory listing; DSF-referenced .ter files remain directly accessible", arity: 0) .Add("prefetch-loaded-scenery", "on or off: build the textures of each DSF X-Plane loads as soon as it opens the DSF's terrain definitions, before X-Plane asks for them one by one (default: on)") .Add("track-aircraft", "Follow the aircraft through X-Plane's UDP DATA output (row 20) and build the loaded scenery's textures ahead of it", arity: 0) diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs index 954d200..7ab77a6 100644 --- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs +++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs @@ -6,6 +6,7 @@ internal static class SettingNames private static readonly Dictionary Names = new(StringComparer.Ordinal) { ["level-change"] = "Quality", + ["ground-detail"] = "Ground detail", ["xplane"] = "X-Plane folder", ["base-packages"] = "Scenery packages folder", ["source"] = "Scenery packages folder", diff --git a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs index c6b3266..7308bca 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs @@ -289,6 +289,24 @@ public double Exposure public string ExposureText => $"{Exposure:0} %"; + /// How strong X-Plane's grass and dirt decals are over the photos, in percent, for the slider. + public double GroundDetail + { + get => _document.GetDouble("ground-detail") ?? 0; + set + { + Save("ground-detail", Math.Clamp((int)Math.Round(value), 0, Scenery.TerrainDecals.Full).ToString(CultureInfo.InvariantCulture)); + Raise(); + Raise(nameof(GroundDetailText)); + } + } + + public string GroundDetailText => GroundDetail switch + { + <= 0 => "Off", + _ => $"{GroundDetail:0} %", + }; + public string XPlaneFolder { get => _xplane; @@ -464,6 +482,7 @@ public void Reload() { nameof(Provider), nameof(DownloadWorkers), nameof(DownloadWorkersText), nameof(FilePath), nameof(CacheEstimate), nameof(MatchColoursOn), nameof(MatchColoursOnText), nameof(MatchStrength), nameof(MatchStrengthText), nameof(SeasonsOn), nameof(SeasonsOnText), nameof(Season), nameof(SeasonStrength), nameof(SeasonStrengthText), nameof(Exposure), nameof(ExposureText), + nameof(GroundDetail), nameof(GroundDetailText), nameof(TunedRows), nameof(TuningText), nameof(CanReset), nameof(ResetQuestion), }) Raise(name); diff --git a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml index d7a60fc..8046540 100644 --- a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml +++ b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml @@ -122,6 +122,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/OrthoStreamXP/Mounting/MountSession.cs b/src/OrthoStreamXP/Mounting/MountSession.cs index fa374c6..d97a46a 100644 --- a/src/OrthoStreamXP/Mounting/MountSession.cs +++ b/src/OrthoStreamXP/Mounting/MountSession.cs @@ -131,13 +131,21 @@ private void StartAll() if (s.TextureStore is not null) Log.Info($"Lazy local DDS store: {s.TextureStore}"); if (s.LazyTerrainListing) Log.Info("Terrain directory listing is lazy; DSF-referenced definitions remain available by direct path"); + // X-Plane's own folder holds the decal libraries; with a plain mountpoint there is none. + var decals = new TerrainDecals(Path.GetDirectoryName(s.CustomScenery), s.GroundDetail); + Log.Info(s.GroundDetail switch + { + 0 => "Ground detail: 0 %; X-Plane's grass and dirt decals are left out of the photo terrain (the packages are not changed)", + TerrainDecals.Full => "Ground detail: 100 %; X-Plane draws its grass and dirt decals over the photos as the packages define", + _ => $"Ground detail: {s.GroundDetail} %; X-Plane's grass and dirt decals are faded over the photos (the packages are not changed)", + }); foreach (PackagePlan package in s.Packages) { var roots = new SceneryRoots(package.Source, package.Cache, s.TextureStore, s.Textures); TerrainDefinitions.RemoveCachedCopies(package.Cache); roots.RenameToCacheNames(); - _mounts.Add(new PackageMount(package, roots, builder, textureOptions, s.TrackAircraft, s.LoadAhead, s.LazyTerrainListing, + _mounts.Add(new PackageMount(package, roots, builder, textureOptions, s.TrackAircraft, s.LoadAhead, s.LazyTerrainListing, decals, streaming ? new TextureStreamer(roots, builder!, _view, model, streamingOptions) : null, _view, _readLog, _seasons, _usage, _colours, _loads, exposure)); Log.Info($"Package {package.Name}: read-only from {package.Source}, cache overlay {package.Cache}, mounted at {package.Mountpoint}"); } diff --git a/src/OrthoStreamXP/Mounting/PackageMount.cs b/src/OrthoStreamXP/Mounting/PackageMount.cs index 5de10a0..ecfa834 100644 --- a/src/OrthoStreamXP/Mounting/PackageMount.cs +++ b/src/OrthoStreamXP/Mounting/PackageMount.cs @@ -17,7 +17,7 @@ internal sealed class PackageMount : IDisposable private bool _mounted; public PackageMount(PackagePlan plan, SceneryRoots roots, TextureBuilder? builder, TextureOptions textureOptions, - bool trackAircraft, bool loadAhead, bool lazyTerrainListing, TextureStreamer? streamer, ViewTracker view, ReadLog? readLog, + bool trackAircraft, bool loadAhead, bool lazyTerrainListing, TerrainDecals decals, TextureStreamer? streamer, ViewTracker view, ReadLog? readLog, SeasonClock? seasons, AreaUsage? usage, ColourMatcher? colours, SceneryLoads loads, ushort[]? exposure) { Plan = plan; @@ -34,7 +34,7 @@ public PackageMount(PackagePlan plan, SceneryRoots roots, TextureBuilder? builde // Two builds queued per worker keep every worker busy; the rest wait in load-ahead's own priority order. LoadAhead = Catalog is null || !loadAhead ? null : new LoadAheadPrefetcher(Catalog, Scheduler!, roots, textureOptions.DdsBuildWorkers * 2); } - Overlay = new SceneryOverlay(roots, Scheduler, lazyTerrainListing, Catalog, streamer); + Overlay = new SceneryOverlay(roots, Scheduler, lazyTerrainListing, Catalog, streamer, decals); _host = new FileSystemHost(new SceneryFileSystem(Overlay, "OrthoStreamXP", readLog, plan.Name, seasons, usage, colours, loads, exposure)); } diff --git a/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs b/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs index e50ea5a..9ddbc63 100644 --- a/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs +++ b/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs @@ -18,6 +18,7 @@ internal sealed class SceneryOverlay private readonly SceneryRoots _roots; private readonly TerrainDefinitions _terrain; + private readonly TerrainDecals? _decals; private readonly TextureScheduler? _textures; private readonly TextureStreamer? _streamer; private readonly TextureCatalog? _catalog; @@ -29,11 +30,13 @@ internal sealed class SceneryOverlay /// Learns the loaded scenery's textures from the terrain definitions X-Plane opens (for prefetch). /// Serves photo textures level by level instead of building them whole (then is null). + /// X-Plane's detail decals at the chosen strength; null keeps them as the package defines. public SceneryOverlay(SceneryRoots roots, TextureScheduler? textures, bool lazyTerrainListing, TextureCatalog? catalog = null, - TextureStreamer? streamer = null) + TextureStreamer? streamer = null, TerrainDecals? decals = null) { _roots = roots; - _terrain = new TerrainDefinitions(roots); + _decals = decals; + _terrain = new TerrainDefinitions(roots, decals); _textures = textures; _streamer = streamer; _catalog = catalog; @@ -57,6 +60,8 @@ public SceneryOverlay(SceneryRoots roots, TextureScheduler? textures, bool lazyT /// public OverlayEntry Open(VirtualPath path) { + // The faded decal textures the photo terrain definitions point at. + if (_decals?.Texture(path) is { } decal) return new OverlayEntry(null, null, decal); if (_terrain.Resolve(path) is { } definition) { LogRequest(path); diff --git a/src/OrthoStreamXP/Scenery/FileSystem/TerrainDecals.cs b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDecals.cs new file mode 100644 index 0000000..cc4f86e --- /dev/null +++ b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDecals.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; +using SkiaSharp; + +namespace OrthoStreamXP.Scenery; + +/// +/// X-Plane's detail decals on the photo terrain, at a chosen strength. A tile's terrain definition names a decal +/// library (DECAL_LIB lib/g10/decals/....dcl) that sprinkles procedural grass, shrubs and dirt over the ground up +/// close; over sharp aerial photos it looks like noise. The decals modulate the ground's colours around mid-grey +/// (2x modulation: 128 leaves it as it is), so a decal texture faded towards grey is the same decal, weaker. +/// +/// 100 %: the library line stays as the package defines it; +/// 0 %: the decal lines are left out; +/// in between: the library's own decal lines are written into the definition in its place, pointing at +/// faded copies of its textures that the mount serves from memory under . +/// +/// A library that cannot be read from X-Plane's folder (or uses more than plain decal lines) stays as it is. +/// +internal sealed class TerrainDecals +{ + public const int Full = 100; + /// The package folder the faded decal textures are served under (it exists only in the mount). + public const string Folder = "orthostreamxp_decals"; + private const string LibraryPrefix = "lib/g10/decals/"; + + private readonly string? _decalsPackage; + private readonly Lazy> _exports; + private readonly ConcurrentDictionary _replacements = new(StringComparer.OrdinalIgnoreCase); + /// Served texture name → X-Plane's original file. + private readonly ConcurrentDictionary _sources = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _faded = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _registerLock = new(); + + /// The X-Plane folder, where the decal libraries are; null when the mount has none. + /// 0 to 100 percent of the decals' effect. + public TerrainDecals(string? xplane, int strength) + { + ArgumentOutOfRangeException.ThrowIfNegative(strength); + ArgumentOutOfRangeException.ThrowIfGreaterThan(strength, Full); + Strength = strength; + _decalsPackage = xplane is null ? null : Path.Join(xplane, "Resources", "default scenery", "1000 decals"); + _exports = new Lazy>(ReadExports); + } + + public int Strength { get; } + + /// + /// What replaces a definition's "DECAL_LIB " line: null keeps the line, "" removes + /// it, otherwise the library's decal lines (each ending in a line break) with faded textures. + /// + public string? Replacement(string library) => Strength switch + { + <= 0 => "", + >= Full => null, + _ => _replacements.GetOrAdd(library, Inline), + }; + + /// A faded decal texture (PNG) for a path under , or null for any other path. + public byte[]? Texture(VirtualPath path) + { + if (path.Parts.Length != 2 || !path.IsUnder(Folder) || !_sources.TryGetValue(path.Name, out string? source)) return null; + return _faded.GetOrAdd(path.Name, _ => new Lazy(() => Faded(source))).Value; + } + + private string? Inline(string library) + { + if (_decalsPackage is null) + { + Log.Warning($"Ground detail {Strength} % needs the X-Plane folder to read {library}; it is kept at full strength"); + return null; + } + if (!_exports.Value.TryGetValue(library, out string? file) || !File.Exists(file)) + { + Log.Warning($"Decal library {library} is not in {_decalsPackage}; it is kept at full strength"); + return null; + } + string[] lines = File.ReadAllText(file).ReplaceLineEndings("\n").Split('\n'); + // The header: A (or I), the version, DECAL. + if (lines.Length < 3 || lines[2].Trim() != "DECAL") + { + Log.Warning($"{file} is not a decal library; {library} is kept at full strength"); + return null; + } + string folder = Path.GetDirectoryName(file)!; + var inline = new System.Text.StringBuilder(); + foreach (string raw in lines.Skip(3)) + { + string line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + string[] tokens = line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + if (!tokens[0].StartsWith("DECAL", StringComparison.OrdinalIgnoreCase) || tokens.Length < 2 + || Register(Path.GetFullPath(Path.Join(folder, tokens[^1]))) is not { } served) + { + Log.Warning($"{file} has a line this program cannot fade ({line}); {library} is kept at full strength"); + return null; + } + inline.Append(string.Join('\t', tokens[..^1])).Append("\t../").Append(Folder).Append('/').Append(served).Append('\n'); + } + return inline.ToString(); + } + + /// The name a decal texture is served under, or null when it is missing or its name is taken by another. + private string? Register(string source) + { + if (!File.Exists(source)) return null; + string name = Path.GetFileName(source); + lock (_registerLock) + { + string registered = _sources.GetOrAdd(name, source); + return string.Equals(registered, source, StringComparison.OrdinalIgnoreCase) ? name : null; + } + } + + /// The library paths X-Plane's decal package exports (lib/g10/decals/x.dcl → its file). + private Dictionary ReadExports() + { + var exports = new Dictionary(StringComparer.OrdinalIgnoreCase); + string index = Path.Join(_decalsPackage, "library.txt"); + if (!File.Exists(index)) return exports; + foreach (string raw in File.ReadLines(index)) + { + string[] tokens = raw.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length >= 3 && tokens[0] == "EXPORT" && tokens[1].StartsWith(LibraryPrefix, StringComparison.OrdinalIgnoreCase)) + exports.TryAdd(tokens[1], Path.Join(_decalsPackage, tokens[2])); + } + return exports; + } + + private byte[]? Faded(string source) + { + try + { + byte[] faded = Fade(File.ReadAllBytes(source), Strength); + Log.Info($"Decal texture {Path.GetFileName(source)} served at {Strength} % strength"); + return faded; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Log.Warning($"Decal texture {source} could not be faded: {ex.Message}"); + return null; + } + } + + /// + /// The image with every channel moved towards mid-grey, keeping percent of its + /// distance from it, as a PNG. Alpha is a second (greyscale) decal, not transparency, so it is faded the same + /// way and never premultiplied. + /// + internal static byte[] Fade(byte[] png, int strength) + { + using SKData data = SKData.CreateCopy(png); + using SKCodec codec = SKCodec.Create(data) ?? throw new InvalidDataException("not a decodable image"); + var info = new SKImageInfo(codec.Info.Width, codec.Info.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + using var bitmap = new SKBitmap(info); + SKCodecResult result = codec.GetPixels(info, bitmap.GetPixels()); + if (result != SKCodecResult.Success) throw new InvalidDataException($"image decode failed: {result}"); + + Span table = stackalloc byte[256]; + double keep = strength / 100.0; + for (int v = 0; v < 256; v++) table[v] = (byte)Math.Clamp(Math.Round(128 + ((v - 128) * keep)), 0, 255); + Span pixels = bitmap.GetPixelSpan(); + for (int i = 0; i < pixels.Length; i++) pixels[i] = table[pixels[i]]; + + using SKData encoded = bitmap.Encode(SKEncodedImageFormat.Png, 100) ?? throw new InvalidDataException("PNG encode failed"); + return encoded.ToArray(); + } +} diff --git a/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs index 958523a..dabb36e 100644 --- a/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs +++ b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs @@ -12,7 +12,11 @@ namespace OrthoStreamXP.Scenery; /// adjusted from the source package's file, or generated from a tile file name. /// Each answer is remembered for the session, so a .ter is produced once however often X-Plane opens it. /// -internal sealed partial class TerrainDefinitions(SceneryRoots roots) +/// +/// X-Plane's detail decals at the chosen strength (see ): the photo tiles' decal lines +/// are faded, left out or kept. Null keeps them as the package defines. +/// +internal sealed partial class TerrainDefinitions(SceneryRoots roots, TerrainDecals? decals) { private const string WaterOverlaySuffix = "_water_overlay.ter", SeaOverlaySuffix = "_sea_overlay.ter"; private const string DecalLibrary = "lib/g10/decals/maquify_2_green_key.dcl"; @@ -21,6 +25,14 @@ internal sealed partial class TerrainDefinitions(SceneryRoots roots) private readonly ConcurrentDictionary _resolved = new(); + /// A DECAL_LIB line with its line break; group 1 is the library path. + [GeneratedRegex(@"^[ \t]*DECAL_LIB[ \t]+(\S+)[^\n]*\n?", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex DecalLibraryPattern(); + + /// A decal line of a .ter (DECAL_LIB, DECAL, DECAL_KEYED, DECAL_PARAMS, ...) with its line break. + [GeneratedRegex(@"^[ \t]*DECAL\w*\b[^\n]*\n?", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex DecalLinePattern(); + [GeneratedRegex(@"^(\s*LOAD_CENTER\s+[-+\d.eE]+\s+[-+\d.eE]+\s+[-+\d.eE]+\s+)\d+(\s*(?:#.*)?)$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] private static partial Regex LoadCenterPattern(); @@ -77,7 +89,7 @@ public static void RemoveCachedCopies(string cache) /// /// The source definition with its LOAD_CENTER pixel size set to its texture's size (LOAD_CENTER_BORDER is kept). /// A definition without LOAD_CENTER gets one after its TERRAIN line: without it X-Plane loads the texture once, - /// at full size, and never reads it again as the distance changes. + /// at full size, and never reads it again as the distance changes. Its decals are set to the chosen strength. /// private string? Adjusted(VirtualPath path, string original) { @@ -85,6 +97,9 @@ public static void RemoveCachedCopies(string cache) if (TextureOf(path.Name, out _, out _) is not { } name) return null; int size = roots.Textures.SizeFor(name.Zoom); string content = File.ReadAllText(original, Encoding.UTF8).ReplaceLineEndings("\n"); + string withDecals = WithDecals(content); + bool decalsChanged = withDecals != content; + content = withDecals; int count = 0; string updated = LoadCenterPattern().Replace(content, m => { @@ -93,7 +108,7 @@ public static void RemoveCachedCopies(string cache) }); if (count > 0) return updated; Match terrain = TerrainLinePattern().Match(content); - if (!terrain.Success) return null; + if (!terrain.Success) return decalsChanged ? content : null; Log.Detail($"{path.Display} has no LOAD_CENTER; one is added so X-Plane loads its texture by distance"); return content.Insert(terrain.Index + terrain.Length, $"\nLOAD_CENTER {LoadCenter(name)} {size.ToString(CultureInfo.InvariantCulture)}"); } @@ -111,7 +126,7 @@ private static string LoadCenter(TextureName name) /// /// Builds a definition from its file name, in one of three forms: /// - /// ROW_COL_BI16.ter: the photo texture with the standard decal library; + /// ROW_COL_BI16.ter: the photo texture with the standard decal library, at the chosen strength; /// ..._water_overlay.ter: inland water blended with the shared water_transition.png; /// ..._sea_overlay.ter: sea blended with the tile's own ROW_COL_ZL16.png coastline mask, which must /// exist in the package (masks come from coastline data and cannot be made from imagery). @@ -145,7 +160,19 @@ private static string LoadCenter(TextureName name) } if (isWaterOverlay || isSeaOverlay) return definition.Append("BORDER_TEX ../textures/water_transition.png\nWET\nNO_SHADOW\n").ToString(); - return definition.Append(CultureInfo.InvariantCulture, $"DECAL_LIB {DecalLibrary}\n").Append("NO_ALPHA\n").ToString(); + definition.Append(CultureInfo.InvariantCulture, $"DECAL_LIB {DecalLibrary}\n").Append("NO_ALPHA\n"); + return WithDecals(definition.ToString()); + } + + /// + /// A photo tile's definition with its decals at the chosen strength: at 0 % every decal line is left out; + /// otherwise each DECAL_LIB line is kept or replaced by the library's faded decal lines. + /// + private string WithDecals(string content) + { + if (decals is null) return content; + if (decals.Strength <= 0) return DecalLinePattern().Replace(content, ""); + return DecalLibraryPattern().Replace(content, m => decals.Replacement(m.Groups[1].Value) ?? m.Value); } /// The width of a PNG, read from its IHDR chunk, or null when the file is not a PNG. diff --git a/src/OrthoStreamXP/orthostreamxp.example.ini b/src/OrthoStreamXP/orthostreamxp.example.ini index 749e064..339b0e1 100644 --- a/src/OrthoStreamXP/orthostreamxp.example.ini +++ b/src/OrthoStreamXP/orthostreamxp.example.ini @@ -65,6 +65,11 @@ build-tiles = 32768 provisional-lod = 0 provisional-wait = 2 lazy-terrain-listing = true +# 0-100: how strong X-Plane's detail decals are on the photos. The packages' terrain definitions name a decal library +# that sprinkles procedural grass and dirt over the ground up close. It suits X-Plane's own land-class textures but +# looks like noise on aerial photos: 0 leaves it out, 100 keeps it as the packages define, between fades it. +# The packages' files are not changed +ground-detail = 0 # --- Building ahead of X-Plane ---------------------------------------------------- # build each DSF's textures as soon as X-Plane starts loading it, before it asks for them (on or off) diff --git a/tests/OrthoStreamXP.Tests/StreamingTests.cs b/tests/OrthoStreamXP.Tests/StreamingTests.cs index 361075b..17e67c7 100644 --- a/tests/OrthoStreamXP.Tests/StreamingTests.cs +++ b/tests/OrthoStreamXP.Tests/StreamingTests.cs @@ -792,7 +792,7 @@ public void A_package_definition_without_LOAD_CENTER_gets_one() { _tree.WriteText($"source/terrain/{Stem}.ter", "A\n800\nTERRAIN\n\nBASE_TEX_NOWRAP ../textures/" + Stem + ".dds\nNO_ALPHA\n"); var roots = new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(-3, TextureSizing.MaxSize)); - string definition = System.Text.Encoding.UTF8.GetString(new TerrainDefinitions(roots).Resolve(VirtualPath.Of("terrain", Stem + ".ter"))!); + string definition = System.Text.Encoding.UTF8.GetString(new TerrainDefinitions(roots, decals: null).Resolve(VirtualPath.Of("terrain", Stem + ".ter"))!); Assert.Matches(@"TERRAIN\nLOAD_CENTER 51\.0\d+ 16\.8\d+ 6\d{3} 1024\n\nBASE_TEX_NOWRAP", definition); Assert.Contains("BASE_TEX_NOWRAP ../textures/" + Stem + ".dds", definition, StringComparison.Ordinal); } diff --git a/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs b/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs index 013ff00..a787693 100644 --- a/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs +++ b/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs @@ -13,7 +13,7 @@ public sealed class TerrainDefinitionsTests : IDisposable public TerrainDefinitionsTests() { Directory.CreateDirectory(_tree.PathOf("source")); - _terrain = new TerrainDefinitions(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(0, TextureSizing.MaxSize))); + _terrain = new TerrainDefinitions(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(0, TextureSizing.MaxSize)), decals: null); } public void Dispose() => _tree.Dispose(); @@ -66,13 +66,137 @@ public void Source_definition_gets_the_texture_size_but_keeps_the_border_size() _tree.WriteText("source/terrain/21904_35824_BI16_sea_overlay.ter", "A\r\n800\r\nTERRAIN\r\n\r\nLOAD_CENTER 39.18969 23.49976 1895 4096\r\nBASE_TEX_NOWRAP ../textures/x.dds\r\n" + "LOAD_CENTER_BORDER 39.18969 23.49976 1895 1024\r\nBORDER_TEX ../textures/x.png\r\n"); - var terrain = new TerrainDefinitions(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache2"), null, new TexturePlan(-3, TextureSizing.MaxSize))); + var terrain = new TerrainDefinitions(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache2"), null, new TexturePlan(-3, TextureSizing.MaxSize)), decals: null); string text = Encoding.UTF8.GetString(terrain.Resolve(VirtualPath.Of("terrain", "21904_35824_BI16_sea_overlay.ter"))!); Assert.Contains("LOAD_CENTER 39.18969 23.49976 1895 1024\n", text, StringComparison.Ordinal); Assert.Contains("LOAD_CENTER_BORDER 39.18969 23.49976 1895 1024\n", text, StringComparison.Ordinal); Assert.DoesNotContain('\r', text); } + private const string Tile = "100000_222560_BI18"; + private const string Library = "lib/g10/decals/maquify_2_green_key.dcl"; + + private TerrainDefinitions WithDecals(int strength, string? xplane = null) => + new(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(0, TextureSizing.MaxSize)), new TerrainDecals(xplane, strength)); + + private static string Resolve(TerrainDefinitions terrain, string name) => + Encoding.UTF8.GetString(terrain.Resolve(VirtualPath.Of("terrain", name)) ?? throw new InvalidOperationException("no definition")); + + private void WritePackageDefinition() => + _tree.WriteText($"source/terrain/{Tile}.ter", + $"A\r\n800\r\nTERRAIN\r\n\r\nLOAD_CENTER 39.18969 125.65063 1895 4096\r\nBASE_TEX_NOWRAP ../textures/{Tile}.dds\r\n" + + $"DECAL_LIB {Library}\r\n decal_params 1 2 3\r\nNO_ALPHA\r\n"); + + /// An X-Plane folder with the decal package: library.txt, the .dcl and its 2x1 RGBA texture. + private string FakeXPlane() + { + const string package = "xplane/Resources/default scenery/1000 decals"; + _tree.WriteText($"{package}/library.txt", $"A\n800\nLIBRARY\n\nEXPORT\t{Library}\t\tmaquify_2_green_key.dcl\n"); + _tree.WriteText($"{package}/maquify_2_green_key.dcl", + "A\r\n1000\r\nDECAL\r\n\r\nDECAL_PARAMS_PROJ\t25\t25\t0.8\t\t-12.0\t20.5\t-8.0\t0\t0.0\t0.0\t\t12\t-20\t8\t-0.0\t0.0\t1.5\ttextures/shrub_dirt.png\r\n\r\n\r\n# a comment\r\n"); + Directory.CreateDirectory(_tree.PathOf($"{package}/textures")); + File.WriteAllBytes(_tree.PathOf($"{package}/textures/shrub_dirt.png"), Png([0, 0, 0, 0, 255, 128, 64, 255])); + return _tree.PathOf("xplane"); + } + + private static byte[] Png(byte[] rgba) + { + var info = new SkiaSharp.SKImageInfo(rgba.Length / 4, 1, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Unpremul); + using var bitmap = new SkiaSharp.SKBitmap(info); + rgba.CopyTo(bitmap.GetPixelSpan()); + using SkiaSharp.SKData png = bitmap.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100); + return png.ToArray(); + } + + private static byte[] Pixels(byte[] png) + { + using var codec = SkiaSharp.SKCodec.Create(SkiaSharp.SKData.CreateCopy(png)); + var info = new SkiaSharp.SKImageInfo(codec.Info.Width, codec.Info.Height, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Unpremul); + using var bitmap = new SkiaSharp.SKBitmap(info); + codec.GetPixels(info, bitmap.GetPixels()); + return bitmap.GetPixelSpan().ToArray(); + } + + [Fact] + public void At_0_percent_a_generated_definition_has_no_decal_library() + { + Assert.Equal( + "A\n800\nTERRAIN\n\nLOAD_CENTER 74.50816 18.85254 2613 4096\nBASE_TEX_NOWRAP ../textures/11952_36192_BI16.dds\nNO_ALPHA\n", + Resolve(WithDecals(0), "11952_36192_BI16.ter")); + } + + [Fact] + public void At_0_percent_the_package_definitions_decal_lines_are_removed_and_the_rest_kept() + { + WritePackageDefinition(); + Assert.Equal( + $"A\n800\nTERRAIN\n\nLOAD_CENTER 39.18969 125.65063 1895 4096\nBASE_TEX_NOWRAP ../textures/{Tile}.dds\nNO_ALPHA\n", + Resolve(WithDecals(0), $"{Tile}.ter")); + } + + [Fact] + public void At_100_percent_the_package_definitions_decal_library_is_kept() + { + WritePackageDefinition(); + Assert.Contains($"DECAL_LIB {Library}\n", Resolve(WithDecals(100, FakeXPlane()), $"{Tile}.ter"), StringComparison.Ordinal); + } + + [Fact] + public void In_between_the_library_is_inlined_with_a_faded_texture_the_mount_serves() + { + WritePackageDefinition(); + string xplane = FakeXPlane(); + var decals = new TerrainDecals(xplane, 50); + var terrain = new TerrainDefinitions(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(0, TextureSizing.MaxSize)), decals); + + string definition = Resolve(terrain, $"{Tile}.ter"); + Assert.DoesNotContain("DECAL_LIB", definition, StringComparison.Ordinal); + Assert.Contains( + $"BASE_TEX_NOWRAP ../textures/{Tile}.dds\n" + + $"DECAL_PARAMS_PROJ\t25\t25\t0.8\t-12.0\t20.5\t-8.0\t0\t0.0\t0.0\t12\t-20\t8\t-0.0\t0.0\t1.5\t../{TerrainDecals.Folder}/shrub_dirt.png\n" + + " decal_params 1 2 3\nNO_ALPHA\n", definition, StringComparison.Ordinal); + + // Every channel, alpha too (a greyscale decal, not transparency), keeps half its distance from mid-grey. + byte[] texture = decals.Texture(VirtualPath.Of(TerrainDecals.Folder, "shrub_dirt.png"))!; + Assert.Equal([64, 64, 64, 64, 192, 128, 96, 192], Pixels(texture)); + Assert.Null(decals.Texture(VirtualPath.Of(TerrainDecals.Folder, "other.png"))); + } + + [Fact] + public void In_between_without_the_X_Plane_folder_the_library_is_kept() + { + WritePackageDefinition(); + Assert.Contains($"DECAL_LIB {Library}\n", Resolve(WithDecals(50), $"{Tile}.ter"), StringComparison.Ordinal); + } + + [Fact] + public void A_library_with_lines_that_cannot_be_faded_is_kept() + { + WritePackageDefinition(); + string xplane = FakeXPlane(); + File.AppendAllText(_tree.PathOf("xplane/Resources/default scenery/1000 decals/maquify_2_green_key.dcl"), "NORMAL_DECAL_PARAMS 1 2 normal.png\n"); + Assert.Contains($"DECAL_LIB {Library}\n", Resolve(WithDecals(50, xplane), $"{Tile}.ter"), StringComparison.Ordinal); + } + + [Fact] + public void Fading_to_100_percent_keeps_the_image() + { + byte[] rgba = [0, 10, 200, 255, 128, 127, 129, 1]; + Assert.Equal(rgba, Pixels(TerrainDecals.Fade(Png(rgba), 100))); + Assert.Equal([128, 128, 128, 128, 128, 128, 128, 128], Pixels(TerrainDecals.Fade(Png(rgba), 0))); + } + + [Fact] + public void The_ground_detail_setting_is_0_unless_raised_and_stays_within_0_to_100() + { + MountSettings Parse(params string[] extra) => MountSettings.FromValues(TestSettings.Of( + [$"source = {_tree.PathOf("source")}", $"cache = {_tree.PathOf("cache")}", $"mountpoint = {_tree.PathOf("mount")}", .. extra])); + Assert.Equal(0, Parse().GroundDetail); + Assert.Equal(40, Parse("ground-detail = 40").GroundDetail); + Assert.Throws(() => Parse("ground-detail = 101")); + Assert.Throws(() => Parse("ground-detail = -1")); + } + [Theory] [InlineData("terrain", "not_a_tile.ter")] [InlineData("terrain", "11953_36192_BI16.ter")] // row not on the 16-tile grid