diff --git a/README.md b/README.md index 420e510..f7b3ca0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Traditional photo scenery often requires downloading and building large areas be - **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. +- **Towns lit at night** — optionally give the photo scenery street lamps and the light of the houses along X-Plane's roads, so towns glow after dark as on X-Plane's own scenery. You can also skip the detailed photos at night to save downloads, keeping the lights. - **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 2c08015..e1ae80f 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 d508839..988bbce 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 and ground detail, folders, downloads, colours, seasons and exposure. +4. [Settings](settings.md): quality and ground detail, folders, downloads, colours, seasons and exposure, night lights and photos at night. 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-colours.png b/docs/images/settings-colours.png index 5a4386c..1f7d451 100644 Binary files a/docs/images/settings-colours.png and b/docs/images/settings-colours.png differ diff --git a/docs/images/settings-download.png b/docs/images/settings-download.png index dc6f63a..401ce97 100644 Binary files a/docs/images/settings-download.png and b/docs/images/settings-download.png differ diff --git a/docs/images/settings-folders.png b/docs/images/settings-folders.png index 714391e..9045293 100644 Binary files a/docs/images/settings-folders.png and b/docs/images/settings-folders.png differ diff --git a/docs/images/settings-night.png b/docs/images/settings-night.png new file mode 100644 index 0000000..d105004 Binary files /dev/null and b/docs/images/settings-night.png differ diff --git a/docs/images/settings-other.png b/docs/images/settings-other.png index e16cd98..9adb40e 100644 Binary files a/docs/images/settings-other.png and b/docs/images/settings-other.png differ diff --git a/docs/images/settings-quality.png b/docs/images/settings-quality.png index 27afb25..1e863bc 100644 Binary files a/docs/images/settings-quality.png and b/docs/images/settings-quality.png differ diff --git a/docs/images/settings-tuning.png b/docs/images/settings-tuning.png index 33efb52..6194430 100644 Binary files a/docs/images/settings-tuning.png and b/docs/images/settings-tuning.png differ diff --git a/docs/settings.md b/docs/settings.md index 026cdbb..6674496 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -2,7 +2,7 @@ [User guide](README.md) › Settings -The Settings page has six tabs. Changes are saved straight away, and a short **Saved** note confirms each one. +The Settings page has seven tabs. Changes are saved straight away, and a short **Saved** note confirms each one. Settings can be changed only while OrthoStreamXP is stopped. While the scenery is streaming, the page shows the settings in use but you cannot edit them: a banner says *Streaming is running. Stop it to change settings.* Click **Stop streaming** in the banner to stop it. Changes apply from the next start. @@ -96,6 +96,43 @@ Most aerial photos are taken in summer. All the colour options only change how the photos look. The cache keeps the photos as downloaded, so changing them never downloads anything again. +## Night + +![Settings: Night](images/settings-night.png) + +How the photo scenery looks after dark. Both switches are **Off** by default and work independently: you can have the lights without skipping the photos, skip the photos without the lights, or both. + +### Night lights + +X-Plane's own scenery glows at night where there are towns. Photo scenery replaces that ground, so without night lights a town on the photos is dark except for X-Plane's 3D roads, cars and buildings. + +Turn on **Light the towns at night** to give every photo tile its own lights: + +- street lamps along X-Plane's roads: a lamp every 28–45 m depending on the road, mostly warm sodium orange with some white LEDs, a few of them out; +- a soft glow around the lamps, which stand beside the road so that X-Plane's 3D roads (or an overlay's, like simHeaven's) do not cover their light; +- a faint, even light from the houses along town streets. + +How many lamps are lit and how bright the houses are depends on how built up the place is: a town centre is fully lit, a village keeps all its lamps, and in the open country only a few lamps along the roads shine. Between the lights the photo stays dark; X-Plane fades the lights in at dusk by itself. + +The roads come from the overlay package installed with your regions (**OrthoStream_Overlays**), or from another overlay package in X-Plane's Custom Scenery. Where no overlay package covers an area, its photo tiles stay dark at night. + +X-Plane loads the lights with the scenery, by day too, so they are there when night falls during a flight. The lights get the same detail as the photos under them at every distance, up to about 3 m per pixel beside the aircraft. Each tile's lights are made the first time X-Plane loads that tile, and again with more detail as you come closer; only the detail X-Plane reads is kept in the cache with the area's photos, compressed (about 50 KB per tile; a 20-minute flight around Zurich kept 160 MB), so the first flight over a new area uses a little more processor time and later flights none. Your scenery packages are not changed. **Off** is the default; a change applies from the next start. + +### Photos at night + +At night the ground is dark and you see little of the photos, so downloading them in full detail mostly costs bandwidth and processor time. Turn on **Skip the photos at night** to save that on night flights: + +- **While it is dark in X-Plane** (the sun more than 9° below the horizon), OrthoStreamXP downloads no detailed photos. The ground you fly over is a blurry version of the photos, made from the small overview pictures it already keeps for far-away scenery. +- **The night lights still work.** With **Light the towns at night** on, the street lamps and houses shine on the blurry ground as usual, as sharp as ever. +- **Places you have flown over before stay sharp**: photos already in the cache are shown as they are. +- **Around sunset and sunrise** the photos download as usual, while there is still light to see them by: from sunset until well after the sky starts to darken, and again from early dawn. + +OrthoStreamXP reads the time of day from X-Plane. When a flight starts, X-Plane first loads the area around the aircraft, and only then, once the flight is running, says what time it is. So the area where a flight starts still gets its photos, even at night, and the saving starts from there: everything X-Plane loads as you fly on. + +If you fly on into the day, ground that loaded during the night stays blurry after sunrise until X-Plane loads it again, which happens as you fly on into new areas. Leave this off for flights that end in daylight. Like every setting, a change applies from the next start. + +How dark it must be is set in the settings file (**Show the file**), not on the page: `skip-photos-sun-below = 9` is the sun's height below the horizon in degrees, from 0 to 18. A higher number keeps the photos longer at dusk and brings them back sooner at dawn; a lower one skips more of the twilight. + ## Tuning ![Settings: Tuning](images/settings-tuning.png) diff --git a/release-notes/v1.3.0.md b/release-notes/v1.3.0.md new file mode 100644 index 0000000..b32f728 --- /dev/null +++ b/release-notes/v1.3.0.md @@ -0,0 +1,13 @@ +## What's new + +- **Towns lit at night.** X-Plane's own scenery glows after dark where there are towns, but until now the photo scenery stayed dark there, apart from X-Plane's 3D roads and buildings. Turn on **Light the towns at night** in the new **Settings › Night** tab, and the photo scenery gets street lamps, their glow and the light of the houses along X-Plane's roads, beside the roads so that 3D roads (X-Plane's or simHeaven's) do not cover it. Town centres are fully lit, villages less, and the open country has only a few lamps along the roads. X-Plane fades the lights in at dusk by itself. +- **Skip the photos at night.** At night the ground is too dark to see much of the photos, so downloading them in full detail mostly costs bandwidth. Turn on **Skip the photos at night**, also in **Settings › Night**, and OrthoStreamXP downloads no detailed photos while it is dark in X-Plane. The ground shows a lower zoom level version of the photos instead, and the night lights shine on it as usual. Places you have flown over before stay sharp, and around sunset and sunrise the photos download as usual. +- **Out of X-Plane's way.** When you start X-Plane with **Fly** on the **Home** page, OrthoStreamXP waits until X-Plane's window is in front, then moves to the notification area next to the clock. Click its icon to open the window again, or right-click it for **Open OrthoStreamXP** and **Exit**. When X-Plane quits, the window comes back on the **Home** page, ready to stop the scenery and show your flight report. + +## Good to know + +- Both night switches are **Off** after updating. Like every setting, they can be changed only while OrthoStreamXP is stopped, and the change applies from the next start. +- The night lights follow the roads in your overlay packages: the **OrthoStream_Overlays** package installed with your regions, or another overlay package in Custom Scenery. Where no overlay package covers an area, its photo scenery stays dark at night. +- The lights for an area are made the first time X-Plane loads it, so the first flight over a new area uses a little more processor time. They are then kept in the cache with that area's photos, at about 50 KB per tile. Your scenery packages are not changed. +- With **Skip the photos at night** on, the area where a flight starts still gets its photos, even at night. Ground loaded during the night stays with lower zoom level before sunrise. +- OrthoStreamXP moves to the notification area only when **Fly** started X-Plane. If you start X-Plane yourself, the window stays where it is. **Exit** in the icon's menu still asks first while the scenery is streaming. diff --git a/src/OrthoStreamXP/Configuration/MountSettings.cs b/src/OrthoStreamXP/Configuration/MountSettings.cs index e9cc6a9..5e09105 100644 --- a/src/OrthoStreamXP/Configuration/MountSettings.cs +++ b/src/OrthoStreamXP/Configuration/MountSettings.cs @@ -42,6 +42,12 @@ internal sealed record MountSettings 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; } + /// The photo tiles get night textures: street lamps and lit windows along X-Plane's roads. + public bool NightLights { get; init; } + /// At night in X-Plane only the low-zoom base is downloaded: no detailed photos under the night lights. + public bool SkipPhotosAtNight { get; init; } + /// How far below the horizon, in degrees, the sun must be before the photos are skipped (0-18). + public double SkipPhotosSunBelow { get; init; } = -Scenery.Daylight.DefaultDarkBelow; /// 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). @@ -141,6 +147,12 @@ public static MountSettings FromValues(SettingValues values, bool console = true 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"); + string nightValue = values.String("night-lights") ?? "off"; + bool nightLights = ParseOnOff(nightValue) ?? throw new SettingsException($"night-lights must be on or off, not '{nightValue}'"); + string skipValue = values.String("skip-photos-at-night") ?? "off"; + bool skipPhotosAtNight = ParseOnOff(skipValue) ?? throw new SettingsException($"skip-photos-at-night must be on or off, not '{skipValue}'"); + double skipSunBelow = values.Double("skip-photos-sun-below", -Scenery.Daylight.DefaultDarkBelow); + if (skipSunBelow is < 0 or > 18) throw new SettingsException("skip-photos-sun-below must be between 0 and 18 degrees"); 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"); @@ -193,6 +205,9 @@ public static MountSettings FromValues(SettingValues values, bool console = true TextureStore = textureStore, LazyTerrainListing = values.Flag("lazy-terrain-listing"), GroundDetail = groundDetail, + NightLights = nightLights, + SkipPhotosAtNight = skipPhotosAtNight, + SkipPhotosSunBelow = skipSunBelow, IgnoredTextureQuality = values.String("texture-quality"), MipStreaming = mipStreaming, NearWait = nearWait, diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs index c08ca95..5c2973f 100644 --- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs +++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs @@ -30,6 +30,9 @@ internal static class SettingsOptions .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("night-lights", "on or off: light the towns on the photo scenery at night. X-Plane's own scenery glows where it is a town; photo tiles are dark but for X-Plane's roads and buildings. With this on, each photo tile gets a night texture of street lamps, their glow and lit windows along X-Plane's roads (read from the overlay packages), which X-Plane fades in at dusk. Each is painted the first time X-Plane loads it and kept in the cache. The packages' files are not changed. Takes effect at the next start (default: off)") + .Add("skip-photos-at-night", "on or off: at night in X-Plane (the sun lower than skip-photos-sun-below) download no detailed photos: the ground under the night lights is drawn from the low-zoom base, blurry, and what is already in the cache is shown as it is. In twilight, and before X-Plane sends where the sun is, the photos stream as usual. Saves downloads and processor time on night flights; needs mip-streaming. Takes effect at the next start (default: off)") + .Add("skip-photos-sun-below", "Degrees, 0-18: how far below the horizon the sun in X-Plane must be before skip-photos-at-night skips the photos. 6 is the end of civil twilight, when the sky turns dark; 12 the end of nautical twilight, when the horizon fades; 18 full night. Lower skips more of the twilight; higher keeps the photos while the sky still lights the ground, and brings them back sooner at dawn. Takes effect at the next start (default: 9)") .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/Home/XPlaneWatch.cs b/src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs new file mode 100644 index 0000000..3e7f937 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Home/XPlaneWatch.cs @@ -0,0 +1,18 @@ +namespace OrthoStreamXP.Gui; + +/// +/// Tells from the process list's answers, one check at a time, when X-Plane quits. Only what a check saw counts: an +/// X-Plane just started may not be in the process list at the next check yet, and that is not a quit. +/// +internal sealed class XPlaneWatch +{ + private bool _seenRunning; + + /// Notes whether X-Plane runs now; true when it ran at the last check and no longer does. + public bool QuitSince(bool runningNow) + { + bool quit = _seenRunning && !runningNow; + _seenRunning = runningNow; + return quit; + } +} diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs index d4b82b3..c0f7d0a 100644 --- a/src/OrthoStreamXP/Gui/MainWindow.xaml.cs +++ b/src/OrthoStreamXP/Gui/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Windows; +using OrthoStreamXP.Setup; namespace OrthoStreamXP.Gui; @@ -18,6 +19,27 @@ public MainWindow(MainViewModel main) { if (new UpdateDialog(offer) { Owner = this }.ShowDialog() == true) _main.Home.InstallConfirmedUpdate(); }; + var tray = new TrayIcon(this); + _main.Home.XPlaneStarted += async (_, _) => await HandOverToXPlaneAsync(tray).ConfigureAwait(true); + // The flight is over: back on the Home page, with Stop and (once stopped) the flight's report. + _main.Home.XPlaneQuit += (_, _) => + { + if (IsVisible) return; + _main.Show(_main.Home); + tray.ShowWindow(); + }; + } + + /// + /// X-Plane was started: once its window has the front (given by this window, which has it), this window goes to the + /// notification area, out of its way. Not before: hidden while it still has the front, the front would go to the + /// window behind it instead of X-Plane's. + /// + private async Task HandOverToXPlaneAsync(TrayIcon tray) + { + await XPlaneWindow.HandOverAsync(settle: TimeSpan.FromSeconds(1), timeout: TimeSpan.FromMinutes(3)).ConfigureAwait(true); + // X-Plane quit meanwhile: the window stays, as it would come back for the quit anyway. + if (IsVisible && SystemCheck.IsXPlaneRunning()) tray.HideWindow(); } /// Closing while the scenery is mounted asks first, then unmounts before the window goes. diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs index 7ab77a6..db47421 100644 --- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs +++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs @@ -19,6 +19,9 @@ internal static class SettingNames ["season"] = "Seasons", ["season-strength"] = "Seasons", ["exposure"] = "Exposure", + ["night-lights"] = "Night lights", + ["skip-photos-at-night"] = "Photos at night", + ["skip-photos-sun-below"] = "Photos at night", ["scenery-packs"] = "Keep the photo scenery below airports", ["xplane-address"] = "X-Plane address", ["xplane-arguments"] = "X-Plane start options", diff --git a/src/OrthoStreamXP/Gui/TrayIcon.cs b/src/OrthoStreamXP/Gui/TrayIcon.cs new file mode 100644 index 0000000..1c111d1 --- /dev/null +++ b/src/OrthoStreamXP/Gui/TrayIcon.cs @@ -0,0 +1,78 @@ +using System.Windows; +using Forms = System.Windows.Forms; + +namespace OrthoStreamXP.Gui; + +/// +/// The window's icon in the notification area, shown while the window is hidden (such as after it started X-Plane, so it +/// does not stay in front of it). A click on it shows the window again. It goes when the window closes. +/// +internal sealed class TrayIcon : IDisposable +{ + private readonly Window _window; + private readonly Forms.NotifyIcon _icon; + private readonly Forms.ContextMenuStrip _menu = new(); + private readonly System.Drawing.Icon _picture = LoadIcon(); + private bool _disposed; + + public TrayIcon(Window window) + { + _window = window; + _menu.Items.Add("Open OrthoStreamXP", null, (_, _) => ShowWindow()); + // Through the window, so closing while the scenery is mounted still asks first. + _menu.Items.Add("Exit", null, (_, _) => + { + ShowWindow(); + _window.Close(); + }); + _icon = new Forms.NotifyIcon { Text = "OrthoStreamXP", Icon = _picture, ContextMenuStrip = _menu }; + _icon.MouseClick += (_, e) => + { + if (e.Button == Forms.MouseButtons.Left) ShowWindow(); + }; + // Shown again by any means (the icon, or a second start of the program): the icon goes. + _window.IsVisibleChanged += (_, _) => + { + if (_window.IsVisible) _icon.Visible = false; + }; + _window.Closed += (_, _) => Dispose(); + } + + /// Whether the icon is in the notification area now. + public bool IsShown => _icon.Visible; + + /// Hides the window from the screen and the taskbar, leaving the icon in the notification area. + public void HideWindow() + { + _icon.Visible = true; + _window.Hide(); + } + + public void ShowWindow() + { + _window.Show(); + if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal; + // Shown on its own (X-Plane quit), this program may not take the front from the window that got it, such as + // Explorer's: on top of all for a moment, it still comes above that window. + _window.Topmost = true; + _window.Topmost = false; + _window.Activate(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // Taken off first: otherwise it stays in the notification area until the mouse passes over it. + _icon.Visible = false; + _icon.Dispose(); + _menu.Dispose(); + _picture.Dispose(); + } + + private static System.Drawing.Icon LoadIcon() + { + using Stream stream = Application.GetResourceStream(new Uri("pack://application:,,,/orthostreamxp;component/orthostreamxp.ico")).Stream; + return new System.Drawing.Icon(stream, Forms.SystemInformation.SmallIconSize); + } +} diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs index a8726b8..f24dde6 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs @@ -85,6 +85,12 @@ public HomeViewModel(MainViewModel main) /// Stopped: start OrthoStreamXP, then X-Plane. Running: start X-Plane. public ICommand Fly { get; } + /// The Fly button started X-Plane: the window gets out of its way. + public event EventHandler? XPlaneStarted; + + /// X-Plane was running and has quit (checked every few seconds while OrthoStreamXP runs). + public event EventHandler? XPlaneQuit; + /// X-Plane is installed where the settings say, and not running: one button starts it (and OrthoStreamXP first). public bool ShowFly => Mount.State is MountState.Stopped or MountState.Running && _xplaneProgram is not null && !_xplaneRunning; @@ -294,12 +300,14 @@ public void OnShown() private string? _xplaneProgram; private bool _xplaneRunning; + private readonly XPlaneWatch _xplaneWatch = new(); /// Notes where X-Plane is installed and whether it runs (on start-up and every few seconds). private void CheckXPlane() { _xplaneProgram = SystemCheck.XPlaneProgram(SettingsDocument.Load(SettingsDocument.DefaultPath).Get("xplane")); _xplaneRunning = SystemCheck.IsXPlaneRunning(); + if (_xplaneWatch.QuitSince(_xplaneRunning)) XPlaneQuit?.Invoke(this, EventArgs.Empty); } /// OrthoStreamXP first (X-Plane looks for scenery only when it starts), then X-Plane. @@ -333,6 +341,7 @@ private async Task FlyAsync() _xplaneProblem = new Notice(Severity.Error, "X-Plane could not be started", $"{ex.Message} Start it yourself; the photo scenery is ready for it."); } Update(); + if (_xplaneProblem is null) XPlaneStarted?.Invoke(this, EventArgs.Empty); } private Notice? _xplaneProblem; diff --git a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs index 7308bca..813e0f2 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs @@ -307,6 +307,34 @@ public double GroundDetail _ => $"{GroundDetail:0} %", }; + /// Street lamps and lit windows on the photo scenery at night. + public bool NightLightsOn + { + get => _document.GetBool("night-lights") == true; + set + { + Save("night-lights", value ? "on" : "off"); + Raise(); + Raise(nameof(NightLightsOnText)); + } + } + + public string NightLightsOnText => NightLightsOn ? "On" : "Off"; + + /// No detailed photos downloaded at night in X-Plane: the ground under the lights is blurry. + public bool SkipPhotosAtNight + { + get => _document.GetBool("skip-photos-at-night") == true; + set + { + Save("skip-photos-at-night", value ? "on" : "off"); + Raise(); + Raise(nameof(SkipPhotosAtNightText)); + } + } + + public string SkipPhotosAtNightText => SkipPhotosAtNight ? "On" : "Off"; + public string XPlaneFolder { get => _xplane; @@ -482,7 +510,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(GroundDetail), nameof(GroundDetailText), nameof(NightLightsOn), nameof(NightLightsOnText), nameof(SkipPhotosAtNight), nameof(SkipPhotosAtNightText), 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 2632b4a..c71f436 100644 --- a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml +++ b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml @@ -144,7 +144,6 @@ - @@ -436,6 +435,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OrthoStreamXP/Mounting/MountSession.cs b/src/OrthoStreamXP/Mounting/MountSession.cs index d97a46a..4122321 100644 --- a/src/OrthoStreamXP/Mounting/MountSession.cs +++ b/src/OrthoStreamXP/Mounting/MountSession.cs @@ -16,6 +16,8 @@ internal sealed class MountSession : IDisposable private readonly List _mounts = []; private readonly CancellationTokenSource _summaries = new(); private readonly ViewTracker _view = new(); + /// Where the sun is in X-Plane: offered by the position listener, read by the streaming to skip the photos at night. + private readonly Daylight _daylight; private readonly FlightRecorder _recorder = new(); private readonly CpuMeter _cpu = new(); private readonly SceneryLoads _loads; @@ -28,6 +30,8 @@ internal sealed class MountSession : IDisposable private ReadLog? _readLog; private SeasonClock? _seasons; private ColourMatcher? _colours; + /// The night textures' painting, shared by every package. + private NightPainting? _nightPainting; private Task? _summaryLoop, _sampleLoop; private bool _stopped; @@ -35,6 +39,7 @@ private MountSession(MountSettings settings) { _settings = settings; _loads = new SceneryLoads(_view); + _daylight = new Daylight(-settings.SkipPhotosSunBelow); } public MountSettings Settings => _settings; @@ -139,14 +144,19 @@ private void StartAll() 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)", }); + NightPainting? nightPainting = s.NightLights ? StartNightLights(s) : null; + Daylight? nightPause = s.SkipPhotosAtNight ? SkipPhotosAtNight(s, streaming) : null; foreach (PackagePlan package in s.Packages) { var roots = new SceneryRoots(package.Source, package.Cache, s.TextureStore, s.Textures); TerrainDefinitions.RemoveCachedCopies(package.Cache); + if (NightTextures.RemoveOldFiles(package.Cache) is > 0 and var oldNights) + Log.Info($"Removed {oldNights} night textures an earlier version kept whole; they are painted again, keeping only what X-Plane reads"); roots.RenameToCacheNames(); _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)); + streaming ? new TextureStreamer(roots, builder!, _view, model, streamingOptions, nightPause) : null, _view, _readLog, _seasons, _usage, _colours, _loads, exposure, + nightPainting is null ? null : new NightTextures(package.Cache, s.Textures.SizeFor, nightPainting))); Log.Info($"Package {package.Name}: read-only from {package.Source}, cache overlay {package.Cache}, mounted at {package.Mountpoint}"); } PrefetchTarget[] targets = [.. _mounts.Where(m => m.Catalog is not null) @@ -165,7 +175,7 @@ private void StartAll() readLog?.Camera(camera); }, streaming ? model.UseScreen : null, - seasons is null ? null : day => seasons.OfferDate(day, _view.Starting)); + seasons is null ? null : day => seasons.OfferDate(day, _view.Starting), _daylight); } if (s.CustomScenery is not null && s.UpdateSceneryPacks) @@ -237,6 +247,8 @@ private async Task StopAsync(bool hurry, bool summaries) var (terrainCount, textureCount) = mount.Overlay.RequestCounts(); Log.Info($"{label}X-Plane request summary: {terrainCount} unique .ter definitions, {textureCount} unique DDS textures"); if (mount.Overlay.PrefetchSummary() is { } summary) Log.Info(label + summary); + if (mount.Overlay.Night is { } night && night.Counts is var (painted, dark, time, kept) && painted + dark > 0) + Log.Info($"{label}Night lights: {painted} paintings in {time.TotalSeconds:F1} s ({kept / 1048576.0:F0} MB written to the cache, compressed), {dark} places without lights"); } Log.Info(_cpu.Summary()); ReviewFlight(); @@ -269,6 +281,7 @@ private async Task StopAsync(bool hurry, bool summaries) } _listener?.Dispose(); foreach (PackageMount mount in _mounts) mount.Dispose(); + _nightPainting?.Dispose(); _tiles?.Dispose(); _readLog?.Dispose(); _summaries.Dispose(); @@ -276,6 +289,38 @@ private async Task StopAsync(bool hurry, bool summaries) if (summaries && !hurry) TrimCache(); } + /// The night lights' painting: X-Plane's roads from the overlay packages, and how many paintings run at once. + private NightPainting StartNightLights(MountSettings s) + { + var roads = new RoadNetworks(RoadNetworks.FindOverlayPackages(s.Packages, s.CustomScenery)); + if (roads.Packages.Count > 0) + Log.Step($"Night lights: on; towns are lit along X-Plane's roads (from {string.Join(", ", roads.Packages.Select(Path.GetFileName))})"); + else + Log.Warning("Night lights: on, but no overlay package with X-Plane's roads was found (install a region on the Packages page); the photo tiles stay dark at night"); + return _nightPainting = new NightPainting(new NightPainter(roads), Math.Clamp(Environment.ProcessorCount / 4, 1, 4)); + } + + /// + /// The sun the streaming follows to skip the photos at night, or null when it cannot: the photos are built whole + /// without mip streaming, and without the aircraft's position X-Plane is never asked where the sun is. + /// + private Daylight? SkipPhotosAtNight(MountSettings s, bool streaming) + { + if (!streaming) + { + Log.Warning("Skip the photos at night needs mip streaming; the photos stream at night too in this run"); + return null; + } + if (!s.TrackAircraft) + { + Log.Warning("Skip the photos at night needs the aircraft's position from X-Plane (track-aircraft); the photos stream at night too in this run"); + return null; + } + Log.Step($"Skip the photos at night: on; while the sun in X-Plane is more than {s.SkipPhotosSunBelow:0.#}° below the horizon, " + + "the ground is drawn from the low-zoom base and no detailed photos are downloaded"); + return _daylight; + } + /// "Extra (level +1)": the quality in the words of the window. private static string QualityName(MountSettings s) => QualityPreset.ForLevel(s.Textures.LevelChange) is { } preset ? $"{preset.Name} quality" : $"level {s.Textures.LevelChange:+0;-0;0}"; diff --git a/src/OrthoStreamXP/Mounting/PackageMount.cs b/src/OrthoStreamXP/Mounting/PackageMount.cs index ecfa834..df761f3 100644 --- a/src/OrthoStreamXP/Mounting/PackageMount.cs +++ b/src/OrthoStreamXP/Mounting/PackageMount.cs @@ -18,7 +18,7 @@ internal sealed class PackageMount : IDisposable public PackageMount(PackagePlan plan, SceneryRoots roots, TextureBuilder? builder, TextureOptions textureOptions, bool trackAircraft, bool loadAhead, bool lazyTerrainListing, TerrainDecals decals, TextureStreamer? streamer, ViewTracker view, ReadLog? readLog, - SeasonClock? seasons, AreaUsage? usage, ColourMatcher? colours, SceneryLoads loads, ushort[]? exposure) + SeasonClock? seasons, AreaUsage? usage, ColourMatcher? colours, SceneryLoads loads, ushort[]? exposure, NightTextures? night) { Plan = plan; Streamer = streamer; @@ -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, decals); + Overlay = new SceneryOverlay(roots, Scheduler, lazyTerrainListing, Catalog, streamer, decals, night); _host = new FileSystemHost(new SceneryFileSystem(Overlay, "OrthoStreamXP", readLog, plan.Name, seasons, usage, colours, loads, exposure)); } diff --git a/src/OrthoStreamXP/OrthoStreamXP.csproj b/src/OrthoStreamXP/OrthoStreamXP.csproj index c4d9434..b252560 100644 --- a/src/OrthoStreamXP/OrthoStreamXP.csproj +++ b/src/OrthoStreamXP/OrthoStreamXP.csproj @@ -11,6 +11,8 @@ orthostreamxp.ico true + + true app.manifest $(NoWarn);WPF0001 @@ -43,6 +45,9 @@ + + + diff --git a/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs b/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs index e4c990a..cad0299 100644 --- a/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs +++ b/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs @@ -29,9 +29,10 @@ internal sealed record AircraftReport(double Lat, double Lon, double? GroundSpee /// Told every position as the camera sees it (streaming's distance bands, the camera log). /// Told X-Plane's window width and horizontal field of view (the paging model's first guess). /// Told the sim's day of the year with every position (the seasons). +/// Offered the sun's height with every position (shared with the photo streaming); its own when null. internal sealed class AircraftPositionListener(IReadOnlyList targets, PositionOptions options, Action? onPosition = null, Action? onCamera = null, Action? onScreen = null, - Action? onDate = null) + Action? onDate = null, Daylight? daylight = null) : IDisposable { private const double RetriggerDistanceM = 1500; @@ -50,6 +51,7 @@ internal sealed class AircraftPositionListener(IReadOnlyList tar private readonly float?[] _camera = new float?[XPlaneUdp.CameraDatarefs.Count]; private (float Width, float FieldOfView)? _screen; private bool _receiving; + private readonly Daylight _daylight = daylight ?? new Daylight(); private volatile AircraftReport? _latest; /// The latest position from X-Plane, or null when none has arrived this session. @@ -158,6 +160,7 @@ private void OnPosition(double lat, double lon) _latest = new AircraftReport(lat, lon, _motion.Current?.SpeedMps * 1.943844, DateTime.UtcNow); onCamera?.Invoke(Camera(lat, lon, jumped)); if (onDate is not null && Value(XPlaneUdp.DateIndex) is { } day) onDate((int)Math.Round(day)); + if (Value(XPlaneUdp.SunPitchIndex) is { } sun) _daylight.Offer(sun); if (onScreen is not null && Value(XPlaneUdp.WindowWidthIndex) is { } width && Value(XPlaneUdp.FieldOfViewIndex) is { } fov && _screen != (width, fov)) { _screen = (width, fov); diff --git a/src/OrthoStreamXP/Scenery/Aircraft/Daylight.cs b/src/OrthoStreamXP/Scenery/Aircraft/Daylight.cs new file mode 100644 index 0000000..b5c277e --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Aircraft/Daylight.cs @@ -0,0 +1,69 @@ +namespace OrthoStreamXP.Scenery; + +/// Where the sun is in X-Plane, as the night lights see it. +internal enum DaylightPhase +{ + Day, + /// The sun just below the horizon: X-Plane fades its lights in or out. + Twilight, + Night, +} + +/// +/// Follows the sun's height in X-Plane and logs each change of day, twilight and night, with the time it happened: +/// set beside the read log, it shows what X-Plane reads of the night textures by day and when night falls. The +/// position listener offers it the sun; the photo streaming reads from the file-system threads. +/// +/// The sun's height below which it is (negative: below the horizon). +internal sealed class Daylight(double darkBelow = Daylight.DefaultDarkBelow) +{ + /// Below this the sky is dark (civil twilight's end), where X-Plane's towns are fully lit. + public const double NightBelow = -6; + /// + /// Below this the photos may be skipped by default: past civil twilight, where the sky still lights the ground + /// enough to see the photos, so their detail is skipped only once it is really dark (and comes back earlier at dawn). + /// + public const double DefaultDarkBelow = -9; + /// A change counts once the sun is this far past the line, so a sun sitting on it does not fill the log. + private const double Margin = 0.5; + + private volatile bool _dark; + + /// + /// The sun is lower than : false until X-Plane sends it, and changing only + /// once the sun is past the line. + /// + public bool Dark => _dark; + + /// The sun's height below which it is . + public double DarkBelow => darkBelow; + + /// The phase as an int, -1 before X-Plane sent the sun: written by the listener, read by any thread. + private int _phase = -1; + + /// The latest phase, or null until X-Plane sends the sun. + public DaylightPhase? Phase => Volatile.Read(ref _phase) is var phase and >= 0 ? (DaylightPhase)phase : null; + + /// The phase of a sun at above the horizon. + public static DaylightPhase PhaseOf(double degrees) => degrees >= 0 ? DaylightPhase.Day : degrees >= NightBelow ? DaylightPhase.Twilight : DaylightPhase.Night; + + /// The sun's height from X-Plane; returns the new phase when it changed (logged), otherwise null. + public DaylightPhase? Offer(double degrees) + { + if (!double.IsFinite(degrees) || degrees is < -90 or > 90) return null; + if (_dark ? degrees > DarkBelow + Margin : degrees < DarkBelow - Margin) _dark = !_dark; + DaylightPhase phase = PhaseOf(degrees); + DaylightPhase? current = Phase; + if (current == phase) return null; + // Past the line by the margin, the way it is crossing: a sun wavering on it is not a change. + if (current is { } previous && PhaseOf(degrees + (phase > previous ? Margin : -Margin)) != phase) return null; + Volatile.Write(ref _phase, (int)phase); + Log.Step($"Sun {Math.Abs(degrees):F1}° {(degrees >= 0 ? "above" : "below")} the horizon in X-Plane: {phase switch + { + DaylightPhase.Day => "day", + DaylightPhase.Twilight => "twilight, X-Plane fades its lights", + _ => "night", + }}"); + return phase; + } +} diff --git a/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs b/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs index 88938c8..a94950e 100644 --- a/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs +++ b/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs @@ -33,10 +33,12 @@ internal static class XPlaneUdp ("sim/graphics/view/window_width", 4012), // The sim's date, for the seasons: days since 1 January. ("sim/time/local_date_days", 4013), + // The sun's height above the horizon in degrees: day, dusk and night in the log. + ("sim/graphics/scenery/sun_pitch_degrees", 4014), ]; public const int ElevationIndex = 4003, HeightAglIndex = 4004, LocalXIndex = 4005, ViewXIndex = 4008, FieldOfViewIndex = 4011, WindowWidthIndex = 4012, - DateIndex = 4013; + DateIndex = 4013, SunPitchIndex = 4014; /// /// An RREF request: X-Plane then sends the dataref's value times a second to diff --git a/src/OrthoStreamXP/Scenery/FileSystem/SceneryFileSystem.cs b/src/OrthoStreamXP/Scenery/FileSystem/SceneryFileSystem.cs index 306c443..78a1b61 100644 --- a/src/OrthoStreamXP/Scenery/FileSystem/SceneryFileSystem.cs +++ b/src/OrthoStreamXP/Scenery/FileSystem/SceneryFileSystem.cs @@ -39,6 +39,8 @@ private sealed class Handle(VirtualPath path, string backing, bool isDirectory) public StreamedTexture? Streamed { get; init; } /// A terrain definition made in memory (no backing file). public byte[]? Content { get; init; } + /// A night texture painted as X-Plane reads it (no backing file). + public NightTexture? Night { get; init; } /// A photo texture X-Plane gets as a finished file: its reads are still paging evidence. public StreamedTexture? Photo { get; init; } public ReadCursor? Cursor { get; init; } @@ -151,6 +153,13 @@ public override int Open(string fileName, uint createOptions, uint grantedAccess readLog?.Open(package, path, "memory"); return STATUS_SUCCESS; } + if (entry.Night is { } night) + { + fileNode = new Handle(path, "", false) { Night = night, Cursor = new ReadCursor(), Length = night.Layout.Length }; + fileInfo = Describe(night); + readLog?.Open(package, path, "night"); + return STATUS_SUCCESS; + } string backing = entry.Backing!; bool isDirectory = Directory.Exists(backing); // Share delete so a finished build can atomically replace a DDS that X-Plane still has open. @@ -188,6 +197,7 @@ public override int GetFileInfo(object fileNode, object fileDesc, out FspFileInf var handle = (Handle)fileNode; fileInfo = handle.Streamed is { } streamed ? Describe(streamed) : handle.Content is { } content ? Describe(content) + : handle.Night is { } night ? Describe(night) : Describe(handle.IsDirectory ? new DirectoryInfo(handle.Backing) : new FileInfo(handle.Backing)); return STATUS_SUCCESS; } @@ -202,7 +212,7 @@ public override unsafe int Read(object fileNode, object fileDesc, IntPtr buffer, { bytesTransferred = 0; var handle = (Handle)fileNode; - if (offset >= (ulong)handle.Length) return handle.File is null && handle.Streamed is null && handle.Content is null ? STATUS_ACCESS_DENIED : STATUS_END_OF_FILE; + if (offset >= (ulong)handle.Length) return handle.File is null && handle.Streamed is null && handle.Content is null && handle.Night is null ? STATUS_ACCESS_DENIED : STATUS_END_OF_FILE; var target = new Span((void*)buffer, checked((int)length)); // Before the read: a photo arriving meanwhile is read with the base's correction until the next read, never the reverse. TextureColour? colour = ColourOf(handle); @@ -222,6 +232,13 @@ public override unsafe int Read(object fileNode, object fileDesc, IntPtr buffer, readLog?.Read(new ReadRecord(package, handle.Path, (long)offset, count)); return STATUS_SUCCESS; } + if (handle.Night is { } night) + { + int count = night.Read(handle.Cursor!, (long)offset, target); + bytesTransferred = (uint)count; + readLog?.Read(new ReadRecord(package, handle.Path, (long)offset, count)); + return STATUS_SUCCESS; + } if (handle.File is null) return STATUS_ACCESS_DENIED; bytesTransferred = (uint)RandomAccess.Read(handle.File, target, (long)offset); Recolor(handle, target[..(int)bytesTransferred], offset, colour); @@ -337,6 +354,7 @@ public override int GetDirInfoByName(object fileNode, object fileDesc, string fi normalizedName = fileName; fileInfo = entry.Streamed is { } streamed ? Describe(streamed) : entry.Content is { } content ? Describe(content) + : entry.Night is { } night ? Describe(night) : Directory.Exists(entry.Backing) ? Describe(new DirectoryInfo(entry.Backing!)) : Describe(new FileInfo(entry.Backing!)); return STATUS_SUCCESS; } @@ -358,11 +376,16 @@ private static FspFileInfo Describe(StreamedTexture texture) } /// A terrain definition made in memory: its size, and the mount time. - private static FspFileInfo Describe(byte[] content) => new() + private static FspFileInfo Describe(byte[] content) => Describe((ulong)content.Length); + + /// A night texture: its full size, every level, whatever is painted yet; the mount time. + private static FspFileInfo Describe(NightTexture night) => Describe((ulong)night.Layout.Length); + + private static FspFileInfo Describe(ulong size) => new() { FileAttributes = (uint)FileAttributes.Archive, - AllocationSize = (ulong)content.Length, - FileSize = (ulong)content.Length, + AllocationSize = size, + FileSize = size, CreationTime = MountedAt, LastAccessTime = MountedAt, LastWriteTime = MountedAt, diff --git a/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs b/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs index 9ddbc63..96ec688 100644 --- a/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs +++ b/src/OrthoStreamXP/Scenery/FileSystem/SceneryOverlay.cs @@ -3,8 +3,11 @@ namespace OrthoStreamXP.Scenery; -/// What a path opens as: a backing file, a photo texture streamed level by level, or a terrain definition made in memory. -internal readonly record struct OverlayEntry(string? Backing, StreamedTexture? Streamed, byte[]? Content = null); +/// +/// What a path opens as: a backing file, a photo texture streamed level by level, a terrain definition made in memory, +/// or a night texture painted as X-Plane reads it. +/// +internal readonly record struct OverlayEntry(string? Backing, StreamedTexture? Streamed, byte[]? Content = null, NightTexture? Night = null); /// /// Resolves a virtual path to what X-Plane reads: the cache overlays the source package, terrain definitions @@ -19,6 +22,7 @@ internal sealed class SceneryOverlay private readonly SceneryRoots _roots; private readonly TerrainDefinitions _terrain; private readonly TerrainDecals? _decals; + private readonly NightTextures? _night; private readonly TextureScheduler? _textures; private readonly TextureStreamer? _streamer; private readonly TextureCatalog? _catalog; @@ -31,12 +35,14 @@ 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. + /// The photo tiles' night textures; null leaves the tiles without night lights. public SceneryOverlay(SceneryRoots roots, TextureScheduler? textures, bool lazyTerrainListing, TextureCatalog? catalog = null, - TextureStreamer? streamer = null, TerrainDecals? decals = null) + TextureStreamer? streamer = null, TerrainDecals? decals = null, NightTextures? night = null) { _roots = roots; _decals = decals; - _terrain = new TerrainDefinitions(roots, decals); + _night = night; + _terrain = new TerrainDefinitions(roots, decals, nightLights: night is not null); _textures = textures; _streamer = streamer; _catalog = catalog; @@ -49,6 +55,8 @@ public SceneryOverlay(SceneryRoots roots, TextureScheduler? textures, bool lazyT public TextureStreamer? Streamer => _streamer; + public NightTextures? Night => _night; + public OverlayEntry Open(string fileName) => Open(VirtualPath.Parse(fileName)); /// @@ -62,6 +70,8 @@ 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); + // The photo tiles' night textures, painted the first time X-Plane opens them. + if (_night?.Open(path) is { } night) return night; if (_terrain.Resolve(path) is { } definition) { LogRequest(path); diff --git a/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs index dabb36e..b624998 100644 --- a/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs +++ b/src/OrthoStreamXP/Scenery/FileSystem/TerrainDefinitions.cs @@ -16,7 +16,10 @@ namespace OrthoStreamXP.Scenery; /// 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) +/// +/// Land photo tiles name their night texture (); water and sea overlays never do. +/// +internal sealed partial class TerrainDefinitions(SceneryRoots roots, TerrainDecals? decals, bool nightLights = false) { private const string WaterOverlaySuffix = "_water_overlay.ter", SeaOverlaySuffix = "_sea_overlay.ter"; private const string DecalLibrary = "lib/g10/decals/maquify_2_green_key.dcl"; @@ -89,17 +92,18 @@ 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. Its decals are set to the chosen strength. + /// at full size, and never reads it again as the distance changes. Its decals are set to the chosen strength, and + /// a land tile names its night texture when the night lights are on. /// private string? Adjusted(VirtualPath path, string original) { // Not a tile name: its texture is not built here, so the definition is served as it is. - if (TextureOf(path.Name, out _, out _) is not { } name) return null; + if (TextureOf(path.Name, out bool isWaterOverlay, out bool isSeaOverlay) 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; + string withLines = isWaterOverlay || isSeaOverlay ? WithDecals(content) : WithNight(WithDecals(content), name); + bool linesChanged = withLines != content; + content = withLines; int count = 0; string updated = LoadCenterPattern().Replace(content, m => { @@ -108,7 +112,7 @@ public static void RemoveCachedCopies(string cache) }); if (count > 0) return updated; Match terrain = TerrainLinePattern().Match(content); - if (!terrain.Success) return decalsChanged ? content : null; + if (!terrain.Success) return linesChanged ? 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)}"); } @@ -160,10 +164,29 @@ private static string LoadCenter(TextureName name) } if (isWaterOverlay || isSeaOverlay) return definition.Append("BORDER_TEX ../textures/water_transition.png\nWET\nNO_SHADOW\n").ToString(); + if (nightLights) definition.Append(NightTextures.TerrainLine(name)).Append('\n'); definition.Append(CultureInfo.InvariantCulture, $"DECAL_LIB {DecalLibrary}\n").Append("NO_ALPHA\n"); return WithDecals(definition.ToString()); } + /// A base texture line (BASE_TEX or BASE_TEX_NOWRAP and its path), without its line break. + [GeneratedRegex(@"^[ \t]*BASE_TEX(?:_NOWRAP)?[ \t]+\S[^\n]*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex BaseTexturePattern(); + + [GeneratedRegex(@"^[ \t]*LIT_TEX", RegexOptions.Multiline | RegexOptions.IgnoreCase)] + private static partial Regex LitTexturePattern(); + + /// + /// A land photo tile's definition naming its night texture right after its photo. One that names a night texture of + /// its own, or has no base texture line, is left as it is. + /// + private string WithNight(string content, TextureName name) + { + if (!nightLights || LitTexturePattern().IsMatch(content)) return content; + Match photo = BaseTexturePattern().Match(content); + return photo.Success ? content.Insert(photo.Index + photo.Length, "\n" + NightTextures.TerrainLine(name)) : content; + } + /// /// 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. diff --git a/src/OrthoStreamXP/Scenery/Night/DsfNetworkReader.cs b/src/OrthoStreamXP/Scenery/Night/DsfNetworkReader.cs new file mode 100644 index 0000000..c3db01e --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/DsfNetworkReader.cs @@ -0,0 +1,244 @@ +using System.Buffers.Binary; + +namespace OrthoStreamXP.Scenery; + +/// +/// Reads the road networks of an uncompressed DSF (X-Plane's scenery tile format): the coordinates of its 32-bit +/// point pools (PO32, scaled by SC32) and the chains its command stream (CMDS) draws from them. Everything else in +/// the file (terrain, objects, polygons) is skipped. X-Plane's own Global Scenery is 7-Zip compressed and not read +/// (); the overlay packages the region installer places are plain DSFs. +/// +internal static class DsfNetworkReader +{ + private const int HeaderSize = 12, FooterSize = 16; // "XPLNEDSF", version; the MD5 at the end + + // Atom ids are four characters stored as a little-endian integer. + private const uint Geodesy = 0x47454F44, Commands = 0x434D4453, Pool32 = 0x504F3332, Scale32 = 0x53433332; // GEOD, CMDS, PO32, SC32 + + /// Whether the file is a 7-Zip archive (how X-Plane ships its Global Scenery DSFs). + public static bool IsCompressed(ReadOnlySpan file) => file.Length >= 6 && file[..6].SequenceEqual((ReadOnlySpan)[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]); + + /// The roads of the DSF of the tile at , . + /// The file is not an uncompressed DSF, or is damaged. + public static RoadNetwork Read(ReadOnlySpan file, int south, int west) + { + if (file.Length < HeaderSize + FooterSize || !file[..8].SequenceEqual("XPLNEDSF"u8)) + throw new InvalidDataException(IsCompressed(file) ? "the DSF is compressed (7-Zip)" : "not a DSF file"); + try + { + ReadOnlySpan atoms = file[HeaderSize..^FooterSize]; + ReadOnlySpan geodesy = default, commands = default; + foreach (var (id, body) in Atoms(atoms)) + { + if (id == Geodesy) geodesy = atoms[body]; + else if (id == Commands) commands = atoms[body]; + } + var pools = new List<(uint[] Lon, uint[] Lat)>(); + var scales = new List<(double LonScale, double LonOffset, double LatScale, double LatOffset)>(); + foreach (var (id, range) in Atoms(geodesy)) + { + ReadOnlySpan body = geodesy[range]; + if (id == Pool32) pools.Add(DecodePool(body)); + else if (id == Scale32) scales.Add(body.Length >= 16 + ? (ReadFloat(body, 0), ReadFloat(body, 4), ReadFloat(body, 8), ReadFloat(body, 12)) + : (0, 0, 0, 0)); + } + return new ChainBuilder(south, west, pools, scales).Run(commands); + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException or IndexOutOfRangeException or OverflowException) + { + throw new InvalidDataException("the DSF is cut short or damaged", ex); + } + } + + private static double ReadFloat(ReadOnlySpan data, int at) => BinaryPrimitives.ReadSingleLittleEndian(data[at..]); + + /// The atoms one after another in a piece of the file (or of a parent atom): id and where its contents are. + private static List<(uint Id, Range Body)> Atoms(ReadOnlySpan data) + { + var atoms = new List<(uint, Range)>(); + int at = 0; + while (at + 8 <= data.Length) + { + uint id = BinaryPrimitives.ReadUInt32LittleEndian(data[at..]); + int length = BinaryPrimitives.ReadInt32LittleEndian(data[(at + 4)..]); + if (length < 8 || length > data.Length - at) throw new InvalidDataException("a DSF atom runs past its parent"); + atoms.Add((id, (at + 8)..(at + length))); + at += length; + } + return atoms; + } + + /// The first two planes (longitude, latitude) of a 32-bit point pool, as stored (not yet scaled). + private static (uint[] Lon, uint[] Lat) DecodePool(ReadOnlySpan pool) + { + int count = BinaryPrimitives.ReadInt32LittleEndian(pool); + int planes = pool[4]; + if (count < 0) throw new InvalidDataException("a DSF point pool has a negative size"); + if (planes < 2) return ([], []); + int at = 5; + uint[] lon = new uint[count], lat = new uint[count]; + uint[]? skipped = null; + for (int plane = 0; plane < planes; plane++) + { + uint[] target = plane == 0 ? lon : plane == 1 ? lat : skipped ??= new uint[count]; + at = DecodePlane(pool, at, target); + } + return (lon, lat); + } + + /// + /// One plane of 32-bit values: raw (0), differenced (1), run-length (2) or both (3). Returns where the next plane starts. + /// + private static int DecodePlane(ReadOnlySpan pool, int at, uint[] values) + { + byte encoding = pool[at++]; + if (encoding > 3) throw new InvalidDataException($"unknown DSF pool encoding {encoding}"); + if (encoding is 0 or 1) + { + for (int i = 0; i < values.Length; i++, at += 4) values[i] = BinaryPrimitives.ReadUInt32LittleEndian(pool[at..]); + } + else + { + int i = 0; + while (i < values.Length) + { + byte code = pool[at++]; + int run = code & 0x7F; + if (i + run > values.Length) throw new InvalidDataException("a DSF pool run is longer than its pool"); + if ((code & 0x80) != 0) + { + uint value = BinaryPrimitives.ReadUInt32LittleEndian(pool[at..]); + at += 4; + values.AsSpan(i, run).Fill(value); + i += run; + } + else + { + for (int end = i + run; i < end; i++, at += 4) values[i] = BinaryPrimitives.ReadUInt32LittleEndian(pool[at..]); + } + } + } + if (encoding is 1 or 3) + { + uint sum = 0; + for (int i = 0; i < values.Length; i++) values[i] = sum = unchecked(sum + values[i]); + } + return at; + } + + /// Walks the command stream, collecting each road chain's points as degrees from the tile's corner. + private sealed class ChainBuilder(int south, int west, List<(uint[] Lon, uint[] Lat)> pools, + List<(double LonScale, double LonOffset, double LatScale, double LatOffset)> scales) + { + private readonly List _lon = [], _lat = []; + private readonly List _start = [0]; + private readonly List _class = []; + private readonly List _rural = []; + private int _pool, _subtype; + private uint _junctionOffset; + + public RoadNetwork Run(ReadOnlySpan commands) + { + int at = 0; + while (at < commands.Length) + { + byte command = commands[at++]; + switch (command) + { + case 1: _pool = BinaryPrimitives.ReadUInt16LittleEndian(commands[at..]); at += 2; break; // pool select + case 2: _junctionOffset = BinaryPrimitives.ReadUInt32LittleEndian(commands[at..]); at += 4; break; + case 3: at += 1; break; // definition select (8, 16, 32 bit): one road network per DSF + case 4: at += 2; break; + case 5: at += 4; break; + case 6: _subtype = commands[at++]; break; // road subtype + case 7: at += 2; break; // object + case 8: at += 4; break; // object range + case 9: // network chain, 16-bit indices from the junction offset + { + int count = commands[at++]; + BeginChain(); + for (int i = 0; i < count; i++, at += 2) Add(BinaryPrimitives.ReadUInt16LittleEndian(commands[at..]) + (long)_junctionOffset); + EndChain(); + break; + } + case 10: // network chain range [first, last) from the junction offset + { + long first = BinaryPrimitives.ReadUInt16LittleEndian(commands[at..]) + (long)_junctionOffset; + long last = BinaryPrimitives.ReadUInt16LittleEndian(commands[(at + 2)..]) + (long)_junctionOffset; + at += 4; + BeginChain(); + for (long i = first; i < last; i++) Add(i); + EndChain(); + break; + } + case 11: // network chain, 32-bit indices + { + int count = commands[at++]; + BeginChain(); + for (int i = 0; i < count; i++, at += 4) Add(BinaryPrimitives.ReadUInt32LittleEndian(commands[at..])); + EndChain(); + break; + } + case 12: at += 2; at += 1 + 2 * commands[at]; break; // polygon: parameter, count, indices + case 13: at += 6; break; // polygon range + case 14: // nested polygon: parameter, windings, each a count and its indices + { + at += 2; + int windings = commands[at++]; + for (int w = 0; w < windings; w++) at += 1 + 2 * commands[at]; + break; + } + case 15: at += 2; at += 1 + 2 * (commands[at] + 1); break; // nested polygon range: parameter, windings, windings + 1 indices + case 16: break; // terrain patch + case 17: at += 1; break; // patch with flags + case 18: at += 9; break; // patch with flags and LOD + case 23 or 26 or 29: at += 1 + 2 * commands[at]; break; // triangles, strip, fan + case 24 or 27 or 30: at += 1 + 4 * commands[at]; break; // the same across pools + case 25 or 28 or 31: at += 4; break; // the same as ranges + case 32: at += 1 + commands[at]; break; // comments + case 33: at += 2 + BinaryPrimitives.ReadUInt16LittleEndian(commands[at..]); break; + case 34: at += 4 + checked((int)BinaryPrimitives.ReadUInt32LittleEndian(commands[at..])); break; + default: throw new InvalidDataException($"unknown DSF command {command}"); + } + } + if (at > commands.Length) throw new InvalidDataException("the DSF commands are cut short"); + return new RoadNetwork(south, west, [.. _lon], [.. _lat], [.. _start], [.. _class], [.. _rural]); + } + + private RoadClass _current; + private bool _currentRural, _skip; + + private void BeginChain() + { + _current = RoadClasses.Of(_subtype, out _currentRural); + _skip = _current == RoadClass.None || _pool >= pools.Count || _pool >= scales.Count || pools[_pool].Lon.Length == 0; + } + + private void Add(long index) + { + if (_skip) return; + var (lon, lat) = pools[_pool]; + if (index < 0 || index >= lon.Length) throw new InvalidDataException("a DSF road chain points outside its pool"); + var s = scales[_pool]; + _lon.Add((float)(lon[index] / 4294967295.0 * s.LonScale + s.LonOffset - west)); + _lat.Add((float)(lat[index] / 4294967295.0 * s.LatScale + s.LatOffset - south)); + } + + private void EndChain() + { + if (_skip) return; + int points = _lon.Count - _start[^1]; + if (points < 2) + { + // A single point lights nothing: dropped. + _lon.RemoveRange(_start[^1], points); + _lat.RemoveRange(_start[^1], points); + return; + } + _start.Add(_lon.Count); + _class.Add(_current); + _rural.Add(_currentRural); + } + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightDds.cs b/src/OrthoStreamXP/Scenery/Night/NightDds.cs new file mode 100644 index 0000000..1f494f7 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightDds.cs @@ -0,0 +1,281 @@ +using System.Buffers.Binary; +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Scenery; + +/// +/// Night textures as DXT1 DDS files with every mip level. The smaller levels are averages in linear light (gamma +/// 2.2), so a town shines as bright from far as from near: averaging the stored values would dim it at a distance. +/// Light fainter than is black at every level, and no block is left greener or bluer +/// than it is red (): every light drawn is orange or white. +/// +/// Only what X-Plane reads is encoded (): the start of level 0 its header chunk takes, and the +/// levels from the finest it wants. Every level is worked out from the whole painting, whichever level the stored part +/// starts at, so a level painted at the same side is the same bytes whether it was stored first or later (a finer +/// painting, for a level wanted later beside the aircraft, differs from it only in its finer detail). +/// +/// +internal static class NightDds +{ + private const double Gamma = 2.2; + private const int LinearSteps = 65535; + private static readonly float[] ToLinear = [.. Enumerable.Range(0, 256).Select(v => (float)Math.Pow(v / 255.0, Gamma))]; + /// Linear light in 65536 steps back to the stored value: fine enough near black, where gamma is steepest. + private static readonly byte[] ToStored = [.. Enumerable.Range(0, LinearSteps + 1).Select(i => (byte)Math.Clamp((int)(Math.Pow(i / (double)LinearSteps, 1 / Gamma) * 255 + 0.5), 0, 255))]; + + /// The whole DDS file, pixels square, of the texture painted in . + public static byte[] Encode(NightWorkspace painted, int nominal) + { + var layout = DdsLayout.For(nominal); + byte[] file = new byte[layout.Length]; + layout.Header.CopyTo(file); + EncodeStored(painted, nominal, 0).CopyTo(file, Dds.HeaderSize); + return file; + } + + /// + /// What is kept () of a night texture pixels square, painted in + /// (whose RGB picture may be smaller or larger), when X-Plane wants it from level + /// . A level as large as the painting or larger is the painting itself, enlarged in linear + /// light where X-Plane wants more than was painted; a smaller one is the painting averaged down. Uses the + /// workspace's pictures: the painting is gone after. + /// + public static byte[] EncodeStored(NightWorkspace painted, int nominal, int from) + { + int side = painted.Side; + var layout = DdsLayout.For(nominal); + from = NightStore.Normalize(layout, from); + byte[] stored = new byte[NightStore.Length(layout, from)]; + + // The painting in linear light, one picture per colour, kept for the levels as large as it or larger. + float[] red = painted.Street, green = painted.Windows, blue = painted.Sodium; + byte[] rgb = painted.Rgb; + for (int i = 0, p = 0; i < side * side; i++, p += 3) (red[i], green[i], blue[i]) = (ToLinear[rgb[p]], ToLinear[rgb[p + 1]], ToLinear[rgb[p + 2]]); + + // Level 0 whole, or the block rows of the header chunk's part; then the levels from "from". + int headBytes = (int)(NightStore.HeadEnd(layout) - Dds.HeaderSize); + var wanted = new List<(int Mip, int BlockRows, int Bytes)>(); + int level0Rows = Math.Max(1, (nominal + 3) / 4), level0RowBytes = level0Rows * 8; + wanted.Add((0, from == 0 ? level0Rows : Math.Min(level0Rows, (headBytes + level0RowBytes - 1) / level0RowBytes), from == 0 ? layout.LengthOf(0) : headBytes)); + for (int mip = Math.Max(1, from); mip < layout.Levels; mip++) wanted.Add((mip, Math.Max(1, (layout.SizeOf(mip) + 3) / 4), layout.LengthOf(mip))); + + // Smaller levels are averaged down from a copy, halved in place as the levels get smaller. + float[] smallRed = painted.Led, smallGreen = painted.WarmGlow, smallBlue = painted.CoolGlow; + int smallSize = 0; + foreach (var (mip, blockRows, bytes) in wanted) + { + int size = layout.SizeOf(mip); + byte[] blocks = new byte[blockRows * Math.Max(1, (size + 3) / 4) * 8]; + if (size == side) + { + EncodeLevel(rgb, size, blockRows, blocks); + } + else if (size > side) + { + EncodeEnlarged(red, green, blue, side, size, blockRows, blocks); + } + else + { + if (smallSize == 0) + { + smallSize = side; + red.CopyTo(smallRed, 0); + green.CopyTo(smallGreen, 0); + blue.CopyTo(smallBlue, 0); + } + for (; smallSize > size; smallSize /= 2) + { + Half(smallRed, smallSize); + Half(smallGreen, smallSize); + Half(smallBlue, smallSize); + } + ToBytes(smallRed, smallGreen, smallBlue, size, rgb); // the levels as large as the painting come first + EncodeLevel(rgb, size, blockRows, blocks); + } + blocks.AsSpan(0, bytes).CopyTo(stored.AsSpan((int)NightStore.Position(layout, from, layout.Offset(mip)))); + } + return stored; + } + + /// + /// The first rows of BC1 blocks of a level pixels square, made + /// from the painting ( pixels, linear light) enlarged smoothly, four pixel rows at a time. + /// + private static void EncodeEnlarged(float[] red, float[] green, float[] blue, int side, int size, int blockRows, Span blocks) + { + // Each pixel of the level read between the four painted pixels around its centre. + double scale = (double)side / size; + int[] x0 = new int[size], x1 = new int[size]; + float[] fx = new float[size]; + for (int x = 0; x < size; x++) (x0[x], x1[x], fx[x]) = Between(x, scale, side); + int rows = Math.Min(4, size), blocksAcross = Math.Max(1, (size + 3) / 4); + byte[] band = new byte[size * rows * 3]; + for (int by = 0; by < blockRows; by++) + { + for (int r = 0; r < rows; r++) + { + var (y0, y1, fy) = Between(by * 4 + r, scale, side); + for (int x = 0; x < size; x++) + { + int a = y0 * side + x0[x], b = y0 * side + x1[x], c = y1 * side + x0[x], d = y1 * side + x1[x]; + float tx = fx[x]; + float Mix(float[] p) => (p[a] + (p[b] - p[a]) * tx) * (1 - fy) + (p[c] + (p[d] - p[c]) * tx) * fy; + int at = (r * size + x) * 3; + (band[at], band[at + 1], band[at + 2]) = Floored(Stored(Mix(red)), Stored(Mix(green)), Stored(Mix(blue))); + } + } + EncodeBand(band, size, rows, blocks.Slice(by * blocksAcross * 8, blocksAcross * 8)); + } + } + + /// The two painted pixels either side of pixel 's centre, and how far between them it is. + private static (int Low, int High, float Fraction) Between(int i, double scale, int side) + { + double at = Math.Clamp((i + 0.5) * scale - 0.5, 0, side - 1); + int low = (int)at; + return (low, Math.Min(low + 1, side - 1), (float)(at - low)); + } + + /// + /// The first rows of BC1 blocks of a level pixels square into + /// , four pixel rows at a time. + /// + private static void EncodeLevel(byte[] rgb, int size, int blockRows, Span blocks) + { + int rows = Math.Min(4, size), rowBytes = size * 3, blocksAcross = Math.Max(1, (size + 3) / 4); + for (int by = 0; by < blockRows; by++) + EncodeBand(rgb.AsSpan(by * 4 * rowBytes, rowBytes * rows), size, rows, blocks.Slice(by * blocksAcross * 8, blocksAcross * 8)); + } + + /// + /// One row of BC1 blocks from pixel rows wide. Only the runs of + /// blocks with light in them are encoded: a black block (most of the country, and the gaps between a town's + /// streets) stays zeros, which BC1 reads as black. + /// + private static void EncodeBand(ReadOnlySpan band, int size, int rows, Span blocks) + { + blocks.Clear(); + if (band.IndexOfAnyExcept((byte)0) < 0) return; + int blocksAcross = Math.Max(1, (size + 3) / 4); + byte[]? run = null; + for (int bx = 0; bx < blocksAcross;) + { + if (!Lit(band, size, bx, rows)) + { + bx++; + continue; + } + int first = bx; + while (bx < blocksAcross && Lit(band, size, bx, rows)) bx++; + // The run's pixels side by side, encoded as a narrow picture of their own. + int left = first * 4, width = Math.Min(size, bx * 4) - left; + run ??= new byte[size * rows * 3]; + for (int r = 0; r < rows; r++) band.Slice((r * size + left) * 3, width * 3).CopyTo(run.AsSpan(r * width * 3)); + byte[] encoded = Dds.EncodeRows(run, width, rows, workers: 1); + Warm(encoded); + encoded.CopyTo(blocks[(first * 8)..]); + } + } + + /// Whether any pixel of the 4 x 4 block of a band has light. + private static bool Lit(ReadOnlySpan band, int size, int bx, int rows) + { + int left = bx * 4, width = Math.Min(4, size - left); + for (int r = 0; r < rows; r++) + if (band.Slice((r * size + left) * 3, width * 3).IndexOfAnyExcept((byte)0) >= 0) return true; + return false; + } + + /// The picture pixels square halved in place: each pixel of the half the mean of its four. + private static void Half(float[] picture, int size) + { + // Each pixel is written at or before the first of its four, which no later pixel reads. + int half = size / 2; + for (int y = 0; y < half; y++) + for (int x = 0; x < half; x++) + { + int top = 2 * y * size + 2 * x, bottom = top + size; + picture[y * half + x] = (picture[top] + picture[top + 1] + picture[bottom] + picture[bottom + 1]) / 4; + } + } + + /// Linear light back to stored RGB, what is fainter than the floor black. + private static void ToBytes(float[] red, float[] green, float[] blue, int size, byte[] rgb) + { + for (int i = 0, p = 0; i < size * size; i++, p += 3) + (rgb[p], rgb[p + 1], rgb[p + 2]) = Floored(Stored(red[i]), Stored(green[i]), Stored(blue[i])); + } + + private static byte Stored(float linear) => ToStored[Math.Clamp((int)(linear * LinearSteps + 0.5f), 0, LinearSteps)]; + + /// A pixel fainter than the floor, black. + private static (byte R, byte G, byte B) Floored(byte r, byte g, byte b) => + Math.Max(r, Math.Max(g, b)) < NightPainter.Floor ? ((byte)0, (byte)0, (byte)0) : (r, g, b); + + /// + /// BC1 keeps green in 6 bits and red and blue in 5, so a dim block of orange light can come out a little green or + /// purple. Each block's two colours get their green and blue brought down to their red at most; the block stays + /// in four-colour mode (first colour above the second): two colours that swapped order swap back with their texels' + /// indices, and two that became one keep it alone. + /// + internal static void Warm(Span blocks) + { + for (int at = 0; at + 8 <= blocks.Length; at += 8) + { + Span block = blocks.Slice(at, 8); + int c0 = WarmColour(BinaryPrimitives.ReadUInt16LittleEndian(block)), c1 = WarmColour(BinaryPrimitives.ReadUInt16LittleEndian(block[2..])); + uint indices = BinaryPrimitives.ReadUInt32LittleEndian(block[4..]); + if (c0 < c1) + { + (c0, c1) = (c1, c0); + indices ^= 0x55555555; // each texel's 0 <-> 1 and 2 <-> 3 + } + else if (c0 == c1) + { + indices = 0; + } + BinaryPrimitives.WriteUInt16LittleEndian(block, (ushort)c0); + BinaryPrimitives.WriteUInt16LittleEndian(block[2..], (ushort)c1); + BinaryPrimitives.WriteUInt32LittleEndian(block[4..], indices); + } + } + + private static int WarmColour(int c) + { + int r = c >> 11, g = (c >> 5) & 63, b = c & 31; + return (r << 11) | (Math.Min(g, r * 63 / 31) << 5) | Math.Min(b, r); + } +} + +/// +/// Where the parts of a night texture's DDS file are kept in its cache file. X-Plane reads every texture's first 64 KB +/// (the header and the start of level 0), then only the levels from the finest it wants, which far away is a 256 px +/// one. So the cache file holds the start of level 0 that chunk covers, then the levels from the finest one X-Plane +/// has wanted ("from"); a small texture whose levels all start within that chunk is kept whole. The header itself is +/// the same for every texture of a size and is not kept. keeps it compressed. +/// +internal static class NightStore +{ + /// Where X-Plane's header chunk ends: the start of level 0 is kept up to here. + public static long HeadEnd(DdsLayout layout) => Math.Min(ReadPattern.HeaderChunk, layout.Length); + + /// The level a stored part starts at: one that starts within the header chunk means the whole texture. + public static int Normalize(DdsLayout layout, int from) => + from <= 0 || from >= layout.Levels || layout.Offset(from) <= HeadEnd(layout) ? 0 : from; + + /// The cache file's length when it holds the levels from . + public static long Length(DdsLayout layout, int from) + { + from = Normalize(layout, from); + return from == 0 ? layout.Length - Dds.HeaderSize : HeadEnd(layout) - Dds.HeaderSize + layout.ChainLength(from); + } + + /// Where byte of the DDS file is in the cache file, or -1 when it is not kept there. + public static long Position(DdsLayout layout, int from, long offset) + { + from = Normalize(layout, from); + if (offset < Dds.HeaderSize || offset >= layout.Length) return -1; + if (from == 0 || offset < HeadEnd(layout)) return offset - Dds.HeaderSize; + return offset >= layout.Offset(from) ? HeadEnd(layout) - Dds.HeaderSize + offset - layout.Offset(from) : -1; + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightFile.cs b/src/OrthoStreamXP/Scenery/Night/NightFile.cs new file mode 100644 index 0000000..7c542fc --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightFile.cs @@ -0,0 +1,128 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Compression; +using Microsoft.Win32.SafeHandles; + +namespace OrthoStreamXP.Scenery; + +/// +/// A night texture's cache file: what is kept of it () in 64 KB chunks, each compressed on its +/// own, so a read unpacks only the chunks it covers. A night texture is mostly black: an all-black chunk takes no +/// space at all, and the others shrink to a fraction. +/// Layout: "NLT3", the level the kept part starts at (int), its length (long), the chunk count (int), each +/// chunk's compressed length (int, 0 for black), then the chunks. +/// +internal static class NightFile +{ + private const int Chunk = 65536; + private static ReadOnlySpan Magic => "NLT3"u8; + + /// The level the kept part starts at, its length, and where each chunk is in the file (0 length: black). + internal sealed record Contents(int From, long Length, long[] ChunkStart, int[] ChunkLength); + + /// Writes the kept part starting at level , whole or not at all. + public static void Write(string path, int from, byte[] kept) + { + int chunks = (int)((kept.LongLength + Chunk - 1) / Chunk); + var bodies = new byte[chunks][]; + for (int i = 0; i < chunks; i++) + { + ReadOnlySpan piece = kept.AsSpan(i * Chunk, (int)Math.Min(Chunk, kept.LongLength - (long)i * Chunk)); + if (piece.IndexOfAnyExcept((byte)0) < 0) + { + bodies[i] = []; + continue; + } + using var packed = new MemoryStream(); + using (var deflate = new DeflateStream(packed, CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(piece); + bodies[i] = packed.ToArray(); + } + int headerLength = 4 + 4 + 8 + 4 + 4 * chunks; + byte[] file = new byte[headerLength + bodies.Sum(b => (long)b.Length)]; + Magic.CopyTo(file); + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(4), from); + BinaryPrimitives.WriteInt64LittleEndian(file.AsSpan(8), kept.LongLength); + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(16), chunks); + int at = headerLength; + for (int i = 0; i < chunks; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(20 + 4 * i), bodies[i].Length); + bodies[i].CopyTo(file, at); + at += bodies[i].Length; + } + AtomicFile.Write(path, file); + } + + /// What the file holds, or null when it is no night texture file (or a damaged one). + public static Contents? ReadContents(SafeFileHandle file) + { + Span head = stackalloc byte[20]; + long fileLength = RandomAccess.GetLength(file); + if (RandomAccess.Read(file, head, 0) < head.Length || !head[..4].SequenceEqual(Magic)) return null; + int from = BinaryPrimitives.ReadInt32LittleEndian(head[4..]), chunks = BinaryPrimitives.ReadInt32LittleEndian(head[16..]); + long length = BinaryPrimitives.ReadInt64LittleEndian(head[8..]); + if (from < 0 || length < 0 || chunks != (length + Chunk - 1) / Chunk || 20 + 4L * chunks > fileLength) return null; + byte[] table = new byte[4 * chunks]; + if (RandomAccess.Read(file, table, 20) < table.Length) return null; + long[] start = new long[chunks]; + int[] lengths = new int[chunks]; + long at = 20 + table.Length; + for (int i = 0; i < chunks; i++) + { + lengths[i] = BinaryPrimitives.ReadInt32LittleEndian(table.AsSpan(4 * i)); + if (lengths[i] < 0) return null; + start[i] = at; + at += lengths[i]; + } + return at == fileLength ? new Contents(from, length, start, lengths) : null; + } + + /// The kept part's bytes from into , unpacking only the chunks they lie in. + /// A chunk does not unpack. + public static void Read(SafeFileHandle file, Contents contents, long position, Span output) + { + byte[] packed = ArrayPool.Shared.Rent(Chunk + 1024), plain = ArrayPool.Shared.Rent(Chunk); + try + { + while (!output.IsEmpty) + { + int chunk = (int)(position / Chunk), within = (int)(position % Chunk); + int count = Math.Min(output.Length, Math.Min(Chunk - within, (int)(contents.Length - position))); + if (count <= 0) break; + int size = contents.ChunkLength[chunk]; + if (size == 0) + { + output[..count].Clear(); + } + else + { + if (size > packed.Length) + { + ArrayPool.Shared.Return(packed); + packed = ArrayPool.Shared.Rent(size); + } + if (RandomAccess.Read(file, packed.AsSpan(0, size), contents.ChunkStart[chunk]) < size) throw new InvalidDataException("a night texture file is cut short"); + int plainLength = Unpack(packed.AsSpan(0, size), plain); + if (plainLength < within + count) throw new InvalidDataException("a night texture chunk is cut short"); + plain.AsSpan(within, count).CopyTo(output); + } + output = output[count..]; + position += count; + } + } + finally + { + ArrayPool.Shared.Return(packed); + ArrayPool.Shared.Return(plain); + } + } + + private static int Unpack(ReadOnlySpan packed, byte[] plain) + { + using var source = new MemoryStream(packed.ToArray(), writable: false); + using var deflate = new DeflateStream(source, CompressionMode.Decompress); + int total = 0, read; + while (total < Chunk && (read = deflate.Read(plain, total, Chunk - total)) > 0) total += read; + return total; + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightPainter.cs b/src/OrthoStreamXP/Scenery/Night/NightPainter.cs new file mode 100644 index 0000000..a37e4e8 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightPainter.cs @@ -0,0 +1,204 @@ +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Scenery; + +/// +/// Paints the lights of a town on a photo texture's night texture, from X-Plane's own roads: +/// +/// street lamps along each road, every 28-45 m by its class, a third white LEDs and the rest sodium orange, at +/// random levels and one in eight out; dimmer and farther apart along a country road. Each lamp is the pool of light +/// it casts, centred beside the road, not on it: X-Plane's 3D roads (or an overlay's, like simHeaven's) are drawn +/// over the photo and would hide light painted on the carriageway. Larger roads have lamps on both kerbs in turn, +/// smaller ones on one; +/// a soft glow round the lamps; +/// the houses' light, smooth and faint, in a band along the town streets, and the streets themselves faintly +/// lit (seen where X-Plane draws no 3D road); +/// how built up the place is () decides how many lamps are lit (all in a village, +/// a fifth in the open country) and how bright the windows are. +/// +/// Between the lights it is black: X-Plane lights the photo itself at night. Every texture is drawn at the same metres +/// per pixel (), so neighbours of different zoom levels shine alike, and the same +/// texture is always drawn the same way. +/// +internal sealed class NightPainter(RoadNetworks roads) +{ + /// The band along a town street its houses' light fills, and that light (the mean of the lit windows it was). + private const double WindowBandM = 50, WindowLight = 0.06; + /// How soft the town's light is (its Gaussian's sigma), and the share of it that is white rather than warm. + private const double TownSigmaM = 8; + private const float TownWhite = 0.25f; + private const float StreetWeight = 0.12f, LampWeight = 1.0f, GlowWeight = 0.7f; + private const double LedShare = 0.35, LampsOut = 0.12, LampMin = 0.55, LampMax = 1.0; + /// How far past the kerb a lamp's pool of light is centred. + private const double LampSetbackM = 2; + /// How wide the glow round the lamps is (its Gaussian's sigma). + private const double GlowSigmaM = 15; + /// The share of lamps lit where nothing is built up; all are lit from on. + private const double LitShareMin = 0.2, VillageBuiltUp = 0.25; + /// A lamp's light where nothing is built up, against 2 - this in a city centre. + private const double LampCountry = 0.8; + /// The windows' light where nothing is built up, and how much more in a city centre. + private const double WindowsCountry = 0.2, WindowsCity = 1.3; + /// What the lights drawn are multiplied by. + public const float Gain = 0.9f; + /// Light fainter than this (out of 255, in its brightest channel) is black: BC1 cannot keep its colour. + public const int Floor = 6; + /// How far round the texture roads are looked for: their glow reaches in. + private const double MarginDegrees = 0.005; + + private static readonly float[] Sodium = [1.00f, 0.70f, 0.36f], Led = [0.95f, 0.92f, 0.85f]; + + /// + /// Paints the texture's lights into 's RGB picture, as large as the workspace (black where + /// there is no light); false when it has none at all. + /// + public bool Paint(TextureName name, NightWorkspace canvas) + { + int side = canvas.Side; + var (north, west) = Tiles.TileToLatLon(name.Col, name.Row, name.Zoom); + var (south, east) = Tiles.TileToLatLon(name.Col + Tiles.TextureSpan, name.Row + Tiles.TextureSpan, name.Zoom); + double metresPerPixel = name.GroundSizeM / side; + var random = new Random(Seed(name)); + var projection = new Projection(name, side); + var chains = new List(); + bool anyRoad = false; + for (int lat = (int)Math.Floor(south - MarginDegrees); lat <= (int)Math.Floor(north + MarginDegrees); lat++) + for (int lon = (int)Math.Floor(west - MarginDegrees); lon <= (int)Math.Floor(east + MarginDegrees); lon++) + { + if (roads.For(lat, lon) is not { } network) continue; + chains.Clear(); + network.Near(south - MarginDegrees, west - MarginDegrees, north + MarginDegrees, east + MarginDegrees, chains); + foreach (int chain in chains) DrawRoad(network, chain, projection, metresPerPixel, canvas, random); + anyRoad |= chains.Count > 0; + } + return anyRoad && Compose(canvas, side, metresPerPixel); + } + + /// The same lamps for a texture every time it is painted. + private static int Seed(TextureName name) => unchecked((name.Row * 73_856_093) ^ (name.Col * 19_349_663) ^ (name.Zoom * 83_492_791)); + + private static void DrawRoad(RoadNetwork network, int chain, Projection projection, double metresPerPixel, NightWorkspace canvas, Random random) + { + RoadClass roadClass = network.ClassOf(chain); + bool rural = network.IsRural(chain); + RoadLook look = RoadClasses.Look(roadClass); + var (lonMemory, latMemory) = network.Points(chain); + ReadOnlySpan lon = lonMemory.Span, lat = latMemory.Span; + int count = lon.Length; + Span x = count <= 256 ? stackalloc double[count] : new double[count]; + Span y = count <= 256 ? stackalloc double[count] : new double[count]; + for (int i = 0; i < count; i++) (x[i], y[i]) = projection.Pixel(network.South + lat[i], network.West + lon[i]); + + double brightness = look.Brightness * (rural ? RoadClasses.RuralBrightness : 1); + int middle = count / 2; + double builtUp = network.BuiltUp(network.South + lat[middle], network.West + lon[middle]); + double litShare = LitShareMin + (1 - LitShareMin) * Math.Min(1, builtUp / VillageBuiltUp); + + // The street itself, as much as its lamps are lit, and the windows along a town street. + double streetWidth = Math.Max(1, Math.Round(look.WidthM / metresPerPixel)); + float street = (float)(brightness * litShare); + bool houses = look.LinedWithHouses && !rural; + double bandWidth = Math.Max(1, Math.Round(WindowBandM / metresPerPixel)); + float windows = (float)(WindowLight * (WindowsCountry + WindowsCity * builtUp)); + for (int i = 0; i < count - 1; i++) + { + NightRaster.Line(canvas.Street, canvas.Side, x[i], y[i], x[i + 1], y[i + 1], streetWidth, street); + if (houses) NightRaster.Line(canvas.Windows, canvas.Side, x[i], y[i], x[i + 1], y[i + 1], bandWidth, windows); + } + + // The lamps: one every spacing along the road from a random start, beside it (on one kerb, or on both in + // turn). Every draw is made whatever its outcome, so one lamp's fate never moves the next lamps. + double spacing = look.LampSpacingM * (rural ? RoadClasses.RuralSpacing : 1) / metresPerPixel; + double setback = (look.WidthM / 2 + LampSetbackM) / metresPerPixel; + float[] lamps = random.NextDouble() < LedShare ? canvas.Led : canvas.Sodium; + int kerb = random.NextDouble() < 0.5 ? 1 : -1; + double next = random.NextDouble() * spacing, travelled = 0; + double lampScale = LampCountry + 2 * (1 - LampCountry) * builtUp, lampSigma = LampSigma(metresPerPixel); + for (int i = 0; i < count - 1; i++) + { + double dx = x[i + 1] - x[i], dy = y[i + 1] - y[i], length = Math.Sqrt(dx * dx + dy * dy); + for (; next <= travelled + length && length > 0; next += spacing) + { + double t = (next - travelled) / length; + bool working = random.NextDouble() >= LampsOut, lit = random.NextDouble() < litShare; + double level = brightness * (LampMin + (LampMax - LampMin) * random.NextDouble()) * lampScale; + // Off the road to the kerb's side: (-dy, dx) is square to the road. + double lx = x[i] + t * dx - kerb * setback * dy / length, ly = y[i] + t * dy + kerb * setback * dx / length; + if (look.LampsBothSides) kerb = -kerb; + // A lamp outside the texture is its neighbour's to draw, which places its own lamps. + if (!working || !lit || lx < 0 || ly < 0 || lx >= canvas.Side || ly >= canvas.Side) continue; + NightRaster.Spot(lamps, canvas.Side, lx, ly, lampSigma, (float)level); + } + travelled += length; + } + } + + /// The lamps, their glow and the town's light together, in their colours, into the RGB picture; false when all black. + private static bool Compose(NightWorkspace canvas, int side, double metresPerPixel) + { + double sigma = Math.Max(1.5, GlowSigmaM / metresPerPixel); + // The glow is the lamps' alone: they stand beside the road, so it lights the verges, not the carriageway + // a 3D road covers. + float[] warmGlow = canvas.WarmGlow, coolGlow = canvas.CoolGlow; + NightRaster.Blur(canvas.Sodium, side, sigma, canvas.Across, warmGlow); + NightRaster.Blur(canvas.Led, side, sigma, canvas.Across, coolGlow); + + // The houses' light beside the streets and the streets' own faint light, as one smooth light: drawn pixel by + // pixel (a window here, none there) it was a speckle, which X-Plane's enlarging beside the aircraft made blotches, + // and a street's hard edge came out in steps. + float[] town = canvas.Windows; + for (int i = 0; i < town.Length; i++) + if (canvas.Street[i] > 0) town[i] = canvas.Street[i] * StreetWeight; + NightRaster.Blur(town, side, Math.Max(1, TownSigmaM / metresPerPixel), canvas.Across, town); + + byte[] rgb = canvas.Rgb; + bool any = false; + for (int i = 0; i < side * side; i++) + { + if (warmGlow[i] == 0 && coolGlow[i] == 0 && town[i] == 0) continue; // the glows cover the lamps and streets + float warm = warmGlow[i] * GlowWeight + canvas.Sodium[i] * LampWeight + town[i] * (1 - TownWhite); + float cool = coolGlow[i] * GlowWeight + canvas.Led[i] * LampWeight + town[i] * TownWhite; + byte r = ToByte((warm * Sodium[0] + cool * Led[0]) * Gain), g = ToByte((warm * Sodium[1] + cool * Led[1]) * Gain), b = ToByte((warm * Sodium[2] + cool * Led[2]) * Gain); + if (Math.Max(r, Math.Max(g, b)) < Floor) continue; + (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]) = (r, g, b); + any = true; + } + return any; + } + + /// + /// A lamp's pool of light holds as much light as a square this wide at full strength (about 2.5 pixels of a ZL16 + /// texture painted at 1024 px in Europe; a street lamp lights the ground some 15-20 m across), at every resolution: + /// finer painting makes it rounder, not dimmer. + /// + internal const double LampSizeM = 17; + + /// + /// The width, in pixels, of a lamp's round soft spot: a Gaussian holding as much light as the square of + /// (2π sigma² = its area). + /// + internal static double LampSigma(double metresPerPixel) => LampSizeM / metresPerPixel / Math.Sqrt(2 * Math.PI); + + private static byte ToByte(float value) => (byte)Math.Clamp((int)(value * 255 + 0.5f), 0, 255); + + /// Where a place falls on the texture, in pixels from its north-west corner (Web Mercator, as the photo). + private readonly struct Projection + { + private readonly double _tiles, _scale, _col, _row; + + public Projection(TextureName name, int side) + { + _tiles = 1L << name.Zoom; + _scale = (double)side / Tiles.TextureSpan; + (_col, _row) = (name.Col, name.Row); + } + + public (double X, double Y) Pixel(double lat, double lon) + { + double tileX = (lon + 180) / 360 * _tiles; + double radians = lat * Math.PI / 180; + double tileY = (1 - Math.Log(Math.Tan(radians) + 1 / Math.Cos(radians)) / Math.PI) / 2 * _tiles; + return ((tileX - _col) * _scale, (tileY - _row) * _scale); + } + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightPainting.cs b/src/OrthoStreamXP/Scenery/Night/NightPainting.cs new file mode 100644 index 0000000..7681cf4 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightPainting.cs @@ -0,0 +1,45 @@ +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Scenery; + +/// +/// The night textures' painting, shared by every package: the painter, the workspaces it paints in, and how many +/// paintings run at once. They run on X-Plane's reads beside the photo builds; the fine ones (beside the aircraft, up +/// to 124 MB each) one at a time. +/// +internal sealed class NightPainting(NightPainter painter, int atOnce) : IDisposable +{ + private readonly SemaphoreSlim _gate = new(atOnce), _large = new(1); + private readonly NightWorkspaces _workspaces = new(); + + /// + /// Paints at pixels and gives the painting to , + /// whose answer is returned; null when the texture has no light at all. : the time it took, + /// not counting the wait for its turn. + /// + public byte[]? Paint(TextureName name, int side, Func keep, out TimeSpan took) + { + bool large = side > NightWorkspaces.LargeSide; + _gate.Wait(); + if (large) _large.Wait(); + long started = System.Diagnostics.Stopwatch.GetTimestamp(); + NightWorkspace workspace = _workspaces.Rent(side); + try + { + return painter.Paint(name, workspace) ? keep(workspace) : null; + } + finally + { + _workspaces.Return(workspace); + took = System.Diagnostics.Stopwatch.GetElapsedTime(started); + if (large) _large.Release(); + _gate.Release(); + } + } + + public void Dispose() + { + _gate.Dispose(); + _large.Dispose(); + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightRaster.cs b/src/OrthoStreamXP/Scenery/Night/NightRaster.cs new file mode 100644 index 0000000..303dea8 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightRaster.cs @@ -0,0 +1,179 @@ +using System.Numerics; +using System.Runtime.InteropServices; + +namespace OrthoStreamXP.Scenery; + +/// +/// Drawing on square single-channel float pictures (row by row, side x side): what the night lights need. +/// Most of a night texture is black, so rows without light are skipped wherever they can be. +/// +internal static class NightRaster +{ + /// + /// A straight line pixels wide from (x0, y0) to (x1, y1) (pixel coordinates, the picture's + /// corner at 0, 0): every pixel whose centre is within half the width of it, at least half a pixel, is raised to + /// . Raising, not adding, keeps crossing streets and a chain's joints from doubling up. + /// + public static void Line(float[] picture, int side, double x0, double y0, double x1, double y1, double width, float value) + { + double radius = Math.Max(0.5, width / 2); + int top = Math.Max(0, (int)Math.Floor(Math.Min(y0, y1) - radius)), bottom = Math.Min(side - 1, (int)Math.Ceiling(Math.Max(y0, y1) + radius)); + double dx = x1 - x0, dy = y1 - y0, length = Math.Sqrt(dx * dx + dy * dy); + for (int y = top; y <= bottom; y++) + { + // The line is a capsule (a band with round ends): convex, so each row crosses it in one stretch, the + // hull of where the row crosses the band and the two end circles. + double cy = y + 0.5, from = double.PositiveInfinity, to = double.NegativeInfinity; + Circle(x0, cy - y0); + Circle(x1, cy - y1); + if (length > 0) + { + // Along the line (0 to its length) and across it (within the radius), each linear in x. + var (alongFrom, alongTo) = Solve(dx / length, (cy - y0) * dy / length - x0 * dx / length, 0, length); + var (acrossFrom, acrossTo) = Solve(-dy / length, (cy - y0) * dx / length + x0 * dy / length, -radius, radius); + double bandFrom = Math.Max(alongFrom, acrossFrom), bandTo = Math.Min(alongTo, acrossTo); + if (bandFrom <= bandTo) (from, to) = (Math.Min(from, bandFrom), Math.Max(to, bandTo)); + } + if (from > to) continue; + int left = Math.Max(0, (int)Math.Ceiling(from - 0.5)), right = Math.Min(side - 1, (int)Math.Floor(to - 0.5)); + Span row = picture.AsSpan(y * side, side); + for (int x = left; x <= right; x++) + if (row[x] < value) row[x] = value; + + void Circle(double centreX, double offsetY) + { + double squared = radius * radius - offsetY * offsetY; + if (squared < 0) return; + double half = Math.Sqrt(squared); + (from, to) = (Math.Min(from, centreX - half), Math.Max(to, centreX + half)); + } + } + } + + /// The x where <= a x + b <= : an interval, every x, or none (from above to). + private static (double From, double To) Solve(double a, double b, double low, double high) + { + if (Math.Abs(a) < 1e-12) return low <= b && b <= high ? (double.NegativeInfinity, double.PositiveInfinity) : (1, 0); + double p = (low - b) / a, q = (high - b) / a; + return p <= q ? (p, q) : (q, p); + } + + /// + /// A round, soft spot of light centred at (, ) (pixel coordinates, anywhere + /// within a pixel): at its centre, falling off as a Gaussian of + /// pixels, three sigmas out. Each pixel is raised to it, not added to, as the lines are. + /// + public static void Spot(float[] picture, int side, double x, double y, double sigma, float value) + { + int radius = Math.Max(1, (int)Math.Ceiling(3 * sigma)); + int cx = (int)Math.Floor(x), cy = (int)Math.Floor(y); + // The falloff across and down, worked out once: the spot is their product. + Span across = stackalloc float[2 * radius + 1], down = stackalloc float[2 * radius + 1]; + double scale = -0.5 / (sigma * sigma); + for (int i = -radius; i <= radius; i++) + { + double dx = cx + i + 0.5 - x, dy = cy + i + 0.5 - y; + across[i + radius] = (float)Math.Exp(dx * dx * scale); + down[i + radius] = (float)Math.Exp(dy * dy * scale) * value; + } + for (int j = -radius; j <= radius; j++) + { + int py = cy + j; + if (py < 0 || py >= side) continue; + int row = py * side; + for (int i = -radius; i <= radius; i++) + { + int px = cx + i; + if (px < 0 || px >= side) continue; + float light = across[i + radius] * down[j + radius]; + if (picture[row + px] < light) picture[row + px] = light; + } + } + } + + /// + /// The picture blurred with a Gaussian of pixels (its kernel 4 sigmas each side, the edges + /// mirrored), one direction then the other, into ( holds the + /// half-way picture). Rows that stay black are only cleared. may be the picture itself: it + /// is read only into the half-way picture, before the result is written. + /// + public static void Blur(float[] picture, int side, double sigma, float[] across, float[] result) + { + int radius = Math.Min(side - 1, (int)Math.Ceiling(4 * sigma)); + float[] kernel = new float[radius + 1]; + double sum = 0; + for (int i = 0; i <= radius; i++) sum += (i == 0 ? 1 : 2) * (kernel[i] = (float)Math.Exp(-0.5 * i * i / (sigma * sigma))); + for (int i = 0; i <= radius; i++) kernel[i] = (float)(kernel[i] / sum); + + bool[] lit = new bool[side]; + float[] padded = new float[side + 2 * radius]; + for (int y = 0; y < side; y++) + { + Span source = picture.AsSpan(y * side, side); + if (source.IndexOfAnyExcept(0f) < 0) + { + across.AsSpan(y * side, side).Clear(); + continue; + } + lit[y] = true; + source.CopyTo(padded.AsSpan(radius)); + for (int i = 1; i <= radius; i++) + { + padded[radius - i] = source[Math.Min(i - 1, side - 1)]; + padded[radius + side - 1 + i] = source[Math.Max(side - i, 0)]; + } + Span target = across.AsSpan(y * side, side); + Scale(padded.AsSpan(radius, side), kernel[0], target); + for (int i = 1; i <= radius; i++) AddPair(padded.AsSpan(radius - i, side), padded.AsSpan(radius + i, side), kernel[i], target); + } + for (int y = 0; y < side; y++) + { + // A row of the result is black unless a row within the radius has light. + bool near = false; + for (int i = -radius; i <= radius && !near; i++) near = lit[Mirror(y + i, side)]; + Span target = result.AsSpan(y * side, side); + if (!near) + { + target.Clear(); + continue; + } + Scale(across.AsSpan(y * side, side), kernel[0], target); + for (int i = 1; i <= radius; i++) + { + int up = Mirror(y - i, side), down = Mirror(y + i, side); + if (lit[up] || lit[down]) AddPair(across.AsSpan(up * side, side), across.AsSpan(down * side, side), kernel[i], target); + } + } + } + + private static void Scale(ReadOnlySpan source, float k, Span into) + { + int i = 0; + if (Vector.IsHardwareAccelerated) + { + var vs = MemoryMarshal.Cast>(source); + var vi = MemoryMarshal.Cast>(into); + for (int v = 0; v < vi.Length; v++) vi[v] = vs[v] * k; + i = vi.Length * Vector.Count; + } + for (; i < into.Length; i++) into[i] = source[i] * k; + } + + /// += (a + b) * k, pixel by pixel. + private static void AddPair(ReadOnlySpan a, ReadOnlySpan b, float k, Span into) + { + int i = 0; + if (Vector.IsHardwareAccelerated) + { + var va = MemoryMarshal.Cast>(a); + var vb = MemoryMarshal.Cast>(b); + var vi = MemoryMarshal.Cast>(into); + for (int v = 0; v < vi.Length; v++) vi[v] += (va[v] + vb[v]) * k; + i = vi.Length * Vector.Count; + } + for (; i < into.Length; i++) into[i] += (a[i] + b[i]) * k; + } + + /// A position past an edge reflected back in (-1 is 0, side is side - 1). + private static int Mirror(int i, int side) => i < 0 ? Math.Min(-i - 1, side - 1) : i >= side ? Math.Max(2 * side - i - 1, 0) : i; +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightTexture.cs b/src/OrthoStreamXP/Scenery/Night/NightTexture.cs new file mode 100644 index 0000000..8d0592a --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightTexture.cs @@ -0,0 +1,142 @@ +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Scenery; + +/// +/// One photo tile's night texture as X-Plane reads it: always its full size, header and every level, but painted only +/// when X-Plane reads a level, and then only from the finest level it wants (). Its header +/// chunk gets the start of level 0 kept for it; a level not painted reads as black. +/// +internal sealed class NightTexture +{ + private readonly NightTextures _owner; + private readonly Lock _gate = new(); + /// The finest level kept in the cache file; when nothing is; -1 until looked up. + private int _from = -1; + private bool _dark; + + /// The texture's side: its photo's (X-Plane reads both at the same level number). + public NightTexture(NightTextures owner, TextureName name, int side, string file) + { + _owner = owner; + Name = name; + File = file; + Layout = DdsLayout.For(side); + } + + public TextureName Name { get; } + public DdsLayout Layout { get; } + /// The cache file. + public string File { get; } + + /// + /// Answers one of X-Plane's reads: the header, and the levels it covers. A read of a level not kept yet paints the + /// texture from that level first; X-Plane's header chunk paints it only the first time (with the levels it reads + /// far away), and its start of level 0 is kept with them. Returns the bytes read. + /// + public int Read(ReadCursor cursor, long offset, Span target) + { + long end = Math.Min(Layout.Length, offset + target.Length); + if (offset >= end) return 0; + int count = (int)(end - offset); + Span output = target[..count]; + if (offset < Dds.HeaderSize) Layout.Header[(int)offset..(int)Math.Min(end, Dds.HeaderSize)].CopyTo(output); + if (end <= Dds.HeaderSize) return count; + + bool headerChunk = ReadPattern.IsHeaderChunk(Layout, offset, end); + long dataStart = Math.Max(offset, Dds.HeaderSize); + var (wanted, _) = ReadPattern.WantedLevel(Layout, cursor, dataStart, end, headerChunk); + ReadPattern.NoteRead(cursor, end, headerChunk); + if (wanted is int mip) Ensure(mip); + else if (headerChunk) Ensure(Layout.Levels - 1, firstLook: true); + + output[(int)(dataStart - offset)..].Clear(); + int from = Fill(output, offset, dataStart, end); + if (!headerChunk) + for (int m = Layout.MipAt(dataStart); m <= Layout.MipAt(end - 1); m++) + if (m >= from) cursor.Set(m, ReadOnlyMemory.Empty, Served.Real); + return count; + } + + /// + /// Makes sure level and the smaller ones are kept. : only if + /// nothing is kept yet, and then from the level X-Plane reads far away. + /// + private void Ensure(int mip, bool firstLook = false) + { + lock (_gate) + { + if (_from < 0) _from = Probe(); + if (_dark || (firstLook ? _from < Layout.Levels : _from <= NightStore.Normalize(Layout, mip))) return; + int from = NightStore.Normalize(Layout, firstLook ? NightTextures.FarLevel(Layout) : mip); + switch (_owner.Paint(this, from)) + { + case true: + _from = from; + break; + case false: + _dark = true; // no light here at all: black, and nothing kept + break; + case null: + break; // could not be kept: black for this read, painted again at the next + } + } + } + + /// The finest level the cache file holds; for none. + private int Probe() + { + try + { + if (!System.IO.File.Exists(File)) return Layout.Levels; + using (var handle = System.IO.File.OpenHandle(File, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + if (Kept(handle) is { } contents) return contents.From; + System.IO.File.Delete(File); // not a night texture of this size (the photos' size changed): painted again + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning($"The night texture of {Name.Stem} could not be looked up ({ex.Message}); it is painted again"); + } + return Layout.Levels; + } + + /// What the cache file holds, when it is a night texture of this size. + private NightFile.Contents? Kept(Microsoft.Win32.SafeHandles.SafeFileHandle handle) => + NightFile.ReadContents(handle) is { } contents && NightStore.Normalize(Layout, contents.From) == contents.From + && contents.Length == NightStore.Length(Layout, contents.From) ? contents : null; + + /// Copies what the cache file keeps of the read's range into ; returns the level it starts at. + private int Fill(Span output, long offset, long dataStart, long end) + { + lock (_gate) + if (_dark || _from >= Layout.Levels) return Layout.Levels; + try + { + using var handle = System.IO.File.OpenHandle(File, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + // The file as it is now: it may have been widened since this read was decided. + if (Kept(handle) is not { } contents) return Layout.Levels; + int from = contents.From; + // At most two pieces are kept: the header chunk's part of level 0, and the levels from "from" (one piece + // when the whole texture is). + (long Start, long End)[] pieces = from == 0 + ? [(Dds.HeaderSize, Layout.Length)] + : [(Dds.HeaderSize, NightStore.HeadEnd(Layout)), (Layout.Offset(from), Layout.Length)]; + foreach (var (pieceStart, pieceEnd) in pieces) + { + long a = Math.Max(dataStart, pieceStart), b = Math.Min(end, pieceEnd); + if (a >= b) continue; + NightFile.Read(handle, contents, NightStore.Position(Layout, from, a), output.Slice((int)(a - offset), (int)(b - a))); + } + return from; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return Layout.Levels; // removed with its area meanwhile: black + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Log.Warning($"The night texture of {Name.Stem} could not be read ({ex.Message}); it is black for this read"); + return Layout.Levels; + } + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightTextures.cs b/src/OrthoStreamXP/Scenery/Night/NightTextures.cs new file mode 100644 index 0000000..85041b3 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightTextures.cs @@ -0,0 +1,131 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Scenery; + +/// +/// The night textures of a package's photo tiles. A photo tile's terrain definition names its night texture +/// (, a LIT_TEX_NOWRAP line), which X-Plane adds to the photo at night, fading it in at dusk +/// by itself. X-Plane loads it with the scenery, by day too, from the file under , which exists +/// only in the mount (). +/// +/// X-Plane reads a night texture at the same level number as the photo (the 1024 px level of a 4096 px photo goes +/// with level 2 of the night texture). So a night texture has the photo's size, or a town's lights would be four times +/// coarser than the ground under them. It is painted at the detail X-Plane reads, from (about +/// 6.5 m a pixel) to twice as fine; a finer level is the painting enlarged smoothly. What X-Plane reads is kept in the +/// package's cache (night\21904_35824_BI16.lit6, compressed, counted and removed with its area); a tile without +/// lights keeps nothing and reads as black. +/// +/// +/// The package's cache folder. +/// The photo texture's side at a zoom level: the night texture's. +internal sealed class NightTextures(string cache, Func photoSide, NightPainting painting) +{ + /// The package folder the night textures are served under (it exists only in the mount). + public const string Folder = "orthostreamxp_night"; + private const string Suffix = "_LIT.dds"; + /// The cache file's extension; a new way of keeping them gets a new one, and the old files are removed. + private const string CacheExtension = ".lit6"; + private static readonly string[] OldExtensions = [".lit1", ".lit2", ".lit3", ".lit4", ".lit5"]; + /// A ZL16 texture painted this wide is about 6.5 m a pixel in Europe: lamps 28-45 m apart stay apart. + private const int Reference16 = 1024; + /// The level side X-Plane reads of most night textures, from far: the first painting keeps that much. + private const int FarSide = 256; + + private readonly ConcurrentDictionary _textures = new(StringComparer.OrdinalIgnoreCase); + private long _painted, _dark, _paintTicks, _keptBytes; + + /// The terrain definition line that gives a photo tile its night texture. + public static string TerrainLine(TextureName name) => $"LIT_TEX_NOWRAP ../{Folder}/{name.Stem}{Suffix}"; + + /// + /// The coarsest a night texture is painted, in pixels: the same metres per pixel at every zoom level (a ZL18 + /// texture covers a quarter of the ground of a ZL16 one, so a quarter of the side), never below 64. + /// + public static int ReferenceSide(int zoom) => zoom <= 16 ? Reference16 : Math.Max(64, Reference16 >> (zoom - 16)); + + /// The side a level of pixels is painted at: the reference side to twice it. + public static int PaintSide(int zoom, int levelSide) => Math.Clamp(levelSide, ReferenceSide(zoom), 2 * ReferenceSide(zoom)); + + /// The level of a night texture X-Plane reads from far away (). + public static int FarLevel(DdsLayout layout) + { + int level = 0; + while (level < layout.Levels - 1 && layout.SizeOf(level) > FarSide) level++; + return level; + } + + /// Paintings with lights and without any this session, the time they took, and the bytes they kept (compressed). + public (long Painted, long Dark, TimeSpan Time, long KeptBytes) Counts => + (Interlocked.Read(ref _painted), Interlocked.Read(ref _dark), Stopwatch.GetElapsedTime(0, Interlocked.Read(ref _paintTicks)), Interlocked.Read(ref _keptBytes)); + + /// + /// Removes the night textures earlier versions kept (whole, or four times coarser than the photos): they are + /// painted again as X-Plane reads them. Returns how many were removed. + /// + public static int RemoveOldFiles(string cache) + { + string folder = Path.Join(cache, "night"); + if (!Directory.Exists(folder)) return 0; + int removed = 0; + foreach (string extension in OldExtensions) + foreach (string file in Directory.EnumerateFiles(folder, "*" + extension)) + { + try + { + File.Delete(file); + removed++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // left for the next start + } + } + return removed; + } + + /// What X-Plane gets for a path under ; null for any other path. Nothing is painted yet. + public OverlayEntry? Open(VirtualPath path) + { + if (path.Parts.Length != 2 || !path.IsUnder(Folder) || !path.Name.EndsWith(Suffix, StringComparison.OrdinalIgnoreCase) + || !TextureName.TryParseStem(path.Name[..^Suffix.Length], out TextureName name)) return null; + NightTexture texture = _textures.GetOrAdd(name.Stem, + _ => new NightTexture(this, name, photoSide(name.Zoom), Path.Join(cache, "night", name.Stem + CacheExtension))); + return new OverlayEntry(null, null, Night: texture); + } + + /// + /// Paints and keeps it from level : true when kept, false when it + /// has no light at all (nothing kept), null when it could not be kept. + /// + internal bool? Paint(NightTexture texture, int from) + { + int side = PaintSide(texture.Name.Zoom, texture.Layout.SizeOf(from)); + TimeSpan took = TimeSpan.Zero; + try + { + byte[]? kept = painting.Paint(texture.Name, side, painted => NightDds.EncodeStored(painted, texture.Layout.Size, from), out took); + if (kept is null) + { + Interlocked.Increment(ref _dark); + return false; + } + NightFile.Write(texture.File, from, kept); + Interlocked.Increment(ref _painted); + long written = new FileInfo(texture.File).Length; + Interlocked.Add(ref _keptBytes, written); + Log.Detail($"Night texture of {texture.Name.Stem} painted at {side} px, kept from {texture.Layout.SizeOf(from)} px ({written / 1024} KB) in {took.TotalMilliseconds:F0} ms"); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning($"The night texture of {texture.Name.Stem} could not be kept ({ex.Message}); it is black until X-Plane reads it again"); + return null; + } + finally + { + Interlocked.Add(ref _paintTicks, (long)(took.TotalSeconds * Stopwatch.Frequency)); + } + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/NightWorkspace.cs b/src/OrthoStreamXP/Scenery/Night/NightWorkspace.cs new file mode 100644 index 0000000..79ce1ea --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/NightWorkspace.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; + +namespace OrthoStreamXP.Scenery; + +/// +/// The pictures one night texture is painted and encoded in, pixels square: about 31 MB at 1024 px. +/// They are reused from one texture to the next (): made new for each, they were +/// large objects that set off a full garbage collection several times a second while a scenery loaded. +/// +internal sealed class NightWorkspace(int side) +{ + public int Side { get; } = side; + public float[] Street { get; } = new float[side * side]; + public float[] Windows { get; } = new float[side * side]; + public float[] Sodium { get; } = new float[side * side]; + public float[] Led { get; } = new float[side * side]; + public float[] WarmGlow { get; } = new float[side * side]; + public float[] CoolGlow { get; } = new float[side * side]; + /// The blur's half-way picture. + public float[] Across { get; } = new float[side * side]; + /// The painted texture's top level, RGB. + public byte[] Rgb { get; } = new byte[side * side * 3]; + + /// Ready for the next texture: the pictures painting draws into are black again. + public void Clear() + { + Array.Clear(Street); + Array.Clear(Windows); + Array.Clear(Sodium); + Array.Clear(Led); + Array.Clear(Rgb); + } +} + +/// +/// The workspaces of every package's night textures, by side: one is taken for each painting and given back after it, +/// so there are never more than the paintings that run at once. Of the large ones (finer than +/// , 124 MB at 2048 px, painted only beside the aircraft) one at most is kept. +/// +internal sealed class NightWorkspaces +{ + public const int LargeSide = 1024; + + private readonly ConcurrentDictionary> _free = new(); + + public NightWorkspace Rent(int side) + { + if (_free.GetOrAdd(side, _ => []).TryTake(out NightWorkspace? workspace)) + { + workspace.Clear(); + return workspace; + } + return new NightWorkspace(side); + } + + public void Return(NightWorkspace workspace) + { + ConcurrentBag free = _free.GetOrAdd(workspace.Side, _ => []); + if (workspace.Side <= LargeSide || free.IsEmpty) free.Add(workspace); + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/RoadClass.cs b/src/OrthoStreamXP/Scenery/Night/RoadClass.cs new file mode 100644 index 0000000..e98823f --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/RoadClass.cs @@ -0,0 +1,57 @@ +namespace OrthoStreamXP.Scenery; + +/// The kinds of road the night lights tell apart; railways, power lines and the rest are . +internal enum RoadClass : byte +{ + None, + Motorway, + Ramp, + Primary, + Secondary, + Local, +} + +/// +/// How a class of road is lit: its width, how bright its lamps and street are, how far apart its lamps stand, and +/// whether they stand on both kerbs in turn or all on one. +/// +internal readonly record struct RoadLook(double WidthM, double Brightness, double LampSpacingM, bool LinedWithHouses, bool LampsBothSides); + +/// +/// X-Plane's road subtypes (the network "road type" of lib/g10/roads.net as a DSF stores it) sorted into +/// es. Seen in the overlays: 10-29 primary, 30-49 secondary, 50-69 local streets, 100-119 +/// motorways, 120-129 ramps, 151-154 railways, 220 power lines; a type ending in 9 is the rural variant of its road. +/// +internal static class RoadClasses +{ + /// A rural road's lamps: this much as bright, this many times as far apart. + public const double RuralBrightness = 0.45, RuralSpacing = 1.8; + + public static RoadClass Of(int subtype, out bool rural) + { + RoadClass road = subtype switch + { + >= 10 and < 30 => RoadClass.Primary, + >= 30 and < 50 => RoadClass.Secondary, + >= 50 and < 100 => RoadClass.Local, + >= 100 and < 120 => RoadClass.Motorway, + >= 120 and < 130 => RoadClass.Ramp, + _ => RoadClass.None, + }; + rural = road is RoadClass.Primary or RoadClass.Secondary or RoadClass.Local && subtype % 10 == 9; + return road; + } + + public static RoadLook Look(RoadClass road) => road switch + { + RoadClass.Motorway => new RoadLook(16, 1.00, 45, false, true), + RoadClass.Ramp => new RoadLook(8, 0.90, 35, false, false), + RoadClass.Primary => new RoadLook(12, 0.95, 35, true, true), + RoadClass.Secondary => new RoadLook(10, 0.85, 32, true, true), + RoadClass.Local => new RoadLook(6, 0.60, 28, true, false), + _ => new RoadLook(0, 0, double.PositiveInfinity, false, false), + }; + + /// Whether a road counts towards how built up a place is (town streets, not motorways or country roads). + public static bool IsStreet(RoadClass road, bool rural) => !rural && road is RoadClass.Primary or RoadClass.Secondary or RoadClass.Local; +} diff --git a/src/OrthoStreamXP/Scenery/Night/RoadNetwork.cs b/src/OrthoStreamXP/Scenery/Night/RoadNetwork.cs new file mode 100644 index 0000000..09a48d0 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/RoadNetwork.cs @@ -0,0 +1,160 @@ +namespace OrthoStreamXP.Scenery; + +/// +/// The roads of one 1° x 1° scenery tile, kept compact: every chain's points end to end as degrees from the tile's +/// south-west corner (float: a few millimetres), where each chain starts, its class and whether it is rural. A grid +/// over the tile finds the chains near a place, and a second one says how built up each place is +/// (), from the length of town streets around it. +/// +internal sealed class RoadNetwork +{ + /// Cells a side of the grid that finds chains: about 1.7 km. + private const int IndexCells = 64; + /// Cells a side of the built-up grid: about 900 m. + private const int DensityCells = 120; + /// Street km per km² that counts as fully built up (a city centre; a village has 4-6). + public const double CityStreetKmPerKm2 = 20; + + private readonly float[] _lon, _lat; + private readonly int[] _start; + private readonly RoadClass[] _class; + private readonly bool[] _rural; + private readonly int[] _cellStart, _cellChains; + private readonly byte[] _firstCellX, _firstCellY; + private readonly float[] _builtUp; + + /// Every chain's longitudes end to end, in degrees east of . + /// Their latitudes, in degrees north of . + /// Where each chain starts in the points, and last where they end (chains + 1 entries). + public RoadNetwork(int south, int west, float[] lon, float[] lat, int[] start, RoadClass[] roadClass, bool[] rural) + { + if (lon.Length != lat.Length || start.Length != roadClass.Length + 1 || rural.Length != roadClass.Length || start[^1] != lon.Length) + throw new ArgumentException("The road arrays do not match"); + South = south; + West = west; + _lon = lon; + _lat = lat; + _start = start; + _class = roadClass; + _rural = rural; + (_cellStart, _cellChains, _firstCellX, _firstCellY) = BuildIndex(); + _builtUp = BuildDensity(); + } + + public int South { get; } + public int West { get; } + public int Count => _class.Length; + public int PointCount => _lon.Length; + + public RoadClass ClassOf(int chain) => _class[chain]; + public bool IsRural(int chain) => _rural[chain]; + + /// A chain's points, in degrees from the tile's corner. + public (ReadOnlyMemory Lon, ReadOnlyMemory Lat) Points(int chain) + { + int from = _start[chain], count = _start[chain + 1] - from; + return (_lon.AsMemory(from, count), _lat.AsMemory(from, count)); + } + + /// Every chain that may cross the box (degrees), each once; a chain may still lie just outside it. + public void Near(double south, double west, double north, double east, List chains) + { + if (east < West || west > West + 1 || north < South || south > South + 1) return; + int x0 = Cell(west - West, IndexCells), x1 = Cell(east - West, IndexCells); + int y0 = Cell(south - South, IndexCells), y1 = Cell(north - South, IndexCells); + for (int y = y0; y <= y1; y++) + for (int x = x0; x <= x1; x++) + { + int cell = y * IndexCells + x; + for (int i = _cellStart[cell]; i < _cellStart[cell + 1]; i++) + { + // A chain is listed in every cell it covers: report it from the first of them inside the box only. + int chain = _cellChains[i]; + if (x == Math.Max(_firstCellX[chain], x0) && y == Math.Max(_firstCellY[chain], y0)) chains.Add(chain); + } + } + } + + /// + /// How built up the place is, 0 (no streets) to 1 ( and more), read between the + /// cells' centres; 0 outside the tile. + /// + public double BuiltUp(double lat, double lon) + { + double fx = (lon - West) * DensityCells - 0.5, fy = (lat - South) * DensityCells - 0.5; + if (fx < -1 || fy < -1 || fx > DensityCells || fy > DensityCells) return 0; + int x0 = Math.Clamp((int)Math.Floor(fx), 0, DensityCells - 2), y0 = Math.Clamp((int)Math.Floor(fy), 0, DensityCells - 2); + double tx = Math.Clamp(fx - x0, 0, 1), ty = Math.Clamp(fy - y0, 0, 1); + float At(int x, int y) => _builtUp[y * DensityCells + x]; + return (At(x0, y0) * (1 - tx) + At(x0 + 1, y0) * tx) * (1 - ty) + (At(x0, y0 + 1) * (1 - tx) + At(x0 + 1, y0 + 1) * tx) * ty; + } + + private static int Cell(double degrees, int cells) => Math.Clamp((int)Math.Floor(degrees * cells), 0, cells - 1); + + /// Each cell's chains (a chain in every cell its bounds cover), and each chain's first cell. + private (int[] Start, int[] Chains, byte[] FirstX, byte[] FirstY) BuildIndex() + { + int chains = _class.Length; + byte[] x0 = new byte[chains], y0 = new byte[chains], x1 = new byte[chains], y1 = new byte[chains]; + int[] count = new int[IndexCells * IndexCells + 1]; + for (int c = 0; c < chains; c++) + { + float minLon = float.MaxValue, minLat = float.MaxValue, maxLon = float.MinValue, maxLat = float.MinValue; + for (int i = _start[c]; i < _start[c + 1]; i++) + { + minLon = Math.Min(minLon, _lon[i]); + maxLon = Math.Max(maxLon, _lon[i]); + minLat = Math.Min(minLat, _lat[i]); + maxLat = Math.Max(maxLat, _lat[i]); + } + (x0[c], x1[c]) = ((byte)Cell(minLon, IndexCells), (byte)Cell(maxLon, IndexCells)); + (y0[c], y1[c]) = ((byte)Cell(minLat, IndexCells), (byte)Cell(maxLat, IndexCells)); + for (int y = y0[c]; y <= y1[c]; y++) + for (int x = x0[c]; x <= x1[c]; x++) + count[y * IndexCells + x + 1]++; + } + for (int i = 1; i < count.Length; i++) count[i] += count[i - 1]; + int[] list = new int[count[^1]]; + int[] next = count[..^1]; + for (int c = 0; c < chains; c++) + for (int y = y0[c]; y <= y1[c]; y++) + for (int x = x0[c]; x <= x1[c]; x++) + list[next[y * IndexCells + x]++] = c; + return (count, list, x0, y0); + } + + /// Street km per km² in each cell, averaged with its neighbours and scaled to 0-1. + private float[] BuildDensity() + { + const double MetresPerDegreeLat = 110_574, MetresPerDegreeLonAtEquator = 111_320; + double cosLat = Math.Cos((South + 0.5) * Math.PI / 180); + double cellKm2 = MetresPerDegreeLat / DensityCells * (MetresPerDegreeLonAtEquator * cosLat / DensityCells) / 1e6; + double[] metres = new double[DensityCells * DensityCells]; + for (int c = 0; c < _class.Length; c++) + { + if (!RoadClasses.IsStreet(_class[c], _rural[c])) continue; + for (int i = _start[c]; i < _start[c + 1] - 1; i++) + { + double dx = (_lon[i + 1] - _lon[i]) * MetresPerDegreeLonAtEquator * cosLat, dy = (_lat[i + 1] - _lat[i]) * MetresPerDegreeLat; + double midLon = (_lon[i] + _lon[i + 1]) / 2.0, midLat = (_lat[i] + _lat[i + 1]) / 2.0; + if (midLon < 0 || midLon >= 1 || midLat < 0 || midLat >= 1) continue; + metres[Cell(midLat, DensityCells) * DensityCells + Cell(midLon, DensityCells)] += Math.Sqrt(dx * dx + dy * dy); + } + } + float[] builtUp = new float[metres.Length]; + for (int y = 0; y < DensityCells; y++) + for (int x = 0; x < DensityCells; x++) + { + double sum = 0; + int cells = 0; + for (int ny = Math.Max(0, y - 1); ny <= Math.Min(DensityCells - 1, y + 1); ny++) + for (int nx = Math.Max(0, x - 1); nx <= Math.Min(DensityCells - 1, x + 1); nx++) + { + sum += metres[ny * DensityCells + nx]; + cells++; + } + builtUp[y * DensityCells + x] = (float)Math.Min(1, sum / cells / 1000 / cellKm2 / CityStreetKmPerKm2); + } + return builtUp; + } +} diff --git a/src/OrthoStreamXP/Scenery/Night/RoadNetworks.cs b/src/OrthoStreamXP/Scenery/Night/RoadNetworks.cs new file mode 100644 index 0000000..c8a6af1 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Night/RoadNetworks.cs @@ -0,0 +1,111 @@ +using System.Globalization; + +namespace OrthoStreamXP.Scenery; + +/// +/// X-Plane's roads by 1° tile, read from the overlay packages' DSFs (the roads X-Plane draws over photo scenery) and +/// kept for the last few tiles asked for. A tile no overlay package has, or has only compressed, has no roads. +/// +internal sealed class RoadNetworks +{ + /// Tiles kept read: the 3 x 3 around the aircraft. + private const int Kept = 9; + + private readonly IReadOnlyList _packages; + private readonly Dictionary<(int South, int West), Lazy> _tiles = []; + private readonly LinkedList<(int South, int West)> _recent = new(); + private readonly Lock _lock = new(); + private int _compressedWarned; + + /// Package folders with an Earth nav data folder, searched in this order. + public RoadNetworks(IReadOnlyList overlayPackages) => _packages = overlayPackages; + + public IReadOnlyList Packages => _packages; + + /// + /// The overlay packages to read roads from: the mounted packages whose name says overlays (their own folders, + /// never the mount), then such packages in X-Plane's Custom Scenery that are not mounted by this program. + /// + public static IReadOnlyList FindOverlayPackages(IReadOnlyList mounted, string? customScenery) + { + static bool IsOverlay(string name) => name.Contains("overlay", StringComparison.OrdinalIgnoreCase); + var found = mounted.Where(p => IsOverlay(p.Name)).Select(p => p.Source).ToList(); + if (customScenery is not null && Directory.Exists(customScenery)) + { + var mountedNames = mounted.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + try + { + found.AddRange(Directory.EnumerateDirectories(customScenery) + .Where(d => IsOverlay(Path.GetFileName(d)) && !mountedNames.Contains(Path.GetFileName(d))) + .Order(StringComparer.OrdinalIgnoreCase)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning($"Night lights: {customScenery} could not be listed for overlay packages: {ex.Message}"); + } + } + return found; + } + + /// "Earth nav data\+40+000\+43+007.dsf" for the tile at 43° N, 7° E. + public static string TileFile(int south, int west) + { + static int Ten(int degrees) => (int)Math.Floor(degrees / 10.0) * 10; + static string Name(int lat, int lon) => string.Create(CultureInfo.InvariantCulture, $"{lat:+00;-00}{lon:+000;-000}"); + return Path.Join("Earth nav data", Name(Ten(south), Ten(west)), Name(south, west) + ".dsf"); + } + + /// The roads of the tile whose south-west corner is at , ; null when none are known. + public RoadNetwork? For(int south, int west) + { + Lazy tile; + lock (_lock) + { + var key = (south, west); + if (!_tiles.TryGetValue(key, out tile!)) + { + _tiles[key] = tile = new Lazy(() => Read(south, west)); + if (_recent.Count >= Kept) + { + _tiles.Remove(_recent.Last!.Value); + _recent.RemoveLast(); + } + } + else + { + _recent.Remove(key); + } + _recent.AddFirst(key); + } + return tile.Value; + } + + private RoadNetwork? Read(int south, int west) + { + string relative = TileFile(south, west); + foreach (string package in _packages) + { + string file = Path.Join(package, relative); + if (!File.Exists(file)) continue; + try + { + byte[] data = File.ReadAllBytes(file); + if (DsfNetworkReader.IsCompressed(data)) + { + if (Interlocked.Exchange(ref _compressedWarned, 1) == 0) + Log.Info($"Night lights: {file} is compressed, which is not read; such tiles get no night lights"); + continue; + } + RoadNetwork roads = DsfNetworkReader.Read(data, south, west); + Log.Detail($"Night lights: {roads.Count} roads ({roads.PointCount} points) read from {file}"); + return roads; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Log.Warning($"Night lights: the roads of {file} could not be read: {ex.Message}"); + } + } + Log.Detail($"Night lights: no overlay package has the roads of {relative}; its textures stay dark at night"); + return null; + } +} diff --git a/src/OrthoStreamXP/Scenery/Streaming/ReadCursor.cs b/src/OrthoStreamXP/Scenery/Streaming/ReadCursor.cs index c8b727d..700e176 100644 --- a/src/OrthoStreamXP/Scenery/Streaming/ReadCursor.cs +++ b/src/OrthoStreamXP/Scenery/Streaming/ReadCursor.cs @@ -14,6 +14,12 @@ internal sealed class ReadCursor /// Where the latest read on this handle ended: a read starting there is the next piece of the same read. public long LastEnd { get; set; } = -1; + /// + /// The latest level X-Plane wanted on this handle was kept to the low-zoom base at night: the next pieces of the + /// same read are counted with it, as night reads, not as stand-ins. + /// + public bool KeptToBase { get; set; } + public bool TryGet(int mip, out ReadOnlyMemory bytes, out Served kind) { lock (_levels) diff --git a/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs b/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs index 0de5b60..3b8647f 100644 --- a/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs +++ b/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs @@ -17,6 +17,11 @@ namespace OrthoStreamXP.Scenery; /// make X-Plane treat the texture as final. Level 0 complete is saved as a normal cached DDS (served as a plain /// file from then on); coarser real chains are saved under .xpstream\mips; the low-zoom base holds the tiles. /// +/// +/// With the photos skipped at night (), nothing finer than the low-zoom base is fetched +/// while the sun in X-Plane is below (skip-photos-sun-below): X-Plane's reads and load-ahead alike get the +/// base, stretched, and what is already cached is served as it is. In twilight the photos stream as usual. +/// /// internal sealed class TextureStreamer : IDisposable { @@ -41,12 +46,17 @@ internal sealed class TextureStreamer : IDisposable private readonly WeightedPriorityGate _ahead; private readonly PagingMeasurements _paging = new(); private readonly ReadWaitStats _waitStats = new(); + /// X-Plane's sun when the photos are skipped at night; null streams them whatever the time. + private readonly Daylight? _nightPause; private long _ramBytes; private int _trimming; - private long _reads, _real, _standIn, _flat, _headerChunks, _readBuilds, _aheadBuilds, _fullBuilds, _failedBuilds; + private long _reads, _real, _standIn, _flat, _headerChunks, _readBuilds, _aheadBuilds, _fullBuilds, _failedBuilds, _nightReads; - public TextureStreamer(SceneryRoots roots, TextureBuilder builder, ViewTracker view, PagingModel model, StreamingOptions options) + /// X-Plane's sun, to skip the photos at night (only the low-zoom base); null streams them always. + public TextureStreamer(SceneryRoots roots, TextureBuilder builder, ViewTracker view, PagingModel model, StreamingOptions options, + Daylight? nightPause = null) { + _nightPause = nightPause; _roots = roots; _builder = builder; _view = view; @@ -80,6 +90,9 @@ public static long BuildTiles(TextureName name, int zoom) public PagingModel Model => _model; + /// Night in X-Plane with the photos skipped at night: nothing finer than the low-zoom base is fetched now. + public bool PhotosPaused => _nightPause?.Dark == true; + /// Reads of texture data answered (not counting header-only reads). public long Reads => Interlocked.Read(ref _reads); @@ -155,6 +168,7 @@ private ReadAnswer ReadLevels(StreamedTexture t, ReadCursor cursor, long offset, Band? band = null; double? distance = null; double waitedMs = 0; + bool keptToBase = false; if (wanted is int mip) { // Full detail wanted: the texture is next to the aircraft, which places it before X-Plane sends a position. @@ -171,9 +185,12 @@ private ReadAnswer ReadLevels(StreamedTexture t, ReadCursor cursor, long offset, Probe(t); realFrom = t.RealFrom; } - if (mip < realFrom) + // At night with the photos skipped, the low-zoom base is the finest level fetched: finer ones are stretched from it. + keptToBase = cursor.KeptToBase = mip < t.BaseMip && PhotosPaused; + int target = keptToBase ? t.BaseMip : mip; + if (target < realFrom) { - Task build = StartBuild(t, Math.Min(mip, t.BaseMip), band switch + Task build = StartBuild(t, Math.Min(target, t.BaseMip), band switch { Band.Near => FetchPriority.NearRead, Band.Middle => FetchPriority.ForRead(mip), @@ -193,16 +210,15 @@ private ReadAnswer ReadLevels(StreamedTexture t, ReadCursor cursor, long offset, bool done = build.Wait(TimeSpan.FromSeconds(wait)); TimeSpan waited = Stopwatch.GetElapsedTime(waitStarted); waitedMs = waited.TotalMilliseconds; - _waitStats.Record(band!.Value, starting, waited, done); - if (done) _waitStats.NoteNeeded(starting, waited); - else _ = build.ContinueWith(b => - { - if (b.IsCompletedSuccessfully && b.Result) _waitStats.NoteNeeded(starting, Stopwatch.GetElapsedTime(waitStarted)); - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + // A wait for the base at night says nothing about how long the photos take: the tuning never sees it. + if (!keptToBase) NoteWait(band!.Value, starting, waitStarted, waited, done, build); } } } + // The next piece of a read kept to the base at night is counted with it. + else if (!headerChunk) keptToBase = cursor.KeptToBase; + // A level X-Plane is not reading (the header chunk's start of level 0, or the tail before the level it wants) // is filled from the best data at hand; it neither fixes the level's version nor counts in the statistics. Served served = Served.Real, any = Served.Real; @@ -230,14 +246,34 @@ private ReadAnswer ReadLevels(StreamedTexture t, ReadCursor cursor, long offset, Interlocked.Increment(ref _headerChunks); return new ReadAnswer(output.Length, first, last, Served: any); } - Interlocked.Increment(ref _reads); - if (served == Served.Real) Interlocked.Increment(ref _real); - else if (served == Served.StandIn) Interlocked.Increment(ref _standIn); - else Interlocked.Increment(ref _flat); + // A read kept to the base at night is blurry by choice: counted apart, so the Home page's share of real reads, + // the flight report and the tuning see only the reads that wanted the photo. + if (keptToBase && served != Served.Real) Interlocked.Increment(ref _nightReads); + else + { + Interlocked.Increment(ref _reads); + if (served == Served.Real) Interlocked.Increment(ref _real); + else if (served == Served.StandIn) Interlocked.Increment(ref _standIn); + else Interlocked.Increment(ref _flat); + } TrimIfNeeded(); return new ReadAnswer(output.Length, first, last, band, served, waitedMs, distance, wanted); } + /// How long a read waited for its level, and how long its picture took when the wait ran out first. + private void NoteWait(Band band, bool starting, long waitStarted, TimeSpan waited, bool done, Task build) + { + _waitStats.Record(band, starting, waited, done); + if (done) _waitStats.NoteNeeded(starting, waited); + else _ = build.ContinueWith(b => + { + if (b.IsCompletedSuccessfully && b.Result) _waitStats.NoteNeeded(starting, Stopwatch.GetElapsedTime(waitStarted)); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + /// X-Plane's reads at night that got the low-zoom base, stretched, because the photos were skipped. + public long NightReads => Interlocked.Read(ref _nightReads); + /// /// The band of a read of . The level X-Plane asks for is its own verdict on the distance; /// the camera's distance can only make a read nearer, never farther, so a stale position (X-Plane still sending @@ -590,8 +626,11 @@ private void Save(StreamedTexture t, int k, byte[] chain) public Task? Warm(VirtualPath path, int k, int priority) { if (Get(path) is not { } t) return null; + // At night a texture with its base has all load-ahead may fetch: nothing to ask for (the warmer rechecks every + // loaded texture at each position, which would count each of them again and again). + if (k < t.BaseMip && PhotosPaused && RealFrom(t) <= t.BaseMip) return Task.FromResult(true); Interlocked.Increment(ref _warmRequests); - return StartBuild(t, Math.Clamp(k, 0, t.BaseMip), priority, byRead: false); + return StartBuild(t, Math.Clamp(k, PhotosPaused ? t.BaseMip : 0, t.BaseMip), priority, byRead: false); } private long _warmRequests; @@ -715,7 +754,8 @@ public string Summary() $"{RealReads} real{Share(RealReads)}, {Interlocked.Read(ref _standIn)} with stand-ins{Share(Interlocked.Read(ref _standIn))}, " + $"{Interlocked.Read(ref _flat)} flat{Share(Interlocked.Read(ref _flat))}; waited: {_waitStats.Describe(Band.Near)}, {_waitStats.Describe(Band.Middle)}; " + $"builds: {Interlocked.Read(ref _readBuilds)} for reads, {Interlocked.Read(ref _aheadBuilds)} ahead ({Interlocked.Read(ref _warmRequests)} asked), " + - $"{Interlocked.Read(ref _fullBuilds)} at full detail, {Interlocked.Read(ref _failedBuilds)} failed; RAM {Interlocked.Read(ref _ramBytes) >> 20} MB{k}"; + $"{Interlocked.Read(ref _fullBuilds)} at full detail, {Interlocked.Read(ref _failedBuilds)} failed" + + (_nightPause is null ? "" : $", {Interlocked.Read(ref _nightReads)} reads at night kept to the low-zoom base") + $"; RAM {Interlocked.Read(ref _ramBytes) >> 20} MB{k}"; } /// A number that changes whenever the summary would. diff --git a/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs b/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs new file mode 100644 index 0000000..b1a76dd --- /dev/null +++ b/src/OrthoStreamXP/Setup/Installation/XPlaneWindow.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace OrthoStreamXP.Setup; + +/// +/// Gives X-Plane's window the front while it starts. Started through Explorer, X-Plane does not bring itself to the front +/// (Windows lets only the program in front, here OrthoStreamXP, decide which window comes next). Windows hands the front +/// over a moment after it is asked: until X-Plane has it, OrthoStreamXP's window must stay, or the front goes to the +/// window behind it. +/// +internal static partial class XPlaneWindow +{ + private const int SwRestore = 9; + private static readonly TimeSpan CheckEvery = TimeSpan.FromMilliseconds(250); + + /// + /// Hands the front to X-Plane's window whenever this program has it, until X-Plane's window has stayed in front for + /// , someone chose another program, X-Plane quit, or passed. + /// Call it while this program's window is in front, and hide that window only after it returns. + /// + /// True when X-Plane's window settled in front. + public static async Task HandOverAsync(TimeSpan settle, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var waited = Stopwatch.StartNew(); + nint shown = 0; + TimeSpan? inFrontSince = null; + bool seen = false; + while (waited.Elapsed < timeout) + { + if (Find() is not { } xplane) + { + if (seen) + { + Log.Info("X-Plane quit while it started"); + return false; + } + } + else + { + seen = true; + uint inFront = ProcessOf(GetForegroundWindow()); + if (inFront == 0) + { + // No window in front for a moment, as while a window is replaced: look again. + } + else if (inFront == xplane.ProcessId) + { + inFrontSince ??= waited.Elapsed; + if (waited.Elapsed - inFrontSince >= settle) + { + Log.Info($"X-Plane's window is in front (after {waited.Elapsed.TotalSeconds:0} s)"); + return true; + } + } + else if (inFront == (uint)Environment.ProcessId) + { + inFrontSince = null; + if (IsIconic(xplane.Window)) ShowWindow(xplane.Window, SwRestore); + bool given = SetForegroundWindow(xplane.Window); + if (xplane.Window != shown) + Log.Info($"X-Plane's window \"{xplane.Title}\" (0x{xplane.Window:X}) {(given ? "brought to the front" : "was not let to the front by Windows")} after {waited.Elapsed.TotalSeconds:0.0} s"); + shown = xplane.Window; + } + else + { + // Another program is in front: chosen by the person, so X-Plane is not pushed over it. + Log.Info($"{ProgramName(inFront)} came to the front while X-Plane started; X-Plane's window is left where it is"); + return false; + } + } + await Task.Delay(CheckEvery, cancellationToken).ConfigureAwait(true); + } + Log.Info($"X-Plane's window did not settle in front within {timeout.TotalSeconds:0} s"); + return false; + } + + private static (uint ProcessId, nint Window, string Title)? Find() + { + Process[] running = Process.GetProcessesByName("X-Plane"); + try + { + foreach (Process process in running) + { + try + { + if (process.MainWindowHandle != 0) return ((uint)process.Id, process.MainWindowHandle, process.MainWindowTitle); + } + catch (InvalidOperationException) + { + // It quit meanwhile. + } + } + return null; + } + finally + { + foreach (Process process in running) process.Dispose(); + } + } + + private static string ProgramName(uint processId) + { + try + { + using Process process = Process.GetProcessById((int)processId); + return process.ProcessName; + } + catch (ArgumentException) + { + return "Another program"; // it quit meanwhile + } + } + + private static uint ProcessOf(nint window) + { + if (window == 0) return 0; + _ = GetWindowThreadProcessId(window, out uint processId); + return processId; + } + + [LibraryImport("user32.dll")] + private static partial nint GetForegroundWindow(); + + [LibraryImport("user32.dll")] + private static partial uint GetWindowThreadProcessId(nint window, out uint processId); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint window); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsIconic(nint window); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindow(nint window, int command); +} diff --git a/src/OrthoStreamXP/orthostreamxp.example.ini b/src/OrthoStreamXP/orthostreamxp.example.ini index 339b0e1..b575f17 100644 --- a/src/OrthoStreamXP/orthostreamxp.example.ini +++ b/src/OrthoStreamXP/orthostreamxp.example.ini @@ -70,6 +70,15 @@ lazy-terrain-listing = true # 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 +# light the towns on the photo tiles at night: street lamps and lit windows along X-Plane's roads, read from the +# overlay packages, painted once per tile and kept in the cache (on or off) +night-lights = off +# at night in X-Plane download no detailed photos: the ground under the lights is the blurry low-zoom base, what is +# already cached is shown as it is, and from dusk on the photos stream again (on or off; needs mip-streaming) +skip-photos-at-night = off +# how far below the horizon the sun must be before the photos are skipped, in degrees (0-18): 6 is the end of civil +# twilight, 12 of nautical twilight; higher keeps the photos longer at dusk and brings them back sooner at dawn +skip-photos-sun-below = 9 # --- 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/DsfBuilder.cs b/tests/OrthoStreamXP.Tests/DsfBuilder.cs new file mode 100644 index 0000000..06ac42f --- /dev/null +++ b/tests/OrthoStreamXP.Tests/DsfBuilder.cs @@ -0,0 +1,134 @@ +using System.Buffers.Binary; + +namespace OrthoStreamXP.Tests; + +/// +/// Writes small uncompressed DSF files with road networks for the night lights' tests: one 32-bit point pool (longitude, +/// latitude and two more planes, each in a different encoding) and the chains as range, 16-bit and 32-bit commands, +/// with a few polygon commands among them that a reader must skip. +/// +internal sealed class DsfBuilder(int south, int west) +{ + // A point no road uses first: the chains' 16-bit indices are written from a junction offset of 1. + private readonly List<(double Lon, double Lat)> _points = [(west, south)]; + private readonly List<(int Subtype, int First, int Count, ChainForm Form)> _chains = []; + + public enum ChainForm + { + Range, + List16, + List32, + } + + /// A road of through the points (longitude, latitude in degrees). + public DsfBuilder Road(int subtype, params (double Lon, double Lat)[] points) => Road(subtype, ChainForm.Range, points); + + public DsfBuilder Road(int subtype, ChainForm form, params (double Lon, double Lat)[] points) + { + _chains.Add((subtype, _points.Count, points.Length, form)); + _points.AddRange(points); + return this; + } + + public byte[] Build() + { + var file = new List(); + file.AddRange("XPLNEDSF"u8.ToArray()); + file.AddRange(Int(1)); + file.AddRange(Atom("HEAD", Atom("PROP", Strings("sim/overlay", "1", "sim/south", $"{south}", "sim/west", $"{west}")))); + file.AddRange(Atom("DEFN", [.. Atom("TERT", []), .. Atom("POLY", Strings("lib/g8/beaches.bch")), .. Atom("NETW", Strings("lib/g10/roads.net"))])); + file.AddRange(Atom("GEOD", [.. Atom("POOL", Pool16()), .. Atom("SCAL", [.. Float(1), .. Float(0)]), .. Atom("PO32", Pool32()), .. Atom("SC32", Scales())])); + file.AddRange(Atom("CMDS", Commands())); + file.AddRange(new byte[16]); // the MD5, not checked + return [.. file]; + } + + private static uint Raw(double degrees, int corner) => (uint)Math.Round((degrees - corner) * 4294967295.0); + + /// The road points: longitude raw, latitude differenced and run-length coded, then two planes a reader skips. + private byte[] Pool32() + { + var data = new List(); + data.AddRange(Int(_points.Count)); + data.Add(4); + data.Add(0); // raw + foreach (var (lon, _) in _points) data.AddRange(UInt(Raw(lon, west))); + data.Add(3); // differenced and run-length coded: every value a literal run of one + uint previous = 0; + foreach (var (_, lat) in _points) + { + uint value = Raw(lat, south); + data.Add(1); + data.AddRange(UInt(unchecked(value - previous))); + previous = value; + } + data.Add(2); // run-length: the whole plane one repeated value + for (int left = _points.Count; left > 0; left -= Math.Min(left, 127)) + { + data.Add((byte)(0x80 | Math.Min(left, 127))); + data.AddRange(UInt(7)); + } + data.Add(1); // differenced + foreach (var _ in _points) data.AddRange(UInt(1)); + return [.. data]; + } + + private static byte[] Pool16() => [.. Int(1), 1, 0, .. BitConverter.GetBytes((ushort)5)]; + + /// Each plane's scale and offset, as X-Plane writes them: longitude and latitude from the tile's corner. + private byte[] Scales() => [.. Float(1), .. Float(west), .. Float(1), .. Float(south), .. Float(65535), .. Float(-32768), .. Float(0), .. Float(0)]; + + private byte[] Commands() + { + var c = new List(); + // A few polygon commands first: a reader must step over them. + c.AddRange([1, 0, 0]); // pool select 0 + c.AddRange([3, 0]); // definition 0 + c.AddRange([12, 0xFF, 0x00, 2, 0, 0, 0, 0]); // polygon: parameter, 2 indices + c.AddRange([13, 0xFF, 0x00, 0, 0, 1, 0]); // polygon range + c.AddRange([14, 0xFF, 0x00, 2, 1, 0, 0, 2, 0, 0, 0, 0]); // nested polygon: 2 windings of 1 and 2 indices + c.AddRange([15, 0xFF, 0x00, 2, 0, 0, 1, 0, 2, 0]); // nested polygon range: 2 windings, 3 indices + c.AddRange([32, 3, (byte)'a', (byte)'b', (byte)'c']); // comment + // The roads, from a junction offset so 16-bit indices reach every point. + const uint offset = 1; + c.AddRange([2, .. UInt(offset)]); + foreach (var (subtype, first, count, form) in _chains) + { + c.AddRange([6, (byte)subtype]); + switch (form) + { + case ChainForm.Range: + c.Add(10); + c.AddRange(BitConverter.GetBytes((ushort)(first - offset))); + c.AddRange(BitConverter.GetBytes((ushort)(first + count - offset))); + break; + case ChainForm.List16: + c.AddRange([9, (byte)count]); + for (int i = 0; i < count; i++) c.AddRange(BitConverter.GetBytes((ushort)(first + i - offset))); + break; + case ChainForm.List32: + c.AddRange([11, (byte)count]); + for (int i = 0; i < count; i++) c.AddRange(UInt((uint)(first + i))); + break; + } + } + return [.. c]; + } + + private static byte[] Atom(string id, byte[] body) + { + byte[] atom = new byte[8 + body.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(atom, BinaryPrimitives.ReadUInt32BigEndian(System.Text.Encoding.ASCII.GetBytes(id))); + BinaryPrimitives.WriteInt32LittleEndian(atom.AsSpan(4), atom.Length); + body.CopyTo(atom, 8); + return atom; + } + + private static byte[] Strings(params string[] values) => [.. values.SelectMany(v => System.Text.Encoding.ASCII.GetBytes(v + "\0"))]; + + private static byte[] Int(int value) => BitConverter.GetBytes(value); + + private static byte[] UInt(uint value) => BitConverter.GetBytes(value); + + private static byte[] Float(float value) => BitConverter.GetBytes(value); +} diff --git a/tests/OrthoStreamXP.Tests/NightLightsTests.cs b/tests/OrthoStreamXP.Tests/NightLightsTests.cs new file mode 100644 index 0000000..3385565 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/NightLightsTests.cs @@ -0,0 +1,604 @@ +using System.Buffers.Binary; +using OrthoStreamXP.Scenery; +using OrthoStreamXP.Setup; +using OrthoStreamXP.Textures; + +namespace OrthoStreamXP.Tests; + +/// The night lights: X-Plane's roads read from overlay DSFs, painted into night textures and served to X-Plane. +public sealed class NightLightsTests : IDisposable +{ + private const int South = 43, West = 7; + private readonly TempTree _tree = new(); + private readonly List _paintings = []; + + public void Dispose() + { + foreach (NightPainting painting in _paintings) painting.Dispose(); + _tree.Dispose(); + } + + /// A ZL16 texture over Nice. + private static readonly TextureName Nice = TextureName.At(43.70, 7.25, "BI", 16); + + private static (double North, double West, double South, double East) Bounds(TextureName name) + { + var (north, west) = Tiles.TileToLatLon(name.Col, name.Row, name.Zoom); + var (south, east) = Tiles.TileToLatLon(name.Col + Tiles.TextureSpan, name.Row + Tiles.TextureSpan, name.Zoom); + return (north, west, south, east); + } + + /// An overlay package whose DSF has a grid of town streets every 0.001° over the west half of the Nice texture. + private string OverlayWithTown() + { + var (north, west, south, east) = Bounds(Nice); + double middle = (west + east) / 2; + var dsf = new DsfBuilder(South, West); + for (double lon = west + 0.0005; lon < middle; lon += 0.001) dsf.Road(50, (lon, south), (lon, north)); + for (double lat = south + 0.0005; lat < north; lat += 0.001) dsf.Road(50, (west, lat), (middle - 0.002, lat)); + _tree.WriteBytes("overlays/" + RoadNetworks.TileFile(South, West).Replace('\\', '/'), dsf.Build()); + return _tree.PathOf("overlays"); + } + + // --- Reading X-Plane's roads ------------------------------------------------------------------------------ + + [Fact] + public void A_DSF_s_road_chains_are_read_in_every_command_form_and_other_networks_are_left_out() + { + byte[] dsf = new DsfBuilder(South, West) + .Road(50, (7.10, 43.20), (7.11, 43.21), (7.12, 43.20)) // local street, as a range + .Road(59, DsfBuilder.ChainForm.List16, (7.30, 43.30), (7.31, 43.30)) // rural local road + .Road(100, DsfBuilder.ChainForm.List32, (7.50, 43.50), (7.60, 43.60)) // motorway + .Road(151, (7.70, 43.70), (7.80, 43.80)) // railway: not a road + .Road(20, (7.90, 43.90)) // a single point lights nothing + .Build(); + + RoadNetwork roads = DsfNetworkReader.Read(dsf, South, West); + + Assert.Equal(3, roads.Count); + Assert.Equal(new[] { RoadClass.Local, RoadClass.Local, RoadClass.Motorway }, new[] { roads.ClassOf(0), roads.ClassOf(1), roads.ClassOf(2) }); + Assert.Equal(new[] { false, true, false }, new[] { roads.IsRural(0), roads.IsRural(1), roads.IsRural(2) }); + var (lon, lat) = roads.Points(0); + Assert.Equal(3, lon.Length); + Assert.Equal(0.11, lon.Span[1], 5); // degrees from the tile's corner + Assert.Equal(0.21, lat.Span[1], 5); + Assert.Equal(0.60, roads.Points(2).Lon.Span[1], 5); + } + + [Fact] + public void A_compressed_or_damaged_DSF_is_refused_in_plain_words() + { + byte[] compressed = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0, 4, .. new byte[40]]; + Assert.True(DsfNetworkReader.IsCompressed(compressed)); + Assert.Contains("compressed", Assert.Throws(() => DsfNetworkReader.Read(compressed, South, West)).Message, StringComparison.Ordinal); + + byte[] dsf = new DsfBuilder(South, West).Road(50, (7.1, 43.1), (7.2, 43.2)).Build(); + Assert.Throws(() => DsfNetworkReader.Read(dsf.AsSpan(0, dsf.Length - 40), South, West)); + Assert.Throws(() => DsfNetworkReader.Read("XPLNEDSF"u8, South, West)); + } + + [Theory] + [InlineData(10, (int)RoadClass.Primary, false)] + [InlineData(29, (int)RoadClass.Primary, true)] + [InlineData(34, (int)RoadClass.Secondary, false)] + [InlineData(50, (int)RoadClass.Local, false)] + [InlineData(69, (int)RoadClass.Local, true)] + [InlineData(100, (int)RoadClass.Motorway, false)] + [InlineData(110, (int)RoadClass.Motorway, false)] + [InlineData(120, (int)RoadClass.Ramp, false)] + [InlineData(151, (int)RoadClass.None, false)] // railway + [InlineData(220, (int)RoadClass.None, false)] // power line + public void Road_subtypes_are_sorted_into_classes(int subtype, int expected, bool rural) + { + Assert.Equal((RoadClass)expected, RoadClasses.Of(subtype, out bool isRural)); + Assert.Equal(rural, isRural); + } + + [Fact] + public void A_road_across_many_cells_is_found_once_and_a_far_one_not_at_all() + { + byte[] dsf = new DsfBuilder(South, West) + .Road(30, (7.01, 43.5), (7.99, 43.5)) // across the whole tile + .Road(50, (7.9, 43.9), (7.91, 43.91)) + .Build(); + RoadNetwork roads = DsfNetworkReader.Read(dsf, South, West); + var found = new List(); + roads.Near(43.4, 7.2, 43.6, 7.8, found); + Assert.Equal([0], found); + found.Clear(); + roads.Near(44.5, 7.2, 44.6, 7.8, found); // another tile + Assert.Empty(found); + } + + [Fact] + public void A_place_is_as_built_up_as_the_streets_around_it() + { + RoadNetwork roads = new RoadNetworks([OverlayWithTown()]).For(South, West)!; + var (north, west, south, _) = Bounds(Nice); + Assert.True(roads.BuiltUp((north + south) / 2, west + 0.02) > 0.8); + Assert.Equal(0, roads.BuiltUp(43.05, 7.95)); + } + + [Fact] + public void Tile_files_are_named_as_X_Plane_names_them() + { + Assert.Equal(Path.Join("Earth nav data", "+40+000", "+43+007.dsf"), RoadNetworks.TileFile(43, 7)); + Assert.Equal(Path.Join("Earth nav data", "-40-060", "-34-059.dsf"), RoadNetworks.TileFile(-34, -59)); + Assert.Equal(Path.Join("Earth nav data", "+00-010", "+00-001.dsf"), RoadNetworks.TileFile(0, -1)); + } + + [Fact] + public void Roads_come_from_overlay_packages_never_through_the_mount() + { + Directory.CreateDirectory(_tree.PathOf("Custom Scenery/yOrtho4XP_Overlays")); + Directory.CreateDirectory(_tree.PathOf("Custom Scenery/OrthoStream_Overlays")); // our own mount point + Directory.CreateDirectory(_tree.PathOf("Custom Scenery/KSEA Airport")); + PackagePlan[] mounted = + [ + new("OrthoStream_Eur", _tree.PathOf("packages/OrthoStream_Eur"), "", _tree.PathOf("Custom Scenery/OrthoStream_Eur")), + new("OrthoStream_Overlays", _tree.PathOf("packages/OrthoStream_Overlays"), "", _tree.PathOf("Custom Scenery/OrthoStream_Overlays")), + ]; + Assert.Equal( + [_tree.PathOf("packages/OrthoStream_Overlays"), _tree.PathOf("Custom Scenery/yOrtho4XP_Overlays")], + RoadNetworks.FindOverlayPackages(mounted, _tree.PathOf("Custom Scenery"))); + } + + // --- Painting ----------------------------------------------------------------------------------------------- + + [Fact] + public void The_town_is_lit_the_country_stays_black_and_the_same_texture_is_painted_the_same_way() + { + var painter = new NightPainter(new RoadNetworks([OverlayWithTown()])); + const int Side = 1024; + var workspaces = new NightWorkspaces(); + NightWorkspace workspace = workspaces.Rent(Side); + Assert.True(painter.Paint(Nice, workspace)); + byte[] rgb = workspace.Rgb.ToArray(); + + double town = 0; + for (int y = 0; y < Side; y++) + for (int x = 0; x < Side / 2 - 40; x++) + town += rgb[(y * Side + x) * 3]; + Assert.True(town / (Side * (Side / 2 - 40)) > 5, $"the town's mean red is {town / (Side * (Side / 2 - 40)):F1}"); + for (int y = 0; y < Side; y++) + for (int x = Side * 3 / 4; x < Side; x++) + Assert.Equal(0, rgb[(y * Side + x) * 3]); + // Every light is orange or white: never greener or bluer than red. + for (int i = 0; i < rgb.Length; i += 3) + Assert.True(rgb[i] >= rgb[i + 1] && rgb[i + 1] >= rgb[i + 2], $"pixel {i / 3} is {rgb[i]},{rgb[i + 1]},{rgb[i + 2]}"); + + // The same texture painted again, in the same workspace after another one: the same picture. + workspaces.Return(workspace); + workspace = workspaces.Rent(Side); + painter.Paint(TextureName.At(43.65, 7.15, "BI", 16), workspace); + workspaces.Return(workspace); + NightWorkspace again = workspaces.Rent(Side); + Assert.Same(workspace, again); + Assert.True(painter.Paint(Nice, again)); + Assert.Equal(rgb, again.Rgb); + } + + [Fact] + public void Lamps_light_the_ground_beside_the_road_on_both_kerbs_where_3D_roads_do_not_cover_it() + { + var (north, west, south, east) = Bounds(Nice); + var dsf = new DsfBuilder(South, West); + dsf.Road(10, ((west + east) / 2, south), ((west + east) / 2, north)); // a primary road, 12 m wide, down the middle + _tree.WriteBytes("overlays/" + RoadNetworks.TileFile(South, West).Replace('\\', '/'), dsf.Build()); + var painter = new NightPainter(new RoadNetworks([_tree.PathOf("overlays")])); + const int Side = 2048; + var workspace = new NightWorkspace(Side); + Assert.True(painter.Paint(Nice, workspace)); + + // The light of each column across the road (Web Mercator: the road's longitude is the middle column). + double[] column = new double[Side]; + for (int y = 0; y < Side; y++) + for (int x = 0; x < Side; x++) + column[x] += workspace.Rgb[(y * Side + x) * 3]; + double metresPerPixel = Nice.GroundSizeM / Side, road = Side / 2.0; + double Sum(double fromM, double toM) + { + int from = (int)Math.Round(road + fromM / metresPerPixel), to = (int)Math.Round(road + toM / metresPerPixel); + return column[from..to].Sum(); + } + double carriageway = Sum(-6, 6), left = Sum(-60, -6), right = Sum(6, 60); + // Most of the light is where a 3D road over the photo leaves it seen (lamps on the road's middle left a quarter + // there), as much on either side. + Assert.True((left + right) / (left + right + carriageway) > 0.5, $"beside the road {left:F0} and {right:F0}, on it {carriageway:F0}"); + Assert.InRange(left / right, 0.7, 1.4); + } + + [Fact] + public void A_place_without_roads_has_no_lights() + { + Assert.False(new NightPainter(new RoadNetworks([])).Paint(Nice, new NightWorkspace(1024))); + Assert.False(new NightPainter(new RoadNetworks([OverlayWithTown()])).Paint(TextureName.At(43.05, 7.95, "BI", 16), new NightWorkspace(1024))); + } + + [Theory] + [InlineData(16, 256, 1024)] // far: painted at the reference and made smaller + [InlineData(16, 1024, 1024)] + [InlineData(16, 2048, 2048)] + [InlineData(16, 4096, 2048)] // beside the aircraft: painted twice as fine, then enlarged + [InlineData(18, 4096, 512)] // a ZL18 tile covers a quarter of the ground: the same metres a pixel + [InlineData(18, 64, 256)] + [InlineData(22, 64, 64)] + public void Night_textures_are_painted_at_the_same_metres_per_pixel_at_every_zoom(int zoom, int levelSide, int painted) + { + Assert.Equal(painted, NightTextures.PaintSide(zoom, levelSide)); + } + + [Theory] + [InlineData(6.5)] // a far texture + [InlineData(3.2)] // beside the aircraft: twice as fine + public void A_lamp_is_a_round_soft_spot_with_as_much_light_as_its_square_in_metres(double metresPerPixel) + { + const int Side = 64; + float[] picture = new float[Side * Side]; + double sigma = NightPainter.LampSigma(metresPerPixel); + NightRaster.Spot(picture, Side, 32.5, 32.5, sigma, 0.6f); // a pixel's centre + + Assert.Equal(0.6f, picture[32 * Side + 32], 3); + double square = Math.Pow(NightPainter.LampSizeM / metresPerPixel, 2) * 0.6; + Assert.InRange(picture.Sum(), square * 0.97, square * 1.03); + // Round: as bright at the same distance whichever the direction. + Assert.Equal(picture[32 * Side + 34], picture[34 * Side + 32], 5); + Assert.Equal(picture[30 * Side + 32], picture[32 * Side + 30], 5); + double diagonal = Math.Exp(-0.5 * 8 / (sigma * sigma)) * 0.6; // at (2, 2): 2.83 pixels out + Assert.Equal(diagonal, picture[34 * Side + 34], 4); + } + + [Fact] + public void A_lamp_between_pixels_lights_them_alike() + { + float[] picture = new float[16 * 16]; + NightRaster.Spot(picture, 16, 8, 8, 0.8, 1f); // on the corner of four pixels + Assert.Equal(picture[7 * 16 + 7], picture[8 * 16 + 8], 5); + Assert.Equal(picture[7 * 16 + 8], picture[8 * 16 + 7], 5); + Assert.Equal(picture[7 * 16 + 7], picture[7 * 16 + 8], 5); + } + + [Fact] + public void A_picture_blurred_in_place_is_the_same_as_blurred_into_another() + { + const int Side = 64; + float[] picture = new float[Side * Side], across = new float[Side * Side], result = new float[Side * Side]; + NightRaster.Line(picture, Side, 5, 10, 50, 40, 3, 0.4f); + NightRaster.Spot(picture, Side, 60, 3, 1.5, 0.9f); // by an edge: the mirrored rows + NightRaster.Blur(picture, Side, 2.5, across, result); + NightRaster.Blur(picture, Side, 2.5, across, picture); + Assert.Equal(result, picture); + } + + // --- The DDS file ----------------------------------------------------------------------------------------------- + + private static byte[] Level(byte[] dds, int side, int mip) + { + var layout = DdsLayout.For(side); + int size = layout.SizeOf(mip); + return Dds.DecodeLevel(dds.AsSpan((int)layout.Offset(mip), layout.LengthOf(mip)), size, size); + } + + /// A workspace whose top level is , as if painted. + private static NightWorkspace Painted(byte[] rgb, int side) + { + var workspace = new NightWorkspace(side); + rgb.CopyTo(workspace.Rgb, 0); + return workspace; + } + + [Theory] + [InlineData(64)] // as painted + [InlineData(256)] // enlarged four times: a texture beside the aircraft finer than it was painted + [InlineData(16)] // made smaller + public void An_even_light_keeps_its_colour_at_every_level_and_never_turns_green(int nominal) + { + const int Side = 64; + byte[] rgb = new byte[Side * Side * 3]; + for (int i = 0; i < rgb.Length; i += 3) (rgb[i], rgb[i + 1], rgb[i + 2]) = (200, 140, 72); + byte[] dds = NightDds.Encode(Painted(rgb, Side), nominal); + + var layout = DdsLayout.For(nominal); + Assert.Equal(layout.Length, dds.Length); + Assert.True(dds.AsSpan(0, Dds.HeaderSize).SequenceEqual(layout.Header)); + for (int mip = 0; mip < layout.Levels; mip++) + { + byte[] level = Level(dds, nominal, mip); + for (int i = 0; i < level.Length; i += 3) + { + Assert.InRange(level[i], 192, 208); + Assert.InRange(level[i + 1], 132, 148); + Assert.True(level[i + 1] <= level[i] && level[i + 2] <= level[i]); + } + } + } + + [Fact] + public void Smaller_levels_average_the_light_not_the_stored_values() + { + // A quarter of a 4 x 4 texture lit full white: its 1 x 1 level holds a quarter of the light, which is 136 stored + // (gamma 2.2), not the 64 a plain average of the stored values gives: a town looks as bright from far as from near. + byte[] rgb = new byte[4 * 4 * 3]; + foreach (int pixel in new[] { 0, 1, 4, 5 }) rgb.AsSpan(pixel * 3, 3).Fill(255); + byte[] last = Level(NightDds.Encode(Painted(rgb, 4), 4), 4, 2); + Assert.InRange(last[0], 128, 144); + } + + [Fact] + public void A_block_turned_green_by_BC1_is_warmed_and_its_texels_keep_their_colours() + { + // Two endpoints of the same red, the first greener: warmed, the second ends up above the first, so they swap + // and every texel's index swaps with them. + const ushort First = (2 << 11) | (40 << 5), Second = (2 << 11) | (4 << 5) | 31; + byte[] block = new byte[8]; + BinaryPrimitives.WriteUInt16LittleEndian(block, First); + BinaryPrimitives.WriteUInt16LittleEndian(block.AsSpan(2), Second); + BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(4), 0b11_10_01_00_11_10_01_00_11_10_01_00_11_10_01_00); // texel i: index i % 4 + + NightDds.Warm(block); + + Assert.True(BinaryPrimitives.ReadUInt16LittleEndian(block) > BinaryPrimitives.ReadUInt16LittleEndian(block.AsSpan(2)), "four-colour mode"); + byte[] texels = Dds.DecodeLevel(block, 4, 4); + for (int i = 0; i < 16; i++) + { + byte r = texels[i * 3], g = texels[i * 3 + 1], b = texels[i * 3 + 2]; + Assert.True(g <= r + 1 && b <= r + 1, $"texel {i} is {r},{g},{b}"); + } + // Texels that were the first colour are now the warmed first colour: red 2, green 4, blue 0. + Assert.Equal([16, 16, 0], texels[..3]); + // Those that were the second keep its warmed version: blue down to the red. + Assert.Equal([16, 16, 16], texels[3..6]); + } + + [Theory] + [InlineData(1024, 2, 65408 + 43704)] // the header chunk's part of level 0, and the levels from 256 px + [InlineData(1024, 1, 65408 + 174776)] + [InlineData(1024, 0, 699064)] // the whole texture but its header + [InlineData(512, 1, 65408 + 43704)] + [InlineData(256, 2, 43704)] // every level starts within the header chunk: kept whole + public void A_cache_file_keeps_the_header_chunk_and_the_levels_from_the_finest_wanted(int side, int from, long length) + { + var layout = DdsLayout.For(side); + int stored = NightStore.Normalize(layout, from); + Assert.Equal(length, NightStore.Length(layout, from)); + Assert.Equal(0, NightStore.Position(layout, from, Dds.HeaderSize)); + Assert.Equal(-1, NightStore.Position(layout, from, 0)); + Assert.Equal(length - 1, NightStore.Position(layout, from, layout.Length - 1)); + if (stored > 0) Assert.Equal(-1, NightStore.Position(layout, from, NightStore.HeadEnd(layout))); + } + + [Fact] + public void A_level_is_the_same_bytes_whichever_level_the_kept_part_starts_at() + { + // A texture the photos' size, painted at the reference side: X-Plane's far levels. + const int Nominal = 4096; + var painter = new NightPainter(new RoadNetworks([OverlayWithTown()])); + NightWorkspace Paint() + { + var workspace = new NightWorkspace(1024); + painter.Paint(Nice, workspace); + return workspace; + } + byte[] whole = NightDds.Encode(Paint(), Nominal); + var layout = DdsLayout.For(Nominal); + foreach (int from in new[] { 1, 2, 4, 7 }) + { + byte[] kept = NightDds.EncodeStored(Paint(), Nominal, from); + Assert.True(kept.AsSpan(0, (int)NightStore.HeadEnd(layout) - Dds.HeaderSize).SequenceEqual(whole.AsSpan(Dds.HeaderSize, (int)NightStore.HeadEnd(layout) - Dds.HeaderSize))); + int at = (int)NightStore.Position(layout, from, layout.Offset(from)); + Assert.True(kept.AsSpan(at).SequenceEqual(whole.AsSpan((int)layout.Offset(from))), $"from level {from}"); + } + } + + // --- Serving ----------------------------------------------------------------------------------------------------- + + /// The photos' side, as the Extra quality has them. + private const int PhotoSide = 4096; + + private NightTextures Textures(params string[] overlays) + { + var painting = new NightPainting(new NightPainter(new RoadNetworks(overlays)), 2); + _paintings.Add(painting); + return new(_tree.PathOf("cache"), _ => PhotoSide, painting); + } + + private static VirtualPath NightPath(TextureName name) => VirtualPath.Of(NightTextures.Folder, name.Stem + "_LIT.dds"); + + /// One of X-Plane's reads on a handle: bytes from . + private static byte[] Read(NightTexture texture, ReadCursor cursor, long offset, int length) + { + byte[] buffer = new byte[length]; + Assert.Equal((int)Math.Min(length, texture.Layout.Length - offset), texture.Read(cursor, offset, buffer)); + return buffer; + } + + /// The whole night texture of the photos' size, painted at . + private byte[] WholeNightTexture(TextureName name, int paintSide) + { + var workspace = new NightWorkspace(paintSide); + new NightPainter(new RoadNetworks([OverlayWithTown()])).Paint(name, workspace); + return NightDds.Encode(workspace, PhotoSide); + } + + [Fact] + public void Night_textures_have_the_photos_size_and_X_Planes_reads_are_painted_at_their_detail() + { + NightTextures night = Textures(OverlayWithTown()); + NightTexture texture = night.Open(NightPath(Nice))!.Value.Night!; + Assert.Equal(PhotoSide, texture.Layout.Size); + Assert.Equal(0, night.Counts.Painted); + var layout = texture.Layout; + byte[] far = WholeNightTexture(Nice, 1024), near = WholeNightTexture(Nice, 2048); + string file = _tree.PathOf($"cache/night/{Nice.Stem}.lit6"); + + // As X-Plane loads a far tile: the header chunk, then the levels from 256 px (the photo's 256 px level with them). + var cursor = new ReadCursor(); + int level4 = (int)layout.Offset(4); + Assert.Equal(far[..65536], Read(texture, cursor, 0, 65536)); + Assert.Equal(far[level4..], Read(texture, cursor, level4, far.Length - level4)); + Assert.Equal(1, night.Counts.Painted); + // Kept compressed: less than half the 64 KB of level 0 and the 43 KB of the levels from 256 px, though this + // town is lit edge to edge. + Assert.True(new FileInfo(file).Length < NightStore.Length(layout, 4) / 2, $"{new FileInfo(file).Length} bytes"); + // Counted and removed with its area, and never taken for a photo (it is not a .dds). + Assert.Equal(AreaName.Of(Nice), CacheAreas.AreaOf(Path.GetFileName(file))); + + // Beside the aircraft: the header chunk again (as kept), then level 0 read on from it, painted twice as fine. + cursor = new ReadCursor(); + Assert.Equal(far[..65536], Read(texture, cursor, 0, 65536)); + Assert.Equal(near[65536..131072], Read(texture, cursor, 65536, 65536)); + Assert.Equal(2, night.Counts.Painted); + Assert.Equal(near[131072..], Read(texture, cursor, 131072, near.Length - 131072)); + Assert.Equal(2, night.Counts.Painted); + + // The next session reads it from the cache without painting. + NightTextures next = Textures(OverlayWithTown()); + Assert.Equal(near, Read(next.Open(NightPath(Nice))!.Value.Night!, new ReadCursor(), 0, near.Length)); + Assert.Equal(0, next.Counts.Painted); + } + + [Fact] + public void A_place_without_lights_reads_black_and_keeps_nothing() + { + NightTextures night = Textures(); + TextureName airport = TextureName.At(43.70, 7.25, "BI", 18); + NightTexture texture = night.Open(NightPath(airport))!.Value.Night!; + int level4 = (int)texture.Layout.Offset(4); + + var cursor = new ReadCursor(); + byte[] head = Read(texture, cursor, 0, 65536), levels = Read(texture, cursor, level4, (int)texture.Layout.Length - level4); + Read(texture, new ReadCursor(), 0, 65536); + + Assert.True(head.AsSpan(0, Dds.HeaderSize).SequenceEqual(texture.Layout.Header)); + Assert.True(head.AsSpan(Dds.HeaderSize).IndexOfAnyExcept((byte)0) < 0); + Assert.True(levels.AsSpan().IndexOfAnyExcept((byte)0) < 0); + Assert.False(Directory.Exists(_tree.PathOf("cache/night"))); + Assert.Equal(1, night.Counts.Dark); + } + + [Fact] + public void Night_textures_earlier_versions_kept_are_removed() + { + _tree.WriteText($"cache/night/{Nice.Stem}.lit1", "whole"); + _tree.WriteText($"cache/night/{Nice.Stem}.lit2", "four times coarser"); + _tree.WriteText($"cache/night/{Nice.Stem}.lit3", "square lamps"); + _tree.WriteText($"cache/night/{Nice.Stem}.lit4", "lamps on the road"); + _tree.WriteText($"cache/night/{Nice.Stem}.lit5", "speckled windows"); + _tree.WriteText($"cache/night/{Nice.Stem}.lit6", "now"); + Assert.Equal(5, NightTextures.RemoveOldFiles(_tree.PathOf("cache"))); + Assert.Equal([_tree.PathOf($"cache/night/{Nice.Stem}.lit6")], Directory.GetFiles(_tree.PathOf("cache/night"))); + } + + [Fact] + public void A_cache_file_gives_back_what_was_kept_across_its_chunks_and_black_ones_take_no_space() + { + byte[] kept = new byte[200_000]; // four chunks: two black, one with light, one partly + new Random(5).NextBytes(kept.AsSpan(70_000, 50_000)); + string path = _tree.PathOf("cache/night/test.lit6"); + NightFile.Write(path, 3, kept); + + Assert.True(new FileInfo(path).Length < 60_000, "the black chunks take nothing"); + using (var handle = File.OpenHandle(path)) + { + NightFile.Contents contents = NightFile.ReadContents(handle)!; + Assert.Equal(3, contents.From); + Assert.Equal(kept.Length, contents.Length); + foreach (var (at, length) in new[] { (0, 10), (65_530, 100), (60_000, 140_000), (199_990, 10) }) + { + byte[] read = new byte[length]; + NightFile.Read(handle, contents, at, read); + Assert.Equal(kept.AsSpan(at, length).ToArray(), read); + } + } + + File.WriteAllBytes(path, [.. File.ReadAllBytes(path)[..^5]]); + using var damaged = File.OpenHandle(path); + Assert.Null(NightFile.ReadContents(damaged)); + } + + [Theory] + [InlineData("textures", "21904_35824_BI16_LIT.dds")] + [InlineData(NightTextures.Folder, "21904_35824_BI16.dds")] + [InlineData(NightTextures.Folder, "not_a_tile_LIT.dds")] + public void Other_paths_are_not_night_textures(string folder, string name) => + Assert.Null(Textures().Open(VirtualPath.Of(folder, name))); + + // --- The sun ----------------------------------------------------------------------------------------------------- + + [Fact] + public void Day_twilight_and_night_are_told_once_each_even_with_the_sun_on_the_line() + { + var daylight = new Daylight(); + Assert.Equal(DaylightPhase.Day, (DaylightPhase)daylight.Offer(20)!); + Assert.Null(daylight.Offer(5)); + Assert.Null(daylight.Offer(-0.2)); // on the line: not yet + Assert.Null(daylight.Offer(0.1)); + Assert.Equal(DaylightPhase.Twilight, (DaylightPhase)daylight.Offer(-0.6)!); + Assert.Null(daylight.Offer(0.2)); // back up a little: not yet day + Assert.Equal(DaylightPhase.Night, (DaylightPhase)daylight.Offer(-30)!); // time moved in X-Plane's menu + Assert.Equal(DaylightPhase.Day, (DaylightPhase)daylight.Offer(40)!); + Assert.Null(daylight.Offer(double.NaN)); + Assert.Equal(DaylightPhase.Day, daylight.Phase); + } + + [Fact] + public void It_is_dark_for_the_photos_only_well_past_civil_twilight_and_a_sun_on_the_line_changes_nothing() + { + var daylight = new Daylight(); + Assert.False(daylight.Dark); // nothing from X-Plane yet + daylight.Offer(-7); + Assert.Equal((DaylightPhase.Night, false), (daylight.Phase!.Value, daylight.Dark)); // night for the lights, not dark for the photos + daylight.Offer(-9.2); + Assert.False(daylight.Dark); // on the line: not yet + daylight.Offer(-9.6); + Assert.True(daylight.Dark); + daylight.Offer(-8.8); + Assert.True(daylight.Dark); // dawn, but on the line + daylight.Offer(-8.4); + Assert.False(daylight.Dark); + daylight.Offer(-30); // time moved in X-Plane's menu + Assert.True(daylight.Dark); + daylight.Offer(double.NaN); + Assert.True(daylight.Dark); + } + + [Fact] + public void How_dark_it_must_be_for_the_photos_can_be_set() + { + var daylight = new Daylight(-12); + daylight.Offer(-11); + Assert.False(daylight.Dark); + daylight.Offer(-12.6); + Assert.True(daylight.Dark); + } + + // --- The setting ----------------------------------------------------------------------------------------------- + + [Fact] + public void Night_lights_are_off_unless_turned_on() + { + Directory.CreateDirectory(_tree.PathOf("source")); + MountSettings Parse(params string[] extra) => MountSettings.FromValues(TestSettings.Of( + [$"source = {_tree.PathOf("source")}", $"cache = {_tree.PathOf("cache")}", $"mountpoint = {_tree.PathOf("mount")}", .. extra])); + Assert.False(Parse().NightLights); + Assert.True(Parse("night-lights = on").NightLights); + Assert.False(Parse("night-lights = off").NightLights); + Assert.Throws(() => Parse("night-lights = bright")); + } + + [Fact] + public void The_photos_stream_at_night_unless_skipped_whatever_the_night_lights() + { + Directory.CreateDirectory(_tree.PathOf("source")); + MountSettings Parse(params string[] extra) => MountSettings.FromValues(TestSettings.Of( + [$"source = {_tree.PathOf("source")}", $"cache = {_tree.PathOf("cache")}", $"mountpoint = {_tree.PathOf("mount")}", .. extra])); + Assert.False(Parse().SkipPhotosAtNight); + Assert.True(Parse("skip-photos-at-night = on").SkipPhotosAtNight); + Assert.Equal((true, false), (Parse("skip-photos-at-night = on", "night-lights = off") is var s ? (s.SkipPhotosAtNight, s.NightLights) : default)); + Assert.Throws(() => Parse("skip-photos-at-night = sometimes")); + Assert.Equal(9, Parse().SkipPhotosSunBelow); + Assert.Equal(12, Parse("skip-photos-sun-below = 12").SkipPhotosSunBelow); + Assert.Throws(() => Parse("skip-photos-sun-below = 25")); + Assert.Throws(() => Parse("skip-photos-sun-below = -3")); + } +} diff --git a/tests/OrthoStreamXP.Tests/StreamingTests.cs b/tests/OrthoStreamXP.Tests/StreamingTests.cs index 17e67c7..c3aaca4 100644 --- a/tests/OrthoStreamXP.Tests/StreamingTests.cs +++ b/tests/OrthoStreamXP.Tests/StreamingTests.cs @@ -29,7 +29,7 @@ private sealed record Setup(FakeTileServer Server, TileSource Tiles, SceneryRoot /// -3: ZL16 textures are 1024 px from zoom 14, their base level 2 (zoom 12). -2: 2048 px from zoom 15, base level 3. private Setup Streaming(int level = -3, double maxWait = 0.2, Func? delay = null, Setup? sharing = null, int detailedBuilds = 16, - long buildTiles = TextureStreamer.DefaultBuildTiles, Func? hold = null) + long buildTiles = TextureStreamer.DefaultBuildTiles, Func? hold = null, Daylight? nightPause = null) { FakeTileServer server = sharing?.Server ?? new FakeTileServer(delay: delay, hold: hold); TileSource tiles = sharing?.Tiles ?? Track(new TileSource("test", FakeTileServer.Template, 32, handler: server) { Store = new TileStore(_tree.PathOf("base")) }); @@ -42,7 +42,7 @@ private Setup Streaming(int level = -3, double maxWait = 0.2, Func s.Streamer.RealFrom(t) == t.BaseMip); + Assert.Equal([12], Zooms(s.Server)); + } + + [Fact] + public async Task At_night_every_piece_of_a_level_read_in_pieces_counts_as_a_night_read() + { + var sun = new Daylight(); + sun.Offer(-20); + Setup s = Streaming(maxWait: 5, nightPause: sun); + StreamedTexture t = s.Streamer.Get(Texture())!; + ReadLevel(s.Streamer, t, t.BaseMip); // the base, so the night reads below are stretched from it + await Until(() => s.Streamer.RealFrom(t) == t.BaseMip); + (long reads, long standIns) = (s.Streamer.Reads, s.Streamer.StandInReads); // the base's own read, flat before it arrived + + var cursor = new ReadCursor(); + long start = t.Layout.Offset(1); + int half = t.Layout.LengthOf(1) / 2; + Assert.Equal(Served.StandIn, s.Streamer.Read(t, cursor, start, new byte[half]).Served); + Assert.Equal(Served.StandIn, s.Streamer.Read(t, cursor, start + half, new byte[t.Layout.LengthOf(1) - half]).Served); + + Assert.Equal((reads, standIns, 2L), (s.Streamer.Reads, s.Streamer.StandInReads, s.Streamer.NightReads)); + } + + [Fact] + public void At_night_a_texture_already_in_the_cache_is_served_sharp() + { + var sun = new Daylight(); + Setup s = Streaming(nightPause: sun); + StreamedTexture t = s.Streamer.Get(Texture())!; + ReadLevel(s.Streamer, t, 0); // by day: the whole texture + int downloads = s.Server.Requests.Count; + + sun.Offer(-20); + Setup again = Streaming(sharing: s, nightPause: sun); // the next session finds it in the cache + StreamedTexture cached = again.Streamer.Get(Texture())!; + var (_, answer) = ReadLevel(again.Streamer, cached, 0); + Assert.Equal(Served.Real, answer.Served); + Assert.Equal(downloads, s.Server.Requests.Count); + } + + [Fact] + public async Task At_night_load_ahead_fetches_only_base_tiles_even_beside_the_aircraft() + { + var sun = new Daylight(); + sun.Offer(-20); + Setup s = Streaming(nightPause: sun); + var catalog = new TextureCatalog(); + using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2); + StreamedTexture near = s.Streamer.Get(Texture())!; + s.View.Update(new CameraReport(near.Lat, near.Lon, 2, null, null, false, DateTime.UtcNow)); + catalog.AddTerrain(VirtualPath.Of("terrain", Stem + ".ter")); + + warmer.StartWhatFits(); + await Until(() => s.Streamer.RealFrom(near) <= near.BaseMip); + Assert.Equal([12], Zooms(s.Server)); + } + + [Fact] + public void In_twilight_by_day_and_before_XPlane_sends_the_sun_the_photos_stream_in_full() + { + var sun = new Daylight(); + Setup s = Streaming(nightPause: sun); + Assert.False(s.Streamer.PhotosPaused); // no sun yet + sun.Offer(-3); + Assert.False(s.Streamer.PhotosPaused); // dusk: the ground is still seen + sun.Offer(-8); + Assert.False(s.Streamer.PhotosPaused); // past civil twilight, but the sky still lights the ground + + StreamedTexture t = s.Streamer.Get(Texture())!; + var (_, answer) = ReadLevel(s.Streamer, t, 0); + Assert.Equal(Served.Real, answer.Served); + Assert.Equal(16, Zooms(s.Server).Count(z => z == 14)); + } + + [Fact] + public void When_the_sun_rises_the_next_read_gets_the_photo_again() + { + var sun = new Daylight(); + sun.Offer(-20); + Setup s = Streaming(maxWait: 5, nightPause: sun); + StreamedTexture t = s.Streamer.Get(Texture())!; + Assert.Equal(Served.StandIn, ReadLevel(s.Streamer, t, 1).Answer.Served); + + sun.Offer(10); + var (_, answer) = ReadLevel(s.Streamer, t, 1); + Assert.Equal(Served.Real, answer.Served); + Assert.Contains(13, Zooms(s.Server)); + Assert.Equal((1L, 1L), (s.Streamer.Reads, s.Streamer.NightReads)); + } + // ---------------------------------------------------------------- load-ahead [Fact] diff --git a/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs b/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs index a787693..9f86f46 100644 --- a/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs +++ b/tests/OrthoStreamXP.Tests/TerrainDefinitionsTests.cs @@ -197,6 +197,50 @@ MountSettings Parse(params string[] extra) => MountSettings.FromValues(TestSetti Assert.Throws(() => Parse("ground-detail = -1")); } + private TerrainDefinitions WithNightLights() => + new(new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, new TexturePlan(0, TextureSizing.MaxSize)), decals: null, nightLights: true); + + [Fact] + public void With_night_lights_a_land_tile_names_its_night_texture_right_after_its_photo() + { + Assert.Equal( + "A\n800\nTERRAIN\n\nLOAD_CENTER 74.50816 18.85254 2613 4096\nBASE_TEX_NOWRAP ../textures/11952_36192_BI16.dds\n" + + $"LIT_TEX_NOWRAP ../{NightTextures.Folder}/11952_36192_BI16_LIT.dds\n" + + "DECAL_LIB lib/g10/decals/maquify_2_green_key.dcl\nNO_ALPHA\n", + Resolve(WithNightLights(), "11952_36192_BI16.ter")); + } + + [Fact] + public void With_night_lights_the_package_definition_gets_the_line_too() + { + WritePackageDefinition(); + Assert.Contains( + $"BASE_TEX_NOWRAP ../textures/{Tile}.dds\nLIT_TEX_NOWRAP ../{NightTextures.Folder}/{Tile}_LIT.dds\nDECAL_LIB", + Resolve(WithNightLights(), $"{Tile}.ter"), StringComparison.Ordinal); + } + + [Fact] + public void Water_overlays_and_definitions_with_their_own_night_texture_get_no_night_line() + { + TerrainDefinitions terrain = WithNightLights(); + Assert.DoesNotContain("LIT_TEX", Resolve(terrain, "100000_125184_BI18_water_overlay.ter"), StringComparison.Ordinal); + Assert.DoesNotContain("LIT_TEX", Resolve(terrain, "100000_148176_BI18_sea_overlay.ter"), StringComparison.Ordinal); + + _tree.WriteText($"source/terrain/{Tile}.ter", + $"A\n800\nTERRAIN\n\nLOAD_CENTER 39.18969 125.65063 1895 4096\nBASE_TEX_NOWRAP ../textures/{Tile}.dds\nLIT_TEX ../textures/own_LIT.dds\n"); + string own = Resolve(terrain, $"{Tile}.ter"); + Assert.Contains("LIT_TEX ../textures/own_LIT.dds", own, StringComparison.Ordinal); + Assert.DoesNotContain(NightTextures.Folder, own, StringComparison.Ordinal); + } + + [Fact] + public void Without_night_lights_no_definition_names_a_night_texture() + { + WritePackageDefinition(); + Assert.DoesNotContain("LIT_TEX", Generate("11952_36192_BI16.ter"), StringComparison.Ordinal); + Assert.DoesNotContain("LIT_TEX", Resolve(_terrain, $"{Tile}.ter"), StringComparison.Ordinal); + } + [Theory] [InlineData("terrain", "not_a_tile.ter")] [InlineData("terrain", "11953_36192_BI16.ter")] // row not on the 16-tile grid diff --git a/tests/OrthoStreamXP.Tests/TrayIconTests.cs b/tests/OrthoStreamXP.Tests/TrayIconTests.cs new file mode 100644 index 0000000..3903b24 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/TrayIconTests.cs @@ -0,0 +1,43 @@ +using System.Windows; +using OrthoStreamXP.Gui; + +namespace OrthoStreamXP.Tests; + +/// The notification-area icon is there exactly while the window is hidden. +public sealed class TrayIconTests +{ + [Fact] + public void Icon_shows_while_the_window_is_hidden_and_goes_when_it_is_shown_again() + { + var seen = new List<(bool WindowVisible, bool IconShown)>(); + Exception? error = null; + var thread = new Thread(() => + { + try + { + var window = new Window { Width = 200, Height = 100, ShowActivated = false }; + using var tray = new TrayIcon(window); + window.Show(); + seen.Add((window.IsVisible, tray.IsShown)); + tray.HideWindow(); + seen.Add((window.IsVisible, tray.IsShown)); + tray.ShowWindow(); + seen.Add((window.IsVisible, tray.IsShown)); + // Shown by other means (a second start of the program): the icon goes too. + tray.HideWindow(); + window.Show(); + seen.Add((window.IsVisible, tray.IsShown)); + window.Close(); + } + catch (Exception ex) + { + error = ex; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + Assert.Null(error); + Assert.Equal([(true, false), (false, true), (true, false), (true, false)], seen); + } +} diff --git a/tests/OrthoStreamXP.Tests/WindowSettingsTests.cs b/tests/OrthoStreamXP.Tests/WindowSettingsTests.cs index fd6a35a..f003717 100644 --- a/tests/OrthoStreamXP.Tests/WindowSettingsTests.cs +++ b/tests/OrthoStreamXP.Tests/WindowSettingsTests.cs @@ -174,6 +174,9 @@ public void Changed_settings_are_named_once_in_the_order_of_the_settings_page() Assert.Equal(["Quality", "Seasons", "Tuned settings"], names); Assert.Equal("Quality, Seasons and Tuned settings", SettingNames.List(names)); Assert.Equal("Quality", SettingNames.List(["Quality"])); + // The Night tab comes after Colours and before Other. + Assert.Equal(["Exposure", "Night lights", "Photos at night", "X-Plane address"], + SettingNames.Of(["xplane-address", "skip-photos-sun-below", "skip-photos-at-night", "night-lights", "exposure"])); } [Fact] diff --git a/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs b/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs new file mode 100644 index 0000000..07a8b17 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/XPlaneWatchTests.cs @@ -0,0 +1,37 @@ +using OrthoStreamXP.Gui; + +namespace OrthoStreamXP.Tests; + +public sealed class XPlaneWatchTests +{ + [Fact] + public void Quit_is_when_a_check_no_longer_sees_X_Plane_that_the_last_one_saw() + { + var watch = new XPlaneWatch(); + Assert.False(watch.QuitSince(true)); + Assert.False(watch.QuitSince(true)); + Assert.True(watch.QuitSince(false)); + // Reported once, not at every check after. + Assert.False(watch.QuitSince(false)); + } + + [Fact] + public void Not_running_yet_is_not_a_quit() + { + // Just started from the Fly button: the first checks may not see it in the process list yet. + var watch = new XPlaneWatch(); + Assert.False(watch.QuitSince(false)); + Assert.False(watch.QuitSince(false)); + Assert.False(watch.QuitSince(true)); + } + + [Fact] + public void A_second_flight_is_watched_too() + { + var watch = new XPlaneWatch(); + watch.QuitSince(true); + Assert.True(watch.QuitSince(false)); + Assert.False(watch.QuitSince(true)); + Assert.True(watch.QuitSince(false)); + } +}