feat: add STEP-113 Android PBF fast gate diagnostics
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-10-11 09:11:27 +09:00
parent c6d13e1059
commit 92efa36f4c
14 changed files with 1368 additions and 5 deletions

View File

@@ -63,6 +63,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var step097Topology: Step097StencilFixture.Topology? = null
private var step099Scenario: Step099ReadinessFixture.Scenario? = null
private var step101Scenario: String? = null
private var step113DiagnosticsHeight: Int? = null
private var step113DiagnosticsMaxLines = 6
private var step113Scene: Step113PbfGateScene? = null
private var step113Scenario: Step113PbfGateScene.Scenario? = null
private var step102Scenario: Step102PlacementFixture.Scenario? = null
private var step102Tick = 0L
private val step102Handler = Handler(Looper.getMainLooper())
@@ -347,6 +351,11 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
step093Variant = savedInstanceState?.getString(STATE_STEP093_VARIANT)
?.let { value -> Step093Variant.values().firstOrNull { it.name == value } }
?: Step093Variant.DEFAULT
val savedStep113 = if (savedInstanceState?.containsKey("step113_scenario") == true) {
savedInstanceState.getString("step113_scenario")
} else intent.getStringExtra("step113_scenario")
step113Scenario = savedStep113
?.let { value -> Step113PbfGateScene.Scenario.values().firstOrNull { it.name == value } }
super.onCreate(savedInstanceState)
Log.i(
TAG,
@@ -359,6 +368,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
status = findViewById(R.id.validationStatus)
diagnostics = findViewById(R.id.validationDiagnostics)
mapView.addOnDidFailLoadingMapListener { errorMessage ->
step113Scene?.onStyleLoadFailed(errorMessage)
if (navSeaPbfMode || purePbfMode) {
Log.e(TAG, "NAVSEA_PBF_STYLE_ERROR reason=$errorMessage")
} else if (realWeatherMode) {
@@ -379,7 +389,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
updateNavSeaPbfButtonState()
updatePbfModeButtonState()
updateRetainedCacheButtonState()
if (step102Scenario != null) loadStep102Style()
if (step113Scenario != null) startStep113Scene(step113Scenario!!)
else if (step102Scenario != null) loadStep102Style()
else if (step101Scenario != null) loadStep101Style()
else if (step097Topology != null) loadStep097StencilStyle()
else if (step093Mode) loadStep093Style(configureBeforeStyle = false)
@@ -688,6 +699,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
menu.add(0, 17, 17, "STEP-099 READINESS")
menu.add(0, 18, 18, "STEP-101 TEARDOWN")
menu.add(0, 19, 19, "STEP-102 PLACEMENT LOCALITY")
menu.add(0, 20, 20, "STEP-113 FZG1-PBF GATE")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step093Topology.values().indices -> applyStep093Scene(Step093Topology.values()[item.itemId])
@@ -701,6 +713,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
17 -> showStep099Menu(anchor)
18 -> showStep101Menu(anchor)
19 -> showStep102Menu(anchor)
20 -> showStep113Menu(anchor)
}
true
}
@@ -761,6 +774,68 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
}
private fun showStep113Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step113PbfGateScene.Scenario.values().forEachIndexed { index, scenario ->
menu.add(0, index, index, "STEP-113 ${scenario.name}")
}
menu.add(0, 10, 10, "EXPORT LAST SNAPSHOT")
menu.add(0, 11, 11, "MUTATE PROBE SOURCE ONCE")
menu.add(0, 12, 12, "EXIT STEP-113")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step113PbfGateScene.Scenario.values().indices ->
startStep113Scene(Step113PbfGateScene.Scenario.values()[item.itemId])
10 -> step113Scene?.export()
11 -> step113Scene?.mutateSource()
12 -> { stopStep113Scene(); loadValidationStyle(active, enabled, "STEP113_EXIT") }
}
true
}
show()
}
}
private fun startStep113Scene(scenario: Step113PbfGateScene.Scenario) {
stopStep113Scene()
stopStep102Scenario()
stopStep099Scenario()
stopStep093DiagnosticsPolling()
step101Handler.removeCallbacksAndMessages(null)
step101Scenario = null
step097Topology = null
step093Mode = false
pureParticleMode = false
purePbfMode = false
navSeaPbfMode = false
realWeatherMode = false
weatherRuntime?.dispose()
weatherRuntime = null
styleReady = false
++styleLoadEpoch // Cancel previous asynchronous style callbacks.
step113Scenario = scenario
retainedCacheEnabled = true
updateRetainedCacheButtonState()
step113DiagnosticsHeight = diagnostics.layoutParams.height
step113DiagnosticsMaxLines = diagnostics.maxLines
diagnostics.maxLines = 10
diagnostics.layoutParams = diagnostics.layoutParams.apply { height = (156 * resources.displayMetrics.density).toInt() }
status.text = "STEP-113 FZG1-PBF Gate · ${scenario.name} · PARTIAL"
step113Scene = Step113PbfGateScene(this, maplibreMap) { diagnostics.text = it }
.also { it.start(scenario) }
}
private fun stopStep113Scene() {
step113Scene?.stop()
step113DiagnosticsHeight?.let { originalHeight ->
diagnostics.maxLines = step113DiagnosticsMaxLines
diagnostics.layoutParams = diagnostics.layoutParams.apply { height = originalHeight }
}
step113DiagnosticsHeight = null
step113Scene = null
step113Scenario = null
}
private fun showStep102Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step102PlacementFixture.Scenario.values().forEachIndexed { index, scenario ->
@@ -778,6 +853,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadStep102Style() {
stopStep113Scene()
val scenario = step102Scenario ?: return
stopStep093DiagnosticsPolling()
step093Mode = false
@@ -810,6 +886,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadStep101Style() {
stopStep113Scene()
val scenario = step101Scenario ?: return
stopStep102Scenario()
stopStep099Scenario()
@@ -836,6 +913,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun applyStep099Scenario(scenario: Step099ReadinessFixture.Scenario) {
stopStep113Scene()
stopStep102Scenario()
stopStep093DiagnosticsPolling()
step093Mode = false
@@ -971,6 +1049,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadStep097StencilStyle() {
stopStep113Scene()
val topology = step097Topology ?: return
styleReady = false
val epoch = ++styleLoadEpoch
@@ -1124,6 +1203,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadStep093Style(configureBeforeStyle: Boolean) {
stopStep113Scene()
weatherRuntime?.dispose()
weatherRuntime = null
styleReady = false
@@ -1621,6 +1701,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
stopStep113Scene()
stopStep102Scenario()
if (step099Scenario != null) stopStep099Scenario()
step097V4GoldenReady = false
@@ -1692,6 +1773,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
)
private fun loadPureParticleStyle(active: Boolean, enabled: Boolean, reason: String) {
stopStep113Scene()
weatherRuntime?.dispose()
weatherRuntime = null
this.active = active
@@ -1721,6 +1803,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadPurePbfStyle(reason: String) {
stopStep113Scene()
weatherRuntime?.dispose()
weatherRuntime = null
styleReady = false
@@ -1752,6 +1835,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadNavSeaPbfStyle(active: Boolean, enabled: Boolean, reason: String) {
stopStep113Scene()
weatherRuntime?.dispose()
weatherRuntime = null
this.active = active
@@ -2397,8 +2481,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
} else if (step093Mode || step097Topology != null) startStep093DiagnosticsPolling()
}
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { mapView.onPause(); super.onPause() }
override fun onResume() { super.onResume(); mapView.onResume(); step113Scene?.resume() }
override fun onPause() { step113Scene?.pause(); mapView.onPause(); super.onPause() }
override fun onStop() {
step101Handler.removeCallbacksAndMessages(null)
step093Handler.removeCallbacks(step093DiagnosticsPoll)
@@ -2408,6 +2492,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
super.onStop()
}
override fun onDestroy() {
stopStep113Scene()
step102Handler.removeCallbacksAndMessages(null)
step101Handler.removeCallbacksAndMessages(null)
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_ENTER scenario=$step101Scenario completed=$step101Completed")
@@ -2422,6 +2507,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() }
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("step113_scenario", step113Scenario?.name)
outState.putBoolean(STATE_ACTIVE, active)
outState.putBoolean(STATE_ENABLED, enabled)
outState.putInt(STATE_MASK_MODE, maskMode)

View File

@@ -0,0 +1,394 @@
package org.maplibre.android.testapp.activity.particle
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Base64
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.maps.WeatherRuntime
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.testapp.BuildConfig
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point
import java.io.File
import java.security.MessageDigest
/** STEP-113: real remote chart, explicit partial parity, fixed forecast and low-frequency pull. */
internal class Step113PbfGateScene(
private val context: Context,
private val map: MapLibreMap,
private val display: (String) -> Unit
) {
enum class Scenario { PBF63, STATIC_RC, PARTICLE_OFF, PARTIAL_RCRR, SOURCE_CHANGE }
private val handler = Handler(Looper.getMainLooper())
private var generation = 0
private var running = false
private var paused = false
private var ready = false
private var failureReason: String? = null
private var runtime: WeatherRuntime? = null
private var scenario = Scenario.PBF63
private var parity = JSONObject()
private var latest: JSONObject? = null
private var lastLogMs = 0L
private var mutation = 0
private var exportSequence = 0L
private var commandResults = JSONObject()
private var savedGestures: List<Boolean>? = null
private val poll = object : Runnable {
override fun run() {
if (!running || paused) return
if (running) {
try {
// One shared snapshot query per refresh; all UI/log/export uses this copy.
val native = JSONObject(map.getRenderSegmentDiagnostics())
latest = envelope(native)
val trace = native.optJSONObject("fastGateTrace")
val frames = trace?.optJSONArray("frames")
val lastFrame = if (frames != null && frames.length() > 0) frames.optJSONObject(frames.length() - 1) else null
val firstFailure = lastFrame?.optString("firstFailure") ?: native.optString("fastPathFallbackReason")
val gates = trace?.optJSONArray("gates") ?: JSONArray()
val rank = (0 until gates.length()).map { gates.getJSONObject(it) }
.sortedByDescending { it.optLong("failures") }.take(4)
val segments = native.optJSONArray("segments") ?: JSONArray()
display(buildString {
append("STEP-113 $scenario · ${if (ready) "READY/PARTIAL" else failureReason ?: "LOADING"} · actual=${native.optInt("styleLayerCount")}\n")
append("first=$firstFailure seq=${lastFrame?.optLong("sequence")} full=${trace?.optLong("fullRendered")} ")
append("window=${trace?.optInt("windowCount")} mismatch=${trace?.optLong("decisionMismatch")}\n")
append(rank.joinToString(" / ") { "${it.optString("reason")}=${it.optLong("failures")}" })
for (i in 0 until segments.length()) {
val s = segments.getJSONObject(i)
val c = s.optJSONObject("cache")
append("\n${s.optString("strategy")}[${s.optInt("beginIndex")},${s.optInt("endExclusive")}) ")
append("${c?.optString("cacheState") ?: "live"} B=${c?.optLong("build") ?: 0} ")
append("R=${c?.optLong("reuse") ?: 0} I=${c?.optLong("invalidation") ?: 0}")
}
})
val now = SystemClock.elapsedRealtime()
if (now - lastLogMs >= 30000L) {
lastLogMs = now
logRecord("summary", summary(native))
}
} catch (error: Exception) {
display("STEP-113 诊断读取失败: ${error.message}")
Log.e(TAG, "diagnostic-read-failed", error)
}
}
handler.postDelayed(this, 3000L)
}
}
fun start(next: Scenario) {
stop()
running = true
scenario = next
ready = false
failureReason = null
latest = null
parity = JSONObject()
lastLogMs = 0L
mutation = 0
commandResults = JSONObject()
val epoch = ++generation
display("STEP-113 $scenario · 加载正式 PBF · 113层完整等价未验证")
val ui = map.uiSettings
savedGestures = listOf(ui.isScrollGesturesEnabled(), ui.isRotateGesturesEnabled(),
ui.isTiltGesturesEnabled(), ui.isZoomGesturesEnabled(),
ui.isDoubleTapGesturesEnabled(), ui.isQuickZoomGesturesEnabled())
ui.setAllGesturesEnabled(false)
map.cancelTransitions()
map.setRetainedUnderlayEnabled(true)
if (!paused) handler.post(poll)
map.setStyle(Style.Builder().fromUri(STYLE_URL)) production@ { loaded ->
if (!running || epoch != generation) return@production
try {
val root = JSONObject(loaded.json)
val baseLayers = root.getJSONArray("layers")
val base = JSONArray(baseLayers.toString())
check(base.length() == 63) { "正式基础层数已变化: ${base.length()},保留现场" }
val ids = (0 until base.length()).map { base.getJSONObject(it).getString("id") }
check(ids.distinct().size == ids.size && "clip-outline" in ids && "sea-area-fill" in ids &&
"special-pattern-line-754" in ids) { "正式 Style 边界 ID 缺失或重复" }
val source = root.getJSONObject("sources").getJSONObject("navsea_delivery")
check(root.optString("sprite") == SPRITE && root.optString("glyphs") == GLYPHS &&
source.optString("type") == "vector" && source.optJSONArray("tiles")?.optString(0) == TILES) {
"正式 Sprite/Glyph/VectorTile URI 与 NavSea URL 事实源不一致"
}
parity = JSONObject().put("evidence", "TEST-APP-STRUCTURAL")
.put("styleUrl", STYLE_URL).put("styleVersion", root.optInt("version"))
.put("styleVersionBasis", "Style specification version; 包版本未知")
.put("stylePackageVersionObserved", JSONObject.NULL)
.put("styleMetadata", root.opt("metadata") ?: JSONObject.NULL)
.put("styleName", root.optString("name"))
.put("fingerprintBasis", "SDK-returned-production-json-utf8")
.put("productionFingerprintSha256", sha256(loaded.json))
.put("baseLayerCount", base.length()).put("baseLayersPreserved", true)
.put("full113Equivalent", false).put("missingProductionDynamicLayerDefinitions", true)
.put("dynamicParity", "测试场结构模式;缺少同版本113层完整Source/Layer/visibility/数据快照")
.put("navSeaSourceHead", "ad9aa043fde37f87462f31de3fbd8ce008f8424b")
.put("navSeaSourceSelector", "RenderSegmentHost.tsx:RC@rn-weather-color")
.put("deviceReferenceSelector", "用户STEP-112:RCRR_PREFIX[0,13)/[13,33)/[33,107)/[107,113)")
.put("baseLayerManifest", layerManifest(base)).put("sprite", root.optString("sprite"))
.put("glyphs", root.optString("glyphs")).put("vectorSource", JSONObject(source.toString()))
val hasField = scenario != Scenario.PBF63
if (hasField) {
val sources = root.getJSONObject("sources")
check(!sources.has(SOURCE)) { "天气 Source ID 冲突" }
sources.put(SOURCE, JSONObject().put("type", "raster-array").put("host-field-submission", true))
val layers = JSONArray()
val prefix = scenario == Scenario.PARTIAL_RCRR
for (i in 0 until base.length()) {
layers.put(base.getJSONObject(i))
if (prefix && base.getJSONObject(i).getString("id") == "clip-outline") addFields(layers)
}
if (!prefix) addFields(layers) // NavSea current source fragment appends to top.
if (scenario == Scenario.SOURCE_CHANGE) {
sources.put(PROBE_SOURCE, JSONObject().put("type", "geojson")
.put("data", JSONObject().put("type", "FeatureCollection").put("features", JSONArray())))
layers.put(JSONObject().put("id", PROBE_LAYER).put("type", "circle")
.put("source", PROBE_SOURCE).put("paint", JSONObject()
.put("circle-radius", 6).put("circle-color", "#ff8800")))
}
root.put("layers", layers)
}
// Prove every original definition, including visibility, survives unchanged and in order.
val finalLayers = root.getJSONArray("layers")
val retainedBase = (0 until finalLayers.length()).map { finalLayers.getJSONObject(it) }
.filter { it.getString("id") in ids }
check(retainedBase.size == base.length() && retainedBase.map { it.getString("id") } == ids &&
retainedBase.indices.all { retainedBase[it].toString() == base.getJSONObject(it).toString() }) {
"基础图层等价检查失败"
}
parity.put("testStyleFingerprintSha256", sha256(root.toString()))
.put("testJsonLayerCount", finalLayers.length())
.put("baseParityBasis", "同次SDK生产Style回读;完整定义逐层比较,不是设备画面证明")
val plan = planJson(scenario, ids.last())
parity.put("testPlanSelector", if (scenario == Scenario.PARTIAL_RCRR)
"结构RCRR:sea-area-fill/special-pattern-line-754/最后生产层ID;非现场113边界" else
if (hasField) "当前NavSea源码RC:rn-weather-color" else "基础63层全RETAINED")
.put("requestedPlan", JSONObject(plan))
commandResults.put("planAccepted", map.setRenderSegmentPlan(plan))
map.setStyle(Style.Builder().fromJson(root.toString())) testStyle@ {
if (!running || epoch != generation) return@testStyle
try {
map.cameraPosition = CameraPosition.Builder().target(LatLng(33.45, 129.97))
.zoom(10.5).bearing(0.0).tilt(0.0).build()
if (hasField) initializeFields()
ready = true
lastLogMs = 0L
handler.removeCallbacksAndMessages(null)
if (!paused) handler.post(poll)
} catch (error: Exception) { fail(error) }
}
} catch (error: Exception) { fail(error) }
}
}
private fun addFields(layers: JSONArray) {
layers.put(JSONObject().put("id", COLOR).put("type", "weather-color").put("source", SOURCE)
.put("paint", JSONObject().put("weather-color-opacity", 0.62)))
layers.put(JSONObject().put("id", PARTICLE).put("type", "raster-particle").put("source", SOURCE)
.put("layout", JSONObject().put("visibility", if (scenario == Scenario.PARTICLE_OFF) "none" else "visible"))
.put("paint", JSONObject().put("raster-particle-active", true).put("raster-particle-particle-count", 1024)))
}
private fun initializeFields() {
val host = WeatherRuntime(map, WeatherRuntime.Config(SOURCE, COLOR, PARTICLE))
runtime = host
// Existing real-weather canonical assets. Explicitly not the current NavSea forecast/QBIN feed.
val results = JSONArray()
for (cycle in listOf("2026080700", "2026080706")) {
val path = "integration/particle-weatherv2/ecmwf-hres-wind-20260807/$cycle"
val meta = context.assets.open("$path/metadata.json").bufferedReader().use { it.readText() }
val data = context.assets.open("$path/field.f32").use { it.readBytes() }
val result = host.submitWindField(meta, data)
results.put(result.name)
check(result.accepted()) { "Field提交拒绝: $cycle $result" }
}
commandResults.put("fieldSubmit", results)
.put("forecastSeek", host.setForecastTime(FORECAST_MS).name)
.put("forecastPause", host.setPlaybackState(WeatherRuntime.PlaybackState.PAUSE).name)
.put("particleVisibility", host.setParticleVisible(scenario != Scenario.PARTICLE_OFF).name)
.put("targetFps", host.setTargetFps(WeatherRuntime.TargetFps.FPS_30).name)
check(host.state == WeatherRuntime.State.READY &&
commandResults.getString("forecastSeek") == "ACCEPTED" &&
commandResults.getString("forecastPause") == "ACCEPTED" &&
commandResults.getString("particleVisibility") == "ACCEPTED" &&
commandResults.getString("targetFps") == "ACCEPTED") { "固定预报/30fps命令未全部接受: $commandResults" }
}
fun mutateSource() {
if (!running || !ready || scenario != Scenario.SOURCE_CHANGE) return
val source = map.style?.getSourceAs<GeoJsonSource>(PROBE_SOURCE) ?: return
source.setGeoJson(Feature.fromGeometry(Point.fromLngLat(129.97 + (++mutation % 2) * 0.01, 33.45)))
// Single explicit mutation; polling never changes Source, Symbol, forecast or camera.
commandResults.put("sourceMutationCount", mutation)
logRecord("source-command", JSONObject().put("schemaVersion", 1).put("scenario", scenario.name)
.put("mutation", mutation).put("source", PROBE_SOURCE).put("evidence", "TEST-APP-STRUCTURAL"))
}
private fun envelope(native: JSONObject): JSONObject = JSONObject().put("schema", "step113-pbf-fast-gate")
.put("schemaVersion", 1).put("appVersion", BuildConfig.VERSION_NAME)
.put("scenario", scenario.name).put("sceneGeneration", generation)
.put("sceneReady", ready).put("blockedReason", failureReason ?: JSONObject.NULL)
.put("sampleElapsedMs", SystemClock.elapsedRealtime()).put("styleParity", parity)
.put("cameraRequested", JSONObject().put("latitude", 33.45).put("longitude", 129.97)
.put("zoom", 10.5).put("bearing", 0.0).put("pitch", 0.0).put("gesturesEnabled", false))
.put("commands", JSONObject(commandResults.toString()))
.put("forecastRequestedMs", if (runtime != null) FORECAST_MS else JSONObject.NULL)
.put("actualForecastTimeObservedMs", JSONObject.NULL)
.put("forecastStatus", runtime?.playbackState?.name ?: "NO_FIELD")
.put("fieldParity", "既有ECMWF canonical双帧测试数据;非NavSea当前业务预报")
.put("particleTick", JSONObject().put("targetFps", runtime?.targetFps?.name ?: "NONE")
.put("state", runtime?.state?.name ?: "NO_FIELD").put("particleVisible", scenario != Scenario.PARTICLE_OFF && runtime != null)
.put("simulationCounterScope", "process-debug-counter; 非本Map独占计数")
.put("processSimulationAdvance", lastPost(native)?.opt("processSimulationAdvance") ?: JSONObject.NULL))
.put("native", native)
private fun lastPost(native: JSONObject): JSONObject? {
val frames = native.optJSONObject("fastGateTrace")?.optJSONArray("frames") ?: return null
return if (frames.length() > 0) frames.optJSONObject(frames.length() - 1)?.optJSONObject("post") else null
}
private fun summary(native: JSONObject): JSONObject {
val t = native.optJSONObject("fastGateTrace") ?: JSONObject()
val gates = t.optJSONArray("gates") ?: JSONArray()
val counts = JSONArray()
for (i in 0 until gates.length()) {
val g = gates.getJSONObject(i)
counts.put(JSONArray().put(g.optString("reason")).put(g.optLong("failures")).put(g.optLong("firstFailures")))
}
val rs = JSONArray()
val segments = native.optJSONArray("segments") ?: JSONArray()
for (i in 0 until segments.length()) {
val s = segments.getJSONObject(i)
val c = s.optJSONObject("cache") ?: continue
rs.put(JSONObject().put("segment", s.optInt("segmentIndex")).put("begin", s.optInt("beginIndex"))
.put("end", s.optInt("endExclusive")).put("state", c.optString("cacheState"))
.put("build", c.optLong("build")).put("reuse", c.optLong("reuse"))
.put("invalidation", c.optLong("invalidation")).put("fallback", c.optLong("fallbackCount"))
.put("reason", c.optString("lastFallbackReason")))
}
return JSONObject().put("schema", "step113-summary").put("schemaVersion", 1).put("scenario", scenario.name)
.put("sceneReady", ready).put("blockedReason", failureReason ?: JSONObject.NULL)
.put("sceneGeneration", generation).put("full113Equivalent", false).put("basePreserved", parity.optBoolean("baseLayersPreserved"))
.put("fingerprint", parity.optString("productionFingerprintSha256")).put("styleUrl", STYLE_URL)
.put("actualLayers", native.optInt("styleLayerCount")).put("planValid", native.optBoolean("valid"))
.put("planReason", native.optString("lastReason")).put("lifecycle", t.optLong("lifecycle"))
.put("attempts", t.optLong("attempts")).put("windowCount", t.optInt("windowCount"))
.put("windowBegin", t.optLong("windowBeginSequence")).put("windowEnd", t.optLong("windowEndSequence"))
.put("windowFullRendered", t.optLong("windowFullRendered"))
.put("windowFastRendered", t.optLong("windowFastRendered"))
.put("processSimulationAdvance", lastPost(native)?.opt("processSimulationAdvance") ?: JSONObject.NULL)
.put("overwritten", t.optLong("overwritten")).put("decisionMismatch", t.optLong("decisionMismatch"))
.put("fullRendered", t.optLong("fullRendered")).put("fastRendered", t.optLong("fastRendered"))
.put("gateFailureCounts", counts).put("retained", rs)
}
fun export() {
// Export exactly the last low-frequency shared snapshot; do not request an extra render/query.
val record = latest ?: return
val file = File(context.filesDir, "step113-${scenario.name}-${SystemClock.elapsedRealtime()}.json")
try {
file.writeText(record.toString(), Charsets.UTF_8)
logRecord("snapshot", record)
display("STEP-113 已导出 ${file.name};Logcat标签 $TAG")
} catch (error: Exception) { Log.e(TAG, "export-failed", error) }
}
private fun logRecord(kind: String, record: JSONObject) {
// Logcat has a per-entry limit. UTF-8 -> Base64 ensures chunks never split Unicode.
val data = Base64.encodeToString(record.toString().toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val chunks = data.chunked(2400)
val id = "${SystemClock.elapsedRealtime()}-${generation}-${++exportSequence}"
chunks.forEachIndexed { index, chunk ->
Log.i(TAG, JSONObject().put("schemaVersion", 1).put("kind", kind).put("exportId", id)
.put("chunkIndex", index).put("chunkCount", chunks.size).put("encoding", "base64-utf8")
.put("payload", chunk).toString())
}
}
fun onStyleLoadFailed(message: String) {
if (running) fail(IllegalStateException(message))
}
private fun fail(error: Exception) {
ready = false
failureReason = "BLOCKED: ${error.message}"
display("STEP-113 BLOCKED: ${error.message}")
Log.e(TAG, "scene-blocked", error)
logRecord("blocked", JSONObject().put("schemaVersion", 1).put("scenario", scenario.name)
.put("reason", error.message).put("styleUrl", STYLE_URL).put("evidence", "NOT-VERIFIED"))
}
fun pause() { paused = true; handler.removeCallbacksAndMessages(null) }
fun resume() {
paused = false
handler.removeCallbacksAndMessages(null)
if (running) handler.post(poll)
}
fun stop() {
running = false
++generation
handler.removeCallbacksAndMessages(null)
runtime?.dispose()
runtime = null
savedGestures?.let { flags ->
val ui = map.uiSettings
ui.setScrollGesturesEnabled(flags[0])
ui.setRotateGesturesEnabled(flags[1])
ui.setTiltGesturesEnabled(flags[2])
ui.setZoomGesturesEnabled(flags[3])
ui.setDoubleTapGesturesEnabled(flags[4])
ui.setQuickZoomGesturesEnabled(flags[5])
}
savedGestures = null
ready = false
}
companion object {
const val TAG = "STEP113Gate"
const val STYLE_URL = "https://pbf.jpcn.ccwu.cc/style/style.navsea-delivery-full-semantic-extentfix.json"
private const val SPRITE = "https://pbf.jpcn.ccwu.cc/sprite/sprite"
private const val GLYPHS = "https://pbf.jpcn.ccwu.cc/fonts/{fontstack}/{range}.pbf"
private const val TILES = "https://pbf.jpcn.ccwu.cc/pbf/pbf-delivery-full-20260418-rebuild/{z}/{x}/{y}.pbf"
private const val SOURCE = "rn-weather-fields"
private const val COLOR = "rn-weather-color"
private const val PARTICLE = "rn-weather-particles"
private const val PROBE_SOURCE = "step113-source-probe"
private const val PROBE_LAYER = "step113-source-probe-circle"
private const val FORECAST_MS = 1786060800000L
private fun planJson(s: Scenario, finalBaseId: String): String {
val segments = JSONArray().put(JSONObject().put("strategy", "RETAINED"))
if (s != Scenario.PBF63) {
segments.put(JSONObject().put("strategy", "CONTINUOUS")
.put("startLayerId", if (s == Scenario.PARTIAL_RCRR) "sea-area-fill" else COLOR))
if (s == Scenario.PARTIAL_RCRR) {
segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", "special-pattern-line-754"))
segments.put(JSONObject().put("strategy", "RETAINED").put("startLayerId", finalBaseId))
}
}
return JSONObject().put("schemaVersion", 1).put("segments", segments).toString()
}
private fun layerManifest(layers: JSONArray): JSONArray = JSONArray().apply {
for (i in 0 until layers.length()) {
val l = layers.getJSONObject(i)
put(JSONObject().put("index", i).put("id", l.getString("id")).put("type", l.getString("type"))
.put("source", l.opt("source") ?: JSONObject.NULL)
.put("visibility", l.optJSONObject("layout")?.optString("visibility", "visible") ?: "visible"))
}
}
private fun sha256(text: String): String = MessageDigest.getInstance("SHA-256")
.digest(text.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}