diff --git a/README.md b/README.md
index f7b3ca0..cde42d5 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Traditional photo scenery often requires downloading and building large areas be
- **Scenery on demand** — download imagery as X-Plane needs it, instead of preparing entire regions in advance.
- **Less disk space wasted** — keep the areas you fly and automatically remove older scenery when your cache reaches its limit.
- **Better scenery ahead** — OrthoStreamXP follows your aircraft and downloads upcoming areas before you reach them.
+- **SimBrief flights ready sooner** — optionally, with your SimBrief username, the departure is built while X-Plane loads and the arrival and climb while you sit at the gate.
- **Simple quality settings** — choose Balanced, Sharp, Ultra, or Extra without learning dozens of technical parameters.
- **Settings that adapt to your setup** — test your connection before the first flight to get a recommended preset, and after a sufficiently long flight, get recommendations based on how the scenery performed on your PC and internet connection. You decide whether to apply them.
- **Even colours** — optionally soften the patchwork look where neighbouring photos were taken on different days, so a grey, hazy patch stands out less.
diff --git a/build-user-guide.ps1 b/build-user-guide.ps1
index 438bee1..90839b9 100644
--- a/build-user-guide.ps1
+++ b/build-user-guide.ps1
@@ -24,7 +24,7 @@ if (-not $browser) { throw 'Google Chrome or Microsoft Edge is needed to print t
# The order of the guide's contents (docs\README.md). The pages keep their names so that pandoc's --file-scope
# turns the links between them (settings.md#quality) into links inside the PDF.
-$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'packages', 'troubleshooting', 'faq'
+$pages = 'README', 'installation', 'first-setup', 'flying', 'simbrief', 'settings', 'cache', 'packages', 'troubleshooting', 'faq'
$work = 'artifacts\user-guide'
if (Test-Path $work) { Remove-Item $work -Recurse -Force }
New-Item -ItemType Directory $work | Out-Null
diff --git a/docs/README.md b/docs/README.md
index 988bbce..20941b7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -7,13 +7,14 @@ OrthoStreamXP adds real-world aerial photos to your X-Plane 12 scenery while you
## Contents
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.
+2. [First setup](first-setup.md): the seven-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, 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.
-8. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho.
+4. [SimBrief](simbrief.md): building your SimBrief flight's scenery before X-Plane needs it (optional).
+5. [Settings](settings.md): quality and ground detail, folders, downloads, colours, seasons and exposure, night lights and photos at night.
+6. [Cache](cache.md): how much disk space the scenery uses and how to free it.
+7. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages.
+8. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug.
+9. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho.
## The short version
diff --git a/docs/first-setup.md b/docs/first-setup.md
index 03cdb4d..8152b08 100644
--- a/docs/first-setup.md
+++ b/docs/first-setup.md
@@ -2,7 +2,7 @@
[User guide](README.md) › First setup
-The setup opens the first time you start OrthoStreamXP. It has six steps and takes a minute or two. Use **Next** and **Back** to move between the steps; nothing is saved until you click **Finish**.
+The setup opens the first time you start OrthoStreamXP. It has seven steps and takes a minute or two. Use **Next** and **Back** to move between the steps; nothing is saved until you click **Finish**.
You can run it again later from **Settings › Run the setup again**. A second run starts from your current settings, and **Cancel** leaves them unchanged.
@@ -87,9 +87,13 @@ Pick how sharp the ground should be: **Balanced**, **Sharp**, **Ultra** or **Ext
Internet speed changes during the day, so you can repeat the test later in [Settings › Pictures and download](settings.md#pictures-and-download).
-## 6. Ready
+## 6. SimBrief
-
+Optional. If you plan your flights with SimBrief, enter your SimBrief username or Pilot ID: OrthoStreamXP then builds your flight's scenery before X-Plane needs it, the departure when streaming starts and the arrival and climb while you sit at the gate. Click **Test SimBrief connection** to check that your latest flight plan is found. Once a username is entered, you can also choose the **download limit per session** and whether to **also build the alternate airport** (see [Why there is a download limit](simbrief.md#why-there-is-a-download-limit)). Leave the field empty to skip this. See [SimBrief](simbrief.md).
+
+## 7. Ready
+
+
This step summarises your choices: the X-Plane folder, the scenery packages, the quality and the cache folder. Tick **Start OrthoStreamXP now** if you want to fly straight away, then click **Finish**.
diff --git a/docs/settings.md b/docs/settings.md
index 6674496..cfa0096 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -139,6 +139,13 @@ How dark it must be is set in the settings file (**Show the file**), not on the
After flights of 10 minutes or more, Home may suggest settings for your preset (see [Flying › Tuning suggestions](flying.md#tuning-suggestions)). The tab lists each setting the tuning changed, with the preset's own value beside it. **Reset to defaults** puts every setting back to the preset's tested values and forgets what was tuned; it asks first. The folders, the picture source and *Downloads at once* stay as they are.
+## SimBrief
+
+- **SimBrief username:** your SimBrief username or Pilot ID. Empty: SimBrief is off.
+- **Test SimBrief connection:** looks up your latest flight plan, so you know OrthoStreamXP finds it.
+- **Download limit per session:** the most the SimBrief prefetch may download per streaming session, in MB (0: no limit). See [Why there is a download limit](simbrief.md#why-there-is-a-download-limit).
+- **Also build the alternate airport:** builds the alternate too, after the arrival. Off by default.
+
## Other

diff --git a/docs/simbrief.md b/docs/simbrief.md
new file mode 100644
index 0000000..b6b105d
--- /dev/null
+++ b/docs/simbrief.md
@@ -0,0 +1,75 @@
+# SimBrief
+
+[User guide](README.md) › SimBrief
+
+If you plan your flights with [SimBrief](https://www.simbrief.com/), OrthoStreamXP can build your flight's scenery before X-Plane needs it. It is optional and off until you enter your SimBrief username.
+
+## What it does
+
+When streaming starts and while you sit at the gate, OrthoStreamXP builds the scenery of your latest SimBrief flight plan ahead of time:
+
+1. **The departure airport, when streaming starts.** While X-Plane is still loading, OrthoStreamXP builds the photos of the scenery tiles X-Plane loads around your departure airport, each as sharp as X-Plane will want it from the gate. The view at the gate is sharp sooner.
+2. **The arrival and the climb, while the aircraft stands at the gate.** About 30 seconds after the aircraft has come to a stop at the departure airport, OrthoStreamXP starts on the arrival airport, then the climb from the departure to the top of climb. This uses the quiet time before pushback, at the lowest priority: what X-Plane needs now always comes first.
+3. **The alternate airport, if you switch it on.** It is rarely flown, so it is off unless you set `simbrief-alternate = on` (see [Settings in the ini file](#settings-in-the-ini-file)).
+
+When the aircraft rolls for takeoff (faster than 40 kt), whatever has not started yet is dropped and the normal scenery loading takes over. Photos already in the cache are never downloaded again.
+
+The gate and the takeoff roll are told by the aircraft's own ground speed and X-Plane's "on the ground" flag, so moving or lifting the camera in an outside view does not count as a takeoff.
+
+**With mip streaming off** only the departure is built, whole textures within `simbrief-radius-km` of the airport, and only as many as the background build queue takes at once (4 × `dds-build-workers`). These builds do not count against the download limit, are not in the per-phase summary in the log, and are not dropped on the takeoff roll. The arrival, the climb and the alternate need mip streaming.
+
+## Setting it up
+
+Enter your SimBrief username, or your Pilot ID (the number in your SimBrief account settings):
+
+- in the **SimBrief** step of the [first setup](first-setup.md#6-simbrief), or
+- later in **Settings › SimBrief**.
+
+Click **Test SimBrief connection** (in the setup or in Settings) to check that OrthoStreamXP finds your latest flight plan. Both places also have the **download limit per session** and **Also build the alternate airport**.
+
+## Why there is a download limit
+
+The photos come from free map services (Bing, Esri). They are made for looking at maps, not for bulk downloads, and a program that downloads too much can be slowed down or blocked. That would hit every OrthoStreamXP user, not only you. Building a whole flight ahead means thousands of photos before you need any of them, so the SimBrief prefetch gets a budget per streaming session. It also protects a metered or slow connection.
+
+When the limit is reached nothing breaks: the rest loads as usual while you fly. Your own flying never counts against it.
+
+| Your situation | Limit |
+|---|---|
+| Metered or mobile connection | 300 MB |
+| Slow connection | 500 MB |
+| A typical flight at High or Ultra | 1000 MB (default) |
+| Extra quality (about four times the downloads near airports), or the alternate airport on | 3000 MB |
+
+If the log says *the download budget ... is used up* on your flights, raise the limit a step. **No limit** (`0`) is not recommended.
+
+## On the Home page
+
+While **Build my SimBrief flight's scenery ahead** is on, the line below the buttons says what OrthoStreamXP knows about your flight: before the start that the plan is read then, after it the flight found (for example *Flight plan: EDDM → EGLL, generated 12 min ago*) or why there is none, and after a reload what the reload found.
+
+- **Build my SimBrief flight's scenery ahead.** Turn this off to skip SimBrief for one session. It can be changed while streaming is stopped and is on again the next time OrthoStreamXP opens. With **Start when OrthoStreamXP opens** on, streaming starts before you can turn it off.
+- **Reload SimBrief flight plan.** OrthoStreamXP reads your flight plan once, when streaming starts. If you generated it afterwards, or planned another flight, press this button: OrthoStreamXP asks SimBrief for your latest flight plan and builds its scenery. The text next to the button says what it found, for example *Loaded EDDM → EGLL, generated 12 min ago*. SimBrief is asked only when you press it, never on its own.
+
+## When the flight plan is skipped
+
+- **An old flight plan.** A plan generated more than 6 hours ago is taken for an earlier flight: its departure is not built. Generate a new one, then press **Reload SimBrief flight plan**.
+- **The aircraft is somewhere else.** If X-Plane places your aircraft more than 50 km from the plan's departure airport, the plan is not this flight's: the departure textures still waiting are dropped.
+- **The download limit.** The SimBrief prefetch downloads at most 1000 MB per streaming session (`simbrief-download-limit`). After that, the rest is left to the normal scenery loading. Your own flying never counts against the limit.
+
+The log says each time why something was skipped.
+
+## Settings in the ini file
+
+These are in `orthostreamxp.ini` (see [Settings](settings.md)):
+
+| Setting | What it does | Default |
+|---|---|---|
+| `simbrief-user` | Your SimBrief username or Pilot ID. Empty: off. | empty |
+| `simbrief-alternate` | `on`: build the alternate airport too, after the arrival. | `off` |
+| `simbrief-download-limit` | The most the SimBrief prefetch may download per streaming session, in MB. `0`: no limit. | `1000` |
+| `simbrief-radius-km` | With mip streaming off: how far around the departure airport textures are built at full detail, 1-50 km. | `10` |
+
+## Privacy
+
+OrthoStreamXP sends your SimBrief username (or Pilot ID) to simbrief.com over HTTPS, once when streaming starts and each time you press **Reload SimBrief flight plan**. Nothing else is sent. The username is never written to the log, the flight reports or the setup summary.
+
+It is stored in `orthostreamxp.ini` (`simbrief-user`). If you attach that file to a bug report, delete that line first.
diff --git a/mkdocs.yml b/mkdocs.yml
index 2d70127..2a258af 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -19,6 +19,7 @@ nav:
- installation.md
- first-setup.md
- flying.md
+ - simbrief.md
- settings.md
- cache.md
- packages.md
diff --git a/src/OrthoStreamXP/Configuration/MountSettings.cs b/src/OrthoStreamXP/Configuration/MountSettings.cs
index 5e09105..c4589ef 100644
--- a/src/OrthoStreamXP/Configuration/MountSettings.cs
+++ b/src/OrthoStreamXP/Configuration/MountSettings.cs
@@ -74,10 +74,20 @@ internal sealed record MountSettings
public bool ReadLog { get; init; }
/// Every texture and terrain file X-Plane asks for, and every build, in the log too (for a bug hunt).
public bool LogDetail { get; init; }
+ /// SimBrief username or Pilot ID: the latest flight plan's departure is prefetched (null: off).
+ public string? SimBriefUser { get; init; }
+ /// How far around the SimBrief departure textures are prefetched.
+ public double SimBriefRadiusKm { get; init; } = DefaultSimBriefRadiusKm;
+ /// The most the SimBrief prefetch may download in a session, in MB; 0: no limit.
+ public int SimBriefDownloadLimitMb { get; init; } = DefaultSimBriefDownloadLimitMb;
+ /// The ground pre-warm builds the alternate's scenery too (rarely flown: off unless chosen).
+ public bool SimBriefAlternate { get; init; }
public const int DefaultFileSystemThreads = 64;
public const double DefaultNearWait = 60, DefaultMaxWait = 6, DefaultLookaheadSeconds = 120;
public const int DefaultBuildMemoryMb = 512;
+ public const double DefaultSimBriefRadiusKm = 10;
+ public const int DefaultSimBriefDownloadLimitMb = 1000;
/// Reads and checks the mount options; a mistake is a in plain words.
/// False for the window: the log never goes to a console there.
@@ -153,6 +163,13 @@ public static MountSettings FromValues(SettingValues values, bool console = true
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");
+ string? simBriefUser = values.String("simbrief-user")?.Trim() is { Length: > 0 } user ? user : null;
+ double simBriefRadiusKm = values.Double("simbrief-radius-km", DefaultSimBriefRadiusKm);
+ if (simBriefRadiusKm is < 1 or > 50) throw new SettingsException("simbrief-radius-km must be between 1 and 50");
+ int simBriefDownloadLimitMb = values.Int("simbrief-download-limit", DefaultSimBriefDownloadLimitMb);
+ string alternateValue = values.String("simbrief-alternate") ?? "off";
+ bool simBriefAlternate = ParseOnOff(alternateValue) ?? throw new SettingsException($"simbrief-alternate must be on or off, not '{alternateValue}'");
+ if (simBriefDownloadLimitMb is < 0 or > 100_000) throw new SettingsException("simbrief-download-limit must be between 0 (no limit) and 100000");
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");
@@ -222,6 +239,10 @@ public static MountSettings FromValues(SettingValues values, bool console = true
CacheLimitBytes = (long)(cacheLimitGb * (1L << 30)),
ReadLog = readLog,
LogDetail = logDetail,
+ SimBriefUser = simBriefUser,
+ SimBriefRadiusKm = simBriefRadiusKm,
+ SimBriefDownloadLimitMb = simBriefDownloadLimitMb,
+ SimBriefAlternate = simBriefAlternate,
};
}
diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs
index 5c2973f..42824d5 100644
--- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs
+++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs
@@ -42,6 +42,10 @@ internal static class SettingsOptions
.Add("prefetch-radius-km", "Width either side of the predicted path, and the radius around a slow or stationary aircraft (default: 5 km)")
.Add("prefetch-lookahead-seconds", $"How far ahead to prefetch, as flying time at the current ground speed: 120 s is 7.4 km at 120 kt, 28 km at 450 kt (default: {MountSettings.DefaultLookaheadSeconds})")
.Add("prefetch-textures", "Maximum DDS files to queue per position update, nearest along the path first (default: 8)")
+ .Add("simbrief-user", "SimBrief username (or the numeric Pilot ID; all digits are taken as one). When streaming starts, the photos around the departure airport of your latest SimBrief flight plan are built before X-Plane loads them; while the aircraft stands at the gate, the arrival and the climb follow. With mip streaming every texture of the scenery tiles X-Plane loads there, each to the detail X-Plane will want; what the cache has already is skipped (default: none, off)")
+ .Add("simbrief-radius-km", $"With mip streaming off (or a package whose DSFs cannot be read): how far around the SimBrief departure airport textures are built ahead at full detail, 1-50 km (default: {MountSettings.DefaultSimBriefRadiusKm})")
+ .Add("simbrief-alternate", "on or off: while the aircraft stands at the gate, build the SimBrief flight plan's alternate airport too, after the arrival (rarely flown, so it seldom pays for its downloads; default: off)")
+ .Add("simbrief-download-limit", $"The most the SimBrief prefetch (departure and ground pre-warm) may download per streaming session, in MB; then the rest is left to load-ahead. It keeps the free map services from slowing down or blocking the program, and protects a metered connection. 0: no limit (default: {MountSettings.DefaultSimBriefDownloadLimitMb})")
.Add("level-change", "Fetch tiles at (zoom in the .ter/DDS file name + N), -4..2. -1 downloads a quarter of the tiles of 0; +1 four times as many (default: 0). Each level keeps its textures in its own cache subfolder. It also sets the texture size: 4096 px at level 0 and above, half per level below (-2: 1024, -1: 2048, 0 and +1: 4096)")
.Add("provisional-lod", "Cold-run speedup: if the full DDS is not ready within provisional-wait seconds, serve a same-size image built from tiles N zoom levels coarser (N=2 downloads 16 tiles instead of 256). 0 disables. Needs background-textures or track-aircraft (default: 2)")
.Add("provisional-wait", "Seconds to wait for the full-detail DDS before serving the provisional one (default: 2.0)")
diff --git a/src/OrthoStreamXP/Gui/MountController.cs b/src/OrthoStreamXP/Gui/MountController.cs
index 4e3d2db..811c3b1 100644
--- a/src/OrthoStreamXP/Gui/MountController.cs
+++ b/src/OrthoStreamXP/Gui/MountController.cs
@@ -87,6 +87,12 @@ public async Task RestartAsync()
await StartAsync().ConfigureAwait(true);
}
+ ///
+ /// Builds the SimBrief flight plan's departure ahead at the next start (when a SimBrief user is set). For this
+ /// window only, not saved: each time OrthoStreamXP opens it is on again.
+ ///
+ public bool DeparturePrefetch { get; set; } = true;
+
/// X-Plane was already running when the mount started, so it has not seen the photo scenery.
public bool XPlaneWasRunningAtStart { get; private set; }
@@ -95,6 +101,15 @@ public async Task RestartAsync()
public MountStatus? Status() => _session?.Status();
+ /// The running session uses SimBrief (a SimBrief user is set and the switch is on).
+ public bool UsesSimBrief => State == MountState.Running && _session is { UsesSimBrief: true };
+
+ /// Asks SimBrief for the latest flight plan again (the Home page's button); what came of it, or null without one.
+ public Task? ReloadFlightPlanAsync() => State == MountState.Running ? _session?.ReloadFlightPlanAsync() : null;
+
+ /// The flight plan the running session fetched as it started (Home's SimBrief line), or null without one.
+ public Task? FirstFlightPlanAsync() => State == MountState.Running ? _session?.FirstFlightPlanAsync() : null;
+
/// The flight report of the session stopped last in this window (null before one stops, or without a flight).
public Tuning.FlightReport? LastReport { get; private set; }
@@ -132,6 +147,11 @@ public async Task StartAsync()
Log.Configure(settings.LogOutput, settings.LogPath, settings.LogDetail);
if (settings.LogOutput.HasFlag(LogOutput.File)) Log.Info($"Log file: {settings.LogPath}{(settings.LogDetail ? " (detailed)" : "")}");
Log.Info($"Settings file: {path}");
+ if (!DeparturePrefetch && settings.SimBriefUser is not null)
+ {
+ settings = settings with { SimBriefUser = null };
+ Log.Info("SimBrief: the departure prefetch is off for this session");
+ }
return MountSession.Start(settings);
}).ConfigureAwait(true);
XPlaneWasRunningAtStart = SystemCheck.IsXPlaneRunning();
@@ -161,7 +181,7 @@ await Task.Run(async () =>
session.Dispose();
// The streamed textures' RAM copies were large arrays. An ordinary collection frees them but keeps the
// memory for reuse (3.9 GB stayed with an idle window); an aggressive one gives it back to Windows.
- GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true);
+ PrewarmMemory.GiveBack();
}).ConfigureAwait(true);
Log.Step("OrthoStreamXP stopped: the photo scenery is no longer shown to X-Plane");
}
diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs
index db47421..e38a7ae 100644
--- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs
+++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs
@@ -25,6 +25,10 @@ internal static class SettingNames
["scenery-packs"] = "Keep the photo scenery below airports",
["xplane-address"] = "X-Plane address",
["xplane-arguments"] = "X-Plane start options",
+ ["simbrief-user"] = "SimBrief departure prefetch",
+ ["simbrief-radius-km"] = "SimBrief departure prefetch",
+ ["simbrief-download-limit"] = "SimBrief prefetch download limit",
+ ["simbrief-alternate"] = "SimBrief alternate airport",
};
/// Settings the running streaming does not use: changing them never needs a restart.
diff --git a/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs
new file mode 100644
index 0000000..00db8f4
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs
@@ -0,0 +1,37 @@
+namespace OrthoStreamXP.Gui;
+
+///
+/// The SimBrief prefetch's explanations, shared by the setup's SimBrief step and the Settings page's SimBrief tab so
+/// both say the same.
+///
+internal static class SimBriefText
+{
+ /// What the download limit (simbrief-download-limit) does, in one line.
+ public const string LimitHelp =
+ "The most the prefetch may download per session. Beyond it, scenery loads as usual while you fly.";
+
+ /// Why there is a limit at all, and how to choose it.
+ public const string LimitWhy =
+ "Why a limit? The free map services can block heavy downloaders, which would hit every user. 1000 MB fits a typical flight; " +
+ "choose less on a metered connection, more with Extra quality or the alternate on.";
+
+ /// What the alternate switch (simbrief-alternate) does.
+ public const string AlternateHelp =
+ "At the gate, build the alternate airport too, after the arrival. Rarely flown, so off by default.";
+
+ /// The limits offered in the setup; a value set by hand in the file is added to them.
+ public static IReadOnlyList LimitChoices(int? current)
+ {
+ List choices =
+ [
+ new("300 MB: a metered or mobile connection", "300"),
+ new("500 MB: a slow connection", "500"),
+ new($"{MountSettings.DefaultSimBriefDownloadLimitMb} MB: a typical flight (recommended)", MountSettings.DefaultSimBriefDownloadLimitMb.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ new("3000 MB: Extra quality, or the alternate airport on", "3000"),
+ new("No limit (not recommended)", "0"),
+ ];
+ string? own = current?.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ if (own is not null && choices.All(c => c.Value != own)) choices.Insert(0, new Choice($"{own} MB (set in the settings file)", own));
+ return choices;
+ }
+}
diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
index f24dde6..4961dc0 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs
@@ -26,6 +26,9 @@ internal sealed class HomeViewModel : ObservableObject, IPage
private System.Windows.Media.ImageSource? _reportBackdrop;
private string? _reviewPath;
private Notice? _tuningNotice;
+ private bool _reloading;
+ /// The running session's flight plan, as fetched at the start or by the last reload; null until known.
+ private string? _planStatus;
private readonly AppUpdates _updates = new();
/// Looks for a new version 10 seconds after the window opens, then every 6 hours.
private readonly DispatcherTimer _updateTimer = new() { Interval = TimeSpan.FromSeconds(10) };
@@ -38,6 +41,7 @@ public HomeViewModel(MainViewModel main)
ChangeQuality = new RelayCommand(() => _main.Show(_main.Settings));
OpenLog = new RelayCommand(() => _main.Show(_main.LogPage));
RestartMount = new RelayCommand(() => _ = Mount.RestartAsync(), () => Mount.IsRunning);
+ ReloadFlightPlan = new RelayCommand(() => _ = ReloadFlightPlanAsync(), () => Mount.UsesSimBrief && !_reloading);
ApplyReview = new RelayCommand(Apply);
DismissReview = new RelayCommand(() =>
{
@@ -60,11 +64,13 @@ public HomeViewModel(MainViewModel main)
{
LoadReview();
LoadReport();
+ PlanStatus = null; // the next session starts with its own plan
}
if (Mount.State == MountState.Starting) _tuningNotice = null;
if (Mount.State == MountState.Running)
{
_hasRun = true;
+ _ = ShowFirstFlightPlanAsync();
_previous = null;
_mbits = _tilesPerSecond = 0;
_failureWindow.Clear();
@@ -149,6 +155,87 @@ public bool AutoStart
}
}
+ /// The SimBrief departure prefetch for the next start (this window only, not saved).
+ public bool DeparturePrefetch
+ {
+ get => Mount.DeparturePrefetch;
+ set
+ {
+ Mount.DeparturePrefetch = value;
+ Raise();
+ Raise(nameof(ShowFlightPlanStatus));
+ }
+ }
+
+ ///
+ /// "Reload SimBrief flight plan": asks SimBrief again whenever the pilot wants (generated the OFP after starting, or
+ /// planned another flight). Never automatic; disabled while a reload runs, so a double click sends one request.
+ ///
+ public ICommand ReloadFlightPlan { get; }
+
+ /// Streaming runs with a SimBrief flight plan to reload.
+ public bool ShowReloadFlightPlan => Mount.UsesSimBrief;
+
+ ///
+ /// Home's SimBrief line, shown whenever the SimBrief switch is on: before the start that the plan is read then,
+ /// while starting that SimBrief is asked, then the flight found ("Flight plan: EDDV → ENGM, generated 12 min ago.")
+ /// or what the last reload found.
+ ///
+ public string FlightPlanStatus => Mount.State switch
+ {
+ MountState.Stopped => "Your latest SimBrief flight plan is read when streaming starts.",
+ MountState.Running when !Mount.UsesSimBrief => "SimBrief is off for this run; the log says why.",
+ MountState.Running or MountState.Starting => _planStatus ?? "Asking SimBrief for your latest flight plan…",
+ _ => _planStatus ?? "",
+ };
+
+ public bool ShowFlightPlanStatus => ShowDeparturePrefetch && DeparturePrefetch && FlightPlanStatus.Length > 0;
+
+ private string? PlanStatus
+ {
+ set
+ {
+ _planStatus = value;
+ Raise(nameof(FlightPlanStatus));
+ Raise(nameof(ShowFlightPlanStatus));
+ }
+ }
+
+ /// Streaming started: shows the plan it fetched, unless a reload already said more.
+ private async Task ShowFirstFlightPlanAsync()
+ {
+ if (Mount.FirstFlightPlanAsync() is not { } first)
+ {
+ PlanStatus = null;
+ return;
+ }
+ string text = await first.ConfigureAwait(true);
+ if (Mount.State == MountState.Running && !_reloading && _planStatus is null) PlanStatus = text;
+ }
+
+ private async Task ReloadFlightPlanAsync()
+ {
+ if (Mount.ReloadFlightPlanAsync() is not { } reload) return;
+ _reloading = true;
+ RelayCommand.Refresh();
+ PlanStatus = "Asking SimBrief for your latest flight plan…";
+ try
+ {
+ PlanStatus = await reload.ConfigureAwait(true);
+ }
+ finally
+ {
+ _reloading = false;
+ RelayCommand.Refresh();
+ }
+ }
+
+ /// A SimBrief user is set, so there is a departure prefetch to turn off.
+ public bool ShowDeparturePrefetch { get; private set; }
+
+ /// The switch applies when streaming starts: it can be changed only while stopped.
+ public bool CanChangeDeparturePrefetch => Mount.State == MountState.Stopped;
+
public string StartStopText => Mount.State switch
{
MountState.Running => "Stop",
@@ -379,6 +466,9 @@ private void LoadSettingsSummary()
.Select(name => new PackageRow(name, xplane is null ? "" : Path.Join(xplane, "Custom Scenery", name)))];
}
Raise(nameof(PackagesCaption));
+ ShowDeparturePrefetch = !string.IsNullOrWhiteSpace(settings.Get("simbrief-user"));
+ Raise(nameof(ShowDeparturePrefetch));
+ Raise(nameof(ShowFlightPlanStatus));
}
// ---------------------------------------------------------------- the last flight's report
@@ -518,7 +608,8 @@ private void Update()
Notices = BuildNotices();
foreach (string name in new[]
{
- nameof(ShowFly), nameof(FlyText), nameof(FlyToolTip),
+ nameof(ShowFly), nameof(FlyText), nameof(FlyToolTip), nameof(CanChangeDeparturePrefetch), nameof(ShowReloadFlightPlan),
+ nameof(FlightPlanStatus), nameof(ShowFlightPlanStatus),
nameof(StartStopText), nameof(StartStopGlyph), nameof(IsBusy), nameof(StatusKind), nameof(StatusGlyph), nameof(StatusTitle),
nameof(StatusText), nameof(ShowGettingStarted), nameof(ShowStats), nameof(StatsTitle), nameof(TexturesValue), nameof(ReadyAheadValue),
nameof(ReadyAheadCaption), nameof(BuildingValue), nameof(SpeedValue), nameof(SpeedCaption), nameof(XPlaneTitle),
diff --git a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs
index 2fdac3b..0c7b9c2 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs
@@ -1,5 +1,4 @@
using System.Collections.ObjectModel;
-using System.Reflection;
using System.Windows.Input;
using OrthoStreamXP.Setup;
@@ -238,8 +237,7 @@ public static HttpClient CreateHttpClient()
{
Timeout = Timeout.InfiniteTimeSpan, // each download watches for silence itself
};
- string version = typeof(PackagesViewModel).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0";
- http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}");
+ http.DefaultRequestHeaders.UserAgent.ParseAdd(HttpIdentity.UserAgent);
return http;
}
}
diff --git a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs
index 813e0f2..9875c2b 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs
@@ -64,6 +64,22 @@ public SettingsViewModel(MainViewModel main)
"Added when OrthoStreamXP starts X-Plane (Start and fly), such as --allow_reshade. Takes effect the next time X-Plane is started.",
FieldKind.Text, placeholder: "--allow_reshade"),
];
+ SimBriefUser = Field("simbrief-user", "SimBrief username",
+ "Your username or Pilot ID. Your latest flight plan's departure, arrival and climb are built ahead. Empty: off.",
+ FieldKind.Text, placeholder: "username or Pilot ID");
+ SimBriefTest = new SimBriefTestViewModel(_main.MeshHttp, () => SimBriefUser.Value);
+ SimBriefUser.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(SettingField.Value)) SimBriefTest.Reset();
+ };
+ SimBrief =
+ [
+ Field("simbrief-download-limit", "Download limit per session",
+ SimBriefText.LimitHelp + " Enter the MB, from 0 (no limit) to 100000.",
+ FieldKind.Number, min: 0, max: 100_000),
+ Field("simbrief-alternate", "Also build the alternate airport", SimBriefText.AlternateHelp,
+ FieldKind.Toggle, on: "on", off: "off"),
+ ];
ConnectionTest = new ConnectionTestViewModel(
() => _document.Get("texture-provider") ?? "bing,esri",
() => _main.Mount.State == MountState.Stopped,
@@ -98,6 +114,15 @@ public SettingsViewModel(MainViewModel main)
public IReadOnlyList Presets { get; }
public IReadOnlyList Providers { get; }
public IReadOnlyList Other { get; }
+
+ /// The SimBrief tab's username; the test below it asks SimBrief with what is typed there.
+ public SettingField SimBriefUser { get; }
+
+ /// The SimBrief tab's "Test SimBrief connection".
+ public SimBriefTestViewModel SimBriefTest { get; }
+
+ /// The SimBrief tab's download limit and alternate airport.
+ public IReadOnlyList SimBrief { get; }
public ICommand BrowseXPlane { get; }
public ICommand BrowsePackages { get; }
public ICommand BrowseCache { get; }
@@ -503,7 +528,7 @@ public void Reload()
CustomLevelNote = QualityPreset.ForLevel(level) is null
? $"Your settings file uses level-change {level}, which is not one of these. Choosing one replaces it."
: null;
- foreach (SettingField field in Other)
+ foreach (SettingField field in Other.Append(SimBriefUser).Concat(SimBrief))
field.Load(_document.Get(field.Key), Default(field.Key));
ConfirmingReset = false;
foreach (string name in new[]
@@ -574,6 +599,8 @@ private SettingField Field(string key, string label, string help, FieldKind kind
private static string Default(string key) => key switch
{
"scenery-packs" => "on",
+ "simbrief-download-limit" => MountSettings.DefaultSimBriefDownloadLimitMb.ToString(CultureInfo.InvariantCulture),
+ "simbrief-alternate" => "off",
_ => "",
};
}
diff --git a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
index cabd626..61a3969 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Collections.ObjectModel;
using System.Windows.Input;
using OrthoStreamXP.Setup;
@@ -30,17 +31,21 @@ public void Show(bool selected)
///
/// The first-run setup: checks WinFsp, finds X-Plane, asks for the folder of the person's own scenery packages,
-/// and lets them pick a cache folder and a quality; then writes orthostreamxp.ini.
+/// lets them pick a cache folder and a quality, and optionally their SimBrief account (departure prefetch); then writes
+/// orthostreamxp.ini.
///
internal sealed class SetupViewModel : ObservableObject
{
- public static readonly string[] StepTitles = ["Welcome", "X-Plane", "Scenery", "Cache", "Quality", "Ready"];
+ public static readonly string[] StepTitles = ["Welcome", "X-Plane", "Scenery", "Cache", "Quality", "SimBrief", "Ready"];
private readonly MainViewModel _main;
private int _step;
private bool _winFsp;
private string? _xplane, _packages;
private string _cache;
+ private string _simBriefUser;
+ private Choice _simBriefLimit;
+ private bool _simBriefAlternate;
/// The cache folder came from the settings or the person: it no longer follows the packages folder.
private bool _cacheChosen;
private QualityPreset _preset = PresetMemory.Recommended.Preset;
@@ -71,6 +76,13 @@ public SetupViewModel(MainViewModel main)
QualityPreset recommended = PresetMemory.Recommended.Preset;
Preset = QualityPreset.ForLevel(existing.GetInt("level-change") ?? recommended.Level) ?? recommended;
IsRerun = existing.Get("cache") is not null;
+ _simBriefUser = existing.Get("simbrief-user") ?? "";
+ SimBriefTest = new SimBriefTestViewModel(_main.MeshHttp, () => SimBriefUser);
+ int? limit = existing.GetInt("simbrief-download-limit");
+ SimBriefLimits = SimBriefText.LimitChoices(limit);
+ string chosen = (limit ?? MountSettings.DefaultSimBriefDownloadLimitMb).ToString(CultureInfo.InvariantCulture);
+ _simBriefLimit = SimBriefLimits.First(c => c.Value == chosen);
+ _simBriefAlternate = existing.GetBool("simbrief-alternate") == true;
ConnectionTest = new ConnectionTestViewModel(
() => existing.Get("texture-provider") ?? "bing,esri",
() => _main.Mount.State == MountState.Stopped,
@@ -146,6 +158,8 @@ private void RegionInstalled(string folder)
public ObservableCollection XPlaneOptions { get; }
public ObservableCollection PackageOptions { get; }
public IReadOnlyList Presets { get; }
+ /// The SimBrief step's "Test SimBrief connection".
+ public SimBriefTestViewModel SimBriefTest { get; }
/// The connection test above the presets: the recommendation follows its speed.
public ConnectionTestViewModel ConnectionTest { get; }
public ICommand Back { get; }
@@ -275,6 +289,46 @@ public string CacheHint
}
}
+ /// SimBrief username or Pilot ID (optional): the departure of the latest flight plan is built ahead.
+ public string SimBriefUser
+ {
+ get => _simBriefUser;
+ set
+ {
+ if (!Set(ref _simBriefUser, value?.Trim() ?? "")) return;
+ SimBriefTest.Reset();
+ Raise(nameof(HasSimBriefUser));
+ Raise(nameof(Summary));
+ }
+ }
+
+ /// The limit and the alternate apply only with a username.
+ public bool HasSimBriefUser => SimBriefUser.Length > 0;
+
+ /// The SimBrief step's download limits; a value set by hand in the file is one of them.
+ public IReadOnlyList SimBriefLimits { get; }
+
+ /// The most the SimBrief prefetch may download per session (simbrief-download-limit).
+ public Choice SimBriefLimit
+ {
+ get => _simBriefLimit;
+ set
+ {
+ if (value is not null && Set(ref _simBriefLimit, value)) Raise(nameof(Summary));
+ }
+ }
+
+ /// Build the alternate airport too (simbrief-alternate).
+ public bool SimBriefAlternate
+ {
+ get => _simBriefAlternate;
+ set
+ {
+ if (Set(ref _simBriefAlternate, value)) Raise(nameof(Summary));
+ }
+ }
+
+
public bool StartNow
{
get => _startNow;
@@ -283,7 +337,10 @@ public bool StartNow
public string Summary =>
$"X-Plane: {_xplane}\nScenery packages: {string.Join(", ", SystemCheck.PackagesIn(_packages))} in {_packages}\n" +
- $"Quality: {Preset.Name}\nCache: {CacheFolder}";
+ $"Quality: {Preset.Name}\nCache: {CacheFolder}\n" +
+ $"SimBrief: {(SimBriefUser.Length > 0 ? $"on (your flight's scenery is built ahead, {LimitShort}{(SimBriefAlternate ? ", with the alternate" : "")})" : "not used")}";
+
+ private string LimitShort => SimBriefLimit.Value == "0" ? "no download limit" : $"up to {SimBriefLimit.Value} MB per session";
public string? Error
{
@@ -373,6 +430,11 @@ private async Task FinishAsync()
settings = SettingsDocument.CreateNew(path, _xplane!, _packages!, CacheFolder, Preset);
}
if (_testedWorkers is { } workers) settings.Set("download-workers", workers);
+ settings.Set("simbrief-user", SimBriefUser.Length > 0 ? SimBriefUser : null);
+ // Defaults stay out of the file, so a later default applies.
+ bool defaultLimit = SimBriefLimit.Value == MountSettings.DefaultSimBriefDownloadLimitMb.ToString(CultureInfo.InvariantCulture);
+ settings.Set("simbrief-download-limit", defaultLimit ? null : SimBriefLimit.Value);
+ settings.Set("simbrief-alternate", SimBriefAlternate ? "on" : null);
Directory.CreateDirectory(CacheFolder);
settings.Save();
Log.Info($"Setup saved the settings to {path}");
diff --git a/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs
new file mode 100644
index 0000000..4ed9891
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs
@@ -0,0 +1,104 @@
+using System.Windows.Input;
+using OrthoStreamXP.Scenery;
+
+namespace OrthoStreamXP.Gui;
+
+///
+/// "Test SimBrief connection": fetches the latest flight plan of the username entered and says which flight it found,
+/// or why there is none, so a typo shows before the first flight.
+///
+internal sealed class SimBriefTestViewModel : ObservableObject
+{
+ private readonly SimBriefClient _client;
+ private readonly Func _user;
+ private bool _isRunning;
+ private string? _status;
+ private bool _failed;
+ private bool _succeeded;
+
+ /// The username or Pilot ID entered now.
+ public SimBriefTestViewModel(HttpClient http, Func user)
+ {
+ _client = new SimBriefClient(http);
+ _user = user;
+ Run = new RelayCommand(() => _ = RunAsync(), () => !IsRunning && !string.IsNullOrWhiteSpace(_user()));
+ }
+
+ public ICommand Run { get; }
+
+ public bool IsRunning
+ {
+ get => _isRunning;
+ private set
+ {
+ if (!Set(ref _isRunning, value)) return;
+ Raise(nameof(RunText));
+ RelayCommand.Refresh();
+ }
+ }
+
+ public string RunText => IsRunning ? "Testing…" : "Test SimBrief connection";
+
+ /// The flight found, or why there is none; null before a test.
+ public string? Status
+ {
+ get => _status;
+ private set => Set(ref _status, value);
+ }
+
+ /// The last test found no flight plan (shown in red).
+ public bool Failed
+ {
+ get => _failed;
+ private set => Set(ref _failed, value);
+ }
+
+ /// The last test found a flight plan (shown in green).
+ public bool Succeeded
+ {
+ get => _succeeded;
+ private set => Set(ref _succeeded, value);
+ }
+
+ /// The username changed: the last result no longer applies.
+ public void Reset()
+ {
+ Status = null;
+ Failed = false;
+ Succeeded = false;
+ RelayCommand.Refresh();
+ }
+
+ private async Task RunAsync()
+ {
+ IsRunning = true;
+ Status = "Asking SimBrief for your latest flight plan…";
+ Failed = false;
+ Succeeded = false;
+ try
+ {
+ SimBriefAnswer answer = await _client.FetchAsync(_user() ?? "", CancellationToken.None).ConfigureAwait(true);
+ Failed = answer.Origin is null;
+ Succeeded = !Failed;
+ Status = Describe(answer);
+ }
+#pragma warning disable CA1031 // The click's task is not awaited: an unexpected failure must show and be logged, not vanish.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ Log.Warning($"SimBrief connection test failed unexpectedly ({ex.GetType().Name}: {ex.Message})");
+ Failed = true;
+ Status = "The test failed unexpectedly; see the log.";
+ }
+ finally
+ {
+ IsRunning = false;
+ }
+ }
+
+ internal static string Describe(SimBriefAnswer answer)
+ {
+ if (answer.Plan is not { } plan) return answer.NoPlan;
+ return $"Connected. Your latest flight plan is {plan.Describe()}.";
+ }
+}
diff --git a/src/OrthoStreamXP/Gui/Views/HomeView.xaml b/src/OrthoStreamXP/Gui/Views/HomeView.xaml
index f4b40e2..a591377 100644
--- a/src/OrthoStreamXP/Gui/Views/HomeView.xaml
+++ b/src/OrthoStreamXP/Gui/Views/HomeView.xaml
@@ -86,13 +86,14 @@
-
+
+
-
+
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml
index c71f436..2633018 100644
--- a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml
+++ b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml
@@ -1,6 +1,7 @@
@@ -534,6 +535,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/SetupView.xaml b/src/OrthoStreamXP/Gui/Views/SetupView.xaml
index e61585f..afc5ee4 100644
--- a/src/OrthoStreamXP/Gui/Views/SetupView.xaml
+++ b/src/OrthoStreamXP/Gui/Views/SetupView.xaml
@@ -71,7 +71,7 @@
-
+
@@ -273,7 +273,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrthoStreamXP/Infrastructure/HttpIdentity.cs b/src/OrthoStreamXP/Infrastructure/HttpIdentity.cs
new file mode 100644
index 0000000..875d7d4
--- /dev/null
+++ b/src/OrthoStreamXP/Infrastructure/HttpIdentity.cs
@@ -0,0 +1,16 @@
+using System.Reflection;
+
+namespace OrthoStreamXP;
+
+///
+/// How OrthoStreamXP names itself to web servers: one User-Agent, orthostreamxp/<version>, for the tile
+/// downloads, the connection test, the mesh downloads and SimBrief, so a provider sees one program at one version.
+///
+internal static class HttpIdentity
+{
+ /// The app's version without the build's commit ("1.3.0-rc.8"); "0" when the assembly has none.
+ public static string Version { get; } =
+ typeof(HttpIdentity).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0";
+
+ public static string UserAgent { get; } = $"orthostreamxp/{Version}";
+}
diff --git a/src/OrthoStreamXP/Infrastructure/PrewarmMemory.cs b/src/OrthoStreamXP/Infrastructure/PrewarmMemory.cs
new file mode 100644
index 0000000..ce74783
--- /dev/null
+++ b/src/OrthoStreamXP/Infrastructure/PrewarmMemory.cs
@@ -0,0 +1,131 @@
+using System.Diagnostics;
+using OrthoStreamXP.Scenery;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP;
+
+///
+/// Gives back the memory the ground pre-warm's builds left behind. In the EDDV to ENGM flight the working set stayed at
+/// 4 GB (3.5 GB of it the large-object heap: build buffers) for the 24 minutes from the end of the pre-warm to the
+/// takeoff, while the streamer held 0.9 GB of textures. One compacting collection once every package's pre-warm is
+/// built, while the aircraft still stands at the gate, gives that back; at most once a session, off the warmer's thread.
+///
+/// The collection stops every thread of the process for its length, the file-system threads serving X-Plane included
+/// (R1). So it waits until it is quiet: no build running in any package and no read from X-Plane for
+/// (10 s). If that does not happen within (10 min), or the aircraft rolls or new
+/// pre-warm work comes first, it is skipped (and may run after the next pre-warm). The pause is logged, and the working
+/// set again a little later, to see what the next builds take back.
+///
+///
+internal sealed class PrewarmMemory
+{
+ /// How long it waits for quiet, for how long, how often it looks, and when it logs the working set again.
+ internal sealed record Timing(TimeSpan QuietFor, TimeSpan GiveUpAfter, TimeSpan Poll, TimeSpan LookAgainAfter)
+ {
+ public static readonly Timing Default = new(TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
+ }
+
+ private readonly Timing _timing;
+
+ private readonly IReadOnlyList _warmers;
+ private readonly TimeProvider _clock;
+ private readonly CancellationToken _stop;
+ private readonly Action _giveBack;
+ private int _state; // 0 waiting for the pre-warm, 1 waiting for quiet, 2 given back
+
+ internal PrewarmMemory(IReadOnlyList warmers, CancellationToken stop, Timing? timing = null, Action? giveBack = null, TimeProvider? clock = null)
+ {
+ _timing = timing ?? Timing.Default;
+ _warmers = warmers;
+ _stop = stop;
+ _clock = clock ?? TimeProvider.System;
+ _giveBack = giveBack ?? GiveBack;
+ }
+
+ /// Compacts once every one of has built its ground pre-warm and it is quiet.
+ public static void Watch(IReadOnlyList warmers, CancellationToken stop)
+ {
+ var memory = new PrewarmMemory(warmers, stop);
+ foreach (MipWarmer warmer in warmers) warmer.GroundDone += memory.OnGroundDone;
+ }
+
+ /// The task waiting for quiet, once the pre-warm is built (tests await it).
+ internal Task Waiting { get; private set; } = Task.CompletedTask;
+
+ internal void OnGroundDone()
+ {
+ if (_warmers.Any(w => w.GroundPending)) return; // another package still builds
+ if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) return;
+ Waiting = Task.Run(WhenQuietAsync);
+ }
+
+ private async Task WhenQuietAsync()
+ {
+ try
+ {
+ if (await QuietAsync().ConfigureAwait(false) is not { } quiet)
+ {
+ Interlocked.Exchange(ref _state, 0); // the next pre-warm may try again
+ return;
+ }
+ long before = Environment.WorkingSet, heap = GC.GetTotalMemory(false);
+ TimeSpan paused = GC.GetTotalPauseDuration();
+ long started = Stopwatch.GetTimestamp();
+ _giveBack();
+ TimeSpan took = Stopwatch.GetElapsedTime(started), pause = GC.GetTotalPauseDuration() - paused;
+ Interlocked.Exchange(ref _state, 2);
+ Log.Info($"Ground pre-warm: everything is built and X-Plane read nothing for {quiet.TotalSeconds:0} s; unused build memory given back " +
+ $"(working set {CacheInventory.FormatBytes(before)} -> {CacheInventory.FormatBytes(Environment.WorkingSet)}, " +
+ $".NET heap {CacheInventory.FormatBytes(heap)} -> {CacheInventory.FormatBytes(GC.GetTotalMemory(false))}; " +
+ $"every thread paused {pause.TotalMilliseconds:0} ms, {took.TotalMilliseconds:0} ms in all)");
+ long readsThen = Reads(), buildsThen = Builds();
+ await Task.Delay(_timing.LookAgainAfter, _clock, _stop).ConfigureAwait(false);
+ Log.Info($"Ground pre-warm: {_timing.LookAgainAfter.TotalSeconds:0} s after the memory was given back: working set {CacheInventory.FormatBytes(Environment.WorkingSet)}, " +
+ $"{Reads() - readsThen} reads from X-Plane and {Builds() - buildsThen} builds since");
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested)
+ {
+ // Streaming stopped: the stop gives the memory back anyway.
+ }
+ }
+
+ ///
+ /// Waits until nothing builds and X-Plane has read nothing for ; returns how long it was quiet,
+ /// or null when it gave up (new pre-warm work, or no quiet within ).
+ ///
+ private async Task QuietAsync()
+ {
+ DateTimeOffset start = _clock.GetUtcNow(), quietSince = start;
+ long reads = Reads();
+ while (_clock.GetUtcNow() - start < _timing.GiveUpAfter)
+ {
+ await Task.Delay(_timing.Poll, _clock, _stop).ConfigureAwait(false);
+ DateTimeOffset now = _clock.GetUtcNow();
+ if (_warmers.Any(w => w.GroundPending)) return Skip("new pre-warm work came first");
+ long readsNow = Reads();
+ if (readsNow != reads || _warmers.Any(w => w.Building))
+ {
+ (reads, quietSince) = (readsNow, now);
+ continue;
+ }
+ if (now - quietSince >= _timing.QuietFor) return now - quietSince;
+ }
+ return Skip($"X-Plane kept reading or building for {_timing.GiveUpAfter.TotalMinutes:0} min");
+ }
+
+ private static TimeSpan? Skip(string why)
+ {
+ Log.Info($"Ground pre-warm: the build memory is not given back now ({why}): the collection would pause X-Plane's reads");
+ return null;
+ }
+
+ private long Reads() => _warmers.Sum(w => w.XPlaneReads);
+
+ private long Builds() => _warmers.Sum(w => (long)w.Queued);
+
+ ///
+ /// The large build and texture arrays: an ordinary collection frees them but keeps the memory for reuse; an aggressive
+ /// one gives it back to Windows (also used when streaming stops).
+ ///
+ public static void GiveBack() => GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true);
+}
diff --git a/src/OrthoStreamXP/Mounting/MountSession.cs b/src/OrthoStreamXP/Mounting/MountSession.cs
index 4122321..8b9854b 100644
--- a/src/OrthoStreamXP/Mounting/MountSession.cs
+++ b/src/OrthoStreamXP/Mounting/MountSession.cs
@@ -33,6 +33,9 @@ internal sealed class MountSession : IDisposable
/// The night textures' painting, shared by every package.
private NightPainting? _nightPainting;
private Task? _summaryLoop, _sampleLoop;
+ private HttpClient? _simBrief;
+ /// The SimBrief side (plan, departure prefetch, ground pre-warm, reload); told every position X-Plane sends.
+ private volatile SimBriefSession? _simBriefSession;
private bool _stopped;
private MountSession(MountSettings settings)
@@ -173,6 +176,7 @@ private void StartAll()
{
_view.Update(camera);
readLog?.Camera(camera);
+ _simBriefSession?.OnCamera(camera);
},
streaming ? model.UseScreen : null,
seasons is null ? null : day => seasons.OfferDate(day, _view.Starting), _daylight);
@@ -193,10 +197,62 @@ private void StartAll()
mount.Warmer?.Start();
}
_listener?.Start();
+ StartDeparturePrefetch();
_summaryLoop = LogSummariesAsync(_summaries.Token);
_sampleLoop = SampleAsync(_summaries.Token);
}
+ ///
+ /// With a SimBrief user set: builds the textures around the latest flight plan's departure airport while X-Plane
+ /// is still starting, skipping the ones in the cache. Runs in the background; a failure is only logged.
+ ///
+ private void StartDeparturePrefetch()
+ {
+ if (_settings.SimBriefUser is not { } user) return;
+ DepartureTarget[] targets = [.. _mounts
+ .Select(m => (Mount: m, Prefetch: m.Warmer is { } warmer ? warmer.Prefetch : m.Scheduler is { } scheduler ? scheduler.Prefetch : (Func, int>?)null))
+ .Where(m => m.Prefetch is not null)
+ .Select(m => new DepartureTarget(m.Mount.Plan.Name, m.Mount.Plan.Source, path => m.Mount.Roots.CachedFile(path) is not null, m.Prefetch!,
+ m.Mount.Warmer is { } warmer ? (textures, lat, lon) => warmer.PrefetchAround(textures, lat, lon) : null))];
+ if (targets.Length == 0)
+ {
+ Log.Info("SimBrief: departure prefetch needs an imagery provider and load-ahead (or aircraft tracking); it is off for this run");
+ return;
+ }
+ if (_mounts.All(m => m.Warmer is null))
+ Log.Info("SimBrief: mip streaming is off, so only the departure is built (whole textures, as many as the background queue takes); " +
+ "not counted against the download limit and not dropped on the takeoff roll");
+ _simBrief = new HttpClient();
+ var client = new SimBriefClient(_simBrief);
+ AircraftPositionListener? listener = _listener;
+ PrefetchBudget? budget = null;
+ if (_settings.SimBriefDownloadLimitMb > 0)
+ {
+ budget = new PrefetchBudget(_settings.SimBriefDownloadLimitMb * 1024L * 1024);
+ foreach (PackageMount mount in _mounts) mount.Warmer?.UseBudget(budget);
+ }
+ // The ground pre-warm needs X-Plane's positions.
+ GroundTarget[] ground = listener is null ? [] : [.. _mounts.Where(m => m.Warmer is not null)
+ .Select(m => (Mount: m, Warmer: m.Warmer!))
+ .Select(m => new GroundTarget(m.Mount.Plan.Name, m.Mount.Plan.Source, m.Warmer.PrefetchAround, m.Warmer.PrefetchGround, m.Warmer.CancelGround,
+ m.Warmer.CancelDeparture, m.Warmer.RunningSummary))];
+ if (ground.Length > 0) PrewarmMemory.Watch([.. _mounts.Select(m => m.Warmer).OfType()], _summaries.Token);
+ var simBrief = new SimBriefSession(ct => client.FetchAsync(user, ct), targets, ground,
+ () => listener?.Latest is { } at ? (at.Lat, at.Lon) : null, _settings.SimBriefRadiusKm,
+ alternate: _settings.SimBriefAlternate, stop: _summaries.Token, budget: budget);
+ _simBriefSession = simBrief;
+ simBrief.Start();
+ }
+
+ /// Whether this session uses SimBrief (a SimBrief user is set and the prefetch is on).
+ public bool UsesSimBrief => _simBriefSession is not null;
+
+ /// The Home page's "Reload SimBrief flight plan": what came of it, in the window's words; null without SimBrief.
+ public Task? ReloadFlightPlanAsync() => _stopped ? null : _simBriefSession?.ReloadAsync();
+
+ /// The flight plan fetched as streaming started, in the window's words; null without SimBrief.
+ public Task? FirstFlightPlanAsync() => _stopped ? null : _simBriefSession?.DescribeFirstAsync();
+
/// The live numbers of every package together.
public MountStatus Status()
{
@@ -249,6 +305,9 @@ private async Task StopAsync(bool hurry, bool summaries)
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");
+ if (mount.Warmer is { } warmer)
+ foreach (PrefetchMeter phase in warmer.Phases)
+ if (phase.Summary() is { } line) Log.Info(label + line);
}
Log.Info(_cpu.Summary());
ReviewFlight();
@@ -267,22 +326,16 @@ private async Task StopAsync(bool hurry, bool summaries)
else
await finished.ConfigureAwait(false);
}
+ // SimBrief's departure prefetch, reload and ground pre-warm queue into the packages' load-ahead: they end before
+ // the packages are disposed. A failure there is a bug, but only logged: the listener and the packages must still go.
foreach (Task? loop in new[] { _summaryLoop, _sampleLoop })
- {
- if (loop is null) continue;
- try
- {
- await loop.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Stopped.
- }
- }
+ await EndAsync(loop, "").ConfigureAwait(false);
+ await EndAsync(_simBriefSession?.WhenDone(), "SimBrief prefetch").ConfigureAwait(false);
_listener?.Dispose();
foreach (PackageMount mount in _mounts) mount.Dispose();
_nightPainting?.Dispose();
_tiles?.Dispose();
+ _simBrief?.Dispose();
_readLog?.Dispose();
_summaries.Dispose();
// Every file is closed now: the cache can be kept within its limit.
@@ -321,6 +374,26 @@ private NightPainting StartNightLights(MountSettings s)
return _daylight;
}
+ /// Waits for a background loop to end at stop; a failure of is logged, not thrown.
+ private static async Task EndAsync(Task? loop, string what)
+ {
+ if (loop is null) return;
+ try
+ {
+ await loop.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Stopped.
+ }
+#pragma warning disable CA1031 // Stopping must finish whatever the SimBrief side threw: it is logged, then the rest is disposed.
+ catch (Exception ex) when (what.Length > 0)
+#pragma warning restore CA1031
+ {
+ Log.Warning($"{what} failed while streaming stopped ({ex.GetType().Name}: {ex.Message}); stopping carries on");
+ }
+ }
+
/// "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 df761f3..80a4d8a 100644
--- a/src/OrthoStreamXP/Mounting/PackageMount.cs
+++ b/src/OrthoStreamXP/Mounting/PackageMount.cs
@@ -21,6 +21,7 @@ public PackageMount(PackagePlan plan, SceneryRoots roots, TextureBuilder? builde
SeasonClock? seasons, AreaUsage? usage, ColourMatcher? colours, SceneryLoads loads, ushort[]? exposure, NightTextures? night)
{
Plan = plan;
+ Roots = roots;
Streamer = streamer;
Scheduler = builder is null || streamer is not null ? null : new TextureScheduler(roots, builder, textureOptions);
Catalog = builder is not null && (trackAircraft || loadAhead) ? new TextureCatalog() : null;
@@ -39,6 +40,7 @@ public PackageMount(PackagePlan plan, SceneryRoots roots, TextureBuilder? builde
}
public PackagePlan Plan { get; }
+ public SceneryRoots Roots { get; }
public TextureScheduler? Scheduler { get; }
public TextureStreamer? Streamer { get; }
public TextureCatalog? Catalog { get; }
diff --git a/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs b/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs
index cad0299..e5ad39f 100644
--- a/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs
+++ b/src/OrthoStreamXP/Scenery/Aircraft/AircraftPositionListener.cs
@@ -11,8 +11,11 @@ internal sealed record PositionOptions(int Port, IPEndPoint XPlane, double Radiu
/// A mounted package's loaded textures, and how to queue the ones ahead of the aircraft.
internal sealed record PrefetchTarget(TextureCatalog Catalog, Func, int> Prefetch);
-/// The latest aircraft position X-Plane sent; is null until it is known.
-internal sealed record AircraftReport(double Lat, double Lon, double? GroundSpeedKt, DateTime ReceivedUtc);
+///
+/// The latest aircraft position X-Plane sent. : X-Plane's own, else worked out from the
+/// positions; null until it is known. : X-Plane's on-ground flag; null when it does not send it.
+///
+internal sealed record AircraftReport(double Lat, double Lon, double? GroundSpeedKt, DateTime ReceivedUtc, bool? OnGround = null);
///
/// Follows the aircraft and prefetches the textures of the loaded scenery along its predicted path.
@@ -96,7 +99,7 @@ private void Subscribe(int timesPerSecond)
foreach (var (dataref, index) in XPlaneUdp.CameraDatarefs)
{
// The screen and field of view change rarely: once a second is plenty.
- int rate = timesPerSecond == 0 ? 0 : index >= XPlaneUdp.FieldOfViewIndex ? 1 : timesPerSecond;
+ int rate = timesPerSecond == 0 ? 0 : XPlaneUdp.Rare(index) ? 1 : timesPerSecond;
_socket.SendTo(XPlaneUdp.SubscribeRequest(dataref, index, rate), options.XPlane);
}
}
@@ -157,8 +160,10 @@ private void OnPosition(double lat, double lon)
onPosition?.Invoke(lat, lon);
TimeSpan now = _clock.Elapsed;
bool jumped = _motion.Add(now, lat, lon);
- _latest = new AircraftReport(lat, lon, _motion.Current?.SpeedMps * 1.943844, DateTime.UtcNow);
- onCamera?.Invoke(Camera(lat, lon, jumped));
+ double? xplaneKt = GroundSpeedKt();
+ bool? onGround = OnGround();
+ _latest = new AircraftReport(lat, lon, xplaneKt ?? _motion.Current?.SpeedMps * GroundWatch.KnotsPerMps, DateTime.UtcNow, onGround);
+ onCamera?.Invoke(Camera(lat, lon, jumped, xplaneKt, onGround));
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))
@@ -191,11 +196,18 @@ private void OnPosition(double lat, double lon)
private float? Value(int index) => _camera[index - XPlaneUdp.ElevationIndex];
+ /// X-Plane's ground speed in knots; null until it sends one.
+ private double? GroundSpeedKt() => Value(XPlaneUdp.GroundSpeedIndex) is { } mps && mps >= 0 ? mps * GroundWatch.KnotsPerMps : null;
+
+ /// X-Plane's on-ground flag; null until it sends one.
+ private bool? OnGround() => Value(XPlaneUdp.OnGroundIndex) is { } flag ? flag >= 0.5f : null;
+
///
/// The camera: the aircraft position moved by the view's offset from the aircraft (x east, z south), its height
/// the aircraft's above the ground plus the view's above the aircraft. Without those values, the aircraft itself.
+ /// The aircraft's ground speed and on-ground flag go with it, as X-Plane sent them (null when it did not).
///
- private CameraReport Camera(double lat, double lon, bool jumped)
+ private CameraReport Camera(double lat, double lon, bool jumped, double? groundSpeedKt, bool? onGround)
{
double east = 0, north = 0, up = 0;
int local = XPlaneUdp.LocalXIndex, view = XPlaneUdp.ViewXIndex;
@@ -204,7 +216,8 @@ private CameraReport Camera(double lat, double lon, bool jumped)
(east, up, north) = (vx - ax, vy - ay, -(vz - az));
double camLat = lat + north / 111_320.0, camLon = lon + east / (111_320.0 * Math.Max(0.01, Math.Cos(lat * Math.PI / 180)));
double height = Math.Max(0, (Value(XPlaneUdp.HeightAglIndex) ?? 0) + up);
- return new CameraReport(camLat, camLon, height, Value(XPlaneUdp.ElevationIndex), _motion.Current, jumped, DateTime.UtcNow);
+ return new CameraReport(camLat, camLon, height, Value(XPlaneUdp.ElevationIndex), _motion.Current, jumped, DateTime.UtcNow,
+ GroundSpeedKt: groundSpeedKt, OnGround: onGround);
}
/// A position from an answer to our subscription, or from Data Output row 20.
diff --git a/src/OrthoStreamXP/Scenery/Aircraft/DsfTerrain.cs b/src/OrthoStreamXP/Scenery/Aircraft/DsfTerrain.cs
new file mode 100644
index 0000000..ca4ffb2
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Aircraft/DsfTerrain.cs
@@ -0,0 +1,75 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+
+namespace OrthoStreamXP.Scenery;
+
+/// Reads the terrain definitions a DSF uses (its TERT table), without reading the rest of the file.
+internal static class DsfTerrain
+{
+ /// A DSF's path in a package: Earth nav data\+50+000\+52+009.dsf.
+ public static string PathIn(string packageSource, int lat, int lon)
+ {
+ static int Floor10(int v) => (int)Math.Floor(v / 10.0) * 10;
+ static string Signed(int v, int digits) => (v < 0 ? "-" : "+") + Math.Abs(v).ToString(new string('0', digits), CultureInfo.InvariantCulture);
+ return Path.Join(packageSource, "Earth nav data", Signed(Floor10(lat), 2) + Signed(Floor10(lon), 3), Signed(lat, 2) + Signed(lon, 3) + ".dsf");
+ }
+
+ private static readonly byte[] Magic = "XPLNEDSF"u8.ToArray();
+ private const int HeaderSize = 12, AtomHeader = 8;
+ // Atom IDs are four characters stored as a little-endian integer, so they read backwards on disk.
+ private const uint Definitions = 'D' << 24 | 'E' << 16 | 'F' << 8 | 'N', TerrainTable = 'T' << 24 | 'E' << 16 | 'R' << 8 | 'T';
+
+ ///
+ /// The TERT names ("terrain/21552_34576_BI16.ter", "terrain_Water", ...), or null when the file is not an
+ /// uncompressed DSF (a 7-Zip DSF X-Plane unpacks itself) or cannot be read.
+ ///
+ public static IReadOnlyList? Read(string path)
+ {
+ try
+ {
+ using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.RandomAccess);
+ Span header = stackalloc byte[HeaderSize];
+ if (file.Read(header) < HeaderSize || !header[..Magic.Length].SequenceEqual(Magic)) return null;
+ long end = file.Length - 16; // the MD5 footer
+ for (long at = HeaderSize; at + AtomHeader <= end;)
+ {
+ var (id, length) = AtomAt(file, at);
+ if (length < AtomHeader) return null;
+ if (id == Definitions) return TerrainIn(file, at + AtomHeader, Math.Min(at + length, end));
+ at += length;
+ }
+ return [];
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static IReadOnlyList? TerrainIn(FileStream file, long start, long end)
+ {
+ for (long at = start; at + AtomHeader <= end;)
+ {
+ var (id, length) = AtomAt(file, at);
+ if (length < AtomHeader || at + length > end) return null;
+ if (id == TerrainTable)
+ {
+ byte[] table = new byte[length - AtomHeader];
+ file.Position = at + AtomHeader;
+ file.ReadExactly(table);
+ return [.. Encoding.UTF8.GetString(table).Split('\0', StringSplitOptions.RemoveEmptyEntries)];
+ }
+ at += length;
+ }
+ return [];
+ }
+
+ private static (uint Id, long Length) AtomAt(FileStream file, long at)
+ {
+ Span atom = stackalloc byte[AtomHeader];
+ file.Position = at;
+ file.ReadExactly(atom);
+ return (BinaryPrimitives.ReadUInt32LittleEndian(atom), BinaryPrimitives.ReadUInt32LittleEndian(atom[4..]));
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Aircraft/SceneryLoads.cs b/src/OrthoStreamXP/Scenery/Aircraft/SceneryLoads.cs
index b4e32ea..d4c785a 100644
--- a/src/OrthoStreamXP/Scenery/Aircraft/SceneryLoads.cs
+++ b/src/OrthoStreamXP/Scenery/Aircraft/SceneryLoads.cs
@@ -35,6 +35,21 @@ internal sealed class SceneryLoads(ViewTracker view)
private readonly Lock _gate = new();
private readonly Dictionary _loaded = [];
+ ///
+ /// The DSF tiles X-Plane 12 loads around a place: three rows (the place's and one either side) by four columns,
+ /// leaning towards the nearer half of the place's column (52.46°N 9.68°E: latitudes 51-53, longitudes 8-11).
+ ///
+ public static IReadOnlyList<(int Lat, int Lon)> TilesAround(double lat, double lon)
+ {
+ int row = (int)Math.Floor(lat), col = (int)Math.Floor(lon);
+ int west = lon - col >= 0.5 ? col - 1 : col - 2;
+ var tiles = new List<(int, int)>(12);
+ for (int r = row - 1; r <= row + 1; r++)
+ for (int c = west; c <= west + 3; c++)
+ if (r is >= -90 and < 90) tiles.Add((r, ((c + 180) % 360 + 360) % 360 - 180));
+ return tiles;
+ }
+
/// Notes a file X-Plane opened; a DSF not opened in the last minutes is logged.
public void Opened(VirtualPath path, string package)
{
diff --git a/src/OrthoStreamXP/Scenery/Aircraft/ViewTracker.cs b/src/OrthoStreamXP/Scenery/Aircraft/ViewTracker.cs
index 54a032c..67615c2 100644
--- a/src/OrthoStreamXP/Scenery/Aircraft/ViewTracker.cs
+++ b/src/OrthoStreamXP/Scenery/Aircraft/ViewTracker.cs
@@ -6,7 +6,7 @@ namespace OrthoStreamXP.Scenery;
/// flight or a teleport. : placed from X-Plane's reads, not sent by X-Plane.
///
internal sealed record CameraReport(double Lat, double Lon, double HeightAglM, double? ElevationM, Motion? Motion, bool Jumped, DateTime ReceivedUtc,
- bool Inferred = false);
+ bool Inferred = false, double? GroundSpeedKt = null, bool? OnGround = null);
///
/// The latest camera position for the distance bands and the read log, and whether X-Plane is still starting a
diff --git a/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs b/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs
index a94950e..6750884 100644
--- a/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs
+++ b/src/OrthoStreamXP/Scenery/Aircraft/XPlaneUdp.cs
@@ -35,10 +35,17 @@ internal static class XPlaneUdp
("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),
+ // The aircraft's own ground speed (m/s) and whether it stands on the ground (1): the takeoff roll and the gate,
+ // independent of where the camera is (an outside view moves and lifts it).
+ ("sim/flightmodel/position/groundspeed", 4015),
+ ("sim/flightmodel/failures/onground_any", 4016),
];
public const int ElevationIndex = 4003, HeightAglIndex = 4004, LocalXIndex = 4005, ViewXIndex = 4008, FieldOfViewIndex = 4011, WindowWidthIndex = 4012,
- DateIndex = 4013, SunPitchIndex = 4014;
+ DateIndex = 4013, SunPitchIndex = 4014, GroundSpeedIndex = 4015, OnGroundIndex = 4016;
+
+ /// The values that change rarely (screen, field of view, date): asked for once a second, not with every position.
+ public static bool Rare(int index) => index is FieldOfViewIndex or WindowWidthIndex or DateIndex;
///
/// An RREF request: X-Plane then sends the dataref's value times a second to
diff --git a/src/OrthoStreamXP/Scenery/Flight/ClimbRoute.cs b/src/OrthoStreamXP/Scenery/Flight/ClimbRoute.cs
new file mode 100644
index 0000000..a84e324
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/ClimbRoute.cs
@@ -0,0 +1,113 @@
+namespace OrthoStreamXP.Scenery;
+
+///
+/// The part of a flight plan's route the ground pre-warm covers: from the departure along the navlog to the top of
+/// climb, where X-Plane still wants real detail. At cruise altitude X-Plane reads only coarse levels, which load-ahead
+/// keeps up with on its own. Sampled every , each sample with its height above the departure.
+///
+internal static class ClimbRoute
+{
+ /// The climb ends at the earliest of the top of climb, this far along the route, and this much flight time.
+ public const double MaxNm = 200, MaxMinutes = 25;
+ public const double StepKm = 5;
+ private const double KmPerNm = 1.852, MetresPerFoot = 0.3048;
+
+ /// A point of the climb: where, how high above the departure, and how far along the route.
+ internal readonly record struct Sample(double Lat, double Lon, double HeightM, double AlongKm);
+
+ /// The climb's samples, what ended it, and how long it is.
+ internal sealed record Climb(IReadOnlyList Samples, string End, double Nm, double Minutes);
+
+ public static Climb Of(FlightPlan plan)
+ {
+ FlightOrigin from = plan.Origin;
+ var points = new List<(double Lat, double Lon, double HeightM, double Minutes)> { (from.Lat, from.Lon, 0, 0) };
+ double maxKm = MaxNm * KmPerNm, along = 0;
+ string end = "the end of the route";
+ foreach (RoutePoint fix in plan.Route)
+ {
+ var previous = points[^1];
+ double leg = Geo.DistanceM(previous.Lat, previous.Lon, fix.Lat, fix.Lon) / 1000;
+ if (along == 0 && leg < 1) continue; // the departure airport itself, as the navlog's first point
+ // A plan with stages but no TOC point: the climb ends where its last CLB point is.
+ if (fix.Stage.Length > 0 && !fix.Stage.Equals("CLB", StringComparison.OrdinalIgnoreCase) && !IsTopOfClimb(fix))
+ {
+ end = "the end of the climb";
+ break;
+ }
+ double height = Math.Max(0, fix.AltitudeFt - from.ElevationFt) * MetresPerFoot;
+ // The caps cut the leg where they are reached (time only when the plan has times).
+ double byDistance = along + leg > maxKm ? (maxKm - along) / leg : 1;
+ double byTime = fix.Minutes > MaxMinutes && fix.Minutes > previous.Minutes ? (MaxMinutes - previous.Minutes) / (fix.Minutes - previous.Minutes) : 1;
+ double f = Math.Clamp(Math.Min(byDistance, byTime), 0, 1);
+ if (f < 1)
+ {
+ points.Add((previous.Lat + (fix.Lat - previous.Lat) * f, previous.Lon + (fix.Lon - previous.Lon) * f,
+ previous.HeightM + (height - previous.HeightM) * f, previous.Minutes + (fix.Minutes - previous.Minutes) * f));
+ along += leg * f;
+ end = byDistance <= byTime ? $"{MaxNm:0} NM" : $"{MaxMinutes:0} min";
+ break;
+ }
+ points.Add((fix.Lat, fix.Lon, height, fix.Minutes));
+ along += leg;
+ if (IsTopOfClimb(fix))
+ {
+ end = "the top of climb";
+ break;
+ }
+ }
+ return new Climb(Sampled(points), points.Count > 1 ? end : "no route", along / KmPerNm, points[^1].Minutes);
+ }
+
+ private static bool IsTopOfClimb(RoutePoint fix) => fix.Ident.Equals("TOC", StringComparison.OrdinalIgnoreCase);
+
+ /// The points every along the legs (the ends included).
+ private static List Sampled(List<(double Lat, double Lon, double HeightM, double Minutes)> points)
+ {
+ var samples = new List { new(points[0].Lat, points[0].Lon, points[0].HeightM, 0) };
+ double along = 0;
+ for (int i = 1; i < points.Count; i++)
+ {
+ var (a, b) = (points[i - 1], points[i]);
+ double leg = Geo.DistanceM(a.Lat, a.Lon, b.Lat, b.Lon) / 1000;
+ int steps = Math.Max(1, (int)Math.Ceiling(leg / StepKm));
+ for (int s = 1; s <= steps; s++)
+ {
+ double f = (double)s / steps;
+ samples.Add(new Sample(a.Lat + (b.Lat - a.Lat) * f, a.Lon + (b.Lon - a.Lon) * f, a.HeightM + (b.HeightM - a.HeightM) * f, along + leg * f));
+ }
+ along += leg;
+ }
+ return samples;
+ }
+
+ /// The DSF tiles under the climb and one either side (every neighbour of a tile it crosses).
+ public static IReadOnlyList<(int Lat, int Lon)> Tiles(IReadOnlyList samples)
+ {
+ var tiles = new HashSet<(int, int)>();
+ foreach (Sample s in samples)
+ {
+ int row = (int)Math.Floor(s.Lat), col = (int)Math.Floor(s.Lon);
+ for (int r = row - 1; r <= row + 1; r++)
+ for (int c = col - 1; c <= col + 1; c++)
+ if (r is >= -90 and < 90) tiles.Add((r, ((c + 180) % 360 + 360) % 360 - 180));
+ }
+ return [.. tiles];
+ }
+
+ ///
+ /// How close the aircraft comes to a place on the climb (straight-line distance, its height included), and how far
+ /// along the route that is: the level X-Plane will want there, and the order to fetch it in.
+ ///
+ public static (double DistanceM, double AlongKm) Nearest(IReadOnlyList samples, double lat, double lon)
+ {
+ double best = double.MaxValue, at = 0;
+ foreach (Sample s in samples)
+ {
+ double ground = Geo.DistanceM(s.Lat, s.Lon, lat, lon);
+ double d = Math.Sqrt(ground * ground + s.HeightM * s.HeightM);
+ if (d < best) (best, at) = (d, s.AlongKm);
+ }
+ return (best, at);
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs b/src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs
new file mode 100644
index 0000000..38af764
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs
@@ -0,0 +1,82 @@
+namespace OrthoStreamXP.Scenery;
+
+/// A mounted package as the departure prefetch sees it: where its terrain definitions are, what its cache has, and its build queue.
+/// True when the full-detail texture is in the cache already (nothing to fetch).
+/// Queues whole textures ahead of X-Plane (the load-ahead's or the build scheduler's queue).
+///
+/// With mip streaming: queues every texture of the scenery around the departure, each to the level X-Plane will want
+/// from there (); null when textures are built whole only.
+///
+internal sealed record DepartureTarget(string Name, string Source, Func IsCached, Func, int> Prefetch,
+ Func, double, double, (int Queued, int Had)>? PrefetchArea = null);
+
+///
+/// Builds the scenery around the departure airport of the latest SimBrief flight plan as the mount starts, before
+/// X-Plane loads it: X-Plane's first load at the gate then finds it in the cache. With mip streaming that is every
+/// texture of the DSF tiles X-Plane loads there (), each to the level X-Plane will want;
+/// otherwise (or when the package's DSFs cannot be read) the textures within radiusKm at full detail. What
+/// the cache has already is left alone; X-Plane's own reads still come first in the download queue.
+///
+/// Where the aircraft is now, when X-Plane has sent a position.
+/// The time now, for the plan's age (); the system's when null.
+internal sealed class DeparturePrefetch(Func<(double Lat, double Lon)?> aircraft, IReadOnlyList targets, double radiusKm,
+ TimeProvider? clock = null)
+{
+ /// The most textures queued per package: a 10 km circle holds a few hundred ZL18 textures at most.
+ public const int MaxTextures = 600;
+
+ /// Queues the departure textures of the cache does not have; returns how many were queued.
+ public int Run(FlightPlan? flight, CancellationToken ct)
+ {
+ if (flight is null) return 0;
+ FlightOrigin from = flight.Origin;
+ string place = from.Icao.Length > 0 ? from.Icao : $"{from.Lat:F3}, {from.Lon:F3}";
+ DateTimeOffset now = (clock ?? TimeProvider.System).GetUtcNow();
+ if (flight.StaleAt(now))
+ {
+ Log.Info($"SimBrief: the latest flight plan ({place}) is {flight.AgeAt(now)!.Value.TotalHours:0} h old; its departure is not prefetched");
+ return 0;
+ }
+ if (aircraft() is { } at && Geo.DistanceM(at.Lat, at.Lon, from.Lat, from.Lon) > FlightPlan.NearDepartureKm * 1000)
+ {
+ Log.Info($"SimBrief: {place} is the latest flight plan's departure, but the aircraft is more than {FlightPlan.NearDepartureKm:0} km away; nothing prefetched");
+ return 0;
+ }
+
+ int queued = 0;
+ foreach (DepartureTarget target in targets)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (target.PrefetchArea is { } prefetchArea)
+ {
+ IReadOnlyList area = SceneryArea.Textures(target.Source, from.Lat, from.Lon, out int tiles);
+ if (area.Count > 0)
+ {
+ var (areaQueued, had) = prefetchArea(area, from.Lat, from.Lon);
+ queued += areaQueued;
+ Log.Info($"SimBrief: {place} - {area.Count} textures in the {tiles} scenery tiles X-Plane loads there: " +
+ $"{had} already have what X-Plane will want, {areaQueued} queued ({target.Name})");
+ continue;
+ }
+ Log.Info($"SimBrief: {target.Name} has no readable scenery tiles around {place}; building what is within {radiusKm:0.#} km instead");
+ }
+ IReadOnlyList near = AirportTextures.Near(target.Source, from.Lat, from.Lon, radiusKm, MaxTextures);
+ if (near.Count == 0)
+ {
+ Log.Info($"SimBrief: {target.Name} has no photo textures listed within {radiusKm:0.#} km of {place}");
+ continue;
+ }
+ VirtualPath[] missing = [.. near.Where(t => !target.IsCached(t))];
+ int cached = near.Count - missing.Length;
+ if (missing.Length == 0)
+ {
+ Log.Info($"SimBrief: {place} - all {near.Count} textures within {radiusKm:0.#} km already cached ({target.Name})");
+ continue;
+ }
+ int added = target.Prefetch(missing);
+ queued += added;
+ Log.Info($"SimBrief: {place} - {near.Count} textures within {radiusKm:0.#} km: {cached} already cached, {added} queued ({target.Name})");
+ }
+ return queued;
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs b/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs
new file mode 100644
index 0000000..f4fadee
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs
@@ -0,0 +1,53 @@
+namespace OrthoStreamXP.Scenery;
+
+/// An airport of a flight plan; is 0 when the plan does not say.
+internal sealed record FlightOrigin(string Icao, double Lat, double Lon, double ElevationFt = 0);
+
+/// A point of the flight plan's navlog: where, how high (feet above sea level), when, and in which stage.
+/// Flight time from takeoff to here.
+/// SimBrief's stage: CLB, CRZ or DSC; empty when the plan has none.
+internal sealed record RoutePoint(string Ident, double Lat, double Lon, double AltitudeFt, double Minutes, string Stage);
+
+/// The latest SimBrief flight plan as far as the scenery goes: the airports and the route.
+/// When the pilot generated it (SimBrief's params.time_generated); null when the plan does not say.
+/// SimBrief's id of the OFP (params.request_id): the same id is the same plan; null when the plan does not say.
+internal sealed record FlightPlan(FlightOrigin Origin, FlightOrigin? Destination, FlightOrigin? Alternate, IReadOnlyList Route,
+ DateTimeOffset? Generated = null, string? Id = null)
+{
+ /// The same OFP as , by SimBrief's id, else by when it was generated; never when neither is known.
+ public bool SameOfp(FlightPlan? other) =>
+ other is not null && (Id is not null && other.Id is not null ? Id == other.Id
+ : Generated is not null && Generated == other.Generated);
+
+ ///
+ /// An older plan is taken for an earlier flight: its departure is not prefetched. At mount start X-Plane usually
+ /// sends no position yet, so the age is the only check there is.
+ ///
+ public static readonly TimeSpan MaxAge = TimeSpan.FromHours(6);
+
+ /// "EDDH → EGPH" (or the departure alone), as the window and the log name the flight.
+ public string Describe()
+ {
+ string from = Origin.Icao.Length > 0 ? Origin.Icao : "your departure";
+ return Destination is { Icao.Length: > 0 } to ? $"{from} → {to.Icao}" : from;
+ }
+
+ /// How long ago it was generated; null when unknown.
+ public TimeSpan? AgeAt(DateTimeOffset now) => Generated is { } at ? now - at : null;
+
+ /// Older than : taken for an earlier flight, its departure is not prefetched.
+ public bool StaleAt(DateTimeOffset now) => AgeAt(now) is { } age && age > MaxAge;
+
+ ///
+ /// An aircraft within this of the plan's departure is at it (the ground pre-warm starts there); farther away it flies
+ /// another flight and the plan is not this flight's (the departure prefetch is skipped or dropped).
+ ///
+ public const double NearDepartureKm = 50;
+
+ /// Two airports this close share their scenery (the 3 x 4 tiles X-Plane loads round each are the same).
+ public const double SamePlaceKm = 10;
+
+ public static bool SamePlace(FlightOrigin a, FlightOrigin b) =>
+ (a.Icao.Length > 0 && a.Icao.Equals(b.Icao, StringComparison.OrdinalIgnoreCase))
+ || Geo.DistanceM(a.Lat, a.Lon, b.Lat, b.Lon) < SamePlaceKm * 1000;
+}
diff --git a/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs
new file mode 100644
index 0000000..42b8cff
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs
@@ -0,0 +1,284 @@
+namespace OrthoStreamXP.Scenery;
+
+///
+/// What the ground pre-warm does now. : it ran, and the aircraft rolls for takeoff.
+/// : the aircraft rolls for takeoff before it ever ran (said once); only the departure's requests go.
+///
+internal enum GroundDecision { None, Start, Stop, Rolled }
+
+///
+/// When the ground pre-warm runs: once, after the aircraft has stood still at the flight plan's departure for a while
+/// (an airliner spends 10-15 minutes there before pushback), until it rolls for takeoff. Taxiing does not stop it; a
+/// position X-Plane did not send (placed from its reads) never starts it. Speed and on-ground are the aircraft's, as
+/// X-Plane sends them (groundspeed, onground_any), so moving or lifting the camera in an outside view
+/// changes nothing. Without them (an X-Plane that does not send them) it falls back to the speed worked out from the
+/// positions and the camera's height, which an outside view lifts: then the height only keeps a paused aircraft in the
+/// air from starting it.
+///
+internal sealed class GroundWatch(double departureLat, double departureLon)
+{
+ public const double ParkedKt = 3, RollingKt = 40, OnGroundM = 300;
+ /// Metres per second to knots.
+ public const double KnotsPerMps = 1.943844;
+ public static readonly TimeSpan ParkedFor = TimeSpan.FromSeconds(30);
+
+ private enum State { Waiting, Running, Done }
+
+ private State _state;
+ private DateTime? _parkedSince;
+ private bool _rolled;
+
+ /// Ground speed; null when X-Plane's positions have not moved enough to tell (standing still).
+ /// The camera's height above the ground (only used when is unknown).
+ /// X-Plane's on-ground flag; null when X-Plane does not send it.
+ public GroundDecision Update(double lat, double lon, double? speedKt, double heightM, bool inferred, DateTime at, bool? onGround = null)
+ {
+ double speed = speedKt ?? 0;
+ switch (_state)
+ {
+ case State.Running:
+ // Airborne without a takeoff roll seen (a position or speed missed) ends it too.
+ if (speed <= RollingKt && (inferred || onGround != false)) return GroundDecision.None;
+ _state = State.Done;
+ return GroundDecision.Stop;
+ case State.Waiting when !_rolled && !inferred && speed > RollingKt:
+ // Not parked long enough to start (or not at the departure): the roll still ends the departure's requests.
+ _rolled = true;
+ _parkedSince = null;
+ return GroundDecision.Rolled;
+ case State.Waiting:
+ bool parked = !inferred && speed <= ParkedKt && (onGround ?? heightM <= OnGroundM)
+ && Geo.DistanceM(departureLat, departureLon, lat, lon) <= FlightPlan.NearDepartureKm * 1000;
+ if (!parked)
+ {
+ _parkedSince = null;
+ return GroundDecision.None;
+ }
+ _parkedSince ??= at;
+ if (at - _parkedSince < ParkedFor) return GroundDecision.None;
+ _state = State.Running;
+ return GroundDecision.Start;
+ default:
+ return GroundDecision.None;
+ }
+ }
+}
+
+/// A mounted package as the ground pre-warm sees it: its DSFs, and how to queue and drop textures in its load-ahead.
+/// Queues the textures around an airport (lat, lon) at a phase, nearest first ().
+/// Queues textures placed by (distance X-Plane will see them from, order) at a phase: the climb ().
+/// Drops the ground pre-warm's textures still waiting; returns how many.
+/// Drops the SimBrief departure's textures still waiting; returns how many.
+/// The builds running now, for the takeoff roll's log line ().
+internal sealed record GroundTarget(string Name, string Source,
+ Func, double, double, int?, (int Queued, int Had)> PrefetchAround,
+ Func, Func, int, (int Queued, int Had)> PrefetchAlong,
+ Func Cancel, Func CancelDeparture, Func? Running = null);
+
+///
+/// The ground pre-warm: while the aircraft stands at the departure, the scenery of the rest of the flight is built into
+/// the cache, below everything X-Plane and the departure need: first the arrival and the alternate (where missing
+/// photos are noticed first), each as X-Plane will want it from the gate there; then the climb from the departure to
+/// the top of climb (), each texture as X-Plane will want it when the aircraft passes.
+///
+/// The session's stop: no queuing after it, and a in progress ends early.
+/// Runs the queuing off X-Plane's UDP thread ( when null; tests run it inline).
+/// The alternate is built too (simbrief-alternate); off, it is left out with a log line.
+internal sealed class GroundPrewarm(Task plan, IReadOnlyList targets, Func? run = null,
+ bool alternate = true, CancellationToken stop = default)
+{
+ public const int Arrival = 0, Alternate = 1, Route = 2;
+
+ private readonly Lock _gate = new();
+ private Task _plan = plan;
+ private GroundWatch? _watch;
+ private volatile bool _stopped;
+ private bool _positionChecked;
+ /// Counts the plans: a queuing for an earlier one stops (see ).
+ private volatile int _generation;
+
+ /// The queuing once the pre-warm started; the session awaits it before the packages go away.
+ public Task Running { get; private set; } = Task.CompletedTask;
+
+ ///
+ /// A newer flight plan (the Home page's reload): watched from the start again, so the pre-warm runs for it once the
+ /// aircraft stands at its departure. The caller drops the old plan's requests; a queuing for it still running stops.
+ ///
+ public void UsePlan(FlightPlan flight)
+ {
+ lock (_gate)
+ {
+ _plan = Task.FromResult(flight);
+ _watch = null;
+ _positionChecked = false;
+ _stopped = false;
+ _generation++;
+ }
+ }
+
+ /// Every position X-Plane sends (from its UDP thread): starts or stops the pre-warm when the watch says so.
+ public void OnCamera(CameraReport camera)
+ {
+ lock (_gate) OnCameraLocked(camera);
+ }
+
+ private void OnCameraLocked(CameraReport camera)
+ {
+ if (stop.IsCancellationRequested || !_plan.IsCompletedSuccessfully || _plan.Result is not { } flight) return;
+ if (!camera.Inferred && !_positionChecked)
+ {
+ _positionChecked = true;
+ DropDepartureIfElsewhere(flight, camera);
+ }
+ _watch ??= new GroundWatch(flight.Origin.Lat, flight.Origin.Lon);
+ double? speedKt = camera.GroundSpeedKt ?? camera.Motion?.SpeedMps * GroundWatch.KnotsPerMps;
+ switch (_watch.Update(camera.Lat, camera.Lon, speedKt, camera.HeightAglM, camera.Inferred, camera.ReceivedUtc, camera.OnGround))
+ {
+ case GroundDecision.Start:
+ int generation = _generation;
+ Task queuing = (run ?? Task.Run)(() => QueueInBackground(flight, generation));
+ Running = Running.IsCompleted ? queuing : Task.WhenAll(Running, queuing);
+ break;
+ case GroundDecision.Stop:
+ _stopped = true;
+ Cancel();
+ break;
+ case GroundDecision.Rolled:
+ int dropped = DropDeparture();
+ Log.Info($"SimBrief: the aircraft is rolling for takeoff; {dropped} departure textures still waiting were dropped, load-ahead takes over");
+ break;
+ }
+ }
+
+ ///
+ /// off the UDP thread. Never throws: a failure is logged (the pre-warm is a hint, never a reason
+ /// for streaming to fail), and the session's stop ends it quietly.
+ ///
+ private void QueueInBackground(FlightPlan flight, int generation)
+ {
+ try
+ {
+ Queue(flight, generation);
+ }
+ catch (OperationCanceledException) when (stop.IsCancellationRequested)
+ {
+ return; // streaming stopped
+ }
+ catch (ObjectDisposedException) when (stop.IsCancellationRequested)
+ {
+ return; // streaming stopped while a package was queuing
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Log.Warning($"Ground pre-warm failed ({ex.Message})");
+ }
+#pragma warning disable CA1031 // Anything else is a bug, but on a background thread nobody would see it: log it, don't lose it.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ Log.Warning($"Ground pre-warm failed unexpectedly ({ex.GetType().Name}: {ex.Message})");
+ }
+ if (!_stopped && generation == _generation) return;
+ // The aircraft rolled (or a newer plan came) while it was being queued: what went in after the cancel goes too.
+ int late = DropGround();
+ if (late > 0) Log.Info($"Ground pre-warm: {late} more textures queued during the takeoff roll were dropped");
+ }
+
+ /// Queues the three phases in every package; returns how many textures were queued.
+ internal int Queue(FlightPlan flight) => Queue(flight, _generation);
+
+ private int Queue(FlightPlan flight, int generation)
+ {
+ Log.Info($"Ground pre-warm: the aircraft stands at {Name(flight.Origin)}; building the rest of the flight's scenery until it rolls for takeoff");
+ int queued = 0;
+ foreach (GroundTarget target in targets)
+ {
+ stop.ThrowIfCancellationRequested();
+ if (flight.Destination is { } to)
+ {
+ if (FlightPlan.SamePlace(to, flight.Origin))
+ Log.Info($"Ground pre-warm: the arrival {Name(to)} is the departure; its scenery is the departure's ({target.Name})");
+ else
+ queued += Area(target, to, Arrival, "arrival", generation);
+ }
+ if (flight.Alternate is { } other)
+ {
+ if (!alternate)
+ Log.Info($"Ground pre-warm: the alternate {Name(other)} is left out (simbrief-alternate = off) ({target.Name})");
+ else if (flight.Destination is { } arrival && FlightPlan.SamePlace(other, arrival))
+ Log.Info($"Ground pre-warm: the alternate {Name(other)} is the arrival; nothing more to build ({target.Name})");
+ else if (FlightPlan.SamePlace(other, flight.Origin))
+ Log.Info($"Ground pre-warm: the alternate {Name(other)} is the departure; its scenery is the departure's ({target.Name})");
+ else
+ queued += Area(target, other, Alternate, "alternate", generation);
+ }
+ queued += Climb(target, flight, generation);
+ }
+ return queued;
+ }
+
+ /// The aircraft rolled, or a newer plan replaced the one being queued: queue no more of it.
+ private bool Stale(int generation) => _stopped || generation != _generation;
+
+ private int Area(GroundTarget target, FlightOrigin airport, int phase, string what, int generation)
+ {
+ if (Stale(generation)) return 0;
+ stop.ThrowIfCancellationRequested();
+ IReadOnlyList textures = SceneryArea.Textures(target.Source, airport.Lat, airport.Lon, out int tiles);
+ if (textures.Count == 0)
+ {
+ Log.Info($"Ground pre-warm: {target.Name} has no readable scenery tiles around the {what} {Name(airport)}");
+ return 0;
+ }
+ var (queued, had) = target.PrefetchAround(textures, airport.Lat, airport.Lon, phase);
+ Log.Info($"Ground pre-warm: {what} {Name(airport)} - {textures.Count} textures in {tiles} scenery tiles: {had} already have what X-Plane will want, {queued} queued ({target.Name})");
+ return queued;
+ }
+
+ private int Climb(GroundTarget target, FlightPlan flight, int generation)
+ {
+ if (Stale(generation)) return 0;
+ stop.ThrowIfCancellationRequested();
+ ClimbRoute.Climb climb = ClimbRoute.Of(flight);
+ if (climb.Samples.Count < 2)
+ {
+ Log.Info($"Ground pre-warm: the flight plan has no route from {Name(flight.Origin)}; the climb is left to load-ahead ({target.Name})");
+ return 0;
+ }
+ IReadOnlyList textures = SceneryArea.TexturesIn(target.Source, ClimbRoute.Tiles(climb.Samples), out int tiles);
+ var (queued, had) = target.PrefetchAlong(textures, (lat, lon) => ClimbRoute.Nearest(climb.Samples, lat, lon), Route);
+ Log.Info($"Ground pre-warm: the climb to {climb.End} ({climb.Nm:0} NM, {climb.Minutes:0} min) - {textures.Count} textures in {tiles} scenery tiles: " +
+ $"{had} already have what X-Plane will want, {queued} queued ({target.Name})");
+ return queued;
+ }
+
+ ///
+ /// The first position X-Plane sends: an aircraft far from the plan's departure flies another flight (the plan is
+ /// stale), so the departure's textures still waiting are dropped. Before X-Plane runs this is the only place check.
+ ///
+ private void DropDepartureIfElsewhere(FlightPlan flight, CameraReport camera)
+ {
+ double km = Geo.DistanceM(flight.Origin.Lat, flight.Origin.Lon, camera.Lat, camera.Lon) / 1000;
+ if (km <= FlightPlan.NearDepartureKm) return;
+ int dropped = DropDeparture();
+ Log.Info($"SimBrief: the aircraft is {km:0} km from the flight plan's departure {Name(flight.Origin)}, so the plan is not this flight's; " +
+ $"{dropped} departure textures still waiting were dropped");
+ }
+
+ private void Cancel()
+ {
+ foreach (GroundTarget target in targets)
+ if (target.Running is { } running) Log.Info($"Load-ahead at the takeoff roll: {running()} ({target.Name})");
+ int dropped = DropGround(), departure = DropDeparture();
+ Log.Info($"Ground pre-warm: the aircraft is rolling for takeoff; {dropped} textures still waiting were dropped " +
+ $"(and {departure} of the departure), load-ahead takes over");
+ }
+
+ /// Drops the ground pre-warm's textures still waiting in every package; returns how many.
+ public int DropGround() => targets.Sum(t => t.Cancel());
+
+ /// Drops the SimBrief departure's textures still waiting in every package; returns how many.
+ public int DropDeparture() => targets.Sum(t => t.CancelDeparture());
+
+ private static string Name(FlightOrigin airport) => airport.Icao.Length > 0 ? airport.Icao : $"{airport.Lat:F3}, {airport.Lon:F3}";
+}
diff --git a/src/OrthoStreamXP/Scenery/Flight/SimBriefClient.cs b/src/OrthoStreamXP/Scenery/Flight/SimBriefClient.cs
new file mode 100644
index 0000000..398dd87
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/SimBriefClient.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+using System.Net;
+using System.Text.Json;
+
+namespace OrthoStreamXP.Scenery;
+
+/// SimBrief's answer: the latest flight plan, or what went wrong in plain words.
+internal sealed record SimBriefAnswer(FlightPlan? Plan, string? Problem)
+{
+ public FlightOrigin? Origin => Plan?.Origin;
+
+ public string? Destination => Plan?.Destination?.Icao;
+
+ /// Why there is no flight plan, as the window says it.
+ public string NoPlan => $"No flight plan: {Problem?.TrimEnd('.')}.";
+
+ public static SimBriefAnswer Failed(string problem) => new(null, problem);
+}
+
+///
+/// Reads the latest flight plan (OFP) a SimBrief user generated, through SimBrief's public fetcher: the departure,
+/// arrival and alternate airports and the navlog. A failure is answered with its reason, never an exception: the flight plan is a hint, never a reason for the
+/// mount to fail.
+///
+internal sealed class SimBriefClient(HttpClient http)
+{
+ public const string FetcherUrl = "https://www.simbrief.com/api/xml.fetcher.php";
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ /// The fetcher's address for a username, or for the numeric Pilot ID when is all digits.
+ public static Uri UrlFor(string user)
+ {
+ string trimmed = user.Trim();
+ string key = trimmed.Length > 0 && trimmed.All(char.IsAsciiDigit) ? "userid" : "username";
+ return new Uri($"{FetcherUrl}?{key}={Uri.EscapeDataString(trimmed)}&json=1");
+ }
+
+ ///
+ /// Asks SimBrief for the user's latest flight plan. The problem it answers with is logged and shown: it never holds
+ /// the username or Pilot ID, even when SimBrief's text or an exception's message would (the URL carries it).
+ ///
+ public async Task FetchAsync(string user, CancellationToken ct)
+ {
+ SimBriefAnswer answer = await AskAsync(user, ct).ConfigureAwait(false);
+ string trimmed = user.Trim();
+ return answer.Problem is { } problem && trimmed.Length > 0 && problem.Contains(trimmed, StringComparison.OrdinalIgnoreCase)
+ ? answer with { Problem = problem.Replace(trimmed, "(your SimBrief user)", StringComparison.OrdinalIgnoreCase) }
+ : answer;
+ }
+
+ private async Task AskAsync(string user, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(user)) return SimBriefAnswer.Failed("no SimBrief username or Pilot ID is set");
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(Timeout);
+ try
+ {
+ // The User-Agent goes on the request: the client handed in may be shared and set up for something else.
+ using var request = new HttpRequestMessage(HttpMethod.Get, UrlFor(user));
+ request.Headers.UserAgent.ParseAdd(HttpIdentity.UserAgent);
+ using HttpResponseMessage response = await http.SendAsync(request, timeout.Token).ConfigureAwait(false);
+ string body = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false);
+ // SimBrief answers an unknown user with 400 and its reason in fetch.status.
+ if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.BadRequest)
+ return SimBriefAnswer.Failed($"SimBrief did not answer ({(int)response.StatusCode} {response.ReasonPhrase})");
+ return Parse(body);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return SimBriefAnswer.Failed($"SimBrief did not answer within {Timeout.TotalSeconds:0} s");
+ }
+ catch (HttpRequestException ex)
+ {
+ return SimBriefAnswer.Failed($"SimBrief could not be reached ({ex.Message})");
+ }
+ }
+
+ ///
+ /// The flight plan in SimBrief's JSON, or the reason there is none. Any shape it does not expect (a field of another
+ /// type, an object that is a string) is "could not be read", never an exception: the answer comes from outside.
+ ///
+ internal static SimBriefAnswer Parse(string json)
+ {
+ try
+ {
+ using JsonDocument doc = JsonDocument.Parse(json);
+ JsonElement root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return SimBriefAnswer.Failed(Unreadable);
+ // fetch.status is SimBrief's own word on the request: present but of another shape, nothing else is trusted.
+ if (root.TryGetProperty("fetch", out JsonElement fetch)
+ && (fetch.ValueKind != JsonValueKind.Object || (fetch.TryGetProperty("status", out JsonElement s) && s.ValueKind != JsonValueKind.String)))
+ return SimBriefAnswer.Failed(Unreadable);
+ string? status = Text(fetch, "status"); // no fetch: Text reads nothing from the default element
+ if (status is not null && !status.Equals("Success", StringComparison.OrdinalIgnoreCase))
+ return SimBriefAnswer.Failed(status.StartsWith("Error:", StringComparison.OrdinalIgnoreCase) ? status[6..].Trim() : status);
+ if (Airport(root, "origin") is not { } origin) return SimBriefAnswer.Failed("the latest flight plan has no departure position");
+ return new SimBriefAnswer(new FlightPlan(origin, Airport(root, "destination"), Airport(root, "alternate"), Route(root), Generated(root), RequestId(root)), null);
+ }
+ catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
+ {
+ // JsonElement's accessors throw InvalidOperationException on a value of another kind: the checks above
+ // should leave none, this is the net under them.
+ return SimBriefAnswer.Failed(Unreadable);
+ }
+ }
+
+ private const string Unreadable = "SimBrief's answer could not be read";
+
+ /// An object-valued property; null when it is missing or not an object (SimBrief sends "" for empty ones).
+ private static JsonElement? Child(JsonElement parent, string name) =>
+ parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.Object
+ ? value : null;
+
+ /// An airport by its key; SimBrief gives several alternates as an array (the first is taken).
+ private static FlightOrigin? Airport(JsonElement root, string key)
+ {
+ if (!root.TryGetProperty(key, out JsonElement airport)) return null;
+ if (airport.ValueKind == JsonValueKind.Array) airport = airport.GetArrayLength() > 0 ? airport[0] : default;
+ if (airport.ValueKind != JsonValueKind.Object || Number(airport, "pos_lat") is not { } lat || Number(airport, "pos_long") is not { } lon
+ || lat is < -90 or > 90 || lon is < -180 or > 180) return null;
+ return new FlightOrigin(Text(airport, "icao_code") ?? "", lat, lon, Number(airport, "elevation") ?? 0);
+ }
+
+ /// When the plan was generated: params.time_generated, Unix seconds (as a string, like the coordinates).
+ private static DateTimeOffset? Generated(JsonElement root)
+ {
+ if (Child(root, "params") is not { } parameters
+ || Number(parameters, "time_generated") is not { } seconds || seconds is <= 0 or > 32503680000) return null; // > year 3000: not a time
+ return DateTimeOffset.FromUnixTimeSeconds((long)seconds);
+ }
+
+ /// SimBrief's id of the OFP, params.request_id (a string or a number).
+ private static string? RequestId(JsonElement root)
+ {
+ if (Child(root, "params") is not { } parameters || !parameters.TryGetProperty("request_id", out JsonElement id)) return null;
+ string? text = id.ValueKind switch
+ {
+ JsonValueKind.String => id.GetString(),
+ JsonValueKind.Number => id.GetRawText(),
+ _ => null,
+ };
+ return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
+ }
+
+ /// The navlog's points in order; one point comes as an object, not an array.
+ private static List Route(JsonElement root)
+ {
+ if (Child(root, "navlog") is not { } navlog || !navlog.TryGetProperty("fix", out JsonElement fixes)) return [];
+ IEnumerable all = fixes.ValueKind switch
+ {
+ JsonValueKind.Array => fixes.EnumerateArray(),
+ JsonValueKind.Object => [fixes],
+ _ => [],
+ };
+ var route = new List();
+ foreach (JsonElement fix in all)
+ if (fix.ValueKind == JsonValueKind.Object && Number(fix, "pos_lat") is { } lat && Number(fix, "pos_long") is { } lon && lat is >= -90 and <= 90 && lon is >= -180 and <= 180)
+ route.Add(new RoutePoint(Text(fix, "ident") ?? "", lat, lon, Number(fix, "altitude_feet") ?? 0,
+ (Number(fix, "time_total") ?? 0) / 60, Text(fix, "stage") ?? ""));
+ return route;
+ }
+
+ private static string? Text(JsonElement parent, string name) =>
+ parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+
+ /// A coordinate: SimBrief sends them as strings ("48.353783"), a number is taken too.
+ private static double? Number(JsonElement parent, string name)
+ {
+ if (parent.ValueKind != JsonValueKind.Object || !parent.TryGetProperty(name, out JsonElement value)) return null;
+ double? number = value.ValueKind switch
+ {
+ JsonValueKind.Number when value.TryGetDouble(out double n) => n,
+ JsonValueKind.String when double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
+ _ => null,
+ };
+ return number is { } finite && double.IsFinite(finite) ? finite : null;
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs
new file mode 100644
index 0000000..d4b657a
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs
@@ -0,0 +1,214 @@
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Scenery;
+
+///
+/// One streaming session's SimBrief side: the flight plan (fetched once as streaming starts, and again only when the
+/// pilot presses "Reload SimBrief flight plan" on Home: no polling), the departure prefetch, and the ground pre-warm.
+/// A reload with a newer plan drops the old plan's requests still waiting, prefetches the new departure when it moved,
+/// and watches for the new flight's gate from the start. Failures are only logged: SimBrief never fails streaming.
+///
+internal sealed class SimBriefSession
+{
+ private readonly Func> _fetch;
+ private readonly DeparturePrefetch _departure;
+ private readonly CancellationToken _stop;
+ private readonly PrefetchBudget? _budget;
+ private readonly TimeProvider _clock;
+ private readonly Lock _gate = new();
+ private readonly List _work = [];
+ private FlightPlan? _current;
+ private Task? _reload;
+ private string _firstNoPlan = "";
+
+ /// Asks SimBrief for the latest flight plan ().
+ /// The packages the ground pre-warm queues in; empty: no ground pre-warm.
+ /// Where the aircraft is now, when X-Plane has sent a position.
+ /// Runs the ground pre-warm's queuing (tests run it inline).
+ /// The ground pre-warm builds the alternate too (simbrief-alternate).
+ /// The download limit (simbrief-download-limit); a reloaded new plan starts it again. Null: no limit.
+ public SimBriefSession(Func> fetch, IReadOnlyList departureTargets,
+ IReadOnlyList groundTargets, Func<(double Lat, double Lon)?> aircraft, double radiusKm,
+ TimeProvider? clock = null, Func? run = null, bool alternate = false, PrefetchBudget? budget = null,
+ CancellationToken stop = default)
+ {
+ _fetch = fetch;
+ _budget = budget;
+ _clock = clock ?? TimeProvider.System;
+ _departure = new DeparturePrefetch(aircraft, departureTargets, radiusKm, _clock);
+ _stop = stop;
+ FirstPlan = FetchFirstAsync();
+ if (groundTargets.Count > 0) Ground = new GroundPrewarm(FirstPlan, groundTargets, run, alternate, stop);
+ }
+
+ /// The plan fetched as streaming started (null when there was none).
+ public Task FirstPlan { get; }
+
+ public GroundPrewarm? Ground { get; }
+
+ /// The plan in use now: the first, or the latest reloaded.
+ public FlightPlan? Current
+ {
+ get
+ {
+ lock (_gate) return _current;
+ }
+ }
+
+ /// Starts the departure prefetch for the first plan, in the background.
+ public void Start()
+ {
+ lock (_gate) _work.Add(PrefetchDepartureAsync(FirstPlan));
+ }
+
+ /// Every position X-Plane sends (from its UDP thread).
+ public void OnCamera(CameraReport camera) => Ground?.OnCamera(camera);
+
+ ///
+ /// The Home page's "Reload SimBrief flight plan": asks SimBrief again and says what came of it, in the window's
+ /// words. A press while a reload runs gets that reload's answer (one request).
+ ///
+ public Task ReloadAsync()
+ {
+ lock (_gate)
+ {
+ if (_reload is { IsCompleted: false } running) return running;
+ _reload = ReloadOnceAsync();
+ _work.Add(_reload);
+ return _reload;
+ }
+ }
+
+ /// Everything still running in the background; the session awaits it before the packages go away.
+ public Task WhenDone()
+ {
+ lock (_gate) return Task.WhenAll([.. _work, Ground?.Running ?? Task.CompletedTask]);
+ }
+
+ private async Task FetchFirstAsync()
+ {
+ SimBriefAnswer answer = await FetchAsync().ConfigureAwait(false);
+ if (answer.Problem is { } problem) Log.Warning($"SimBrief: {problem}");
+ lock (_gate)
+ {
+ _current ??= answer.Plan;
+ _firstNoPlan = answer.NoPlan;
+ }
+ return answer.Plan;
+ }
+
+ ///
+ /// The plan fetched as streaming started, in the window's words ("Flight plan: EDDV → ENGM, generated 12 min ago."),
+ /// or why there is none: Home shows it while the SimBrief switch is on.
+ ///
+ public async Task DescribeFirstAsync()
+ {
+ try
+ {
+ if (await FirstPlan.ConfigureAwait(false) is not { } plan)
+ lock (_gate) return _firstNoPlan;
+ return $"Flight plan: {plan.Describe()}{Age(plan)}.";
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested)
+ {
+ return "Streaming stopped.";
+ }
+ }
+
+ private async Task ReloadOnceAsync()
+ {
+ try
+ {
+ await FirstPlan.ConfigureAwait(false); // its answer first: the reload compares against it
+ SimBriefAnswer answer = await FetchAsync().ConfigureAwait(false);
+ if (answer.Plan is not { } plan)
+ {
+ Log.Info($"SimBrief: reloading the flight plan: {answer.Problem}");
+ return answer.NoPlan;
+ }
+ FlightPlan? old;
+ lock (_gate)
+ {
+ old = _current;
+ _current = plan;
+ }
+ string flight = plan.Describe();
+ if (plan.SameOfp(old))
+ {
+ Log.Info($"SimBrief: reloaded the flight plan; unchanged ({flight})");
+ return $"Unchanged: {flight} is still your latest flight plan{Age(plan)}.";
+ }
+ bool moved = old is null || !FlightPlan.SamePlace(old.Origin, plan.Origin);
+ // A stale old plan had its departure skipped: a fresh one from the same airport still needs it built.
+ bool buildDeparture = moved || old!.StaleAt(_clock.GetUtcNow());
+ int ground = Ground?.DropGround() ?? 0;
+ int departure = moved ? Ground?.DropDeparture() ?? 0 : 0;
+ _budget?.Restart(); // a turnaround: the new flight gets the whole limit, whatever the last one spent
+ Ground?.UsePlan(plan);
+ Log.Info($"SimBrief: new flight plan {flight}{Age(plan)}; the old one's textures still waiting were dropped " +
+ $"({ground} of the ground pre-warm{(moved ? $", {departure} of the departure" : "")})" +
+ (_budget is null ? "" : $"; the download limit of {CacheInventory.FormatBytes(_budget.LimitBytes)} starts again"));
+ if (buildDeparture) await PrefetchDepartureAsync(Task.FromResult(plan)).ConfigureAwait(false);
+ return $"Loaded {flight}{Age(plan)}." + (buildDeparture ? " Its departure is being built." : "");
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested)
+ {
+ return "Streaming stopped.";
+ }
+ }
+
+ ///
+ /// Asks SimBrief. The client answers its failures as a problem; anything it throws besides the stop (a bug) becomes
+ /// one too, so neither the first plan nor a reload ever ends faulted (the stop awaits them).
+ ///
+ private async Task FetchAsync()
+ {
+ try
+ {
+ return await _fetch(_stop).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // The flight plan is a hint: whatever went wrong is logged as "no plan", never a reason for streaming to fail.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ return SimBriefAnswer.Failed($"the flight plan could not be read ({ex.GetType().Name})");
+ }
+ }
+
+ /// The departure prefetch for a plan once it is there; a failure is only logged.
+ private async Task PrefetchDepartureAsync(Task plan)
+ {
+ try
+ {
+ FlightPlan? flight = await plan.WaitAsync(_stop).ConfigureAwait(false);
+ await Task.Run(() => _departure.Run(flight, _stop), _stop).ConfigureAwait(false); // reads DSFs: off the caller's thread
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested)
+ {
+ // Stopped before the flight plan arrived.
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Log.Warning($"SimBrief: departure prefetch failed ({ex.Message})");
+ }
+ catch (ObjectDisposedException) when (_stop.IsCancellationRequested)
+ {
+ // Streaming stopped while a package was queuing.
+ }
+#pragma warning disable CA1031 // Anything else is a bug, but on a background task nobody would see it: log it, don't lose it.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ Log.Warning($"SimBrief: departure prefetch failed unexpectedly ({ex.GetType().Name}: {ex.Message})");
+ }
+ }
+
+ /// ", generated 12 min ago" when the plan says when.
+ private string Age(FlightPlan plan) => plan.AgeAt(_clock.GetUtcNow()) is not { } age ? ""
+ : age < TimeSpan.FromHours(1) ? $", generated {Math.Max(0, age.TotalMinutes):0} min ago"
+ : $", generated {age.TotalHours:0} h ago";
+}
diff --git a/src/OrthoStreamXP/Scenery/Prefetch/AirportTextures.cs b/src/OrthoStreamXP/Scenery/Prefetch/AirportTextures.cs
new file mode 100644
index 0000000..77d7744
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Prefetch/AirportTextures.cs
@@ -0,0 +1,51 @@
+using OrthoStreamXP.Textures;
+
+namespace OrthoStreamXP.Scenery;
+
+///
+/// The photo textures a package has around a place, from the names of its terrain definitions (terrain\*.ter): the
+/// same textures X-Plane will ask for when it loads the scenery there, at the zoom the package uses (ZL16, or ZL18
+/// near airports), known before X-Plane opens anything.
+///
+/// A region's terrain folder holds hundreds of thousands of files (a whole continent), so it is never listed: each
+/// texture cell around the place is looked up by its name (row_col_*.ter) at every zoom a package may use, a few
+/// hundred indexed lookups instead of a scan of the whole folder.
+///
+///
+internal static class AirportTextures
+{
+ /// The zoom levels texture names are looked for at (Ortho4XP and AutoOrtho packages use 12-19).
+ internal const int MinZoom = 12, MaxZoom = 19;
+
+ ///
+ /// The textures whose centre is within , nearest first, at most .
+ /// Empty when the package has no terrain folder (its definitions are generated as X-Plane opens them).
+ ///
+ public static IReadOnlyList Near(string packageSource, double lat, double lon, double radiusKm, int max)
+ {
+ string folder = Path.Join(packageSource, "terrain");
+ if (!Directory.Exists(folder)) return [];
+ double radiusM = radiusKm * 1000;
+ // The box around the circle; the cells in it are checked against the circle by their centre.
+ double dLat = radiusKm / 110.574, dLon = radiusKm / (111.320 * Math.Max(0.01, Math.Cos(lat * Math.PI / 180)));
+ var found = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int zoom = MinZoom; zoom <= MaxZoom; zoom++)
+ {
+ var (west, north) = Tiles.LatLonToTile(lat + dLat, lon - dLon, zoom);
+ var (east, south) = Tiles.LatLonToTile(lat - dLat, lon + dLon, zoom);
+ for (int row = north / Tiles.TextureSpan * Tiles.TextureSpan; row <= south; row += Tiles.TextureSpan)
+ for (int col = west / Tiles.TextureSpan * Tiles.TextureSpan; col <= east; col += Tiles.TextureSpan)
+ {
+ var cell = new TextureName(row, col, "", zoom);
+ var (centreLat, centreLon) = cell.Center;
+ double distance = Geo.DistanceM(lat, lon, centreLat, centreLon);
+ if (distance > radiusM) continue;
+ // 21632_35680_*.ter: the plain definition and its water/sea overlays, whatever the provider code.
+ foreach (string file in Directory.EnumerateFiles(folder, $"{row}_{col}_*.ter"))
+ if (TerrainDefinitions.TextureOf(Path.GetFileName(file), out _, out _) is { } name && name.Zoom == zoom)
+ found.TryAdd(name.Stem, distance);
+ }
+ }
+ return [.. found.OrderBy(f => f.Value).ThenBy(f => f.Key, StringComparer.Ordinal).Take(max).Select(f => VirtualPath.Of("textures", f.Key + ".dds"))];
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs
index 9c8c865..19f7286 100644
--- a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs
+++ b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs
@@ -1,5 +1,7 @@
+using System.Collections.Concurrent;
using System.Diagnostics;
using OrthoStreamXP.Textures;
+using OrthoStreamXP.Setup;
namespace OrthoStreamXP.Scenery;
@@ -45,10 +47,35 @@ internal enum BuildKind { Base, Small, Heavy }
private static readonly TimeSpan LoadingReportInterval = TimeSpan.FromSeconds(10), QuietReportInterval = TimeSpan.FromMinutes(2);
///
- /// A texture to warm; and fixed for the flight-path prefetch.
- /// : queued again because the aircraft moved, not because X-Plane loaded it.
+ /// A texture to warm; and fixed for the flight-path and departure
+ /// prefetch. : queued again because the aircraft moved, not because X-Plane loaded it.
+ /// : the order among the prefetch's own requests (from the departure, or along the route).
+ /// : the SimBrief phase that queued it (see ); null for every
+ /// other request. A ground phase's () is dropped when the aircraft rolls for takeoff, and so is the
+ /// departure's (): its level and priority were chosen for the gate.
///
- private readonly record struct Request(VirtualPath Texture, int? Mip = null, int? Priority = null, bool Recheck = false);
+ private readonly record struct Request(VirtualPath Texture, int? Mip = null, int? Priority = null, bool Recheck = false, double? Distance = null,
+ PrefetchMeter? Meter = null)
+ {
+ public bool Ground => Meter is { Ground: true };
+
+ public bool Departure => Meter is { Ground: false };
+ }
+
+ /// The SimBrief prefetch's phases: the departure, then the ground pre-warm's (index = its phase + 1).
+ private readonly PrefetchMeter[] _phases =
+ [new("departure", ground: false), new("arrival", ground: true), new("alternate", ground: true), new("climb", ground: true)];
+ /// The SimBrief prefetch queued something: X-Plane's loads are checked against it (the hit rate).
+ private volatile bool _prefetched;
+ /// The ground pre-warm's builds running now (for the takeoff roll's log line, H3).
+ private int _groundRunning;
+ private PrefetchBudget? _budget;
+ /// The budget's round () in which this package's SimBrief requests were dropped; -1: none yet.
+ private int _budgetSpentRound = -1;
+ /// Passes of and their longest time since the last log (H2).
+ private int _passes;
+ private TimeSpan _longestPass;
+ private const int PassesPerReport = 100;
private readonly TextureCatalog _catalog;
private readonly TextureStreamer _streamer;
@@ -56,6 +83,8 @@ internal enum BuildKind { Base, Small, Heavy }
private readonly int _heavyLimit;
private readonly Lock _gate = new();
private readonly Dictionary _waiting = [];
+ /// Textures whose base tile is being fetched before the level asked for (see ).
+ private readonly ConcurrentDictionary _stepping = new();
private readonly SemaphoreSlim _wake = new(0);
private readonly CancellationTokenSource _stop = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
@@ -63,6 +92,8 @@ internal enum BuildKind { Base, Small, Heavy }
/// Builds running, by .
private readonly int[] _running = new int[3];
private int _queued, _alreadyHad;
+ /// The ground pre-warm queued textures that have not all been built yet (see ).
+ private volatile bool _groundPending;
private (int Known, int Waiting, int Queued, int AlreadyHad) _reported;
private TimeSpan _reportedAt;
@@ -82,6 +113,14 @@ public MipWarmer(TextureCatalog catalog, TextureStreamer streamer, ViewTracker v
public bool WarmsLoadedScenery { get; }
+ ///
+ /// When everything the ground pre-warm queued has been built and nothing else is building (again after a later
+ /// phase was queued, if the earlier ones finished first): the aircraft
+ /// still stands at the gate, and the build buffers can be given back. Not raised when the takeoff roll dropped
+ /// requests still waiting (the aircraft moves then). Raised on the warmer's own thread.
+ ///
+ public event Action? GroundDone;
+
/// Warm builds started this session.
public int Queued => Volatile.Read(ref _queued);
@@ -93,18 +132,36 @@ public void Start()
$"({_heavyLimit} detailed textures, {SmallInFlight} of a few tiles and {BaseInFlight} base tiles at a time)");
}
- private void OnEntryAdded(TextureCatalog.Entry entry) => Add(new Request(entry.Texture));
+ private void OnEntryAdded(TextureCatalog.Entry entry)
+ {
+ if (_prefetched)
+ foreach (PrefetchMeter meter in _phases) meter.NoteLoaded(entry.Texture);
+ Add(new Request(entry.Texture));
+ }
+
+ /// The SimBrief prefetch spends from (shared by every package); set before it queues anything.
+ public void UseBudget(PrefetchBudget budget)
+ {
+ _budget = budget;
+ foreach (PrefetchMeter meter in _phases) meter.Budget = budget;
+ }
+
+ /// The SimBrief prefetch's phases this session, in their order.
+ public IReadOnlyList Phases => _phases;
/// A new position: what every loaded texture will need has changed.
private void OnMoved(CameraReport camera)
{
lock (_gate)
foreach (TextureCatalog.Entry entry in _catalog.Entries)
- _waiting.TryAdd(entry.Texture.Key, new Request(entry.Texture, Recheck: true));
+ AddOrOutrank(new Request(entry.Texture, Recheck: true));
Wake();
}
- /// The flight-path prefetch: whole textures along the predicted path. Returns how many were queued.
+ ///
+ /// The flight-path prefetch (and the SimBrief departure without streaming's levels): whole textures ahead of
+ /// X-Plane. Returns how many were queued. Each gets its base tile first (see ).
+ ///
public int Prefetch(IReadOnlyList textures)
{
lock (_gate)
@@ -114,25 +171,244 @@ public int Prefetch(IReadOnlyList textures)
return textures.Count;
}
+ ///
+ /// The scenery X-Plane loads around an airport of the SimBrief flight plan: the departure (
+ /// null), or the ground pre-warm's arrival or alternate (, ).
+ /// Every texture goes to the level X-Plane will want with the aircraft there (full detail beside it, the middle levels
+ /// further out, a base tile far away), the same choice load-ahead makes once X-Plane sends a position, only made
+ /// before X-Plane opens anything. The order is load-ahead's around a point:
+ /// without motion, nearest first. Returns how many were queued and how many had what they need already.
+ ///
+ public (int Queued, int Had) PrefetchAround(IReadOnlyList textures, double lat, double lon, int? phase = null)
+ {
+ var entries = new List(textures.Count);
+ foreach (VirtualPath texture in textures)
+ if (_streamer.Get(texture) is { } t) entries.Add(new TextureCatalog.Entry(texture, t.Lat, t.Lon));
+ IReadOnlyList nearestFirst = PrefetchPlanner.Select(entries, lat, lon, motion: null, AroundRadiusKm, lookaheadSeconds: 0, int.MaxValue);
+ // The order is the distance in metres, as load-ahead's own requests are ranked by: Select's order, on the same scale.
+ var placed = new List<(VirtualPath Texture, double DistanceM, double Order)>(nearestFirst.Count);
+ foreach (VirtualPath texture in nearestFirst)
+ if (_streamer.Get(texture) is { } t)
+ {
+ double distance = Geo.DistanceM(lat, lon, t.Lat, t.Lon);
+ placed.Add((texture, distance, distance));
+ }
+ return PrefetchPlaced(placed, phase);
+ }
+
+ ///
+ /// The radius selects in: wider than the 3 x 4 DSF tiles X-Plane loads round a place
+ /// () reach from it anywhere, so the tiles alone decide what is queued.
+ ///
+ internal const double AroundRadiusKm = 500;
+
+ ///
+ /// The ground pre-warm's climb (): each texture to the level X-Plane will want from
+ /// 's distance (to the nearest point of the climb: a line, not a point, so not
+ /// 's order), ordered by its second value (along the climb). A texture already
+ /// waiting for anything else keeps that request. Returns how many were queued and how many had what they need already.
+ ///
+ public (int Queued, int Had) PrefetchGround(IReadOnlyList textures, Func place, int phase)
+ {
+ var placed = new List<(VirtualPath Texture, double DistanceM, double Order)>(textures.Count);
+ foreach (VirtualPath texture in textures)
+ if (_streamer.Get(texture) is { } t)
+ {
+ var (distance, order) = place(t.Lat, t.Lon);
+ placed.Add((texture, distance, order));
+ }
+ return PrefetchPlaced(placed, phase);
+ }
+
+ /// Drops the ground pre-warm's requests still waiting (the takeoff roll, a newer plan); returns how many.
+ public int CancelGround()
+ {
+ int dropped = Drop(static r => r.Ground);
+ if (dropped > 0) _groundPending = false; // not all built: nothing to give back for
+ return dropped;
+ }
+
+ /// H3, for the takeoff roll's log line: the builds running now, by kind, and how many are the ground pre-warm's.
+ public string RunningSummary() =>
+ $"{Volatile.Read(ref _running[(int)BuildKind.Heavy])} detailed, {Volatile.Read(ref _running[(int)BuildKind.Small])} small and " +
+ $"{Volatile.Read(ref _running[(int)BuildKind.Base])} base builds running, {Volatile.Read(ref _groundRunning)} of them the ground pre-warm's";
+
+ /// A build runs in this package: one of load-ahead's, or one for X-Plane's reads.
+ public bool Building
+ {
+ get
+ {
+ for (int kind = 0; kind < _running.Length; kind++)
+ if (Volatile.Read(ref _running[kind]) > 0) return true;
+ return _streamer.Building > 0;
+ }
+ }
+
+ /// X-Plane's reads of this package's textures so far ().
+ public long XPlaneReads => _streamer.Reads;
+
+ /// The ground pre-warm queued work that has not all been built yet.
+ public bool GroundPending => _groundPending;
+
+ /// Raises when the ground pre-warm's work is all built (called after each pass).
+ internal void CheckGroundDone()
+ {
+ if (!_groundPending) return;
+ for (int kind = 0; kind < _running.Length; kind++)
+ if (Volatile.Read(ref _running[kind]) > 0) return; // the cheap check first: while it builds, this is the answer
+ if (GroundWaiting > 0) return;
+ _groundPending = false;
+ GroundDone?.Invoke();
+ }
+
+ ///
+ /// Drops the SimBrief departure's requests still waiting (the aircraft rolls for takeoff): their level and priority
+ /// were chosen for the gate, and after takeoff they would compete with load-ahead for the climb. Returns how many.
+ ///
+ public int CancelDeparture() => Drop(static r => r.Departure);
+
+ private int Drop(Func which)
+ {
+ int dropped = 0;
+ lock (_gate)
+ foreach (var (key, request) in _waiting)
+ if (which(request))
+ {
+ _waiting.Remove(key); // allowed while enumerating a Dictionary
+ dropped++;
+ }
+ Wake();
+ return dropped;
+ }
+
+ /// The ground pre-warm's requests still waiting.
+ public int GroundWaiting
+ {
+ get
+ {
+ lock (_gate) return _waiting.Values.Count(w => w.Ground);
+ }
+ }
+
+ ///
+ /// Queues the SimBrief prefetch's textures, each placed by the distance X-Plane will see it from (its level) and its
+ /// order among them. Every phase gives way to what load-ahead and the flight path want ().
+ ///
+ private (int Queued, int Had) PrefetchPlaced(List<(VirtualPath Texture, double DistanceM, double Order)> placed, int? phase)
+ {
+ var requests = new List(placed.Count);
+ int had = 0;
+ PrefetchMeter meter = _phases[phase is int which ? which + 1 : 0];
+ if (_budget is { Exhausted: true }) return (0, 0); // spent: left to load-ahead
+ foreach (var (texture, distance, order) in placed)
+ {
+ if (_streamer.Get(texture) is not { } t) continue;
+ int wanted = _streamer.Model.MipFor(distance, t.SizeM, t.Layout.Size);
+ int mip = Math.Min(wanted, Coarsest(t));
+ int real = _streamer.RealFrom(t);
+ if (real <= mip)
+ {
+ had++;
+ meter.NoteHad();
+ continue;
+ }
+ // Far ones are only their base tile: before the middle levels, as X-Plane reads far scenery first. Nothing of
+ // the departure is more urgent than Warm: near the gate it is still a guess, and it must not hold up what
+ // X-Plane really asks for or load-ahead next to the camera. The ground pre-warm's phases come after all of
+ // it, in their order.
+ int priority = phase is int p ? GroundPriority(p)
+ : mip >= Coarsest(t) ? FetchPriority.Warm
+ : Math.Max(FetchPriority.Warm, PriorityOf(t, wanted));
+ requests.Add(new Request(texture, mip, priority, Distance: order, Meter: meter));
+ }
+ var queued = new List(requests.Count);
+ lock (_gate)
+ foreach (Request request in requests)
+ if (AddOrOutrank(request)) queued.Add(request.Texture);
+ foreach (VirtualPath texture in queued) meter.NoteQueued(texture); // off the gate: X-Plane's loads wait on it
+ if (queued.Count > 0) _prefetched = true;
+ if (meter.Ground && queued.Count > 0) _groundPending = true;
+ Wake();
+ return (queued.Count, had);
+ }
+
+ ///
+ /// The coarsest level worth fetching ahead: the base tile, or the finer level X-Plane's smallest read takes anyway
+ /// when that is finer (ZL18 textures: level 4, not their base level 6). Fetching only the base tile there made every
+ /// such texture wait while X-Plane loaded it (926 of 3,554 at EDDV).
+ ///
+ private static int Coarsest(StreamedTexture t) => Math.Min(t.BaseMip, ReadPattern.CoarsestRead(t.Layout));
+
+ /// A ground pre-warm phase's priority: after everything else, the phases in their order.
+ internal static int GroundPriority(int phase) => FetchPriority.Background + 1 + phase;
+
+ /// A load-ahead build's priority by the band X-Plane will read the texture in.
+ private static int PriorityOf(StreamedTexture t, int wanted) => TextureStreamer.BandOf(t, wanted) switch
+ {
+ Band.Near => FetchPriority.NearWarm,
+ Band.Middle => FetchPriority.Warm + wanted,
+ _ => FetchPriority.Background,
+ };
+
private void Add(Request request)
{
- lock (_gate) _waiting.TryAdd(request.Texture.Key, request);
+ lock (_gate) AddOrOutrank(request);
Wake();
}
+ ///
+ /// Queues unless a request of the same or a higher rank waits for the texture already
+ /// (: load-ahead and the flight path, then the SimBrief departure, then the ground pre-warm). The
+ /// SimBrief requests give way because their level is a guess made at the gate, and the takeoff roll drops them
+ /// (, ): taking a waiting load-ahead request with them would
+ /// leave a texture X-Plane loaded without one (H1). A newer departure request replaces a waiting one (a reloaded
+ /// plan); a ground request never replaces one. Under the gate.
+ ///
+ private bool AddOrOutrank(Request request)
+ {
+ string key = request.Texture.Key;
+ if (_waiting.TryGetValue(key, out Request existing))
+ {
+ int rank = Rank(request), current = Rank(existing);
+ if (rank < current || (rank == current && (request.Ground || existing.Meter is null))) return false;
+ }
+ _waiting[key] = request;
+ return true;
+ }
+
+ /// Which request keeps a texture: 2 load-ahead and the flight path, 1 the SimBrief departure, 0 the ground pre-warm.
+ private static int Rank(Request request) => request.Meter is null ? 2 : request.Ground ? 0 : 1;
+
+ /// The level and priority the request waiting for fetches now; null when none waits.
+ internal (int Mip, int Priority, bool Ground, double Order)? WaitingFor(VirtualPath texture)
+ {
+ Request request;
+ lock (_gate)
+ if (!_waiting.TryGetValue(texture.Key, out request)) return null;
+ return Target(request.Texture, request.Mip, request.Priority, request.Distance) is { } t ? (t.Mip, t.Priority, request.Ground, t.Distance) : null;
+ }
+
private void Wake()
{
if (_wake.CurrentCount == 0) _wake.Release();
}
/// The level to fetch and its priority, and the distance (for the order); null when it is not a photo texture.
- internal (StreamedTexture Texture, int Mip, int Priority, double Distance)? Target(VirtualPath path, int? mip = null, int? priority = null)
+ internal (StreamedTexture Texture, int Mip, int Priority, double Distance)? Target(VirtualPath path, int? mip = null, int? priority = null,
+ double? departureDistance = null)
{
if (_streamer.Get(path) is not { } t) return null;
+ // A level asked for ahead (flight path, departure) on a texture with nothing yet: its base tile first, in the
+ // base tiles' own places and before the middle levels. Load-ahead gives every texture one, but skips a texture
+ // that is whole already: the low-zoom base there (far scenery, colour matching, the report's map) would stay
+ // empty. And a far texture X-Plane reads before its base tile arrives gets one flat colour.
+ if (mip is int asked && asked < t.BaseMip && _streamer.RealFrom(t) > t.BaseMip)
+ return (t, t.BaseMip, priority is int ground && ground > FetchPriority.Background ? ground : Math.Min(priority ?? FetchPriority.Warm, FetchPriority.Warm),
+ departureDistance ?? (_view.Current is { } at ? ViewTracker.DistanceM(at, t.Lat, t.Lon) : double.MaxValue));
if (_view.Current is not { } camera)
- return (t, mip ?? t.BaseMip, priority ?? FetchPriority.Background, double.MaxValue);
+ return (t, mip ?? Coarsest(t), priority ?? FetchPriority.Background, departureDistance ?? double.MaxValue);
double distance = ViewTracker.DistanceM(camera, t.Lat, t.Lon);
- if (mip is int fixedMip) return (t, fixedMip, priority ?? FetchPriority.Warm, distance);
+ if (mip is int fixedMip) return (t, fixedMip, priority ?? FetchPriority.Warm, departureDistance ?? distance);
double soon = distance;
if (camera.Motion is { SpeedMps: >= PrefetchPlanner.MovingSpeedMps } motion)
@@ -142,14 +418,8 @@ private void Wake()
soon = Math.Max(0, distance - Math.Max(0, closing) * LeadSeconds);
}
int wanted = _streamer.Model.MipFor(soon, t.SizeM, t.Layout.Size);
- int target = Math.Min(wanted, t.BaseMip);
- int targetPriority = TextureStreamer.BandOf(t, wanted) switch
- {
- Band.Near => FetchPriority.NearWarm,
- Band.Middle => FetchPriority.Warm + wanted,
- _ => FetchPriority.Background,
- };
- return (t, target, targetPriority, distance);
+ int target = Math.Min(wanted, Coarsest(t));
+ return (t, target, PriorityOf(t, wanted), distance);
}
private async Task RunAsync()
@@ -159,7 +429,10 @@ private async Task RunAsync()
{
while (!stop.IsCancellationRequested)
{
+ long pass = Stopwatch.GetTimestamp();
StartWhatFits();
+ NotePass(Stopwatch.GetElapsedTime(pass));
+ CheckGroundDone();
ReportProgress();
await _wake.WaitAsync(TimeSpan.FromSeconds(1), stop).ConfigureAwait(false);
}
@@ -173,11 +446,12 @@ private async Task RunAsync()
/// Starts the most urgent waiting textures while there is room for them.
internal void StartWhatFits()
{
+ DropPrefetchOverBudget();
Request[] waiting;
lock (_gate) waiting = [.. _waiting.Values];
if (waiting.Length == 0) return;
var ranked = waiting
- .Select(r => (Request: r, Target: Target(r.Texture, r.Mip, r.Priority)))
+ .Select(r => (Request: r, Target: Target(r.Texture, r.Mip, r.Priority, r.Distance)))
.OrderBy(x => x.Target?.Priority ?? int.MaxValue).ThenBy(x => x.Target?.Distance ?? double.MaxValue);
foreach (var (request, target) in ranked)
{
@@ -187,6 +461,7 @@ internal void StartWhatFits()
Remove(request);
continue;
}
+ if (_stepping.ContainsKey(request.Texture.Key)) continue; // its base tile is on the way
BuildKind kind = KindOf(t.Texture, t.Mip);
if (Full(kind)) continue;
if (_streamer.RealFrom(t.Texture) <= t.Mip)
@@ -195,23 +470,69 @@ internal void StartWhatFits()
if (!request.Recheck) Interlocked.Increment(ref _alreadyHad);
continue;
}
- if (_streamer.Warm(request.Texture, t.Mip, t.Priority) is not { } build)
+ if (_streamer.Warm(request.Texture, t.Mip, t.Priority, request.Meter) is not { } build)
{
Remove(request);
continue;
}
- Remove(request);
+ // A base step keeps the request: its level follows once the base tile is in. If the base tile fails (a
+ // provider gap, the sea, a 404), so would the level: the request goes, instead of being ranked and tried
+ // again every pass for the rest of the session.
+ bool baseStep = request.Mip is int final && final < t.Mip;
+ if (!baseStep) Remove(request);
+ if (build.IsCompleted)
+ {
+ // Nothing started: it was there already, or it failed within the retry time.
+ if (baseStep && !Succeeded(build)) Remove(request);
+ continue;
+ }
Interlocked.Increment(ref _queued);
- if (build.IsCompleted) continue;
+ if (baseStep) _stepping[request.Texture.Key] = 0;
Interlocked.Increment(ref _running[(int)kind]);
- build.ContinueWith(_ =>
+ bool ground = request.Ground;
+ if (ground) Interlocked.Increment(ref _groundRunning);
+ string key = request.Texture.Key;
+ build.ContinueWith(done =>
{
+ if (baseStep && !Succeeded(done)) Remove(request);
+ if (ground) Interlocked.Decrement(ref _groundRunning);
Interlocked.Decrement(ref _running[(int)kind]);
+ _stepping.TryRemove(key, out byte _);
Wake();
}, TaskScheduler.Default);
}
}
+ private static bool Succeeded(Task build) => build.IsCompletedSuccessfully && build.Result;
+
+ /// Once the download budget is spent: the SimBrief requests still waiting go, and that is logged once.
+ private void DropPrefetchOverBudget()
+ {
+ if (_budget is not { Exhausted: true } budget || _budgetSpentRound == budget.Round) return;
+ _budgetSpentRound = budget.Round;
+ int dropped = Drop(static r => r.Meter is not null);
+ if (dropped > 0) _groundPending = false; // not all built: nothing to give back for
+ if (budget.FirstToReport())
+ Log.Info($"SimBrief prefetch: the download budget of {CacheInventory.FormatBytes(budget.LimitBytes)} is used up " +
+ $"(simbrief-download-limit); {dropped} textures still waiting are left to load-ahead");
+ }
+
+ ///
+ /// H2: while the ground pre-warm has work queued (the biggest queue), how long a pass takes ranking and probing it,
+ /// logged every passes with the queue's size.
+ ///
+ private void NotePass(TimeSpan took)
+ {
+ if (!_groundPending) return;
+ if (took > _longestPass) _longestPass = took;
+ if (++_passes < PassesPerReport) return;
+ int waiting;
+ lock (_gate) waiting = _waiting.Count;
+ Log.Info($"Load-ahead: {waiting} textures waiting; the last pass took {took.TotalMilliseconds:0} ms, " +
+ $"the longest of the last {_passes} {_longestPass.TotalMilliseconds:0} ms");
+ (_passes, _longestPass) = (0, TimeSpan.Zero);
+ }
+
private bool Full(BuildKind kind) => Volatile.Read(ref _running[(int)kind]) >= kind switch
{
BuildKind.Base => BaseInFlight,
diff --git a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs
new file mode 100644
index 0000000..b2a70d0
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs
@@ -0,0 +1,38 @@
+namespace OrthoStreamXP.Scenery;
+
+///
+/// The most the SimBrief prefetch (the departure and the ground pre-warm, every package together) may download in one
+/// session (simbrief-download-limit, R4): an arrival, alternate and climb can mean thousands of tiles, and a provider
+/// that rate-limits or blocks the program would hit every user. Spent by the prefetch's own builds only, in the
+/// phases' order (their priorities); load-ahead and X-Plane's reads never count. Builds already running finish.
+/// A reloaded new flight plan (a turnaround) starts it again: the limit is per flight plan within the session.
+///
+internal sealed class PrefetchBudget(long limitBytes)
+{
+ private long _spent;
+ private int _reported;
+ private int _round;
+
+ public long LimitBytes { get; } = limitBytes;
+
+ public long SpentBytes => Interlocked.Read(ref _spent);
+
+ public bool Exhausted => SpentBytes >= LimitBytes;
+
+ /// How often it was started again (): a package drops its requests once per round.
+ public int Round => Volatile.Read(ref _round);
+
+ /// A new flight plan: nothing spent yet, and the "used up" line may be logged again.
+ public void Restart()
+ {
+ Interlocked.Exchange(ref _spent, 0);
+ Interlocked.Exchange(ref _reported, 0);
+ Interlocked.Increment(ref _round);
+ }
+
+ /// A prefetch build downloaded (from its build thread).
+ public void Spend(long bytes) => Interlocked.Add(ref _spent, bytes);
+
+ /// True the first time only: the "used up" line is logged once a round, whichever package sees it first.
+ public bool FirstToReport() => Interlocked.Exchange(ref _reported, 1) == 0;
+}
diff --git a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs
new file mode 100644
index 0000000..0f7aacb
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs
@@ -0,0 +1,76 @@
+using System.Collections.Concurrent;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Scenery;
+
+///
+/// What one phase of the SimBrief prefetch (the departure, the arrival, the alternate, the climb) cost and brought: the
+/// textures it queued or found built already, the builds it started (tiles, bytes, build time), and how many of the
+/// textures it queued X-Plane loaded later (the hit rate). Logged when streaming stops; the numbers for the PR (R12)
+/// and for the download budget (R4). Thread-safe: builds report from the build threads.
+///
+/// A phase of the ground pre-warm (dropped on the takeoff roll), not the departure.
+internal sealed class PrefetchMeter(string phase, bool ground)
+{
+ private readonly ConcurrentDictionary _queued = new(), _loaded = new();
+ private long _had, _started, _built, _failed, _tiles, _bytes, _buildTicks;
+
+ public string Phase { get; } = phase;
+
+ public bool Ground { get; } = ground;
+
+ /// The session's download budget its builds spend from; null when there is none.
+ public PrefetchBudget? Budget { get; set; }
+
+ public int Queued => _queued.Count;
+
+ public long Had => Interlocked.Read(ref _had);
+
+ public long Started => Interlocked.Read(ref _started);
+
+ public long Built => Interlocked.Read(ref _built);
+
+ public long Failed => Interlocked.Read(ref _failed);
+
+ /// Tiles downloaded by its builds (the low-zoom base or the RAM cache had the rest).
+ public long Tiles => Interlocked.Read(ref _tiles);
+
+ public long Bytes => Interlocked.Read(ref _bytes);
+
+ public TimeSpan BuildTime => TimeSpan.FromTicks(Interlocked.Read(ref _buildTicks));
+
+ /// Of the textures it queued, how many X-Plane loaded afterwards.
+ public int Loaded => _loaded.Count;
+
+ public void NoteQueued(VirtualPath texture) => _queued.TryAdd(texture.Key, 0);
+
+ public void NoteHad() => Interlocked.Increment(ref _had);
+
+ public void NoteStarted() => Interlocked.Increment(ref _started);
+
+ /// A build it started ended (from the build's thread).
+ public void NoteBuild(bool ok, long tiles, long bytes, TimeSpan took)
+ {
+ Interlocked.Increment(ref ok ? ref _built : ref _failed);
+ Interlocked.Add(ref _tiles, tiles);
+ Interlocked.Add(ref _bytes, bytes);
+ Interlocked.Add(ref _buildTicks, took.Ticks);
+ Budget?.Spend(bytes);
+ }
+
+ /// X-Plane loaded a texture: a hit when this phase queued it.
+ public void NoteLoaded(VirtualPath texture)
+ {
+ if (_queued.ContainsKey(texture.Key)) _loaded.TryAdd(texture.Key, 0);
+ }
+
+ /// The line logged at stop; null when the phase did nothing this session.
+ public string? Summary()
+ {
+ if (Queued == 0 && Had == 0) return null;
+ string hits = Queued > 0 ? $"X-Plane loaded {Loaded} of the {Queued} queued ({100.0 * Loaded / Queued:0} %)" : "nothing queued";
+ return $"SimBrief prefetch, {Phase}: {Queued} textures queued, {Had} already had what X-Plane wants; " +
+ $"{Started} builds started, {Built} built, {Failed} failed; {Tiles} tiles, {CacheInventory.FormatBytes(Bytes)} downloaded; " +
+ $"{BuildTime.TotalMinutes:0.0} min of build time; {hits}";
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs
new file mode 100644
index 0000000..98d9275
--- /dev/null
+++ b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs
@@ -0,0 +1,34 @@
+namespace OrthoStreamXP.Scenery;
+
+///
+/// The scenery X-Plane loads around a place (a departure, an arrival, an alternate) or along a route: its 1° DSF tiles,
+/// and the photo textures they use, read from the package's own DSF files. These are exactly the textures X-Plane will
+/// ask for there, known before it opens anything.
+///
+internal static class SceneryArea
+{
+ ///
+ /// The photo textures of the package's DSFs around the place, each once; is how many of
+ /// its DSF tiles the package has and could read.
+ ///
+ public static IReadOnlyList Textures(string packageSource, double lat, double lon, out int tiles) =>
+ TexturesIn(packageSource, SceneryLoads.TilesAround(lat, lon), out tiles);
+
+ /// The photo textures of the package's DSFs among , each once.
+ public static IReadOnlyList TexturesIn(string packageSource, IEnumerable<(int Lat, int Lon)> dsfTiles, out int tiles)
+ {
+ var stems = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var textures = new List();
+ tiles = 0;
+ foreach (var (tileLat, tileLon) in dsfTiles.Distinct())
+ {
+ string dsf = DsfTerrain.PathIn(packageSource, tileLat, tileLon);
+ if (!File.Exists(dsf) || DsfTerrain.Read(dsf) is not { } names) continue;
+ tiles++;
+ foreach (string terrain in names)
+ if (TerrainDefinitions.TextureOf(Path.GetFileName(terrain), out _, out _) is { } name && stems.Add(name.Stem))
+ textures.Add(VirtualPath.Of("textures", name.Stem + ".dds"));
+ }
+ return textures;
+ }
+}
diff --git a/src/OrthoStreamXP/Scenery/Streaming/ReadPattern.cs b/src/OrthoStreamXP/Scenery/Streaming/ReadPattern.cs
index 9526e38..f7517b3 100644
--- a/src/OrthoStreamXP/Scenery/Streaming/ReadPattern.cs
+++ b/src/OrthoStreamXP/Scenery/Streaming/ReadPattern.cs
@@ -31,6 +31,19 @@ internal static (int? Wanted, int Reading) WantedLevel(DdsLayout layout, ReadCur
return last > first ? (first + 1, first + 1) : (null, last + 1);
}
+ ///
+ /// The coarsest level X-Plane can be after: its smallest read is the file's last 64 KB chunk, which begins inside a
+ /// finer level whose tail it holds (), so it is after the level that follows. A 4096 px
+ /// texture's last chunk starts in level 3: X-Plane takes at least level 4 (256 px) of every texture it loads, however
+ /// far, which for a ZL18 texture is two levels finer than its base tile.
+ ///
+ internal static int CoarsestRead(DdsLayout layout)
+ {
+ long start = Math.Max(Dds.HeaderSize, (layout.Length - 1) / HeaderChunk * HeaderChunk);
+ int first = layout.MipAt(start);
+ return start == layout.Offset(first) ? first : Math.Min(first + 1, layout.Levels - 1);
+ }
+
/// Notes a read on the handle for : where it ended, and where the header chunk did.
internal static void NoteRead(ReadCursor cursor, long end, bool headerChunk)
{
diff --git a/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs b/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs
index 3b8647f..a79e15d 100644
--- a/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs
+++ b/src/OrthoStreamXP/Scenery/Streaming/TextureStreamer.cs
@@ -506,7 +506,7 @@ private static void FillFlat(Span output, long fileOffset)
/// Fetches real data for levels and smaller, unless the texture has it or a build that
/// covers it is already running (that one is raised to instead).
///
- private Task StartBuild(StreamedTexture t, int k, int priority, bool byRead)
+ private Task StartBuild(StreamedTexture t, int k, int priority, bool byRead, PrefetchMeter? meter = null)
{
lock (t.Gate)
{
@@ -522,16 +522,20 @@ private Task StartBuild(StreamedTexture t, int k, int priority, bool byRea
// Every attempt already retried each tile on every server: the next one waits a little (a connection down).
if (t.FailedAt != 0 && Stopwatch.GetElapsedTime(t.FailedAt) < RetryAfter) return Task.FromResult(false);
var ticket = new FetchTicket(priority);
- Task task = Task.Run(() => BuildAsync(t, k, ticket, byRead));
+ meter?.NoteStarted();
+ Task task = Task.Run(() => BuildAsync(t, k, ticket, byRead, meter));
t.Builds[k] = (task, ticket);
return task;
}
}
- private async Task BuildAsync(StreamedTexture t, int k, FetchTicket ticket, bool byRead)
+ /// The prefetch phase that started it, told what it cost; null for every other build.
+ private async Task BuildAsync(StreamedTexture t, int k, FetchTicket ticket, bool byRead, PrefetchMeter? meter)
{
long started = Stopwatch.GetTimestamp();
bool detailed = k < t.BaseMip;
+ var stats = new TileStats();
+ bool built = false;
try
{
long weight = BuildWeight(t.Layout.SizeOf(k)), tiles = BuildTiles(t.Name, t.ZoomFor(k));
@@ -547,7 +551,6 @@ private async Task BuildAsync(StreamedTexture t, int k, FetchTicket ticket
if (ahead) _ahead.Release(tiles);
throw;
}
- var stats = new TileStats();
int zoom = t.ZoomFor(k);
byte[] chain;
try
@@ -576,6 +579,7 @@ private async Task BuildAsync(StreamedTexture t, int k, FetchTicket ticket
Log.Detail($"Streamed {t.Path.Name} levels {k}-{t.Layout.Levels - 1} ({t.Layout.SizeOf(k)} px) from zoom {zoom}: " +
$"{stats.Misses} tiles downloaded, {Stopwatch.GetElapsedTime(started).TotalSeconds:F1} s ({(byRead ? "X-Plane read" : "ahead")})");
TrimIfNeeded();
+ built = true;
return true;
}
catch (Exception ex)
@@ -588,6 +592,7 @@ private async Task BuildAsync(StreamedTexture t, int k, FetchTicket ticket
finally
{
lock (t.Gate) t.Builds.Remove(k);
+ meter?.NoteBuild(built, stats.Misses, Interlocked.Read(ref stats.Bytes), Stopwatch.GetElapsedTime(started));
}
}
@@ -623,14 +628,15 @@ private void Save(StreamedTexture t, int k, byte[] chain)
/// Fetches levels and smaller ahead of X-Plane (no wait). Returns the build, finished at
/// once when the texture already has them, or null when the path is not a photo texture.
///
- public Task? Warm(VirtualPath path, int k, int priority)
+ /// The SimBrief prefetch phase asking, told what a build it starts costs.
+ public Task? Warm(VirtualPath path, int k, int priority, PrefetchMeter? meter = null)
{
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, PhotosPaused ? t.BaseMip : 0, t.BaseMip), priority, byRead: false);
+ return StartBuild(t, Math.Clamp(k, PhotosPaused ? t.BaseMip : 0, t.BaseMip), priority, byRead: false, meter);
}
private long _warmRequests;
diff --git a/src/OrthoStreamXP/Setup/Cache/CacheAreas.cs b/src/OrthoStreamXP/Setup/Cache/CacheAreas.cs
index 1c74db9..199f157 100644
--- a/src/OrthoStreamXP/Setup/Cache/CacheAreas.cs
+++ b/src/OrthoStreamXP/Setup/Cache/CacheAreas.cs
@@ -93,7 +93,9 @@ public static (IReadOnlyList Removed, long Freed) Trim(string cache, l
if (limitBytes <= 0 || total <= limitBytes) return ([], 0);
long target = limitBytes * 9 / 10;
var remove = new HashSet();
- foreach (CacheArea area in areas.OrderBy(a => a.LastUsedUtc))
+ // An area X-Plane never opened (prefetched for a flight that never went there)
+ // goes before every area really flown: its newest file write would otherwise make it look used just now.
+ foreach (CacheArea area in areas.OrderBy(a => usage.LastUsed(a.Name) is null ? 0 : 1).ThenBy(a => a.LastUsedUtc))
{
if (total <= target) break;
remove.Add(area.Name);
diff --git a/src/OrthoStreamXP/Setup/Settings/ConnectionTest.cs b/src/OrthoStreamXP/Setup/Settings/ConnectionTest.cs
index 9947652..19986b1 100644
--- a/src/OrthoStreamXP/Setup/Settings/ConnectionTest.cs
+++ b/src/OrthoStreamXP/Setup/Settings/ConnectionTest.cs
@@ -134,7 +134,7 @@ public static async Task RunAsync(ImageryProvider provider, IP
AutomaticDecompression = DecompressionMethods.None,
})
{ Timeout = TimeSpan.FromSeconds(10) };
- http.DefaultRequestHeaders.UserAgent.ParseAdd("orthostreamxp/0.2");
+ http.DefaultRequestHeaders.UserAgent.ParseAdd(HttpIdentity.UserAgent);
using IEnumerator<(int X, int Y)> tiles = TestTiles(provider).GetEnumerator();
// Connections are opened first, so the first round measures downloads and not their set-up.
diff --git a/src/OrthoStreamXP/Textures/Tiles/TileSource.cs b/src/OrthoStreamXP/Textures/Tiles/TileSource.cs
index 739b081..7c05b4a 100644
--- a/src/OrthoStreamXP/Textures/Tiles/TileSource.cs
+++ b/src/OrthoStreamXP/Textures/Tiles/TileSource.cs
@@ -8,6 +8,8 @@ namespace OrthoStreamXP.Textures;
internal sealed class TileStats
{
public int Hits, Misses, Calls, Fallbacks;
+ /// Bytes downloaded for the build (every answer, as the session's own count).
+ public long Bytes;
private long _downloadTicks;
public double DownloadSeconds => TimeSpan.FromTicks(Interlocked.Read(ref _downloadTicks)).TotalSeconds;
@@ -150,7 +152,7 @@ public TileSource(IReadOnlyList providers, int downloadWorkers,
AutomaticDecompression = DecompressionMethods.None,
})
{ Timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 120)) };
- _http.DefaultRequestHeaders.UserAgent.ParseAdd("orthostreamxp/0.2");
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd(HttpIdentity.UserAgent);
}
/// The provider names in the order they are tried, e.g. "esri,bing".
@@ -343,6 +345,7 @@ private async Task DownloadAsync(int x, int y, int zoom, Pending pending
byte[] bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
Interlocked.Increment(ref _tilesDownloaded);
Interlocked.Add(ref _bytesDownloaded, bytes.Length);
+ Interlocked.Add(ref stats.Bytes, bytes.Length);
Interlocked.Add(ref _tileTicks, Stopwatch.GetElapsedTime(started).Ticks);
byte[] decoded = Images.DecodeTile(bytes); // rejects error pages and truncated data
stats.AddDownload(Stopwatch.GetElapsedTime(started));
diff --git a/src/OrthoStreamXP/orthostreamxp.example.ini b/src/OrthoStreamXP/orthostreamxp.example.ini
index b575f17..d81a356 100644
--- a/src/OrthoStreamXP/orthostreamxp.example.ini
+++ b/src/OrthoStreamXP/orthostreamxp.example.ini
@@ -83,6 +83,16 @@ 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)
prefetch-loaded-scenery = on
+# SimBrief username (or numeric Pilot ID): when streaming starts, the photos around the departure airport of your
+# latest SimBrief flight plan are built before X-Plane loads them; while the aircraft stands at the gate, the arrival
+# and the climb follow. What the cache has already is skipped (empty: off)
+# simbrief-user = yourname
+# with mip streaming off: how far around the departure airport, at full detail, 1-50 km
+# simbrief-radius-km = 10
+# the most the SimBrief prefetch may download per streaming session, in MB; the rest is left to load-ahead (0: no limit)
+# simbrief-download-limit = 1000
+# while the aircraft stands at the gate, build the alternate airport's scenery too (rarely flown: off by default)
+# simbrief-alternate = off
# --- Following the aircraft -------------------------------------------------------
track-aircraft = true
diff --git a/tests/OrthoStreamXP.Tests/CacheAreasTests.cs b/tests/OrthoStreamXP.Tests/CacheAreasTests.cs
index c44d7f5..ef49d53 100644
--- a/tests/OrthoStreamXP.Tests/CacheAreasTests.cs
+++ b/tests/OrthoStreamXP.Tests/CacheAreasTests.cs
@@ -78,6 +78,38 @@ public void Over_the_limit_the_areas_flown_longest_ago_go_first_down_to_90_perce
Assert.Null(AreaUsage.Load(Cache).LastUsed(new AreaName(50, 13))); // forgotten on disk too
}
+ [Fact]
+ public void An_area_only_prefetched_goes_before_an_area_flown_even_when_its_files_are_newer()
+ {
+ // R5: an area was prefetched (files written just now) but never flown; Wrocław was flown earlier.
+ File($"level0_4096-4096/P/textures/{Wroclaw}.dds", 600);
+ AreaUsage usage = AreaUsage.Load(Cache);
+ usage.Note(new AreaName(51, 17));
+ Thread.Sleep(20);
+ File($"level0_4096-4096/P/textures/{Prague}.dds", 600); // the pre-warm writes it after the flight's last use
+
+ var (removed, _) = CacheAreas.Trim(Cache, 1000, usage, TestContext.Current.CancellationToken);
+
+ Assert.Equal([new AreaName(50, 13)], removed);
+ }
+
+ [Fact]
+ public void An_area_prefetched_and_flown_later_is_trimmed_by_its_age_like_any_other()
+ {
+ // R5: a pre-warmed area counts as never flown only until X-Plane opens it; then the oldest use goes first again.
+ File($"level0_4096-4096/P/textures/{Prague}.dds", 600); // pre-warmed first...
+ AreaUsage usage = AreaUsage.Load(Cache);
+ usage.Note(new AreaName(51, 17)); // ...Wrocław flown...
+ Thread.Sleep(20);
+ File($"level0_4096-4096/P/textures/{Wroclaw}.dds", 600);
+ Thread.Sleep(20);
+ usage.Note(new AreaName(50, 13)); // ...and then Prague flown too, last
+
+ var (removed, _) = CacheAreas.Trim(Cache, 1000, usage, TestContext.Current.CancellationToken);
+
+ Assert.Equal([new AreaName(51, 17)], removed); // the area flown longest ago, not the one first only prefetched
+ }
+
[Fact]
public void An_area_is_pictured_from_its_textures_smallest_levels()
{
diff --git a/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs
new file mode 100644
index 0000000..2eca5e0
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs
@@ -0,0 +1,463 @@
+using System.Globalization;
+using System.Text;
+using OrthoStreamXP.Scenery;
+
+namespace OrthoStreamXP.Tests;
+
+///
+/// The ground pre-warm: the flight plan's airports and navlog, the climb it covers, when it runs, and what it queues,
+/// including plans without a TOC, with the alternate the same as the arrival, and with the arrival the same as the departure.
+///
+public sealed class GroundPrewarmTests : IDisposable
+{
+ private readonly TempTree _tree = new();
+
+ public void Dispose() => _tree.Dispose();
+
+ private static readonly FlightOrigin Eddv = new("EDDV", 52.4611, 9.6851, 183), Eddf = new("EDDF", 50.0333, 8.5706, 364),
+ Eddk = new("EDDK", 50.8659, 7.1427, 302), Eddh = new("EDDH", 53.6304, 9.9882, 53);
+
+ // ---------------------------------------------------------------- a flight plan as SimBrief sends it
+
+ private static string Airport(FlightOrigin a) =>
+ string.Create(CultureInfo.InvariantCulture, $$"""{"icao_code":"{{a.Icao}}","pos_lat":"{{a.Lat}}","pos_long":"{{a.Lon}}","elevation":"{{a.ElevationFt}}"}""");
+
+ private static string Fix(RoutePoint f) =>
+ string.Create(CultureInfo.InvariantCulture,
+ $$"""{"ident":"{{f.Ident}}","pos_lat":"{{f.Lat}}","pos_long":"{{f.Lon}}","altitude_feet":"{{f.AltitudeFt}}","time_total":"{{f.Minutes * 60}}","stage":"{{f.Stage}}"}""");
+
+ /// An OFP: is written as SimBrief does, an object or (several) an array.
+ private static string Ofp(FlightOrigin from, FlightOrigin? to, FlightOrigin[] alternates, params RoutePoint[] fixes)
+ {
+ var json = new StringBuilder("""{"fetch":{"status":"Success"},""");
+ json.Append(CultureInfo.InvariantCulture, $"\"origin\":{Airport(from)},");
+ if (to is not null) json.Append(CultureInfo.InvariantCulture, $"\"destination\":{Airport(to)},");
+ if (alternates.Length == 1) json.Append(CultureInfo.InvariantCulture, $"\"alternate\":{Airport(alternates[0])},");
+ else if (alternates.Length > 1) json.Append(CultureInfo.InvariantCulture, $"\"alternate\":[{string.Join(",", alternates.Select(Airport))}],");
+ json.Append(fixes.Length == 1 ? $"\"navlog\":{{\"fix\":{Fix(fixes[0])}}}" : $"\"navlog\":{{\"fix\":[{string.Join(",", fixes.Select(Fix))}]}}");
+ return json.Append('}').ToString();
+ }
+
+ /// EDDV to EDDF: due south-west, climbing 2,000 ft a minute at 6 NM a minute, TOC at FL360 after 18 minutes.
+ private static RoutePoint[] ClimbSouth(bool toc = true, bool stages = true)
+ {
+ var fixes = new List { new("EDDV", Eddv.Lat, Eddv.Lon, 183, 0, stages ? "CLB" : "") };
+ for (int minute = 3; minute <= 18; minute += 3)
+ {
+ double km = minute * 6 * 1.852;
+ fixes.Add(new RoutePoint(minute == 18 && toc ? "TOC" : $"WP{minute}", Eddv.Lat - km / 111.2, Eddv.Lon - km / 2 / 68, minute * 2000, minute, stages ? "CLB" : ""));
+ }
+ fixes.Add(new RoutePoint("WP21", Eddv.Lat - 18 * 6 * 1.852 / 111.2 - 0.3, Eddv.Lon - 1, 36000, 21, stages ? "CRZ" : ""));
+ fixes.Add(new RoutePoint("EDDF", Eddf.Lat, Eddf.Lon, 364, 45, stages ? "DSC" : ""));
+ return [.. fixes];
+ }
+
+ [Fact]
+ public void Reads_the_airports_and_the_navlog_of_a_flight_plan()
+ {
+ SimBriefAnswer answer = SimBriefClient.Parse(Ofp(Eddv, Eddf, [Eddk], ClimbSouth()));
+ FlightPlan plan = answer.Plan!;
+
+ Assert.Equal(Eddv, plan.Origin);
+ Assert.Equal(Eddf, plan.Destination);
+ Assert.Equal(Eddk, plan.Alternate);
+ Assert.Equal(9, plan.Route.Count);
+ Assert.Equal(("TOC", 36000.0, 18.0, "CLB"), (plan.Route[6].Ident, plan.Route[6].AltitudeFt, plan.Route[6].Minutes, plan.Route[6].Stage));
+
+ // Several alternates come as an array: the first is taken. A one-point navlog comes as an object.
+ FlightPlan two = SimBriefClient.Parse(Ofp(Eddv, Eddf, [Eddk, Eddh], ClimbSouth()[1])).Plan!;
+ Assert.Equal(Eddk, two.Alternate);
+ Assert.Single(two.Route);
+
+ // No destination or alternate in the plan: none.
+ FlightPlan bare = SimBriefClient.Parse(Ofp(Eddv, null, [], ClimbSouth()[1])).Plan!;
+ Assert.Null(bare.Destination);
+ Assert.Null(bare.Alternate);
+ }
+
+ // ---------------------------------------------------------------- the climb
+
+ [Fact]
+ public void The_climb_ends_at_the_top_of_climb()
+ {
+ ClimbRoute.Climb climb = ClimbRoute.Of(new FlightPlan(Eddv, Eddf, null, ClimbSouth()));
+
+ Assert.Equal("the top of climb", climb.End);
+ Assert.Equal(18, climb.Minutes, 3);
+ Assert.InRange(climb.Nm, 115, 125); // 108 NM south and 54 NM east of it in 18 minutes: 121 NM along the track
+ Assert.Equal(0, climb.Samples[0].HeightM);
+ Assert.Equal((36000 - 183) * 0.3048, climb.Samples[^1].HeightM, 1); // above the departure, not the sea
+ Assert.All(climb.Samples.Zip(climb.Samples.Skip(1)), p => Assert.InRange(p.Second.AlongKm - p.First.AlongKm, 0.1, ClimbRoute.StepKm + 0.01));
+ }
+
+ [Fact]
+ public void A_plan_without_a_TOC_ends_its_climb_where_the_climb_stage_does()
+ {
+ // a) SimBrief plans without a TOC point: the stages still say where the climb ends.
+ ClimbRoute.Climb climb = ClimbRoute.Of(new FlightPlan(Eddv, Eddf, null, ClimbSouth(toc: false)));
+
+ Assert.Equal("the end of the climb", climb.End);
+ Assert.Equal(18, climb.Minutes, 3); // the last CLB point, WP18; the CRZ point after it is left out
+ }
+
+ [Fact]
+ public void A_plan_without_a_TOC_or_stages_ends_at_the_caps()
+ {
+ // a) Neither a TOC nor stages: 25 minutes of flight come first here (25 x 6 NM = 150 NM, under 200 NM).
+ ClimbRoute.Climb byTime = ClimbRoute.Of(new FlightPlan(Eddv, Eddf, null, ClimbSouth(toc: false, stages: false)));
+ Assert.Equal("25 min", byTime.End);
+ Assert.Equal(25, byTime.Minutes, 3);
+
+ // A plan without times, on to Zurich (250 NM): 200 NM along the route.
+ RoutePoint[] untimed = [.. ClimbSouth(toc: false, stages: false).Select(f => f with { Minutes = 0 }), new("LSZH", 47.4647, 8.5492, 1416, 0, "")];
+ ClimbRoute.Climb byDistance = ClimbRoute.Of(new FlightPlan(Eddv, Eddf, null, untimed));
+ Assert.Equal("200 NM", byDistance.End);
+ Assert.Equal(200, byDistance.Nm, 0);
+ }
+
+ [Fact]
+ public void The_climb_covers_the_tiles_it_crosses_and_one_either_side_and_sees_its_height()
+ {
+ ClimbRoute.Climb climb = ClimbRoute.Of(new FlightPlan(Eddv, Eddf, null, ClimbSouth()));
+ IReadOnlyList<(int Lat, int Lon)> tiles = ClimbRoute.Tiles(climb.Samples);
+
+ Assert.Contains((52, 9), tiles); // the departure's
+ Assert.Contains((53, 10), tiles); // a neighbour of it
+ Assert.Contains((50, 8), tiles); // the tile of the top of climb
+ Assert.DoesNotContain((50, 11), tiles); // two columns away from every tile the climb crosses
+
+ // Beside the departure the aircraft is on the ground; under the top of climb it is 11 km up.
+ Assert.InRange(ClimbRoute.Nearest(climb.Samples, Eddv.Lat, Eddv.Lon).DistanceM, 0, 1);
+ var toc = climb.Samples[^1];
+ var (distance, along) = ClimbRoute.Nearest(climb.Samples, toc.Lat, toc.Lon);
+ Assert.True(distance >= toc.HeightM - 1, $"{distance} m from a point {toc.HeightM} m below the aircraft");
+ Assert.True(along > 150);
+ }
+
+ // ---------------------------------------------------------------- when it runs
+
+ [Fact]
+ public void It_starts_after_half_a_minute_parked_at_the_departure_and_stops_for_the_takeoff_roll()
+ {
+ var watch = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ GroundDecision At(double seconds, double? kt = null, double heightM = 3, bool inferred = false, double lat = 52.4611) =>
+ watch.Update(lat, Eddv.Lon, kt, heightM, inferred, t0.AddSeconds(seconds));
+
+ Assert.Equal(GroundDecision.None, At(0, inferred: true)); // placed from X-Plane's reads: not yet
+ Assert.Equal(GroundDecision.None, At(1));
+ Assert.Equal(GroundDecision.None, At(20, kt: 10)); // moving: the 30 s start again
+ Assert.Equal(GroundDecision.None, At(21));
+ Assert.Equal(GroundDecision.None, At(50));
+ Assert.Equal(GroundDecision.Start, At(51.5));
+ Assert.Equal(GroundDecision.None, At(300, kt: 15)); // taxiing goes on
+ Assert.Equal(GroundDecision.Stop, At(400, kt: 60)); // the takeoff roll
+ Assert.Equal(GroundDecision.None, At(2000)); // parked again (after landing): never a second time
+
+ var away = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ Assert.Equal(GroundDecision.None, away.Update(Eddf.Lat, Eddf.Lon, 0, 3, false, t0));
+ Assert.Equal(GroundDecision.None, away.Update(Eddf.Lat, Eddf.Lon, 0, 3, false, t0.AddMinutes(5))); // not at this plan's departure
+
+ // The height is the camera's: an outside view 150 m up at the gate neither keeps it from starting nor stops it.
+ var outside = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ outside.Update(Eddv.Lat, Eddv.Lon, 0, 150, false, t0);
+ Assert.Equal(GroundDecision.Start, outside.Update(Eddv.Lat, Eddv.Lon, 0, 150, false, t0.AddSeconds(31)));
+ Assert.Equal(GroundDecision.None, outside.Update(Eddv.Lat, Eddv.Lon, 0, 400, false, t0.AddSeconds(60)));
+ Assert.Equal(GroundDecision.Stop, outside.Update(Eddv.Lat, Eddv.Lon, 45, 400, false, t0.AddSeconds(90)));
+
+ // Paused in the air near the departure (no speed, 2 km up): not parked.
+ var paused = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ paused.Update(Eddv.Lat, Eddv.Lon, 0, 2000, false, t0);
+ Assert.Equal(GroundDecision.None, paused.Update(Eddv.Lat, Eddv.Lon, 0, 2000, false, t0.AddMinutes(2)));
+ }
+
+ [Fact]
+ public void With_XPlanes_on_ground_flag_the_camera_height_does_not_matter()
+ {
+ // R6/M4: an outside view lifts the camera well above the aircraft. With onground_any it starts all the same, and
+ // the aircraft lifting off ends it even if no roll faster than 40 kt was seen.
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ var high = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ Assert.Equal(GroundDecision.None, high.Update(Eddv.Lat, Eddv.Lon, 0, 2000, false, t0, onGround: true));
+ Assert.Equal(GroundDecision.Start, high.Update(Eddv.Lat, Eddv.Lon, 0, 2000, false, t0.AddSeconds(31), onGround: true));
+ Assert.Equal(GroundDecision.None, high.Update(Eddv.Lat, Eddv.Lon, 20, 2000, false, t0.AddSeconds(60), onGround: true)); // taxiing
+ Assert.Equal(GroundDecision.Stop, high.Update(Eddv.Lat, Eddv.Lon, 30, 50, false, t0.AddSeconds(90), onGround: false)); // airborne
+
+ // Paused in the air: X-Plane says so, whatever the camera's height.
+ var air = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ air.Update(Eddv.Lat, Eddv.Lon, 0, 3, false, t0, onGround: false);
+ Assert.Equal(GroundDecision.None, air.Update(Eddv.Lat, Eddv.Lon, 0, 3, false, t0.AddMinutes(2), onGround: false));
+ }
+
+ [Fact]
+ public void XPlanes_own_ground_speed_wins_over_the_speed_worked_out_from_the_positions()
+ {
+ // Moving the camera fast in an outside view, a pushback or a reposition: the speed from the positions is not the aircraft's.
+ int departure = 0;
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, null, []));
+ var prewarm = new GroundPrewarm(plan, [new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => 0, () => ++departure)]);
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ CameraReport At(double seconds, double motionKt, double? xplaneKt) =>
+ new(Eddv.Lat, Eddv.Lon, 3, null, new Motion(motionKt / GroundWatch.KnotsPerMps, 270), false, t0.AddSeconds(seconds),
+ GroundSpeedKt: xplaneKt, OnGround: true);
+
+ prewarm.OnCamera(At(0, 80, 0));
+ Assert.Equal(0, departure); // 80 kt from the positions, 0 kt from X-Plane: standing
+ prewarm.OnCamera(At(1, 80, 60));
+ Assert.Equal(1, departure); // X-Plane's 60 kt: the roll
+ }
+
+ [Fact]
+ public void A_takeoff_roll_before_the_prewarm_ran_is_said_once()
+ {
+ var watch = new GroundWatch(Eddv.Lat, Eddv.Lon);
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ GroundDecision At(double seconds, double? kt = null, bool inferred = false) =>
+ watch.Update(Eddv.Lat, Eddv.Lon, kt, 3, inferred, t0.AddSeconds(seconds));
+
+ Assert.Equal(GroundDecision.None, At(0));
+ Assert.Equal(GroundDecision.None, At(10, kt: 60, inferred: true)); // not X-Plane's position
+ Assert.Equal(GroundDecision.Rolled, At(20, kt: 60)); // parked only 20 s: never started, but the roll counts
+ Assert.Equal(GroundDecision.None, At(25, kt: 120)); // once
+ Assert.Equal(GroundDecision.None, At(30, kt: 140));
+ }
+
+ [Fact]
+ public void The_takeoff_roll_drops_the_departures_requests_whether_or_not_the_prewarm_ran()
+ {
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, null, []));
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ CameraReport At(double seconds, double kt) => Parked(t0.AddSeconds(seconds), kt);
+
+ // Taxied out at once: the pre-warm never started, the departure's requests still go.
+ int ground = 0, departure = 0;
+ var target = new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => ++ground, () => ++departure);
+ var prewarm = new GroundPrewarm(plan, [target]);
+ prewarm.OnCamera(At(0, 10));
+ prewarm.OnCamera(At(60, 60));
+ Assert.Equal((0, 1), (ground, departure));
+ prewarm.OnCamera(At(70, 120));
+ Assert.Equal((0, 1), (ground, departure)); // once
+
+ // Parked long enough: the pre-warm ran, and the roll drops both.
+ int ground2 = 0, departure2 = 0;
+ var target2 = new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => ++ground2, () => ++departure2);
+ var ran = new GroundPrewarm(plan, [target2]);
+ ran.OnCamera(At(0, 0));
+ ran.OnCamera(At(31, 0)); // starts (queues nothing: the package has no scenery here)
+ ran.OnCamera(At(600, 60));
+ Assert.True(ground2 >= 1 && departure2 >= 1, $"ground {ground2}, departure {departure2}");
+ }
+
+ [Fact]
+ public void A_first_position_far_from_the_departure_drops_the_departures_requests_once()
+ {
+ // R11: the plan's age passed at mount start, but the aircraft is somewhere else: the plan is not this flight's.
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, null, []));
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ int departure = 0;
+ var prewarm = new GroundPrewarm(plan, [new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => 0, () => ++departure)]);
+
+ prewarm.OnCamera(new CameraReport(Eddv.Lat, Eddv.Lon, 3, null, null, false, t0, Inferred: true)); // placed from X-Plane's reads: not a position
+ Assert.Equal(0, departure);
+ prewarm.OnCamera(new CameraReport(Eddf.Lat, Eddf.Lon, 3, null, null, false, t0.AddSeconds(1))); // at EDDF, 270 km away
+ Assert.Equal(1, departure);
+ prewarm.OnCamera(new CameraReport(Eddf.Lat, Eddf.Lon, 3, null, null, false, t0.AddSeconds(2)));
+ Assert.Equal(1, departure); // only the first position
+
+ int atGate = 0;
+ var here = new GroundPrewarm(plan, [new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => 0, () => ++atGate)]);
+ here.OnCamera(new CameraReport(Eddv.Lat + 0.01, Eddv.Lon, 3, null, null, false, t0));
+ Assert.Equal(0, atGate);
+ }
+
+ private static CameraReport Parked(DateTime at, double kt = 0) =>
+ new(Eddv.Lat, Eddv.Lon, 3, null, kt > 0 ? new Motion(kt / GroundWatch.KnotsPerMps, 270) : null, false, at);
+
+ [Fact]
+ public async Task Stopping_streaming_while_it_queues_neither_throws_nor_queues_on()
+ {
+ // M2: the packages' load-ahead is disposed while the pre-warm is still queuing.
+ using var stop = new CancellationTokenSource();
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, Eddk, ClimbSouth()));
+ int calls = 0;
+ var (pkg, _) = Package(Everywhere);
+ var target = pkg with
+ {
+ PrefetchAround = (_, _, _, _) =>
+ {
+ calls++;
+ stop.Cancel(); // streaming stops during the first phase...
+ throw new ObjectDisposedException("MipWarmer"); // ...and the load-ahead is gone
+ },
+ };
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ var prewarm = new GroundPrewarm(plan, [target], stop: stop.Token, run: a => Task.Run(a, TestContext.Current.CancellationToken));
+
+ prewarm.OnCamera(Parked(t0));
+ prewarm.OnCamera(Parked(t0.AddSeconds(31)));
+ await prewarm.Running; // completes, doesn't throw
+ Assert.Equal(1, calls); // the alternate and the climb were not queued after the stop
+
+ prewarm.OnCamera(Parked(t0.AddSeconds(40), kt: 60));
+ Assert.True(prewarm.Running.IsCompletedSuccessfully);
+ }
+
+ [Fact]
+ public async Task An_unexpected_failure_while_queuing_is_logged_not_lost()
+ {
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, null, []));
+ var (pkg, _) = Package(Everywhere);
+ var target = pkg with { PrefetchAround = (_, _, _, _) => throw new InvalidOperationException("a bug") };
+ var lines = new List();
+ void Sink(string line)
+ {
+ if (line.Contains("Ground pre-warm failed", StringComparison.Ordinal)) lock (lines) lines.Add(line);
+ }
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ var prewarm = new GroundPrewarm(plan, [target], run: a => Task.Run(a, TestContext.Current.CancellationToken));
+ Log.Sink += Sink;
+ try
+ {
+ prewarm.OnCamera(Parked(t0));
+ prewarm.OnCamera(Parked(t0.AddSeconds(31)));
+ await prewarm.Running;
+ Assert.True(Log.Flush(TimeSpan.FromSeconds(5)));
+ }
+ finally
+ {
+ Log.Sink -= Sink;
+ }
+ Assert.Contains(lines, l => l.Contains("InvalidOperationException: a bug", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void The_takeoff_roll_is_logged_once_even_while_it_still_queues()
+ {
+ // L1: the aircraft rolls before the queuing is done.
+ var plan = Task.FromResult(new FlightPlan(Eddv, Eddf, null, []));
+ Action? pending = null;
+ int cancels = 0;
+ var target = new GroundTarget("pkg", _tree.PathOf("pkg"), (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => ++cancels, () => 0);
+ var lines = new List();
+ void Sink(string line)
+ {
+ if (line.Contains("rolling for takeoff", StringComparison.Ordinal)) lock (lines) lines.Add(line);
+ }
+ var t0 = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
+ var prewarm = new GroundPrewarm(plan, [target], run: a =>
+ {
+ pending = a; // still queuing
+ return Task.CompletedTask;
+ });
+ Log.Sink += Sink;
+ try
+ {
+ prewarm.OnCamera(Parked(t0));
+ prewarm.OnCamera(Parked(t0.AddSeconds(31)));
+ prewarm.OnCamera(Parked(t0.AddSeconds(60), kt: 60)); // the roll
+ pending!(); // the queuing ends after it
+ Assert.True(Log.Flush(TimeSpan.FromSeconds(5)));
+ }
+ finally
+ {
+ Log.Sink -= Sink;
+ }
+ Assert.Single(lines);
+ Assert.Equal(2, cancels); // what was queued after the roll's cancel is dropped too
+ }
+
+ // ---------------------------------------------------------------- what it queues
+
+ private sealed record Call(int Phase, int Textures, double FirstDistanceM);
+
+ /// A package with one texture in each DSF tile named, and a target that records what it is asked to queue.
+ private (GroundTarget Target, List Calls) Package(params (int Lat, int Lon)[] tiles)
+ {
+ int n = 0;
+ foreach (var (lat, lon) in tiles)
+ {
+ string path = DsfTerrain.PathIn("pkg", lat, lon).Replace('\\', '/');
+ _tree.WriteBytes(path, SceneryAreaTests.Dsf($"terrain/{21552 + 16 * n++}_34576_BI16.ter"));
+ }
+ var calls = new List();
+ var target = new GroundTarget("pkg", _tree.PathOf("pkg"), (textures, lat, lon, phase) =>
+ {
+ calls.Add(new Call(phase ?? -1, textures.Count, Geo.DistanceM(lat, lon, Eddv.Lat, Eddv.Lon)));
+ return (textures.Count, 0);
+ }, (textures, place, phase) =>
+ {
+ calls.Add(new Call(phase, textures.Count, place(Eddv.Lat, Eddv.Lon).DistanceM));
+ return (textures.Count, 0);
+ }, () => 0, () => 0);
+ return (target, calls);
+ }
+
+ private static readonly (int, int)[] Everywhere =
+ [.. Enumerable.Range(48, 7).SelectMany(lat => Enumerable.Range(5, 8).Select(lon => (lat, lon)))];
+
+ [Fact]
+ public void Queues_the_arrival_then_the_alternate_then_the_climb()
+ {
+ var (target, calls) = Package(Everywhere);
+ int queued = new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddf, Eddk, ClimbSouth()));
+
+ Assert.Equal([GroundPrewarm.Arrival, GroundPrewarm.Alternate, GroundPrewarm.Route], calls.Select(c => c.Phase));
+ Assert.Equal(12, calls[0].Textures); // the 3 x 4 tiles round EDDF
+ Assert.Equal(12, calls[1].Textures); // and round EDDK
+ Assert.True(calls[2].Textures > 12); // the tiles under the climb and either side
+ Assert.InRange(calls[0].FirstDistanceM, 250_000, 300_000); // EDDV is 270 km from EDDF: placed from the arrival
+ Assert.InRange(calls[2].FirstDistanceM, 0, 1); // the departure lies on the climb
+ Assert.Equal(calls.Sum(c => c.Textures), queued);
+ }
+
+ [Fact]
+ public void The_alternate_is_left_out_unless_it_is_switched_on()
+ {
+ // The maintainer's PR structure, step 3: the alternate is rarely flown, so it is behind its own switch.
+ var (target, calls) = Package(Everywhere);
+ new GroundPrewarm(Task.FromResult(null), [target], alternate: false).Queue(new FlightPlan(Eddv, Eddf, Eddk, ClimbSouth()));
+
+ Assert.Equal([GroundPrewarm.Arrival, GroundPrewarm.Route], calls.Select(c => c.Phase));
+ }
+
+ [Fact]
+ public void An_alternate_that_is_the_arrival_is_not_built_twice()
+ {
+ // b) SimBrief plans with the destination as the alternate.
+ var (target, calls) = Package(Everywhere);
+ new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddf, Eddf with { Icao = "" }, ClimbSouth()));
+
+ Assert.Equal([GroundPrewarm.Arrival, GroundPrewarm.Route], calls.Select(c => c.Phase)); // the same place, even without its code
+ }
+
+ [Fact]
+ public void An_arrival_that_is_the_departure_leaves_its_area_to_the_departure_prefetch()
+ {
+ // c) A local flight (a circuit, a test flight): back to the departure.
+ RoutePoint[] circuit =
+ [
+ new("EDDV", Eddv.Lat, Eddv.Lon, 183, 0, "CLB"), new("WP1", Eddv.Lat + 0.1, Eddv.Lon, 3000, 3, "CLB"),
+ new("TOC", Eddv.Lat + 0.2, Eddv.Lon + 0.1, 5000, 5, "CLB"), new("WP2", Eddv.Lat + 0.1, Eddv.Lon + 0.2, 5000, 8, "CRZ"),
+ new("EDDV", Eddv.Lat, Eddv.Lon, 183, 15, "DSC"),
+ ];
+ var (target, calls) = Package(Everywhere);
+ new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddv, Eddh, circuit));
+
+ Assert.Equal([GroundPrewarm.Alternate, GroundPrewarm.Route], calls.Select(c => c.Phase)); // no arrival phase; EDDH still is
+
+ // With the alternate the departure too, only the climb is left.
+ var (only, onlyCalls) = Package(Everywhere);
+ new GroundPrewarm(Task.FromResult(null), [only]).Queue(new FlightPlan(Eddv, Eddv, Eddv, circuit));
+ Assert.Equal([GroundPrewarm.Route], onlyCalls.Select(c => c.Phase));
+ }
+
+ [Fact]
+ public void A_plan_without_a_navlog_still_gets_its_arrival_and_alternate()
+ {
+ var (target, calls) = Package(Everywhere);
+ new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddf, Eddk, []));
+
+ Assert.Equal([GroundPrewarm.Arrival, GroundPrewarm.Alternate], calls.Select(c => c.Phase));
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/LoadMeasureTests.cs b/tests/OrthoStreamXP.Tests/LoadMeasureTests.cs
index 7e56f31..777fa00 100644
--- a/tests/OrthoStreamXP.Tests/LoadMeasureTests.cs
+++ b/tests/OrthoStreamXP.Tests/LoadMeasureTests.cs
@@ -48,7 +48,7 @@ public void A_tile_is_logged_once_however_often_it_is_opened()
}
lock (lines)
{
- string[] loaded = [.. lines.Where(l => l.Contains("scenery tile", StringComparison.Ordinal))];
+ string[] loaded = [.. lines.Where(l => l.Contains("X-Plane loads the scenery tile", StringComparison.Ordinal))]; // not the lines of tests running beside it
Assert.Equal(2, loaded.Length);
Assert.Contains("+47+010 of Europe; no position from X-Plane yet", loaded[0], StringComparison.Ordinal);
Assert.Contains("+46+010", loaded[1], StringComparison.Ordinal);
diff --git a/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs b/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs
new file mode 100644
index 0000000..f323517
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs
@@ -0,0 +1,79 @@
+using System.Buffers.Binary;
+using System.Text;
+using OrthoStreamXP.Scenery;
+
+namespace OrthoStreamXP.Tests;
+
+/// The scenery X-Plane loads around a departure: its DSF tiles, and the textures their TERT tables name.
+public sealed class SceneryAreaTests : IDisposable
+{
+ private readonly TempTree _tree = new();
+
+ public void Dispose() => _tree.Dispose();
+
+ /// A minimal uncompressed DSF: header, a HEAD atom, DEFN with TERT (and an atom before it), the MD5 footer.
+ internal static byte[] Dsf(params string[] terrain)
+ {
+ static byte[] Atom(string id, byte[] body)
+ {
+ byte[] atom = new byte[8 + body.Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(atom, (uint)(id[0] << 24 | id[1] << 16 | id[2] << 8 | id[3]));
+ BinaryPrimitives.WriteUInt32LittleEndian(atom.AsSpan(4), (uint)atom.Length);
+ body.CopyTo(atom, 8);
+ return atom;
+ }
+ byte[] table = Encoding.UTF8.GetBytes(string.Concat(terrain.Select(t => t + "\0")));
+ byte[] definitions = Atom("DEFN", [.. Atom("OBJT", Encoding.UTF8.GetBytes("lib/tree.obj\0")), .. Atom("TERT", table)]);
+ byte[] header = [.. "XPLNEDSF"u8, 1, 0, 0, 0];
+ return [.. header, .. Atom("HEAD", new byte[20]), .. definitions, .. Atom("GEOD", new byte[64]), .. new byte[16]];
+ }
+
+ [Fact]
+ public void Reads_the_terrain_table_of_an_uncompressed_DSF_and_nothing_of_a_compressed_one()
+ {
+ string dsf = _tree.WriteBytes("a.dsf", Dsf("terrain_Water", "terrain/21552_34576_BI16_water_overlay.ter", "terrain/21584_34464_BI16.ter"));
+ string zipped = _tree.WriteBytes("b.dsf", [.. "7z¼¯'\u001C"u8.ToArray(), .. new byte[64]]);
+
+ Assert.Equal(["terrain_Water", "terrain/21552_34576_BI16_water_overlay.ter", "terrain/21584_34464_BI16.ter"], DsfTerrain.Read(dsf));
+ Assert.Null(DsfTerrain.Read(zipped));
+ Assert.Null(DsfTerrain.Read(_tree.PathOf("missing.dsf")));
+ }
+
+ [Fact]
+ public void XPlane_loads_three_rows_by_four_columns_leaning_to_the_nearer_half()
+ {
+ // EDDV (52.46 N, 9.68 E): X-Plane 12 loaded +51..+53 x +008..+011.
+ var tiles = SceneryLoads.TilesAround(52.46, 9.68);
+ Assert.Equal(12, tiles.Count);
+ Assert.Equal([51, 52, 53], tiles.Select(t => t.Lat).Distinct());
+ Assert.Equal([8, 9, 10, 11], tiles.Select(t => t.Lon).Distinct());
+
+ // In the western half of the column, one more to the west.
+ Assert.Equal([7, 8, 9, 10], SceneryLoads.TilesAround(52.46, 9.2).Select(t => t.Lon).Distinct());
+ // Across the antimeridian.
+ Assert.Equal([178, 179, -180, -179], SceneryLoads.TilesAround(-17.7, 179.6).Select(t => t.Lon).Distinct());
+ }
+
+ [Fact]
+ public void A_DSF_lives_in_its_ten_degree_folder()
+ {
+ Assert.Equal(Path.Join("pkg", "Earth nav data", "+50+000", "+52+009.dsf"), DsfTerrain.PathIn("pkg", 52, 9));
+ Assert.Equal(Path.Join("pkg", "Earth nav data", "-40-060", "-34-059.dsf"), DsfTerrain.PathIn("pkg", -34, -59));
+ }
+
+ [Fact]
+ public void Lists_each_texture_of_the_tiles_the_package_has_once()
+ {
+ string package = _tree.PathOf("pkg");
+ _tree.WriteBytes("pkg/Earth nav data/+50+000/+52+009.dsf",
+ Dsf("terrain_Water", "terrain/21552_34576_BI16.ter", "terrain/21552_34576_BI16_water_overlay.ter", "terrain/21584_34464_BI18.ter"));
+ _tree.WriteBytes("pkg/Earth nav data/+50+010/+52+010.dsf", Dsf("terrain/21552_34576_BI16.ter", "terrain/21600_34608_BI16.ter"));
+
+ IReadOnlyList textures = SceneryArea.Textures(package, 52.46, 9.68, out int tiles);
+
+ Assert.Equal(2, tiles);
+ Assert.Equal(["21552_34576_BI16.dds", "21584_34464_BI18.dds", "21600_34608_BI16.dds"], textures.Select(t => t.Name));
+ Assert.Empty(SceneryArea.Textures(_tree.PathOf("none"), 52.46, 9.68, out int none));
+ Assert.Equal(0, none);
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs
new file mode 100644
index 0000000..2f23848
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs
@@ -0,0 +1,546 @@
+using System.Net;
+using System.Text;
+using OrthoStreamXP.Scenery;
+using OrthoStreamXP.Textures;
+
+namespace OrthoStreamXP.Tests;
+
+/// The SimBrief flight plan's departure: reading the OFP, picking the package's textures there, and skipping the cached ones.
+public sealed class SimBriefPrefetchTests : IDisposable
+{
+ private const double Lat = 48.353783, Lon = 11.786086; // EDDM
+ private readonly TempTree _tree = new();
+
+ public void Dispose() => _tree.Dispose();
+
+ private const string Ofp = """
+ {"fetch":{"userid":"123456","static_id":{},"status":"Success","time":"0.01"},
+ "origin":{"icao_code":"EDDM","iata_code":"MUC","pos_lat":"48.353783","pos_long":"11.786086"},
+ "destination":{"icao_code":"EGLL","pos_lat":"51.4775","pos_long":"-0.461389"}}
+ """;
+
+ // ---------------------------------------------------------------- the client
+
+ private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
+ {
+ public Uri? Asked { get; private set; }
+
+ public string? Agent { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Asked = request.RequestUri;
+ Agent = request.Headers.UserAgent.ToString();
+ return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
+ }
+ }
+
+ private static async Task<(FlightOrigin? Origin, Uri? Asked)> Fetch(string user, HttpStatusCode status, string body)
+ {
+ using var handler = new StubHandler(status, body);
+ using var http = new HttpClient(handler);
+ FlightOrigin? origin = (await new SimBriefClient(http).FetchAsync(user, TestContext.Current.CancellationToken)).Origin;
+ return (origin, handler.Asked);
+ }
+
+ [Fact]
+ public async Task Reads_the_origin_of_the_latest_flight_plan()
+ {
+ var (origin, asked) = await Fetch("pilot", HttpStatusCode.OK, Ofp);
+
+ Assert.Equal(new FlightOrigin("EDDM", Lat, Lon), origin);
+ Assert.Equal("https://www.simbrief.com/api/xml.fetcher.php?username=pilot&json=1", asked!.AbsoluteUri);
+ }
+
+ [Fact]
+ public async Task SimBrief_is_asked_with_the_User_Agent_of_the_tile_downloads()
+ {
+ // R7: one orthostreamxp/ for every server, whatever the HttpClient handed in was set up with.
+ using var handler = new StubHandler(HttpStatusCode.OK, Ofp);
+ using var http = new HttpClient(handler);
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("something-else/9");
+
+ await new SimBriefClient(http).FetchAsync("pilot", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpIdentity.UserAgent, handler.Agent);
+ Assert.Matches(@"^orthostreamxp/\d+\.\d+", HttpIdentity.UserAgent);
+ }
+
+ private sealed class ThrowingHandler(Exception ex) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromException(ex);
+ }
+
+ [Theory]
+ [InlineData("SecretPilot")]
+ [InlineData("987654")] // a Pilot ID
+ public async Task No_problem_SimBrief_answers_with_holds_the_username_or_Pilot_ID(string user)
+ {
+ // R8: the problem is logged and shown; the user is never in it, whatever the server or the network said.
+ CancellationToken ct = TestContext.Current.CancellationToken;
+ string url = SimBriefClient.UrlFor(user).AbsoluteUri;
+ var answers = new List();
+ foreach (HttpMessageHandler handler in new HttpMessageHandler[]
+ {
+ new StubHandler(HttpStatusCode.BadRequest, $$$"""{"fetch":{"status":"Error: Unknown UserID {{{user}}}"}}"""),
+ new StubHandler(HttpStatusCode.OK, $$$"""{"fetch":{"status":"Error: no flight plan for {{{user.ToUpperInvariant()}}}"}}"""),
+ new StubHandler(HttpStatusCode.InternalServerError, user),
+ new StubHandler(HttpStatusCode.OK, $"{user}"),
+ new StubHandler(HttpStatusCode.OK, """{"fetch":{"status":"Success"}}"""),
+ new ThrowingHandler(new HttpRequestException($"Connection refused ({url})")),
+ })
+ {
+ using (handler)
+ using (var http = new HttpClient(handler))
+ answers.Add(await new SimBriefClient(http).FetchAsync(user, ct));
+ }
+
+ Assert.All(answers, a =>
+ {
+ Assert.NotNull(a.Problem);
+ Assert.DoesNotContain(user, a.Problem, StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ [Theory]
+ [InlineData("""{"fetch":"Success","origin":{"pos_lat":"48.3","pos_long":"11.7"}}""")] // fetch as a string
+ [InlineData("""{"fetch":{"status":1},"origin":{"pos_lat":"48.3","pos_long":"11.7"}}""")] // status as a number
+ [InlineData("""{"fetch":{"status":"Success"},"origin":"EDDM"}""")] // an airport as a string
+ [InlineData("""["not", "an", "object"]""")]
+ [InlineData("""{"fetch":{"status":"Success"},"origin":{"pos_lat":"48.3","pos_long":"11.7"},"navlog":""}""")]
+ [InlineData("""{"fetch":{"status":"Success"},"origin":{"pos_lat":"48.3","pos_long":"11.7"},"navlog":{"fix":["a", 1, null]},"params":""}""")]
+ [InlineData("""{"fetch":{"status":"Success"},"origin":{"pos_lat":1e999,"pos_long":"11.7"}}""")]
+ [InlineData("""{"fetch":{"status":"Success"},"origin":{"pos_lat":"48.3","pos_long":"11.7"},"alternate":[]}""")]
+ public void An_answer_of_an_unexpected_shape_is_read_or_refused_never_thrown(string json)
+ {
+ // Maintainer, #30 item 1: InvalidOperationException from a field of another type broke the stop.
+ SimBriefAnswer answer = SimBriefClient.Parse(json);
+ Assert.True(answer.Plan is not null || answer.Problem is not null);
+ }
+
+ [Fact]
+ public void The_shapes_SimBrief_leaves_empty_still_give_the_plan()
+ {
+ // SimBrief sends "" for an empty object: the plan is read without the navlog's junk or its times.
+ SimBriefAnswer answer = SimBriefClient.Parse("""
+ {"fetch":{"status":"Success"},"origin":{"icao_code":"EDDM","pos_lat":"48.3","pos_long":"11.7"},
+ "navlog":{"fix":["junk", {"ident":"TOC","pos_lat":"48.5","pos_long":"11.9","stage":"CLB"}]},"params":""}
+ """);
+ Assert.Equal("EDDM", answer.Origin!.Icao);
+ Assert.Equal(["TOC"], answer.Plan!.Route.Select(p => p.Ident));
+ Assert.Null(answer.Plan.Generated);
+ Assert.Equal("SimBrief's answer could not be read", SimBriefClient.Parse("""{"fetch":{"status":1}}""").Problem);
+ }
+
+ [Fact]
+ public async Task A_fetch_that_throws_leaves_no_plan_and_ends_the_session_cleanly()
+ {
+ // The stop awaits WhenDone: a fault there kept the listener and the packages from being disposed.
+ var (session, _, departure, _) = Session(() => throw new InvalidOperationException("a bug"));
+ session.Start();
+ await session.WhenDone(); // does not throw
+ Assert.Null(await session.FirstPlan);
+ Assert.Empty(departure);
+ Assert.StartsWith("No flight plan: the flight plan could not be read", await session.DescribeFirstAsync(), StringComparison.Ordinal);
+ Assert.StartsWith("No flight plan", await session.ReloadAsync(), StringComparison.Ordinal);
+ await session.WhenDone();
+ }
+
+ [Fact]
+ public void A_number_is_the_pilot_id_anything_else_the_username()
+ {
+ Assert.Equal("https://www.simbrief.com/api/xml.fetcher.php?userid=123456&json=1", SimBriefClient.UrlFor(" 123456 ").AbsoluteUri);
+ Assert.Equal("https://www.simbrief.com/api/xml.fetcher.php?username=Jo%20Doe&json=1", SimBriefClient.UrlFor("Jo Doe").AbsoluteUri);
+ }
+
+ [Fact]
+ public async Task An_unknown_user_or_a_failing_server_gives_no_origin()
+ {
+ var (unknown, _) = await Fetch("nobody", HttpStatusCode.BadRequest, """{"fetch":{"status":"Error: Unknown UserID"}}""");
+ var (down, _) = await Fetch("pilot", HttpStatusCode.InternalServerError, "oops");
+ var (garbled, _) = await Fetch("pilot", HttpStatusCode.OK, "");
+ var (noOrigin, _) = await Fetch("pilot", HttpStatusCode.OK, """{"fetch":{"status":"Success"}}""");
+
+ Assert.Null(unknown);
+ Assert.Null(down);
+ Assert.Null(garbled);
+ Assert.Null(noOrigin);
+ }
+
+ [Fact]
+ public async Task The_connection_test_names_the_flight_or_says_why_there_is_none()
+ {
+ using var ok = new StubHandler(HttpStatusCode.OK, Ofp);
+ using var unknown = new StubHandler(HttpStatusCode.BadRequest, """{"fetch":{"status":"Error: Unknown UserID"}}""");
+ using var okHttp = new HttpClient(ok);
+ using var unknownHttp = new HttpClient(unknown);
+ CancellationToken ct = TestContext.Current.CancellationToken;
+
+ SimBriefAnswer found = await new SimBriefClient(okHttp).FetchAsync("pilot", ct);
+ SimBriefAnswer missing = await new SimBriefClient(unknownHttp).FetchAsync("nobody", ct);
+ SimBriefAnswer empty = await new SimBriefClient(okHttp).FetchAsync(" ", ct);
+
+ Assert.Equal("EGLL", found.Destination);
+ Assert.Contains("EDDM → EGLL", Gui.SimBriefTestViewModel.Describe(found), StringComparison.Ordinal);
+ Assert.Equal("No flight plan: Unknown UserID.", Gui.SimBriefTestViewModel.Describe(missing));
+ Assert.Null(empty.Origin);
+ Assert.NotNull(empty.Problem);
+ }
+
+ // ---------------------------------------------------------------- the package's textures there
+
+ private string Package(params TextureName[] textures)
+ {
+ foreach (TextureName name in textures) _tree.WriteText($"pkg/terrain/{name.Stem}.ter", "A\n800\nTERRAIN\n");
+ Directory.CreateDirectory(_tree.PathOf("pkg"));
+ return _tree.PathOf("pkg");
+ }
+
+ private static readonly TextureName AtAirport = TextureName.At(Lat, Lon, "BI", 18);
+ private static readonly TextureName Around = TextureName.At(Lat + 0.03, Lon, "BI", 16); // about 3.5 km north
+ private static readonly TextureName FarAway = TextureName.At(Lat + 0.3, Lon, "BI", 16); // about 33 km north
+
+ private static string[] Stems(IEnumerable textures) => [.. textures.Select(t => Path.GetFileNameWithoutExtension(t.Name))];
+
+ [Fact]
+ public void Picks_the_package_textures_within_the_radius_nearest_first()
+ {
+ string package = Package(FarAway, Around, AtAirport);
+ _tree.WriteText($"pkg/terrain/{AtAirport.Stem}_sea_overlay.ter", "A\n800\nTERRAIN\n"); // the same texture again
+
+ Assert.Equal([AtAirport.Stem, Around.Stem], Stems(AirportTextures.Near(package, Lat, Lon, radiusKm: 10, max: 100)));
+ Assert.Equal([AtAirport.Stem], Stems(AirportTextures.Near(package, Lat, Lon, radiusKm: 10, max: 1)));
+ Assert.Empty(AirportTextures.Near(_tree.PathOf("nothing"), Lat, Lon, radiusKm: 10, max: 100));
+ }
+
+ // ---------------------------------------------------------------- what is queued
+
+ private static readonly DateTimeOffset Now = new(2026, 11, 11, 12, 0, 0, TimeSpan.Zero);
+
+ private sealed class FixedClock(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ }
+
+ private static readonly TimeProvider Clock = new FixedClock(Now);
+
+ private static FlightPlan PlanFrom(FlightOrigin origin, DateTimeOffset? generated = null) => new(origin, null, null, [], generated);
+
+ private async Task<(int Queued, List Prefetched)> Run(Func isCached, FlightOrigin? origin = null,
+ (double, double)? aircraft = null, DateTimeOffset? generated = null)
+ {
+ string package = Package(AtAirport, Around, FarAway);
+ var prefetched = new List();
+ var target = new DepartureTarget("pkg", package, isCached, textures =>
+ {
+ prefetched.AddRange(textures);
+ return textures.Count;
+ });
+ var prefetch = new DeparturePrefetch(() => aircraft, [target], radiusKm: 10, Clock);
+ return (prefetch.Run(PlanFrom(origin ?? new FlightOrigin("EDDM", Lat, Lon), generated), TestContext.Current.CancellationToken), prefetched);
+ }
+
+ [Fact]
+ public async Task Only_textures_the_cache_does_not_have_are_queued()
+ {
+ var (queued, prefetched) = await Run(t => t.Name.StartsWith(AtAirport.Stem, StringComparison.Ordinal));
+
+ Assert.Equal(1, queued);
+ Assert.Equal([Around.Stem], Stems(prefetched));
+ }
+
+ [Fact]
+ public async Task Nothing_is_queued_when_everything_is_cached()
+ {
+ var (queued, prefetched) = await Run(_ => true);
+
+ Assert.Equal(0, queued);
+ Assert.Empty(prefetched);
+ }
+
+ [Fact]
+ public async Task An_aircraft_somewhere_else_means_the_flight_plan_is_not_this_flight()
+ {
+ var (queued, prefetched) = await Run(_ => false, aircraft: (51.4775, -0.461389));
+ var (nearby, _) = await Run(_ => false, aircraft: (Lat + 0.01, Lon));
+
+ Assert.Equal(0, queued);
+ Assert.Empty(prefetched);
+ Assert.Equal(2, nearby);
+ }
+
+ [Fact]
+ public async Task A_plan_generated_more_than_six_hours_ago_is_taken_for_an_earlier_flight()
+ {
+ // R11: at mount start X-Plane usually sends no position, so the plan's age is the check.
+ var (old, prefetched) = await Run(_ => false, generated: Now - TimeSpan.FromHours(7));
+ var (recent, _) = await Run(_ => false, generated: Now - TimeSpan.FromHours(2));
+ var (unknown, _) = await Run(_ => false); // a plan without the time: used
+
+ Assert.Equal(0, old);
+ Assert.Empty(prefetched);
+ Assert.Equal(2, recent);
+ Assert.Equal(2, unknown);
+ }
+
+ [Fact]
+ public void Reads_when_the_plan_was_generated()
+ {
+ const string withTime = """{"fetch":{"status":"Success"},"params":{"request_id":"1","time_generated":"1762790400"},"origin":{"icao_code":"EDDM","pos_lat":"48.35","pos_long":"11.78"}}""";
+ Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1762790400), SimBriefClient.Parse(withTime).Plan!.Generated);
+ Assert.Equal("1", SimBriefClient.Parse(withTime).Plan!.Id); // params.request_id: tells a reloaded OFP from the same one
+ Assert.Null(SimBriefClient.Parse(Ofp).Plan!.Id);
+ Assert.Null(SimBriefClient.Parse(Ofp).Plan!.Generated); // no params
+ const string garbled = """{"params":{"time_generated":"soon"},"origin":{"icao_code":"EDDM","pos_lat":"48.35","pos_long":"11.78"}}""";
+ Assert.Null(SimBriefClient.Parse(garbled).Plan!.Generated);
+ }
+
+ // ---------------------------------------------------------------- the session
+
+ private static readonly FlightOrigin Eddm = new("EDDM", Lat, Lon), Egll = new("EGLL", 51.4775, -0.461389);
+
+ private static SimBriefAnswer Answer(FlightOrigin from, FlightOrigin to, string id) => new(new FlightPlan(from, to, null, [], null, id), null);
+
+ [Fact]
+ public async Task Home_is_told_the_flight_plan_found_at_the_start_or_why_there_is_none()
+ {
+ SimBriefSession Session(SimBriefAnswer answer) => new(_ => Task.FromResult(answer),
+ [new DepartureTarget("pkg", Package(AtAirport, Around), _ => false, textures => textures.Count)], [], () => null, radiusKm: 10, Clock);
+
+ Assert.StartsWith("Flight plan: EDDM → EGLL", await Session(Answer(Eddm, Egll, "4")).DescribeFirstAsync(), StringComparison.Ordinal);
+ Assert.Equal("No flight plan: no flight plan yet.", await Session(SimBriefAnswer.Failed("no flight plan yet")).DescribeFirstAsync());
+ Assert.Equal("EDDM", new FlightPlan(Eddm, null, null, []).Describe()); // no destination: the departure alone
+ }
+
+ [Fact]
+ public async Task The_departure_prefetch_runs_once_the_first_plan_is_there()
+ {
+ var departure = new List();
+ var session = new SimBriefSession(_ => Task.FromResult(Answer(Eddm, Egll, "5")),
+ [new DepartureTarget("pkg", Package(AtAirport, Around), _ => false, textures =>
+ {
+ lock (departure) departure.AddRange(textures);
+ return textures.Count;
+ })], [], () => null, radiusKm: 10, Clock);
+
+ session.Start();
+ await session.WhenDone();
+
+ Assert.Equal([AtAirport.Stem, Around.Stem], Stems(departure));
+ Assert.Equal("5", session.Current!.Id);
+ }
+
+ // ---------------------------------------------------------------- reloading the plan (M1, R9)
+
+ /// A session over the EDDM package, its fetch answering in turn (the last again after).
+ private (SimBriefSession Session, Func Fetches, List Departure, Func<(int Cancel, int CancelDeparture, int Starts)> Counts)
+ Session(params Func>[] answers)
+ {
+ int fetches = 0, cancels = 0, cancelDepartures = 0, starts = 0;
+ var departure = new List();
+ string package = Package(AtAirport, Around);
+ var target = new DepartureTarget("pkg", package, _ => false, textures =>
+ {
+ lock (departure) departure.AddRange(textures);
+ return textures.Count;
+ });
+ var ground = new GroundTarget("pkg", package, (_, _, _, _) => (0, 0), (_, _, _) => (0, 0), () => ++cancels, () => ++cancelDepartures);
+ var session = new SimBriefSession(_ => answers[Math.Min(Interlocked.Increment(ref fetches), answers.Length) - 1](), [target], [ground],
+ () => null, radiusKm: 10, Clock, run: _ =>
+ {
+ starts++;
+ return Task.CompletedTask;
+ });
+ return (session, () => Volatile.Read(ref fetches), departure, () => (cancels, cancelDepartures, starts));
+ }
+
+ private static CameraReport ParkedAt(FlightOrigin airport, double seconds) =>
+ new(airport.Lat, airport.Lon, 3, null, null, false, new DateTime(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc).AddSeconds(seconds));
+
+ [Fact]
+ public async Task A_reloaded_plan_from_elsewhere_drops_the_old_one_prefetches_its_departure_and_watches_its_gate()
+ {
+ var (session, fetches, departure, counts) = Session(() => Task.FromResult(Answer(Egll, Eddm, "1")), () => Task.FromResult(Answer(Eddm, Egll, "2")));
+ session.Start();
+ await session.WhenDone();
+ Assert.Empty(departure); // EGLL: this package has nothing there
+ session.OnCamera(ParkedAt(Egll, 0));
+ session.OnCamera(ParkedAt(Egll, 31));
+ Assert.Equal((0, 0, 1), counts()); // the old plan's pre-warm ran
+
+ string said = await session.ReloadAsync();
+
+ Assert.StartsWith("Loaded EDDM → EGLL", said, StringComparison.Ordinal);
+ Assert.Equal((1, 1), (counts().Cancel, counts().CancelDeparture)); // the old plan's requests went
+ Assert.Equal([AtAirport.Stem, Around.Stem], Stems(departure)); // the new departure is built
+ Assert.Equal("2", session.Current!.Id);
+ session.OnCamera(ParkedAt(Eddm, 100));
+ session.OnCamera(ParkedAt(Eddm, 131));
+ Assert.Equal(2, counts().Starts); // watched from the start for the new gate
+ Assert.Equal(2, fetches());
+ }
+
+ [Fact]
+ public async Task Reloading_the_same_plan_changes_nothing()
+ {
+ var (session, fetches, departure, counts) = Session(() => Task.FromResult(Answer(Eddm, Egll, "7")));
+ session.Start();
+ await session.WhenDone();
+ departure.Clear();
+
+ string said = await session.ReloadAsync();
+
+ Assert.StartsWith("Unchanged: EDDM → EGLL", said, StringComparison.Ordinal);
+ Assert.Equal((0, 0, 0), counts());
+ Assert.Empty(departure);
+ Assert.Equal(2, fetches());
+ }
+
+ [Fact]
+ public async Task A_fresh_plan_from_the_same_airport_as_a_stale_one_gets_its_departure_built()
+ {
+ // Mounted with yesterday's OFP (departure skipped as too old), then a new one generated at the same airport.
+ SimBriefAnswer Dated(string id, TimeSpan age) => new(new FlightPlan(Eddm, Egll, null, [], Now - age, id), null);
+ var (session, _, departure, _) = Session(() => Task.FromResult(Dated("1", TimeSpan.FromHours(29))),
+ () => Task.FromResult(Dated("2", TimeSpan.FromMinutes(1))));
+ session.Start();
+ await session.WhenDone();
+ Assert.Empty(departure); // the stale plan's departure is not prefetched
+
+ string said = await session.ReloadAsync();
+
+ Assert.EndsWith("Its departure is being built.", said, StringComparison.Ordinal);
+ Assert.Equal([AtAirport.Stem, Around.Stem], Stems(departure));
+ }
+
+ [Fact]
+ public async Task A_new_plan_from_a_reload_starts_the_download_limit_again_and_the_same_plan_does_not()
+ {
+ // A turnaround: the first flight spent the whole limit, then the pilot plans the way back and reloads.
+ var budget = new PrefetchBudget(100);
+ SimBriefAnswer[] answers = [Answer(Eddm, Egll, "1"), Answer(Eddm, Egll, "1"), Answer(Egll, Eddm, "2")];
+ int fetches = 0;
+ var session = new SimBriefSession(_ => Task.FromResult(answers[Math.Min(fetches++, answers.Length - 1)]), [], [], () => null,
+ radiusKm: 10, Clock, budget: budget);
+ await session.FirstPlan;
+ budget.Spend(100);
+
+ await session.ReloadAsync(); // unchanged: still spent
+ Assert.True(budget.Exhausted);
+
+ Assert.StartsWith("Loaded EGLL → EDDM", await session.ReloadAsync(), StringComparison.Ordinal);
+ Assert.Equal(0, budget.SpentBytes);
+ Assert.Equal(1, budget.Round);
+ }
+
+ [Fact]
+ public async Task Two_presses_during_one_reload_send_one_request()
+ {
+ var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var (session, fetches, _, _) = Session(() => Task.FromResult(Answer(Eddm, Egll, "1")), () => held.Task);
+ session.Start();
+ await session.FirstPlan;
+
+ Task first = session.ReloadAsync(), second = session.ReloadAsync();
+ Assert.Same(first, second);
+ await Task.Delay(50, TestContext.Current.CancellationToken);
+ held.SetResult(Answer(Eddm, Egll, "1"));
+ await first;
+
+ Assert.Equal(2, fetches()); // the start's and one reload
+ }
+
+ [Fact]
+ public async Task A_plan_generated_after_streaming_started_is_found_by_the_reload()
+ {
+ // The common case: OrthoStreamXP started first, the OFP generated afterwards.
+ var (session, _, departure, _) = Session(() => Task.FromResult(SimBriefAnswer.Failed("no flight plan yet")),
+ () => Task.FromResult(Answer(Eddm, Egll, "3")));
+ session.Start();
+ await session.WhenDone();
+ Assert.Empty(departure);
+
+ Assert.StartsWith("Loaded EDDM → EGLL", await session.ReloadAsync(), StringComparison.Ordinal);
+ Assert.NotEmpty(departure);
+ }
+
+ [Theory]
+ [InlineData(null, "1000", 5)]
+ [InlineData(3000, "3000", 5)]
+ [InlineData(1500, "1500", 6)] // set by hand in the file: kept, offered first
+ public void The_setup_offers_the_download_limits_and_keeps_one_set_by_hand(int? current, string first, int count)
+ {
+ IReadOnlyList choices = OrthoStreamXP.Gui.SimBriefText.LimitChoices(current);
+
+ Assert.Equal(count, choices.Count);
+ Assert.Contains(choices, c => c.Value == (current?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "1000"));
+ Assert.Contains(choices, c => c.Value == "0"); // no limit
+ if (count == 6) Assert.Equal(first, choices[0].Value);
+ }
+
+ [Fact]
+ public void No_flight_plan_queues_nothing()
+ {
+ var prefetch = new DeparturePrefetch(() => null,
+ [new DepartureTarget("pkg", Package(AtAirport), _ => false, _ => throw new InvalidOperationException("nothing to queue"))], radiusKm: 10);
+
+ Assert.Equal(0, prefetch.Run(null, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void With_streaming_the_whole_area_XPlane_loads_is_queued_and_without_its_DSFs_the_circle()
+ {
+ string package = Package(AtAirport, Around, FarAway);
+ _tree.WriteBytes("pkg/Earth nav data/+40+010/+48+011.dsf", SceneryAreaTests.Dsf($"terrain/{AtAirport.Stem}.ter", $"terrain/{FarAway.Stem}.ter"));
+ var area = new List();
+ var whole = new List();
+ DepartureTarget Target(string source) => new("pkg", source, _ => false, t => { whole.AddRange(t); return t.Count; },
+ (textures, _, _) => { area.AddRange(textures); return (textures.Count - 1, 1); });
+ CancellationToken ct = TestContext.Current.CancellationToken;
+
+ int queued = new DeparturePrefetch(() => null, [Target(package)], 10).Run(PlanFrom(new FlightOrigin("EDDM", Lat, Lon)), ct);
+ Assert.Equal(1, queued);
+ Assert.Equal([AtAirport.Stem, FarAway.Stem], Stems(area)); // as the DSF names them, far ones too
+ Assert.Empty(whole);
+
+ // A package without its DSFs there (or 7-Zip ones): the textures within the radius, whole.
+ Directory.Delete(_tree.PathOf("pkg/Earth nav data"), recursive: true);
+ area.Clear();
+ new DeparturePrefetch(() => null, [Target(package)], 10).Run(PlanFrom(new FlightOrigin("EDDM", Lat, Lon)), ct);
+ Assert.Empty(area);
+ Assert.Equal([AtAirport.Stem, Around.Stem], Stems(whole));
+ }
+
+ // ---------------------------------------------------------------- the settings
+
+ [Fact]
+ public void The_settings_are_off_by_default_and_the_radius_is_checked()
+ {
+ MountSettings Parse(params string[] extra)
+ {
+ Directory.CreateDirectory(_tree.PathOf("source"));
+ return MountSettings.FromValues(TestSettings.Of(
+ [$"source = {_tree.PathOf("source")}", $"cache = {_tree.PathOf("cache")}", $"mountpoint = {_tree.PathOf("mount")}", "texture-provider = bing", .. extra]));
+ }
+
+ Assert.Null(Parse().SimBriefUser);
+ Assert.Equal(MountSettings.DefaultSimBriefRadiusKm, Parse().SimBriefRadiusKm);
+ Assert.Equal("pilot", Parse("simbrief-user = pilot ", "simbrief-radius-km = 20").SimBriefUser);
+ Assert.Equal(20, Parse("simbrief-radius-km = 20").SimBriefRadiusKm);
+ Assert.Throws(() => Parse("simbrief-radius-km = 0"));
+ Assert.Throws(() => Parse("simbrief-radius-km = 51"));
+
+ // R4: the download budget, 1000 MB unless set; 0 is no limit.
+ Assert.Equal(MountSettings.DefaultSimBriefDownloadLimitMb, Parse().SimBriefDownloadLimitMb);
+ Assert.Equal(1000, MountSettings.DefaultSimBriefDownloadLimitMb);
+ Assert.Equal(0, Parse("simbrief-download-limit = 0").SimBriefDownloadLimitMb);
+ Assert.Equal(250, Parse("simbrief-download-limit = 250").SimBriefDownloadLimitMb);
+ Assert.Throws(() => Parse("simbrief-download-limit = -1"));
+
+ // The alternate: off unless switched on.
+ Assert.False(Parse().SimBriefAlternate);
+ Assert.True(Parse("simbrief-alternate = on").SimBriefAlternate);
+ Assert.Throws(() => Parse("simbrief-alternate = maybe"));
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/StreamingTests.cs b/tests/OrthoStreamXP.Tests/StreamingTests.cs
index c3aaca4..6815d24 100644
--- a/tests/OrthoStreamXP.Tests/StreamingTests.cs
+++ b/tests/OrthoStreamXP.Tests/StreamingTests.cs
@@ -1,3 +1,4 @@
+using System.Net;
using OrthoStreamXP.Scenery;
using OrthoStreamXP.Textures;
@@ -29,12 +30,13 @@ 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, Daylight? nightPause = null)
+ long buildTiles = TextureStreamer.DefaultBuildTiles, Func? hold = null, Func? status = null, Daylight? nightPause = null,
+ string cache = "cache")
{
- FakeTileServer server = sharing?.Server ?? new FakeTileServer(delay: delay, hold: hold);
+ FakeTileServer server = sharing?.Server ?? new FakeTileServer(status: status, delay: delay, hold: hold);
TileSource tiles = sharing?.Tiles ?? Track(new TileSource("test", FakeTileServer.Template, 32, handler: server) { Store = new TileStore(_tree.PathOf("base")) });
var plan = new TexturePlan(level, TextureSizing.MaxSize);
- var roots = new SceneryRoots(_tree.PathOf("source"), _tree.PathOf("cache"), null, plan);
+ var roots = new SceneryRoots(_tree.PathOf("source"), _tree.PathOf(cache), null, plan);
Directory.CreateDirectory(roots.Source);
var builder = new TextureBuilder(tiles, 2);
var view = new ViewTracker();
@@ -583,6 +585,413 @@ public async Task Before_a_position_arrives_load_ahead_fetches_one_base_tile_per
Assert.Equal([12, 12], Zooms(s.Server));
}
+ [Fact]
+ public async Task The_departure_area_is_fetched_as_XPlane_will_want_it_from_the_gate()
+ {
+ // Before X-Plane sends a position: the texture at the departure in full (its base tile first), the far one as a base tile.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture near = s.Streamer.Get(Texture())!, far = s.Streamer.Get(Texture("21904_36144_BI16"))!; // 20 textures east
+
+ var (queued, had) = warmer.PrefetchAround([Texture(), Texture("21904_36144_BI16")], near.Lat, near.Lon);
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return s.Streamer.RealFrom(near) == 0 && s.Streamer.RealFrom(far) <= far.BaseMip;
+ });
+
+ Assert.Equal((2, 0), (queued, had));
+ Assert.Equal(16, Zooms(s.Server).Count(z => z == 14)); // only the near one in full
+ Assert.Equal(2, Zooms(s.Server).Count(z => z == 12)); // a base tile each
+ Assert.Equal((0, 2), warmer.PrefetchAround([Texture(), Texture("21904_36144_BI16")], near.Lat, near.Lon)); // the next start: nothing to do
+ }
+
+ [Fact]
+ public void The_departure_prefetch_is_never_more_urgent_than_Warm()
+ {
+ // R2: full detail beside the gate is still a guess; it must not rank with load-ahead next to the camera.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture near = s.Streamer.Get(Texture())!;
+ VirtualPath middle = Texture("21904_35856_BI16"), far = Texture("21904_36144_BI16");
+
+ warmer.PrefetchAround([Texture(), middle, far], near.Lat, near.Lon);
+
+ Assert.Equal(FetchPriority.Warm, warmer.WaitingFor(Texture())!.Value.Priority); // its base tile first, at Warm
+ Assert.All(new[] { Texture(), middle, far }, texture => Assert.True(warmer.WaitingFor(texture)!.Value.Priority >= FetchPriority.Warm));
+ }
+
+ [Fact]
+ public async Task The_end_of_the_ground_prewarm_is_told_once_it_is_all_built_but_not_after_a_takeoff_cancel()
+ {
+ // F3: the build buffers are given back while the aircraft still stands at the gate.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ int done = 0;
+ warmer.GroundDone += () => done++;
+ (double, double) Far(double lat, double lon) => (500_000, 0); // a base tile only
+ StreamedTexture arrival = s.Streamer.Get(Texture("21904_36144_BI16"))!;
+
+ warmer.CheckGroundDone();
+ Assert.Equal(0, done); // nothing queued yet
+
+ Assert.Equal((1, 0), warmer.PrefetchGround([Texture("21904_36144_BI16")], Far, GroundPrewarm.Arrival));
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ warmer.CheckGroundDone();
+ return done > 0;
+ });
+ Assert.True(s.Streamer.RealFrom(arrival) <= arrival.BaseMip);
+ warmer.CheckGroundDone();
+ Assert.Equal(1, done); // once
+
+ // Dropped on the takeoff roll: the aircraft moves, no compaction then.
+ warmer.PrefetchGround([Texture("21824_35824_BI16")], Far, GroundPrewarm.Route);
+ Assert.Equal(1, warmer.CancelGround());
+ warmer.CheckGroundDone();
+ Assert.Equal(1, done);
+ }
+
+ [Fact]
+ public async Task Each_prefetch_phase_counts_its_builds_downloads_and_what_XPlane_loaded_later()
+ {
+ // R12: the numbers for the PR, per phase.
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ const string arrivalStem = "21904_36144_BI16";
+ StreamedTexture arrival = s.Streamer.Get(Texture(arrivalStem))!;
+ (double, double) Beside(double lat, double lon) => (0, 0);
+ Assert.All(warmer.Phases, p => Assert.Null(p.Summary())); // nothing done: nothing logged
+
+ warmer.PrefetchGround([Texture(arrivalStem)], Beside, GroundPrewarm.Arrival);
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return s.Streamer.RealFrom(arrival) == 0 && warmer.WaitingFor(Texture(arrivalStem)) is null;
+ });
+ PrefetchMeter meter = warmer.Phases.Single(p => p.Phase == "arrival");
+ await Until(() => meter.Built == 2); // its base tile, then full detail
+ Assert.Equal((1, 2L, 0L), (meter.Queued, meter.Started, meter.Failed));
+ Assert.Equal(17, meter.Tiles); // 1 base tile + 16 at zoom 14
+ Assert.True(meter.Bytes > 0);
+ Assert.True(meter.BuildTime > TimeSpan.Zero);
+ Assert.Equal(0, meter.Loaded);
+
+ catalog.AddTerrain(VirtualPath.Of("terrain", arrivalStem + ".ter")); // X-Plane loads it on the approach
+ Assert.Equal(1, meter.Loaded);
+ Assert.Contains("X-Plane loaded 1 of the 1 queued (100 %)", meter.Summary(), StringComparison.Ordinal);
+
+ // The departure, built already: counted as had, not queued; the other phases stay silent.
+ warmer.PrefetchAround([Texture(arrivalStem)], arrival.Lat, arrival.Lon);
+ PrefetchMeter departure = warmer.Phases.Single(p => p.Phase == "departure");
+ Assert.Equal((0, 1L), (departure.Queued, departure.Had));
+ Assert.Null(warmer.Phases.Single(p => p.Phase == "climb").Summary());
+ }
+
+ [Fact]
+ public async Task Once_the_download_budget_is_spent_the_prefetch_stops_and_load_ahead_goes_on()
+ {
+ // R4: a 1-byte budget is spent by the first build; the rest of the prefetch is left to load-ahead.
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ var budget = new PrefetchBudget(1);
+ warmer.UseBudget(budget);
+ (double, double) Far(double lat, double lon) => (500_000, 0); // base tiles only: one build each
+ VirtualPath first = Texture("21904_36144_BI16"), second = Texture("21824_35824_BI16");
+ StreamedTexture firstTexture = s.Streamer.Get(first)!;
+
+ warmer.PrefetchGround([first], Far, GroundPrewarm.Arrival);
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return budget.Exhausted;
+ });
+ Assert.True(s.Streamer.RealFrom(firstTexture) <= firstTexture.BaseMip);
+
+ warmer.StartWhatFits(); // drops what is left, logs once
+ Assert.Equal((0, 0), warmer.PrefetchGround([second], Far, GroundPrewarm.Route));
+ Assert.Null(warmer.WaitingFor(second));
+
+ catalog.AddTerrain(VirtualPath.Of("terrain", "21824_35824_BI16.ter")); // X-Plane loads it: load-ahead still fetches it
+ Assert.NotNull(warmer.WaitingFor(second));
+ long spent = budget.SpentBytes;
+ StreamedTexture secondTexture = s.Streamer.Get(second)!;
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return s.Streamer.RealFrom(secondTexture) <= secondTexture.BaseMip;
+ });
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ Assert.Equal(spent, budget.SpentBytes); // load-ahead never counts
+
+ budget.Restart(); // a reloaded new flight plan (a turnaround): the prefetch may queue again
+ Assert.False(budget.Exhausted);
+ Assert.NotEqual((0, 0), warmer.PrefetchGround([second], Far, GroundPrewarm.Arrival));
+ }
+
+ [Fact]
+ public async Task A_texture_whose_base_tile_is_cached_already_spends_nothing_of_the_budget()
+ {
+ // R4: only real downloads count. X-Plane's own read put the base tile in the tile store (another package's
+ // texture cache here, so the texture itself was never built for this one): the prefetch finds it there and
+ // neither queues nor downloads anything for it.
+ Setup flown = Streaming();
+ StreamedTexture t = flown.Streamer.Get(Texture())!;
+ ReadLevel(flown.Streamer, t, t.BaseMip);
+ await Until(() => flown.Streamer.RealFrom(t) <= t.BaseMip);
+ int downloads = flown.Server.Requests.Count;
+
+ Setup s = Streaming(sharing: flown, cache: "cache2");
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ var budget = new PrefetchBudget(1_000_000);
+ warmer.UseBudget(budget);
+
+ Assert.Equal((0, 1), warmer.PrefetchGround([Texture()], (_, _) => (500_000, 0), GroundPrewarm.Arrival)); // far: its base tile only
+ warmer.StartWhatFits();
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+
+ Assert.Equal(downloads, flown.Server.Requests.Count);
+ Assert.Equal(0L, budget.SpentBytes);
+ }
+
+ [Fact]
+ public void The_takeoff_roll_drops_only_the_departures_requests_still_waiting()
+ {
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ VirtualPath flightPath = Texture("21904_35856_BI16"), arrival = Texture("21904_36144_BI16");
+ (double, double) Beside(double lat, double lon) => (0, 0);
+
+ warmer.PrefetchAround([Texture()], here.Lat, here.Lon);
+ warmer.Prefetch([flightPath]);
+ warmer.PrefetchGround([arrival], Beside, GroundPrewarm.Arrival);
+
+ Assert.Equal(1, warmer.CancelDeparture());
+ Assert.Null(warmer.WaitingFor(Texture()));
+ Assert.NotNull(warmer.WaitingFor(flightPath)); // the flight-path prefetch is not the departure's
+ Assert.Equal(1, warmer.GroundWaiting);
+ Assert.Equal(0, warmer.CancelDeparture());
+ }
+
+ [Fact]
+ public void The_ground_prewarm_ranks_after_the_departure_and_is_dropped_for_the_takeoff()
+ {
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ VirtualPath arrival = Texture("21904_36144_BI16"), route = Texture("21824_35824_BI16");
+ (double, double) Beside(double lat, double lon) => (0, 0);
+
+ warmer.PrefetchAround([Texture()], here.Lat, here.Lon); // the departure
+ Assert.Equal((1, 0), warmer.PrefetchGround([arrival], Beside, GroundPrewarm.Arrival));
+ Assert.Equal((1, 0), warmer.PrefetchGround([route], Beside, GroundPrewarm.Route));
+ Assert.Equal((0, 0), warmer.PrefetchGround([Texture()], Beside, GroundPrewarm.Route)); // the departure's request stays
+ Assert.Equal(2, warmer.GroundWaiting);
+
+ // Full detail asked for, nothing there yet: the base tile first, still below the departure and in phase order.
+ int departure = warmer.WaitingFor(Texture())!.Value.Priority;
+ int first = warmer.Target(arrival, 0, MipWarmer.GroundPriority(GroundPrewarm.Arrival))!.Value.Priority;
+ int last = warmer.Target(route, 0, MipWarmer.GroundPriority(GroundPrewarm.Route))!.Value.Priority;
+ Assert.True(departure < FetchPriority.Background && FetchPriority.Background < first && first < last, $"{departure} < {first} < {last}");
+
+ Assert.Equal(2, warmer.CancelGround());
+ Assert.Equal(0, warmer.GroundWaiting);
+ Assert.Equal(0, warmer.CancelGround());
+ }
+
+ [Fact]
+ public void A_texture_XPlane_loads_while_the_ground_prewarm_waits_for_it_gets_load_aheads_request()
+ {
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ (double, double) Beside(double lat, double lon) => (0, 0);
+ s.View.Update(new CameraReport(here.Lat, here.Lon, 2, null, null, false, DateTime.UtcNow)); // parked beside it
+
+ Assert.Equal((1, 0), warmer.PrefetchGround([Texture()], Beside, GroundPrewarm.Route));
+ Assert.True(warmer.WaitingFor(Texture())!.Value.Ground);
+
+ catalog.AddTerrain(VirtualPath.Of("terrain", Stem + ".ter")); // X-Plane loads it
+ var request = warmer.WaitingFor(Texture())!.Value;
+ Assert.False(request.Ground);
+ Assert.Equal((0, FetchPriority.NearWarm), (request.Mip, request.Priority)); // full detail beside the camera, near priority
+ Assert.Equal((0, 0), warmer.PrefetchGround([Texture()], Beside, GroundPrewarm.Route)); // and the ground pre-warm doesn't take it back
+
+ Assert.Equal(0, warmer.CancelGround()); // the takeoff roll keeps it
+ Assert.NotNull(warmer.WaitingFor(Texture()));
+ }
+
+ [Fact]
+ public void A_texture_XPlane_loads_while_the_departure_waits_for_it_gets_load_aheads_request()
+ {
+ // Maintainer, #30 item 2 (H1 for the departure): the takeoff roll drops the departure's requests; a load-ahead
+ // request that the departure's had kept out would go with it.
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ s.View.Update(new CameraReport(here.Lat, here.Lon, 2, null, null, false, DateTime.UtcNow));
+
+ Assert.Equal((1, 0), warmer.PrefetchAround([Texture()], here.Lat, here.Lon));
+ catalog.AddTerrain(VirtualPath.Of("terrain", Stem + ".ter")); // X-Plane loads it
+ Assert.Equal(FetchPriority.NearWarm, warmer.WaitingFor(Texture())!.Value.Priority);
+
+ Assert.Equal(0, warmer.CancelDeparture()); // the takeoff roll keeps it
+ Assert.NotNull(warmer.WaitingFor(Texture()));
+ }
+
+ [Fact]
+ public void A_departure_queued_again_leaves_load_aheads_waiting_request_alone()
+ {
+ // H1 the other way round: "Reload" while X-Plane runs queues a departure over textures X-Plane has loaded.
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ s.View.Update(new CameraReport(here.Lat, here.Lon, 2, null, null, false, DateTime.UtcNow));
+ catalog.AddTerrain(VirtualPath.Of("terrain", Stem + ".ter"));
+ VirtualPath other = Texture("21904_35856_BI16");
+
+ Assert.Equal((1, 0), warmer.PrefetchAround([Texture(), other], here.Lat, here.Lon)); // only the one X-Plane has not loaded
+ Assert.Equal(1, warmer.CancelDeparture());
+ Assert.Equal(FetchPriority.NearWarm, warmer.WaitingFor(Texture())!.Value.Priority);
+
+ // A departure request replaces a departure request (the newer plan), and outranks a waiting ground one.
+ Assert.Equal((1, 0), warmer.PrefetchGround([other], (_, _) => (0, 0), GroundPrewarm.Arrival));
+ Assert.Equal((1, 0), warmer.PrefetchAround([other], here.Lat, here.Lon));
+ Assert.Equal((1, 0), warmer.PrefetchAround([other], here.Lat, here.Lon));
+ Assert.Equal(0, warmer.GroundWaiting);
+ Assert.Equal(1, warmer.CancelDeparture());
+ }
+
+ [Fact]
+ public void Around_an_airport_the_order_is_load_aheads_nearest_first()
+ {
+ // R6: PrefetchPlanner.Select without motion orders the departure, the arrival and the alternate.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture near = s.Streamer.Get(Texture())!;
+ VirtualPath middle = Texture("21904_35856_BI16"), far = Texture("21904_36144_BI16");
+
+ warmer.PrefetchAround([far, middle, Texture()], near.Lat, near.Lon, GroundPrewarm.Arrival);
+
+ double[] order = [.. new[] { Texture(), middle, far }.Select(t => warmer.WaitingFor(t)!.Value.Order)];
+ Assert.Equal(order.Order(), order); // nearest first, in metres like load-ahead's own requests
+ Assert.InRange(order[0], 0, 1);
+ Assert.True(order[2] > 20_000);
+ Assert.All(new[] { Texture(), middle, far }, t => Assert.True(warmer.WaitingFor(t)!.Value.Ground));
+ }
+
+ [Fact]
+ public async Task The_build_memory_is_given_back_only_once_XPlane_is_quiet()
+ {
+ // R1: the compacting collection pauses every thread, X-Plane's reads too: it waits for a quiet moment.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ using var stop = new CancellationTokenSource();
+ int givenBack = 0;
+ var timing = new PrewarmMemory.Timing(TimeSpan.FromMilliseconds(300), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(10));
+ var memory = new PrewarmMemory([warmer], stop.Token, timing, () => Interlocked.Increment(ref givenBack));
+ StreamedTexture t = s.Streamer.Get(Texture())!;
+
+ memory.OnGroundDone();
+ var reading = System.Diagnostics.Stopwatch.StartNew();
+ while (reading.ElapsedMilliseconds < 600)
+ {
+ ReadLevel(s.Streamer, t, t.BaseMip); // X-Plane reads: not quiet
+ await Task.Delay(50, TestContext.Current.CancellationToken);
+ Assert.Equal(0, Volatile.Read(ref givenBack));
+ }
+ await memory.Waiting.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ Assert.Equal(1, givenBack);
+
+ memory.OnGroundDone(); // once a session
+ await memory.Waiting.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ Assert.Equal(1, givenBack);
+ }
+
+ [Fact]
+ public async Task New_prewarm_work_cancels_a_give_back_waiting_for_quiet()
+ {
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ using var stop = new CancellationTokenSource();
+ int givenBack = 0;
+ var timing = new PrewarmMemory.Timing(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(60), TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(10));
+ var memory = new PrewarmMemory([warmer], stop.Token, timing, () => givenBack++);
+
+ memory.OnGroundDone();
+ warmer.PrefetchGround([Texture("21904_36144_BI16")], (_, _) => (500_000, 0), GroundPrewarm.Arrival); // a reloaded plan's pre-warm
+ await memory.Waiting.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ Assert.Equal(0, givenBack);
+ }
+
+ [Fact]
+ public void A_move_of_the_camera_outranks_a_waiting_ground_request_for_a_loaded_texture()
+ {
+ Setup s = Streaming();
+ var catalog = new TextureCatalog();
+ catalog.AddTerrain(VirtualPath.Of("terrain", Stem + ".ter")); // loaded before the warmer listens: only a move queues it
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ StreamedTexture here = s.Streamer.Get(Texture())!;
+ (double, double) Beside(double lat, double lon) => (0, 0);
+ Assert.Equal((1, 0), warmer.PrefetchGround([Texture()], Beside, GroundPrewarm.Route));
+ Assert.True(warmer.WaitingFor(Texture())!.Value.Ground);
+
+ s.View.Update(new CameraReport(here.Lat, here.Lon, 2, null, null, false, DateTime.UtcNow)); // the first position: OnMoved
+ var request = warmer.WaitingFor(Texture())!.Value;
+ Assert.False(request.Ground);
+ Assert.Equal(FetchPriority.NearWarm, request.Priority);
+ Assert.Equal(0, warmer.CancelGround());
+ Assert.NotNull(warmer.WaitingFor(Texture()));
+ }
+
+ [Fact]
+ public async Task A_texture_whose_base_tile_fails_leaves_the_queue_and_counts_one_build()
+ {
+ // M3: the base step kept its request whatever happened, so a texture without imagery was tried every pass all
+ // session, and each try counted as a build started.
+ Setup s = Streaming(status: zoom => zoom == 12 ? HttpStatusCode.InternalServerError : HttpStatusCode.OK);
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+
+ warmer.Prefetch([Texture()]);
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return warmer.WaitingFor(Texture()) is null;
+ });
+ for (int pass = 0; pass < 5; pass++) warmer.StartWhatFits(); // within the retry time: nothing more
+
+ Assert.Equal(1, warmer.Queued);
+ Assert.DoesNotContain(14, Zooms(s.Server)); // the level was never tried without its base
+ }
+
+ [Fact]
+ public async Task A_whole_texture_prefetched_ahead_of_XPlane_gets_its_base_tile_too()
+ {
+ // The SimBrief departure: fetched whole before X-Plane loads it, then skipped by load-ahead as complete. Without
+ // its base tile the low-zoom base (far scenery, colour matching, the report's map) would stay empty there.
+ Setup s = Streaming();
+ using var warmer = new MipWarmer(new TextureCatalog(), s.Streamer, s.View, heavy: 2);
+ StreamedTexture t = s.Streamer.Get(Texture())!;
+
+ warmer.Prefetch([Texture()]);
+ await Until(() =>
+ {
+ warmer.StartWhatFits(); // as its loop does: the base tile, then the level
+ return s.Streamer.RealFrom(t) == 0 && s.Builder.StoreHasRegion(t.Name, t.ZoomFor(t.BaseMip));
+ });
+ int[] zooms = Zooms(s.Server);
+ Assert.Equal(1, zooms.Count(z => z == 12));
+ Assert.Equal(16, zooms.Count(z => z == 14));
+ Assert.Equal(12, zooms[0]); // the base tile before the level's tiles
+ }
+
[Fact]
public async Task With_the_camera_beside_a_texture_load_ahead_fetches_it_in_full_and_the_far_one_as_a_base_tile()
{
@@ -600,6 +1009,36 @@ public async Task With_the_camera_beside_a_texture_load_ahead_fetches_it_in_full
Assert.Equal(1, Zooms(s.Server).Count(z => z == 12));
}
+ [Fact]
+ public void XPlanes_smallest_read_takes_level_4_of_a_4096_px_texture()
+ {
+ // Its last 64 KB chunk starts inside level 3 (levels 4 and smaller are 43 KB): X-Plane is after level 4 at least.
+ Assert.Equal(4, ReadPattern.CoarsestRead(DdsLayout.For(4096)));
+ // 2048 px: it starts inside level 2, so level 3 (256 px again).
+ Assert.Equal(3, ReadPattern.CoarsestRead(DdsLayout.For(2048)));
+ // 1024 px: the last chunk starts inside level 1.
+ Assert.Equal(2, ReadPattern.CoarsestRead(DdsLayout.For(1024)));
+ }
+
+ [Fact]
+ public async Task Before_a_position_a_ZL18_texture_gets_the_level_XPlane_reads_at_least_not_just_its_base_tile()
+ {
+ // At EDDV 926 ZL18 textures waited while X-Plane loaded them: their base tile is level 6, X-Plane takes level 4.
+ Setup s = Streaming(level: 0);
+ var catalog = new TextureCatalog();
+ using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2);
+ catalog.AddTerrain(VirtualPath.Of("terrain", "90384_137776_BI18.ter"));
+ StreamedTexture t = s.Streamer.Get(Texture("90384_137776_BI18"))!;
+
+ await Until(() =>
+ {
+ warmer.StartWhatFits();
+ return s.Streamer.RealFrom(t) <= 4;
+ });
+ Assert.Equal(6, t.BaseMip);
+ Assert.Equal([14], Zooms(s.Server).Distinct()); // level 4 is one tile at zoom 14
+ }
+
[Fact]
public void Load_ahead_sorts_a_build_by_its_tiles_with_base_tiles_apart()
{
diff --git a/tests/OrthoStreamXP.Tests/XPlaneUdpTests.cs b/tests/OrthoStreamXP.Tests/XPlaneUdpTests.cs
index 459af10..37ef198 100644
--- a/tests/OrthoStreamXP.Tests/XPlaneUdpTests.cs
+++ b/tests/OrthoStreamXP.Tests/XPlaneUdpTests.cs
@@ -84,6 +84,62 @@ public async Task The_listener_subscribes_and_follows_what_XPlane_sends()
try { await fake; } catch (OperationCanceledException) { }
}
+ [Fact]
+ public void The_aircrafts_ground_speed_and_on_ground_flag_are_asked_for_with_every_position()
+ {
+ // R6: X-Plane's own values for the gate and the takeoff roll; only the screen and the date are asked for once a second.
+ Assert.Contains(("sim/flightmodel/position/groundspeed", XPlaneUdp.GroundSpeedIndex), XPlaneUdp.CameraDatarefs);
+ Assert.Contains(("sim/flightmodel/failures/onground_any", XPlaneUdp.OnGroundIndex), XPlaneUdp.CameraDatarefs);
+ Assert.False(XPlaneUdp.Rare(XPlaneUdp.GroundSpeedIndex));
+ Assert.False(XPlaneUdp.Rare(XPlaneUdp.OnGroundIndex));
+ Assert.True(XPlaneUdp.Rare(XPlaneUdp.FieldOfViewIndex) && XPlaneUdp.Rare(XPlaneUdp.WindowWidthIndex) && XPlaneUdp.Rare(XPlaneUdp.DateIndex));
+ Assert.Equal(XPlaneUdp.CameraDatarefs.Count, XPlaneUdp.CameraDatarefs.Select(d => d.Index).Distinct().Count());
+ Assert.All(XPlaneUdp.CameraDatarefs, d => Assert.InRange(d.Index, XPlaneUdp.ElevationIndex, XPlaneUdp.ElevationIndex + XPlaneUdp.CameraDatarefs.Count - 1));
+ }
+
+ [Fact]
+ public async Task The_camera_report_carries_XPlanes_ground_speed_and_on_ground_flag()
+ {
+ using var xplane = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
+ var xplaneAddress = (IPEndPoint)xplane.Client.LocalEndPoint!;
+ using var stop = new CancellationTokenSource();
+ Task fake = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ UdpReceiveResult request;
+ try
+ {
+ request = await xplane.ReceiveAsync(stop.Token);
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionReset)
+ {
+ continue;
+ }
+ byte[] answer = Answer((XPlaneUdp.GroundSpeedIndex, 10.29f), (XPlaneUdp.OnGroundIndex, 1f),
+ (XPlaneUdp.LatitudeIndex, 51.10872f), (XPlaneUdp.LongitudeIndex, 16.86611f));
+ await xplane.SendAsync(answer, request.RemoteEndPoint, stop.Token);
+ }
+ }, stop.Token);
+
+ var cameras = new ConcurrentQueue();
+ var options = new PositionOptions(FreePort(), xplaneAddress, RadiusKm: 5, MaxTextures: 8, LookaheadSeconds: 180);
+ using (var listener = new AircraftPositionListener([new PrefetchTarget(new TextureCatalog(), _ => 0)], options, onCamera: cameras.Enqueue))
+ {
+ listener.Start();
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (cameras.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(50, TestContext.Current.CancellationToken);
+ Assert.True(cameras.TryPeek(out CameraReport? camera), "no position arrived");
+ Assert.Equal(20, camera.GroundSpeedKt!.Value, 0); // 10.29 m/s
+ Assert.True(camera.OnGround);
+ Assert.Equal(20, listener.Latest!.GroundSpeedKt!.Value, 0);
+ Assert.True(listener.Latest.OnGround);
+ }
+
+ await stop.CancelAsync();
+ try { await fake; } catch (OperationCanceledException) { }
+ }
+
private static byte[] Answer(params (int Index, float Value)[] values)
{
byte[] packet = new byte[5 + 8 * values.Length];