From 2b20a0f2b703f972193151dd0a1d7354845198ed Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 6 Oct 2026 00:10:35 -0400 Subject: [PATCH] Inflate map sightline data with the browser's gzip on the web Opening a map on the web froze the page for 0.6 to 1.7 s. Most of that was the pure-Dart gzip inflate and CRC-32 from package:archive, which on the web run on the UI thread (compute does not leave it there): about 0.4 s per side on Bind. inflateWorldGzip now hands the bytes to the browser's DecompressionStream, which inflates Bind's 19 MB side in about 50 ms. Desktop is unchanged: it still calls decodeWorldGzip (native zlib) inside its background isolate. Browsers without DecompressionStream (Firefox before 113, Safari before 16.4) also keep decodeWorldGzip. The integrity contract holds. The browser rejects a wrong CRC-32 or length, a stream cut before its footer, and bytes after it; the header and the footer length are still checked here, and any failure is the same FormatException as before. Navigation charts read through the same function, so they get the same speedup. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../navigation_geometry_provider.dart | 6 +-- .../svg_height_runtime_provider.dart | 4 +- lib/view_cone/vision_world_gzip.dart | 23 +++++++-- lib/view_cone/vision_world_gzip_io.dart | 7 +++ lib/view_cone/vision_world_gzip_web.dart | 47 +++++++++++++++++++ 5 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 lib/view_cone/vision_world_gzip_io.dart create mode 100644 lib/view_cone/vision_world_gzip_web.dart diff --git a/lib/providers/navigation_geometry_provider.dart b/lib/providers/navigation_geometry_provider.dart index 1d469d15..d1f50ca7 100644 --- a/lib/providers/navigation_geometry_provider.dart +++ b/lib/providers/navigation_geometry_provider.dart @@ -56,13 +56,13 @@ Future loadNavigationGeometry(MapValue map, )); } -NavigationGeometryMap _decodeNavigation( +Future _decodeNavigation( ({ MapValue map, Uint8List bytes, Offset defenseOffset, - }) source) { - final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source.bytes))); + }) source) async { + final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source.bytes))); if (decoded is! Map || decoded['map'] != source.map.name) { throw const FormatException('Navigation geometry map mismatch.'); } diff --git a/lib/providers/svg_height_runtime_provider.dart b/lib/providers/svg_height_runtime_provider.dart index fd05a21f..77c3461e 100644 --- a/lib/providers/svg_height_runtime_provider.dart +++ b/lib/providers/svg_height_runtime_provider.dart @@ -258,8 +258,8 @@ Future _loadSide(Uint8List source, List artworkBytes, return model; } -Map _decodeModel(Uint8List source) { - final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source))); +Future> _decodeModel(Uint8List source) async { + final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source))); if (decoded is! Map) { throw const FormatException('SVG sightline asset must be an object.'); } diff --git a/lib/view_cone/vision_world_gzip.dart b/lib/view_cone/vision_world_gzip.dart index 863c5809..a2dead26 100644 --- a/lib/view_cone/vision_world_gzip.dart +++ b/lib/view_cone/vision_world_gzip.dart @@ -2,14 +2,13 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; +import 'vision_world_gzip_web.dart' + if (dart.library.io) 'vision_world_gzip_io.dart' as platform; + /// archive uses native zlib on desktop and its Dart decoder on web. The SDK /// accepts partial streams, so untrusted blocks also need their complete footer. Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) { - if (compressed.length < 18 || - compressed[0] != 0x1f || - compressed[1] != 0x8b) { - throw const FormatException('Invalid world gzip header.'); - } + checkWorldGzipHeader(compressed); final decoded = const GZipDecoder().decodeBytes(compressed, verify: true); final footer = ByteData.sublistView(compressed, compressed.length - 8); if (footer.getUint32(4, Endian.little) != decoded.length || @@ -19,3 +18,17 @@ Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) { } return decoded; } + +/// [decodeWorldGzip] with the same checks. On the web the browser's own gzip +/// inflates and verifies the data: there the Dart decoder and CRC run on the +/// UI thread and take about 0.4 s per side of a large map. +Future inflateWorldGzip(Uint8List compressed) => + platform.inflateWorldGzip(compressed); + +void checkWorldGzipHeader(Uint8List compressed) { + if (compressed.length < 18 || + compressed[0] != 0x1f || + compressed[1] != 0x8b) { + throw const FormatException('Invalid world gzip header.'); + } +} diff --git a/lib/view_cone/vision_world_gzip_io.dart b/lib/view_cone/vision_world_gzip_io.dart new file mode 100644 index 00000000..fdec57f6 --- /dev/null +++ b/lib/view_cone/vision_world_gzip_io.dart @@ -0,0 +1,7 @@ +import 'dart:typed_data'; + +import 'vision_world_gzip.dart'; + +/// Desktop's zlib is native and fast; it runs in a background isolate. +Future inflateWorldGzip(Uint8List compressed) async => + decodeWorldGzip(compressed); diff --git a/lib/view_cone/vision_world_gzip_web.dart b/lib/view_cone/vision_world_gzip_web.dart new file mode 100644 index 00000000..62ebc8aa --- /dev/null +++ b/lib/view_cone/vision_world_gzip_web.dart @@ -0,0 +1,47 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'vision_world_gzip.dart'; + +@JS('DecompressionStream') +extension type _DecompressionStream._(JSObject _) implements JSObject { + external factory _DecompressionStream(String format); +} + +extension type _ReadableStream._(JSObject _) implements JSObject { + external _ReadableStream pipeThrough(JSObject transform); +} + +@JS('Response') +extension type _Response._(JSObject _) implements JSObject { + external factory _Response(JSAny body); + external _ReadableStream get body; + external JSPromise arrayBuffer(); +} + +/// The browser's gzip checks what [decodeWorldGzip] checks: it rejects a +/// wrong CRC-32 or length, a stream that ends before its footer, and bytes +/// after it. The length is compared again here so a short read cannot pass. +Future inflateWorldGzip(Uint8List compressed) async { + // Firefox before 113 and Safari before 16.4 lack it; they keep the slow path. + if (!globalContext.has('DecompressionStream')) { + return decodeWorldGzip(compressed); + } + checkWorldGzipHeader(compressed); + final Uint8List decoded; + try { + final inflated = _Response(compressed.toJS) + .body + .pipeThrough(_DecompressionStream('gzip')); + decoded = + (await _Response(inflated).arrayBuffer().toDart).toDart.asUint8List(); + } on Object { + throw const FormatException('Invalid world gzip checksum or length.'); + } + final footer = ByteData.sublistView(compressed, compressed.length - 8); + if (footer.getUint32(4, Endian.little) != decoded.length) { + throw const FormatException('Invalid world gzip checksum or length.'); + } + return decoded; +}