From baf937431876ada43e638253418b4c8350260054 Mon Sep 17 00:00:00 2001 From: tei Date: Mon, 28 Sep 2026 21:37:16 +0900 Subject: [PATCH] fix: reset V17 weather runtime across Android and iOS --- .../style/sources/raster_array_source.hpp | 4 ++ .../weather/WeatherRuntimeBridgeModule.kt | 23 ++++-- .../ios/WeatherRuntimeBridge.mm | 11 ++- .../src/cpp/native_map_view.cpp | 11 +++ .../src/cpp/native_map_view.hpp | 1 + .../maplibre/android/maps/MapLibreMap.java | 5 ++ .../org/maplibre/android/maps/NativeMap.java | 1 + .../maplibre/android/maps/NativeMapView.java | 9 +++ .../maplibre/android/maps/WeatherRuntime.java | 28 +++++++- .../android/maps/WeatherRuntimeStateTest.java | 34 +++++++++ platform/ios/src/MLNParticleFieldRuntime.h | 7 +- platform/ios/src/MLNParticleFieldRuntime.mm | 71 +++++++++++++++++-- .../style/sources/raster_array_source.cpp | 31 ++++++++ test/source/raster_array_v4_host_api.test.cpp | 57 +++++++++++++++ 14 files changed, 276 insertions(+), 17 deletions(-) diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index cf8425a76..2c7643669 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -245,6 +245,10 @@ public: particle::ParticleProjectionResult resolveParticleProjection() const; bool clearEncodedField(const std::string& fieldKey); void clearAllEncodedFields(); + // Explicit terminal cleanup for an attached WeatherRuntime. Unlike an ordinary + // V4 window clear, this also drops bindings, forecast clock and V3 host ingress. + // Source and Style layers remain installed for the next runtime generation. + bool resetWeatherRuntimeState(); // Producer-facing timestamp seek. Core alone selects T0/T1 and MIX. bool seekRuntimeTimelineToTimestamp(uint64_t timestamp); // Low-frequency JNI commands for the Source-owned validation timeline. diff --git a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt index f0b40f392..fd70c7cc1 100644 --- a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt +++ b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt @@ -46,7 +46,11 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : ), ) } - val runtime = map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER)) + val runtime = try { + map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER)) + } catch (error: IllegalStateException) { + return@withMapView reject(promise, "RUNTIME_CLEANUP_FAILED", error.message ?: "WeatherRuntime replacement cleanup failed") + } if (!attachmentOwner(view).attach(map)) { map.disposeWeatherRuntime() return@withMapView reject(promise, "RUNTIME_DISPOSED", "The MapView attachment owner has been destroyed") @@ -57,11 +61,18 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : @ReactMethod override fun detach(reactTag: Double, promise: Promise) = withMapView(reactTag, promise) { view, map -> val owner = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*> - val hasRuntime = map.weatherRuntime != null - val detachedByOwner = (owner as? WeatherRuntimeAttachmentOwner)?.detach(map) ?: false - if (!detachedByOwner) map.disposeWeatherRuntime() - if (!hasRuntime) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map") - else promise.resolve(null) + // Duplicate detach is idempotent; Core failures must not be reported as success. + try { + val detachedByOwner = (owner as? WeatherRuntimeAttachmentOwner)?.detach(map) ?: false + if (!detachedByOwner) map.disposeWeatherRuntime() + if (map.weatherRuntime != null) { + reject(promise, "RUNTIME_CLEANUP_FAILED", "WeatherRuntime remains attached after detach") + } else { + promise.resolve(null) + } + } catch (error: IllegalStateException) { + reject(promise, "RUNTIME_CLEANUP_FAILED", error.message ?: "WeatherRuntime detach cleanup failed") + } } @ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) } diff --git a/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm index a3353fbfe..34612cf63 100644 --- a/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm +++ b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm @@ -155,7 +155,16 @@ RCT_EXPORT_MODULE(WeatherRuntimeBridge) }]; } - (void)detach:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { [map disposeParticleFieldRuntime]; resolve(nil); }]; + [self withMapForTag:reactTag requireRuntime:NO resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { + // Duplicate detach is a no-op, but an actual Source cleanup failure is not. + if (!runtime) { resolve(nil); return; } + NSError *error = nil; + if (![map disposeParticleFieldRuntimeWithError:&error]) { + reject(@"RUNTIME_CLEANUP_FAILED", error.localizedDescription ?: @"WeatherRuntime detach cleanup failed", error); + return; + } + resolve(nil); + }]; } - (void)getState:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { resolve(snapshot(runtime)); }]; } - (void)setRetainedUnderlayEnabled:(double)reactTag enabled:(BOOL)enabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index f043dcd73..fa6120c5f 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -1472,6 +1472,16 @@ jni::jboolean NativeMapView::clearRasterArraySourceHostFields(JNIEnv& env, const return cleared; } +jni::jboolean NativeMapView::resetRasterArraySourceWeatherRuntime(JNIEnv& env, const jni::String& sourceId) { + const auto id = jni::Make(env, sourceId); + auto* source = dynamic_cast(map->getStyle().getSource(id)); + if (!source) return false; + const bool reset = source->resetWeatherRuntimeState(); + mbgl::Log::Info(mbgl::Event::JNI, "WeatherRuntime: event=TERMINAL_RESET sourceId=" + id + + " result=" + (reset ? "SUCCESS" : "REJECTED")); + return reset; +} + jni::jboolean NativeMapView::seekRasterArraySourceTimelineToTimestamp(JNIEnv& env, const jni::String& sourceId, const jni::jlong timestamp) { @@ -1920,6 +1930,7 @@ void NativeMapView::registerNative(jni::JNIEnv& env) { METHOD(&NativeMapView::submitRasterArraySourceHostField, "nativeSubmitRasterArraySourceHostField"), METHOD(&NativeMapView::submitRasterArraySourceHostFieldV2, "nativeSubmitRasterArraySourceHostFieldV2"), METHOD(&NativeMapView::clearRasterArraySourceHostFields, "nativeClearRasterArraySourceHostFields"), + METHOD(&NativeMapView::resetRasterArraySourceWeatherRuntime, "nativeResetRasterArraySourceWeatherRuntime"), METHOD(&NativeMapView::seekRasterArraySourceTimelineToTimestamp, "nativeSeekRasterArraySourceTimelineToTimestamp"), METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"), METHOD(&NativeMapView::submitRasterArraySourceEncodedField, "nativeSubmitRasterArraySourceEncodedField"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index ccadc74ae..a623fcffc 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -318,6 +318,7 @@ public: jni::jint, jni::jint); jni::jboolean clearRasterArraySourceHostFields(JNIEnv&, const jni::String&); + jni::jboolean resetRasterArraySourceWeatherRuntime(JNIEnv&, const jni::String&); jni::jboolean seekRasterArraySourceTimelineToTimestamp(JNIEnv&, const jni::String&, jni::jlong); jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat); jni::jint submitRasterArraySourceEncodedField(JNIEnv&, const jni::String&, const jni::String&, const jni::String&, jni::jlong, jni::jlong, const jni::String&, const jni::Array&); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java index c28440064..4b905410a 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java @@ -491,6 +491,11 @@ public final class MapLibreMap { return nativeMapView.clearRasterArraySourceHostFields(sourceId); } + /** Internal terminal reset for V3+V4 state on the same existing Style source. */ + boolean resetRasterArraySourceWeatherRuntime(String sourceId) { + return nativeMapView.resetRasterArraySourceWeatherRuntime(sourceId); + } + /** Requests a Core-owned T0/T1/MIX selection for a producer timestamp in milliseconds. */ public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) { return nativeMapView.seekRasterArraySourceTimelineToTimestamp(sourceId, timestamp); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index e2b5226d5..94da5d60e 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -172,6 +172,7 @@ interface NativeMap { boolean vectorPresent, int vectorLength, int validityLength); boolean clearRasterArraySourceHostFields(String sourceId); + boolean resetRasterArraySourceWeatherRuntime(String sourceId); boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index 421c2da41..935df068d 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -1070,6 +1070,12 @@ final class NativeMapView implements NativeMap { && nativeClearRasterArraySourceHostFields(sourceId); } + @Override + public boolean resetRasterArraySourceWeatherRuntime(String sourceId) { + return !checkState("resetRasterArraySourceWeatherRuntime") + && nativeResetRasterArraySourceWeatherRuntime(sourceId); + } + @Override public boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp) { return !checkState("seekRasterArraySourceTimelineToTimestamp") @@ -1876,6 +1882,9 @@ final class NativeMapView implements NativeMap { @Keep private native boolean nativeClearRasterArraySourceHostFields(String sourceId); + @Keep + private native boolean nativeResetRasterArraySourceWeatherRuntime(String sourceId); + @Keep private native boolean nativeSeekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java index 979fc7f95..c65c34ac5 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java @@ -81,7 +81,25 @@ public final class WeatherRuntime { public Config getConfig() { return config; } public boolean isDisposed() { return disposed; } /** Ends this map/style-scoped facade. It is safe to call repeatedly. */ - public void dispose() { if (disposed) return; disposed = true; cancelCadenceWake(); } + public void dispose() { + if (disposed) return; + cancelCadenceWake(); + // If Style has already dropped the source, there is no Source-owned V4 store + // left to reset; otherwise disposal must not claim success before Core reset. + if (map.hasStyleSource(config.sourceId)) { + map.setStyleLayerVisibility(config.weatherColorLayerId, false); + map.setStyleLayerVisibility(config.particleLayerId, false); + if (!map.resetRasterArraySourceWeatherRuntime(config.sourceId)) { + throw new IllegalStateException("WeatherRuntime terminal Core reset failed"); + } + } + submittedEncodedFieldKeys.clear(); + v4ParticleBindingFieldKey = null; + v4ForecastPlaying = false; + playbackState = PlaybackState.PAUSE; + state = State.DISABLED; + disposed = true; + } /** Core-owned capacity; Host never sees the shader's UBO layout. */ public int getWeatherColorStopCapacity() { return weatherColorStopCapacity; } @@ -154,8 +172,14 @@ public final class WeatherRuntime { map.controlRasterArraySourceTimeline(config.sourceId, 1, 0f); boolean color = map.setStyleLayerVisibility(config.weatherColorLayerId, false); boolean particle = map.setStyleLayerVisibility(config.particleLayerId, false); - boolean clear = map.clearRasterArraySourceHostFields(config.sourceId); + boolean clear = map.resetRasterArraySourceWeatherRuntime(config.sourceId); state = stateAfterDisable(color && particle && clear); + if (clear) { + submittedEncodedFieldKeys.clear(); + v4ParticleBindingFieldKey = null; + v4ForecastPlaying = false; + playbackState = PlaybackState.PAUSE; + } if (state != State.READY) cancelCadenceWake(); return result(color && particle && clear); } diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeStateTest.java b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeStateTest.java index 9655c521e..b8cf282da 100644 --- a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeStateTest.java +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/WeatherRuntimeStateTest.java @@ -103,6 +103,40 @@ public class WeatherRuntimeStateTest { Mockito.verify(map, Mockito.never()).submitRasterArraySourceHostField(Mockito.anyString(), Mockito.anyString(), Mockito.any(byte[].class)); } + @Test public void disableResetsV3AndV4ViaOneCoreCommandAndCanEnableAgain() { + MapLibreMap map = Mockito.mock(MapLibreMap.class); + Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8); + Mockito.when(map.setStyleLayerVisibility(Mockito.anyString(), Mockito.anyBoolean())).thenReturn(true); + Mockito.when(map.resetRasterArraySourceWeatherRuntime("source")).thenReturn(true); + WeatherRuntime runtime = new WeatherRuntime(map, new WeatherRuntime.Config("source", "color", "particle")); + assertEquals(WeatherRuntime.CommandResult.ACCEPTED, runtime.setWeatherEnabled(false)); + assertEquals(WeatherRuntime.State.DISABLED, runtime.getState()); + Mockito.verify(map).resetRasterArraySourceWeatherRuntime("source"); + Mockito.verify(map, Mockito.never()).clearRasterArraySourceHostFields(Mockito.anyString()); + assertEquals(WeatherRuntime.CommandResult.ACCEPTED, runtime.setWeatherEnabled(true)); + assertEquals(WeatherRuntime.State.WAITING_FOR_FIELDS, runtime.getState()); + } + + @Test public void terminalDisposeResetsSourceAndFailureDoesNotClaimDisposed() { + MapLibreMap map = Mockito.mock(MapLibreMap.class); + Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8); + Mockito.when(map.hasStyleSource("source")).thenReturn(true); + Mockito.when(map.resetRasterArraySourceWeatherRuntime("source")).thenReturn(false).thenReturn(true); + WeatherRuntime runtime = new WeatherRuntime(map, new WeatherRuntime.Config("source", "color", "particle")); + try { + runtime.dispose(); + org.junit.Assert.fail("Core reset failure must be reported"); + } catch (IllegalStateException expected) { + assertEquals(false, runtime.isDisposed()); + } + runtime.dispose(); + assertEquals(true, runtime.isDisposed()); + Mockito.verify(map, Mockito.times(2)).resetRasterArraySourceWeatherRuntime("source"); + Mockito.clearInvocations(map); + runtime.dispose(); + Mockito.verifyNoInteractions(map); + } + @Test public void disposeIsIdempotentAndRejectsFurtherControls() { MapLibreMap map = Mockito.mock(MapLibreMap.class); Mockito.when(map.getWeatherColorStopCapacity()).thenReturn(8); diff --git a/platform/ios/src/MLNParticleFieldRuntime.h b/platform/ios/src/MLNParticleFieldRuntime.h index ee845126e..775147c4d 100644 --- a/platform/ios/src/MLNParticleFieldRuntime.h +++ b/platform/ios/src/MLNParticleFieldRuntime.h @@ -131,7 +131,8 @@ MLN_EXPORT @property (nonatomic, readonly) MLNParticleFieldRuntimeTargetFps targetFps; - (instancetype)init NS_UNAVAILABLE; -- (void)dispose; +- (void)dispose; // Existing API; use the error-returning form for Bridge operations. +- (BOOL)disposeWithError:(NSError * _Nullable * _Nullable)error; - (MLNParticleHostFieldSubmitResult)submitWindFieldWithMetadataJSON:(NSString *)metadataJSON payload:(NSData *)payload error:(NSError * _Nullable * _Nullable)error; - (MLNParticleHostFieldSubmitResult)submitFieldWithMetadataJSON:(NSString *)metadataJSON scalar:(nullable NSData *)scalar vector:(nullable NSData *)vector validity:(NSData *)validity error:(NSError * _Nullable * _Nullable)error; @@ -196,7 +197,11 @@ MLN_EXPORT - (nullable MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration error:(NSError * _Nullable * _Nullable)error; - (nullable MLNParticleFieldRuntime *)particleFieldRuntime; +// Legacy public selector retained for existing Native consumers. - (void)disposeParticleFieldRuntime; +// Completes V3+V4 Source cleanup before detaching; a failure leaves the runtime +// associated so the caller may retry and must not be reported as success. +- (BOOL)disposeParticleFieldRuntimeWithError:(NSError * _Nullable * _Nullable)error; @end diff --git a/platform/ios/src/MLNParticleFieldRuntime.mm b/platform/ios/src/MLNParticleFieldRuntime.mm index 92f752c81..52e024af8 100644 --- a/platform/ios/src/MLNParticleFieldRuntime.mm +++ b/platform/ios/src/MLNParticleFieldRuntime.mm @@ -138,11 +138,36 @@ std::vector charsFromData(NSData *data) { - (MLNParticleFieldRuntimePlaybackState)playbackState { return _playbackState; } - (MLNParticleFieldRuntimeTargetFps)targetFps { return _targetFps; } -- (void)dispose { +- (BOOL)disposeWithError:(NSError **)error { + if (_disposed) return YES; + // A terminal dispose must clear the Source while the map and Style are valid. + // Otherwise the Source outlives this ObjC runtime and retains V4 field windows. [_v4CadenceTimer invalidate]; _v4CadenceTimer = nil; + _v4ForecastPlaying = NO; + const BOOL completed = [self withCurrentSource:error action:^bool( + mbgl::style::RasterArraySource &source, + mbgl::style::WeatherColorLayer &weather, + mbgl::style::RasterParticleLayer &particle) { + weather.setVisibility(mbgl::style::VisibilityType::None); + particle.setVisibility(mbgl::style::VisibilityType::None); + return source.resetWeatherRuntimeState(); + }]; + if (!completed) return NO; + [_submittedEncodedFieldKeys removeAllObjects]; + _v4ParticleBindingFieldKey = nil; + _playbackState = MLNParticleFieldRuntimePlaybackStatePause; + _state = MLNParticleFieldRuntimeStateDisabled; _disposed = YES; _mapView = nil; + return YES; +} + +- (void)dispose { + NSError *error = nil; + if (![self disposeWithError:&error]) { + NSLog(@"WeatherRuntime: legacy dispose cleanup failed: %@", error.localizedDescription ?: @"unknown"); + } } - (void)refreshV4CadenceWake { @@ -355,11 +380,34 @@ std::vector charsFromData(NSData *data) { if (_disposed) return MLNParticleFieldRuntimeCommandResultRejected; if (enabled && _state == MLNParticleFieldRuntimeStateError) return MLNParticleFieldRuntimeCommandResultRejected; if (enabled && _state != MLNParticleFieldRuntimeStateDisabled) return MLNParticleFieldRuntimeCommandResultAccepted; - __block bool accepted = false; BOOL completed = [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source, mbgl::style::WeatherColorLayer &weather, mbgl::style::RasterParticleLayer &particle) { - if (!enabled) { source.controlRuntimeTimeline(1); weather.setVisibility(mbgl::style::VisibilityType::None); particle.setVisibility(mbgl::style::VisibilityType::None); accepted = source.clearHostFields(); } - else { weather.setVisibility(_weatherColorVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); particle.setVisibility(_particleVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); accepted = true; } return true; + if (!enabled && _state == MLNParticleFieldRuntimeStateDisabled) return MLNParticleFieldRuntimeCommandResultAccepted; + __block bool accepted = false; + BOOL completed = [self withCurrentSource:error action:^bool( + mbgl::style::RasterArraySource &source, + mbgl::style::WeatherColorLayer &weather, + mbgl::style::RasterParticleLayer &particle) { + if (!enabled) { + weather.setVisibility(mbgl::style::VisibilityType::None); + particle.setVisibility(mbgl::style::VisibilityType::None); + accepted = source.resetWeatherRuntimeState(); + } else { + weather.setVisibility(_weatherColorVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); + particle.setVisibility(_particleVisible ? mbgl::style::VisibilityType::Visible : mbgl::style::VisibilityType::None); + accepted = true; + } + return accepted; }]; - if (completed && accepted) _state = enabled ? MLNParticleFieldRuntimeStateWaitingForFields : MLNParticleFieldRuntimeStateDisabled; else if (completed) _state = MLNParticleFieldRuntimeStateError; + if (completed && accepted) { + _state = enabled ? MLNParticleFieldRuntimeStateWaitingForFields : MLNParticleFieldRuntimeStateDisabled; + if (!enabled) { + [_submittedEncodedFieldKeys removeAllObjects]; + _v4ParticleBindingFieldKey = nil; + _v4ForecastPlaying = NO; + _playbackState = MLNParticleFieldRuntimePlaybackStatePause; + } + } else if (completed) { + _state = MLNParticleFieldRuntimeStateError; + } [self refreshV4CadenceWake]; return commandResult(completed && accepted); } @@ -400,7 +448,7 @@ namespace { const void *ParticleFieldRuntimeAssociationKey = &ParticleFieldRunti @implementation MLNMapView (ParticleFieldRuntime) - (MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration error:(NSError **)error { - [self disposeParticleFieldRuntime]; + if (![self disposeParticleFieldRuntimeWithError:error]) return nil; if (!configuration) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"A particle field runtime configuration is required."); return nil; } MLNParticleFieldRuntime *runtime = [[MLNParticleFieldRuntime alloc] initWithMapView:self configuration:configuration error:error]; if (!runtime) return nil; @@ -413,9 +461,18 @@ namespace { const void *ParticleFieldRuntimeAssociationKey = &ParticleFieldRunti } - (void)disposeParticleFieldRuntime { + NSError *error = nil; + if (![self disposeParticleFieldRuntimeWithError:&error]) { + NSLog(@"WeatherRuntime: legacy map dispose cleanup failed: %@", error.localizedDescription ?: @"unknown"); + } +} + +- (BOOL)disposeParticleFieldRuntimeWithError:(NSError **)error { MLNParticleFieldRuntime *runtime = self.particleFieldRuntime; - [runtime dispose]; + if (!runtime) return YES; + if (![runtime disposeWithError:error]) return NO; objc_setAssociatedObject(self, ParticleFieldRuntimeAssociationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return YES; } @end diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index 6287c593a..19e7fca29 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -535,6 +535,37 @@ void RasterArraySource::clearAllEncodedFields() { refreshV4RendererState(); } +bool RasterArraySource::resetWeatherRuntimeState() { + // V3 Host ingress and V4 QBIN can coexist on the same raster-array Source. + // Clearing the V3 window alone would leave the independent V4 store/projection. + // Pause first so Source notifications during cleanup cannot advance the clock. + forecastClock.pause(); + lastForecastTick = {}; + if (impl().hostFieldSubmission && !clearHostFields()) return false; + + const auto oldFieldCount = encodedFieldStore.fieldCount(); + const auto oldDecodedBytes = encodedFieldStore.decodedByteSize(); + const auto hadColorProjection = static_cast(impl().v4RendererState.color); + const auto hadParticleProjection = static_cast(impl().v4RendererState.particle); + encodedFieldStore.clearAll(); + colorBinding.reset(); + particleBinding.reset(); + forecastClock = particle::ForecastClock{}; + lastForecastTick = {}; + refreshV4RendererState(); + Log::Info(Event::Render, + "WeatherRuntime: event=V4_RUNTIME_RESET sourceId=" + getID() + + " oldFieldCount=" + std::to_string(oldFieldCount) + + " oldDecodedBytes=" + std::to_string(oldDecodedBytes) + + " oldColorProjection=" + std::to_string(hadColorProjection) + + " oldParticleProjection=" + std::to_string(hadParticleProjection) + + " remainingFieldCount=" + std::to_string(encodedFieldStore.fieldCount()) + + " remainingDecodedBytes=" + std::to_string(encodedFieldStore.decodedByteSize()) + + " remainingColorProjection=" + std::to_string(static_cast(impl().v4RendererState.color)) + + " remainingParticleProjection=" + std::to_string(static_cast(impl().v4RendererState.particle))); + return true; +} + void RasterArraySource::refreshV4RendererState() { Impl::V4RendererState next; const auto& current = impl().v4RendererState; diff --git a/test/source/raster_array_v4_host_api.test.cpp b/test/source/raster_array_v4_host_api.test.cpp index 84cfafef9..1024a5074 100644 --- a/test/source/raster_array_v4_host_api.test.cpp +++ b/test/source/raster_array_v4_host_api.test.cpp @@ -912,3 +912,60 @@ TEST(Source, RasterArrayV4MaterializerDecodesDirectionToUInt16LittleEndian) { EXPECT_NEAR(materialized->t0->vector[7], 0.0f, 1e-5f); EXPECT_EQ(materialized->t0->vectorSemantics, particle::VectorSemantics::Direction); } + + +// The RN weather source uses Host V3 ingress and V4 QBIN on one Style source. +// A terminal detach must clear both, drop old bindings, and permit a new owner. +TEST(Source, RasterArrayV4TerminalResetClearsBothIngressesAndAllowsNewGeneration) { + style::RasterArraySource source{"v4-detach-reset"}; + auto v3T0 = particle::OfflineFixtureParticleAdapter::load("uniform-east", 71); + auto v3T1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 72); + v3T1.validTime = v3T0.validTime + Hour; + ASSERT_EQ(source.submitInitialRuntimeFieldWindow(v3T0, v3T1), + style::RasterArrayRuntimeFieldUpdateResult::Applied); + ASSERT_TRUE(source.impl().field); + ASSERT_TRUE(source.impl().nextField); + + submit(source, "wind", "wind", Base, 1); + submit(source, "wind", "wind", Base + Hour, 2); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + ASSERT_TRUE(source.impl().v4RendererState.color); + ASSERT_TRUE(source.impl().v4RendererState.particle); + ASSERT_EQ(source.impl().v4RendererState.color->sampleState.mix, 0.5f); + EXPECT_EQ(source.impl().v4RendererState.particle->sampleState.mix, 0.5f); + source.setForecastPlaybackState(particle::ForecastPlaybackState::Playing); + ASSERT_EQ(source.impl().hostFieldSubmission, true); + + ASSERT_TRUE(source.resetWeatherRuntimeState()); + EXPECT_FALSE(source.impl().field); + EXPECT_FALSE(source.impl().nextField); + EXPECT_FALSE(source.impl().timeline); + EXPECT_EQ(source.impl().v4RendererState.color, nullptr); + EXPECT_EQ(source.impl().v4RendererState.particle, nullptr); + EXPECT_FALSE(source.impl().v4RendererState.colorInput); + EXPECT_FALSE(source.impl().v4RendererState.particleInput); + EXPECT_EQ(source.resolveColorProjection().error, particle::BindingProjectionError::InvalidBinding); + EXPECT_EQ(source.resolveParticleProjection().error, particle::BindingProjectionError::InvalidBinding); + EXPECT_EQ(source.impl().v4RendererState.color, nullptr); + EXPECT_EQ(source.impl().v4RendererState.particle, nullptr); + EXPECT_FALSE(source.tickForecastClock(std::chrono::steady_clock::now())); + EXPECT_TRUE(source.resetWeatherRuntimeState()); // Idempotent. + + submit(source, "wind", "wind", Base, 3); + submit(source, "wind", "wind", Base + Hour, 4); + source.setColorBinding({"wind", {1, 1, particle::FieldComponent::UEast}, + particle::FieldValueClass::VelocityComponent, 1}); + source.setParticleBinding({particle::ParticleBindingMode::VectorComponents, + "wind", {1, 1, particle::FieldComponent::UEast}, + {2, 1, particle::FieldComponent::VNorth}, {}, 1}); + ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2)); + EXPECT_TRUE(source.resolveColorProjection().accepted()); + EXPECT_TRUE(source.resolveParticleProjection().accepted()); + EXPECT_TRUE(source.impl().v4RendererState.color); + EXPECT_TRUE(source.impl().v4RendererState.particle); +}