From b4e02a5e8a86e782ae79af71b1e65c842d0dce04 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 12 Nov 2026 17:59:39 +0100 Subject: [PATCH 1/5] Add the 3 x 4 rule and the DSF terrain table reader next to SceneryLoads Refactor only, no behaviour change: nothing calls them yet. SceneryLoads observes the DSF tiles X-Plane opens; TilesAround says which ones it opens around a place (three rows by four columns, leaning to the nearer half of the column, checked at EDDV). DsfTerrain reads a DSF's TERT table without reading the rest of the file. The SimBrief prefetch builds on both in the next commits. Co-Authored-By: Claude Opus 5.5 --- .../Scenery/Aircraft/DsfTerrain.cs | 75 +++++++++++++++++++ .../Scenery/Aircraft/SceneryLoads.cs | 15 ++++ tests/OrthoStreamXP.Tests/SceneryAreaTests.cs | 63 ++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/OrthoStreamXP/Scenery/Aircraft/DsfTerrain.cs create mode 100644 tests/OrthoStreamXP.Tests/SceneryAreaTests.cs 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/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs b/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs new file mode 100644 index 0000000..dca0ee4 --- /dev/null +++ b/tests/OrthoStreamXP.Tests/SceneryAreaTests.cs @@ -0,0 +1,63 @@ +using System.Buffers.Binary; +using System.Text; +using OrthoStreamXP.Scenery; + +namespace OrthoStreamXP.Tests; + +/// The scenery X-Plane loads around a place: its DSF tiles, and the terrain 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)); + } +} From e51a9eed84b06b7db7acf691610ccff70992edfc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 12 Nov 2026 18:01:06 +0100 Subject: [PATCH 2/5] Load-ahead: base tile first, and nothing coarser than X-Plane's smallest read Two changes to load-ahead for every flight, not only SimBrief ones: - A level asked for ahead on a texture with nothing yet (the flight-path prefetch) fetches its base tile first, in the base tiles' own places. Load-ahead skips a texture that is whole already, so without this the low-zoom base (far scenery, colour matching, the report's map) stayed empty there. If the base tile fails, the request goes instead of being tried every pass, and only real build starts count. - Nothing is fetched ahead coarser than X-Plane's smallest read: it reads the file's last 64 KB chunk, so it takes at least level 4 of a 4096 px texture. For ZL18 textures that is two levels finer than the base tile; fetching only the base tile made all 926 ZL18 textures at EDDV wait while X-Plane loaded them. Co-Authored-By: Claude Opus 5.5 --- .../Scenery/Prefetch/MipWarmer.cs | 57 ++++++++++++-- .../Scenery/Streaming/ReadPattern.cs | 13 ++++ tests/OrthoStreamXP.Tests/StreamingTests.cs | 76 ++++++++++++++++++- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs index 9c8c865..2d49398 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using OrthoStreamXP.Textures; @@ -56,6 +57,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(); @@ -104,7 +107,10 @@ private void OnMoved(CameraReport camera) Wake(); } - /// The flight-path prefetch: whole textures along the predicted path. Returns how many were queued. + /// + /// The flight-path prefetch: whole textures along the predicted path. Returns how many were queued. Each gets its + /// base tile first (see ). + /// public int Prefetch(IReadOnlyList textures) { lock (_gate) @@ -120,6 +126,22 @@ private void Add(Request request) Wake(); } + /// The level and priority the request waiting for fetches now; null when none waits. + internal (int Mip, int Priority)? WaitingFor(VirtualPath texture) + { + Request request; + lock (_gate) + if (!_waiting.TryGetValue(texture.Key, out request)) return null; + return Target(request.Texture, request.Mip, request.Priority) is { } t ? (t.Mip, t.Priority) : null; + } + + /// + /// 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)); + private void Wake() { if (_wake.CurrentCount == 0) _wake.Release(); @@ -129,8 +151,15 @@ private void Wake() internal (StreamedTexture Texture, int Mip, int Priority, double Distance)? Target(VirtualPath path, int? mip = null, int? priority = null) { if (_streamer.Get(path) is not { } t) return null; + // A level asked for ahead (the flight path) 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, Math.Min(priority ?? FetchPriority.Warm, FetchPriority.Warm), + _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, double.MaxValue); double distance = ViewTracker.DistanceM(camera, t.Lat, t.Lon); if (mip is int fixedMip) return (t, fixedMip, priority ?? FetchPriority.Warm, distance); @@ -142,7 +171,7 @@ 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 target = Math.Min(wanted, Coarsest(t)); int targetPriority = TextureStreamer.BandOf(t, wanted) switch { Band.Near => FetchPriority.NearWarm, @@ -187,6 +216,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) @@ -200,18 +230,33 @@ internal void StartWhatFits() 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(_ => + string key = request.Texture.Key; + build.ContinueWith(done => { + if (baseStep && !Succeeded(done)) Remove(request); Interlocked.Decrement(ref _running[(int)kind]); + _stepping.TryRemove(key, out byte _); Wake(); }, TaskScheduler.Default); } } + private static bool Succeeded(Task build) => build.IsCompletedSuccessfully && build.Result; + private bool Full(BuildKind kind) => Volatile.Read(ref _running[(int)kind]) >= kind switch { BuildKind.Base => BaseInFlight, 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/tests/OrthoStreamXP.Tests/StreamingTests.cs b/tests/OrthoStreamXP.Tests/StreamingTests.cs index c3aaca4..d0eace7 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,9 +30,9 @@ 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) { - 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); @@ -600,6 +601,77 @@ 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 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 flight-path prefetch: 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 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() { From 01b92c2b2b662d4143a6b843c4334b5da60b1439 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 12 Nov 2026 18:04:33 +0100 Subject: [PATCH 3/5] Prefetch the SimBrief departure while X-Plane loads Optional, off until a SimBrief username or Pilot ID is set (simbrief-user). - Setup step 6 "SimBrief" with a connection test, and a SimBrief tab in Settings (username, test, download limit per session). - Fetch and parse the latest OFP (origin, destination, alternate, navlog) once per streaming session, 10 s timeout, in the background. Any answer of an unexpected shape is "could not be read", a failing fetch is "no plan": logged, never a mount failure, and the stop logs a failure of the SimBrief work and still disposes everything. The username is never logged nor shown in the setup summary; User-Agent orthostreamxp/ for every server. - Departure prefetch: the 3 x 4 DSF tiles X-Plane loads around the departure, ordered by PrefetchPlanner.Select without motion, each texture to the level the paging model predicts from the gate, at Warm at most. A departure request never replaces a waiting load-ahead request, and load-ahead's replaces it (H1). Without mip streaming (or unreadable DSFs): the textures within simbrief-radius-km at full detail, bounded by the background queue, outside the budget (documented and logged). - A plan older than 6 h, or an aircraft already > 50 km away, skips the prefetch. - Download budget simbrief-download-limit (1000 MB): when spent, the waiting SimBrief requests are dropped; load-ahead and X-Plane's reads never count, nor do tiles the cache has. - Areas X-Plane never opened go first when the cache is trimmed; once flown they are trimmed by age like any other. - Per-phase summary at stop (queued, builds, tiles/MB, build time, hit rate). - Home: a session switch and the flight plan line. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + build-user-guide.ps1 | 2 +- docs/README.md | 13 +- docs/first-setup.md | 10 +- docs/settings.md | 6 + docs/simbrief.md | 65 +++ mkdocs.yml | 1 + .../Configuration/MountSettings.cs | 16 + .../Configuration/SettingsOptions.cs | 3 + src/OrthoStreamXP/Gui/MountController.cs | 17 + .../Gui/Settings/SettingNames.cs | 3 + .../Gui/Settings/SimBriefText.cs | 36 ++ .../Gui/ViewModels/HomeViewModel.cs | 64 ++- .../Gui/ViewModels/PackagesViewModel.cs | 4 +- .../Gui/ViewModels/SettingsViewModel.cs | 26 +- .../Gui/ViewModels/SetupViewModel.cs | 54 ++- .../Gui/ViewModels/SimBriefTestViewModel.cs | 104 +++++ src/OrthoStreamXP/Gui/Views/HomeView.xaml | 13 +- src/OrthoStreamXP/Gui/Views/SettingsView.xaml | 63 +++ src/OrthoStreamXP/Gui/Views/SetupView.xaml | 79 +++- .../Infrastructure/HttpIdentity.cs | 16 + src/OrthoStreamXP/Mounting/MountSession.cs | 84 +++- src/OrthoStreamXP/Mounting/PackageMount.cs | 2 + .../Scenery/Flight/DeparturePrefetch.cs | 81 ++++ .../Scenery/Flight/FlightPlan.cs | 50 +++ .../Scenery/Flight/SimBriefClient.cs | 178 ++++++++ .../Scenery/Flight/SimBriefSession.cs | 138 ++++++ .../Scenery/Prefetch/AirportTextures.cs | 51 +++ .../Scenery/Prefetch/MipWarmer.cs | 200 +++++++-- .../Scenery/Prefetch/PrefetchBudget.cs | 25 ++ .../Scenery/Prefetch/PrefetchMeter.cs | 73 ++++ .../Scenery/Prefetch/SceneryArea.cs | 34 ++ .../Scenery/Streaming/TextureStreamer.cs | 18 +- src/OrthoStreamXP/Setup/Cache/CacheAreas.cs | 4 +- .../Setup/Settings/ConnectionTest.cs | 2 +- .../Textures/Tiles/TileSource.cs | 5 +- src/OrthoStreamXP/orthostreamxp.example.ini | 7 + tests/OrthoStreamXP.Tests/CacheAreasTests.cs | 32 ++ tests/OrthoStreamXP.Tests/LoadMeasureTests.cs | 2 +- tests/OrthoStreamXP.Tests/SceneryAreaTests.cs | 18 +- .../SimBriefPrefetchTests.cs | 412 ++++++++++++++++++ tests/OrthoStreamXP.Tests/StreamingTests.cs | 207 ++++++++- 42 files changed, 2133 insertions(+), 86 deletions(-) create mode 100644 docs/simbrief.md create mode 100644 src/OrthoStreamXP/Gui/Settings/SimBriefText.cs create mode 100644 src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs create mode 100644 src/OrthoStreamXP/Infrastructure/HttpIdentity.cs create mode 100644 src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs create mode 100644 src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs create mode 100644 src/OrthoStreamXP/Scenery/Flight/SimBriefClient.cs create mode 100644 src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs create mode 100644 src/OrthoStreamXP/Scenery/Prefetch/AirportTextures.cs create mode 100644 src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs create mode 100644 src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs create mode 100644 src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs create mode 100644 tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs diff --git a/README.md b/README.md index f7b3ca0..cdf4340 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. - **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..deed0af 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 -![Setup step 6: Ready](images/setup-ready.png) +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. 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** (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 + +![Setup step 7: Ready](images/setup-ready.png) 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..d32fbdc 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -139,6 +139,12 @@ 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). + ## Other ![Settings: Other](images/settings-other.png) diff --git a/docs/simbrief.md b/docs/simbrief.md new file mode 100644 index 0000000..c29bb73 --- /dev/null +++ b/docs/simbrief.md @@ -0,0 +1,65 @@ +# 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, OrthoStreamXP builds the scenery of your latest SimBrief flight plan's departure airport ahead of time: while X-Plane is still loading, it 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. Photos already in the cache are never downloaded again. + +**With mip streaming off** only whole textures within `simbrief-radius-km` of the airport are built, 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 and are not in the per-phase summary in the log. + +## 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**. + +## 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 scenery ahead means many 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) | 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. + +- **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. + +## 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 before you start streaming. +- **The aircraft is somewhere else.** If X-Plane already places your aircraft more than 50 km from the plan's departure airport when the plan arrives, the plan is not this flight's: nothing is built. +- **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-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. Nothing else is sent. The username is never written to the log 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..f4163fd 100644 --- a/src/OrthoStreamXP/Configuration/MountSettings.cs +++ b/src/OrthoStreamXP/Configuration/MountSettings.cs @@ -74,10 +74,18 @@ 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; 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 +161,11 @@ 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); + 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 +235,9 @@ public static MountSettings FromValues(SettingValues values, bool console = true CacheLimitBytes = (long)(cacheLimitGb * (1L << 30)), ReadLog = readLog, LogDetail = logDetail, + SimBriefUser = simBriefUser, + SimBriefRadiusKm = simBriefRadiusKm, + SimBriefDownloadLimitMb = simBriefDownloadLimitMb, }; } diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs index 5c2973f..a4a7461 100644 --- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs +++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs @@ -42,6 +42,9 @@ 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. 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-download-limit", $"The most the SimBrief prefetch (the departure) 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..b1fd6c1 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,12 @@ 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 }; + + /// 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 +144,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(); diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs index db47421..005f218 100644 --- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs +++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs @@ -25,6 +25,9 @@ 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", }; /// 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..a8c269e --- /dev/null +++ b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs @@ -0,0 +1,36 @@ +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 SimBrief prefetch may download per streaming session. When it is reached, nothing breaks: the rest loads as usual while you fly. Your own flying never counts against it."; + + /// Why there is a limit at all, and how to choose it. + public const string LimitWhy = + "Why a 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, which would hit every OrthoStreamXP user, not only you. " + + "Building scenery ahead means many photos before you need any of them, so the prefetch gets a budget. It also protects a metered or slow connection.\n" + + "Which value? 1000 MB (the default) is meant for a typical flight at High or Ultra. " + + "Choose less (300-500 MB) on a metered or mobile connection. Choose more (2000-3000 MB) with Extra, which downloads about four times as much near airports. " + + "If the log says \"the download budget ... is used up\" on your flights, raise it a step. No limit is not recommended."; + + /// 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", "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..b4d39d4 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs @@ -26,6 +26,8 @@ internal sealed class HomeViewModel : ObservableObject, IPage private System.Windows.Media.ImageSource? _reportBackdrop; private string? _reviewPath; private Notice? _tuningNotice; + /// The running session's flight plan, as fetched at the start; 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) }; @@ -60,11 +62,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 +153,60 @@ 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)); + } + } + + /// + /// 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."). + /// + 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. + private async Task ShowFirstFlightPlanAsync() + { + if (Mount.FirstFlightPlanAsync() is not { } first) + { + PlanStatus = null; + return; + } + string text = await first.ConfigureAwait(true); + if (Mount.State == MountState.Running && _planStatus is null) PlanStatus = text; + } + + /// 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 +437,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 +579,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(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..a98cb19 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs @@ -64,6 +64,20 @@ 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 SimBrief username or Pilot ID (the number in your SimBrief account settings). When streaming starts, the photos around the departure airport of your latest SimBrief flight plan are built before X-Plane loads them. Photos already in the cache are skipped. Leave it empty to turn this 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), + ]; ConnectionTest = new ConnectionTestViewModel( () => _document.Get("texture-provider") ?? "bing,esri", () => _main.Mount.State == MountState.Stopped, @@ -98,6 +112,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. + public IReadOnlyList SimBrief { get; } public ICommand BrowseXPlane { get; } public ICommand BrowsePackages { get; } public ICommand BrowseCache { get; } @@ -503,7 +526,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 +597,7 @@ 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), _ => "", }; } diff --git a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs index cabd626..a917c35 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,20 @@ 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; /// 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 +75,12 @@ 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); ConnectionTest = new ConnectionTestViewModel( () => existing.Get("texture-provider") ?? "bing,esri", () => _main.Mount.State == MountState.Stopped, @@ -146,6 +156,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 +287,35 @@ 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 applies 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)); + } + } + public bool StartNow { get => _startNow; @@ -283,7 +324,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})" : "not used")}"; + + private string LimitShort => SimBriefLimit.Value == "0" ? "no download limit" : $"up to {SimBriefLimit.Value} MB per session"; public string? Error { @@ -373,6 +417,10 @@ 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); 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..3723ea0 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 645fe06..db1fa3d 100644 --- a/src/OrthoStreamXP/Gui/Views/SetupView.xaml +++ b/src/OrthoStreamXP/Gui/Views/SetupView.xaml @@ -284,7 +284,7 @@ + Text="Optional. OrthoStreamXP reads your latest flight plan and builds the departure, arrival and climb ahead, so the scenery is sharp sooner. Leave it empty to skip." /> 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 df4eb7e..f7a62c6 100644 --- a/src/OrthoStreamXP/Mounting/MountSession.cs +++ b/src/OrthoStreamXP/Mounting/MountSession.cs @@ -34,7 +34,7 @@ internal sealed class MountSession : IDisposable private NightPainting? _nightPainting; private Task? _summaryLoop, _sampleLoop; private HttpClient? _simBrief; - /// The SimBrief side (plan, departure prefetch). + /// The SimBrief side (plan, departure prefetch, ground pre-warm, reload); told every position X-Plane sends. private volatile SimBriefSession? _simBriefSession; private bool _stopped; @@ -176,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); @@ -212,7 +213,7 @@ private void StartDeparturePrefetch() .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 ? warmer.PrefetchAround : null))]; + 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"); @@ -220,18 +221,25 @@ private void StartDeparturePrefetch() } 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"); + "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) { - var budget = new PrefetchBudget(_settings.SimBriefDownloadLimitMb * 1024L * 1024); + budget = new PrefetchBudget(_settings.SimBriefDownloadLimitMb * 1024L * 1024); foreach (PackageMount mount in _mounts) mount.Warmer?.UseBudget(budget); } - var simBrief = new SimBriefSession(ct => client.FetchAsync(user, ct), targets, + // 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, - stop: _summaries.Token); + stop: _summaries.Token, budget: budget); _simBriefSession = simBrief; simBrief.Start(); } @@ -239,6 +247,9 @@ private void StartDeparturePrefetch() /// 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(); @@ -315,8 +326,8 @@ private async Task StopAsync(bool hurry, bool summaries) else await finished.ConfigureAwait(false); } - // SimBrief's departure prefetch queues into the packages' load-ahead: it ends before the packages are disposed. - // A failure there is a bug, but only logged: the listener and the packages must still go. + // 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 }) await EndAsync(loop, "").ConfigureAwait(false); await EndAsync(_simBriefSession?.WhenDone(), "SimBrief prefetch").ConfigureAwait(false); 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/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 index 568ead8..38af764 100644 --- a/src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs +++ b/src/OrthoStreamXP/Scenery/Flight/DeparturePrefetch.cs @@ -31,9 +31,10 @@ 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}"; - if (flight.AgeAt((clock ?? TimeProvider.System).GetUtcNow()) is { } age && age > FlightPlan.MaxAge) + DateTimeOffset now = (clock ?? TimeProvider.System).GetUtcNow(); + if (flight.StaleAt(now)) { - Log.Info($"SimBrief: the latest flight plan ({place}) is {age.TotalHours:0} h old; its departure is not prefetched"); + 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) diff --git a/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs b/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs index 115df98..f4fadee 100644 --- a/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs +++ b/src/OrthoStreamXP/Scenery/Flight/FlightPlan.cs @@ -35,8 +35,11 @@ public string Describe() /// 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; farther away it flies + /// 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; diff --git a/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs new file mode 100644 index 0000000..3cdec39 --- /dev/null +++ b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs @@ -0,0 +1,272 @@ +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 (where missing photos are noticed +/// first), 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). +internal sealed class GroundPrewarm(Task plan, IReadOnlyList targets, Func? run = null, + CancellationToken stop = default) +{ + public const int Arrival = 0, Route = 1; + + 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 two 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); + } + 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/SimBriefSession.cs b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs index 6140294..37106ff 100644 --- a/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs +++ b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs @@ -1,36 +1,51 @@ +using OrthoStreamXP.Setup; + namespace OrthoStreamXP.Scenery; /// -/// One streaming session's SimBrief side: the flight plan (fetched once as streaming starts: no polling) and the -/// departure prefetch. Failures are only logged: SimBrief never fails streaming. +/// 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 download limit (simbrief-download-limit); a reloaded new plan starts it again. Null: no limit. public SimBriefSession(Func> fetch, IReadOnlyList departureTargets, - Func<(double Lat, double Lon)?> aircraft, double radiusKm, TimeProvider? clock = null, CancellationToken stop = default) + IReadOnlyList groundTargets, Func<(double Lat, double Lon)?> aircraft, double radiusKm, + TimeProvider? clock = null, Func? run = null, 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, stop); } /// The plan fetched as streaming started (null when there was none). public Task FirstPlan { get; } - /// The plan in use now. + public GroundPrewarm? Ground { get; } + + /// The plan in use now: the first, or the latest reloaded. public FlightPlan? Current { get @@ -45,10 +60,28 @@ 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]); + lock (_gate) return Task.WhenAll([.. _work, Ground?.Running ?? Task.CompletedTask]); } private async Task FetchFirstAsync() @@ -81,9 +114,51 @@ public async Task DescribeFirstAsync() } } + 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 the plan never ends faulted (the stop awaits it). + /// one too, so neither the first plan nor a reload ever ends faulted (the stop awaits them). /// private async Task FetchAsync() { diff --git a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs index c97a3fb..527106f 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs @@ -51,18 +51,31 @@ internal enum BuildKind { Base, Small, Heavy } /// 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. + /// 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, double? Distance = null, - PrefetchMeter? Meter = 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. - private readonly PrefetchMeter[] _phases = [new("departure")]; + /// 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("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 ran out and this package's SimBrief requests were dropped (once). - private bool _budgetSpent; + /// 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; @@ -79,6 +92,8 @@ private readonly record struct Request(VirtualPath Texture, int? Mip = null, int /// 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; @@ -98,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); @@ -149,13 +172,14 @@ public int Prefetch(IReadOnlyList textures) } /// - /// The SimBrief departure: every texture of the scenery X-Plane loads around the departure, each 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. + /// The scenery X-Plane loads around an airport of the SimBrief flight plan: the departure ( + /// null), or the ground pre-warm's arrival (). + /// 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) + 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) @@ -169,7 +193,7 @@ public int Prefetch(IReadOnlyList textures) double distance = Geo.DistanceM(lat, lon, t.Lat, t.Lon); placed.Add((texture, distance, distance)); } - return PrefetchPlaced(placed); + return PrefetchPlaced(placed, phase); } /// @@ -178,6 +202,71 @@ public int Prefetch(IReadOnlyList textures) /// 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; @@ -192,15 +281,24 @@ private int Drop(Func which) 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. It gives way to what load-ahead and the flight path want (). + /// 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) + 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[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) { @@ -216,8 +314,10 @@ private int Drop(Func which) } // 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. - int priority = mip >= Coarsest(t) ? FetchPriority.Warm + // 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)); } @@ -227,6 +327,7 @@ private int Drop(Func which) 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); } @@ -238,6 +339,9 @@ private int Drop(Func which) /// 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 { @@ -253,24 +357,35 @@ private void Add(Request request) } /// - /// Queues unless the texture waits for load-ahead or the flight path already. A waiting - /// SimBrief departure request gives way to every request: its level is a guess made at the gate, and overwriting a - /// waiting load-ahead request with it would leave a texture X-Plane loaded with that guess (H1). Under the gate. + /// 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) { - if (_waiting.TryGetValue(request.Texture.Key, out Request existing) && existing.Meter is null) return false; - _waiting[request.Texture.Key] = 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, double Order)? WaitingFor(VirtualPath texture) + 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, t.Distance) : 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() @@ -288,7 +403,7 @@ private void Wake() // 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, Math.Min(priority ?? FetchPriority.Warm, FetchPriority.Warm), + 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 ?? Coarsest(t), priority ?? FetchPriority.Background, departureDistance ?? double.MaxValue); @@ -314,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); } @@ -371,10 +489,13 @@ internal void StartWhatFits() Interlocked.Increment(ref _queued); if (baseStep) _stepping[request.Texture.Key] = 0; Interlocked.Increment(ref _running[(int)kind]); + 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(); @@ -387,14 +508,31 @@ internal void StartWhatFits() /// Once the download budget is spent: the SimBrief requests still waiting go, and that is logged once. private void DropPrefetchOverBudget() { - if (_budgetSpent || _budget is not { Exhausted: true } budget) return; - _budgetSpent = true; + 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 index 23e3723..84e8a54 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs @@ -1,15 +1,17 @@ namespace OrthoStreamXP.Scenery; /// -/// The most the SimBrief prefetch (every package together) may download in one -/// session (simbrief-download-limit, R4): the scenery around an airport can mean thousands of tiles, and a provider +/// 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 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; @@ -17,9 +19,20 @@ internal sealed class PrefetchBudget(long limitBytes) 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 session, whichever package sees it first. + /// 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 index 2e8d426..9a65c73 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs @@ -4,18 +4,21 @@ namespace OrthoStreamXP.Scenery; /// -/// What one phase of the SimBrief prefetch (the departure) cost and brought: the +/// What one phase of the SimBrief prefetch (the departure, the arrival, 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. /// -internal sealed class PrefetchMeter(string phase) +/// 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; } diff --git a/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs index 98d9275..985aac5 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs @@ -1,7 +1,7 @@ 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, +/// The scenery X-Plane loads around a place (a departure, an arrival) 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. /// diff --git a/src/OrthoStreamXP/orthostreamxp.example.ini b/src/OrthoStreamXP/orthostreamxp.example.ini index 9821899..b74eb50 100644 --- a/src/OrthoStreamXP/orthostreamxp.example.ini +++ b/src/OrthoStreamXP/orthostreamxp.example.ini @@ -84,7 +84,8 @@ skip-photos-sun-below = 9 # 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. What the cache has already is skipped (empty: off) +# 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 diff --git a/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs new file mode 100644 index 0000000..c4e046d --- /dev/null +++ b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs @@ -0,0 +1,437 @@ +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 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 climb was 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_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.Route], calls.Select(c => c.Phase)); // the alternate EDDK is not built + Assert.Equal(12, calls[0].Textures); // the 3 x 4 tiles round EDDF + Assert.True(calls[1].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[1].FirstDistanceM, 0, 1); // the departure lies on the climb + Assert.Equal(calls.Sum(c => c.Textures), queued); + } + + [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.Route], calls.Select(c => c.Phase)); // no arrival phase: only the climb is left + } + + [Fact] + public void A_plan_without_a_navlog_still_gets_its_arrival() + { + var (target, calls) = Package(Everywhere); + new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddf, Eddk, [])); + + Assert.Equal([GroundPrewarm.Arrival], calls.Select(c => c.Phase)); + } +} diff --git a/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs index 5da01b9..a3dbc45 100644 --- a/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs +++ b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs @@ -133,6 +133,20 @@ public void The_shapes_SimBrief_leaves_empty_still_give_the_plan() 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() { @@ -292,7 +306,7 @@ public void Reads_when_the_plan_was_generated() 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); + [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()); @@ -308,7 +322,7 @@ [new DepartureTarget("pkg", Package(AtAirport, Around), _ => false, textures => { lock (departure) departure.AddRange(textures); return textures.Count; - })], () => null, radiusKm: 10, Clock); + })], [], () => null, radiusKm: 10, Clock); session.Start(); await session.WhenDone(); @@ -317,23 +331,138 @@ [new DepartureTarget("pkg", Package(AtAirport, Around), _ => false, textures => Assert.Equal("5", session.Current!.Id); } - [Fact] - public async Task A_fetch_that_throws_leaves_no_plan_and_ends_the_session_cleanly() + // ---------------------------------------------------------------- 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) { - // The stop awaits WhenDone: a fault there kept the listener and the packages from being disposed. + int fetches = 0, cancels = 0, cancelDepartures = 0, starts = 0; var departure = new List(); - var session = new SimBriefSession(_ => throw new InvalidOperationException("a bug"), - [new DepartureTarget("pkg", Package(AtAirport, Around), _ => false, textures => + 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: _ => { - lock (departure) departure.AddRange(textures); - return textures.Count; - })], () => null, radiusKm: 10, Clock); + 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(); // does not throw - Assert.Null(await session.FirstPlan); + 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.StartsWith("No flight plan: the flight plan could not be read", await session.DescribeFirstAsync(), StringComparison.Ordinal); + 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] diff --git a/tests/OrthoStreamXP.Tests/StreamingTests.cs b/tests/OrthoStreamXP.Tests/StreamingTests.cs index 3997675..1ca7fee 100644 --- a/tests/OrthoStreamXP.Tests/StreamingTests.cs +++ b/tests/OrthoStreamXP.Tests/StreamingTests.cs @@ -621,6 +621,38 @@ public void The_departure_prefetch_is_never_more_urgent_than_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() { @@ -628,17 +660,18 @@ public async Task Each_prefetch_phase_counts_its_builds_downloads_and_what_XPlan Setup s = Streaming(); var catalog = new TextureCatalog(); using var warmer = new MipWarmer(catalog, s.Streamer, s.View, heavy: 2); - const string gateStem = "21904_36144_BI16"; - StreamedTexture gate = s.Streamer.Get(Texture(gateStem))!; + 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.PrefetchAround([Texture(gateStem)], gate.Lat, gate.Lon); + warmer.PrefetchGround([Texture(arrivalStem)], Beside, GroundPrewarm.Arrival); await Until(() => { warmer.StartWhatFits(); - return s.Streamer.RealFrom(gate) == 0 && warmer.WaitingFor(Texture(gateStem)) is null; + return s.Streamer.RealFrom(arrival) == 0 && warmer.WaitingFor(Texture(arrivalStem)) is null; }); - PrefetchMeter meter = warmer.Phases.Single(p => p.Phase == "departure"); + 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 @@ -646,13 +679,15 @@ await Until(() => Assert.True(meter.BuildTime > TimeSpan.Zero); Assert.Equal(0, meter.Loaded); - catalog.AddTerrain(VirtualPath.Of("terrain", gateStem + ".ter")); // X-Plane loads it at the gate + 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); - // Asked again, built already: counted as had, not queued again. - warmer.PrefetchAround([Texture(gateStem)], gate.Lat, gate.Lon); - Assert.Equal((1, 1L), (meter.Queued, meter.Had)); + // 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] @@ -664,11 +699,11 @@ public async Task Once_the_download_budget_is_spent_the_prefetch_stops_and_load_ 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)!; - (double Lat, double Lon) far = (firstTexture.Lat + 4.5, firstTexture.Lon); // 500 km: base tiles only, one build each - warmer.PrefetchAround([first], far.Lat, far.Lon); + warmer.PrefetchGround([first], Far, GroundPrewarm.Arrival); await Until(() => { warmer.StartWhatFits(); @@ -677,7 +712,7 @@ await Until(() => Assert.True(s.Streamer.RealFrom(firstTexture) <= firstTexture.BaseMip); warmer.StartWhatFits(); // drops what is left, logs once - Assert.Equal((0, 0), warmer.PrefetchAround([second], far.Lat, far.Lon)); + 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 @@ -691,6 +726,10 @@ await Until(() => }); 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] @@ -710,7 +749,7 @@ public async Task A_texture_whose_base_tile_is_cached_already_spends_nothing_of_ var budget = new PrefetchBudget(1_000_000); warmer.UseBudget(budget); - Assert.Equal((0, 1), warmer.PrefetchAround([Texture()], t.Lat + 2, t.Lon)); // 220 km: its base tile only + 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); @@ -718,11 +757,80 @@ public async Task A_texture_whose_base_tile_is_cached_already_spends_nothing_of_ 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 departure's level is a guess made at the gate; once X-Plane - // loads the texture, load-ahead's request takes over. + // 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); @@ -730,15 +838,17 @@ public void A_texture_XPlane_loads_while_the_departure_waits_for_it_gets_load_ah 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)); - Assert.Equal(FetchPriority.Warm, warmer.WaitingFor(Texture())!.Value.Priority); 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: a departure queued over textures X-Plane has loaded does not overwrite their request. + // 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); @@ -748,25 +858,97 @@ public void A_departure_queued_again_leaves_load_aheads_waiting_request_alone() 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); - Assert.Equal((1, 0), warmer.PrefetchAround([other], here.Lat, here.Lon)); // a departure request replaces a departure request + + // 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_the_departure_the_order_is_load_aheads_nearest_first() + public void Around_an_airport_the_order_is_load_aheads_nearest_first() { - // R6: PrefetchPlanner.Select without motion orders the departure. + // R6: PrefetchPlanner.Select without motion orders the departure and the arrival. 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); + 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] 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]; From 07c935574fe8ef62b575908eefcc7f1adac8aa84 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 12 Nov 2026 18:06:10 +0100 Subject: [PATCH 5/5] Build the SimBrief alternate too, behind its own switch (off by default) simbrief-alternate = on (Setup and Settings): the ground pre-warm builds the alternate's 3 x 4 tiles after the arrival, before the climb. Off by default: it is rarely flown and spends downloads from the same limit. An alternate that is the arrival or the departure is not built twice. Co-Authored-By: Claude Opus 5.5 --- docs/first-setup.md | 2 +- docs/settings.md | 1 + docs/simbrief.md | 8 ++-- .../Configuration/MountSettings.cs | 5 +++ .../Configuration/SettingsOptions.cs | 1 + .../Gui/Settings/SettingNames.cs | 1 + .../Gui/Settings/SimBriefText.cs | 8 +++- .../Gui/ViewModels/SettingsViewModel.cs | 5 ++- .../Gui/ViewModels/SetupViewModel.cs | 18 +++++++- src/OrthoStreamXP/Gui/Views/SetupView.xaml | 16 ++++++- src/OrthoStreamXP/Mounting/MountSession.cs | 2 +- .../Scenery/Flight/GroundPrewarm.cs | 22 +++++++--- .../Scenery/Flight/SimBriefSession.cs | 5 ++- .../Scenery/Prefetch/MipWarmer.cs | 4 +- .../Scenery/Prefetch/PrefetchBudget.cs | 2 +- .../Scenery/Prefetch/PrefetchMeter.cs | 2 +- .../Scenery/Prefetch/SceneryArea.cs | 2 +- src/OrthoStreamXP/orthostreamxp.example.ini | 2 + .../OrthoStreamXP.Tests/GroundPrewarmTests.cs | 44 +++++++++++++++---- .../SimBriefPrefetchTests.cs | 5 +++ tests/OrthoStreamXP.Tests/StreamingTests.cs | 2 +- 21 files changed, 124 insertions(+), 33 deletions(-) diff --git a/docs/first-setup.md b/docs/first-setup.md index 4add1e2..8152b08 100644 --- a/docs/first-setup.md +++ b/docs/first-setup.md @@ -89,7 +89,7 @@ Internet speed changes during the day, so you can repeat the test later in [Sett ## 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** (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). +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 diff --git a/docs/settings.md b/docs/settings.md index d32fbdc..cfa0096 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -144,6 +144,7 @@ After flights of 10 minutes or more, Home may suggest settings for your preset ( - **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 index 9ee8c93..b6b105d 100644 --- a/docs/simbrief.md +++ b/docs/simbrief.md @@ -10,12 +10,13 @@ When streaming starts and while you sit at the gate, OrthoStreamXP builds the sc 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 and the climb need mip streaming. +**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 @@ -24,7 +25,7 @@ Enter your SimBrief username, or your Pilot ID (the number in your SimBrief acco - 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**. +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 @@ -37,7 +38,7 @@ When the limit is reached nothing breaks: the rest loads as usual while you fly. | 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) | 3000 MB | +| 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. @@ -63,6 +64,7 @@ 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` | diff --git a/src/OrthoStreamXP/Configuration/MountSettings.cs b/src/OrthoStreamXP/Configuration/MountSettings.cs index f4163fd..c4589ef 100644 --- a/src/OrthoStreamXP/Configuration/MountSettings.cs +++ b/src/OrthoStreamXP/Configuration/MountSettings.cs @@ -80,6 +80,8 @@ internal sealed record MountSettings 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; @@ -165,6 +167,8 @@ public static MountSettings FromValues(SettingValues values, bool console = true 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"); @@ -238,6 +242,7 @@ public static MountSettings FromValues(SettingValues values, bool console = true SimBriefUser = simBriefUser, SimBriefRadiusKm = simBriefRadiusKm, SimBriefDownloadLimitMb = simBriefDownloadLimitMb, + SimBriefAlternate = simBriefAlternate, }; } diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs index 7bf1b48..42824d5 100644 --- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs +++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs @@ -44,6 +44,7 @@ internal static class SettingsOptions .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)") diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs index 005f218..e38a7ae 100644 --- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs +++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs @@ -28,6 +28,7 @@ internal static class SettingNames ["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 index fdb5603..00db8f4 100644 --- a/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs +++ b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs @@ -13,7 +13,11 @@ internal static class SimBriefText /// 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."; + "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) @@ -23,7 +27,7 @@ public static IReadOnlyList LimitChoices(int? current) 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", "3000"), + 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); diff --git a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs index 3544c49..9875c2b 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs @@ -77,6 +77,8 @@ public SettingsViewModel(MainViewModel main) 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", @@ -119,7 +121,7 @@ public SettingsViewModel(MainViewModel main) /// The SimBrief tab's "Test SimBrief connection". public SimBriefTestViewModel SimBriefTest { get; } - /// The SimBrief tab's download limit. + /// The SimBrief tab's download limit and alternate airport. public IReadOnlyList SimBrief { get; } public ICommand BrowseXPlane { get; } public ICommand BrowsePackages { get; } @@ -598,6 +600,7 @@ private SettingField Field(string key, string label, string help, FieldKind kind { "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 a917c35..61a3969 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs @@ -45,6 +45,7 @@ internal sealed class SetupViewModel : ObservableObject 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; @@ -81,6 +82,7 @@ public SetupViewModel(MainViewModel main) 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, @@ -300,7 +302,7 @@ public string SimBriefUser } } - /// The limit applies only with a username. + /// 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. @@ -316,6 +318,17 @@ public Choice SimBriefLimit } } + /// 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; @@ -325,7 +338,7 @@ public bool StartNow public string Summary => $"X-Plane: {_xplane}\nScenery packages: {string.Join(", ", SystemCheck.PackagesIn(_packages))} in {_packages}\n" + $"Quality: {Preset.Name}\nCache: {CacheFolder}\n" + - $"SimBrief: {(SimBriefUser.Length > 0 ? $"on (your flight's scenery is built ahead, {LimitShort})" : "not used")}"; + $"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"; @@ -421,6 +434,7 @@ private async Task FinishAsync() // 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/Views/SetupView.xaml b/src/OrthoStreamXP/Gui/Views/SetupView.xaml index db1fa3d..afc5ee4 100644 --- a/src/OrthoStreamXP/Gui/Views/SetupView.xaml +++ b/src/OrthoStreamXP/Gui/Views/SetupView.xaml @@ -318,7 +318,7 @@ AutomationProperties.Name="Test SimBrief connection" /> - + @@ -345,6 +345,20 @@ + + + + + + + + + + + + + diff --git a/src/OrthoStreamXP/Mounting/MountSession.cs b/src/OrthoStreamXP/Mounting/MountSession.cs index f7a62c6..8b9854b 100644 --- a/src/OrthoStreamXP/Mounting/MountSession.cs +++ b/src/OrthoStreamXP/Mounting/MountSession.cs @@ -239,7 +239,7 @@ private void StartDeparturePrefetch() 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, - stop: _summaries.Token, budget: budget); + alternate: _settings.SimBriefAlternate, stop: _summaries.Token, budget: budget); _simBriefSession = simBrief; simBrief.Start(); } diff --git a/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs index 3cdec39..42b8cff 100644 --- a/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs +++ b/src/OrthoStreamXP/Scenery/Flight/GroundPrewarm.cs @@ -77,16 +77,17 @@ internal sealed record GroundTarget(string Name, string Source, /// /// 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 (where missing photos are noticed -/// first), as X-Plane will want it from the gate there; then the climb from the departure to +/// 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, - CancellationToken stop = default) + bool alternate = true, CancellationToken stop = default) { - public const int Arrival = 0, Route = 1; + public const int Arrival = 0, Alternate = 1, Route = 2; private readonly Lock _gate = new(); private Task _plan = plan; @@ -183,7 +184,7 @@ private void QueueInBackground(FlightPlan flight, int generation) if (late > 0) Log.Info($"Ground pre-warm: {late} more textures queued during the takeoff roll were dropped"); } - /// Queues the two phases in every package; returns how many textures were queued. + /// 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) @@ -200,6 +201,17 @@ private int Queue(FlightPlan flight, int generation) 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; diff --git a/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs index 37106ff..d4b657a 100644 --- a/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs +++ b/src/OrthoStreamXP/Scenery/Flight/SimBriefSession.cs @@ -25,10 +25,11 @@ internal sealed class SimBriefSession /// 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, PrefetchBudget? budget = null, + TimeProvider? clock = null, Func? run = null, bool alternate = false, PrefetchBudget? budget = null, CancellationToken stop = default) { _fetch = fetch; @@ -37,7 +38,7 @@ public SimBriefSession(Func> fetch, IRea _departure = new DeparturePrefetch(aircraft, departureTargets, radiusKm, _clock); _stop = stop; FirstPlan = FetchFirstAsync(); - if (groundTargets.Count > 0) Ground = new GroundPrewarm(FirstPlan, groundTargets, run, stop); + if (groundTargets.Count > 0) Ground = new GroundPrewarm(FirstPlan, groundTargets, run, alternate, stop); } /// The plan fetched as streaming started (null when there was none). diff --git a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs index 527106f..19f7286 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/MipWarmer.cs @@ -64,7 +64,7 @@ private readonly record struct Request(VirtualPath Texture, int? Mip = null, int /// 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("climb", ground: true)]; + [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). @@ -173,7 +173,7 @@ public int Prefetch(IReadOnlyList textures) /// /// The scenery X-Plane loads around an airport of the SimBrief flight plan: the departure ( - /// null), or the ground pre-warm's arrival (). + /// 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: diff --git a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs index 84e8a54..b2a70d0 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchBudget.cs @@ -2,7 +2,7 @@ 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 and climb can mean thousands of tiles, and a provider +/// 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. diff --git a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs index 9a65c73..0f7aacb 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/PrefetchMeter.cs @@ -4,7 +4,7 @@ namespace OrthoStreamXP.Scenery; /// -/// What one phase of the SimBrief prefetch (the departure, the arrival, the climb) cost and brought: the +/// 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. diff --git a/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs index 985aac5..98d9275 100644 --- a/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs +++ b/src/OrthoStreamXP/Scenery/Prefetch/SceneryArea.cs @@ -1,7 +1,7 @@ namespace OrthoStreamXP.Scenery; /// -/// The scenery X-Plane loads around a place (a departure, an arrival) or along a route: its 1° DSF tiles, +/// 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. /// diff --git a/src/OrthoStreamXP/orthostreamxp.example.ini b/src/OrthoStreamXP/orthostreamxp.example.ini index b74eb50..d81a356 100644 --- a/src/OrthoStreamXP/orthostreamxp.example.ini +++ b/src/OrthoStreamXP/orthostreamxp.example.ini @@ -91,6 +91,8 @@ prefetch-loaded-scenery = on # 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/GroundPrewarmTests.cs b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs index c4e046d..2eca5e0 100644 --- a/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs +++ b/tests/OrthoStreamXP.Tests/GroundPrewarmTests.cs @@ -6,7 +6,7 @@ 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 and with the arrival the same as the departure. +/// 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 { @@ -297,7 +297,7 @@ public async Task Stopping_streaming_while_it_queues_neither_throws_nor_queues_o prewarm.OnCamera(Parked(t0)); prewarm.OnCamera(Parked(t0.AddSeconds(31))); await prewarm.Running; // completes, doesn't throw - Assert.Equal(1, calls); // the climb was not queued after the stop + 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); @@ -397,19 +397,40 @@ 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_climb() + 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.Route], calls.Select(c => c.Phase)); // the alternate EDDK is not built + 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.True(calls[1].Textures > 12); // the tiles under the climb and either side + 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[1].FirstDistanceM, 0, 1); // the departure lies on the climb + 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() { @@ -423,15 +444,20 @@ public void An_arrival_that_is_the_departure_leaves_its_area_to_the_departure_pr var (target, calls) = Package(Everywhere); new GroundPrewarm(Task.FromResult(null), [target]).Queue(new FlightPlan(Eddv, Eddv, Eddh, circuit)); - Assert.Equal([GroundPrewarm.Route], calls.Select(c => c.Phase)); // no arrival phase: only the climb is left + 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() + 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], calls.Select(c => c.Phase)); + Assert.Equal([GroundPrewarm.Arrival, GroundPrewarm.Alternate], calls.Select(c => c.Phase)); } } diff --git a/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs index a3dbc45..2f23848 100644 --- a/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs +++ b/tests/OrthoStreamXP.Tests/SimBriefPrefetchTests.cs @@ -537,5 +537,10 @@ MountSettings Parse(params string[] extra) 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 1ca7fee..6815d24 100644 --- a/tests/OrthoStreamXP.Tests/StreamingTests.cs +++ b/tests/OrthoStreamXP.Tests/StreamingTests.cs @@ -872,7 +872,7 @@ public void A_departure_queued_again_leaves_load_aheads_waiting_request_alone() [Fact] public void Around_an_airport_the_order_is_load_aheads_nearest_first() { - // R6: PrefetchPlanner.Select without motion orders the departure and the arrival. + // 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())!;